Compare commits
135 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 002d08ba5f | |||
| a7ac30f39b | |||
| cd7cf2c4c6 | |||
| 8d8b6b9bcb | |||
| 02012f105a | |||
| 09c91172ae | |||
| 8a0717c839 | |||
| f3c4169752 | |||
| eedad4b81e | |||
| 4e39ddd5d0 | |||
| c99cb048bf | |||
| 8c4b458724 | |||
| 792560191c | |||
| c884ce88ba | |||
| 4bcd942928 | |||
| bf394e0e4b | |||
| ec94001ab8 | |||
| 505cc8c3f6 | |||
| 9fc30d3ab9 | |||
| 887664f3c0 | |||
| ae712eae64 | |||
| a3df8f6ef5 | |||
| 1006079654 | |||
| 6721a4142a | |||
| 7bf938c19f | |||
| 5932250314 | |||
| b2068e9ef2 | |||
| 2ca56dbaf4 | |||
| 9785cda39d | |||
| 0cea99cd5e | |||
| 71bc754feb | |||
| cabbfed730 | |||
| 8b6ef7778a | |||
| 06319655b0 | |||
| f636169783 | |||
| 0f8f38ff52 | |||
| 142998afb3 | |||
| 665d7d2bb6 | |||
| 1746d47412 | |||
| 9bb2f7bcae | |||
| 1f2720463b | |||
| 8bb530e9b4 | |||
| 72df965a9c | |||
| f688325170 | |||
| c711213dcc | |||
| 11ecdccd65 | |||
| 27ab84d198 | |||
| 28e4da3879 | |||
| 31c0a83e51 | |||
| 34fddc3bf5 | |||
| 13c3dac554 | |||
| f7ed053e8e | |||
| 2e8feb56dd | |||
| 793178b345 | |||
| ee0359efd5 | |||
| 1dc57a85c2 | |||
| e7e8960fec | |||
| 79e278064d | |||
| 474b894d1c | |||
| 0c76b74afe | |||
| b608ed2c50 | |||
| f9d4e6bc74 | |||
| 223f11b989 | |||
| 5095be0b0d | |||
| 2aa64cf0a4 | |||
| 90c266d68a | |||
| 8410b2f4ef | |||
| ba69a5ad20 | |||
| 07b9b20eb5 | |||
| 901a0515b7 | |||
| 50c01a19c9 | |||
| 3a8fa87acb | |||
| df1dbbb8fb | |||
| 531ca851c4 | |||
| b3fd74529d | |||
| 00b376d234 | |||
| fa720d0054 | |||
| 91438e8214 | |||
| 08e9a34183 | |||
| 85937b8cbb | |||
| 107986bf85 | |||
| a8cabbebd5 | |||
| 636a0c5f43 | |||
| bf46782db3 | |||
| 02919e959e | |||
| d231eba8be | |||
| 637382f364 | |||
| bf6ef5b426 | |||
| 822e8fcd86 | |||
| 63e1a866a3 | |||
| 22c88c449d | |||
| ed6c0ac10c | |||
| 355c5d1e9a | |||
| 61d8b1dee2 | |||
| 0db32a865c | |||
| a4061ee1e1 | |||
| ec64be340e | |||
| f1c383c392 | |||
| 984c327162 | |||
| e2689d1b04 | |||
| b5c84f3df8 | |||
| 36c683df9e | |||
| 510adcb49b | |||
| ab87fd23cd | |||
| 1a352aa8d0 | |||
| 919b4ed46d | |||
| f7c57f21c5 | |||
| d4f4237723 | |||
| ba574f3052 | |||
| 0683b70abe | |||
| eaf6fae94c | |||
| e19a202565 | |||
| 8309d829cc | |||
| aabe8dedb2 | |||
| d0da907c00 | |||
| 6b434750c5 | |||
| c9047af626 | |||
| 52d692e8ec | |||
| 5c3c8b3fef | |||
| b0079fc3ee | |||
| ecfa123a79 | |||
| 9da372e4a2 | |||
| 9a998e7b52 | |||
| c3c9a80310 | |||
| 116eabba7d | |||
| e6c2649410 | |||
| e85d6f6963 | |||
| eb39aaa904 | |||
| bb7ee3cc3a | |||
| 84647c12ea | |||
| 156d237460 | |||
| 7d0710a1e6 | |||
| 5c797827f7 | |||
| b87b7f4505 | |||
| 92f08ca5a8 |
10
.env.example
10
.env.example
@@ -1,5 +1,5 @@
|
||||
# file: .env.example
|
||||
# version: 13
|
||||
# version: 14
|
||||
|
||||
# KSP Logging root directory. Used by config/std.logging.json for relative log output paths.
|
||||
# The current Config document fallback is "logs" when neither the process environment nor .env defines this variable.
|
||||
@@ -28,16 +28,16 @@ KSP_WALLETS_DIRECTORY=wallets
|
||||
KSP_PUBLIC_SOLANA_DEVNET_HTTP_URL=https://api.devnet.solana.com
|
||||
|
||||
# Optional public Solana Mainnet HTTP endpoint override used by config/std.transport.json and its example.
|
||||
# The committed Transport document falls back to https://api.mainnet-beta.solana.com when this variable is absent.
|
||||
KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL=https://api.mainnet-beta.solana.com
|
||||
# The committed Transport document falls back to https://api.mainnet.solana.com when this variable is absent.
|
||||
KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL=https://api.mainnet.solana.com
|
||||
|
||||
# Optional public Solana Devnet WebSocket endpoint override used by config/std.transport.json.
|
||||
# The committed Transport document falls back to wss://api.devnet.solana.com when this variable is absent.
|
||||
KSP_PUBLIC_SOLANA_DEVNET_WS_URL=wss://api.devnet.solana.com
|
||||
|
||||
# Optional public Solana Mainnet WebSocket endpoint override used by config/std.transport.json and its example.
|
||||
# The committed Transport document falls back to wss://api.mainnet-beta.solana.com when this variable is absent.
|
||||
KSP_PUBLIC_SOLANA_MAINNET_WS_URL=wss://api.mainnet-beta.solana.com
|
||||
# The committed Transport document falls back to wss://api.mainnet.solana.com when this variable is absent.
|
||||
KSP_PUBLIC_SOLANA_MAINNET_WS_URL=wss://api.mainnet.solana.com
|
||||
|
||||
# Optional complete private-provider HTTP endpoint URL used only by the Transport example when explicitly selected.
|
||||
# Keep provider credentials in a KSP_SECRET_* variable; do not copy a real credential-bearing URL into committed JSON.
|
||||
|
||||
80
CHANGELOG.md
80
CHANGELOG.md
@@ -1,8 +1,86 @@
|
||||
<!-- file: CHANGELOG.md -->
|
||||
<!-- version: 28 -->
|
||||
<!-- version: 34 -->
|
||||
|
||||
# Changelog KSP
|
||||
|
||||
## 0.3.14 — gap repair et hardening multi-source du Worker RawTransaction — 2026-09-12
|
||||
|
||||
`0.3.14` ferme `ksp-worker-raw-transaction-ingest-lib` par une continuité run-local explicite au-dessus des cinq familles live de `0.3.13`, sans transformer le Worker en moteur historique. Le Worker distingue désormais reconnect, tentative de replay, delivery de replay et preuve de coverage ; il maintient des gaps bornés avec plage inclusive, `TargetCoverage` conservative et frontier de continuité distincte de la processing frontier. Une reprise live ou l'acceptation d'un `from_slot` ne vaut jamais à elle seule preuve de réparation.
|
||||
|
||||
La réconciliation peut s'appuyer sur une coverage réellement prouvée d'une source redondante, un scan HTTP borné de blocs/slots ou l'hydration `getTransaction observed` des références connues. Les bornes finales restent 64 gaps ouverts, 4096 slots par plage, fenêtres de discovery de 512 slots, 4 `getBlock` logiques en vol et un seul gap activement réparé. Les matériaux de repair repassent par la même canonicalisation Common RAW, la même admission, la même hydration globale et le même Store ; aucun second pipeline, retry réseau Worker, Job Backfill automatique ou backend Store direct n'est introduit.
|
||||
|
||||
La politique multi-source ne fault plus mécaniquement sur toute perte de source : la continuation n'est autorisée que lorsque les gaps concernés sont réconciliés et que la coverage présente/future requise est réellement démontrée. Les snapshots publics ajoutent une observabilité source-neutral et bornée des gaps avec états/raisons/méthodes et compteurs checked, sans source key, endpoint, token, signature, payload ou erreur provider arbitraire. La health policy reste conservative, la fairness alterne trafic nominal/repair sur les capacités existantes et le shutdown borne également le drain interne des sources avec abort+join des siblings non coopératifs.
|
||||
|
||||
La fermeture cross-layer confirme le pipeline unique `Transport -> Worker -> ksp-raw-transaction-lib -> Store`, la non-régression Legacy/V0/V1, les dependency firewalls et l'indépendance Worker/Backfill. Le gate technique final passe fmt, audits Rust/Markdown, `cargo check --workspace`, Clippy workspace/all-targets/all-features avec `-D warnings`, `cargo test --workspace --all-targets --all-features` et les graphes Cargo : `1 931` tests passent, `0` échoue et `15` restent ignored opt-in/operator-only. Les smokes keyless Solana HTTP Devnet et WebSocket Devnet passent `1/1` chacun ; Yellowstone/provider-gated, Helius live, PostgreSQL live et Worker->Store end-to-end restent explicitement non exécutés lorsqu'aucune ressource correspondante n'a été provisionnée.
|
||||
|
||||
`prompts/034-V0_3_15_START_PROMPT.md` ouvre `0.3.15` exclusivement depuis le futur tag stable `v0.3.14`. Cette release introduit `ksp-app-raw-transaction-ingest-desk` comme Desk Tauri de composition/supervision : une **route** est une stratégie complète pouvant nécessiter plusieurs capacités Config/Transport, elle n'est sélectionnable que si toutes ses requirements sont composables, puis une instance Worker réelle est démarrée par route sélectionnée et valide l'opérabilité runtime au Start. Le frontend reste une surface sûre ; Config, Transport, Store et Worker conservent leurs responsabilités.
|
||||
|
||||
## 0.3.13 — convergence live multi-source du Worker RawTransaction — 2026-09-10
|
||||
|
||||
`0.3.13` généralise `ksp-worker-raw-transaction-ingest-lib` de la verticale Yellowstone de `0.3.12` vers une composition caller-owned de `1..32` sources logiques appartenant au même réseau. Cinq familles live convergent désormais vers le même pipeline Common RAW / admission / Store : Yellowstone, Standard Logs avec hydration `getTransaction observed`, Standard Block RAW-direct, Helius `transactionSubscribe` avec hydration et HTTP Block Polling run-local RAW-direct. Le Worker reste sans Config, Job Backfill, backend Store physique, client réseau direct ou SDK provider parallèle.
|
||||
|
||||
La convergence partage un registre global d'hydration entre Yellowstone, Standard Logs et Helius afin de coalescer `(network, signature, commitment)` avant fan-out HTTP. Après canonicalisation, les acquisitions de même `(network, signature)` sont sérialisées de manière bornée : un contenu identique conserve les observations/provenances distinctes, tandis qu'une divergence devient un `content_conflict` terminal explicite. Aucune majorité provider, préférence silencieuse ou stratégie `first-provider-wins` n'est introduite. Standard Block et HTTP Block Polling admettent directement les transactions Legacy/V0/V1 qualifiées avec `maxSupportedTransactionVersion = 1`.
|
||||
|
||||
Le supervisor multi-source possède toutes les tâches et applique des bornes source-neutral sur admission, pending signals, hydrations et persistence avec une fairness minimale entre sources reference-bearing. Les snapshots agrègent activity/health/backpressure/reconnect/replay/gap sans exposer le matériau provider. La politique reste conservative : une source configurée qui échoue est terminale faute d'équivalence de coverage prouvée ; le gap repair, les rôles degraded/failover et la preuve de couverture restent donc `0.3.14`. Le shutdown stop/fault/drain/abort+join est durci contre les races, leaders d'hydration abandonnés, Store lent et publications tardives.
|
||||
|
||||
Le gate déterministe final passe `cargo fmt`, audits Rust/Markdown, `cargo check --workspace`, Clippy workspace/all-targets/all-features avec `-D warnings`, `cargo test --workspace --all-targets --all-features` et les graphes Cargo : `1 850` tests passent, `0` échoue et `15` restent ignored opt-in/operator-only. Les smokes keyless Solana HTTP Devnet et WebSocket Devnet passent `1/1` chacun. Yellowstone provider-gated, Helius `transactionSubscribe` live, `blockSubscribe` provider dédié, HTTP polling Worker end-to-end et Worker multi-source vers Store restent explicitement non exécutés faute de gate provisionné ; aucune fixture ne les requalifie en PASS.
|
||||
|
||||
`prompts/033-V0_3_14_START_PROMPT.md` ouvre `0.3.14` exclusivement depuis le futur tag stable `v0.3.13`. Cette release doit fermer le gap repair/hardening multi-source limité au run actif — replay adressable, coverage redondante, scan HTTP borné, hydration de réparation, unresolved gaps et health policy — sans transformer le Worker en moteur historique ni créer de dépendance vers `ksp-job-backfill-lib`.
|
||||
|
||||
## 0.3.12 — Yellowstone + hydration HTTP + continuité de run du Worker RawTransaction — 2026-09-09
|
||||
|
||||
`0.3.12` ouvre la première source réseau productive de `ksp-worker-raw-transaction-ingest-lib` sans remettre en cause la fondation source-neutral de `0.3.11`. Le Worker dépend désormais de la façade `ksp-onchain-transport-lib` mais reste sans Config, Job Backfill, backend Store physique, `reqwest`, `tonic` ou proto provider direct. `RawTransactionIngestRuntimeResources` reçoit une source Yellowstone caller-composed et un pool HTTP cohérent ; `start` conserve la fondation sans source, tandis que `start_with_runtime_resources` lance la verticale live supervisée.
|
||||
|
||||
Les updates Yellowstone `Transaction`, `TransactionStatus` et les transactions incluses dans `Block` sont projetées vers des signaux source-neutral, coalescées de manière bornée par `(network, signature, commitment)`, hydratées via `getTransaction` observed puis converties par `ksp-raw-transaction-lib` avant admission/persistence Store. `BlockMeta` et `Slot` restent continuity-only. La provenance composite conserve les identités logiques sûres de la source et de la route d’hydration, sans URL, secret ni payload distant. Les gardes réseau/signature/slot/index/commitment empêchent les croisements de matériau avant admission.
|
||||
|
||||
La release matérialise une processing frontier strictement run-local : le Worker distingue travail pending/settled, n’avance jamais au travers d’un pending plus ancien et compacte ses états sans revendiquer une preuve de complétude durable. Le reconnect/replay reste Transport-owned : `from_slot` et `SubscribeReplayInfo` ne sont pas pilotés par le Worker. Le Worker projette un état source-neutral `Active/Reconnecting/Closing/Closed/Failed`, compte reconnect/replay-attempt/continuity-gap et fault avec `source_failed` lorsqu’une hausse du gap Transport prouve que la rétention ne permet plus la reprise demandée. Aucun Backfill ou repair automatique n’est déclenché.
|
||||
|
||||
Le hardening final ferme les duplicate storms, saturation/coalescence, Store lent/fautif, stop/fault pendant hydration/reconnect, overflow de compteurs, abandon des pending sans faux `settled`, abort/join des tâches privées et absence de retry HTTP possédé par le Worker. `pre.010` ajoute les canaris cross-layer Transport -> Worker -> Common RAW -> Store et verrouille les dependency firewalls, fixtures Legacy/V0, redaction et inventaires publics.
|
||||
|
||||
Le gate déterministe `pre.011` passe rustfmt check, audits Rust/Markdown, `cargo check --workspace`, Clippy strict, `cargo test --workspace --all-targets --all-features`, les suites ciblées et les trois inspections Cargo demandées. Les smokes live keyless HTTP Devnet et WebSocket Devnet sont exécutés séparément et passent ; les smokes Yellowstone token-gated et le Worker end-to-end Yellowstone + HTTP + Store restent explicitement non exécutés faute de ressources fournies, sans faux PASS. `pre.012` réconcilie ensuite README/USAGE et les architectures concernées avec cette verticale réellement matérialisée.
|
||||
|
||||
La suite reste découpée : `0.3.13` ajoute WS standard, Helius `transactionSubscribe`, HTTP live polling et la convergence multi-source ; `0.3.14` ferme le gap repair/hardening multi-source. `prompts/032-V0_3_13_START_PROMPT.md` ouvre `0.3.13` uniquement depuis le tag stable `v0.3.12` et impose un `pre.001` d’audit/sizing avant toute extension productive.
|
||||
|
||||
## 0.3.11 — Fondation runtime source-neutral du Worker RawTransaction ingest — 2026-09-08
|
||||
|
||||
`0.3.11` introduit `ksp-worker-raw-transaction-ingest-lib` comme premier Worker concret KSP pour l’acquisition continue de `RawTransaction`, tout en fermant volontairement la release **sans source réseau productive**. La crate consomme `ksp-worker-api`, `ksp-raw-transaction-lib` et la seule façade `ksp-store-lib`; elle ne dépend ni de Config, ni d’un Job, ni d’un backend Store physique, ni encore de `ksp-onchain-transport-lib`. Son identité stable est `raw_transaction_ingest`, ses settings bornent la capacité d’admission, la concurrence de persistence et la deadline de drain, et son `start` utilise exclusivement le runtime Tokio actif fourni par le caller.
|
||||
|
||||
Le runtime possède un supervisor privé, des tâches source/persistence détenues, une admission centrale `mpsc` bornée avec backpressure, la canonicalisation/assembly Common RAW, une observation key déterministe et la persistence atomique en mode `Normal`. Les outcomes distinguent insert, idempotence et tombstone purgée ; un contenu divergent devient `worker_raw_transaction_ingest.content_conflict`, les erreurs Store restent classifiées sans texte distant, et aucun provider gagnant n’est choisi implicitement. Les snapshots concrets latest-value se projettent directement sur `WorkerSnapshotSource`, utilisent séquences/compteurs checked et conservent le terminal pour les lecteurs tardifs.
|
||||
|
||||
Le hardening final ferme les races stop/fault, les source failures, la saturation observable, le Store lent/fautif et le shutdown borné. À l’expiration de `shutdown_drain_timeout`, le Worker ferme les admissions, abort les tâches restantes, les rejoint toutes puis publie `drain_timeout`; aucune tâche privée ne survit au terminal. Les tests externes de hardening/release verrouillent les sept codes d’erreur publics, les huit modules de production, les vingt-quatre exports crate-root, la redaction, le dependency firewall et l’absence de surface historique/Backfill/Transport/Config/backend direct.
|
||||
|
||||
Le gate technique final `pre.011` passe rustfmt check, audits Rust/Markdown, `cargo check --workspace`, Clippy strict, 56 tests Worker, `cargo test --workspace --all-targets --all-features`, les arbres Cargo Worker normal/features et `cargo tree --duplicates`. La réconciliation `pre.012` ajoute ensuite les README/USAGE durables du Worker et réaligne l’architecture sur l’état matériel : la fondation `0.3.11` n’a aucun adapter HTTP/WS/Yellowstone productif, aucune API publique d’enqueue/source registration et aucun replay/gap-repair live.
|
||||
|
||||
La trajectoire live reste découpée : `0.3.12` ouvre Yellowstone transactions/blocks/status + hydration HTTP + continuité de run, `0.3.13` ajoute WS standard/Helius/HTTP live et la convergence multi-source, puis `0.3.14` ferme le gap repair/hardening multi-source. `prompts/031-V0_3_12_START_PROMPT.md` ouvre `0.3.12` uniquement depuis le tag stable `v0.3.11`; son `pre.001` doit réauditer les surfaces Yellowstone/Solana/provider actuelles, l’injection Transport, l’hydration, le frontier, `from_slot`/ReplayInfo, les smokes accessibles et le sizing avant toute implémentation lourde.
|
||||
|
||||
## 0.3.10 — Lower-layer RAW Transaction commune + qualification cross-source — 2026-09-08
|
||||
|
||||
`0.3.10` introduit `ksp-raw-transaction-lib` comme lower-layer source-neutral commune aux producteurs `RawTransaction`. La canonicalisation RAW v1 auparavant locale à `ksp-job-backfill-lib` est extraite sans modifier les golden bytes/hash existants ; le Backfill consomme désormais cette common crate sans créer de dépendance vers un Worker. La bibliothèque construit les modèles `RawTransaction`/`RawTransactionObservation` de `ksp-store-api`, conserve l'identité canonique `(network, signature)`, borne et redacted les entrées hostiles, et reste volontairement sans Transport, Config, runtime async, Job, Worker ou backend Store physique.
|
||||
|
||||
La common matérialise également un wire Solana source-neutral Legacy/V0/V1 avec sérialisation Base64 et extraction de signature depuis le matériau transactionnel complet. La représentation V1 couvre header, compiled instructions, configuration V1 et signatures terminales ; les gardes structurelles rejettent les états non supportés plutôt que d'inventer une compatibilité. `ksp-onchain-transport-lib` ajoute `get_block_observed`, qui conserve l'identité sûre de la route réellement gagnante tout en réutilisant la validation `getBlock` existante.
|
||||
|
||||
Les preuves cross-source ferment explicitement ce qui est qualifié et ce qui exige encore hydration. Un `blockSubscribe` standard `full/base64` peut produire le même RAW v1 qu'un `getBlock` HTTP sur le sous-ensemble déterministe prouvé. Helius `transactionSubscribe` reste un signal nécessitant hydration HTTP lorsque `blockTime`/version manquent. Yellowstone qualifie le wire Transaction V1 et la conservation du `block_time` dans Block, mais `Transaction` nécessite hydration pour le temps de bloc et le chemin Block complet reste conservateur tant que l'identité byte-for-byte entre meta protobuf et meta JSON HTTP n'est pas prouvée. Aucun RAW-direct Yellowstone complet ni feed EARLY n'est revendiqué sans preuve.
|
||||
|
||||
Le gate technique final `pre.007` passe les audits Rust/Markdown, `cargo check --workspace`, Clippy workspace/all-targets/all-features avec `-D warnings`, `cargo test --workspace --all-targets --all-features`, les suites ciblées common/Transport/Backfill et les graphes Cargo demandés ; les smokes live opt-in restent ignorés conformément à leur contrat. La réconciliation `pre.008` aligne ensuite la terminologie durable des couches Store sur `D1 RAW -> D2 STRUCTURAL -> D3 DECODED -> D4 DOMAIN`, réserve `N1–N4` aux niveaux architecturaux et complète `README.md`/`USAGE.md` de `ksp-raw-transaction-lib`. Son gate opérateur repasse format, audits, `cargo check --workspace` et Clippy strict sur `0.3.10-pre.8`.
|
||||
|
||||
Le runtime concret a été volontairement retiré de `0.3.10` après recalibrage du sizing. La trajectoire devient `0.3.11` fondation runtime/persistence déterministe, `0.3.12` Yellowstone + hydration/continuité, `0.3.13` WS standard/Helius/HTTP live + convergence multi-source, `0.3.14` gap repair/hardening/smokes, `0.3.15` Desk d'ingestion et `0.3.16` extension Backfill multi-source. Le Job historique paramétré et le Worker continu restent des producteurs indépendants du même Store.
|
||||
|
||||
`prompts/030-V0_3_11_START_PROMPT.md` ouvre `0.3.11` uniquement depuis le tag stable `v0.3.10`. Son `pre.001` doit auditer la base réelle, le graphe de dépendances, le runtime/supervision, les bornes de channels, la persistence/déduplication déterministe, les snapshots et le dimensionnement avant toute implémentation lourde ; aucune source live complexe n'est requise pour fermer cette release.
|
||||
|
||||
## 0.3.9 — Worker API générique + audit exhaustif d'acquisition RawTransaction — 2026-09-05
|
||||
|
||||
`0.3.9` introduit `ksp-worker-api` comme façade générique, courte et runtime-neutral pour services continus. La surface stable reste Core-only et expose `WorkerId`, `WorkerKindCode`, `WorkerState`, `WorkerHealth`, `WorkerActivity`, `WorkerLifecycle`, `WorkerStopToken`, `WorkerSnapshotSequence`, `WorkerSnapshot`, `WorkerSnapshotFuture` et `WorkerSnapshotSource`. Les identités sont bornées et redacted, le lifecycle possède des transitions explicites avec terminaux immuables, le stop token est partagé/idempotent, la séquence ne wrappe pas et le snapshot source latest-value reste object-safe, `Send + Sync` et implémentable depuis l'extérieur. L'API ne possède aucun runtime `start/stop` universel, aucune sémantique Job/checkpoint, aucun domaine Solana et aucune dépendance Transport/Store/Config/Tauri.
|
||||
|
||||
Après freeze de cette API, la seconde moitié de la release réalise puis consolide l'audit exhaustif des voies d'acquisition `RawTransaction`. Le document durable `docs/architecture/011-RAW_TRANSACTION_ACQUISITION.md` est réécrit comme une synthèse unique Store-centrique : `RawTransaction` est le cœur RAW durable, tandis que `RawTransactionObservation` conserve les provenances multiples. L'audit distingue systématiquement possibilité protocolaire/provider, support architectural KSP, support implémenté et preuve live ; il couvre HTTP, WebSocket standard, Helius `transactionSubscribe`, Yellowstone gRPC, acquisition par blocs/slots, discovery + hydration, replay/gap repair, archives et sources EARLY/pre-execution. Les matrices recensent aussi les principaux providers/réseaux/tier/coûts connus sans réduire l'architecture aux seuls comptes gratuits actuellement disponibles.
|
||||
|
||||
La séparation des producteurs RAW est figée. `ksp-job-backfill-lib` reste un producteur historique **paramétré, borné et terminable** ; le futur `ksp-worker-raw-transaction-ingest-lib` sera un producteur live **continu start/stop sans requête métier historique**. Ils ne s'appellent pas, ne se supervisent pas et ne collaborent pas : chacun alimente indépendamment le même Store. HTTP, WS, gRPC, replay et archive sont des capabilities orthogonales à ces rôles ; un Worker peut utiliser HTTP pour hydration ou réparation de sa propre continuité live, tandis qu'un Job pourra utiliser gRPC/replay si une campagne historique bornée le justifie. Le handoff `0.3.10` retient `ksp-raw-transaction-lib` comme lower-layer source-neutral commune afin d'extraire la canonicalisation RAW v1 aujourd'hui locale au Backfill sans duplication ni edge Job ↔ Worker ; les golden bytes/hash RAW v1 doivent rester inchangés.
|
||||
|
||||
La release normalise aussi l'identité réseau KSP : `mainnet` devient l'identité canonique dans Config/Store/Transport/tests et `mainnet-beta` reste seulement un alias legacy/externe lorsque la frontière provider l'exige. Les données Mainnet N1 encore utilisées comme données de test ne dictent aucune compatibilité durable et aucune migration SQL n'est introduite pour préserver l'ancien libellé. Pendant le gate final, les baselines workspace sont également relevées à `jsonschema ^0.53` et `yellowstone-grpc-proto ^12.7`; Yellowstone 12.7 ajoute le RPC serveur `SubscribeGossip`, ce qui nécessite uniquement l'adaptation des fixtures `Geyser` avec une réponse `UNIMPLEMENTED` et n'ouvre aucune capability Gossip de production.
|
||||
|
||||
Le gate technique final passe les audits Rust/Markdown, `cargo check --workspace`, Clippy workspace/all-targets/all-features avec `-D warnings`, 385/385 tests unitaires `ksp-onchain-transport-lib`, 43/43 canaries `release_completeness`, `cargo test --workspace --all-targets --all-features`, le bundle complet `ksp-worker-api` et les graphes Cargo jusqu'à `cargo tree --duplicates`; seuls les smokes/probes explicitement opt-in restent ignorés. La réconciliation documentaire `pre.008` réaligne ensuite README/USAGE/indexes/architecture/plan/validation et son gate opérateur repasse audits Rust/Markdown plus `cargo check --workspace` en `0.3.9-pre.8`.
|
||||
|
||||
`prompts/029-V0_3_10_START_PROMPT.md` ouvre `0.3.10` exclusivement depuis le tag stable `v0.3.9`. La prochaine release doit d'abord matérialiser `ksp-raw-transaction-lib`, migrer le Backfill vers cette canonicalisation commune sans changement fonctionnel, puis construire `ksp-worker-raw-transaction-ingest-lib` multi-source dès V1. Son `pre.001` est obligatoirement un gate d'audit/sizing : inventaire exact des gaps Transport/Config, runtime Worker, capabilities live, continuité, preuves provider et dépendances avant toute implémentation lourde.
|
||||
|
||||
## 0.3.8 — Store Desk V1 RAW, inspection backend-neutral et trajectoire Worker multi-source — 2026-09-04
|
||||
|
||||
`0.3.8` introduit `ksp-app-store-desk`, application Tauri KSP read-only dédiée à l'inspection du Store. Le package reprend le gabarit Desk commun (splash/shell/assets/styles/tracing), compose `ksp-config-lib`, `ksp-logging-lib` et la seule façade `ksp-store-lib`, et interdit à l'application tout accès direct à `ksp-store-api`, `ksp-store-postgres-lib`, SQL, driver/pool PostgreSQL, Transport on-chain ou ressource physique. Le bootstrap ouvre un Store lié au profil/réseau composite, expose health/runtime backend-neutral et effectue un shutdown borné à la fermeture de la fenêtre principale.
|
||||
|
||||
10
Cargo.toml
10
Cargo.toml
@@ -1,12 +1,12 @@
|
||||
# file: Cargo.toml
|
||||
# version: 475
|
||||
# version: 593
|
||||
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/ksp-app-backfill-desk", "crates/ksp-app-config-desk", "crates/ksp-app-solprices-desk", "crates/ksp-app-store-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-interface-lib", "crates/ksp-job-api", "crates/ksp-job-backfill-lib", "crates/ksp-logging-lib", "crates/ksp-offchain-transport-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-program-api", "crates/ksp-store-api", "crates/ksp-store-lib", "crates/ksp-store-postgres-lib", "crates/ksp-wallet-lib"]
|
||||
members = ["crates/ksp-app-backfill-desk", "crates/ksp-app-config-desk", "crates/ksp-app-solprices-desk", "crates/ksp-app-store-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-interface-lib", "crates/ksp-job-api", "crates/ksp-job-backfill-lib", "crates/ksp-logging-lib", "crates/ksp-offchain-transport-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-program-api", "crates/ksp-raw-transaction-lib", "crates/ksp-store-api", "crates/ksp-store-lib", "crates/ksp-store-postgres-lib", "crates/ksp-wallet-lib", "crates/ksp-worker-api", "crates/ksp-worker-raw-transaction-ingest-lib"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.8"
|
||||
version = "0.3.14"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
|
||||
@@ -25,7 +25,7 @@ fs2 = { version = "^0.4" }
|
||||
futures-util = { version = "^0.3", default-features = false }
|
||||
getrandom = { version = "^0.4", default-features = false }
|
||||
http = { version = "^1.5", default-features = false }
|
||||
jsonschema = { version = "^0.52", default-features = false }
|
||||
jsonschema = { version = "^0.53", default-features = false }
|
||||
reqwest = { version = "^0.13", default-features = false }
|
||||
rustls = { version = "^0.23", default-features = false }
|
||||
rustls-native-certs = { version = "^0.8", default-features = false }
|
||||
@@ -49,7 +49,7 @@ tokio-tungstenite = { version = "^0.30", default-features = false }
|
||||
tonic = { version = "^0.14", default-features = false }
|
||||
tonic-prost = { version = "^0.14", default-features = false }
|
||||
ts-rs = { version = "^12.0" }
|
||||
yellowstone-grpc-proto = { version = "^12.6", default-features = false }
|
||||
yellowstone-grpc-proto = { version = "^12.7", default-features = false }
|
||||
zeroize = { version = "^1.9" }
|
||||
|
||||
[workspace.lints.rust]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: README.md -->
|
||||
<!-- version: 10 -->
|
||||
<!-- version: 14 -->
|
||||
|
||||
# Khadhroony Solana Project
|
||||
|
||||
@@ -55,7 +55,9 @@ La couche RAW dispose d'une façade Store backend-neutral et d'un premier job hi
|
||||
|
||||
`ksp-app-store-desk` fournit l'inspection desktop read-only du Store RAW via `ksp-store-lib` : health/runtime backend-neutral, tables server-side `RawTransaction` et `RawAccountState`, observations associées, détails avec previews bornées et lecture des états de rétention/tombstones. DataTables possède l'unique pagination visible de l'inspection random-access ; la pagination cursor/keyset des consumers machine reste distincte et intacte.
|
||||
|
||||
Ces contrats restent distincts des futurs workers continus : un job borné n'est ni un service worker ni un pipeline générique imposé aux autres couches.
|
||||
`ksp-worker-api` fournit désormais la fondation générique des services continus : identité Worker, lifecycle borné, health/activity, intention de stop coopératif et observation latest-value. Cette API reste Core-only, runtime-neutral et sans connaissance Solana, Transport, Store ou Job. Elle ne démarre ni n'arrête elle-même un runtime concret.
|
||||
|
||||
`ksp-worker-raw-transaction-ingest-lib` est le premier Worker concret distinct du Job Backfill. Il conserve sa fondation Start/Stop, admission bornée, Common RAW, Store backend-neutral, shutdown borné et snapshots latest-value. Ses sources productives couvrent maintenant Yellowstone + hydration HTTP `getTransaction`, Standard WS `logsSubscribe` + hydration, Standard WS `blockSubscribe` RAW-direct, Helius `transactionSubscribe` + hydration et Solana HTTP live block polling. Le polling HTTP démarre au `getSlot` observé du run, découvre uniquement les blocs live via `getBlocksWithLimit` puis matérialise les slots listés via `getBlock observed`; il n'est jamais utilisé comme Backfill implicite. Les signaux hydratés de même `(network, signature, commitment)` convergent vers le même coordinator, tandis que les voies block RAW-direct passent par la même admission Common RAW. Le reconnect/replay reste propriétaire de Transport, la frontier de traitement reste run-local et un gap de rétention prouvé provoque un fault au lieu de lancer une campagne historique. `ksp-job-backfill-lib` conserve son rôle historique paramétré et borné ; les deux producteurs restent indépendants et convergent uniquement vers les mêmes contrats RAW/Store.
|
||||
|
||||
## Points d'entrée
|
||||
|
||||
|
||||
36
ROADMAP.md
36
ROADMAP.md
@@ -1,5 +1,5 @@
|
||||
<!-- file: ROADMAP.md -->
|
||||
<!-- version: 103 -->
|
||||
<!-- version: 112 -->
|
||||
|
||||
# Roadmap KSP
|
||||
|
||||
@@ -40,7 +40,7 @@ Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues. U
|
||||
|
||||
### Cadrage
|
||||
|
||||
- [X] `0.2.0` — Audit bot3, ordre fonctionnel de `0.2.x`, architecture durable, discipline de sizing et pipeline RAW/CORE/DECODE/SPECIALIZED stabilisés.
|
||||
- [X] `0.2.0` — Audit bot3, ordre fonctionnel de `0.2.x`, architecture durable, discipline de sizing et pipeline RAW/STRUCTURAL/DECODED/DOMAIN stabilisés.
|
||||
- [X] `0.2.1` — HTTP foundation stable : matrice 52+14, runtime/routing/résilience/exécution HTTP, 4 canaris typés, `std.transport`, adapter Config -> Transport, canaries de clôture, smoke Devnet opt-in et documentation durable validés ; les 48 wrappers typés restants sont reportés à `0.2.2`–`0.2.4`.
|
||||
|
||||
### Releases fonctionnelles décidées/pressenties
|
||||
@@ -87,13 +87,13 @@ RAW -> STRUCTURAL -> DECODED -> DOMAIN
|
||||
```
|
||||
|
||||
- **RAW** et **STRUCTURAL** ne nécessitent aucun décodage Program.
|
||||
- La progression n'est pas une chaîne obligatoire pour chaque famille : une donnée event-only peut s'arrêter en N1, et un état de compte pourra aller directement vers un futur decoder si aucune décomposition STRUCTURAL utile n'existe.
|
||||
- La progression n'est pas une chaîne obligatoire pour chaque famille : une donnée event-only peut s'arrêter en D1, et un état de compte pourra aller directement vers un futur decoder si aucune décomposition STRUCTURAL utile n'existe.
|
||||
- À la fin des couches horizontales RAW/STRUCTURAL réellement persistées, ajouter les jobs/workers/apps nécessaires avant d'ouvrir la couche suivante.
|
||||
- À partir de **DECODED**, avancer verticalement groupe par groupe ; **DOMAIN** désigne les projections métier et la matérialisation est le processus qui les produit.
|
||||
|
||||
## 0.3.x — RAW / acquisition persistée
|
||||
|
||||
- [X] `0.3.1` — `ksp-store-api` stable : modèles N1 RAW backend-agnostic `RawTransaction` et `RawAccountState` avec observations, provenance, payload/hash/timestamps bornés, 10 capabilities object-safe, queries cursorisées sans plafond métier arbitraire, outcomes idempotence/conflit et lifecycle logique rétention/tombstone/force-rehydrate ; aucun backend physique, Config, runtime Store, notification dédiée ni surface STRUCTURAL/DECODED/DOMAIN.
|
||||
- [X] `0.3.1` — `ksp-store-api` stable : modèles D1 RAW backend-agnostic `RawTransaction` et `RawAccountState` avec observations, provenance, payload/hash/timestamps bornés, 10 capabilities object-safe, queries cursorisées sans plafond métier arbitraire, outcomes idempotence/conflit et lifecycle logique rétention/tombstone/force-rehydrate ; aucun backend physique, Config, runtime Store, notification dédiée ni surface STRUCTURAL/DECODED/DOMAIN.
|
||||
- [X] `0.3.2` — `ksp-store-lib` + `ksp-store-postgres-lib` stables comme fondation runtime/backend PostgreSQL : feature `postgres` par défaut, `Store` lié à un unique `RawNetworkId`, Config `std.store` avec targets/bases `devnet`/`mainnet`/`testnet`, pool Deadpool borné, `tokio-postgres`, TLS Rustls `Disabled`/`VerifyFull`, moteur de migrations privé `V000` + SHA-256/advisory lock, health/readiness portable et close borné. Gate complet + PostgreSQL réel major 17 verts ; aucune table/capability `RawTransaction`/`RawAccountState` métier n'est encore ajoutée.
|
||||
- [X] `0.3.3` — Vertical slice PostgreSQL `RawTransaction` complète sur `ksp-store-lib` + `ksp-store-postgres-lib` : six capabilities transaction/observation/rétention, V001 physique liée à un réseau, acquisition canonical+observation atomique, idempotence/conflit, get/list keyset cursorisé, archive/purge/tombstone/ForceRehydrate, hardening des erreurs et du schéma, concurrence et rollback validés sur PostgreSQL 17.
|
||||
- [X] `0.3.4` — Vertical slice PostgreSQL `RawAccountState` complète sur `ksp-store-lib` + `ksp-store-postgres-lib` : quatre capabilities account ajoutées aux six transaction pour une conformance RAW 10/10, V002 additive de 32 ressources au-dessus de V000/V001 immuables, state+observation atomiques, idempotence/conflit exacts, metadata Yellowstone observation-only, get/list keyset `(slot,pubkey,state_hash)` avec cursor KSPA anti-replay, hardening cross-family et live validé sur PostgreSQL 17 sans rétention destructive account.
|
||||
@@ -101,22 +101,26 @@ RAW -> STRUCTURAL -> DECODED -> DOMAIN
|
||||
- [X] `0.3.6` — `ksp-job-api` + `ksp-job-backfill-lib` stables pour la première verticale historique `RawTransaction` : lifecycle/cancellation/latest-value runtime-neutral, scopes `Latest`/`Before`/`After`/signatures explicites, découverte/hydratation Transport observée, RAW v1 canonique, persistance Store atomique/idempotente, concurrence bornée, frontier contiguë, checkpoint/reprise caller-owned, snapshots sûrs et hardening externe sans dépendance backend/provider directe.
|
||||
- [X] `0.3.7` — `ksp-app-backfill-desk` livrée comme Desk Tauri KSP spécialisée : composition Config Mainnet/Devnet/Testnet, quatre scopes HTTP, validation/start single-run, monitoring latest-value `ksp-backfill-status`, Cancel ciblé/idempotent, Resume in-session par checkpoint Rust-only réémis pour un nouveau JobId, autocomplete libre depuis `ksp-core-lib`, hardening IPC/dependency boundaries et build Linux `.deb`/`.rpm`/`.AppImage`. Aucun SQL/backend/provider physique ni checkpoint durable n’est exposé.
|
||||
- [X] `0.3.8` — `ksp-app-store-desk` V1 RAW livrée sur le gabarit KSP courant comme application Tauri read-only backend-neutral. La Desk compose Config + Logging + `ksp-store-lib`, expose health/runtime sûrs, DataTables `serverSide` pour `RawTransaction`, `RawAccountState` et leurs observations, détails bornés, provenance sûre et états de rétention/tombstone, sans SQL/backend physique/Transport dans l'application. La nouvelle inspection random-access `offset + limit + counts exacts` reste distincte de la pagination machine cursor/keyset conservée pour workers/backfills/replays. Le gate final et les builds Linux `.deb`/`.rpm` sont verts.
|
||||
- [ ] `0.3.9` — Introduire `ksp-worker-api` comme API **générique et volontairement courte** de lifecycle/health/progression/snapshot pour services continus, distincte de `ksp-job-api` et sans dépendance Solana/Transport/Store/Tauri. Une fois l'API Worker fonctionnellement fermée, terminer la release par un audit fonctionnel exhaustif des sources/méthodes d'acquisition `RawTransaction` : HTTP, WS standard, extensions provider, Yellowstone gRPC, blocks/slots, discovery + hydration, replay/gap-repair et combinaisons multi-provider. Cet audit prépare `0.3.10` sans ajouter d'endpoint ni de Config provider dans `0.3.9`.
|
||||
- [ ] `0.3.10` — Introduire `ksp-worker-raw-transaction-ingest-lib` **multi-source dès V1**. Le worker compose les stratégies retenues par l'audit `0.3.9` comme sources alternatives, complémentaires, redondantes ou spécialisées live/catch-up/gap-repair, converge vers `RawTransaction + RawTransactionObservation`, persiste par `ksp-store-lib` et utilise `ksp-worker-api` pour supervision/cancellation. Cette release porte aussi, et seulement si l'audit les justifie, les adaptations nécessaires de `ksp-onchain-transport-lib`/`ksp-config-lib` : futures sources Helius HTTP/WS Mainnet/Devnet avec réutilisation de `KSP_SECRET_HELIUS_API_KEY`, capabilities/tier réaudités au moment du travail, et éventuelle canonicalisation `mainnet`/`mainnet-beta` uniquement avec stratégie de compatibilité sûre. Aucun decode Program ni dépendance backend/provider directe dans le worker.
|
||||
- [ ] `0.3.11` — Introduire `ksp-app-raw-transaction-ingest-desk`, Desk Tauri KSP spécialisée qui choisit et supervise **une ou plusieurs sources/méthodes** réellement admises par `0.3.10` : lifecycle, health, rates, backpressure, reconnect/recovery, gap state et compteurs sûrs. La Desk ne réimplémente ni discovery, hydration, déduplication, reprise ni persistence ; l'inspection détaillée des RAW persistés reste la responsabilité de `ksp-app-store-desk`.
|
||||
- [ ] `0.3.12` — Étendre `ksp-job-backfill-lib` et `ksp-app-backfill-desk` au **backfill multi-source/multi-stratégie** à partir de la même matrice d'acquisition auditée en `0.3.9`. Conserver la stratégie actuelle `getSignaturesForAddress + getTransaction` comme première voie HTTP valide, puis ajouter seulement les voies historiques/catch-up/gap-repair réellement pertinentes et sûres (par exemple block/slot, replay provider lorsqu'il existe), sans supposer qu'une source live WS constitue un historique universel.
|
||||
- [X] `0.3.9` — `ksp-worker-api` stable comme API générique et volontairement courte pour services continus : identité/kind bornés, lifecycle explicite, health/activity sûrs, stop token partagé, séquence/snapshot latest-value et `WorkerSnapshotSource` object-safe, avec dépendance Core-only et sans runtime/start-stop universel, Solana, Transport, Store, Config ou Tauri. Après freeze Worker API, l'audit `RawTransaction` a été consolidé dans une synthèse Store-centrique exhaustive séparant possibilités/support/preuve et classant HTTP, WS standard/provider, Yellowstone gRPC, blocs/slots, discovery + hydration, replay de continuité, archives et sources EARLY. Le Job Backfill historique paramétré et le Worker Ingest live start/stop sont deux producteurs indépendants du même Store ; `mainnet` est désormais l'identité KSP canonique et `mainnet-beta` un alias legacy/externe. Le handoff retient `ksp-raw-transaction-lib` comme lower-layer commune de canonicalisation RAW v1 pour `0.3.10`, sans edge Job ↔ Worker.
|
||||
- [X] `0.3.10` — Lower-layer RAW Transaction commune stabilisée : `ksp-raw-transaction-lib` possède la canonicalisation RAW v1 source-neutral partagée, le Backfill est migré sans changement des golden bytes/hash, Transport expose `get_block_observed`, le wire Solana Legacy/V0/V1 est matérialisé et les canaris HTTP/WS standard/Helius/Yellowstone qualifient précisément les voies directes ou nécessitant hydration. La release se ferme sans runtime Worker concret ; celui-ci commence en `0.3.11` selon le redécoupage `0.3.11`–`0.3.14`.
|
||||
- [X] `0.3.11` — Fondation runtime de `ksp-worker-raw-transaction-ingest-lib` livrée : crate/dependency firewall, settings source-neutral, handle/start-stop, lifecycle, snapshots, supervisor, admission bornée, canonicalisation Common RAW, persistence/déduplication Store déterministe, backpressure et shutdown/fault hardening. La release se ferme volontairement sans source réseau productive ; Yellowstone + hydration HTTP commencent en `0.3.12`.
|
||||
- [X] `0.3.12` — Première source live productive du Worker livrée : Yellowstone `Transaction`/`TransactionStatus`/`Block` vers signaux source-neutral, coalescence bornée puis hydration HTTP `getTransaction` observed avant Common RAW/Store ; `BlockMeta`/`Slot` restent continuity-only. Processing frontier run-local, reconnect/from_slot/ReplayInfo Transport-owned, compteurs source-neutral et fault sur gap de rétention prouvé sans Backfill automatique. Hardening duplicate/backpressure/stop/fault et canaris cross-layer fermés ; gate workspace complet vert, smokes live HTTP Devnet + WebSocket Devnet verts, Yellowstone token-gated/Worker end-to-end laissés explicitement non exécutés.
|
||||
- [X] `0.3.13` — Convergence live multi-source du Worker RAW livrée : composition caller-owned `1..32` sources d’un même réseau, cinq familles Yellowstone / Standard Logs / Standard Block / Helius Transaction / HTTP Block Polling, hydration globale des trois voies reference-bearing, RAW-direct Legacy/V0/V1 pour les deux voies blocs, observations multiples, content conflict explicite, coalescence cross-source, fairness/backpressure/health source-neutral et shutdown hardening sans second actor Transport. Gate workspace complet vert (`1 850` PASS / `0` échec / `15` ignored) et smokes keyless HTTP + WebSocket Devnet verts ; les preuves provider/resource-gated non exécutées restent explicitement non revendiquées.
|
||||
- [X] `0.3.14` — Gap repair/hardening multi-source du Worker RAW livré : gaps run-local bornés, distinction reconnect/replay/delivery/coverage, TargetCoverage conservative, coverage redondante prouvée, discovery HTTP bornée, known-reference hydration, réconciliation source-loss, health gap-aware, fairness nominal/repair, snapshots publics source-neutral et shutdown/drain renforcé. Le pipeline Common RAW/Store reste unique, Worker/Backfill restent indépendants, EARLY n’est pas ajouté. Gate workspace complet vert (`1 931` PASS / `0` échec / `15` ignored) et smokes keyless HTTP + WebSocket Devnet verts ; preuves provider/resource-gated non provisionnées explicitement non revendiquées.
|
||||
- [ ] `0.3.15` — Introduire `ksp-app-raw-transaction-ingest-desk`, Desk Tauri KSP de composition/supervision des routes live `RawTransaction`. Une route est une stratégie complète pouvant nécessiter plusieurs capabilities/endpoints (par exemple gRPC + HTTP ou WS + HTTP) et n’est sélectionnable que si toutes ses requirements sont composables depuis Config sur le réseau choisi ; la présence d’un provider ne vaut jamais capability implicite. Au Start, la Config est revalidée et **une instance Worker est lancée par route sélectionnée** — pas par source physique — afin de valider l’opérabilité réelle puis exposer lifecycle, health, backpressure, reconnect/replay, gaps/repair et compteurs sûrs. Plusieurs Workers peuvent partager le même Store, sans coordination implicite de gaps entre routes. La Desk ne réimplémente ni Transport, discovery, hydration, déduplication, repair ni persistence.
|
||||
- [ ] `0.3.16` — Étendre `ksp-job-backfill-lib` et `ksp-app-backfill-desk` au **backfill multi-source/multi-stratégie** à partir de la même matrice d'acquisition auditée en `0.3.9`. Conserver la stratégie actuelle `getSignaturesForAddress + getTransaction` comme première voie HTTP valide, puis ajouter seulement les voies historiques/catch-up/gap-repair réellement pertinentes et sûres, sans supposer qu'une source live WS constitue un historique universel.
|
||||
|
||||
### TODO/IDEAS — applications spécialisées et control plane
|
||||
|
||||
- [ ] **TODO** — faire évoluer `ksp-app-store-desk` avec chaque nouvelle couche réellement persistée : RAW d'abord, puis STRUCTURAL, DECODED, journal de processing/materialization et projections DOMAIN selon les contrats effectivement disponibles. Les vues avancées restent backend-agnostiques et passent uniquement par `ksp-store-lib`.
|
||||
- [ ] **TODO** — différer une future `ksp-app-control-desk` jusqu'à ce que KSP dispose au minimum d'un niveau N3/D3 de processing/materialization exploitable et de plusieurs decoders réels. Cette application sera une surface **end-user simplifiée** : démarrer/arrêter les flux autorisés, lancer une recherche courante, voir l'état global et les erreurs importantes. Elle ne recopiera pas le diagnostic détaillé, les réglages avancés, les tables complètes ni toutes les fonctions des `ksp-app-*-desk` spécialisées, qui resteront les outils d'administration, développement et investigation approfondie.
|
||||
- [ ] **TODO** — différer une future `ksp-app-control-desk` jusqu'à ce que KSP dispose au minimum d'une couche D3 DECODED de processing/materialization exploitable et de plusieurs decoders réels. Cette application sera une surface **end-user simplifiée** : démarrer/arrêter les flux autorisés, lancer une recherche courante, voir l'état global et les erreurs importantes. Elle ne recopiera pas le diagnostic détaillé, les réglages avancés, les tables complètes ni toutes les fonctions des `ksp-app-*-desk` spécialisées, qui resteront les outils d'administration, développement et investigation approfondie.
|
||||
|
||||
### TODO/IDEAS — taxonomie N1, processing et rétention
|
||||
### TODO/IDEAS — taxonomie D1, processing et rétention
|
||||
|
||||
- [ ] **TODO** — maintenir la matrice d’admission HTTP/WS/gRPC/provider lors de toute nouvelle famille N1 : plusieurs sources ne convergent vers un même struct que si elles satisfont la même sémantique sans perte.
|
||||
- [ ] **TODO `0.3.9`** — produire après fermeture de `ksp-worker-api` une matrice RAW Transaction unique et réutilisable par `0.3.10` puis `0.3.12`, couvrant discovery, hydration/direct payload, filtres, ordering/duplicates, reconnect/replay, gap repair, backpressure, commitment, provenance, quotas, gaps Transport/Config et applicability continuous-ingest/catch-up/backfill.
|
||||
- [ ] **TODO Helius futur** — ne pas ajouter d'URL/endpoints Helius en `0.3.8` ni pendant la construction générique de Worker API. L'audit `0.3.9` réévalue les capabilities/tier courants ; `0.3.10` peut ensuite ajouter les profils HTTP/WS réellement nécessaires en réutilisant exclusivement `KSP_SECRET_HELIUS_API_KEY` via Config.
|
||||
- [ ] **TODO réseau** — auditer `mainnet` vs `mainnet-beta` avant tout renommage : Store persisté, Config, Transport, checkpoints/fingerprints et aliases externes doivent converger vers une identité KSP unique pour le même cluster ; aucune migration/canonicalisation n'est supposée avant cette preuve.
|
||||
- [ ] **TODO** — maintenir la matrice d’admission HTTP/WS/gRPC/provider lors de toute nouvelle famille D1 : plusieurs sources ne convergent vers un même struct que si elles satisfont la même sémantique sans perte.
|
||||
- [X] **TODO `0.3.9`** — matrice RAW Transaction unique produite et consolidée dans `docs/architecture/011-RAW_TRANSACTION_ACQUISITION.md`, avec séparation possibilités/support/preuve, capabilities orthogonales aux producteurs, provenance/déduplication/continuité et handoffs désormais scindés `0.3.11`–`0.3.14` Worker live / `0.3.16` Backfill historique.
|
||||
- [X] **TODO Helius `0.3.13`** — la voie Worker Helius `transactionSubscribe` réutilise la surface Transport et les profils Config Helius déjà existants avec `KSP_SECRET_HELIUS_API_KEY`; aucun nouveau profil, secret, SDK provider, tier codé ou second client parallèle n’a été nécessaire. Le payload riche reste Transport-owned et la voie Worker hydrate via `getTransaction observed` tant que le RAW complet n’est pas prouvé directement.
|
||||
- [X] **TODO réseau** — identité KSP canonique fixée à `mainnet` dans Config/Store/Transport/tests ; `mainnet-beta` reste uniquement un alias legacy/externe lorsque la frontière provider l'exige. Les données D1 RAW antérieures restent non autoritaires pendant cette phase et aucune compatibilité de base de test n'impose l'ancien libellé.
|
||||
- [X] `RawAccountState` + observation — contrat commun stabilisé en `0.3.1` avec bytes complets + slot, provenance séparée et enrichissements source-specific optionnels ; la persistence PostgreSQL physique est complétée en `0.3.4` avec les quatre capabilities account et la conformance RAW 10/10.
|
||||
- [ ] **TODO** — statut/commitment transactionnel restant : `0.3.5` couvre uniquement le fait passif d’exécution `slot + signature + outcome`; réauditer séparément `signatureSubscribe` et `getSignatureStatuses` lorsqu’un consumer de commitment/snapshot réel apparaît, sans fusionner snapshot, transition et execution update dans un modèle Option-soup.
|
||||
- [ ] **IDEA** — logs realtime enrichis : `logsSubscribe` alimente déjà la projection minimale `TransactionExecutionEvent`, mais un éventuel `TransactionLogEvent` portant les lignes de log reste différé dans `docs/IDEAS.md` jusqu’à démonstration d’un consumer et de bornes explicites. `logMessages` reste dans `RawTransaction` jusqu’à STRUCTURAL ; le wake-up post-commit reste distinct et Store API-owned conformément à `KSP-NOTIFY-*`.
|
||||
@@ -129,13 +133,13 @@ RAW -> STRUCTURAL -> DECODED -> DOMAIN
|
||||
- [X] rétention physique `RawTransaction` PostgreSQL — `0.3.3` matérialise `Full -> Archived -> Purged`, tombstone et ForceRehydrate atomiques ; `Compacted` reste explicitement unsupported tant qu’aucune représentation compactée réelle n’existe.
|
||||
- [ ] **TODO** — policy de rétention/compaction : définir les critères d’éligibilité fondés sur les preuves de processing et la maintenance worker/job ; Store applique une transition demandée mais ne décide pas seul qu’un RAW peut être archivé/purgé, et la compaction physique ne sera ajoutée qu’avec un besoin réel.
|
||||
- [X] frontière `ksp-interface-lib` / `ksp-store-api` — ownership documenté et canaris de non-duplication stabilisés en `0.3.1`; les events passifs non persistés restent Interface, les modèles persistants/replayables restent Store API.
|
||||
- [ ] **IDEA** — réauditer la structure de processing/decode/materialization historique kbot2/kbot3 lors de l’ouverture de N2/N3 ; conserver l’isolation instruction/CPI et les statuts terminal/versionnés, sans reprendre automatiquement le schéma SQL historique.
|
||||
- [ ] **IDEA** — réauditer la structure de processing/decode/materialization historique kbot2/kbot3 lors de l’ouverture de D2/D3 ; conserver l’isolation instruction/CPI et les statuts terminal/versionnés, sans reprendre automatiquement le schéma SQL historique.
|
||||
|
||||
## Série STRUCTURAL suivante
|
||||
|
||||
- [ ] Définir la persistence STRUCTURAL canonique Solana générique pour les familles réellement décomposables.
|
||||
- [ ] Implémenter en priorité `RawTransaction -> STRUCTURAL` sans decoder Program : transaction/message, comptes/références, instructions top-level, CPI/inner instructions, logs/meta/balances/return data et relations structurelles.
|
||||
- [ ] Vérifier avant extension si d'autres familles N1 possèdent une vraie décomposition STRUCTURAL utile ; ne pas créer de niveau vide par convention.
|
||||
- [ ] Vérifier avant extension si d'autres familles D1 possèdent une vraie décomposition STRUCTURAL utile ; ne pas créer de niveau vide par convention.
|
||||
- [ ] Ajouter replay/backfill RAW -> STRUCTURAL avec processing versionné.
|
||||
- [ ] Ajouter le worker/service STRUCTURAL utile sans le coupler à un worker RAW concret.
|
||||
- [ ] Faire évoluer `ksp-app-store-desk` avec des vues/requêtes STRUCTURAL lorsque cette couche est réellement persistée ; ne pas créer une seconde application de browsing des mêmes données.
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
{
|
||||
"profile_id": "mainnet",
|
||||
"network": "mainnet-beta",
|
||||
"network": "mainnet",
|
||||
"backend": "postgres",
|
||||
"postgres": {
|
||||
"connection_uri": "${KSP_SECRET_STORE_MAINNET_POSTGRES_URI:-postgresql://localhost/ksp_mainnet}",
|
||||
|
||||
@@ -31,8 +31,8 @@
|
||||
"name": "mainnet_public",
|
||||
"enabled": true,
|
||||
"provider": "solana-public",
|
||||
"cluster": "mainnet-beta",
|
||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL:-https://api.mainnet-beta.solana.com}",
|
||||
"cluster": "mainnet",
|
||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL:-https://api.mainnet.solana.com}",
|
||||
"connect_timeout_ms": 5000,
|
||||
"request_timeout_ms": 15000,
|
||||
"max_idle_connections_per_host": 8,
|
||||
@@ -57,7 +57,7 @@
|
||||
"name": "mainnet_private",
|
||||
"enabled": true,
|
||||
"provider": "private-provider",
|
||||
"cluster": "mainnet-beta",
|
||||
"cluster": "mainnet",
|
||||
"url": "${KSP_SECRET_SOLANA_HTTP_URL:-https://example.invalid}",
|
||||
"connect_timeout_ms": 5000,
|
||||
"request_timeout_ms": 10000,
|
||||
@@ -85,15 +85,15 @@
|
||||
"name": "mainnet_public_ws",
|
||||
"enabled": true,
|
||||
"provider": "solana-public",
|
||||
"cluster": "mainnet-beta",
|
||||
"cluster": "mainnet",
|
||||
"kind": "solana_standard",
|
||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_WS_URL:-wss://api.mainnet-beta.solana.com}"
|
||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_WS_URL:-wss://api.mainnet.solana.com}"
|
||||
},
|
||||
{
|
||||
"name": "mainnet_helius_ws",
|
||||
"enabled": true,
|
||||
"provider": "helius",
|
||||
"cluster": "mainnet-beta",
|
||||
"cluster": "mainnet",
|
||||
"kind": "helius_laserstream",
|
||||
"url": "wss://mainnet.helius-rpc.com/?api-key=${KSP_SECRET_HELIUS_API_KEY:-replace-me}",
|
||||
"session": {
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
{
|
||||
"profile_id": "mainnet",
|
||||
"network": "mainnet-beta",
|
||||
"network": "mainnet",
|
||||
"backend": "postgres",
|
||||
"postgres": {
|
||||
"connection_uri": "${KSP_SECRET_STORE_MAINNET_POSTGRES_URI:-postgresql://localhost/ksp_mainnet}",
|
||||
|
||||
@@ -144,8 +144,8 @@
|
||||
"name": "solana_mainnet_public",
|
||||
"enabled": true,
|
||||
"provider": "solana-public",
|
||||
"cluster": "mainnet-beta",
|
||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL:-https://api.mainnet-beta.solana.com}",
|
||||
"cluster": "mainnet",
|
||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL:-https://api.mainnet.solana.com}",
|
||||
"connect_timeout_ms": 5000,
|
||||
"request_timeout_ms": 15000,
|
||||
"max_idle_connections_per_host": 8,
|
||||
@@ -172,9 +172,9 @@
|
||||
"name": "solana_mainnet_public_ws",
|
||||
"enabled": true,
|
||||
"provider": "solana-public",
|
||||
"cluster": "mainnet-beta",
|
||||
"cluster": "mainnet",
|
||||
"kind": "solana_standard",
|
||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_WS_URL:-wss://api.mainnet-beta.solana.com}"
|
||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_WS_URL:-wss://api.mainnet.solana.com}"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -185,8 +185,8 @@
|
||||
"name": "solana_mainnet_public",
|
||||
"enabled": true,
|
||||
"provider": "solana-public",
|
||||
"cluster": "mainnet-beta",
|
||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL:-https://api.mainnet-beta.solana.com}",
|
||||
"cluster": "mainnet",
|
||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL:-https://api.mainnet.solana.com}",
|
||||
"connect_timeout_ms": 5000,
|
||||
"request_timeout_ms": 15000,
|
||||
"max_idle_connections_per_host": 8,
|
||||
@@ -225,7 +225,7 @@
|
||||
"name": "publicnode_solana_mainnet_http",
|
||||
"enabled": true,
|
||||
"provider": "publicnode",
|
||||
"cluster": "mainnet-beta",
|
||||
"cluster": "mainnet",
|
||||
"url": "https://solana-rpc.publicnode.com",
|
||||
"connect_timeout_ms": 5000,
|
||||
"request_timeout_ms": 15000,
|
||||
@@ -267,9 +267,9 @@
|
||||
"name": "solana_mainnet_public_ws",
|
||||
"enabled": true,
|
||||
"provider": "solana-public",
|
||||
"cluster": "mainnet-beta",
|
||||
"cluster": "mainnet",
|
||||
"kind": "solana_standard",
|
||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_WS_URL:-wss://api.mainnet-beta.solana.com}"
|
||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_WS_URL:-wss://api.mainnet.solana.com}"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -280,8 +280,8 @@
|
||||
"name": "solana_mainnet_public",
|
||||
"enabled": true,
|
||||
"provider": "solana-public",
|
||||
"cluster": "mainnet-beta",
|
||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL:-https://api.mainnet-beta.solana.com}",
|
||||
"cluster": "mainnet",
|
||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL:-https://api.mainnet.solana.com}",
|
||||
"connect_timeout_ms": 5000,
|
||||
"request_timeout_ms": 15000,
|
||||
"max_idle_connections_per_host": 8,
|
||||
@@ -308,9 +308,9 @@
|
||||
"name": "solana_mainnet_public_ws",
|
||||
"enabled": true,
|
||||
"provider": "solana-public",
|
||||
"cluster": "mainnet-beta",
|
||||
"cluster": "mainnet",
|
||||
"kind": "solana_standard",
|
||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_WS_URL:-wss://api.mainnet-beta.solana.com}"
|
||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_WS_URL:-wss://api.mainnet.solana.com}"
|
||||
}
|
||||
],
|
||||
"grpc_endpoints": [
|
||||
@@ -318,7 +318,7 @@
|
||||
"name": "publicnode_solana_mainnet_yellowstone",
|
||||
"enabled": true,
|
||||
"provider": "publicnode",
|
||||
"cluster": "mainnet-beta",
|
||||
"cluster": "mainnet",
|
||||
"protocol": "solana_yellowstone",
|
||||
"url": "https://solana-yellowstone-grpc.publicnode.com:443",
|
||||
"secret_metadata": [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-backfill-desk/unit_tests/backfill_request.rs
|
||||
// version: 3
|
||||
// version: 4
|
||||
|
||||
fn ready_options() -> crate::BackfillDeskOptionsDto {
|
||||
let limits = crate::backfill_request_limits();
|
||||
@@ -22,7 +22,7 @@ fn ready_options() -> crate::BackfillDeskOptionsDto {
|
||||
return crate::BackfillDeskOptionsDto {
|
||||
commitments: crate::backfill_commitment_codes(),
|
||||
composition_ready: true,
|
||||
configured_networks: vec!["mainnet-beta".to_owned()],
|
||||
configured_networks: vec!["mainnet".to_owned()],
|
||||
http_routes: vec![
|
||||
crate::BackfillHttpRouteOptionDto {
|
||||
pooled: true,
|
||||
@@ -36,7 +36,7 @@ fn ready_options() -> crate::BackfillDeskOptionsDto {
|
||||
program_id_options: crate::program_id_autocomplete_options(),
|
||||
scope_kinds: crate::backfill_scope_kind_codes(),
|
||||
store_diagnostic: std::option::Option::None,
|
||||
store_network: std::option::Option::Some("mainnet-beta".to_owned()),
|
||||
store_network: std::option::Option::Some("mainnet".to_owned()),
|
||||
store_ready: true,
|
||||
transport_diagnostic: std::option::Option::None,
|
||||
transport_ready: true,
|
||||
@@ -81,7 +81,7 @@ fn latest_address_maps_to_backend_network_role_commitment_and_bounds() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(mapped.network().as_str(), "mainnet-beta");
|
||||
assert_eq!(mapped.network().as_str(), "mainnet");
|
||||
assert_eq!(mapped.role().as_str(), "backfill_pool");
|
||||
assert_eq!(mapped.commitment(), ksp_job_backfill_lib::BackfillCommitment::Finalized);
|
||||
assert_eq!(mapped.scope().kind(), ksp_job_backfill_lib::BackfillScopeKind::LatestAddress);
|
||||
@@ -193,7 +193,7 @@ fn request_preview_never_contains_address_or_signature_values() {
|
||||
if let std::result::Result::Ok(serialized) = serialized {
|
||||
assert!(!serialized.contains("11111111111111111111111111111111"));
|
||||
assert!(!serialized.contains(&"1".repeat(64)));
|
||||
assert!(serialized.contains("mainnet-beta"));
|
||||
assert!(serialized.contains("mainnet"));
|
||||
assert!(serialized.contains("backfill_pool"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-backfill-desk/unit_tests/backfill_run.rs
|
||||
// version: 3
|
||||
// version: 4
|
||||
|
||||
#[test]
|
||||
fn generated_run_ids_are_backend_owned_bounded_and_unique_in_session() {
|
||||
@@ -30,7 +30,7 @@ fn cancellable_runtime(
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
let network = match ksp_store_lib::RawNetworkId::new("mainnet-beta") {
|
||||
let network = match ksp_store_lib::RawNetworkId::new("mainnet") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-backfill-desk/unit_tests/backfill_status.rs
|
||||
// version: 1
|
||||
// version: 2
|
||||
|
||||
fn initial_notification() -> std::option::Option<ksp_job_api::JobNotification<ksp_job_backfill_lib::BackfillJobSnapshot>> {
|
||||
let signature = match ksp_job_backfill_lib::BackfillSignature::new("1".repeat(ksp_job_backfill_lib::MIN_BACKFILL_SIGNATURE_TEXT_BYTES)) {
|
||||
@@ -14,7 +14,7 @@ fn initial_notification() -> std::option::Option<ksp_job_api::JobNotification<ks
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
let network = match ksp_store_lib::RawNetworkId::new("mainnet-beta") {
|
||||
let network = match ksp_store_lib::RawNetworkId::new("mainnet") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-backfill-desk/unit_tests/store_runtime.rs
|
||||
// version: 1
|
||||
// version: 2
|
||||
|
||||
#[test]
|
||||
fn store_health_mapping_is_explicit_and_conservative() {
|
||||
@@ -10,7 +10,7 @@ fn store_health_mapping_is_explicit_and_conservative() {
|
||||
#[test]
|
||||
fn store_transport_network_coherence_requires_exact_logical_identity() {
|
||||
assert!(super::validate_network_coherence("devnet", "devnet").is_ok());
|
||||
let mismatch = super::validate_network_coherence("mainnet-beta", "devnet");
|
||||
let mismatch = super::validate_network_coherence("mainnet", "devnet");
|
||||
assert!(mismatch.is_err());
|
||||
if let std::result::Result::Err(error) = mismatch {
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_STORE_NETWORK_MISMATCH);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-store-desk/unit_tests/dto_common.rs
|
||||
// version: 2
|
||||
// version: 3
|
||||
|
||||
#[test]
|
||||
fn command_error_projection_keeps_only_stable_public_fields() {
|
||||
@@ -19,7 +19,7 @@ fn store_runtime_status_dto_contains_only_safe_portable_fields() {
|
||||
diagnostic: std::option::Option::None,
|
||||
health_state: "ready".to_owned(),
|
||||
migration_version_decimal: std::option::Option::Some("2".to_owned()),
|
||||
network: std::option::Option::Some("mainnet-beta".to_owned()),
|
||||
network: std::option::Option::Some("mainnet".to_owned()),
|
||||
pending_migration_count: 0,
|
||||
pool_available: 1,
|
||||
pool_capacity: 8,
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"tracing:default",
|
||||
"dialog:default"
|
||||
"tracing:default"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"@fltsci/tauri-plugin-tracing": "^0.3",
|
||||
"@fortawesome/fontawesome-free": "^7.3",
|
||||
"@tauri-apps/api": "^2.11",
|
||||
"@tauri-apps/plugin-dialog": "^2.7",
|
||||
"bootstrap": "^5.3",
|
||||
"datatables.net-bs5": "^3.0",
|
||||
"datatables.net-select-bs5": "^4.0",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: crates/ksp-config-lib/USAGE.md -->
|
||||
<!-- version: 14 -->
|
||||
<!-- version: 15 -->
|
||||
|
||||
# Utilisation de ksp-config-lib
|
||||
|
||||
@@ -250,7 +250,7 @@ Targets committed :
|
||||
|
||||
```text
|
||||
devnet -> network devnet -> KSP_SECRET_STORE_DEVNET_POSTGRES_URI
|
||||
mainnet -> network mainnet-beta -> KSP_SECRET_STORE_MAINNET_POSTGRES_URI
|
||||
mainnet -> network mainnet -> KSP_SECRET_STORE_MAINNET_POSTGRES_URI
|
||||
testnet -> network testnet -> KSP_SECRET_STORE_TESTNET_POSTGRES_URI
|
||||
```
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
{
|
||||
"profile_id": "mainnet",
|
||||
"network": "mainnet-beta",
|
||||
"network": "mainnet",
|
||||
"backend": "postgres",
|
||||
"postgres": {
|
||||
"connection_uri": "${KSP_SECRET_STORE_MAINNET_POSTGRES_URI:-postgresql://localhost/ksp_mainnet}",
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
"name": "fixture_helius_ws",
|
||||
"enabled": true,
|
||||
"provider": "helius",
|
||||
"cluster": "mainnet-beta",
|
||||
"cluster": "mainnet",
|
||||
"kind": "helius_laserstream",
|
||||
"url": "wss://mainnet.helius-rpc.com/?api-key=${KSP_SECRET_HELIUS_API_KEY:-fixture-helius-key}"
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"name": "fixture_http",
|
||||
"enabled": true,
|
||||
"provider": "fixture-provider",
|
||||
"cluster": "mainnet-beta",
|
||||
"cluster": "mainnet",
|
||||
"url": "https://fallback.invalid",
|
||||
"connect_timeout_ms": 1000,
|
||||
"request_timeout_ms": 3000,
|
||||
@@ -73,7 +73,7 @@
|
||||
"name": "fixture_ws",
|
||||
"enabled": true,
|
||||
"provider": "fixture-provider",
|
||||
"cluster": "mainnet-beta",
|
||||
"cluster": "mainnet",
|
||||
"kind": "solana_standard",
|
||||
"url": "wss://fallback.invalid"
|
||||
}
|
||||
@@ -83,7 +83,7 @@
|
||||
"name": "fixture_grpc",
|
||||
"enabled": true,
|
||||
"provider": "fixture-provider",
|
||||
"cluster": "mainnet-beta",
|
||||
"cluster": "mainnet",
|
||||
"protocol": "solana_yellowstone",
|
||||
"url": "https://grpc.invalid",
|
||||
"metadata": [
|
||||
@@ -115,7 +115,7 @@
|
||||
"name": "fixture_http",
|
||||
"enabled": true,
|
||||
"provider": "fixture-provider",
|
||||
"cluster": "mainnet-beta",
|
||||
"cluster": "mainnet",
|
||||
"url": "https://fallback.invalid",
|
||||
"connect_timeout_ms": 1000,
|
||||
"request_timeout_ms": 3000,
|
||||
@@ -143,7 +143,7 @@
|
||||
"name": "fixture_ws",
|
||||
"enabled": true,
|
||||
"provider": "fixture-provider",
|
||||
"cluster": "mainnet-beta",
|
||||
"cluster": "mainnet",
|
||||
"kind": "solana_standard",
|
||||
"url": "wss://fallback.invalid"
|
||||
}
|
||||
@@ -153,7 +153,7 @@
|
||||
"name": "fixture_grpc",
|
||||
"enabled": true,
|
||||
"provider": "fixture-provider",
|
||||
"cluster": "mainnet-beta",
|
||||
"cluster": "mainnet",
|
||||
"protocol": "solana_yellowstone",
|
||||
"url": "https://grpc.invalid",
|
||||
"metadata": [
|
||||
@@ -180,7 +180,7 @@
|
||||
"name": "fixture_http",
|
||||
"enabled": true,
|
||||
"provider": "fixture-provider",
|
||||
"cluster": "mainnet-beta",
|
||||
"cluster": "mainnet",
|
||||
"url": "https://fallback.invalid",
|
||||
"connect_timeout_ms": 1000,
|
||||
"request_timeout_ms": 3000,
|
||||
@@ -208,7 +208,7 @@
|
||||
"name": "fixture_ws",
|
||||
"enabled": true,
|
||||
"provider": "fixture-provider",
|
||||
"cluster": "mainnet-beta",
|
||||
"cluster": "mainnet",
|
||||
"kind": "solana_standard",
|
||||
"url": "wss://fallback.invalid"
|
||||
}
|
||||
@@ -218,7 +218,7 @@
|
||||
"name": "fixture_grpc",
|
||||
"enabled": true,
|
||||
"provider": "fixture-provider",
|
||||
"cluster": "mainnet-beta",
|
||||
"cluster": "mainnet",
|
||||
"protocol": "solana_yellowstone",
|
||||
"url": "https://grpc.invalid",
|
||||
"metadata": [],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-config-lib/unit_tests/store.rs
|
||||
// version: 4
|
||||
// version: 5
|
||||
|
||||
#[test]
|
||||
fn committed_store_profile_maps_exact_runtime_settings_and_secret_fallback() {
|
||||
@@ -122,7 +122,7 @@ fn named_store_targets_select_one_network_and_database_without_runtime_multiplex
|
||||
let environment = crate::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
|
||||
for (target_id, network, expected_uri) in [
|
||||
("devnet", "devnet", "postgresql://devnet.invalid/ksp_devnet"),
|
||||
("mainnet", "mainnet-beta", "postgresql://mainnet.invalid/ksp_mainnet"),
|
||||
("mainnet", "mainnet", "postgresql://mainnet.invalid/ksp_mainnet"),
|
||||
("testnet", "testnet", "postgresql://testnet.invalid/ksp_testnet"),
|
||||
] {
|
||||
let resolved = engine.load_resolved_store_config(std::option::Option::Some(target_id), &environment);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-config-lib/unit_tests/transport.rs
|
||||
// version: 12
|
||||
// version: 13
|
||||
|
||||
#[test]
|
||||
fn fixture_transport_profile_maps_complete_runtime_contract() {
|
||||
@@ -68,7 +68,7 @@ fn fixture_transport_profile_maps_complete_runtime_contract() {
|
||||
let helius = &ws.endpoints()[1];
|
||||
assert_eq!(helius.name(), "fixture_helius_ws");
|
||||
assert_eq!(helius.provider().as_str(), "helius");
|
||||
assert_eq!(helius.cluster().as_str(), "mainnet-beta");
|
||||
assert_eq!(helius.cluster().as_str(), "mainnet");
|
||||
assert_eq!(helius.protocol(), ksp_onchain_transport_lib::WsProtocolKind::HeliusLaserStream);
|
||||
assert_eq!(helius.url().as_str(), "wss://mainnet.helius-rpc.com/?api-key=fixture-helius-key");
|
||||
let _connect_future = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::connect(helius.clone());
|
||||
@@ -122,13 +122,13 @@ fn committed_transport_document_maps_default_and_explicit_profiles() {
|
||||
if let std::result::Result::Ok(mainnet) = mainnet {
|
||||
assert_eq!(mainnet.profile_id(), "mainnet_public");
|
||||
assert_eq!(mainnet.selection_source(), crate::ConfigProfileSelectionSource::Explicit);
|
||||
assert_eq!(mainnet.settings().endpoints()[0].cluster().as_str(), "mainnet-beta");
|
||||
assert_eq!(mainnet.settings().endpoints()[0].url().as_str(), "https://api.mainnet-beta.solana.com");
|
||||
assert_eq!(mainnet.settings().endpoints()[0].cluster().as_str(), "mainnet");
|
||||
assert_eq!(mainnet.settings().endpoints()[0].url().as_str(), "https://api.mainnet.solana.com");
|
||||
assert!(mainnet.grpc_settings().is_none(), "generic Mainnet profile must stay distinct from the PublicNode gRPC profile");
|
||||
let ws = mainnet.ws_settings();
|
||||
assert!(ws.is_some(), "committed V3 Mainnet profile should expose WebSocket settings");
|
||||
if let std::option::Option::Some(ws) = ws {
|
||||
assert_eq!(ws.endpoints()[0].url().as_str(), "wss://api.mainnet-beta.solana.com");
|
||||
assert_eq!(ws.endpoints()[0].url().as_str(), "wss://api.mainnet.solana.com");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,7 +190,7 @@ fn committed_v3_publicnode_profiles_map_provider_neutral_yellowstone_grpc_with_n
|
||||
(
|
||||
"publicnode_mainnet",
|
||||
"publicnode_solana_mainnet_yellowstone",
|
||||
"mainnet-beta",
|
||||
"mainnet",
|
||||
"https://solana-yellowstone-grpc.publicnode.com:443",
|
||||
"KSP_SECRET_PUBLICNODE_MAINNET_GRPC_X_TOKEN",
|
||||
"PUBLICNODE-MAINNET-GRPC-X-TOKEN-CANARY",
|
||||
@@ -480,7 +480,7 @@ fn helius_laserstream_mainnet_and_devnet_api_key_map_to_protocol_and_safe_redact
|
||||
assert_eq!(ws.endpoints().len(), 2);
|
||||
let endpoint = &ws.endpoints()[1];
|
||||
assert_eq!(endpoint.provider().as_str(), "helius");
|
||||
assert_eq!(endpoint.cluster().as_str(), "mainnet-beta");
|
||||
assert_eq!(endpoint.cluster().as_str(), "mainnet");
|
||||
assert_eq!(endpoint.protocol(), ksp_onchain_transport_lib::WsProtocolKind::HeliusLaserStream);
|
||||
assert_eq!(endpoint.url().as_str(), "wss://mainnet.helius-rpc.com/?api-key=helius-api-key-canary");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# file: crates/ksp-job-backfill-lib/Cargo.toml
|
||||
# version: 4
|
||||
# version: 7
|
||||
|
||||
[package]
|
||||
name = "ksp-job-backfill-lib"
|
||||
@@ -13,6 +13,7 @@ ksp-core-lib = { path = "../ksp-core-lib" }
|
||||
ksp-job-api = { path = "../ksp-job-api" }
|
||||
ksp-logging-lib = { path = "../ksp-logging-lib" }
|
||||
ksp-onchain-transport-lib = { path = "../ksp-onchain-transport-lib" }
|
||||
ksp-raw-transaction-lib = { path = "../ksp-raw-transaction-lib" }
|
||||
ksp-store-lib = { path = "../ksp-store-lib", default-features = false }
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
@@ -20,6 +21,9 @@ tokio = { workspace = true, features = ["macros", "sync"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
tokio-tungstenite = { workspace = true, features = ["handshake"] }
|
||||
tonic = { workspace = true, features = ["codegen", "server"] }
|
||||
yellowstone-grpc-proto = { workspace = true, features = ["tonic"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<!-- file: crates/ksp-job-backfill-lib/README.md -->
|
||||
<!-- version: 1 -->
|
||||
<!-- version: 2 -->
|
||||
|
||||
# ksp-job-backfill-lib
|
||||
|
||||
`ksp-job-backfill-lib` implémente le premier job historique concret de KSP : un backfill borné de transactions Solana vers la couche RAW durable.
|
||||
|
||||
La crate compose les contrats Job, Transport observé et Store backend-neutral sans posséder leurs politiques internes. Elle couvre l'admission, la découverte historique, l'hydratation `getTransaction`, la conversion RAW v1, la persistance atomique, la concurrence bornée, la frontier contiguë, le checkpoint caller-owned, l'annulation coopérative et les snapshots latest-value.
|
||||
La crate compose les contrats Job, Transport observé, common RAW et Store backend-neutral sans posséder leurs politiques internes. Elle couvre l'admission, la découverte historique, l'hydratation `getTransaction`, l'adaptation vers `ksp-raw-transaction-lib`, la persistance atomique, la concurrence bornée, la frontier contiguë, le checkpoint caller-owned, l'annulation coopérative et les snapshots latest-value.
|
||||
|
||||
## Identité
|
||||
|
||||
@@ -94,6 +94,7 @@ ksp-core-lib
|
||||
ksp-job-api
|
||||
ksp-logging-lib
|
||||
ksp-onchain-transport-lib
|
||||
ksp-raw-transaction-lib
|
||||
ksp-store-lib (default-features = false)
|
||||
futures-util
|
||||
serde_json
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
// file: crates/ksp-job-backfill-lib/src/conversion.rs
|
||||
// version: 4
|
||||
// version: 5
|
||||
|
||||
use sha2::Digest; // rust-rules: trait-import
|
||||
|
||||
/// KSP-owned source-independent RAW transaction format identifier produced by this Backfill vertical.
|
||||
pub const RAW_TRANSACTION_FORMAT_ID: &str = "ksp.solana.raw_transaction";
|
||||
/// Initial KSP-owned RAW transaction format version produced by this Backfill vertical.
|
||||
pub const RAW_TRANSACTION_FORMAT_VERSION: u32 = 1;
|
||||
|
||||
const RAW_TRANSACTION_METHOD_CODE: &str = "getTransaction";
|
||||
const RAW_TRANSACTION_OBSERVATION_CONTRACT_VERSION: u32 = 1;
|
||||
const RAW_TRANSACTION_PROTOCOL_CODE: &str = "solana.http.json_rpc";
|
||||
|
||||
/// Complete in-memory RAW transaction acquisition ready for the later Store persistence tranche.
|
||||
/// Complete in-memory RAW transaction acquisition ready for Store persistence.
|
||||
#[derive(Debug)]
|
||||
pub struct BackfillRawAcquisition {
|
||||
inner: Box<BackfillRawAcquisitionInner>,
|
||||
@@ -20,35 +15,34 @@ pub struct BackfillRawAcquisition {
|
||||
|
||||
#[derive(Debug)]
|
||||
struct BackfillRawAcquisitionInner {
|
||||
transaction: ksp_store_lib::RawTransaction,
|
||||
observation: ksp_store_lib::RawTransactionObservation,
|
||||
acquisition: ksp_raw_transaction_lib::RawTransactionAcquisition,
|
||||
}
|
||||
|
||||
impl crate::BackfillRawAcquisition {
|
||||
/// Returns the canonical RAW transaction produced from the typed Transport response.
|
||||
/// Returns the canonical RAW transaction produced through the source-neutral common layer.
|
||||
#[must_use]
|
||||
pub const fn transaction(&self) -> &ksp_store_lib::RawTransaction {
|
||||
return &self.inner.transaction;
|
||||
pub fn transaction(&self) -> &ksp_store_lib::RawTransaction {
|
||||
return self.inner.acquisition.transaction();
|
||||
}
|
||||
|
||||
/// Returns the acquisition observation whose provenance records the actual successful endpoint.
|
||||
/// Returns the Backfill-owned acquisition observation linked by the common assembly layer.
|
||||
#[must_use]
|
||||
pub const fn observation(&self) -> &ksp_store_lib::RawTransactionObservation {
|
||||
return &self.inner.observation;
|
||||
pub fn observation(&self) -> &ksp_store_lib::RawTransactionObservation {
|
||||
return self.inner.acquisition.observation();
|
||||
}
|
||||
|
||||
/// Consumes the in-memory acquisition into the canonical transaction and its observation.
|
||||
/// Consumes the in-memory acquisition into the canonical transaction and its Backfill-owned observation.
|
||||
#[must_use]
|
||||
pub fn into_parts(self) -> (ksp_store_lib::RawTransaction, ksp_store_lib::RawTransactionObservation) {
|
||||
let inner = *self.inner;
|
||||
return (inner.transaction, inner.observation);
|
||||
return inner.acquisition.into_parts();
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of hydrating one deterministic Backfill candidate through observed `getTransaction`.
|
||||
#[derive(Debug)]
|
||||
pub enum BackfillHydrationOutcome {
|
||||
/// The RPC returned one complete transaction and conversion produced canonical RAW plus provenance.
|
||||
/// The RPC returned one complete transaction and common conversion produced canonical RAW plus Backfill provenance.
|
||||
Available(crate::BackfillRawAcquisition),
|
||||
/// The RPC returned JSON `null`; only the canonical transaction identity exists and no provenance is fabricated.
|
||||
Missing(ksp_store_lib::RawTransactionReference),
|
||||
@@ -71,11 +65,12 @@ impl crate::BackfillHydrationOutcome {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hydrates one candidate with the typed observed Transport path and converts it to canonical RAW v1.
|
||||
/// Hydrates one candidate with the typed observed Transport path and converts it through the common RAW v1 layer.
|
||||
///
|
||||
/// The caller supplies the local receipt timestamp because wall-clock ownership remains outside this
|
||||
/// pure conversion tranche. Transport retains endpoint selection and retry. This function never
|
||||
/// persists to Store; persistence begins in `pre.007`.
|
||||
/// pure conversion path. Transport retains endpoint selection and retry. Backfill retains campaign
|
||||
/// provenance and observation-key ownership; the common crate owns signature parsing, RAW v1
|
||||
/// canonicalization, content hashing and transaction/observation assembly.
|
||||
pub async fn hydrate_backfill_candidate(
|
||||
transport: &ksp_onchain_transport_lib::HttpTransportPool,
|
||||
request: &crate::BackfillRequest,
|
||||
@@ -105,12 +100,12 @@ pub async fn hydrate_backfill_candidate(
|
||||
std::option::Option::None => return std::result::Result::Ok(crate::BackfillHydrationOutcome::Missing(reference)),
|
||||
};
|
||||
let fields = CanonicalTransactionFields {
|
||||
slot: transaction.slot(),
|
||||
block_time: transaction.block_time(),
|
||||
transaction: transaction.transaction(),
|
||||
meta: transaction.meta(),
|
||||
version: transaction.version(),
|
||||
slot: transaction.slot(),
|
||||
transaction: transaction.transaction(),
|
||||
transaction_index: transaction.transaction_index(),
|
||||
version: transaction.version(),
|
||||
};
|
||||
let acquisition = convert_available_fields(request, reference, fields, provider.as_str(), endpoint.as_str(), received_at);
|
||||
return match acquisition {
|
||||
@@ -119,49 +114,22 @@ pub async fn hydrate_backfill_candidate(
|
||||
};
|
||||
}
|
||||
|
||||
/// Decodes one validated Base58 signature to exactly 64 canonical bytes without a Solana SDK dependency.
|
||||
/// Decodes one validated Base58 signature through the source-neutral common parser while preserving Backfill error semantics.
|
||||
pub(crate) fn decode_backfill_signature(signature: &crate::BackfillSignature) -> ksp_core_lib::Result<ksp_store_lib::RawTransactionSignature> {
|
||||
let text = signature.as_str().as_bytes();
|
||||
let mut decoded = [0_u8; 64];
|
||||
let mut leading_zeroes = 0_usize;
|
||||
for byte in text {
|
||||
if *byte != b'1' {
|
||||
break;
|
||||
}
|
||||
leading_zeroes += 1;
|
||||
}
|
||||
for byte in text {
|
||||
let digit = match base58_digit(*byte) {
|
||||
std::option::Option::Some(digit) => digit,
|
||||
std::option::Option::None => return std::result::Result::Err(conversion_error("signature")),
|
||||
};
|
||||
let mut carry = u32::from(digit);
|
||||
for output in decoded.iter_mut().rev() {
|
||||
let value = (u32::from(*output) * 58) + carry;
|
||||
*output = (value & 0xff) as u8;
|
||||
carry = value >> 8;
|
||||
}
|
||||
if carry != 0 {
|
||||
return std::result::Result::Err(conversion_error("signature"));
|
||||
}
|
||||
}
|
||||
let significant_len = match decoded.iter().position(|byte| return *byte != 0) {
|
||||
std::option::Option::Some(index) => decoded.len() - index,
|
||||
std::option::Option::None => 0,
|
||||
let parsed = ksp_raw_transaction_lib::parse_raw_transaction_signature(signature.as_str());
|
||||
return match parsed {
|
||||
std::result::Result::Ok(signature) => std::result::Result::Ok(signature),
|
||||
std::result::Result::Err(_) => std::result::Result::Err(conversion_error("signature")),
|
||||
};
|
||||
if leading_zeroes + significant_len != decoded.len() {
|
||||
return std::result::Result::Err(conversion_error("signature"));
|
||||
}
|
||||
return std::result::Result::Ok(ksp_store_lib::RawTransactionSignature::new(decoded));
|
||||
}
|
||||
|
||||
struct CanonicalTransactionFields<'a> {
|
||||
slot: u64,
|
||||
block_time: std::option::Option<i64>,
|
||||
transaction: &'a ksp_onchain_transport_lib::SolanaEncodedTransaction,
|
||||
meta: &'a ksp_onchain_transport_lib::SolanaWireField<serde_json::Value>,
|
||||
version: &'a ksp_onchain_transport_lib::SolanaWireField<ksp_onchain_transport_lib::SolanaTransactionVersion>,
|
||||
slot: u64,
|
||||
transaction: &'a ksp_onchain_transport_lib::SolanaEncodedTransaction,
|
||||
transaction_index: &'a ksp_onchain_transport_lib::SolanaWireField<u32>,
|
||||
version: &'a ksp_onchain_transport_lib::SolanaWireField<ksp_onchain_transport_lib::SolanaTransactionVersion>,
|
||||
}
|
||||
|
||||
fn canonical_reference(request: &crate::BackfillRequest, candidate: &crate::BackfillCandidate) -> ksp_core_lib::Result<ksp_store_lib::RawTransactionReference> {
|
||||
@@ -184,30 +152,9 @@ fn convert_available_fields(
|
||||
endpoint: &str,
|
||||
received_at: ksp_store_lib::RawTimestamp,
|
||||
) -> ksp_core_lib::Result<crate::BackfillRawAcquisition> {
|
||||
let block_time = convert_block_time(fields.block_time);
|
||||
let block_time = match block_time {
|
||||
std::result::Result::Ok(block_time) => block_time,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let bytes = canonical_payload_bytes(&fields);
|
||||
let bytes = match bytes {
|
||||
std::result::Result::Ok(bytes) => bytes,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let hash: [u8; 32] = sha2::Sha256::digest(bytes.as_slice()).into();
|
||||
let format_id = ksp_store_lib::RawFormatId::new(crate::RAW_TRANSACTION_FORMAT_ID);
|
||||
let format_id = match format_id {
|
||||
std::result::Result::Ok(format_id) => format_id,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(conversion_error("payload.format_id")),
|
||||
};
|
||||
let payload = ksp_store_lib::RawPayload::try_new(
|
||||
format_id,
|
||||
crate::RAW_TRANSACTION_FORMAT_VERSION,
|
||||
bytes.into_boxed_slice(),
|
||||
ksp_store_lib::RawContentHash::new(hash),
|
||||
);
|
||||
let payload = match payload {
|
||||
std::result::Result::Ok(payload) => payload,
|
||||
let transaction = canonical_transaction(&reference, fields);
|
||||
let transaction = match transaction {
|
||||
std::result::Result::Ok(transaction) => transaction,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let provenance = build_provenance(request, provider, endpoint, received_at);
|
||||
@@ -215,182 +162,60 @@ fn convert_available_fields(
|
||||
std::result::Result::Ok(provenance) => provenance,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let observation_key = observation_key(request, &reference, provider, endpoint);
|
||||
let transaction = ksp_store_lib::RawTransaction::new(reference.clone(), fields.slot, block_time, payload);
|
||||
let observation = ksp_store_lib::RawTransactionObservation::new(observation_key, reference, provenance);
|
||||
return std::result::Result::Ok(crate::BackfillRawAcquisition { inner: Box::new(BackfillRawAcquisitionInner { transaction, observation }) });
|
||||
let observation_key = observation_key(request, transaction.reference(), provider, endpoint);
|
||||
let acquisition = ksp_raw_transaction_lib::assemble_raw_transaction_acquisition(transaction, observation_key, provenance);
|
||||
return std::result::Result::Ok(crate::BackfillRawAcquisition { inner: Box::new(BackfillRawAcquisitionInner { acquisition }) });
|
||||
}
|
||||
|
||||
fn convert_block_time(value: std::option::Option<i64>) -> ksp_core_lib::Result<std::option::Option<ksp_store_lib::RawTimestamp>> {
|
||||
let seconds = match value {
|
||||
std::option::Option::Some(seconds) => seconds,
|
||||
std::option::Option::None => return std::result::Result::Ok(std::option::Option::None),
|
||||
};
|
||||
let seconds = match u64::try_from(seconds) {
|
||||
std::result::Result::Ok(seconds) => seconds,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(conversion_error("block_time")),
|
||||
};
|
||||
let millis = match seconds.checked_mul(1_000) {
|
||||
std::option::Option::Some(millis) => millis,
|
||||
std::option::Option::None => return std::result::Result::Err(conversion_error("block_time")),
|
||||
};
|
||||
let timestamp = ksp_store_lib::RawTimestamp::from_unix_millis(millis);
|
||||
return match timestamp {
|
||||
std::result::Result::Ok(timestamp) => std::result::Result::Ok(std::option::Option::Some(timestamp)),
|
||||
std::result::Result::Err(_) => std::result::Result::Err(conversion_error("block_time")),
|
||||
};
|
||||
}
|
||||
|
||||
fn canonical_payload_bytes(fields: &CanonicalTransactionFields<'_>) -> ksp_core_lib::Result<std::vec::Vec<u8>> {
|
||||
let (transaction_data, transaction_encoding) = match fields.transaction {
|
||||
ksp_onchain_transport_lib::SolanaEncodedTransaction::Binary { data, encoding } => {
|
||||
if *encoding != ksp_onchain_transport_lib::SolanaTransactionBinaryEncoding::Base64 {
|
||||
return std::result::Result::Err(conversion_error("transaction.encoding"));
|
||||
}
|
||||
(data.as_str(), "base64")
|
||||
fn canonical_transaction(
|
||||
reference: &ksp_store_lib::RawTransactionReference,
|
||||
fields: CanonicalTransactionFields<'_>,
|
||||
) -> ksp_core_lib::Result<ksp_store_lib::RawTransaction> {
|
||||
let transaction_data = match fields.transaction {
|
||||
ksp_onchain_transport_lib::SolanaEncodedTransaction::Binary { data, encoding }
|
||||
if *encoding == ksp_onchain_transport_lib::SolanaTransactionBinaryEncoding::Base64 =>
|
||||
{
|
||||
data.clone()
|
||||
},
|
||||
ksp_onchain_transport_lib::SolanaEncodedTransaction::LegacyBinary(_) | ksp_onchain_transport_lib::SolanaEncodedTransaction::Json(_) => {
|
||||
ksp_onchain_transport_lib::SolanaEncodedTransaction::Binary { .. }
|
||||
| ksp_onchain_transport_lib::SolanaEncodedTransaction::LegacyBinary(_)
|
||||
| ksp_onchain_transport_lib::SolanaEncodedTransaction::Json(_) => {
|
||||
return std::result::Result::Err(conversion_error("transaction.encoding"));
|
||||
},
|
||||
};
|
||||
let mut bytes = std::vec::Vec::new();
|
||||
bytes.extend_from_slice(b"{\"transaction\":[");
|
||||
if let std::result::Result::Err(error) = append_json_string(&mut bytes, transaction_data) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
bytes.push(b',');
|
||||
if let std::result::Result::Err(error) = append_json_string(&mut bytes, transaction_encoding) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
bytes.push(b']');
|
||||
if let std::result::Result::Err(error) = append_wire_value(&mut bytes, "meta", fields.meta, |output, value| return append_canonical_json(output, value)) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let version_result = append_wire_value(&mut bytes, "version", fields.version, |output, value| {
|
||||
let meta = map_wire_field(fields.meta, |value| return value.clone());
|
||||
let version = map_wire_field(fields.version, |value| {
|
||||
return match value {
|
||||
ksp_onchain_transport_lib::SolanaTransactionVersion::Legacy => append_json_string(output, "legacy"),
|
||||
ksp_onchain_transport_lib::SolanaTransactionVersion::Number(number) => {
|
||||
output.extend_from_slice(number.to_string().as_bytes());
|
||||
std::result::Result::Ok(())
|
||||
},
|
||||
ksp_onchain_transport_lib::SolanaTransactionVersion::Legacy => ksp_raw_transaction_lib::RawTransactionVersion::Legacy,
|
||||
ksp_onchain_transport_lib::SolanaTransactionVersion::Number(number) => ksp_raw_transaction_lib::RawTransactionVersion::Number(*number),
|
||||
};
|
||||
});
|
||||
if let std::result::Result::Err(error) = version_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let transaction_index_result = append_wire_value(&mut bytes, "transactionIndex", fields.transaction_index, |output, value| {
|
||||
output.extend_from_slice(value.to_string().as_bytes());
|
||||
return std::result::Result::Ok(());
|
||||
});
|
||||
if let std::result::Result::Err(error) = transaction_index_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
bytes.push(b'}');
|
||||
return std::result::Result::Ok(bytes);
|
||||
let transaction_index = map_wire_field(fields.transaction_index, |value| return *value);
|
||||
let material = ksp_raw_transaction_lib::RawTransactionMaterial::binary_base64(
|
||||
reference.network().clone(),
|
||||
reference.signature(),
|
||||
fields.slot,
|
||||
fields.block_time,
|
||||
transaction_data,
|
||||
meta,
|
||||
version,
|
||||
transaction_index,
|
||||
);
|
||||
let canonical = ksp_raw_transaction_lib::canonicalize_raw_transaction(material);
|
||||
return match canonical {
|
||||
std::result::Result::Ok(transaction) => std::result::Result::Ok(transaction),
|
||||
std::result::Result::Err(_) => std::result::Result::Err(conversion_error("common.canonicalization")),
|
||||
};
|
||||
}
|
||||
|
||||
fn append_wire_value<T, F>(
|
||||
output: &mut std::vec::Vec<u8>,
|
||||
key: &str,
|
||||
field: &ksp_onchain_transport_lib::SolanaWireField<T>,
|
||||
mut append_value: F,
|
||||
) -> ksp_core_lib::Result<()>
|
||||
fn map_wire_field<T, U, F>(field: &ksp_onchain_transport_lib::SolanaWireField<T>, mut map_value: F) -> ksp_raw_transaction_lib::RawTransactionWireField<U>
|
||||
where
|
||||
F: FnMut(&mut std::vec::Vec<u8>, &T) -> ksp_core_lib::Result<()>,
|
||||
F: FnMut(&T) -> U,
|
||||
{
|
||||
return match field {
|
||||
ksp_onchain_transport_lib::SolanaWireField::Omitted => std::result::Result::Ok(()),
|
||||
ksp_onchain_transport_lib::SolanaWireField::Null => {
|
||||
output.push(b',');
|
||||
let key_result = append_json_string(output, key);
|
||||
if let std::result::Result::Err(error) = key_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
output.extend_from_slice(b":null");
|
||||
std::result::Result::Ok(())
|
||||
},
|
||||
ksp_onchain_transport_lib::SolanaWireField::Value(value) => {
|
||||
output.push(b',');
|
||||
let key_result = append_json_string(output, key);
|
||||
if let std::result::Result::Err(error) = key_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
output.push(b':');
|
||||
append_value(output, value)
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fn append_canonical_json(output: &mut std::vec::Vec<u8>, value: &serde_json::Value) -> ksp_core_lib::Result<()> {
|
||||
return match value {
|
||||
serde_json::Value::Null => {
|
||||
output.extend_from_slice(b"null");
|
||||
std::result::Result::Ok(())
|
||||
},
|
||||
serde_json::Value::Bool(value) => {
|
||||
if *value {
|
||||
output.extend_from_slice(b"true");
|
||||
} else {
|
||||
output.extend_from_slice(b"false");
|
||||
}
|
||||
std::result::Result::Ok(())
|
||||
},
|
||||
serde_json::Value::Number(value) => {
|
||||
output.extend_from_slice(value.to_string().as_bytes());
|
||||
std::result::Result::Ok(())
|
||||
},
|
||||
serde_json::Value::String(value) => append_json_string(output, value.as_str()),
|
||||
serde_json::Value::Array(values) => {
|
||||
output.push(b'[');
|
||||
for (index, item) in values.iter().enumerate() {
|
||||
if index != 0 {
|
||||
output.push(b',');
|
||||
}
|
||||
let item_result = append_canonical_json(output, item);
|
||||
if let std::result::Result::Err(error) = item_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
}
|
||||
output.push(b']');
|
||||
std::result::Result::Ok(())
|
||||
},
|
||||
serde_json::Value::Object(values) => {
|
||||
output.push(b'{');
|
||||
let mut keys = values.keys().collect::<std::vec::Vec<_>>();
|
||||
keys.sort_unstable();
|
||||
for (index, key) in keys.iter().enumerate() {
|
||||
if index != 0 {
|
||||
output.push(b',');
|
||||
}
|
||||
let key_result = append_json_string(output, key.as_str());
|
||||
if let std::result::Result::Err(error) = key_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
output.push(b':');
|
||||
let item = values.get(key.as_str());
|
||||
let item = match item {
|
||||
std::option::Option::Some(item) => item,
|
||||
std::option::Option::None => return std::result::Result::Err(conversion_error("payload.meta")),
|
||||
};
|
||||
let item_result = append_canonical_json(output, item);
|
||||
if let std::result::Result::Err(error) = item_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
}
|
||||
output.push(b'}');
|
||||
std::result::Result::Ok(())
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fn append_json_string(output: &mut std::vec::Vec<u8>, value: &str) -> ksp_core_lib::Result<()> {
|
||||
let encoded = serde_json::to_vec(value);
|
||||
return match encoded {
|
||||
std::result::Result::Ok(encoded) => {
|
||||
output.extend_from_slice(encoded.as_slice());
|
||||
std::result::Result::Ok(())
|
||||
},
|
||||
std::result::Result::Err(_) => std::result::Result::Err(conversion_error("payload.json")),
|
||||
ksp_onchain_transport_lib::SolanaWireField::Omitted => ksp_raw_transaction_lib::RawTransactionWireField::Omitted,
|
||||
ksp_onchain_transport_lib::SolanaWireField::Null => ksp_raw_transaction_lib::RawTransactionWireField::Null,
|
||||
ksp_onchain_transport_lib::SolanaWireField::Value(value) => ksp_raw_transaction_lib::RawTransactionWireField::Value(map_value(value)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -463,18 +288,6 @@ fn hash_bytes(hasher: &mut sha2::Sha256, value: &[u8]) {
|
||||
hasher.update(value);
|
||||
}
|
||||
|
||||
fn base58_digit(byte: u8) -> std::option::Option<u8> {
|
||||
return match byte {
|
||||
b'1'..=b'9' => std::option::Option::Some(byte - b'1'),
|
||||
b'A'..=b'H' => std::option::Option::Some((byte - b'A') + 9),
|
||||
b'J'..=b'N' => std::option::Option::Some((byte - b'J') + 17),
|
||||
b'P'..=b'Z' => std::option::Option::Some((byte - b'P') + 22),
|
||||
b'a'..=b'k' => std::option::Option::Some((byte - b'a') + 33),
|
||||
b'm'..=b'z' => std::option::Option::Some((byte - b'm') + 44),
|
||||
_ => std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
fn conversion_error(field: &'static str) -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_BACKFILL_RAW_CONVERSION_INVALID, "invalid deterministic Backfill RAW conversion")
|
||||
.with_context("field", field);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-job-backfill-lib/src/lib.rs
|
||||
// version: 6
|
||||
// version: 7
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
@@ -30,10 +30,6 @@ pub use self::checkpoint::BackfillCheckpoint;
|
||||
pub use self::conversion::BackfillHydrationOutcome;
|
||||
/// Complete in-memory RAW transaction acquisition ready for later Store persistence.
|
||||
pub use self::conversion::BackfillRawAcquisition;
|
||||
/// KSP-owned source-independent RAW transaction format identifier produced by this Backfill vertical.
|
||||
pub use self::conversion::RAW_TRANSACTION_FORMAT_ID;
|
||||
/// Initial KSP-owned RAW transaction format version produced by this Backfill vertical.
|
||||
pub use self::conversion::RAW_TRANSACTION_FORMAT_VERSION;
|
||||
/// Hydrates one candidate through observed Transport and converts a non-null response to canonical RAW v1.
|
||||
pub use self::conversion::hydrate_backfill_candidate;
|
||||
/// One deterministic transaction candidate produced by bounded discovery.
|
||||
@@ -112,6 +108,10 @@ pub use self::runtime::BackfillJobRuntime;
|
||||
pub use self::runtime::BackfillJobSnapshot;
|
||||
/// Cloneable runtime-neutral-facing latest-value source for concrete Backfill snapshots.
|
||||
pub use self::runtime::BackfillSnapshotSource;
|
||||
/// KSP-owned source-independent RAW transaction format identifier re-exported from the common RAW layer.
|
||||
pub use ksp_raw_transaction_lib::RAW_TRANSACTION_FORMAT_ID;
|
||||
/// Frozen RAW transaction format version re-exported from the common RAW layer.
|
||||
pub use ksp_raw_transaction_lib::RAW_TRANSACTION_FORMAT_VERSION;
|
||||
|
||||
/// Internal contiguous completion frontier used by bounded execution.
|
||||
pub(crate) use self::checkpoint::CompletionFrontier;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-job-backfill-lib/src/request.rs
|
||||
// version: 6
|
||||
// version: 7
|
||||
|
||||
use sha2::Digest; // rust-rules: trait-import
|
||||
|
||||
@@ -12,9 +12,9 @@ pub const MAX_BACKFILL_PAGES: usize = 10_000;
|
||||
/// Maximum page size admitted for one `getSignaturesForAddress` request.
|
||||
pub const MAX_BACKFILL_PAGE_SIZE: usize = 1_000;
|
||||
/// Maximum Base58 text length possible for one canonical 64-byte Solana signature.
|
||||
pub const MAX_BACKFILL_SIGNATURE_TEXT_BYTES: usize = 88;
|
||||
pub const MAX_BACKFILL_SIGNATURE_TEXT_BYTES: usize = ksp_raw_transaction_lib::MAX_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES;
|
||||
/// Minimum Base58 text length possible for one canonical 64-byte Solana signature.
|
||||
pub const MIN_BACKFILL_SIGNATURE_TEXT_BYTES: usize = 64;
|
||||
pub const MIN_BACKFILL_SIGNATURE_TEXT_BYTES: usize = ksp_raw_transaction_lib::MIN_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES;
|
||||
|
||||
/// Commitment levels intentionally admitted by the historical Backfill vertical.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
// file: crates/ksp-job-backfill-lib/tests/dependency_boundary.rs
|
||||
// version: 6
|
||||
// version: 9
|
||||
|
||||
//! Dependency firewall canaries through the concrete cancellation and latest-value runtime tranche.
|
||||
|
||||
#[test]
|
||||
fn pre_009_manifest_uses_only_planned_ksp_edges_and_private_tokio_runtime() {
|
||||
fn pre_003_manifest_adds_common_raw_edge_without_crossing_store_or_runtime_boundaries() {
|
||||
let manifest = include_str!("../Cargo.toml");
|
||||
let production_manifest = match manifest.split_once("[dev-dependencies]") {
|
||||
std::option::Option::Some((production, _)) => production,
|
||||
std::option::Option::None => manifest,
|
||||
};
|
||||
for required in [
|
||||
"futures-util = { workspace = true, features = [\"std\"] }",
|
||||
"ksp-core-lib = { path = \"../ksp-core-lib\" }",
|
||||
"ksp-job-api = { path = \"../ksp-job-api\" }",
|
||||
"ksp-logging-lib = { path = \"../ksp-logging-lib\" }",
|
||||
"ksp-onchain-transport-lib = { path = \"../ksp-onchain-transport-lib\" }",
|
||||
"ksp-raw-transaction-lib = { path = \"../ksp-raw-transaction-lib\" }",
|
||||
"ksp-store-lib = { path = \"../ksp-store-lib\", default-features = false }",
|
||||
"serde_json.workspace = true",
|
||||
"sha2.workspace = true",
|
||||
"tokio = { workspace = true, features = [\"macros\", \"sync\"] }",
|
||||
] {
|
||||
assert!(manifest.contains(required), "required Backfill dependency missing: {required}");
|
||||
assert!(production_manifest.contains(required), "required Backfill dependency missing: {required}");
|
||||
}
|
||||
for forbidden in [
|
||||
"ksp-config-lib",
|
||||
@@ -32,13 +37,56 @@ fn pre_009_manifest_uses_only_planned_ksp_edges_and_private_tokio_runtime() {
|
||||
"serde = {",
|
||||
"tonic",
|
||||
] {
|
||||
assert!(!manifest.contains(forbidden), "forbidden Backfill dependency present: {forbidden}");
|
||||
assert!(!production_manifest.contains(forbidden), "forbidden Backfill dependency present: {forbidden}");
|
||||
}
|
||||
let root = include_str!("../src/lib.rs");
|
||||
assert!(!root.contains("tokio::"), "Tokio implementation types must not leak through the public crate root");
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_005_websocket_parity_dependency_stays_test_only() {
|
||||
let manifest = include_str!("../Cargo.toml");
|
||||
let parts = manifest.splitn(2, "[dev-dependencies]").collect::<std::vec::Vec<_>>();
|
||||
let (dependencies, dev_dependencies) = match parts.as_slice() {
|
||||
[dependencies, dev_dependencies] => (*dependencies, *dev_dependencies),
|
||||
_ => {
|
||||
assert_eq!(parts.len(), 2, "Backfill manifest must retain exactly one dev-dependencies section");
|
||||
return;
|
||||
},
|
||||
};
|
||||
assert!(!dependencies.contains("tokio-tungstenite"), "WebSocket parity client must not become a production Backfill dependency");
|
||||
assert!(
|
||||
dev_dependencies.contains("tokio-tungstenite = { workspace = true, features = [\"handshake\"] }"),
|
||||
"WebSocket parity client must remain an explicit test-only dependency",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_006_yellowstone_parity_dependencies_stay_test_only() {
|
||||
let manifest = include_str!("../Cargo.toml");
|
||||
let parts = manifest.splitn(2, "[dev-dependencies]").collect::<std::vec::Vec<_>>();
|
||||
let (dependencies, dev_dependencies) = match parts.as_slice() {
|
||||
[dependencies, dev_dependencies] => (*dependencies, *dev_dependencies),
|
||||
_ => {
|
||||
assert_eq!(parts.len(), 2, "Backfill manifest must retain exactly one dev-dependencies section");
|
||||
return;
|
||||
},
|
||||
};
|
||||
assert!(!dependencies.contains("tonic"), "Yellowstone parity server must not become a production Backfill dependency");
|
||||
assert!(!dependencies.contains("yellowstone-grpc-proto"), "Yellowstone protobuf must not become a production Backfill dependency");
|
||||
assert!(
|
||||
dev_dependencies.contains("tonic = { workspace = true, features = [\"codegen\", \"server\"] }"),
|
||||
"Yellowstone parity server must remain an explicit test-only dependency",
|
||||
);
|
||||
assert!(
|
||||
dev_dependencies.contains("yellowstone-grpc-proto = { workspace = true, features = [\"tonic\"] }"),
|
||||
"Yellowstone protobuf must remain an explicit test-only dependency",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_009_production_sources_keep_transport_store_and_scheduler_ownership_separate() {
|
||||
let neutral_sources = [
|
||||
@@ -56,7 +104,12 @@ fn pre_009_production_sources_keep_transport_store_and_scheduler_ownership_separ
|
||||
}
|
||||
}
|
||||
let conversion = include_str!("../src/conversion.rs");
|
||||
assert!(conversion.contains("serde_json::"));
|
||||
assert!(conversion.contains("ksp_raw_transaction_lib::canonicalize_raw_transaction"));
|
||||
assert!(conversion.contains("ksp_raw_transaction_lib::assemble_raw_transaction_acquisition"));
|
||||
assert!(conversion.contains("ksp_raw_transaction_lib::parse_raw_transaction_signature"));
|
||||
assert!(!conversion.contains("fn append_canonical_json"));
|
||||
assert!(!conversion.contains("fn base58_digit"));
|
||||
assert!(!conversion.contains("RawPayload::try_new"));
|
||||
assert!(conversion.contains("get_transaction_observed"));
|
||||
assert!(conversion.contains("SolanaTransactionEncoding::Base64"));
|
||||
assert!(conversion.contains("std::option::Option::Some(0)"));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-job-backfill-lib/tests/hardening.rs
|
||||
// version: 4
|
||||
// version: 7
|
||||
|
||||
//! Adversarial, security, visibility and external-boundary hardening canaries for `pre.010`.
|
||||
|
||||
@@ -268,13 +268,14 @@ fn pre_010_manifest_dependency_surface_is_exact_and_backend_neutral() {
|
||||
"ksp-job-api",
|
||||
"ksp-logging-lib",
|
||||
"ksp-onchain-transport-lib",
|
||||
"ksp-raw-transaction-lib",
|
||||
"ksp-store-lib",
|
||||
"serde_json.workspace",
|
||||
"sha2.workspace",
|
||||
"tokio",
|
||||
])
|
||||
);
|
||||
assert_eq!(dev, std::collections::BTreeSet::from(["tokio"]));
|
||||
assert_eq!(dev, std::collections::BTreeSet::from(["tokio", "tokio-tungstenite", "tonic", "yellowstone-grpc-proto"]));
|
||||
assert!(manifest.contains("ksp-store-lib = { path = \"../ksp-store-lib\", default-features = false }"));
|
||||
assert!(manifest.contains("tokio = { workspace = true, features = [\"macros\", \"sync\"] }"));
|
||||
assert!(!manifest.contains("ksp-store-postgres-lib"));
|
||||
@@ -328,6 +329,7 @@ fn pre_010_lower_layers_have_no_dependency_return_to_job() {
|
||||
include_str!("../../ksp-core-lib/Cargo.toml"),
|
||||
include_str!("../../ksp-logging-lib/Cargo.toml"),
|
||||
include_str!("../../ksp-onchain-transport-lib/Cargo.toml"),
|
||||
include_str!("../../ksp-raw-transaction-lib/Cargo.toml"),
|
||||
include_str!("../../ksp-store-api/Cargo.toml"),
|
||||
include_str!("../../ksp-store-lib/Cargo.toml"),
|
||||
include_str!("../../ksp-store-postgres-lib/Cargo.toml"),
|
||||
|
||||
158
crates/ksp-job-backfill-lib/tests/http_block_material.rs
Normal file
158
crates/ksp-job-backfill-lib/tests/http_block_material.rs
Normal file
@@ -0,0 +1,158 @@
|
||||
// file: crates/ksp-job-backfill-lib/tests/http_block_material.rs
|
||||
// version: 2
|
||||
|
||||
//! Cross-layer canary for the future Worker-owned HTTP block adapter without changing production ownership.
|
||||
|
||||
fn pool_for_url(url: &str) -> ksp_core_lib::Result<ksp_onchain_transport_lib::HttpTransportPool> {
|
||||
let endpoint_url = match ksp_onchain_transport_lib::HttpEndpointUrl::parse(url) {
|
||||
Ok(endpoint_url) => endpoint_url,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let role = ksp_onchain_transport_lib::HttpEndpointRoleSettings::new(
|
||||
ksp_onchain_transport_lib::HttpRoleName::new("default"),
|
||||
true,
|
||||
std::vec![ksp_onchain_transport_lib::HttpRequestKind::wildcard()],
|
||||
10,
|
||||
ksp_onchain_transport_lib::HttpRoleLimits::new(
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
),
|
||||
);
|
||||
let endpoint = ksp_onchain_transport_lib::HttpEndpointSettings::new(
|
||||
"http-block-fixture",
|
||||
true,
|
||||
ksp_onchain_transport_lib::HttpProviderName::new("fixture-provider"),
|
||||
ksp_onchain_transport_lib::HttpClusterName::new("devnet"),
|
||||
endpoint_url,
|
||||
std::time::Duration::from_secs(1),
|
||||
std::time::Duration::from_secs(1),
|
||||
std::option::Option::Some(1),
|
||||
std::vec![role],
|
||||
);
|
||||
return ksp_onchain_transport_lib::HttpTransportPool::new(ksp_onchain_transport_lib::HttpTransportSettings::new(
|
||||
std::vec![endpoint],
|
||||
ksp_onchain_transport_lib::HttpRetrySettings::new(0, std::time::Duration::from_millis(1), std::time::Duration::from_millis(1)),
|
||||
));
|
||||
}
|
||||
|
||||
fn serve_once(body: &'static str) -> std::io::Result<(std::string::String, std::thread::JoinHandle<std::io::Result<()>>)> {
|
||||
let listener = match std::net::TcpListener::bind("127.0.0.1:0") {
|
||||
Ok(listener) => listener,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let address = match listener.local_addr() {
|
||||
Ok(address) => address,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let handle = std::thread::spawn(move || {
|
||||
let (mut stream, _) = match listener.accept() {
|
||||
Ok(accepted) => accepted,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let mut bytes = std::vec::Vec::new();
|
||||
let mut buffer = [0_u8; 1024];
|
||||
loop {
|
||||
let count = match std::io::Read::read(&mut stream, &mut buffer) {
|
||||
Ok(count) => count,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
bytes.extend_from_slice(&buffer[..count]);
|
||||
if bytes.windows(4).any(|window| return window == b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
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);
|
||||
if let Err(error) = std::io::Write::write_all(&mut stream, response.as_bytes()) {
|
||||
return Err(error);
|
||||
}
|
||||
return Ok(());
|
||||
});
|
||||
return Ok((format!("http://{address}"), handle));
|
||||
}
|
||||
|
||||
fn map_meta(field: &ksp_onchain_transport_lib::SolanaWireField<serde_json::Value>) -> ksp_raw_transaction_lib::RawTransactionWireField<serde_json::Value> {
|
||||
return match field {
|
||||
ksp_onchain_transport_lib::SolanaWireField::Omitted => ksp_raw_transaction_lib::RawTransactionWireField::Omitted,
|
||||
ksp_onchain_transport_lib::SolanaWireField::Null => ksp_raw_transaction_lib::RawTransactionWireField::Null,
|
||||
ksp_onchain_transport_lib::SolanaWireField::Value(value) => ksp_raw_transaction_lib::RawTransactionWireField::Value(value.clone()),
|
||||
};
|
||||
}
|
||||
|
||||
fn map_version(
|
||||
field: &ksp_onchain_transport_lib::SolanaWireField<ksp_onchain_transport_lib::SolanaTransactionVersion>,
|
||||
) -> ksp_raw_transaction_lib::RawTransactionWireField<ksp_raw_transaction_lib::RawTransactionVersion> {
|
||||
return match field {
|
||||
ksp_onchain_transport_lib::SolanaWireField::Omitted => ksp_raw_transaction_lib::RawTransactionWireField::Omitted,
|
||||
ksp_onchain_transport_lib::SolanaWireField::Null => ksp_raw_transaction_lib::RawTransactionWireField::Null,
|
||||
ksp_onchain_transport_lib::SolanaWireField::Value(ksp_onchain_transport_lib::SolanaTransactionVersion::Legacy) => {
|
||||
ksp_raw_transaction_lib::RawTransactionWireField::Value(ksp_raw_transaction_lib::RawTransactionVersion::Legacy)
|
||||
},
|
||||
ksp_onchain_transport_lib::SolanaWireField::Value(ksp_onchain_transport_lib::SolanaTransactionVersion::Number(number)) => {
|
||||
ksp_raw_transaction_lib::RawTransactionWireField::Value(ksp_raw_transaction_lib::RawTransactionVersion::Number(*number))
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn pre_004_http_observed_block_projects_each_base64_transaction_to_common_material_without_new_production_edge() {
|
||||
const BODY: &str = concat!(
|
||||
"{\"jsonrpc\":\"2.0\",\"result\":{\"previousBlockhash\":\"previous\",\"blockhash\":\"block\",\"parentSlot\":430000122,",
|
||||
"\"rewards\":[],\"numRewardPartitions\":0,\"blockTime\":1787072400,\"blockHeight\":410000000,",
|
||||
"\"transactions\":[{\"transaction\":[\"AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
"AAAAAA\",\"base64\"],\"meta\":{\"err\":null,\"fee\":5000},\"version\":\"legacy\"},",
|
||||
"{\"transaction\":[\"AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEA\",",
|
||||
"\"base64\"],\"meta\":{\"err\":null,\"fee\":6000},\"version\":0}]},\"id\":1}",
|
||||
);
|
||||
let (url, handle) = serve_once(BODY).expect("fixture server must start");
|
||||
let pool = pool_for_url(url.as_str()).expect("fixture pool must build");
|
||||
let config = ksp_onchain_transport_lib::SolanaGetBlockConfig::new(
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed),
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionEncoding::Base64),
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionDetails::Full),
|
||||
std::option::Option::Some(0),
|
||||
std::option::Option::Some(false),
|
||||
);
|
||||
let observed = pool
|
||||
.get_block_observed(&ksp_onchain_transport_lib::HttpRoleName::new("default"), 430_000_123, std::option::Option::Some(&config))
|
||||
.await
|
||||
.expect("observed block fixture must succeed");
|
||||
assert_eq!(observed.endpoint_name(), "http-block-fixture");
|
||||
assert_eq!(observed.provider().as_str(), "fixture-provider");
|
||||
let block = observed.value().as_ref().expect("fixture block must be present");
|
||||
let transactions = block.transactions().value().expect("fixture transactions must be present");
|
||||
assert_eq!(transactions.len(), 2);
|
||||
let network = ksp_store_lib::RawNetworkId::new("devnet").expect("fixture network must be valid");
|
||||
let expected_signatures = [[0_u8; 64], [1_u8; 64]];
|
||||
for (index, transaction) in transactions.iter().enumerate() {
|
||||
let (data, meta, version) = match transaction.transaction() {
|
||||
ksp_onchain_transport_lib::SolanaEncodedTransaction::Binary {
|
||||
data,
|
||||
encoding: ksp_onchain_transport_lib::SolanaTransactionBinaryEncoding::Base64,
|
||||
} => (data.as_str(), map_meta(transaction.meta()), map_version(transaction.version())),
|
||||
_ => panic!("fixture must remain full Base64 transaction material"),
|
||||
};
|
||||
let transaction_index = u32::try_from(index).expect("fixture transaction index must fit u32");
|
||||
let material = ksp_raw_transaction_lib::RawTransactionMaterial::binary_base64_with_embedded_signature(
|
||||
network.clone(),
|
||||
430_000_123,
|
||||
block.block_time(),
|
||||
data,
|
||||
meta,
|
||||
version,
|
||||
ksp_raw_transaction_lib::RawTransactionWireField::Value(transaction_index),
|
||||
)
|
||||
.expect("HTTP block material must map to common RAW material");
|
||||
let raw = ksp_raw_transaction_lib::canonicalize_raw_transaction(material).expect("mapped HTTP block material must canonicalize");
|
||||
assert_eq!(raw.reference().signature().as_bytes(), &expected_signatures[index]);
|
||||
assert_eq!(raw.slot(), 430_000_123);
|
||||
assert_eq!(raw.block_time().map(|value| return value.unix_millis()), std::option::Option::Some(1_787_072_400_000));
|
||||
assert!(raw.payload().bytes().ends_with(format!("\"transactionIndex\":{transaction_index}}}").as_bytes()));
|
||||
}
|
||||
handle.join().expect("fixture server must join").expect("fixture server must complete successfully");
|
||||
return;
|
||||
}
|
||||
503
crates/ksp-job-backfill-lib/tests/ws_raw_parity.rs
Normal file
503
crates/ksp-job-backfill-lib/tests/ws_raw_parity.rs
Normal file
@@ -0,0 +1,503 @@
|
||||
// file: crates/ksp-job-backfill-lib/tests/ws_raw_parity.rs
|
||||
// version: 3
|
||||
|
||||
//! Cross-layer parity canaries qualifying standard `blockSubscribe` and Helius `transactionSubscribe` for RAW v1 ingestion.
|
||||
|
||||
use futures_util::SinkExt; // rust-rules: trait-import
|
||||
use futures_util::StreamExt; // rust-rules: trait-import
|
||||
|
||||
const FIXTURE_BLOCK_TIME_MILLIS: u64 = 1_787_072_400_000;
|
||||
const FIXTURE_SLOT: u64 = 430_000_123;
|
||||
const ZERO_SIGNATURE_TEXT: &str = "1111111111111111111111111111111111111111111111111111111111111111";
|
||||
const ZERO_TRANSACTION_BASE64: &str = "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
|
||||
type FixtureWebSocket = tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>;
|
||||
|
||||
fn http_pool_for_url(url: &str) -> ksp_core_lib::Result<ksp_onchain_transport_lib::HttpTransportPool> {
|
||||
let endpoint_url = match ksp_onchain_transport_lib::HttpEndpointUrl::parse(url) {
|
||||
Ok(endpoint_url) => endpoint_url,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let role = ksp_onchain_transport_lib::HttpEndpointRoleSettings::new(
|
||||
ksp_onchain_transport_lib::HttpRoleName::new("default"),
|
||||
true,
|
||||
std::vec![ksp_onchain_transport_lib::HttpRequestKind::wildcard()],
|
||||
10,
|
||||
ksp_onchain_transport_lib::HttpRoleLimits::new(
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
),
|
||||
);
|
||||
let endpoint = ksp_onchain_transport_lib::HttpEndpointSettings::new(
|
||||
"ws-parity-http-fixture",
|
||||
true,
|
||||
ksp_onchain_transport_lib::HttpProviderName::new("fixture-http-provider"),
|
||||
ksp_onchain_transport_lib::HttpClusterName::new("devnet"),
|
||||
endpoint_url,
|
||||
std::time::Duration::from_secs(1),
|
||||
std::time::Duration::from_secs(1),
|
||||
std::option::Option::Some(1),
|
||||
std::vec![role],
|
||||
);
|
||||
return ksp_onchain_transport_lib::HttpTransportPool::new(ksp_onchain_transport_lib::HttpTransportSettings::new(
|
||||
std::vec![endpoint],
|
||||
ksp_onchain_transport_lib::HttpRetrySettings::new(0, std::time::Duration::from_millis(1), std::time::Duration::from_millis(1)),
|
||||
));
|
||||
}
|
||||
|
||||
fn serve_http_once(body: &'static str) -> std::io::Result<(std::string::String, std::thread::JoinHandle<std::io::Result<()>>)> {
|
||||
let listener = match std::net::TcpListener::bind("127.0.0.1:0") {
|
||||
Ok(listener) => listener,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let address = match listener.local_addr() {
|
||||
Ok(address) => address,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let handle = std::thread::spawn(move || {
|
||||
let (mut stream, _) = match listener.accept() {
|
||||
Ok(accepted) => accepted,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let mut bytes = std::vec::Vec::new();
|
||||
let mut buffer = [0_u8; 1024];
|
||||
loop {
|
||||
let count = match std::io::Read::read(&mut stream, &mut buffer) {
|
||||
Ok(count) => count,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
bytes.extend_from_slice(&buffer[..count]);
|
||||
if bytes.windows(4).any(|window| return window == b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
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);
|
||||
if let Err(error) = std::io::Write::write_all(&mut stream, response.as_bytes()) {
|
||||
return Err(error);
|
||||
}
|
||||
return Ok(());
|
||||
});
|
||||
return Ok((format!("http://{address}"), handle));
|
||||
}
|
||||
|
||||
fn ws_endpoint(url: &str, protocol: ksp_onchain_transport_lib::WsProtocolKind) -> ksp_core_lib::Result<ksp_onchain_transport_lib::WsEndpointSettings> {
|
||||
let parsed = match ksp_onchain_transport_lib::WsEndpointUrl::parse(url) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
return Ok(ksp_onchain_transport_lib::WsEndpointSettings::new(
|
||||
"ws-raw-parity-fixture",
|
||||
true,
|
||||
ksp_onchain_transport_lib::WsProviderName::new("fixture-ws-provider"),
|
||||
ksp_onchain_transport_lib::WsClusterName::new("devnet"),
|
||||
protocol,
|
||||
parsed,
|
||||
ksp_onchain_transport_lib::WsSessionSettings::default(),
|
||||
));
|
||||
}
|
||||
|
||||
async fn read_ws_request(websocket: &mut FixtureWebSocket) -> std::result::Result<serde_json::Value, std::string::String> {
|
||||
let message = match websocket.next().await {
|
||||
std::option::Option::Some(std::result::Result::Ok(message)) => message,
|
||||
std::option::Option::Some(std::result::Result::Err(_)) => return Err("fixture WebSocket request decode failed".to_owned()),
|
||||
std::option::Option::None => return Err("fixture WebSocket request was absent".to_owned()),
|
||||
};
|
||||
let text = match message.to_text() {
|
||||
Ok(text) => text,
|
||||
Err(_) => return Err("fixture WebSocket request was not text".to_owned()),
|
||||
};
|
||||
return serde_json::from_str(text).map_err(|_| return "fixture WebSocket request JSON was invalid".to_owned());
|
||||
}
|
||||
|
||||
async fn send_ws_json(websocket: &mut FixtureWebSocket, value: serde_json::Value) -> std::result::Result<(), std::string::String> {
|
||||
return websocket
|
||||
.send(tokio_tungstenite::tungstenite::Message::Text(value.to_string().into()))
|
||||
.await
|
||||
.map_err(|_| return "fixture WebSocket response send failed".to_owned());
|
||||
}
|
||||
|
||||
async fn send_ws_result(
|
||||
websocket: &mut FixtureWebSocket,
|
||||
request: &serde_json::Value,
|
||||
result: serde_json::Value,
|
||||
) -> std::result::Result<(), std::string::String> {
|
||||
let id = match request.get("id").and_then(serde_json::Value::as_u64) {
|
||||
std::option::Option::Some(id) => id,
|
||||
std::option::Option::None => return Err("fixture request id was absent".to_owned()),
|
||||
};
|
||||
return send_ws_json(websocket, serde_json::json!({"jsonrpc":"2.0","id":id,"result":result})).await;
|
||||
}
|
||||
|
||||
async fn wait_for_ws_close(websocket: &mut FixtureWebSocket) {
|
||||
loop {
|
||||
match websocket.next().await {
|
||||
std::option::Option::Some(std::result::Result::Ok(tokio_tungstenite::tungstenite::Message::Close(_))) => return,
|
||||
std::option::Option::Some(std::result::Result::Ok(_)) => {},
|
||||
std::option::Option::Some(std::result::Result::Err(_)) | std::option::Option::None => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_meta(field: &ksp_onchain_transport_lib::SolanaWireField<serde_json::Value>) -> ksp_raw_transaction_lib::RawTransactionWireField<serde_json::Value> {
|
||||
return match field {
|
||||
ksp_onchain_transport_lib::SolanaWireField::Omitted => ksp_raw_transaction_lib::RawTransactionWireField::Omitted,
|
||||
ksp_onchain_transport_lib::SolanaWireField::Null => ksp_raw_transaction_lib::RawTransactionWireField::Null,
|
||||
ksp_onchain_transport_lib::SolanaWireField::Value(value) => ksp_raw_transaction_lib::RawTransactionWireField::Value(value.clone()),
|
||||
};
|
||||
}
|
||||
|
||||
fn map_version(
|
||||
field: &ksp_onchain_transport_lib::SolanaWireField<ksp_onchain_transport_lib::SolanaTransactionVersion>,
|
||||
) -> ksp_raw_transaction_lib::RawTransactionWireField<ksp_raw_transaction_lib::RawTransactionVersion> {
|
||||
return match field {
|
||||
ksp_onchain_transport_lib::SolanaWireField::Omitted => ksp_raw_transaction_lib::RawTransactionWireField::Omitted,
|
||||
ksp_onchain_transport_lib::SolanaWireField::Null => ksp_raw_transaction_lib::RawTransactionWireField::Null,
|
||||
ksp_onchain_transport_lib::SolanaWireField::Value(ksp_onchain_transport_lib::SolanaTransactionVersion::Legacy) => {
|
||||
ksp_raw_transaction_lib::RawTransactionWireField::Value(ksp_raw_transaction_lib::RawTransactionVersion::Legacy)
|
||||
},
|
||||
ksp_onchain_transport_lib::SolanaWireField::Value(ksp_onchain_transport_lib::SolanaTransactionVersion::Number(number)) => {
|
||||
ksp_raw_transaction_lib::RawTransactionWireField::Value(ksp_raw_transaction_lib::RawTransactionVersion::Number(*number))
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fn raw_from_block_transaction(
|
||||
network: ksp_store_lib::RawNetworkId,
|
||||
slot: u64,
|
||||
block_time: std::option::Option<i64>,
|
||||
transaction: &ksp_onchain_transport_lib::SolanaBlockTransaction,
|
||||
transaction_index: u32,
|
||||
) -> std::option::Option<ksp_store_lib::RawTransaction> {
|
||||
let data = match transaction.transaction() {
|
||||
ksp_onchain_transport_lib::SolanaEncodedTransaction::Binary { data, encoding: ksp_onchain_transport_lib::SolanaTransactionBinaryEncoding::Base64 } => {
|
||||
data.as_str()
|
||||
},
|
||||
_ => return std::option::Option::None,
|
||||
};
|
||||
let material = match ksp_raw_transaction_lib::RawTransactionMaterial::binary_base64_with_embedded_signature(
|
||||
network,
|
||||
slot,
|
||||
block_time,
|
||||
data,
|
||||
map_meta(transaction.meta()),
|
||||
map_version(transaction.version()),
|
||||
ksp_raw_transaction_lib::RawTransactionWireField::Value(transaction_index),
|
||||
) {
|
||||
Ok(material) => material,
|
||||
Err(_) => return std::option::Option::None,
|
||||
};
|
||||
return ksp_raw_transaction_lib::canonicalize_raw_transaction(material).ok();
|
||||
}
|
||||
|
||||
fn raw_from_confirmed_transaction(
|
||||
network: ksp_store_lib::RawNetworkId,
|
||||
transaction: &ksp_onchain_transport_lib::SolanaConfirmedTransaction,
|
||||
) -> std::option::Option<ksp_store_lib::RawTransaction> {
|
||||
let data = match transaction.transaction() {
|
||||
ksp_onchain_transport_lib::SolanaEncodedTransaction::Binary { data, encoding: ksp_onchain_transport_lib::SolanaTransactionBinaryEncoding::Base64 } => {
|
||||
data.as_str()
|
||||
},
|
||||
_ => return std::option::Option::None,
|
||||
};
|
||||
let material = match ksp_raw_transaction_lib::RawTransactionMaterial::binary_base64_with_embedded_signature(
|
||||
network,
|
||||
transaction.slot(),
|
||||
transaction.block_time(),
|
||||
data,
|
||||
map_meta(transaction.meta()),
|
||||
map_version(transaction.version()),
|
||||
match transaction.transaction_index() {
|
||||
ksp_onchain_transport_lib::SolanaWireField::Omitted => ksp_raw_transaction_lib::RawTransactionWireField::Omitted,
|
||||
ksp_onchain_transport_lib::SolanaWireField::Null => ksp_raw_transaction_lib::RawTransactionWireField::Null,
|
||||
ksp_onchain_transport_lib::SolanaWireField::Value(value) => ksp_raw_transaction_lib::RawTransactionWireField::Value(*value),
|
||||
},
|
||||
) {
|
||||
Ok(material) => material,
|
||||
Err(_) => return std::option::Option::None,
|
||||
};
|
||||
return ksp_raw_transaction_lib::canonicalize_raw_transaction(material).ok();
|
||||
}
|
||||
|
||||
fn raw_from_helius_full(
|
||||
network: ksp_store_lib::RawNetworkId,
|
||||
notification: &ksp_onchain_transport_lib::HeliusFullTransactionNotification,
|
||||
) -> std::option::Option<ksp_store_lib::RawTransaction> {
|
||||
let object = match notification.transaction().as_object() {
|
||||
std::option::Option::Some(object) => object,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
let encoded = match object.get("transaction") {
|
||||
std::option::Option::Some(value) => match value.as_array() {
|
||||
std::option::Option::Some(encoded) => encoded,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
},
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
if encoded.len() != 2 {
|
||||
return std::option::Option::None;
|
||||
}
|
||||
let encoding = match encoded.get(1) {
|
||||
std::option::Option::Some(value) => match value.as_str() {
|
||||
std::option::Option::Some(encoding) => encoding,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
},
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
if encoding != "base64" {
|
||||
return std::option::Option::None;
|
||||
}
|
||||
let data = match encoded.first() {
|
||||
std::option::Option::Some(value) => match value.as_str() {
|
||||
std::option::Option::Some(data) => data,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
},
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
let meta = match object.get("meta") {
|
||||
std::option::Option::None => ksp_raw_transaction_lib::RawTransactionWireField::Omitted,
|
||||
std::option::Option::Some(serde_json::Value::Null) => ksp_raw_transaction_lib::RawTransactionWireField::Null,
|
||||
std::option::Option::Some(value) => ksp_raw_transaction_lib::RawTransactionWireField::Value(value.clone()),
|
||||
};
|
||||
let signature = match ksp_raw_transaction_lib::parse_raw_transaction_signature(notification.signature()) {
|
||||
Ok(signature) => signature,
|
||||
Err(_) => return std::option::Option::None,
|
||||
};
|
||||
let transaction_index = match u32::try_from(notification.transaction_index()) {
|
||||
Ok(transaction_index) => transaction_index,
|
||||
Err(_) => return std::option::Option::None,
|
||||
};
|
||||
let material = ksp_raw_transaction_lib::RawTransactionMaterial::binary_base64(
|
||||
network,
|
||||
signature,
|
||||
notification.slot(),
|
||||
std::option::Option::None,
|
||||
data,
|
||||
meta,
|
||||
ksp_raw_transaction_lib::RawTransactionWireField::Omitted,
|
||||
ksp_raw_transaction_lib::RawTransactionWireField::Value(transaction_index),
|
||||
);
|
||||
return ksp_raw_transaction_lib::canonicalize_raw_transaction(material).ok();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn pre_005_standard_block_subscribe_full_base64_matches_http_get_block_raw_v1_for_legacy_transaction() {
|
||||
const HTTP_BODY: &str = concat!(
|
||||
"{\"jsonrpc\":\"2.0\",\"result\":{\"previousBlockhash\":\"previous\",\"blockhash\":\"block\",\"parentSlot\":430000122,",
|
||||
"\"rewards\":[],\"numRewardPartitions\":0,\"blockTime\":1787072400,\"blockHeight\":410000000,",
|
||||
"\"transactions\":[{\"transaction\":[\"AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
"AAAAAA\",\"base64\"],\"meta\":{\"err\":null,\"fee\":5000},\"version\":\"legacy\"}]},\"id\":1}",
|
||||
);
|
||||
let (http_url, http_server) = serve_http_once(HTTP_BODY).expect("HTTP parity fixture must start");
|
||||
let http_pool = http_pool_for_url(http_url.as_str()).expect("HTTP parity pool must build");
|
||||
let http_config = ksp_onchain_transport_lib::SolanaGetBlockConfig::new(
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed),
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionEncoding::Base64),
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionDetails::Full),
|
||||
std::option::Option::Some(0),
|
||||
std::option::Option::Some(false),
|
||||
);
|
||||
let http = http_pool
|
||||
.get_block_observed(&ksp_onchain_transport_lib::HttpRoleName::new("default"), FIXTURE_SLOT, std::option::Option::Some(&http_config))
|
||||
.await
|
||||
.expect("HTTP block parity fixture must decode");
|
||||
let http_block = http.value().as_ref().expect("HTTP parity block must be present").clone();
|
||||
http_server.join().expect("HTTP parity fixture must join").expect("HTTP parity fixture must complete");
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("WS parity listener must bind");
|
||||
let address = listener.local_addr().expect("WS parity listener address must resolve");
|
||||
let ws_block = serde_json::from_str::<serde_json::Value>(HTTP_BODY).expect("HTTP parity JSON must parse")["result"].clone();
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = match listener.accept().await {
|
||||
Ok(accepted) => accepted,
|
||||
Err(_) => return Err("WS parity server accept failed".to_owned()),
|
||||
};
|
||||
let mut websocket = match tokio_tungstenite::accept_async(stream).await {
|
||||
Ok(websocket) => websocket,
|
||||
Err(_) => return Err("WS parity handshake failed".to_owned()),
|
||||
};
|
||||
let subscribe = match read_ws_request(&mut websocket).await {
|
||||
Ok(subscribe) => subscribe,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if subscribe.get("method") != std::option::Option::Some(&serde_json::json!("blockSubscribe")) {
|
||||
return Err("WS parity server received unexpected subscribe method".to_owned());
|
||||
}
|
||||
if let Err(error) = send_ws_result(&mut websocket, &subscribe, serde_json::json!(501)).await {
|
||||
return Err(error);
|
||||
}
|
||||
if let Err(error) = send_ws_json(
|
||||
&mut websocket,
|
||||
serde_json::json!({
|
||||
"jsonrpc":"2.0",
|
||||
"method":"blockNotification",
|
||||
"params":{"subscription":501,"result":{"context":{"slot":FIXTURE_SLOT},"value":{"slot":FIXTURE_SLOT,"block":ws_block,"err":null}}}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Err(error);
|
||||
}
|
||||
let unsubscribe = match read_ws_request(&mut websocket).await {
|
||||
Ok(unsubscribe) => unsubscribe,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if unsubscribe.get("method") != std::option::Option::Some(&serde_json::json!("blockUnsubscribe")) {
|
||||
return Err("WS parity server received unexpected unsubscribe method".to_owned());
|
||||
}
|
||||
if let Err(error) = send_ws_result(&mut websocket, &unsubscribe, serde_json::json!(true)).await {
|
||||
return Err(error);
|
||||
}
|
||||
wait_for_ws_close(&mut websocket).await;
|
||||
return Ok::<(), std::string::String>(());
|
||||
});
|
||||
let endpoint = ws_endpoint(format!("ws://{address}").as_str(), ksp_onchain_transport_lib::WsProtocolKind::SolanaStandard)
|
||||
.expect("standard WS parity endpoint must build");
|
||||
let session = ksp_onchain_transport_lib::SolanaStandardWsSession::connect(endpoint).await.expect("standard WS parity session must connect");
|
||||
let ws_config = ksp_onchain_transport_lib::SolanaBlockSubscribeConfig::new(
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed),
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionEncoding::Base64),
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionDetails::Full),
|
||||
std::option::Option::Some(0),
|
||||
std::option::Option::Some(false),
|
||||
);
|
||||
let mut subscription = session
|
||||
.block_subscribe(&ksp_onchain_transport_lib::SolanaBlockSubscribeFilter::All, std::option::Option::Some(&ws_config))
|
||||
.await
|
||||
.expect("standard blockSubscribe parity subscription must register");
|
||||
let notification = subscription
|
||||
.recv()
|
||||
.await
|
||||
.expect("standard blockSubscribe parity notification must arrive")
|
||||
.expect("standard blockSubscribe parity notification must decode");
|
||||
let ws_block = notification.value().block().expect("standard blockSubscribe parity block must be present");
|
||||
assert_eq!(ws_block, &http_block);
|
||||
let network = ksp_store_lib::RawNetworkId::new("devnet").expect("parity fixture network must be valid");
|
||||
let http_transaction =
|
||||
http_block.transactions().value().expect("HTTP parity transactions must be present").first().expect("HTTP parity transaction must exist");
|
||||
let ws_transaction = ws_block.transactions().value().expect("WS parity transactions must be present").first().expect("WS parity transaction must exist");
|
||||
let http_raw = raw_from_block_transaction(network.clone(), FIXTURE_SLOT, http_block.block_time(), http_transaction, 0)
|
||||
.expect("HTTP block transaction must canonicalize");
|
||||
let ws_raw = raw_from_block_transaction(network, notification.value().slot(), ws_block.block_time(), ws_transaction, 0)
|
||||
.expect("WS block transaction must canonicalize");
|
||||
assert_eq!(ws_raw.reference(), http_raw.reference());
|
||||
assert_eq!(ws_raw.slot(), http_raw.slot());
|
||||
assert_eq!(ws_raw.block_time(), http_raw.block_time());
|
||||
assert_eq!(ws_raw.payload().bytes(), http_raw.payload().bytes());
|
||||
assert_eq!(ws_raw.payload().content_hash(), http_raw.payload().content_hash());
|
||||
assert!(subscription.unsubscribe().await.expect("standard blockSubscribe parity unsubscribe must complete"));
|
||||
session.close().await.expect("standard WS parity session must close");
|
||||
server.await.expect("standard WS parity server task must join").expect("standard WS parity server must complete");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn pre_005_helius_full_base64_without_block_time_and_version_requires_http_hydration_for_raw_v1() {
|
||||
const HTTP_BODY: &str = concat!(
|
||||
"{\"jsonrpc\":\"2.0\",\"result\":{\"slot\":430000123,\"blockTime\":1787072400,",
|
||||
"\"transaction\":[\"AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\",\"base64\"],",
|
||||
"\"meta\":{\"err\":null,\"fee\":5000},\"version\":\"legacy\",\"transactionIndex\":0},\"id\":1}",
|
||||
);
|
||||
let (http_url, http_server) = serve_http_once(HTTP_BODY).expect("Helius hydration HTTP fixture must start");
|
||||
let http_pool = http_pool_for_url(http_url.as_str()).expect("Helius hydration HTTP pool must build");
|
||||
let http_config = ksp_onchain_transport_lib::SolanaGetTransactionConfig::new(
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed),
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionEncoding::Base64),
|
||||
std::option::Option::Some(0),
|
||||
);
|
||||
let http = http_pool
|
||||
.get_transaction_observed(&ksp_onchain_transport_lib::HttpRoleName::new("default"), ZERO_SIGNATURE_TEXT, std::option::Option::Some(&http_config))
|
||||
.await
|
||||
.expect("Helius hydration HTTP fixture must decode");
|
||||
let http_transaction = http.value().as_ref().expect("Helius hydration HTTP transaction must be present");
|
||||
let network = ksp_store_lib::RawNetworkId::new("devnet").expect("Helius hydration fixture network must be valid");
|
||||
let http_raw = raw_from_confirmed_transaction(network.clone(), http_transaction).expect("hydrated HTTP transaction must canonicalize");
|
||||
http_server.join().expect("Helius hydration HTTP fixture must join").expect("Helius hydration HTTP fixture must complete");
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("Helius parity listener must bind");
|
||||
let address = listener.local_addr().expect("Helius parity listener address must resolve");
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = match listener.accept().await {
|
||||
Ok(accepted) => accepted,
|
||||
Err(_) => return Err("Helius parity server accept failed".to_owned()),
|
||||
};
|
||||
let mut websocket = match tokio_tungstenite::accept_async(stream).await {
|
||||
Ok(websocket) => websocket,
|
||||
Err(_) => return Err("Helius parity handshake failed".to_owned()),
|
||||
};
|
||||
let subscribe = match read_ws_request(&mut websocket).await {
|
||||
Ok(subscribe) => subscribe,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if subscribe.get("method") != std::option::Option::Some(&serde_json::json!("transactionSubscribe")) {
|
||||
return Err("Helius parity server received unexpected subscribe method".to_owned());
|
||||
}
|
||||
if let Err(error) = send_ws_result(&mut websocket, &subscribe, serde_json::json!(601)).await {
|
||||
return Err(error);
|
||||
}
|
||||
if let Err(error) = send_ws_json(
|
||||
&mut websocket,
|
||||
serde_json::json!({
|
||||
"jsonrpc":"2.0",
|
||||
"method":"transactionNotification",
|
||||
"params":{
|
||||
"subscription":601,
|
||||
"result":{
|
||||
"transaction":{"transaction":[ZERO_TRANSACTION_BASE64,"base64"],"meta":{"err":null,"fee":5000}},
|
||||
"signature":ZERO_SIGNATURE_TEXT,
|
||||
"slot":FIXTURE_SLOT,
|
||||
"transactionIndex":0
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Err(error);
|
||||
}
|
||||
let unsubscribe = match read_ws_request(&mut websocket).await {
|
||||
Ok(unsubscribe) => unsubscribe,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if unsubscribe.get("method") != std::option::Option::Some(&serde_json::json!("transactionUnsubscribe")) {
|
||||
return Err("Helius parity server received unexpected unsubscribe method".to_owned());
|
||||
}
|
||||
if let Err(error) = send_ws_result(&mut websocket, &unsubscribe, serde_json::json!(true)).await {
|
||||
return Err(error);
|
||||
}
|
||||
wait_for_ws_close(&mut websocket).await;
|
||||
return Ok::<(), std::string::String>(());
|
||||
});
|
||||
let endpoint = ws_endpoint(format!("ws://{address}").as_str(), ksp_onchain_transport_lib::WsProtocolKind::HeliusLaserStream)
|
||||
.expect("Helius parity endpoint must build");
|
||||
let session = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::connect(endpoint).await.expect("Helius parity session must connect");
|
||||
let request = ksp_onchain_transport_lib::HeliusTransactionSubscribeRequest::new(
|
||||
ksp_onchain_transport_lib::HeliusTransactionSubscribeFilter::default(),
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::HeliusTransactionSubscribeOptions::new(
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed),
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::HeliusTransactionSubscribeEncoding::Base64),
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionDetails::Full),
|
||||
std::option::Option::Some(false),
|
||||
std::option::Option::Some(0),
|
||||
)),
|
||||
);
|
||||
let mut subscription = session.transaction_subscribe(&request).await.expect("Helius parity transaction subscription must register");
|
||||
let notification = subscription.recv().await.expect("Helius parity notification must arrive").expect("Helius parity notification must decode");
|
||||
assert!(matches!(¬ification, ksp_onchain_transport_lib::HeliusTransactionNotification::Full(_)));
|
||||
let full = match notification {
|
||||
ksp_onchain_transport_lib::HeliusTransactionNotification::Full(full) => full,
|
||||
_ => return,
|
||||
};
|
||||
let helius_raw = raw_from_helius_full(network, &full).expect("best-effort Helius full projection must canonicalize for negative parity proof");
|
||||
assert_eq!(helius_raw.reference(), http_raw.reference());
|
||||
assert_eq!(helius_raw.slot(), http_raw.slot());
|
||||
assert!(helius_raw.block_time().is_none());
|
||||
assert_eq!(http_raw.block_time().map(|value| return value.unix_millis()), std::option::Option::Some(FIXTURE_BLOCK_TIME_MILLIS),);
|
||||
assert_ne!(helius_raw.block_time(), http_raw.block_time());
|
||||
assert_ne!(helius_raw.payload().bytes(), http_raw.payload().bytes());
|
||||
assert_ne!(helius_raw.payload().content_hash(), http_raw.payload().content_hash());
|
||||
assert!(subscription.unsubscribe().await.expect("Helius parity transaction unsubscribe must complete"));
|
||||
session.close().await.expect("Helius parity session must close");
|
||||
server.await.expect("Helius parity server task must join").expect("Helius parity server must complete");
|
||||
}
|
||||
471
crates/ksp-job-backfill-lib/tests/yellowstone_raw_parity.rs
Normal file
471
crates/ksp-job-backfill-lib/tests/yellowstone_raw_parity.rs
Normal file
@@ -0,0 +1,471 @@
|
||||
// file: crates/ksp-job-backfill-lib/tests/yellowstone_raw_parity.rs
|
||||
// version: 3
|
||||
|
||||
//! Deterministic local Yellowstone gRPC parity canary for source-neutral RAW transaction v1 projection.
|
||||
|
||||
struct FixtureStream<T> {
|
||||
receiver: tokio::sync::mpsc::Receiver<T>,
|
||||
}
|
||||
|
||||
impl<T> FixtureStream<T> {
|
||||
fn new(receiver: tokio::sync::mpsc::Receiver<T>) -> Self {
|
||||
return Self { receiver };
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> futures_util::Stream for FixtureStream<T> {
|
||||
type Item = T;
|
||||
|
||||
fn poll_next(self: std::pin::Pin<&mut Self>, context: &mut std::task::Context<'_>) -> std::task::Poll<std::option::Option<Self::Item>> {
|
||||
return self.get_mut().receiver.poll_recv(context);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FixtureGeyser;
|
||||
|
||||
#[allow(clippy::implicit_return)] // tonic::async_trait generates async wrapper tails outside the authored fixture bodies.
|
||||
#[tonic::async_trait]
|
||||
impl yellowstone_grpc_proto::geyser::geyser_server::Geyser for FixtureGeyser {
|
||||
type SubscribeStream = FixtureStream<std::result::Result<yellowstone_grpc_proto::geyser::SubscribeUpdate, tonic::Status>>;
|
||||
|
||||
async fn subscribe(
|
||||
&self,
|
||||
request: tonic::Request<tonic::Streaming<yellowstone_grpc_proto::geyser::SubscribeRequest>>,
|
||||
) -> std::result::Result<tonic::Response<Self::SubscribeStream>, tonic::Status> {
|
||||
let mut inbound = request.into_inner();
|
||||
let (sender, receiver) = tokio::sync::mpsc::channel(4);
|
||||
tokio::spawn(async move {
|
||||
let initial = inbound.message().await;
|
||||
if !matches!(initial, std::result::Result::Ok(std::option::Option::Some(_))) {
|
||||
return;
|
||||
}
|
||||
if sender.send(std::result::Result::Ok(transaction_update())).await.is_err() {
|
||||
return;
|
||||
}
|
||||
let _ = sender.send(std::result::Result::Ok(block_update())).await;
|
||||
});
|
||||
return std::result::Result::Ok(tonic::Response::new(FixtureStream::new(receiver)));
|
||||
}
|
||||
|
||||
type SubscribeDeshredStream = futures_util::stream::Empty<std::result::Result<yellowstone_grpc_proto::geyser::SubscribeUpdateDeshred, tonic::Status>>;
|
||||
|
||||
async fn subscribe_deshred(
|
||||
&self,
|
||||
_request: tonic::Request<tonic::Streaming<yellowstone_grpc_proto::geyser::SubscribeDeshredRequest>>,
|
||||
) -> std::result::Result<tonic::Response<Self::SubscribeDeshredStream>, tonic::Status> {
|
||||
return std::result::Result::Err(tonic::Status::unimplemented("deshred is outside the pre.006 fixture"));
|
||||
}
|
||||
|
||||
type SubscribeGossipStream = futures_util::stream::Empty<std::result::Result<yellowstone_grpc_proto::geyser::SubscribeUpdateGossip, tonic::Status>>;
|
||||
|
||||
async fn subscribe_gossip(
|
||||
&self,
|
||||
_request: tonic::Request<yellowstone_grpc_proto::geyser::SubscribeGossipRequest>,
|
||||
) -> std::result::Result<tonic::Response<Self::SubscribeGossipStream>, tonic::Status> {
|
||||
return std::result::Result::Err(tonic::Status::unimplemented("gossip is outside the pre.006 fixture"));
|
||||
}
|
||||
|
||||
async fn subscribe_replay_info(
|
||||
&self,
|
||||
_request: tonic::Request<yellowstone_grpc_proto::geyser::SubscribeReplayInfoRequest>,
|
||||
) -> std::result::Result<tonic::Response<yellowstone_grpc_proto::geyser::SubscribeReplayInfoResponse>, tonic::Status> {
|
||||
return std::result::Result::Err(tonic::Status::unimplemented("replay info is outside the pre.006 fixture"));
|
||||
}
|
||||
|
||||
async fn ping(
|
||||
&self,
|
||||
_request: tonic::Request<yellowstone_grpc_proto::geyser::PingRequest>,
|
||||
) -> std::result::Result<tonic::Response<yellowstone_grpc_proto::geyser::PongResponse>, tonic::Status> {
|
||||
return std::result::Result::Err(tonic::Status::unimplemented("unary is outside the pre.006 fixture"));
|
||||
}
|
||||
|
||||
async fn get_latest_blockhash(
|
||||
&self,
|
||||
_request: tonic::Request<yellowstone_grpc_proto::geyser::GetLatestBlockhashRequest>,
|
||||
) -> std::result::Result<tonic::Response<yellowstone_grpc_proto::geyser::GetLatestBlockhashResponse>, tonic::Status> {
|
||||
return std::result::Result::Err(tonic::Status::unimplemented("unary is outside the pre.006 fixture"));
|
||||
}
|
||||
|
||||
async fn get_block_height(
|
||||
&self,
|
||||
_request: tonic::Request<yellowstone_grpc_proto::geyser::GetBlockHeightRequest>,
|
||||
) -> std::result::Result<tonic::Response<yellowstone_grpc_proto::geyser::GetBlockHeightResponse>, tonic::Status> {
|
||||
return std::result::Result::Err(tonic::Status::unimplemented("unary is outside the pre.006 fixture"));
|
||||
}
|
||||
|
||||
async fn get_slot(
|
||||
&self,
|
||||
_request: tonic::Request<yellowstone_grpc_proto::geyser::GetSlotRequest>,
|
||||
) -> std::result::Result<tonic::Response<yellowstone_grpc_proto::geyser::GetSlotResponse>, tonic::Status> {
|
||||
return std::result::Result::Err(tonic::Status::unimplemented("unary is outside the pre.006 fixture"));
|
||||
}
|
||||
|
||||
async fn is_blockhash_valid(
|
||||
&self,
|
||||
_request: tonic::Request<yellowstone_grpc_proto::geyser::IsBlockhashValidRequest>,
|
||||
) -> std::result::Result<tonic::Response<yellowstone_grpc_proto::geyser::IsBlockhashValidResponse>, tonic::Status> {
|
||||
return std::result::Result::Err(tonic::Status::unimplemented("unary is outside the pre.006 fixture"));
|
||||
}
|
||||
|
||||
async fn get_version(
|
||||
&self,
|
||||
_request: tonic::Request<yellowstone_grpc_proto::geyser::GetVersionRequest>,
|
||||
) -> std::result::Result<tonic::Response<yellowstone_grpc_proto::geyser::GetVersionResponse>, tonic::Status> {
|
||||
return std::result::Result::Err(tonic::Status::unimplemented("unary is outside the pre.006 fixture"));
|
||||
}
|
||||
}
|
||||
|
||||
struct FixtureServer {
|
||||
endpoint_url: std::string::String,
|
||||
shutdown: std::option::Option<tokio::sync::oneshot::Sender<()>>,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl FixtureServer {
|
||||
async fn start() -> std::result::Result<Self, &'static str> {
|
||||
let bind_address = match "127.0.0.1:0".parse::<std::net::SocketAddr>() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err("fixture bind address must parse"),
|
||||
};
|
||||
let incoming = match tonic::transport::server::TcpIncoming::bind(bind_address) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err("fixture gRPC listener must bind"),
|
||||
};
|
||||
let local_address = match incoming.local_addr() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err("fixture gRPC listener must expose local address"),
|
||||
};
|
||||
let (shutdown, shutdown_receiver) = tokio::sync::oneshot::channel();
|
||||
let task = tokio::spawn(async move {
|
||||
let service = yellowstone_grpc_proto::geyser::geyser_server::GeyserServer::new(FixtureGeyser);
|
||||
let result = tonic::transport::Server::builder()
|
||||
.serve_with_incoming_shutdown(service, incoming, async move {
|
||||
let _ = shutdown_receiver.await;
|
||||
})
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
});
|
||||
return std::result::Result::Ok(Self { endpoint_url: format!("http://{local_address}"), shutdown: std::option::Option::Some(shutdown), task });
|
||||
}
|
||||
|
||||
async fn stop(mut self) {
|
||||
if let std::option::Option::Some(shutdown) = self.shutdown.take() {
|
||||
let _ = shutdown.send(());
|
||||
}
|
||||
let result = self.task.await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
fn transaction_info() -> yellowstone_grpc_proto::geyser::SubscribeUpdateTransactionInfo {
|
||||
return yellowstone_grpc_proto::geyser::SubscribeUpdateTransactionInfo {
|
||||
signature: vec![9_u8; 64],
|
||||
is_vote: false,
|
||||
transaction: std::option::Option::Some(yellowstone_grpc_proto::solana::storage::confirmed_block::Transaction {
|
||||
signatures: vec![vec![9_u8; 64]],
|
||||
message: std::option::Option::Some(yellowstone_grpc_proto::solana::storage::confirmed_block::Message {
|
||||
header: std::option::Option::Some(yellowstone_grpc_proto::solana::storage::confirmed_block::MessageHeader {
|
||||
num_required_signatures: 1,
|
||||
num_readonly_signed_accounts: 0,
|
||||
num_readonly_unsigned_accounts: 1,
|
||||
}),
|
||||
account_keys: vec![vec![1_u8; 32], vec![2_u8; 32]],
|
||||
recent_blockhash: vec![3_u8; 32],
|
||||
instructions: vec![yellowstone_grpc_proto::solana::storage::confirmed_block::CompiledInstruction {
|
||||
program_id_index: 1,
|
||||
accounts: vec![0_u8],
|
||||
data: vec![4_u8, 5, 6],
|
||||
}],
|
||||
versioned: true,
|
||||
address_table_lookups: vec![],
|
||||
config: std::option::Option::Some(yellowstone_grpc_proto::solana::storage::confirmed_block::TransactionConfig {
|
||||
priority_fee: std::option::Option::Some(7),
|
||||
compute_unit_limit: std::option::Option::Some(8),
|
||||
loaded_accounts_data_size_limit: std::option::Option::Some(9),
|
||||
heap_size: std::option::Option::Some(32_768),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
meta: std::option::Option::Some(yellowstone_grpc_proto::solana::storage::confirmed_block::TransactionStatusMeta {
|
||||
fee: 5_000,
|
||||
pre_balances: vec![100, 200],
|
||||
post_balances: vec![90, 210],
|
||||
compute_units_consumed: std::option::Option::Some(123),
|
||||
cost_units: std::option::Option::Some(456),
|
||||
..std::default::Default::default()
|
||||
}),
|
||||
index: 3,
|
||||
};
|
||||
}
|
||||
|
||||
fn transaction_update() -> yellowstone_grpc_proto::geyser::SubscribeUpdate {
|
||||
return yellowstone_grpc_proto::geyser::SubscribeUpdate {
|
||||
filters: vec!["transactions-pre-006".to_owned()],
|
||||
update_oneof: std::option::Option::Some(yellowstone_grpc_proto::geyser::subscribe_update::UpdateOneof::Transaction(
|
||||
yellowstone_grpc_proto::geyser::SubscribeUpdateTransaction { transaction: std::option::Option::Some(transaction_info()), slot: 42 },
|
||||
)),
|
||||
created_at: std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
fn block_update() -> yellowstone_grpc_proto::geyser::SubscribeUpdate {
|
||||
return yellowstone_grpc_proto::geyser::SubscribeUpdate {
|
||||
filters: vec!["blocks-pre-006".to_owned()],
|
||||
update_oneof: std::option::Option::Some(yellowstone_grpc_proto::geyser::subscribe_update::UpdateOneof::Block(
|
||||
yellowstone_grpc_proto::geyser::SubscribeUpdateBlock {
|
||||
slot: 42,
|
||||
blockhash: ksp_core_lib::Pubkey::new_from_array([21_u8; 32]).to_string(),
|
||||
rewards: std::option::Option::None,
|
||||
block_time: std::option::Option::Some(yellowstone_grpc_proto::solana::storage::confirmed_block::UnixTimestamp { timestamp: 1_787_104_000 }),
|
||||
block_height: std::option::Option::Some(yellowstone_grpc_proto::solana::storage::confirmed_block::BlockHeight { block_height: 41 }),
|
||||
transactions: vec![transaction_info()],
|
||||
parent_slot: 41,
|
||||
parent_blockhash: ksp_core_lib::Pubkey::new_from_array([22_u8; 32]).to_string(),
|
||||
executed_transaction_count: 1,
|
||||
updated_account_count: 0,
|
||||
accounts: vec![],
|
||||
entries_count: 0,
|
||||
entries: vec![],
|
||||
},
|
||||
)),
|
||||
created_at: std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
fn fixture_settings(url: &str) -> ksp_core_lib::Result<ksp_onchain_transport_lib::YellowstoneGrpcEndpointSettings> {
|
||||
let endpoint_url = match ksp_onchain_transport_lib::YellowstoneGrpcEndpointUrl::parse(url) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(ksp_onchain_transport_lib::YellowstoneGrpcEndpointSettings::new(
|
||||
"pre-006-fixture",
|
||||
true,
|
||||
ksp_onchain_transport_lib::YellowstoneGrpcProviderName::new("fixture-provider"),
|
||||
ksp_onchain_transport_lib::YellowstoneGrpcClusterName::new("devnet"),
|
||||
endpoint_url,
|
||||
ksp_onchain_transport_lib::YellowstoneGrpcSessionSettings::default(),
|
||||
));
|
||||
}
|
||||
|
||||
fn project_wire(
|
||||
transaction: &ksp_onchain_transport_lib::YellowstoneStoredTransaction,
|
||||
) -> ksp_core_lib::Result<ksp_raw_transaction_lib::RawSolanaTransactionWire> {
|
||||
let message = transaction.message();
|
||||
let header = message.header();
|
||||
let required = match u8::try_from(header.num_required_signatures()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(projection_error("num_required_signatures")),
|
||||
};
|
||||
let readonly_signed = match u8::try_from(header.num_readonly_signed_accounts()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(projection_error("num_readonly_signed_accounts")),
|
||||
};
|
||||
let readonly_unsigned = match u8::try_from(header.num_readonly_unsigned_accounts()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(projection_error("num_readonly_unsigned_accounts")),
|
||||
};
|
||||
let version = if message.config().is_some() {
|
||||
ksp_raw_transaction_lib::RawSolanaMessageVersion::V1
|
||||
} else if message.versioned() {
|
||||
ksp_raw_transaction_lib::RawSolanaMessageVersion::V0
|
||||
} else {
|
||||
ksp_raw_transaction_lib::RawSolanaMessageVersion::Legacy
|
||||
};
|
||||
let signatures = transaction.signatures().iter().map(|value| return *value.as_bytes()).collect();
|
||||
let account_keys = message.account_keys().iter().map(solana_pubkey_bytes).collect();
|
||||
let mut instructions = std::vec::Vec::with_capacity(message.instructions().len());
|
||||
for instruction in message.instructions() {
|
||||
let program_id_index = match u8::try_from(instruction.program_id_index()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(projection_error("program_id_index")),
|
||||
};
|
||||
instructions.push(ksp_raw_transaction_lib::RawSolanaCompiledInstruction::new(
|
||||
program_id_index,
|
||||
instruction.accounts().to_vec(),
|
||||
instruction.data().to_vec(),
|
||||
));
|
||||
}
|
||||
let address_table_lookups = message
|
||||
.address_table_lookups()
|
||||
.iter()
|
||||
.map(|lookup| {
|
||||
return ksp_raw_transaction_lib::RawSolanaAddressTableLookup::new(
|
||||
lookup.account_key().to_bytes(),
|
||||
lookup.writable_indexes().to_vec(),
|
||||
lookup.readonly_indexes().to_vec(),
|
||||
);
|
||||
})
|
||||
.collect();
|
||||
let config = message.config().map(|value| {
|
||||
return ksp_raw_transaction_lib::RawSolanaTransactionConfig::new(
|
||||
value.priority_fee(),
|
||||
value.compute_unit_limit(),
|
||||
value.loaded_accounts_data_size_limit(),
|
||||
value.heap_size(),
|
||||
);
|
||||
});
|
||||
let raw_message = ksp_raw_transaction_lib::RawSolanaTransactionMessage::new(
|
||||
version,
|
||||
ksp_raw_transaction_lib::RawSolanaMessageHeader::new(required, readonly_signed, readonly_unsigned),
|
||||
account_keys,
|
||||
*message.recent_blockhash().as_bytes(),
|
||||
instructions,
|
||||
address_table_lookups,
|
||||
config,
|
||||
);
|
||||
return std::result::Result::Ok(ksp_raw_transaction_lib::RawSolanaTransactionWire::new(signatures, raw_message));
|
||||
}
|
||||
|
||||
fn solana_pubkey_bytes(value: &ksp_core_lib::Pubkey) -> [u8; 32] {
|
||||
return value.to_bytes();
|
||||
}
|
||||
|
||||
fn projection_error(field: &'static str) -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(ksp_core_lib::ErrorCode::new("test", "yellowstone_raw_projection"), "Yellowstone fixture field does not fit common wire")
|
||||
.with_context("field", field);
|
||||
}
|
||||
|
||||
fn error_code_text(error: &ksp_core_lib::Error) -> std::string::String {
|
||||
return format!("{}.{}", error.code().domain(), error.code().code());
|
||||
}
|
||||
|
||||
fn expected_v1_wire() -> ksp_raw_transaction_lib::RawSolanaTransactionWire {
|
||||
let message = ksp_raw_transaction_lib::RawSolanaTransactionMessage::new(
|
||||
ksp_raw_transaction_lib::RawSolanaMessageVersion::V1,
|
||||
ksp_raw_transaction_lib::RawSolanaMessageHeader::new(1, 0, 1),
|
||||
vec![[1_u8; 32], [2_u8; 32]],
|
||||
[3_u8; 32],
|
||||
vec![ksp_raw_transaction_lib::RawSolanaCompiledInstruction::new(1, vec![0_u8], vec![4_u8, 5, 6])],
|
||||
vec![],
|
||||
std::option::Option::Some(ksp_raw_transaction_lib::RawSolanaTransactionConfig::new(
|
||||
std::option::Option::Some(7),
|
||||
std::option::Option::Some(8),
|
||||
std::option::Option::Some(9),
|
||||
std::option::Option::Some(32_768),
|
||||
)),
|
||||
);
|
||||
return ksp_raw_transaction_lib::RawSolanaTransactionWire::new(vec![[9_u8; 64]], message);
|
||||
}
|
||||
|
||||
fn fail_test(message: std::string::String) {
|
||||
assert!(message.is_empty(), "{message}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pre_006_yellowstone_transaction_and_block_preserve_exact_v1_wire_and_block_time() {
|
||||
let server = match FixtureServer::start().await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
assert!(error.is_empty(), "fixture server start failed: {error}");
|
||||
return;
|
||||
},
|
||||
};
|
||||
let settings = match fixture_settings(server.endpoint_url.as_str()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
fail_test(format!("fixture settings failed: {}", error_code_text(&error)));
|
||||
return;
|
||||
},
|
||||
};
|
||||
let channel = match ksp_onchain_transport_lib::YellowstoneGrpcChannel::connect(&settings).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
fail_test(format!("fixture channel connect failed: {}", error_code_text(&error)));
|
||||
return;
|
||||
},
|
||||
};
|
||||
let request = ksp_onchain_transport_lib::YellowstoneSubscribeRequest::new();
|
||||
let mut session = match channel.open_standard_subscribe(request).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
fail_test(format!("fixture subscribe failed: {}", error_code_text(&error)));
|
||||
return;
|
||||
},
|
||||
};
|
||||
let expected = match ksp_raw_transaction_lib::serialize_solana_transaction_wire(&expected_v1_wire()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
fail_test(format!("expected V1 wire serialization failed: {}", error_code_text(&error)));
|
||||
return;
|
||||
},
|
||||
};
|
||||
let transaction = match session.next_update().await {
|
||||
std::result::Result::Ok(std::option::Option::Some(value)) => value,
|
||||
std::result::Result::Ok(std::option::Option::None) => {
|
||||
fail_test("transaction update must be present".to_owned());
|
||||
return;
|
||||
},
|
||||
std::result::Result::Err(error) => {
|
||||
fail_test(format!("transaction update decode failed: {}", error_code_text(&error)));
|
||||
return;
|
||||
},
|
||||
};
|
||||
let transaction = match transaction {
|
||||
ksp_onchain_transport_lib::YellowstoneSubscribeUpdate::Transaction(value) => value,
|
||||
_ => {
|
||||
fail_test("first Yellowstone fixture update must be a transaction".to_owned());
|
||||
return;
|
||||
},
|
||||
};
|
||||
assert_eq!(transaction.slot(), 42);
|
||||
assert_eq!(transaction.transaction().index(), 3);
|
||||
assert_eq!(transaction.transaction().meta().fee(), 5_000);
|
||||
let transaction_wire = match project_wire(transaction.transaction().transaction()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
fail_test(format!("transaction projection failed: {}", error_code_text(&error)));
|
||||
return;
|
||||
},
|
||||
};
|
||||
let transaction_bytes = match ksp_raw_transaction_lib::serialize_solana_transaction_wire(&transaction_wire) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
fail_test(format!("transaction V1 wire serialization failed: {}", error_code_text(&error)));
|
||||
return;
|
||||
},
|
||||
};
|
||||
assert_eq!(transaction_bytes, expected);
|
||||
let block = match session.next_update().await {
|
||||
std::result::Result::Ok(std::option::Option::Some(value)) => value,
|
||||
std::result::Result::Ok(std::option::Option::None) => {
|
||||
fail_test("block update must be present".to_owned());
|
||||
return;
|
||||
},
|
||||
std::result::Result::Err(error) => {
|
||||
fail_test(format!("block update decode failed: {}", error_code_text(&error)));
|
||||
return;
|
||||
},
|
||||
};
|
||||
let block = match block {
|
||||
ksp_onchain_transport_lib::YellowstoneSubscribeUpdate::Block(value) => value,
|
||||
_ => {
|
||||
fail_test("second Yellowstone fixture update must be a block".to_owned());
|
||||
return;
|
||||
},
|
||||
};
|
||||
assert_eq!(block.slot(), 42);
|
||||
assert_eq!(block.block_time(), std::option::Option::Some(1_787_104_000));
|
||||
assert_eq!(block.transactions().len(), 1);
|
||||
assert_eq!(block.transactions()[0].index(), 3);
|
||||
let block_wire = match project_wire(block.transactions()[0].transaction()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
fail_test(format!("block transaction projection failed: {}", error_code_text(&error)));
|
||||
return;
|
||||
},
|
||||
};
|
||||
let block_bytes = match ksp_raw_transaction_lib::serialize_solana_transaction_wire(&block_wire) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
fail_test(format!("block V1 wire serialization failed: {}", error_code_text(&error)));
|
||||
return;
|
||||
},
|
||||
};
|
||||
assert_eq!(block_bytes, expected);
|
||||
assert_eq!(block_bytes, transaction_bytes);
|
||||
assert!(transaction.transaction().meta().error().is_none());
|
||||
assert_eq!(block.transactions()[0].meta().fee(), transaction.transaction().meta().fee());
|
||||
assert!(session.close().await.is_ok());
|
||||
server.stop().await;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-job-backfill-lib/unit_tests/conversion.rs
|
||||
// version: 2
|
||||
// version: 3
|
||||
|
||||
fn signature_text() -> std::option::Option<crate::BackfillSignature> {
|
||||
return match crate::BackfillSignature::new("1".repeat(64)) {
|
||||
@@ -46,6 +46,14 @@ fn candidate(network: &str, signature: crate::BackfillSignature) -> std::option:
|
||||
return std::option::Option::Some(crate::BackfillCandidate::new(crate::BackfillCandidateIdentity::new(network, signature), std::option::Option::Some(42)));
|
||||
}
|
||||
|
||||
fn raw_reference(network: &str) -> std::option::Option<ksp_store_lib::RawTransactionReference> {
|
||||
let network = match ksp_store_lib::RawNetworkId::new(network) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
return std::option::Option::Some(ksp_store_lib::RawTransactionReference::new(network, ksp_store_lib::RawTransactionSignature::new([0_u8; 64])));
|
||||
}
|
||||
|
||||
fn received_at() -> std::option::Option<ksp_store_lib::RawTimestamp> {
|
||||
return match ksp_store_lib::RawTimestamp::from_unix_millis(1_700_000_001_000) {
|
||||
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
||||
@@ -169,21 +177,26 @@ fn pre_006_wire_omission_and_null_produce_distinct_canonical_bytes() {
|
||||
let version_null = ksp_onchain_transport_lib::SolanaWireField::Null;
|
||||
let transaction_index_omitted = ksp_onchain_transport_lib::SolanaWireField::Omitted;
|
||||
let transaction_index_null = ksp_onchain_transport_lib::SolanaWireField::Null;
|
||||
let omitted = super::canonical_payload_bytes(&fields(&transaction, &meta_omitted, &version_omitted, &transaction_index_omitted, std::option::Option::None));
|
||||
let reference = match raw_reference("devnet") {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let omitted =
|
||||
super::canonical_transaction(&reference, fields(&transaction, &meta_omitted, &version_omitted, &transaction_index_omitted, std::option::Option::None));
|
||||
assert!(omitted.is_ok());
|
||||
let omitted = match omitted {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(omitted, b"{\"transaction\":[\"AQID\",\"base64\"]}");
|
||||
let nulls = super::canonical_payload_bytes(&fields(&transaction, &meta_null, &version_null, &transaction_index_null, std::option::Option::None));
|
||||
assert_eq!(omitted.payload().bytes(), b"{\"transaction\":[\"AQID\",\"base64\"]}");
|
||||
let nulls = super::canonical_transaction(&reference, fields(&transaction, &meta_null, &version_null, &transaction_index_null, std::option::Option::None));
|
||||
assert!(nulls.is_ok());
|
||||
let nulls = match nulls {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(nulls, b"{\"transaction\":[\"AQID\",\"base64\"],\"meta\":null,\"version\":null,\"transactionIndex\":null}");
|
||||
assert_ne!(omitted, nulls);
|
||||
assert_eq!(nulls.payload().bytes(), b"{\"transaction\":[\"AQID\",\"base64\"],\"meta\":null,\"version\":null,\"transactionIndex\":null}");
|
||||
assert_ne!(omitted.payload().bytes(), nulls.payload().bytes());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -198,8 +211,12 @@ fn pre_006_non_base64_transaction_shapes_are_rejected() {
|
||||
let meta = ksp_onchain_transport_lib::SolanaWireField::Omitted;
|
||||
let version = ksp_onchain_transport_lib::SolanaWireField::Omitted;
|
||||
let transaction_index = ksp_onchain_transport_lib::SolanaWireField::Omitted;
|
||||
let reference = match raw_reference("devnet") {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
for transaction in [&base58, &legacy, &json] {
|
||||
let result = super::canonical_payload_bytes(&fields(transaction, &meta, &version, &transaction_index, std::option::Option::None));
|
||||
let result = super::canonical_transaction(&reference, fields(transaction, &meta, &version, &transaction_index, std::option::Option::None));
|
||||
assert!(result.is_err());
|
||||
if let std::result::Result::Err(error) = result {
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_BACKFILL_RAW_CONVERSION_INVALID);
|
||||
@@ -210,12 +227,26 @@ fn pre_006_non_base64_transaction_shapes_are_rejected() {
|
||||
|
||||
#[test]
|
||||
fn pre_006_negative_and_unrepresentable_block_times_are_terminal_conversion_errors() {
|
||||
let negative = super::convert_block_time(std::option::Option::Some(-1));
|
||||
let reference = match raw_reference("devnet") {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let transaction = ksp_onchain_transport_lib::SolanaEncodedTransaction::Binary {
|
||||
data: "AQID".to_owned(),
|
||||
encoding: ksp_onchain_transport_lib::SolanaTransactionBinaryEncoding::Base64,
|
||||
};
|
||||
let meta = ksp_onchain_transport_lib::SolanaWireField::Omitted;
|
||||
let version = ksp_onchain_transport_lib::SolanaWireField::Omitted;
|
||||
let transaction_index = ksp_onchain_transport_lib::SolanaWireField::Omitted;
|
||||
let negative = super::canonical_transaction(&reference, fields(&transaction, &meta, &version, &transaction_index, std::option::Option::Some(-1)));
|
||||
assert!(negative.is_err());
|
||||
let oversized = super::convert_block_time(std::option::Option::Some(i64::MAX));
|
||||
let oversized = super::canonical_transaction(&reference, fields(&transaction, &meta, &version, &transaction_index, std::option::Option::Some(i64::MAX)));
|
||||
assert!(oversized.is_err());
|
||||
let absent = super::convert_block_time(std::option::Option::None);
|
||||
assert!(matches!(absent, std::result::Result::Ok(std::option::Option::None)));
|
||||
let absent = super::canonical_transaction(&reference, fields(&transaction, &meta, &version, &transaction_index, std::option::Option::None));
|
||||
assert!(absent.is_ok());
|
||||
if let std::result::Result::Ok(absent) = absent {
|
||||
assert!(absent.block_time().is_none());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -242,6 +273,12 @@ fn pre_006_observation_key_is_deterministic_and_endpoint_specific() {
|
||||
let other_endpoint = super::observation_key(&request, &reference, "provider", "endpoint-b");
|
||||
let other_provider = super::observation_key(&request, &reference, "provider-2", "endpoint-a");
|
||||
assert_eq!(first, same);
|
||||
assert_eq!(
|
||||
first.as_bytes(),
|
||||
&[
|
||||
184, 85, 15, 15, 33, 101, 243, 112, 222, 145, 139, 212, 251, 14, 199, 130, 57, 26, 253, 221, 184, 140, 87, 240, 16, 116, 62, 0, 11, 19, 103, 2
|
||||
]
|
||||
);
|
||||
assert_ne!(first, other_endpoint);
|
||||
assert_ne!(first, other_provider);
|
||||
return;
|
||||
@@ -294,6 +331,15 @@ fn pre_006_missing_outcome_contains_only_network_scoped_reference() {
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_003_backfill_uses_common_raw_v1_contract_without_changing_frozen_identity() {
|
||||
assert_eq!(crate::RAW_TRANSACTION_FORMAT_ID, ksp_raw_transaction_lib::RAW_TRANSACTION_FORMAT_ID);
|
||||
assert_eq!(crate::RAW_TRANSACTION_FORMAT_VERSION, ksp_raw_transaction_lib::RAW_TRANSACTION_FORMAT_VERSION);
|
||||
assert_eq!(crate::MIN_BACKFILL_SIGNATURE_TEXT_BYTES, ksp_raw_transaction_lib::MIN_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES);
|
||||
assert_eq!(crate::MAX_BACKFILL_SIGNATURE_TEXT_BYTES, ksp_raw_transaction_lib::MAX_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES);
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_006_fix_001_raw_acquisition_uses_one_private_indirection() {
|
||||
assert_eq!(std::mem::size_of::<crate::BackfillRawAcquisition>(), std::mem::size_of::<usize>(),);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-job-backfill-lib/unit_tests/request.rs
|
||||
// version: 2
|
||||
// version: 3
|
||||
|
||||
fn signature(character: char) -> std::option::Option<crate::BackfillSignature> {
|
||||
return match crate::BackfillSignature::new(character.to_string().repeat(crate::MIN_BACKFILL_SIGNATURE_TEXT_BYTES)) {
|
||||
@@ -235,7 +235,7 @@ fn pre_005_scope_kind_and_anchor_are_distinct_semantics() {
|
||||
|
||||
#[test]
|
||||
fn pre_011_resume_reissues_checkpoint_for_new_job_without_changing_scope_semantics() {
|
||||
let network = match network("mainnet-beta") {
|
||||
let network = match network("mainnet") {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: crates/ksp-onchain-transport-lib/USAGE.md -->
|
||||
<!-- version: 23 -->
|
||||
<!-- version: 26 -->
|
||||
|
||||
# Utilisation de `ksp-onchain-transport-lib`
|
||||
|
||||
@@ -281,7 +281,7 @@ let grpc_endpoint = match ksp_onchain_transport_lib::YellowstoneGrpcEndpointSett
|
||||
"publicnode_mainnet_yellowstone",
|
||||
true,
|
||||
ksp_onchain_transport_lib::YellowstoneGrpcProviderName::new("publicnode"),
|
||||
ksp_onchain_transport_lib::YellowstoneGrpcClusterName::new("mainnet-beta"),
|
||||
ksp_onchain_transport_lib::YellowstoneGrpcClusterName::new("mainnet"),
|
||||
grpc_url,
|
||||
ksp_onchain_transport_lib::YellowstoneGrpcSessionSettings::default(),
|
||||
)
|
||||
@@ -387,9 +387,11 @@ let snapshot = stream.snapshot();
|
||||
let closed = stream.close().await;
|
||||
```
|
||||
|
||||
`try_update()` remplace dynamiquement la requête complète tant que la session est `Active`. Une mutation pendant `Reconnecting` est refusée pour éviter une application ambiguë. Le snapshot expose reconnects, replay attempts, gaps, duplicates, dernier `from_slot` demandé et dernier slot observé, sans endpoint ni payload arbitraire.
|
||||
`try_update()` remplace dynamiquement la requête complète tant que la session est `Active`. Une mutation pendant `Reconnecting` est refusée pour éviter une application ambiguë. Le snapshot expose reconnects, replay attempts, replay deliveries conservatrices, reprises dont la couverture reste non prouvée, gaps de rétention, duplicates, dernier `from_slot` demandé et dernier slot observé, sans endpoint ni payload arbitraire.
|
||||
|
||||
Le reconnect réutilise la dernière requête acceptée et peut avancer `from_slot`, mais le consumer doit traiter cette reprise comme best-effort. KSP ne promet ni exactly-once, ni replay historique complet, ni absence de fork/equivocation entre nœuds.
|
||||
Une `replay_delivery_count` n'augmente que lorsque le premier flux post-reconnect redélivre exactement le slot de reprise demandé. Elle prouve une livraison au bord de replay, pas la complétude de l'intervalle. Dès que le premier update slot-bearing atteint ou dépasse cette borne, `replay_coverage_unproven_count` augmente aussi : Transport ne possède pas de preuve générique que tous les updates correspondant aux filtres ont été livrés entre la borne et la reprise live. Le compteur augmente également si un nouveau reconnect survient avant tout matériau slot-bearing. Cette valeur signifie explicitement « couverture non prouvée » ; elle ne prouve pas qu'un événement filtré existait ou a été perdu.
|
||||
|
||||
Le reconnect réutilise la dernière requête acceptée et peut avancer `from_slot`, mais le consumer doit traiter cette reprise comme best-effort. `replay_attempt_count`, `replay_delivery_count` et une preuve de coverage sont des notions distinctes. Transport ne publie actuellement aucun compteur `replay_covered` générique, car ni l'acceptation de `from_slot` ni une livraison ponctuelle ne démontrent à elles seules une couverture historique complète. KSP ne promet ni exactly-once, ni replay historique complet, ni absence de fork/equivocation entre nœuds.
|
||||
|
||||
## 5. Appels typés
|
||||
|
||||
@@ -449,7 +451,9 @@ let inflation_rate = pool.get_inflation_rate(&role).await;
|
||||
let stake_minimum = pool.get_stake_minimum_delegation(&role, Some(&context)).await;
|
||||
```
|
||||
|
||||
`getBlock` possède également une forme bare-encoding legacy séparée et deprecated. Les valeurs Economics restent celles du runtime : le consumer ne doit pas supposer localement un taux d'inflation ou un minimum de délégation constant.
|
||||
`getBlock` possède également une forme bare-encoding legacy séparée et deprecated. Pour les consumers qui doivent conserver la provenance exacte d’un pool multi-endpoint, `get_block_observed(...)` exécute la même forme moderne et retourne la valeur typée avec le nom sûr de l’endpoint et le provider réellement gagnants après sélection/retry/reroute. Cette projection n’expose ni URL, ni headers, ni body HTTP brut. Une réponse RPC `null` reste `None` dans la valeur observée.
|
||||
|
||||
Lorsque `transactionDetails = full` et `encoding = base64`, chaque `SolanaBlockTransaction` conserve le transaction wire Base64, `meta` et `version`. Le consumer peut alors projeter ces DTOs vers sa couche métier sans faire dépendre Transport d’une canonicalisation RAW particulière. Les valeurs Economics restent celles du runtime : le consumer ne doit pas supposer localement un taux d'inflation ou un minimum de délégation constant.
|
||||
|
||||
## 6. Exécution JSON-RPC standard générique
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":{"previousBlockhash":"previous-blockhash-fixture","blockhash":"blockhash-fixture","parentSlot":430000122,"rewards":[],"numRewardPartitions":0,"blockTime":1787072400,"blockHeight":410000000,"transactions":[{"transaction":["AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","base64"],"meta":{"err":null,"fee":5000},"version":"legacy"}]},"id":1}
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/grpc_stream.rs
|
||||
// version: 3
|
||||
// version: 5
|
||||
|
||||
use tonic_prost::prost::Message; // rust-rules: trait-import
|
||||
|
||||
@@ -34,11 +34,50 @@ pub struct YellowstoneGrpcSubscribeSnapshot {
|
||||
continuity_gap_count: u64,
|
||||
duplicate_update_count: u64,
|
||||
replay_attempt_count: u64,
|
||||
replay_delivery_count: u64,
|
||||
replay_coverage_unproven_count: u64,
|
||||
last_requested_from_slot: std::option::Option<u64>,
|
||||
last_observed_slot: std::option::Option<u64>,
|
||||
terminal_error_code: std::option::Option<ksp_core_lib::ErrorCode>,
|
||||
}
|
||||
|
||||
/// Cloneable latest-value observer for one standard Yellowstone Subscribe session snapshot.
|
||||
///
|
||||
/// The observer exposes only the already-safe transport snapshot and keeps the internal Tokio watch channel private. Cloning the observer does not duplicate
|
||||
/// the gRPC stream, request state or reconnect actor.
|
||||
#[derive(Clone)]
|
||||
pub struct YellowstoneGrpcSubscribeSnapshotSource {
|
||||
receiver: tokio::sync::watch::Receiver<crate::YellowstoneGrpcSubscribeSnapshot>,
|
||||
}
|
||||
|
||||
impl crate::YellowstoneGrpcSubscribeSnapshotSource {
|
||||
fn new(receiver: tokio::sync::watch::Receiver<crate::YellowstoneGrpcSubscribeSnapshot>) -> Self {
|
||||
return Self { receiver };
|
||||
}
|
||||
|
||||
/// Returns the current safe reconnect/replay snapshot without waiting for another actor transition.
|
||||
#[must_use]
|
||||
pub fn current(&self) -> crate::YellowstoneGrpcSubscribeSnapshot {
|
||||
return *self.receiver.borrow();
|
||||
}
|
||||
|
||||
/// Waits for one newer safe reconnect/replay snapshot.
|
||||
///
|
||||
/// `None` means the owning Subscribe actor dropped the latest-value publisher and no further snapshot can arrive.
|
||||
pub async fn wait_for_change(&mut self) -> std::option::Option<crate::YellowstoneGrpcSubscribeSnapshot> {
|
||||
return match self.receiver.changed().await {
|
||||
std::result::Result::Ok(()) => std::option::Option::Some(*self.receiver.borrow_and_update()),
|
||||
std::result::Result::Err(_) => std::option::Option::None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for crate::YellowstoneGrpcSubscribeSnapshotSource {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter.debug_struct("YellowstoneGrpcSubscribeSnapshotSource").field("current", &self.current()).finish();
|
||||
}
|
||||
}
|
||||
|
||||
impl YellowstoneGrpcSubscribeSnapshot {
|
||||
const fn new(initial_from_slot: std::option::Option<u64>) -> Self {
|
||||
return Self {
|
||||
@@ -47,6 +86,8 @@ impl YellowstoneGrpcSubscribeSnapshot {
|
||||
continuity_gap_count: 0,
|
||||
duplicate_update_count: 0,
|
||||
replay_attempt_count: 0,
|
||||
replay_delivery_count: 0,
|
||||
replay_coverage_unproven_count: 0,
|
||||
last_requested_from_slot: initial_from_slot,
|
||||
last_observed_slot: std::option::Option::None,
|
||||
terminal_error_code: std::option::Option::None,
|
||||
@@ -85,6 +126,25 @@ impl YellowstoneGrpcSubscribeSnapshot {
|
||||
return self.replay_attempt_count;
|
||||
}
|
||||
|
||||
/// Returns the number of replay-bearing reconnects that delivered the requested replay boundary slot again.
|
||||
///
|
||||
/// This is conservative delivery evidence only. It does not prove that every matching update in the replay interval was delivered and must never be
|
||||
/// interpreted as `replay_covered`.
|
||||
#[must_use]
|
||||
pub const fn replay_delivery_count(self) -> u64 {
|
||||
return self.replay_delivery_count;
|
||||
}
|
||||
|
||||
/// Returns the number of successful replay-bearing reconnects whose target coverage remains unproven.
|
||||
///
|
||||
/// The counter advances when the first post-reconnect slot-bearing update reaches or passes the requested replay boundary, because generic Transport
|
||||
/// cannot prove from that delivery alone that every matching update in the replay interval was delivered. It also advances when another reconnect starts
|
||||
/// before any slot-bearing replay material arrives. This is an explicit lack of coverage proof, not proof that a filtered event actually existed or was lost.
|
||||
#[must_use]
|
||||
pub const fn replay_coverage_unproven_count(self) -> u64 {
|
||||
return self.replay_coverage_unproven_count;
|
||||
}
|
||||
|
||||
/// Returns the most recent effective `from_slot` sent by KSP, including any clamp to `SubscribeReplayInfo.first_available`.
|
||||
#[must_use]
|
||||
pub const fn last_requested_from_slot(self) -> std::option::Option<u64> {
|
||||
@@ -154,6 +214,12 @@ impl SolanaYellowstoneGrpcSubscribeSession {
|
||||
return *self.snapshot_rx.borrow();
|
||||
}
|
||||
|
||||
/// Returns one cloneable latest-value observer for reconnect/replay snapshot transitions.
|
||||
#[must_use]
|
||||
pub fn snapshot_source(&self) -> crate::YellowstoneGrpcSubscribeSnapshotSource {
|
||||
return crate::YellowstoneGrpcSubscribeSnapshotSource::new(self.snapshot_rx.clone());
|
||||
}
|
||||
|
||||
/// Queues one complete standard Yellowstone request mutation without waiting for network dispatch.
|
||||
///
|
||||
/// The mutation is rejected synchronously when local validation fails, the encoded request exceeds the configured outbound bound, the bounded request
|
||||
@@ -463,17 +529,44 @@ enum UpdateIdentity {
|
||||
}
|
||||
|
||||
struct ContinuityTracker {
|
||||
pending_replay_from_slot: std::option::Option<u64>,
|
||||
recent_order: std::collections::VecDeque<UpdateIdentity>,
|
||||
recent_set: std::collections::HashSet<UpdateIdentity>,
|
||||
}
|
||||
|
||||
impl ContinuityTracker {
|
||||
fn new() -> Self {
|
||||
return Self { recent_order: std::collections::VecDeque::new(), recent_set: std::collections::HashSet::new() };
|
||||
return Self {
|
||||
pending_replay_from_slot: std::option::Option::None,
|
||||
recent_order: std::collections::VecDeque::new(),
|
||||
recent_set: std::collections::HashSet::new(),
|
||||
};
|
||||
}
|
||||
|
||||
fn begin_replay(&mut self, from_slot: std::option::Option<u64>) {
|
||||
self.pending_replay_from_slot = from_slot;
|
||||
return;
|
||||
}
|
||||
|
||||
fn abandon_pending_replay(&mut self, snapshot: &mut crate::YellowstoneGrpcSubscribeSnapshot) -> bool {
|
||||
if self.pending_replay_from_slot.take().is_none() {
|
||||
return false;
|
||||
}
|
||||
snapshot.replay_coverage_unproven_count = snapshot.replay_coverage_unproven_count.saturating_add(1);
|
||||
return true;
|
||||
}
|
||||
|
||||
fn observe(&mut self, update: &crate::YellowstoneSubscribeUpdate, snapshot: &mut crate::YellowstoneGrpcSubscribeSnapshot) {
|
||||
if let std::option::Option::Some(slot) = update_slot(update) {
|
||||
if let std::option::Option::Some(requested) = self.pending_replay_from_slot
|
||||
&& slot >= requested
|
||||
{
|
||||
if slot == requested {
|
||||
snapshot.replay_delivery_count = snapshot.replay_delivery_count.saturating_add(1);
|
||||
}
|
||||
snapshot.replay_coverage_unproven_count = snapshot.replay_coverage_unproven_count.saturating_add(1);
|
||||
self.pending_replay_from_slot = std::option::Option::None;
|
||||
}
|
||||
snapshot.last_observed_slot = std::option::Option::Some(match snapshot.last_observed_slot {
|
||||
std::option::Option::Some(previous) => std::cmp::max(previous, slot),
|
||||
std::option::Option::None => slot,
|
||||
@@ -577,6 +670,7 @@ async fn run_subscribe_actor(
|
||||
&mut shutdown_rx,
|
||||
&mut snapshot,
|
||||
&snapshot_tx,
|
||||
&mut tracker,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -619,6 +713,7 @@ async fn run_subscribe_actor(
|
||||
&mut shutdown_rx,
|
||||
&mut snapshot,
|
||||
&snapshot_tx,
|
||||
&mut tracker,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -658,8 +753,12 @@ async fn reconnect_subscribe_stream(
|
||||
shutdown_rx: &mut tokio::sync::watch::Receiver<std::option::Option<tokio::time::Instant>>,
|
||||
snapshot: &mut crate::YellowstoneGrpcSubscribeSnapshot,
|
||||
snapshot_tx: &tokio::sync::watch::Sender<crate::YellowstoneGrpcSubscribeSnapshot>,
|
||||
tracker: &mut ContinuityTracker,
|
||||
) -> ReconnectOutcome {
|
||||
clear_request_sender(request_state);
|
||||
if tracker.abandon_pending_replay(snapshot) {
|
||||
snapshot_tx.send_replace(*snapshot);
|
||||
}
|
||||
snapshot.state = crate::YellowstoneGrpcSubscribeState::Reconnecting;
|
||||
snapshot.terminal_error_code = std::option::Option::None;
|
||||
snapshot_tx.send_replace(*snapshot);
|
||||
@@ -742,6 +841,7 @@ async fn reconnect_subscribe_stream(
|
||||
return ReconnectOutcome::Exhausted(error);
|
||||
}
|
||||
snapshot.reconnect_count = snapshot.reconnect_count.saturating_add(1);
|
||||
tracker.begin_replay(effective_from_slot);
|
||||
snapshot.state = crate::YellowstoneGrpcSubscribeState::Active;
|
||||
snapshot.terminal_error_code = std::option::Option::None;
|
||||
snapshot_tx.send_replace(*snapshot);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/grpc_subscribe.rs
|
||||
// version: 8
|
||||
// version: 9
|
||||
|
||||
const MAX_GRPC_BLOCKHASH_TEXT_LENGTH_BYTES: usize = 128;
|
||||
const MAX_GRPC_BLOCK_VECTOR_COUNT: usize = 65_536;
|
||||
@@ -2529,13 +2529,34 @@ impl YellowstoneSubscribeEntryFilter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Opaque deterministic identity material for one complete Yellowstone Subscribe request.
|
||||
///
|
||||
/// The encoded bytes remain private. `Hash` feeds those canonical bytes to a caller-provided hasher while `Debug` exposes only their length.
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub struct YellowstoneSubscribeRequestIdentity {
|
||||
bytes: std::vec::Vec<u8>,
|
||||
}
|
||||
|
||||
impl std::hash::Hash for crate::YellowstoneSubscribeRequestIdentity {
|
||||
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||
state.write(&(self.bytes.len() as u64).to_be_bytes());
|
||||
state.write(self.bytes.as_slice());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for crate::YellowstoneSubscribeRequestIdentity {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter.debug_struct("YellowstoneSubscribeRequestIdentity").field("byte_len", &self.bytes.len()).finish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider-neutral standard Yellowstone subscribe request owned by KSP.
|
||||
///
|
||||
/// The seven upstream maps are represented independently and retain named empty entries. An entirely empty map is the logical KSP representation of no active
|
||||
/// filter in that family; protobuf map encoding does not distinguish an omitted map from an empty map. Filter-group names are globally unique across all seven
|
||||
/// maps so the names echoed by `SubscribeUpdate.filters` remain unambiguous. `Debug` exposes only counts and common scalar options, never filter names or
|
||||
/// future
|
||||
/// filter payloads.
|
||||
/// future filter payloads.
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub struct YellowstoneSubscribeRequest {
|
||||
accounts: std::collections::BTreeMap<crate::YellowstoneSubscribeFilterName, crate::YellowstoneSubscribeAccountFilter>,
|
||||
@@ -2551,7 +2572,7 @@ pub struct YellowstoneSubscribeRequest {
|
||||
from_slot: std::option::Option<u64>,
|
||||
}
|
||||
|
||||
impl YellowstoneSubscribeRequest {
|
||||
impl crate::YellowstoneSubscribeRequest {
|
||||
/// Creates an empty subscribe request. Empty requests are valid because later bidi lifecycle code uses request mutations to clear filters or carry ping
|
||||
/// state.
|
||||
#[must_use]
|
||||
@@ -2762,6 +2783,40 @@ impl YellowstoneSubscribeRequest {
|
||||
return self.from_slot;
|
||||
}
|
||||
|
||||
/// Builds one opaque deterministic identity for the complete logical Subscribe request.
|
||||
///
|
||||
/// The identity preserves filter-family separation, globally sorted filter names, exact filter wire payloads and common request options. Its Debug surface
|
||||
/// exposes only the encoded byte length. Callers may hash the opaque value but cannot recover the underlying identity bytes through this API.
|
||||
pub fn identity(&self) -> ksp_core_lib::Result<crate::YellowstoneSubscribeRequestIdentity> {
|
||||
let validation = self.validate();
|
||||
if let std::result::Result::Err(error) = validation {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let mut bytes = std::vec::Vec::new();
|
||||
append_subscribe_identity_map(&mut bytes, b"accounts", &self.accounts, |filter| return filter.to_wire());
|
||||
append_subscribe_identity_map(&mut bytes, b"blocks", &self.blocks, |filter| return filter.to_wire());
|
||||
append_subscribe_identity_map(&mut bytes, b"blocks_meta", &self.blocks_meta, |filter| return filter.to_wire());
|
||||
append_subscribe_identity_map(&mut bytes, b"entry", &self.entry, |filter| return filter.to_wire());
|
||||
append_subscribe_identity_map(&mut bytes, b"slots", &self.slots, |filter| return filter.to_wire());
|
||||
append_subscribe_identity_map(&mut bytes, b"transactions", &self.transactions, |filter| return filter.to_wire());
|
||||
append_subscribe_identity_map(&mut bytes, b"transactions_status", &self.transactions_status, |filter| return filter.to_wire());
|
||||
let mut common = match self.to_wire() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
common.accounts.clear();
|
||||
common.blocks.clear();
|
||||
common.blocks_meta.clear();
|
||||
common.entry.clear();
|
||||
common.slots.clear();
|
||||
common.transactions.clear();
|
||||
common.transactions_status.clear();
|
||||
let common = yellowstone_grpc_proto::prost::Message::encode_to_vec(&common);
|
||||
append_subscribe_identity_component(&mut bytes, b"common");
|
||||
append_subscribe_identity_component(&mut bytes, common.as_slice());
|
||||
return std::result::Result::Ok(crate::YellowstoneSubscribeRequestIdentity { bytes });
|
||||
}
|
||||
|
||||
/// Validates all deterministic common subscribe-request bounds before any network I/O.
|
||||
pub fn validate(&self) -> ksp_core_lib::Result<()> {
|
||||
if self.total_filter_count() > MAX_GRPC_SUBSCRIBE_FILTER_GROUP_COUNT {
|
||||
@@ -3710,13 +3765,38 @@ fn commitment_to_wire(commitment: std::option::Option<crate::SolanaCommitment>)
|
||||
});
|
||||
}
|
||||
|
||||
impl std::default::Default for YellowstoneSubscribeRequest {
|
||||
fn append_subscribe_identity_component(output: &mut std::vec::Vec<u8>, value: &[u8]) {
|
||||
output.extend_from_slice(&(value.len() as u64).to_be_bytes());
|
||||
output.extend_from_slice(value);
|
||||
return;
|
||||
}
|
||||
|
||||
fn append_subscribe_identity_map<V, W, F>(
|
||||
output: &mut std::vec::Vec<u8>,
|
||||
family: &[u8],
|
||||
values: &std::collections::BTreeMap<crate::YellowstoneSubscribeFilterName, V>,
|
||||
mut to_wire: F,
|
||||
) where
|
||||
W: yellowstone_grpc_proto::prost::Message,
|
||||
F: FnMut(&V) -> W,
|
||||
{
|
||||
append_subscribe_identity_component(output, family);
|
||||
output.extend_from_slice(&(values.len() as u64).to_be_bytes());
|
||||
for (name, filter) in values {
|
||||
append_subscribe_identity_component(output, name.as_str().as_bytes());
|
||||
let wire = yellowstone_grpc_proto::prost::Message::encode_to_vec(&to_wire(filter));
|
||||
append_subscribe_identity_component(output, wire.as_slice());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
impl std::default::Default for crate::YellowstoneSubscribeRequest {
|
||||
fn default() -> Self {
|
||||
return Self::new();
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for YellowstoneSubscribeRequest {
|
||||
impl std::fmt::Debug for crate::YellowstoneSubscribeRequest {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter
|
||||
.debug_struct("YellowstoneSubscribeRequest")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/lib.rs
|
||||
// version: 45
|
||||
// version: 48
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
@@ -143,6 +143,8 @@ pub use self::grpc_settings::YellowstoneGrpcTransportSettings;
|
||||
pub use self::grpc_stream::SolanaYellowstoneGrpcSubscribeSession;
|
||||
/// Safe Yellowstone reconnect/replay continuity snapshot.
|
||||
pub use self::grpc_stream::YellowstoneGrpcSubscribeSnapshot;
|
||||
/// Cloneable latest-value observer for one standard Yellowstone Subscribe session snapshot.
|
||||
pub use self::grpc_stream::YellowstoneGrpcSubscribeSnapshotSource;
|
||||
/// Safe Yellowstone bidirectional Subscribe lifecycle state.
|
||||
pub use self::grpc_stream::YellowstoneGrpcSubscribeState;
|
||||
/// One validated standard Yellowstone account predicate.
|
||||
@@ -213,6 +215,8 @@ pub use self::grpc_subscribe::YellowstoneSubscribePingUpdate;
|
||||
pub use self::grpc_subscribe::YellowstoneSubscribePongUpdate;
|
||||
/// Provider-neutral standard Yellowstone Subscribe request.
|
||||
pub use self::grpc_subscribe::YellowstoneSubscribeRequest;
|
||||
/// Opaque deterministic identity for one complete Yellowstone Subscribe request.
|
||||
pub use self::grpc_subscribe::YellowstoneSubscribeRequestIdentity;
|
||||
/// Complete slot-family filter group for standard Yellowstone Subscribe.
|
||||
pub use self::grpc_subscribe::YellowstoneSubscribeSlotFilter;
|
||||
/// Complete transaction-family filter shared by transactions and transaction-status maps.
|
||||
@@ -551,6 +555,8 @@ pub use self::ws_protocol_session::HeliusLaserStreamWsSession;
|
||||
pub use self::ws_protocol_session::SolanaStandardWsSession;
|
||||
/// Shareable compatibility handle for one explicitly created standard Solana physical WebSocket session.
|
||||
pub use self::ws_session::WsSession;
|
||||
/// Cloneable latest-value observer for one safe physical WebSocket session snapshot.
|
||||
pub use self::ws_session::WsSessionSnapshotSource;
|
||||
/// Open cluster or network descriptor used by WebSocket endpoint settings.
|
||||
pub use self::ws_settings::WsClusterName;
|
||||
/// Runtime settings for one named WebSocket endpoint.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/rpc_blocks.rs
|
||||
// version: 7
|
||||
// version: 8
|
||||
|
||||
const MAX_GET_BLOCKS_RANGE: u64 = 500_000;
|
||||
const MAX_GET_RECENT_PERFORMANCE_SAMPLES: u64 = 720;
|
||||
@@ -591,25 +591,42 @@ impl crate::HttpTransportPool {
|
||||
slot: u64,
|
||||
config: std::option::Option<&crate::SolanaGetBlockConfig>,
|
||||
) -> ksp_core_lib::Result<std::option::Option<crate::SolanaConfirmedBlock>> {
|
||||
if let std::option::Option::Some(config) = config
|
||||
&& config.commitment() == std::option::Option::Some(crate::SolanaCommitment::Processed)
|
||||
{
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(
|
||||
crate::ERROR_CODE_INVALID_RPC_PARAMETERS,
|
||||
"getBlock commitment must be confirmed or finalized when explicitly provided",
|
||||
)
|
||||
.with_context("rpc_method", "getBlock")
|
||||
.with_context("commitment", "processed"),
|
||||
);
|
||||
}
|
||||
let mut params = std::vec![serde_json::json!(slot)];
|
||||
if let std::option::Option::Some(config) = config {
|
||||
params.push((*config).to_json_value());
|
||||
}
|
||||
let params = get_block_params(slot, config);
|
||||
let params = match params {
|
||||
std::result::Result::Ok(params) => params,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return self.execute_get_block(role, params).await;
|
||||
}
|
||||
|
||||
/// Executes the current object-form `getBlock` request and reports the safe identity of the endpoint that produced the successful response.
|
||||
///
|
||||
/// Routing, admission, timeout and retry behavior are identical to [`Self::get_block`]. The returned observation never contains an endpoint URL,
|
||||
/// HTTP headers or a raw HTTP body.
|
||||
pub async fn get_block_observed(
|
||||
&self,
|
||||
role: &crate::HttpRoleName,
|
||||
slot: u64,
|
||||
config: std::option::Option<&crate::SolanaGetBlockConfig>,
|
||||
) -> ksp_core_lib::Result<crate::HttpObservedValue<std::option::Option<crate::SolanaConfirmedBlock>>> {
|
||||
let params = get_block_params(slot, config);
|
||||
let params = match params {
|
||||
std::result::Result::Ok(params) => params,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let observed = self.execute_blocks_rpc_observed("getBlock", role, params).await;
|
||||
let observed = match observed {
|
||||
std::result::Result::Ok(observed) => observed,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let (value, endpoint_name, provider) = observed.into_parts();
|
||||
let block = decode_get_block(value);
|
||||
return match block {
|
||||
std::result::Result::Ok(block) => std::result::Result::Ok(crate::HttpObservedValue::new(block, endpoint_name, provider)),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
};
|
||||
}
|
||||
|
||||
/// Executes the deprecated bare-encoding `getBlock` request form retained by Solana RPC for backwards compatibility.
|
||||
#[deprecated(note = "use HttpTransportPool::get_block with SolanaGetBlockConfig; the bare encoding request form is deprecated")]
|
||||
pub async fn get_block_legacy(
|
||||
@@ -635,16 +652,8 @@ impl crate::HttpTransportPool {
|
||||
params: std::vec::Vec<serde_json::Value>,
|
||||
) -> ksp_core_lib::Result<std::option::Option<crate::SolanaConfirmedBlock>> {
|
||||
let value = self.execute_blocks_rpc("getBlock", role, 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 block = crate::SolanaConfirmedBlock::decode_wire("getBlock", value);
|
||||
return match block {
|
||||
std::result::Result::Ok(block) => std::result::Result::Ok(std::option::Option::Some(block)),
|
||||
return match value {
|
||||
std::result::Result::Ok(value) => decode_get_block(value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
};
|
||||
}
|
||||
@@ -829,6 +838,48 @@ impl crate::HttpTransportPool {
|
||||
};
|
||||
return self.execute_standard_rpc(role, method, params).await;
|
||||
}
|
||||
|
||||
async fn execute_blocks_rpc_observed(
|
||||
&self,
|
||||
method_name: &'static str,
|
||||
role: &crate::HttpRoleName,
|
||||
params: std::vec::Vec<serde_json::Value>,
|
||||
) -> ksp_core_lib::Result<crate::HttpObservedValue<serde_json::Value>> {
|
||||
let method = blocks_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_observed(role, method, params).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_get_block(value: serde_json::Value) -> ksp_core_lib::Result<std::option::Option<crate::SolanaConfirmedBlock>> {
|
||||
if value.is_null() {
|
||||
return std::result::Result::Ok(std::option::Option::None);
|
||||
}
|
||||
let block = crate::SolanaConfirmedBlock::decode_wire("getBlock", value);
|
||||
return match block {
|
||||
std::result::Result::Ok(block) => std::result::Result::Ok(std::option::Option::Some(block)),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
};
|
||||
}
|
||||
|
||||
fn get_block_params(slot: u64, config: std::option::Option<&crate::SolanaGetBlockConfig>) -> ksp_core_lib::Result<std::vec::Vec<serde_json::Value>> {
|
||||
if let std::option::Option::Some(config) = config
|
||||
&& config.commitment() == std::option::Option::Some(crate::SolanaCommitment::Processed)
|
||||
{
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RPC_PARAMETERS, "getBlock commitment must be confirmed or finalized when explicitly provided")
|
||||
.with_context("rpc_method", "getBlock")
|
||||
.with_context("commitment", "processed"),
|
||||
);
|
||||
}
|
||||
let mut params = std::vec![serde_json::json!(slot)];
|
||||
if let std::option::Option::Some(config) = config {
|
||||
params.push((*config).to_json_value());
|
||||
}
|
||||
return std::result::Result::Ok(params);
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/ws_protocol_session.rs
|
||||
// version: 6
|
||||
// version: 7
|
||||
|
||||
/// Typed facade for one standard Solana WebSocket physical session.
|
||||
///
|
||||
@@ -42,6 +42,12 @@ impl SolanaStandardWsSession {
|
||||
return self.inner.snapshot();
|
||||
}
|
||||
|
||||
/// Returns a cloneable latest-value observer for safe physical-session snapshots from the shared actor.
|
||||
#[must_use]
|
||||
pub fn snapshot_source(&self) -> crate::WsSessionSnapshotSource {
|
||||
return self.inner.snapshot_source();
|
||||
}
|
||||
|
||||
/// Returns the latest observable physical-session state.
|
||||
#[must_use]
|
||||
pub fn state(&self) -> crate::WsSessionState {
|
||||
@@ -117,6 +123,12 @@ impl HeliusLaserStreamWsSession {
|
||||
return self.inner.snapshot();
|
||||
}
|
||||
|
||||
/// Returns a cloneable latest-value observer for safe physical-session snapshots from the shared actor.
|
||||
#[must_use]
|
||||
pub fn snapshot_source(&self) -> crate::WsSessionSnapshotSource {
|
||||
return self.inner.snapshot_source();
|
||||
}
|
||||
|
||||
/// Returns the latest observable physical-session state.
|
||||
#[must_use]
|
||||
pub fn state(&self) -> crate::WsSessionState {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/ws_session.rs
|
||||
// version: 14
|
||||
// version: 15
|
||||
|
||||
use futures_util::SinkExt; // rust-rules: trait-import
|
||||
use futures_util::StreamExt; // rust-rules: trait-import
|
||||
@@ -10,6 +10,43 @@ const HELIUS_WS_HEARTBEAT_INTERVAL: std::time::Duration = std::time::Duration::f
|
||||
|
||||
type WsPhysicalStream = tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
|
||||
|
||||
/// Cloneable latest-value observer for one physical WebSocket session snapshot.
|
||||
///
|
||||
/// The observer exposes only the already-safe [`crate::WsSessionSnapshot`] projection and keeps the internal Tokio watch channel private. Cloning it does
|
||||
/// not create another socket, actor, reconnect loop or subscription registry.
|
||||
#[derive(Clone)]
|
||||
pub struct WsSessionSnapshotSource {
|
||||
receiver: tokio::sync::watch::Receiver<crate::WsSessionSnapshot>,
|
||||
}
|
||||
|
||||
impl crate::WsSessionSnapshotSource {
|
||||
fn new(receiver: tokio::sync::watch::Receiver<crate::WsSessionSnapshot>) -> Self {
|
||||
return Self { receiver };
|
||||
}
|
||||
|
||||
/// Returns the current safe physical-session snapshot without waiting for another actor transition.
|
||||
#[must_use]
|
||||
pub fn current(&self) -> crate::WsSessionSnapshot {
|
||||
return (*self.receiver.borrow()).clone();
|
||||
}
|
||||
|
||||
/// Waits for one newer safe physical-session snapshot.
|
||||
///
|
||||
/// `None` means the owning WebSocket actor dropped the latest-value publisher and no further snapshot can arrive.
|
||||
pub async fn wait_for_change(&mut self) -> std::option::Option<crate::WsSessionSnapshot> {
|
||||
return match self.receiver.changed().await {
|
||||
std::result::Result::Ok(()) => std::option::Option::Some((*self.receiver.borrow_and_update()).clone()),
|
||||
std::result::Result::Err(_) => std::option::Option::None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for crate::WsSessionSnapshotSource {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter.debug_struct("WsSessionSnapshotSource").field("current", &self.current()).finish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Shareable handle for one explicitly created physical WebSocket session.
|
||||
///
|
||||
/// The handle never exposes the sensitive endpoint URL or the underlying socket. All socket I/O is owned by one internal actor task and all caller
|
||||
@@ -124,6 +161,12 @@ impl WsSession {
|
||||
return self.snapshot_rx.borrow().clone();
|
||||
}
|
||||
|
||||
/// Returns a cloneable latest-value observer for safe physical-session snapshots.
|
||||
#[must_use]
|
||||
pub fn snapshot_source(&self) -> crate::WsSessionSnapshotSource {
|
||||
return crate::WsSessionSnapshotSource::new(self.snapshot_rx.clone());
|
||||
}
|
||||
|
||||
/// Returns the latest observable physical-session state.
|
||||
#[must_use]
|
||||
pub fn state(&self) -> crate::WsSessionState {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/tests/public_api.rs
|
||||
// version: 49
|
||||
// version: 54
|
||||
|
||||
//! Integration tests for the public `ksp-onchain-transport-lib` consumer contract.
|
||||
|
||||
@@ -569,6 +569,17 @@ fn public_v0_2_7_pre_004_physical_websocket_session_contract_is_available_from_c
|
||||
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_WS_SESSION_CLOSED.code(), "ws_session_closed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_v0_3_14_pre_003_websocket_snapshot_source_is_available_from_crate_root() {
|
||||
let _physical = ksp_onchain_transport_lib::WsSession::snapshot_source;
|
||||
let _standard = ksp_onchain_transport_lib::SolanaStandardWsSession::snapshot_source;
|
||||
let _helius = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::snapshot_source;
|
||||
let _current = ksp_onchain_transport_lib::WsSessionSnapshotSource::current;
|
||||
let _wait = ksp_onchain_transport_lib::WsSessionSnapshotSource::wait_for_change;
|
||||
assert!(std::any::type_name::<ksp_onchain_transport_lib::WsSessionSnapshotSource>().ends_with("WsSessionSnapshotSource"));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_v0_2_7_pre_005_bounded_websocket_close_contract_is_available_from_crate_root() {
|
||||
let _close = ksp_onchain_transport_lib::WsSession::close;
|
||||
@@ -1013,11 +1024,36 @@ fn public_v0_2_9_pre_010_yellowstone_reconnect_snapshot_is_available_from_crate_
|
||||
let _snapshot = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot>();
|
||||
let _state = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeState::Reconnecting;
|
||||
let _session_snapshot = ksp_onchain_transport_lib::SolanaYellowstoneGrpcSubscribeSession::snapshot;
|
||||
let _snapshot_source = ksp_onchain_transport_lib::SolanaYellowstoneGrpcSubscribeSession::snapshot_source;
|
||||
let _source_type = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshotSource>();
|
||||
let _source_current = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshotSource::current;
|
||||
let _source_wait = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshotSource::wait_for_change;
|
||||
let _reconnect_count = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot::reconnect_count;
|
||||
let _gap_count = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot::continuity_gap_count;
|
||||
let _duplicate_count = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot::duplicate_update_count;
|
||||
let _replay_count = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot::replay_attempt_count;
|
||||
let _delivery_count = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot::replay_delivery_count;
|
||||
let _coverage_unproven_count = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot::replay_coverage_unproven_count;
|
||||
let _requested = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot::last_requested_from_slot;
|
||||
let _observed = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot::last_observed_slot;
|
||||
let _terminal = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot::terminal_error_code;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_v0_3_10_pre_004_observed_get_block_surface_is_available_from_crate_root() {
|
||||
let method = ksp_onchain_transport_lib::HttpTransportPool::get_block_observed;
|
||||
let _ = method;
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_v0_3_13_pre_002_yellowstone_subscribe_identity_is_opaque_and_available_from_crate_root() {
|
||||
let request = ksp_onchain_transport_lib::YellowstoneSubscribeRequest::new();
|
||||
let identity = request.identity().expect("empty validated request identity must build");
|
||||
let _identity_type = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneSubscribeRequestIdentity>();
|
||||
let debug = std::format!("{identity:?}");
|
||||
assert!(debug.contains("YellowstoneSubscribeRequestIdentity"));
|
||||
assert!(debug.contains("byte_len"));
|
||||
assert!(!debug.contains("bytes:"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/tests/release_completeness.rs
|
||||
// version: 41
|
||||
// version: 46
|
||||
|
||||
//! Release-level completeness canaries for staged HTTP and WebSocket Transport coverage.
|
||||
|
||||
@@ -1000,14 +1000,10 @@ fn release_v0_2_8_pre_010_live_smoke_policy_preserves_secret_and_dependency_owne
|
||||
#[test]
|
||||
fn release_v0_2_9_pre_002_materializes_minimal_yellowstone_engine_without_provider_or_ws_coupling() {
|
||||
let manifest_directory = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let workspace = manifest_directory.parent().and_then(std::path::Path::parent).expect("Transport test must resolve workspace root");
|
||||
let root_manifest = std::fs::read_to_string(workspace.join("Cargo.toml")).expect("workspace manifest must be readable");
|
||||
let transport_manifest = std::fs::read_to_string(manifest_directory.join("Cargo.toml")).expect("Transport manifest must be readable");
|
||||
let settings_source = include_str!("../src/grpc_settings.rs");
|
||||
let channel_source = include_str!("../src/grpc_channel.rs");
|
||||
let crate_root = include_str!("../src/lib.rs");
|
||||
assert!(root_manifest.contains("tonic = { version = \"^0.14\", default-features = false }"));
|
||||
assert!(root_manifest.contains("yellowstone-grpc-proto = { version = \"^12.6\", default-features = false }"));
|
||||
assert!(transport_manifest.contains("tonic = { workspace = true, features = [\"channel\""));
|
||||
assert!(transport_manifest.contains("yellowstone-grpc-proto.workspace = true"));
|
||||
assert!(!transport_manifest.contains("yellowstone-grpc-client"));
|
||||
@@ -1028,15 +1024,11 @@ fn release_v0_2_9_pre_002_materializes_minimal_yellowstone_engine_without_provid
|
||||
#[test]
|
||||
fn release_v0_2_9_pre_003_adds_tls_metadata_and_exactly_seven_standard_unary_methods_without_subscribe() {
|
||||
let manifest_directory = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let workspace = manifest_directory.parent().and_then(std::path::Path::parent).expect("Transport test must resolve workspace root");
|
||||
let root_manifest = std::fs::read_to_string(workspace.join("Cargo.toml")).expect("workspace manifest must be readable");
|
||||
let transport_manifest = std::fs::read_to_string(manifest_directory.join("Cargo.toml")).expect("Transport manifest must be readable");
|
||||
let settings_source = include_str!("../src/grpc_settings.rs");
|
||||
let channel_source = include_str!("../src/grpc_channel.rs");
|
||||
let unary_source = include_str!("../src/grpc_unary.rs");
|
||||
let crate_root = include_str!("../src/lib.rs");
|
||||
assert!(root_manifest.contains("http = { version = \"^1.5\", default-features = false }"));
|
||||
assert!(root_manifest.contains("tonic-prost = { version = \"^0.14\", default-features = false }"));
|
||||
assert!(transport_manifest.contains("tonic = { workspace = true, features = [\"channel\", \"tls-aws-lc\", \"tls-webpki-roots\"] }"));
|
||||
assert!(transport_manifest.contains("tonic = { workspace = true, features = [\"codegen\", \"server\"] }"));
|
||||
assert!(transport_manifest.contains("tonic-prost.workspace = true"));
|
||||
@@ -1315,6 +1307,9 @@ fn release_v0_2_9_pre_010_adds_bounded_reconnect_replay_and_conservative_continu
|
||||
for required in [
|
||||
"YellowstoneGrpcSubscribeState::Reconnecting",
|
||||
"YellowstoneGrpcSubscribeSnapshot",
|
||||
"YellowstoneGrpcSubscribeSnapshotSource",
|
||||
"snapshot_source",
|
||||
"wait_for_change",
|
||||
"reconnect_subscribe_stream",
|
||||
"replay_first_available",
|
||||
"SubscribeReplayInfo",
|
||||
@@ -1344,3 +1339,63 @@ fn release_v0_2_9_pre_010_adds_bounded_reconnect_replay_and_conservative_continu
|
||||
let _snapshot = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot>();
|
||||
let _state = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeState::Reconnecting;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_v0_3_13_pre_002_yellowstone_subscribe_identity_remains_opaque_and_dependency_neutral() {
|
||||
let source = include_str!("../src/grpc_subscribe.rs");
|
||||
let root = include_str!("../src/lib.rs");
|
||||
let identity_start = source.find("pub struct YellowstoneSubscribeRequestIdentity").expect("identity struct must remain present");
|
||||
let request_start = source.find("/// Provider-neutral standard Yellowstone subscribe request").expect("request contract marker must remain present");
|
||||
let identity_surface = &source[identity_start..request_start];
|
||||
assert!(source.contains("pub fn identity(&self)"));
|
||||
assert!(root.contains("pub use self::grpc_subscribe::YellowstoneSubscribeRequestIdentity;"));
|
||||
assert!(!identity_surface.contains("pub fn bytes("));
|
||||
assert!(!identity_surface.contains("pub fn as_bytes("));
|
||||
assert!(!root.contains("pub use yellowstone_grpc_proto"));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_v0_3_14_pre_003_ws_snapshot_source_reuses_actor_watch_without_second_runtime() {
|
||||
let session_source = include_str!("../src/ws_session.rs");
|
||||
let facade_source = include_str!("../src/ws_protocol_session.rs");
|
||||
let session_tests = include_str!("../unit_tests/ws_session.rs");
|
||||
assert!(session_source.contains("pub struct WsSessionSnapshotSource"));
|
||||
assert!(session_source.contains("self.snapshot_rx.clone()"));
|
||||
assert!(facade_source.matches("pub fn snapshot_source").count() >= 2);
|
||||
for required in [
|
||||
"v0_3_14_pre_003_snapshot_source_tracks_latest_value_without_owning_runtime",
|
||||
"websocket_reconnect_resubscribes_in_local_id_order_and_remaps_remote_ids",
|
||||
"websocket_notification_queue_overflow_fails_only_slow_subscription_and_cleans_remote_binding",
|
||||
] {
|
||||
assert!(session_tests.contains(required), "missing pre.003 WebSocket continuity test: {required}");
|
||||
}
|
||||
assert!(!session_source.contains("WsSessionSnapshotSource {\n socket:"));
|
||||
assert!(!facade_source.contains("tokio_tungstenite::connect_async"));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_v0_3_14_pre_004_native_replay_evidence_never_conflates_attempt_delivery_and_coverage() {
|
||||
let stream_source = include_str!("../src/grpc_stream.rs");
|
||||
let stream_tests = include_str!("../unit_tests/grpc_stream.rs");
|
||||
for required in [
|
||||
"replay_attempt_count",
|
||||
"replay_delivery_count",
|
||||
"replay_coverage_unproven_count",
|
||||
"pending_replay_from_slot",
|
||||
"begin_replay",
|
||||
"abandon_pending_replay",
|
||||
"first_available > requested",
|
||||
"This is conservative delivery evidence only",
|
||||
"explicit lack of coverage proof",
|
||||
] {
|
||||
assert!(stream_source.contains(required), "missing pre.004 native replay evidence token: {required}");
|
||||
}
|
||||
assert!(stream_tests.contains("v0_3_14_pre_004_replay_acceptance_without_boundary_delivery_never_claims_coverage"));
|
||||
assert!(stream_tests.contains("yellowstone_reconnect_replays_from_last_observed_slot_and_counts_duplicate_identity"));
|
||||
assert!(stream_tests.contains("yellowstone_replay_info_proves_and_clamps_retention_gap_without_lossless_claim"));
|
||||
assert!(!stream_source.contains("replay_covered_count"));
|
||||
assert!(!stream_source.contains("replay_coverage_proven_count"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/tests/yellowstone_publicnode_smoke.rs
|
||||
// version: 5
|
||||
// version: 6
|
||||
|
||||
//! Opt-in live PublicNode Mainnet/Testnet smokes for authenticated provider-neutral Yellowstone gRPC Subscribe.
|
||||
|
||||
@@ -146,13 +146,8 @@ async fn assert_publicnode_slot_stream(name: &str, cluster: &str, url: &str, x_t
|
||||
#[ignore = "opt-in live PublicNode Mainnet Yellowstone gRPC smoke; reads the first personal x-token line from stdin and performs an external TLS/Subscribe request"]
|
||||
async fn publicnode_mainnet_yellowstone_streams_slots_with_network_scoped_secret_x_token() {
|
||||
let tokens = publicnode_x_tokens();
|
||||
assert_publicnode_slot_stream(
|
||||
"publicnode_mainnet_yellowstone",
|
||||
"mainnet-beta",
|
||||
"https://solana-yellowstone-grpc.publicnode.com:443",
|
||||
tokens.mainnet.as_str(),
|
||||
)
|
||||
.await;
|
||||
assert_publicnode_slot_stream("publicnode_mainnet_yellowstone", "mainnet", "https://solana-yellowstone-grpc.publicnode.com:443", tokens.mainnet.as_str())
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/grpc_stream.rs
|
||||
// version: 3
|
||||
// version: 6
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum FixtureMode {
|
||||
@@ -13,6 +13,7 @@ enum FixtureMode {
|
||||
Idle,
|
||||
ReconnectReplay,
|
||||
ReplayGap,
|
||||
ReplayAcceptedWithoutBoundary,
|
||||
ReconnectExhausted,
|
||||
}
|
||||
|
||||
@@ -153,12 +154,31 @@ impl yellowstone_grpc_proto::geyser::geyser_server::Geyser for FixtureGeyser {
|
||||
}
|
||||
}
|
||||
},
|
||||
FixtureMode::ReplayAcceptedWithoutBoundary => {
|
||||
if subscribe_call == 1 {
|
||||
assert_eq!(initial.from_slot, std::option::Option::None);
|
||||
let _ = outbound_tx.send(std::result::Result::Ok(slot_update(800))).await;
|
||||
} else {
|
||||
assert_eq!(subscribe_call, 2);
|
||||
assert_eq!(initial.from_slot, std::option::Option::Some(800));
|
||||
if outbound_tx.send(std::result::Result::Ok(slot_update(805))).await.is_err() {
|
||||
return;
|
||||
}
|
||||
let half_close = inbound.message().await;
|
||||
if matches!(half_close, std::result::Result::Ok(std::option::Option::None)) {
|
||||
half_close_seen.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
},
|
||||
FixtureMode::ReconnectExhausted => {
|
||||
assert_eq!(subscribe_call, 1);
|
||||
let _ = outbound_tx.send(std::result::Result::Ok(slot_update(700))).await;
|
||||
},
|
||||
}
|
||||
});
|
||||
if matches!(mode, FixtureMode::ReconnectReplay) && subscribe_call == 2 {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
}
|
||||
return std::result::Result::Ok(tonic::Response::new(super::MpscStream::new(outbound_rx)));
|
||||
}
|
||||
|
||||
@@ -171,6 +191,15 @@ impl yellowstone_grpc_proto::geyser::geyser_server::Geyser for FixtureGeyser {
|
||||
return std::result::Result::Err(tonic::Status::unimplemented("deshred is outside KSP 0.2.9"));
|
||||
}
|
||||
|
||||
type SubscribeGossipStream = futures_util::stream::Empty<std::result::Result<yellowstone_grpc_proto::geyser::SubscribeUpdateGossip, tonic::Status>>;
|
||||
|
||||
async fn subscribe_gossip(
|
||||
&self,
|
||||
_request: tonic::Request<yellowstone_grpc_proto::geyser::SubscribeGossipRequest>,
|
||||
) -> std::result::Result<tonic::Response<Self::SubscribeGossipStream>, tonic::Status> {
|
||||
return std::result::Result::Err(tonic::Status::unimplemented("gossip is outside KSP 0.2.9"));
|
||||
}
|
||||
|
||||
async fn subscribe_replay_info(
|
||||
&self,
|
||||
request: tonic::Request<yellowstone_grpc_proto::geyser::SubscribeReplayInfoRequest>,
|
||||
@@ -179,7 +208,7 @@ impl yellowstone_grpc_proto::geyser::geyser_server::Geyser for FixtureGeyser {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let first_available = match self.mode {
|
||||
FixtureMode::ReconnectReplay | FixtureMode::ReconnectExhausted => std::option::Option::Some(400),
|
||||
FixtureMode::ReconnectReplay | FixtureMode::ReconnectExhausted | FixtureMode::ReplayAcceptedWithoutBoundary => std::option::Option::Some(400),
|
||||
FixtureMode::ReplayGap => std::option::Option::Some(505),
|
||||
_ => return std::result::Result::Err(tonic::Status::unimplemented("replay info is outside this fixture mode")),
|
||||
};
|
||||
@@ -583,6 +612,8 @@ async fn yellowstone_reconnect_replays_from_last_observed_slot_and_counts_duplic
|
||||
assert_eq!(snapshot.state(), crate::YellowstoneGrpcSubscribeState::Active);
|
||||
assert_eq!(snapshot.reconnect_count(), 1);
|
||||
assert_eq!(snapshot.replay_attempt_count(), 1);
|
||||
assert_eq!(snapshot.replay_delivery_count(), 1);
|
||||
assert_eq!(snapshot.replay_coverage_unproven_count(), 1);
|
||||
assert_eq!(snapshot.continuity_gap_count(), 0);
|
||||
assert_eq!(snapshot.duplicate_update_count(), 1);
|
||||
assert_eq!(snapshot.last_requested_from_slot(), std::option::Option::Some(500));
|
||||
@@ -615,6 +646,8 @@ async fn yellowstone_replay_info_proves_and_clamps_retention_gap_without_lossles
|
||||
let snapshot = session.snapshot();
|
||||
assert_eq!(snapshot.reconnect_count(), 1);
|
||||
assert_eq!(snapshot.replay_attempt_count(), 1);
|
||||
assert_eq!(snapshot.replay_delivery_count(), 1);
|
||||
assert_eq!(snapshot.replay_coverage_unproven_count(), 1);
|
||||
assert_eq!(snapshot.continuity_gap_count(), 1);
|
||||
assert_eq!(snapshot.duplicate_update_count(), 0);
|
||||
assert_eq!(snapshot.last_requested_from_slot(), std::option::Option::Some(505));
|
||||
@@ -623,6 +656,42 @@ async fn yellowstone_replay_info_proves_and_clamps_retention_gap_without_lossles
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn v0_3_14_pre_004_replay_acceptance_without_boundary_delivery_never_claims_coverage() {
|
||||
let server = FixtureServer::start(FixtureMode::ReplayAcceptedWithoutBoundary).await;
|
||||
let defaults = crate::YellowstoneGrpcSessionSettings::default();
|
||||
let settings = fixture_settings_with_reconnect(
|
||||
server.endpoint_url.as_str(),
|
||||
8,
|
||||
8,
|
||||
defaults.max_inbound_message_size_bytes(),
|
||||
defaults.max_outbound_message_size_bytes(),
|
||||
crate::YellowstoneGrpcReconnectSettings::new(3, std::time::Duration::from_millis(5), std::time::Duration::from_millis(20)),
|
||||
);
|
||||
let channel = crate::YellowstoneGrpcChannel::connect(&settings).await.expect("fixture channel must connect");
|
||||
let mut session = channel.open_standard_subscribe(initial_request()).await.expect("fixture Subscribe stream must open");
|
||||
let first = session.next_update().await.expect("first slot must decode").expect("first slot must be present");
|
||||
match first {
|
||||
crate::YellowstoneSubscribeUpdate::Slot(value) => assert_eq!(value.slot(), 800),
|
||||
_ => panic!("fixture must return first Slot"),
|
||||
}
|
||||
let resumed = session.next_update().await.expect("post-reconnect slot must decode").expect("post-reconnect slot must be present");
|
||||
match resumed {
|
||||
crate::YellowstoneSubscribeUpdate::Slot(value) => assert_eq!(value.slot(), 805),
|
||||
_ => panic!("fixture must return post-reconnect Slot"),
|
||||
}
|
||||
let snapshot = session.snapshot();
|
||||
assert_eq!(snapshot.reconnect_count(), 1);
|
||||
assert_eq!(snapshot.replay_attempt_count(), 1);
|
||||
assert_eq!(snapshot.replay_delivery_count(), 0);
|
||||
assert_eq!(snapshot.replay_coverage_unproven_count(), 1);
|
||||
assert_eq!(snapshot.continuity_gap_count(), 0);
|
||||
assert_eq!(snapshot.last_requested_from_slot(), std::option::Option::Some(800));
|
||||
assert_eq!(snapshot.last_observed_slot(), std::option::Option::Some(805));
|
||||
session.close().await.expect("coverage-unproven fixture must close cleanly");
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn yellowstone_reconnect_budget_exhaustion_is_terminal_and_safe() {
|
||||
let server = FixtureServer::start(FixtureMode::ReconnectExhausted).await;
|
||||
@@ -644,6 +713,8 @@ async fn yellowstone_reconnect_budget_exhaustion_is_terminal_and_safe() {
|
||||
assert_eq!(snapshot.state(), crate::YellowstoneGrpcSubscribeState::Failed);
|
||||
assert_eq!(snapshot.reconnect_count(), 0);
|
||||
assert_eq!(snapshot.replay_attempt_count(), 2);
|
||||
assert_eq!(snapshot.replay_delivery_count(), 0);
|
||||
assert_eq!(snapshot.replay_coverage_unproven_count(), 0);
|
||||
assert_eq!(snapshot.terminal_error_code(), std::option::Option::Some(crate::ERROR_CODE_GRPC_CHANNEL_FAILED));
|
||||
let rendered = format!("{error:?} {session:?}");
|
||||
assert!(!rendered.contains("GRPC-RECONNECT-SECRET-CANARY"));
|
||||
@@ -653,6 +724,45 @@ async fn yellowstone_reconnect_budget_exhaustion_is_terminal_and_safe() {
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn yellowstone_snapshot_source_observes_replay_attempt_before_successful_reconnect() {
|
||||
let server = FixtureServer::start(FixtureMode::ReconnectReplay).await;
|
||||
let defaults = crate::YellowstoneGrpcSessionSettings::default();
|
||||
let settings = fixture_settings_with_reconnect(
|
||||
server.endpoint_url.as_str(),
|
||||
8,
|
||||
8,
|
||||
defaults.max_inbound_message_size_bytes(),
|
||||
defaults.max_outbound_message_size_bytes(),
|
||||
crate::YellowstoneGrpcReconnectSettings::new(3, std::time::Duration::from_millis(5), std::time::Duration::from_millis(20)),
|
||||
);
|
||||
let channel = crate::YellowstoneGrpcChannel::connect(&settings).await.expect("fixture channel must connect");
|
||||
let mut session = channel.open_standard_subscribe(initial_request()).await.expect("fixture Subscribe stream must open");
|
||||
let mut snapshots = session.snapshot_source();
|
||||
assert_eq!(snapshots.current().state(), crate::YellowstoneGrpcSubscribeState::Active);
|
||||
let _ = session.next_update().await.expect("first slot must decode").expect("first slot must be present");
|
||||
let replaying = tokio::time::timeout(std::time::Duration::from_secs(1), async {
|
||||
loop {
|
||||
let snapshot = match snapshots.wait_for_change().await {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
if snapshot.replay_attempt_count() > 0 && snapshot.reconnect_count() == 0 {
|
||||
return std::option::Option::Some(snapshot);
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("snapshot source must observe reconnect progress")
|
||||
.expect("snapshot source must stay open during reconnect");
|
||||
assert_eq!(replaying.state(), crate::YellowstoneGrpcSubscribeState::Reconnecting);
|
||||
assert_eq!(replaying.replay_attempt_count(), 1);
|
||||
assert_eq!(replaying.reconnect_count(), 0);
|
||||
let _ = session.next_update().await.expect("replayed duplicate must decode").expect("replayed duplicate must be present");
|
||||
session.close().await.expect("reconnected stream must close cleanly");
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn yellowstone_shutdown_interrupts_reconnect_backoff_and_mutation_is_rejected_during_reconnect() {
|
||||
let server = FixtureServer::start(FixtureMode::ReconnectReplay).await;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/grpc_subscribe.rs
|
||||
// version: 5
|
||||
// version: 6
|
||||
|
||||
fn filter_name(value: &str) -> crate::YellowstoneSubscribeFilterName {
|
||||
return crate::YellowstoneSubscribeFilterName::new(value).expect("fixture filter name must validate");
|
||||
@@ -138,6 +138,37 @@ fn yellowstone_subscribe_common_bounds_reject_before_wire_conversion() {
|
||||
assert!(filters.insert_account_filter(excess_name, crate::YellowstoneSubscribeAccountFilter::new()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yellowstone_subscribe_request_identity_is_order_stable_exact_and_debug_redacted() {
|
||||
let mut first = crate::YellowstoneSubscribeRequest::new();
|
||||
let mut second = crate::YellowstoneSubscribeRequest::new();
|
||||
let mut changed = crate::YellowstoneSubscribeRequest::new();
|
||||
let mut filter_a = crate::YellowstoneSubscribeTransactionFilter::new();
|
||||
filter_a.set_failed(std::option::Option::Some(false));
|
||||
let mut filter_b = crate::YellowstoneSubscribeTransactionFilter::new();
|
||||
filter_b.set_vote(std::option::Option::Some(false));
|
||||
assert!(first.insert_transaction_filter(filter_name("identity-filter-beta-canary"), filter_b.clone()).is_ok());
|
||||
assert!(first.insert_transaction_filter(filter_name("identity-filter-alpha-canary"), filter_a.clone()).is_ok());
|
||||
assert!(second.insert_transaction_filter(filter_name("identity-filter-alpha-canary"), filter_a.clone()).is_ok());
|
||||
assert!(second.insert_transaction_filter(filter_name("identity-filter-beta-canary"), filter_b.clone()).is_ok());
|
||||
filter_a.set_failed(std::option::Option::Some(true));
|
||||
assert!(changed.insert_transaction_filter(filter_name("identity-filter-alpha-canary"), filter_a).is_ok());
|
||||
assert!(changed.insert_transaction_filter(filter_name("identity-filter-beta-canary"), filter_b).is_ok());
|
||||
first.set_commitment(std::option::Option::Some(crate::SolanaCommitment::Confirmed));
|
||||
second.set_commitment(std::option::Option::Some(crate::SolanaCommitment::Confirmed));
|
||||
changed.set_commitment(std::option::Option::Some(crate::SolanaCommitment::Confirmed));
|
||||
let first_identity = first.identity().expect("first request identity must build");
|
||||
let second_identity = second.identity().expect("second request identity must build");
|
||||
let changed_identity = changed.identity().expect("changed request identity must build");
|
||||
assert_eq!(first_identity, second_identity);
|
||||
assert_ne!(first_identity, changed_identity);
|
||||
let debug = std::format!("{first_identity:?}");
|
||||
assert!(debug.contains("byte_len"));
|
||||
assert!(!debug.contains("identity-filter-alpha-canary"));
|
||||
assert!(!debug.contains("identity-filter-beta-canary"));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yellowstone_subscribe_debug_omits_filter_names_and_future_payloads() {
|
||||
let mut request = crate::YellowstoneSubscribeRequest::new();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/grpc_unary.rs
|
||||
// version: 3
|
||||
// version: 4
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct FixtureGeyser;
|
||||
@@ -31,6 +31,19 @@ impl yellowstone_grpc_proto::geyser::geyser_server::Geyser for FixtureGeyser {
|
||||
return std::result::Result::Err(tonic::Status::unimplemented("deshred is outside KSP 0.2.9"));
|
||||
}
|
||||
|
||||
type SubscribeGossipStream = std::pin::Pin<
|
||||
std::boxed::Box<
|
||||
dyn futures_util::Stream<Item = std::result::Result<yellowstone_grpc_proto::geyser::SubscribeUpdateGossip, tonic::Status>> + Send + 'static,
|
||||
>,
|
||||
>;
|
||||
|
||||
async fn subscribe_gossip(
|
||||
&self,
|
||||
_request: tonic::Request<yellowstone_grpc_proto::geyser::SubscribeGossipRequest>,
|
||||
) -> std::result::Result<tonic::Response<Self::SubscribeGossipStream>, tonic::Status> {
|
||||
return std::result::Result::Err(tonic::Status::unimplemented("gossip is outside KSP 0.2.9"));
|
||||
}
|
||||
|
||||
async fn subscribe_replay_info(
|
||||
&self,
|
||||
request: tonic::Request<yellowstone_grpc_proto::geyser::SubscribeReplayInfoRequest>,
|
||||
@@ -128,7 +141,7 @@ impl yellowstone_grpc_proto::geyser::geyser_server::Geyser for FixtureGeyser {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Ok(tonic::Response::new(yellowstone_grpc_proto::geyser::GetVersionResponse {
|
||||
version: "fixture-yellowstone-12.6".to_owned(),
|
||||
version: "fixture-yellowstone-12.7".to_owned(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -238,7 +251,7 @@ async fn yellowstone_unary_fixture_covers_all_seven_standard_methods_and_metadat
|
||||
assert_eq!(validity.slot(), 404);
|
||||
assert!(validity.valid());
|
||||
let version = client.get_version().await.expect("version fixture must succeed");
|
||||
assert_eq!(version.version(), "fixture-yellowstone-12.6");
|
||||
assert_eq!(version.version(), "fixture-yellowstone-12.7");
|
||||
let rendered = format!("{client:?} {channel:?}");
|
||||
assert!(!rendered.contains("GRPC-SECRET-CANARY"));
|
||||
assert!(!rendered.contains(server.endpoint_url.as_str()));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/rpc_blocks.rs
|
||||
// version: 6
|
||||
// version: 7
|
||||
|
||||
#[test]
|
||||
fn transaction_details_and_get_block_config_preserve_all_modern_options() {
|
||||
@@ -188,6 +188,35 @@ fn serve_once(body: &'static str) -> (std::string::String, std::thread::JoinHand
|
||||
return (format!("http://{address}"), handle);
|
||||
}
|
||||
|
||||
fn serve_status_and_count(status_line: &'static str) -> (std::string::String, std::thread::JoinHandle<(usize, 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 first request");
|
||||
let first_request = read_request(&mut stream);
|
||||
let response = format!("HTTP/1.1 {status_line}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
|
||||
std::io::Write::write_all(&mut stream, response.as_bytes()).expect("fixture response must write");
|
||||
let mut count = 1_usize;
|
||||
listener.set_nonblocking(true).expect("fixture listener must become nonblocking");
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(120);
|
||||
while std::time::Instant::now() < deadline {
|
||||
match listener.accept() {
|
||||
std::result::Result::Ok((mut retry_stream, _)) => {
|
||||
let _ = read_request(&mut retry_stream);
|
||||
std::io::Write::write_all(&mut retry_stream, response.as_bytes()).expect("fixture retry response must write");
|
||||
count = count.saturating_add(1);
|
||||
},
|
||||
std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
},
|
||||
std::result::Result::Err(error) => panic!("fixture listener failed while counting retries: {error}"),
|
||||
}
|
||||
}
|
||||
return (count, first_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];
|
||||
@@ -784,3 +813,92 @@ async fn typed_get_block_rejects_processed_commitment_before_io() {
|
||||
assert_eq!(error.context()[1].key(), "commitment");
|
||||
assert_eq!(error.context()[1].value(), "processed");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn pre_004_get_block_observed_reports_actual_winner_after_retry_reroute() {
|
||||
let (first_url, first_handle) = serve_status_and_count("429 Too Many Requests");
|
||||
let (winner_url, winner_handle) = serve_once(include_str!("../fixtures/http/get_block.observed_material.success.json"));
|
||||
let urls = [(first_url.as_str(), "first-endpoint", "first-provider"), (winner_url.as_str(), "winner-endpoint", "winner-provider")];
|
||||
let mut endpoints = std::vec::Vec::with_capacity(urls.len());
|
||||
for (url, endpoint_name, provider) in urls {
|
||||
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),
|
||||
);
|
||||
endpoints.push(crate::HttpEndpointSettings::new(
|
||||
endpoint_name,
|
||||
true,
|
||||
crate::HttpProviderName::new(provider),
|
||||
crate::HttpClusterName::new("local"),
|
||||
crate::HttpEndpointUrl::parse(url).expect("fixture URL must parse"),
|
||||
std::time::Duration::from_millis(100),
|
||||
std::time::Duration::from_secs(1),
|
||||
std::option::Option::Some(1),
|
||||
std::vec![role],
|
||||
));
|
||||
}
|
||||
let pool = crate::HttpTransportPool::new(crate::HttpTransportSettings::new(
|
||||
endpoints,
|
||||
crate::HttpRetrySettings::new(1, std::time::Duration::from_millis(1), std::time::Duration::from_millis(2)),
|
||||
))
|
||||
.expect("observed fixture pool must build");
|
||||
let config = crate::SolanaGetBlockConfig::new(
|
||||
std::option::Option::Some(crate::SolanaCommitment::Confirmed),
|
||||
std::option::Option::Some(crate::SolanaTransactionEncoding::Base64),
|
||||
std::option::Option::Some(crate::SolanaTransactionDetails::Full),
|
||||
std::option::Option::Some(0),
|
||||
std::option::Option::Some(false),
|
||||
);
|
||||
let observed = pool
|
||||
.get_block_observed(&crate::HttpRoleName::new("default"), 430_000_123, std::option::Option::Some(&config))
|
||||
.await
|
||||
.expect("retry-safe observed getBlock must succeed on the second endpoint");
|
||||
assert_eq!(observed.endpoint_name(), "winner-endpoint");
|
||||
assert_eq!(observed.provider().as_str(), "winner-provider");
|
||||
let block = observed.value().as_ref().expect("winning response must contain a block");
|
||||
let transactions = block.transactions().value().expect("winning block must contain transactions");
|
||||
assert_eq!(transactions.len(), 1);
|
||||
assert!(matches!(
|
||||
transactions[0].transaction(),
|
||||
crate::SolanaEncodedTransaction::Binary { encoding: crate::SolanaTransactionBinaryEncoding::Base64, .. }
|
||||
));
|
||||
let rendered = format!("{observed:?}");
|
||||
assert!(rendered.contains("winner-endpoint"));
|
||||
assert!(rendered.contains("winner-provider"));
|
||||
assert!(rendered.contains("<available>"));
|
||||
assert!(!rendered.contains("AQAAAAAAAA"));
|
||||
let (first_count, first_request) = first_handle.join().expect("first fixture server must join");
|
||||
assert_eq!(first_count, 1);
|
||||
assert_eq!(request_body(first_request.as_str())["method"], serde_json::json!("getBlock"));
|
||||
let winner_request = winner_handle.join().expect("winner fixture server must join");
|
||||
assert_eq!(request_body(winner_request.as_str())["method"], serde_json::json!("getBlock"));
|
||||
return;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn pre_004_get_block_observed_preserves_null_and_reuses_get_block_validation() {
|
||||
let (url, handle) = serve_once(include_str!("../fixtures/http/get_block.null.json"));
|
||||
let pool = pool_for_url(url.as_str());
|
||||
let observed = pool
|
||||
.get_block_observed(&crate::HttpRoleName::new("default"), 430_000_123, std::option::Option::None)
|
||||
.await
|
||||
.expect("observed null getBlock must succeed");
|
||||
assert!(observed.value().is_none());
|
||||
assert_eq!(observed.endpoint_name(), "fixture");
|
||||
assert_eq!(observed.provider().as_str(), "fixture");
|
||||
handle.join().expect("fixture server must join");
|
||||
let processed = crate::SolanaGetBlockConfig::new(
|
||||
std::option::Option::Some(crate::SolanaCommitment::Processed),
|
||||
std::option::Option::Some(crate::SolanaTransactionEncoding::Base64),
|
||||
std::option::Option::Some(crate::SolanaTransactionDetails::Full),
|
||||
std::option::Option::Some(0),
|
||||
std::option::Option::Some(false),
|
||||
);
|
||||
let result = pool.get_block_observed(&crate::HttpRoleName::new("default"), 1, std::option::Option::Some(&processed)).await;
|
||||
let error = result.expect_err("processed observed getBlock commitment must reject before I/O");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_session.rs
|
||||
// version: 11
|
||||
// version: 12
|
||||
|
||||
use futures_util::SinkExt; // rust-rules: trait-import
|
||||
use futures_util::StreamExt; // rust-rules: trait-import
|
||||
@@ -1355,3 +1355,40 @@ async fn websocket_dropped_notification_receiver_triggers_remote_cleanup_and_rel
|
||||
session.close().await.expect("session close must remain bounded");
|
||||
server.await.expect("local server task must complete");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn v0_3_14_pre_003_snapshot_source_tracks_latest_value_without_owning_runtime() {
|
||||
let session_id = crate::WsSessionId::new(std::num::NonZeroU64::new(1).expect("test session ID must be non-zero"));
|
||||
let initial = crate::WsSessionSnapshot::new(
|
||||
session_id,
|
||||
"local_ws",
|
||||
crate::WsProviderName::new("local-fixture"),
|
||||
crate::WsClusterName::new("local"),
|
||||
crate::WsProtocolKind::SolanaStandard,
|
||||
crate::WsSessionState::Active,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
std::vec::Vec::new(),
|
||||
);
|
||||
let reconnecting = crate::WsSessionSnapshot::new(
|
||||
session_id,
|
||||
"local_ws",
|
||||
crate::WsProviderName::new("local-fixture"),
|
||||
crate::WsClusterName::new("local"),
|
||||
crate::WsProtocolKind::SolanaStandard,
|
||||
crate::WsSessionState::Reconnecting { attempt: 1 },
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
std::vec::Vec::new(),
|
||||
);
|
||||
let (sender, receiver) = tokio::sync::watch::channel(initial.clone());
|
||||
let mut source = crate::WsSessionSnapshotSource::new(receiver);
|
||||
assert_eq!(source.current(), initial);
|
||||
sender.send_replace(reconnecting.clone());
|
||||
assert_eq!(source.wait_for_change().await, std::option::Option::Some(reconnecting));
|
||||
drop(sender);
|
||||
assert_eq!(source.wait_for_change().await, std::option::Option::None);
|
||||
return;
|
||||
}
|
||||
|
||||
18
crates/ksp-raw-transaction-lib/Cargo.toml
Normal file
18
crates/ksp-raw-transaction-lib/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
# file: crates/ksp-raw-transaction-lib/Cargo.toml
|
||||
# version: 2
|
||||
|
||||
[package]
|
||||
name = "ksp-raw-transaction-lib"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
base64.workspace = true
|
||||
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||
ksp-store-api = { path = "../ksp-store-api" }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
98
crates/ksp-raw-transaction-lib/README.md
Normal file
98
crates/ksp-raw-transaction-lib/README.md
Normal file
@@ -0,0 +1,98 @@
|
||||
<!-- file: crates/ksp-raw-transaction-lib/README.md -->
|
||||
<!-- version: 2 -->
|
||||
|
||||
# ksp-raw-transaction-lib
|
||||
|
||||
`ksp-raw-transaction-lib` fournit la lower-layer KSP source-neutral commune aux producteurs de `RawTransaction`.
|
||||
|
||||
La crate transforme un matériau transactionnel complet déjà acquis en représentation RAW canonique KSP, fournit les primitives de parsing de signature et de sérialisation wire Solana nécessaires aux voies qualifiées, puis permet d'associer la transaction canonique à une observation/provenance possédée par le producer.
|
||||
|
||||
Elle ne possède aucun Transport, Config, Job, Worker, runtime async, Store runtime ou backend physique.
|
||||
|
||||
## Responsabilités
|
||||
|
||||
La façade crate-root expose quatre familles de contrats.
|
||||
|
||||
### Canonicalisation RAW Transaction
|
||||
|
||||
- `RawTransactionMaterial` ;
|
||||
- `RawTransactionWireField<T>` pour préserver distinctement omission, `null` explicite et valeur ;
|
||||
- `RawTransactionVersion` ;
|
||||
- `canonicalize_raw_transaction` ;
|
||||
- `RAW_TRANSACTION_FORMAT_ID` et `RAW_TRANSACTION_FORMAT_VERSION`.
|
||||
|
||||
La canonicalisation produit un `ksp_store_api::RawTransaction` avec payload KSP déterministe et SHA-256 de contenu. Le producer reste responsable de fournir un matériau source-neutral complet ; la crate n'effectue aucune I/O réseau.
|
||||
|
||||
### Signature Solana
|
||||
|
||||
- `parse_raw_transaction_signature` valide et décode une signature Base58 bornée vers exactement 64 bytes ;
|
||||
- `format_raw_transaction_signature` encode exactement 64 bytes canoniques vers la forme Base58 bornée correspondante ;
|
||||
- `extract_raw_transaction_signature_from_binary_base64` extrait la première signature canonique d'un wire transactionnel Base64 complet ;
|
||||
- les bornes textuelles publiques permettent l'admission avant décodage.
|
||||
|
||||
Les diagnostics ne recopient pas la signature hostile.
|
||||
|
||||
### Wire transactionnel source-neutral
|
||||
|
||||
La surface `RawSolana*` représente et sérialise les messages transactionnels Solana Legacy, V0 et V1 nécessaires à la common RAW :
|
||||
|
||||
- `RawSolanaMessageVersion` ;
|
||||
- `RawSolanaMessageHeader` ;
|
||||
- `RawSolanaCompiledInstruction` ;
|
||||
- `RawSolanaAddressTableLookup` ;
|
||||
- `RawSolanaTransactionConfig` ;
|
||||
- `RawSolanaTransactionMessage` ;
|
||||
- `RawSolanaTransactionWire` ;
|
||||
- `serialize_solana_transaction_wire` et `serialize_solana_transaction_wire_base64`.
|
||||
|
||||
Cette surface est un contrat de wire commun, pas un decoder Program et pas une façade Transport.
|
||||
|
||||
### Acquisition canonique + observation
|
||||
|
||||
`assemble_raw_transaction_acquisition` associe une transaction canonique à :
|
||||
|
||||
- une `RawObservationKey` déterministe possédée par le producer ;
|
||||
- une `RawAcquisitionProvenance` sûre possédée par le producer.
|
||||
|
||||
Le résultat `RawTransactionAcquisition` contient exactement une entité canonique et une observation liée. Il ne persiste rien lui-même.
|
||||
|
||||
## Frontières
|
||||
|
||||
```text
|
||||
Transport / fixture / import / producer
|
||||
|
|
||||
v
|
||||
matériau source-neutral
|
||||
|
|
||||
v
|
||||
ksp-raw-transaction-lib
|
||||
| |
|
||||
| +-> exact Solana wire helpers
|
||||
v
|
||||
RawTransaction + RawTransactionObservation
|
||||
|
|
||||
v
|
||||
producer runtime -> ksp-store-lib / capability Store appropriée
|
||||
```
|
||||
|
||||
Interdictions structurelles :
|
||||
|
||||
```text
|
||||
ksp-raw-transaction-lib -X-> ksp-onchain-transport-lib
|
||||
ksp-raw-transaction-lib -X-> ksp-config-lib
|
||||
ksp-raw-transaction-lib -X-> ksp-job-api / ksp-job-backfill-lib
|
||||
ksp-raw-transaction-lib -X-> ksp-worker-api / worker concret
|
||||
ksp-raw-transaction-lib -X-> ksp-store-lib / backend physique
|
||||
```
|
||||
|
||||
Le graphe normal reste limité à `ksp-core-lib`, `ksp-store-api`, `base64`, `serde_json` et `sha2`.
|
||||
|
||||
## Relation avec STRUCTURAL
|
||||
|
||||
Cette crate appartient à **RAW**. Elle ne réalise pas la décomposition Store D2 `RAW -> STRUCTURAL`. La future couche STRUCTURAL consommera le RAW persistant et le décomposera en unités Solana génériques plus fines avant tout décodage Program.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [`USAGE.md`](USAGE.md) — utilisation durable de la façade publique ;
|
||||
- [`../../docs/architecture/008-DATA_MATERIALIZATION_AND_STORE.md`](../../docs/architecture/008-DATA_MATERIALIZATION_AND_STORE.md) — frontières RAW / STRUCTURAL / DECODED / DOMAIN ;
|
||||
- [`../../docs/architecture/011-RAW_TRANSACTION_ACQUISITION.md`](../../docs/architecture/011-RAW_TRANSACTION_ACQUISITION.md) — qualification des sources `RawTransaction`.
|
||||
163
crates/ksp-raw-transaction-lib/USAGE.md
Normal file
163
crates/ksp-raw-transaction-lib/USAGE.md
Normal file
@@ -0,0 +1,163 @@
|
||||
<!-- file: crates/ksp-raw-transaction-lib/USAGE.md -->
|
||||
<!-- version: 2 -->
|
||||
|
||||
# Utilisation de ksp-raw-transaction-lib
|
||||
|
||||
Cette page décrit la façade publique durable de `ksp-raw-transaction-lib`. Les consumers utilisent uniquement les exports du crate-root.
|
||||
|
||||
## Parser une signature transactionnelle
|
||||
|
||||
Lorsqu'une source fournit une signature Base58 textuelle, la convertir avec le parser commun plutôt que de dupliquer le décodage :
|
||||
|
||||
```rust
|
||||
fn parse_signature(value: &str) -> ksp_core_lib::Result<ksp_store_api::RawTransactionSignature> {
|
||||
return ksp_raw_transaction_lib::parse_raw_transaction_signature(value);
|
||||
}
|
||||
```
|
||||
|
||||
Le résultat contient exactement 64 bytes canoniques. Les entrées vides, hors bornes, Base58 invalides ou de longueur décodée incorrecte sont rejetées sans recopier la valeur hostile dans l'erreur.
|
||||
|
||||
Lorsqu'un consumer possède déjà les 64 bytes canoniques et doit appeler une API textuelle Solana, utiliser l'encodeur commun plutôt que d'implémenter Base58 localement :
|
||||
|
||||
```rust
|
||||
fn format_signature(value: &ksp_store_api::RawTransactionSignature) -> std::string::String {
|
||||
return ksp_raw_transaction_lib::format_raw_transaction_signature(value);
|
||||
}
|
||||
```
|
||||
|
||||
Le texte produit respecte les bornes publiques de signature et effectue un round-trip exact avec `parse_raw_transaction_signature`.
|
||||
|
||||
Lorsqu'un wire Base64 complet contient déjà son tableau de signatures, utiliser :
|
||||
|
||||
```rust
|
||||
fn embedded_signature(value: &str) -> ksp_core_lib::Result<ksp_store_api::RawTransactionSignature> {
|
||||
return ksp_raw_transaction_lib::extract_raw_transaction_signature_from_binary_base64(value);
|
||||
}
|
||||
```
|
||||
|
||||
## Construire et sérialiser un wire Solana source-neutral
|
||||
|
||||
Le producer peut projeter son DTO Transport/protobuf vers les types `RawSolana*`, puis utiliser le sérialiseur commun. Exemple V1 minimal :
|
||||
|
||||
```rust
|
||||
fn serialize_v1() -> ksp_core_lib::Result<std::string::String> {
|
||||
let message = ksp_raw_transaction_lib::RawSolanaTransactionMessage::new(
|
||||
ksp_raw_transaction_lib::RawSolanaMessageVersion::V1,
|
||||
ksp_raw_transaction_lib::RawSolanaMessageHeader::new(1, 0, 0),
|
||||
vec![[1_u8; 32]],
|
||||
[2_u8; 32],
|
||||
vec![ksp_raw_transaction_lib::RawSolanaCompiledInstruction::new(0, vec![0], vec![3])],
|
||||
vec![],
|
||||
std::option::Option::Some(ksp_raw_transaction_lib::RawSolanaTransactionConfig::default()),
|
||||
);
|
||||
let wire = ksp_raw_transaction_lib::RawSolanaTransactionWire::new(vec![[4_u8; 64]], message);
|
||||
return ksp_raw_transaction_lib::serialize_solana_transaction_wire_base64(&wire);
|
||||
}
|
||||
```
|
||||
|
||||
Pour Legacy ou V0, choisir `RawSolanaMessageVersion::Legacy` ou `V0` et fournir uniquement les éléments admis par cette version. Le sérialiseur valide les invariants structurels avant de produire les bytes.
|
||||
|
||||
`RawSolanaAddressTableLookup` représente les lookups V0. `RawSolanaTransactionConfig` représente la configuration inline V1 ; le cas explicite où toutes ses options valent `None` reste sémantiquement présent.
|
||||
|
||||
## Construire le matériau RAW canonique
|
||||
|
||||
Une fois le wire transactionnel Base64 et les métadonnées source-neutral disponibles, construire `RawTransactionMaterial`. Les états wire doivent conserver la différence entre champ absent, `null` explicite et valeur :
|
||||
|
||||
```rust
|
||||
fn material(
|
||||
network: ksp_store_api::RawNetworkId,
|
||||
signature: ksp_store_api::RawTransactionSignature,
|
||||
transaction_base64: std::string::String,
|
||||
) -> ksp_raw_transaction_lib::RawTransactionMaterial {
|
||||
return ksp_raw_transaction_lib::RawTransactionMaterial::binary_base64(
|
||||
network,
|
||||
signature,
|
||||
42,
|
||||
std::option::Option::None,
|
||||
transaction_base64,
|
||||
ksp_raw_transaction_lib::RawTransactionWireField::Omitted,
|
||||
ksp_raw_transaction_lib::RawTransactionWireField::Value(ksp_raw_transaction_lib::RawTransactionVersion::Legacy),
|
||||
ksp_raw_transaction_lib::RawTransactionWireField::Omitted,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Pour une source où la signature doit être extraite du wire lui-même, `RawTransactionMaterial::binary_base64_with_embedded_signature` évite un second parser producer-owned.
|
||||
|
||||
## Canonicaliser vers RawTransaction
|
||||
|
||||
La canonicalisation est déterministe pour un même matériau complet :
|
||||
|
||||
```rust
|
||||
fn canonicalize(
|
||||
material: ksp_raw_transaction_lib::RawTransactionMaterial,
|
||||
) -> ksp_core_lib::Result<ksp_store_api::RawTransaction> {
|
||||
return ksp_raw_transaction_lib::canonicalize_raw_transaction(material);
|
||||
}
|
||||
```
|
||||
|
||||
La crate :
|
||||
|
||||
- canonicalise les sous-arbres JSON significatifs ;
|
||||
- conserve les états omission / `null` / valeur ;
|
||||
- construit le payload au format KSP RAW Transaction ;
|
||||
- calcule son SHA-256 ;
|
||||
- applique les bornes communes avant construction du modèle Store.
|
||||
|
||||
Elle ne persiste pas la transaction et ne choisit aucune politique de retry, endpoint ou source.
|
||||
|
||||
## Associer l'observation du producer
|
||||
|
||||
Le producer construit sa provenance avec les types Store API, puis assemble l'entité et l'observation :
|
||||
|
||||
```rust
|
||||
fn acquisition(
|
||||
transaction: ksp_store_api::RawTransaction,
|
||||
) -> ksp_core_lib::Result<ksp_raw_transaction_lib::RawTransactionAcquisition> {
|
||||
let provider = match ksp_store_api::RawProvenanceCode::new("example") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let protocol = match ksp_store_api::RawProvenanceCode::new("solana.http") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let method = match ksp_store_api::RawProvenanceCode::new("get_transaction") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let received_at = match ksp_store_api::RawTimestamp::from_unix_millis(1_700_000_000_000) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let provenance = ksp_store_api::RawAcquisitionProvenance::new(
|
||||
provider,
|
||||
protocol,
|
||||
method,
|
||||
ksp_store_api::RawAcquisitionOrigin::Live,
|
||||
received_at,
|
||||
);
|
||||
return std::result::Result::Ok(ksp_raw_transaction_lib::assemble_raw_transaction_acquisition(
|
||||
transaction,
|
||||
ksp_store_api::RawObservationKey::new([7_u8; 32]),
|
||||
provenance,
|
||||
));
|
||||
}
|
||||
```
|
||||
|
||||
La clé d'observation doit être déterministe selon la politique du producer. Plusieurs sources peuvent produire des observations différentes pour la même identité canonique `(network, signature)`.
|
||||
|
||||
## Persister depuis un Job ou un Worker
|
||||
|
||||
La common crate ne dépend pas du Store runtime. Le lifecycle host concret récupère les deux parties puis appelle la façade Store appropriée :
|
||||
|
||||
```rust
|
||||
let (transaction, observation) = acquisition.into_parts();
|
||||
// Le Job/Worker concret transmet ensuite transaction + observation à ksp-store-lib.
|
||||
```
|
||||
|
||||
Ne pas introduire `ksp-store-lib`, Transport, Config, Job ou Worker dans `ksp-raw-transaction-lib` pour simplifier cet appel. La direction de dépendance reste du producer vers la common RAW, puis du producer vers le Store runtime.
|
||||
|
||||
## Frontière avec STRUCTURAL
|
||||
|
||||
Ne pas utiliser cette crate pour décoder des Programs ou pour produire directement des unités STRUCTURAL. Son résultat reste D1 RAW. La décomposition `RAW -> STRUCTURAL` appartient à une couche ultérieure et réutilise le RAW durable comme input.
|
||||
49
crates/ksp-raw-transaction-lib/src/acquisition.rs
Normal file
49
crates/ksp-raw-transaction-lib/src/acquisition.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
// file: crates/ksp-raw-transaction-lib/src/acquisition.rs
|
||||
// version: 1
|
||||
|
||||
/// Complete source-neutral RAW transaction acquisition containing one canonical entity and one producer-owned observation.
|
||||
pub struct RawTransactionAcquisition {
|
||||
observation: ksp_store_api::RawTransactionObservation,
|
||||
transaction: ksp_store_api::RawTransaction,
|
||||
}
|
||||
|
||||
impl crate::RawTransactionAcquisition {
|
||||
/// Returns the producer-owned acquisition observation linked to the canonical transaction.
|
||||
#[must_use]
|
||||
pub const fn observation(&self) -> &ksp_store_api::RawTransactionObservation {
|
||||
return &self.observation;
|
||||
}
|
||||
|
||||
/// Returns the canonical source-independent RAW transaction.
|
||||
#[must_use]
|
||||
pub const fn transaction(&self) -> &ksp_store_api::RawTransaction {
|
||||
return &self.transaction;
|
||||
}
|
||||
|
||||
/// Consumes this acquisition into its canonical transaction and observation.
|
||||
#[must_use]
|
||||
pub fn into_parts(self) -> (ksp_store_api::RawTransaction, ksp_store_api::RawTransactionObservation) {
|
||||
return (self.transaction, self.observation);
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for crate::RawTransactionAcquisition {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter.write_str("RawTransactionAcquisition(..)");
|
||||
}
|
||||
}
|
||||
|
||||
/// Assembles one canonical transaction with a producer-owned observation key and safe acquisition provenance.
|
||||
#[must_use]
|
||||
pub fn assemble_raw_transaction_acquisition(
|
||||
transaction: ksp_store_api::RawTransaction,
|
||||
observation_key: ksp_store_api::RawObservationKey,
|
||||
provenance: ksp_store_api::RawAcquisitionProvenance,
|
||||
) -> crate::RawTransactionAcquisition {
|
||||
let observation = ksp_store_api::RawTransactionObservation::new(observation_key, transaction.reference().clone(), provenance);
|
||||
return crate::RawTransactionAcquisition { observation, transaction };
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/acquisition.rs"]
|
||||
mod tests;
|
||||
325
crates/ksp-raw-transaction-lib/src/canonical.rs
Normal file
325
crates/ksp-raw-transaction-lib/src/canonical.rs
Normal file
@@ -0,0 +1,325 @@
|
||||
// file: crates/ksp-raw-transaction-lib/src/canonical.rs
|
||||
// version: 2
|
||||
|
||||
use sha2::Digest; // rust-rules: trait-import
|
||||
|
||||
/// KSP-owned source-independent RAW transaction format identifier.
|
||||
pub const RAW_TRANSACTION_FORMAT_ID: &str = "ksp.solana.raw_transaction";
|
||||
/// Frozen KSP-owned RAW transaction format version.
|
||||
pub const RAW_TRANSACTION_FORMAT_VERSION: u32 = 1;
|
||||
|
||||
/// Source-wire presence state preserving omitted, explicit null and concrete values independently.
|
||||
#[non_exhaustive]
|
||||
pub enum RawTransactionWireField<T> {
|
||||
/// The source field was omitted entirely.
|
||||
Omitted,
|
||||
/// The source field was explicitly present as JSON/protocol null.
|
||||
Null,
|
||||
/// The source field carried one concrete value.
|
||||
Value(T),
|
||||
}
|
||||
|
||||
impl<T> std::fmt::Debug for crate::RawTransactionWireField<T> {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return match self {
|
||||
Self::Omitted => formatter.write_str("Omitted"),
|
||||
Self::Null => formatter.write_str("Null"),
|
||||
Self::Value(_) => formatter.write_str("Value(..)"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical Solana transaction version admitted by RAW transaction format v1.
|
||||
#[non_exhaustive]
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum RawTransactionVersion {
|
||||
/// Legacy unversioned transaction representation.
|
||||
Legacy,
|
||||
/// Explicit versioned transaction number admitted by the current Solana wire contract.
|
||||
Number(u8),
|
||||
}
|
||||
|
||||
/// Complete source-neutral material required to construct one canonical RAW transaction.
|
||||
pub struct RawTransactionMaterial {
|
||||
block_time: std::option::Option<i64>,
|
||||
meta: crate::RawTransactionWireField<serde_json::Value>,
|
||||
network: ksp_store_api::RawNetworkId,
|
||||
signature: ksp_store_api::RawTransactionSignature,
|
||||
slot: u64,
|
||||
transaction_data: std::string::String,
|
||||
transaction_index: crate::RawTransactionWireField<u32>,
|
||||
version: crate::RawTransactionWireField<crate::RawTransactionVersion>,
|
||||
}
|
||||
|
||||
impl crate::RawTransactionMaterial {
|
||||
/// Creates complete source-neutral material for the frozen RAW v1 binary Base64 body path.
|
||||
#[must_use]
|
||||
pub fn binary_base64(
|
||||
network: ksp_store_api::RawNetworkId,
|
||||
signature: ksp_store_api::RawTransactionSignature,
|
||||
slot: u64,
|
||||
block_time: std::option::Option<i64>,
|
||||
transaction_data: impl std::convert::Into<std::string::String>,
|
||||
meta: crate::RawTransactionWireField<serde_json::Value>,
|
||||
version: crate::RawTransactionWireField<crate::RawTransactionVersion>,
|
||||
transaction_index: crate::RawTransactionWireField<u32>,
|
||||
) -> Self {
|
||||
return Self {
|
||||
block_time,
|
||||
meta,
|
||||
network,
|
||||
signature,
|
||||
slot,
|
||||
transaction_data: transaction_data.into(),
|
||||
transaction_index,
|
||||
version,
|
||||
};
|
||||
}
|
||||
|
||||
/// Creates complete source-neutral RAW v1 material from a Base64 transaction wire that embeds its own canonical signature array.
|
||||
pub fn binary_base64_with_embedded_signature(
|
||||
network: ksp_store_api::RawNetworkId,
|
||||
slot: u64,
|
||||
block_time: std::option::Option<i64>,
|
||||
transaction_data: impl std::convert::Into<std::string::String>,
|
||||
meta: crate::RawTransactionWireField<serde_json::Value>,
|
||||
version: crate::RawTransactionWireField<crate::RawTransactionVersion>,
|
||||
transaction_index: crate::RawTransactionWireField<u32>,
|
||||
) -> ksp_core_lib::Result<Self> {
|
||||
let transaction_data = transaction_data.into();
|
||||
let signature = crate::extract_raw_transaction_signature_from_binary_base64(transaction_data.as_str());
|
||||
return match signature {
|
||||
std::result::Result::Ok(signature) => {
|
||||
std::result::Result::Ok(Self { block_time, meta, network, signature, slot, transaction_data, transaction_index, version })
|
||||
},
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for crate::RawTransactionMaterial {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter
|
||||
.debug_struct("RawTransactionMaterial")
|
||||
.field("network", &self.network)
|
||||
.field("slot", &self.slot)
|
||||
.field("block_time", &self.block_time)
|
||||
.field("transaction_data_len", &self.transaction_data.len())
|
||||
.field("meta", &wire_field_state(&self.meta))
|
||||
.field("version", &wire_field_state(&self.version))
|
||||
.field("transaction_index", &wire_field_state(&self.transaction_index))
|
||||
.finish_non_exhaustive();
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonicalizes complete source-neutral transaction material into the frozen KSP RAW transaction v1 representation.
|
||||
pub fn canonicalize_raw_transaction(material: crate::RawTransactionMaterial) -> ksp_core_lib::Result<ksp_store_api::RawTransaction> {
|
||||
let block_time = convert_block_time(material.block_time);
|
||||
let block_time = match block_time {
|
||||
std::result::Result::Ok(block_time) => block_time,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let bytes = canonical_payload_bytes(&material);
|
||||
let bytes = match bytes {
|
||||
std::result::Result::Ok(bytes) => bytes,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if bytes.is_empty() || bytes.len() > ksp_store_api::MAX_RAW_PAYLOAD_BYTES {
|
||||
return std::result::Result::Err(crate::canonicalization_error("payload.bytes"));
|
||||
}
|
||||
let hash: [u8; 32] = sha2::Sha256::digest(bytes.as_slice()).into();
|
||||
let format_id = ksp_store_api::RawFormatId::new(crate::RAW_TRANSACTION_FORMAT_ID);
|
||||
let format_id = match format_id {
|
||||
std::result::Result::Ok(format_id) => format_id,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(crate::canonicalization_error("payload.format_id")),
|
||||
};
|
||||
let payload = ksp_store_api::RawPayload::try_new(
|
||||
format_id,
|
||||
crate::RAW_TRANSACTION_FORMAT_VERSION,
|
||||
bytes.into_boxed_slice(),
|
||||
ksp_store_api::RawContentHash::new(hash),
|
||||
);
|
||||
let payload = match payload {
|
||||
std::result::Result::Ok(payload) => payload,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(crate::canonicalization_error("payload")),
|
||||
};
|
||||
let reference = ksp_store_api::RawTransactionReference::new(material.network, material.signature);
|
||||
return std::result::Result::Ok(ksp_store_api::RawTransaction::new(reference, material.slot, block_time, payload));
|
||||
}
|
||||
|
||||
fn append_canonical_json(output: &mut std::vec::Vec<u8>, value: &serde_json::Value) -> ksp_core_lib::Result<()> {
|
||||
return match value {
|
||||
serde_json::Value::Null => {
|
||||
output.extend_from_slice(b"null");
|
||||
std::result::Result::Ok(())
|
||||
},
|
||||
serde_json::Value::Bool(value) => {
|
||||
if *value {
|
||||
output.extend_from_slice(b"true");
|
||||
} else {
|
||||
output.extend_from_slice(b"false");
|
||||
}
|
||||
std::result::Result::Ok(())
|
||||
},
|
||||
serde_json::Value::Number(value) => {
|
||||
output.extend_from_slice(value.to_string().as_bytes());
|
||||
std::result::Result::Ok(())
|
||||
},
|
||||
serde_json::Value::String(value) => append_json_string(output, value.as_str()),
|
||||
serde_json::Value::Array(values) => {
|
||||
output.push(b'[');
|
||||
for (index, item) in values.iter().enumerate() {
|
||||
if index != 0 {
|
||||
output.push(b',');
|
||||
}
|
||||
let item_result = append_canonical_json(output, item);
|
||||
if let std::result::Result::Err(error) = item_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
}
|
||||
output.push(b']');
|
||||
std::result::Result::Ok(())
|
||||
},
|
||||
serde_json::Value::Object(values) => {
|
||||
output.push(b'{');
|
||||
let mut keys = values.keys().collect::<std::vec::Vec<_>>();
|
||||
keys.sort_unstable();
|
||||
for (index, key) in keys.iter().enumerate() {
|
||||
if index != 0 {
|
||||
output.push(b',');
|
||||
}
|
||||
let key_result = append_json_string(output, key.as_str());
|
||||
if let std::result::Result::Err(error) = key_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
output.push(b':');
|
||||
let item = values.get(key.as_str());
|
||||
let item = match item {
|
||||
std::option::Option::Some(item) => item,
|
||||
std::option::Option::None => return std::result::Result::Err(crate::canonicalization_error("payload.meta")),
|
||||
};
|
||||
let item_result = append_canonical_json(output, item);
|
||||
if let std::result::Result::Err(error) = item_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
}
|
||||
output.push(b'}');
|
||||
std::result::Result::Ok(())
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fn append_json_string(output: &mut std::vec::Vec<u8>, value: &str) -> ksp_core_lib::Result<()> {
|
||||
let encoded = serde_json::to_vec(value);
|
||||
return match encoded {
|
||||
std::result::Result::Ok(encoded) => {
|
||||
output.extend_from_slice(encoded.as_slice());
|
||||
std::result::Result::Ok(())
|
||||
},
|
||||
std::result::Result::Err(_) => std::result::Result::Err(crate::canonicalization_error("payload.json")),
|
||||
};
|
||||
}
|
||||
|
||||
fn append_wire_value<T, F>(
|
||||
output: &mut std::vec::Vec<u8>,
|
||||
key: &str,
|
||||
field: &crate::RawTransactionWireField<T>,
|
||||
mut append_value: F,
|
||||
) -> ksp_core_lib::Result<()>
|
||||
where
|
||||
F: FnMut(&mut std::vec::Vec<u8>, &T) -> ksp_core_lib::Result<()>,
|
||||
{
|
||||
return match field {
|
||||
crate::RawTransactionWireField::Omitted => std::result::Result::Ok(()),
|
||||
crate::RawTransactionWireField::Null => {
|
||||
output.push(b',');
|
||||
let key_result = append_json_string(output, key);
|
||||
if let std::result::Result::Err(error) = key_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
output.extend_from_slice(b":null");
|
||||
std::result::Result::Ok(())
|
||||
},
|
||||
crate::RawTransactionWireField::Value(value) => {
|
||||
output.push(b',');
|
||||
let key_result = append_json_string(output, key);
|
||||
if let std::result::Result::Err(error) = key_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
output.push(b':');
|
||||
append_value(output, value)
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fn canonical_payload_bytes(material: &crate::RawTransactionMaterial) -> ksp_core_lib::Result<std::vec::Vec<u8>> {
|
||||
let mut bytes = std::vec::Vec::new();
|
||||
bytes.extend_from_slice(b"{\"transaction\":[");
|
||||
let transaction_result = append_json_string(&mut bytes, material.transaction_data.as_str());
|
||||
if let std::result::Result::Err(error) = transaction_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
bytes.push(b',');
|
||||
let encoding_result = append_json_string(&mut bytes, "base64");
|
||||
if let std::result::Result::Err(error) = encoding_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
bytes.push(b']');
|
||||
let meta_result = append_wire_value(&mut bytes, "meta", &material.meta, |output, value| return append_canonical_json(output, value));
|
||||
if let std::result::Result::Err(error) = meta_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let version_result = append_wire_value(&mut bytes, "version", &material.version, |output, value| {
|
||||
return match value {
|
||||
crate::RawTransactionVersion::Legacy => append_json_string(output, "legacy"),
|
||||
crate::RawTransactionVersion::Number(number) => {
|
||||
output.extend_from_slice(number.to_string().as_bytes());
|
||||
std::result::Result::Ok(())
|
||||
},
|
||||
};
|
||||
});
|
||||
if let std::result::Result::Err(error) = version_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let transaction_index_result = append_wire_value(&mut bytes, "transactionIndex", &material.transaction_index, |output, value| {
|
||||
output.extend_from_slice(value.to_string().as_bytes());
|
||||
return std::result::Result::Ok(());
|
||||
});
|
||||
if let std::result::Result::Err(error) = transaction_index_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
bytes.push(b'}');
|
||||
return std::result::Result::Ok(bytes);
|
||||
}
|
||||
|
||||
fn convert_block_time(value: std::option::Option<i64>) -> ksp_core_lib::Result<std::option::Option<ksp_store_api::RawTimestamp>> {
|
||||
let seconds = match value {
|
||||
std::option::Option::Some(seconds) => seconds,
|
||||
std::option::Option::None => return std::result::Result::Ok(std::option::Option::None),
|
||||
};
|
||||
let seconds = match u64::try_from(seconds) {
|
||||
std::result::Result::Ok(seconds) => seconds,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(crate::material_error("block_time")),
|
||||
};
|
||||
let millis = match seconds.checked_mul(1_000) {
|
||||
std::option::Option::Some(millis) => millis,
|
||||
std::option::Option::None => return std::result::Result::Err(crate::material_error("block_time")),
|
||||
};
|
||||
let timestamp = ksp_store_api::RawTimestamp::from_unix_millis(millis);
|
||||
return match timestamp {
|
||||
std::result::Result::Ok(timestamp) => std::result::Result::Ok(std::option::Option::Some(timestamp)),
|
||||
std::result::Result::Err(_) => std::result::Result::Err(crate::material_error("block_time")),
|
||||
};
|
||||
}
|
||||
|
||||
fn wire_field_state<T>(field: &crate::RawTransactionWireField<T>) -> &'static str {
|
||||
return match field {
|
||||
crate::RawTransactionWireField::Omitted => "omitted",
|
||||
crate::RawTransactionWireField::Null => "null",
|
||||
crate::RawTransactionWireField::Value(_) => "value",
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/canonical.rs"]
|
||||
mod tests;
|
||||
27
crates/ksp-raw-transaction-lib/src/error.rs
Normal file
27
crates/ksp-raw-transaction-lib/src/error.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
// file: crates/ksp-raw-transaction-lib/src/error.rs
|
||||
// version: 2
|
||||
|
||||
/// Error code used when RAW v1 canonical payload construction cannot preserve the frozen format contract.
|
||||
pub const ERROR_CODE_RAW_TRANSACTION_CANONICALIZATION_INVALID: ksp_core_lib::ErrorCode =
|
||||
ksp_core_lib::ErrorCode::new("raw_transaction", "canonicalization_invalid");
|
||||
/// Error code used when source-neutral RAW transaction material violates a bounded semantic invariant.
|
||||
pub const ERROR_CODE_RAW_TRANSACTION_MATERIAL_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("raw_transaction", "material_invalid");
|
||||
/// Error code used when Solana transaction signature material cannot resolve to exactly 64 canonical bytes.
|
||||
pub const ERROR_CODE_RAW_TRANSACTION_SIGNATURE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("raw_transaction", "signature_invalid");
|
||||
|
||||
/// Creates a safe canonicalization error without copying source payload material.
|
||||
pub(crate) fn canonicalization_error(field: &'static str) -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_RAW_TRANSACTION_CANONICALIZATION_INVALID, "invalid canonical RAW transaction representation")
|
||||
.with_context("field", field);
|
||||
}
|
||||
|
||||
/// Creates a safe source-material error without copying source payload material.
|
||||
pub(crate) fn material_error(field: &'static str) -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_RAW_TRANSACTION_MATERIAL_INVALID, "invalid source-neutral RAW transaction material")
|
||||
.with_context("field", field);
|
||||
}
|
||||
|
||||
/// Creates a safe signature error without copying source signature or transaction material.
|
||||
pub(crate) fn signature_error() -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_RAW_TRANSACTION_SIGNATURE_INVALID, "invalid canonical Solana transaction signature");
|
||||
}
|
||||
77
crates/ksp-raw-transaction-lib/src/lib.rs
Normal file
77
crates/ksp-raw-transaction-lib/src/lib.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
// file: crates/ksp-raw-transaction-lib/src/lib.rs
|
||||
// version: 4
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
//! Source-neutral canonicalization of complete Solana transactions into KSP RAW transaction v1.
|
||||
//!
|
||||
//! This lower layer owns the frozen RAW transaction format, textual signature parsing/formatting, canonical
|
||||
//! JSON construction, content hashing and assembly of one canonical transaction with one
|
||||
//! producer-owned observation. It owns no Transport, Config, Job, Worker, async runtime or Store
|
||||
//! backend behavior.
|
||||
|
||||
mod acquisition;
|
||||
mod canonical;
|
||||
mod error;
|
||||
mod signature;
|
||||
mod wire;
|
||||
|
||||
/// Complete source-neutral RAW transaction acquisition containing one canonical entity and one producer-owned observation.
|
||||
pub use self::acquisition::RawTransactionAcquisition;
|
||||
/// Assembles one canonical transaction with a producer-owned observation key and safe acquisition provenance.
|
||||
pub use self::acquisition::assemble_raw_transaction_acquisition;
|
||||
/// KSP-owned source-independent RAW transaction format identifier.
|
||||
pub use self::canonical::RAW_TRANSACTION_FORMAT_ID;
|
||||
/// Frozen KSP-owned RAW transaction format version.
|
||||
pub use self::canonical::RAW_TRANSACTION_FORMAT_VERSION;
|
||||
/// Complete source-neutral material required to construct one canonical RAW transaction.
|
||||
pub use self::canonical::RawTransactionMaterial;
|
||||
/// Canonical Solana transaction version admitted by RAW transaction format v1.
|
||||
pub use self::canonical::RawTransactionVersion;
|
||||
/// Source-wire presence state preserving omitted, explicit null and concrete values independently.
|
||||
pub use self::canonical::RawTransactionWireField;
|
||||
/// Canonicalizes complete source-neutral transaction material into the frozen KSP RAW transaction v1 representation.
|
||||
pub use self::canonical::canonicalize_raw_transaction;
|
||||
/// Error code used when RAW v1 canonical payload construction cannot preserve the frozen format contract.
|
||||
pub use self::error::ERROR_CODE_RAW_TRANSACTION_CANONICALIZATION_INVALID;
|
||||
/// Error code used when source-neutral RAW transaction material violates a bounded semantic invariant.
|
||||
pub use self::error::ERROR_CODE_RAW_TRANSACTION_MATERIAL_INVALID;
|
||||
/// Error code used when Solana transaction signature material cannot resolve to exactly 64 canonical bytes.
|
||||
pub use self::error::ERROR_CODE_RAW_TRANSACTION_SIGNATURE_INVALID;
|
||||
/// Maximum UTF-8 byte length admitted for one textual Base58 Solana transaction signature.
|
||||
pub use self::signature::MAX_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES;
|
||||
/// Minimum UTF-8 byte length admitted for one textual Base58 Solana transaction signature.
|
||||
pub use self::signature::MIN_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES;
|
||||
/// Extracts the first canonical 64-byte Solana signature from one complete Base64 transaction wire.
|
||||
pub use self::signature::extract_raw_transaction_signature_from_binary_base64;
|
||||
/// Encodes one canonical 64-byte Solana transaction signature as bounded Base58 text.
|
||||
pub use self::signature::format_raw_transaction_signature;
|
||||
/// Parses one bounded Base58 Solana transaction signature to exactly 64 canonical bytes.
|
||||
pub use self::signature::parse_raw_transaction_signature;
|
||||
/// One source-neutral v0 address-table lookup.
|
||||
pub use self::wire::RawSolanaAddressTableLookup;
|
||||
/// One source-neutral compiled Solana instruction.
|
||||
pub use self::wire::RawSolanaCompiledInstruction;
|
||||
/// Source-neutral Solana message header.
|
||||
pub use self::wire::RawSolanaMessageHeader;
|
||||
/// Source-neutral Solana transaction message version.
|
||||
pub use self::wire::RawSolanaMessageVersion;
|
||||
/// Optional source-neutral Transaction V1 inline configuration.
|
||||
pub use self::wire::RawSolanaTransactionConfig;
|
||||
/// Complete source-neutral Solana transaction message.
|
||||
pub use self::wire::RawSolanaTransactionMessage;
|
||||
/// Complete source-neutral Solana transaction wire material.
|
||||
pub use self::wire::RawSolanaTransactionWire;
|
||||
/// Serializes one source-neutral Solana transaction to exact canonical wire bytes.
|
||||
pub use self::wire::serialize_solana_transaction_wire;
|
||||
/// Serializes one source-neutral Solana transaction to canonical padded standard Base64.
|
||||
pub use self::wire::serialize_solana_transaction_wire_base64;
|
||||
|
||||
/// Creates a safe canonicalization error without copying source payload material.
|
||||
pub(crate) use self::error::canonicalization_error;
|
||||
/// Creates a safe source-material error without copying source payload material.
|
||||
pub(crate) use self::error::material_error;
|
||||
/// Creates a safe signature error without copying textual signature material.
|
||||
pub(crate) use self::error::signature_error;
|
||||
319
crates/ksp-raw-transaction-lib/src/signature.rs
Normal file
319
crates/ksp-raw-transaction-lib/src/signature.rs
Normal file
@@ -0,0 +1,319 @@
|
||||
// file: crates/ksp-raw-transaction-lib/src/signature.rs
|
||||
// version: 4
|
||||
|
||||
use base64::Engine; // rust-rules: trait-import
|
||||
|
||||
/// Maximum UTF-8 byte length admitted for one textual Base58 Solana transaction signature.
|
||||
pub const MAX_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES: usize = 88;
|
||||
/// Minimum UTF-8 byte length admitted for one textual Base58 Solana transaction signature.
|
||||
pub const MIN_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES: usize = 64;
|
||||
|
||||
/// Encodes one canonical 64-byte Solana transaction signature as bounded Base58 text.
|
||||
#[must_use]
|
||||
pub fn format_raw_transaction_signature(signature: &ksp_store_api::RawTransactionSignature) -> std::string::String {
|
||||
const ALPHABET: &[u8; 58] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||
let bytes = signature.as_bytes();
|
||||
let leading_zeroes = bytes.iter().take_while(|byte| return **byte == 0).count();
|
||||
let mut digits = std::vec::Vec::with_capacity(crate::MAX_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES);
|
||||
for byte in bytes {
|
||||
let mut carry = u32::from(*byte);
|
||||
for digit in &mut digits {
|
||||
let expanded = (u32::from(*digit) * 256) + carry;
|
||||
*digit = (expanded % 58) as u8;
|
||||
carry = expanded / 58;
|
||||
}
|
||||
while carry != 0 {
|
||||
digits.push((carry % 58) as u8);
|
||||
carry /= 58;
|
||||
}
|
||||
}
|
||||
let mut output = std::string::String::with_capacity(leading_zeroes + digits.len());
|
||||
for _ in 0..leading_zeroes {
|
||||
output.push('1');
|
||||
}
|
||||
for digit in digits.iter().rev() {
|
||||
output.push(char::from(ALPHABET[usize::from(*digit)]));
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/// Parses one bounded Base58 Solana transaction signature to exactly 64 canonical bytes.
|
||||
pub fn parse_raw_transaction_signature(value: &str) -> ksp_core_lib::Result<ksp_store_api::RawTransactionSignature> {
|
||||
let text = value.as_bytes();
|
||||
if text.len() < crate::MIN_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES || text.len() > crate::MAX_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES {
|
||||
return std::result::Result::Err(
|
||||
crate::signature_error()
|
||||
.with_context("actual_len", text.len().to_string())
|
||||
.with_context("minimum_len", crate::MIN_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES.to_string())
|
||||
.with_context("maximum_len", crate::MAX_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES.to_string()),
|
||||
);
|
||||
}
|
||||
let mut decoded = [0_u8; 64];
|
||||
let mut leading_zeroes = 0_usize;
|
||||
for byte in text {
|
||||
if *byte != b'1' {
|
||||
break;
|
||||
}
|
||||
leading_zeroes += 1;
|
||||
}
|
||||
for byte in text {
|
||||
let digit = match base58_digit(*byte) {
|
||||
std::option::Option::Some(digit) => digit,
|
||||
std::option::Option::None => return std::result::Result::Err(crate::signature_error()),
|
||||
};
|
||||
let mut carry = u32::from(digit);
|
||||
for output in decoded.iter_mut().rev() {
|
||||
let expanded = (u32::from(*output) * 58) + carry;
|
||||
*output = (expanded & 0xff) as u8;
|
||||
carry = expanded >> 8;
|
||||
}
|
||||
if carry != 0 {
|
||||
return std::result::Result::Err(crate::signature_error());
|
||||
}
|
||||
}
|
||||
let significant_len = match decoded.iter().position(|byte| return *byte != 0) {
|
||||
std::option::Option::Some(index) => decoded.len() - index,
|
||||
std::option::Option::None => 0,
|
||||
};
|
||||
if leading_zeroes + significant_len != decoded.len() {
|
||||
return std::result::Result::Err(crate::signature_error());
|
||||
}
|
||||
return std::result::Result::Ok(ksp_store_api::RawTransactionSignature::new(decoded));
|
||||
}
|
||||
|
||||
/// Extracts the first canonical 64-byte Solana signature from one complete Base64-encoded transaction wire.
|
||||
///
|
||||
/// The compact signature-count prefix must use its canonical short-vector representation, contain at least one signature,
|
||||
/// and the decoded transaction must retain message bytes after the declared signature array.
|
||||
pub fn extract_raw_transaction_signature_from_binary_base64(value: &str) -> ksp_core_lib::Result<ksp_store_api::RawTransactionSignature> {
|
||||
if value.is_empty() || value.len() > ksp_store_api::MAX_RAW_PAYLOAD_BYTES {
|
||||
return std::result::Result::Err(crate::signature_error());
|
||||
}
|
||||
let decoded = base64::engine::general_purpose::STANDARD.decode(value);
|
||||
let decoded = match decoded {
|
||||
std::result::Result::Ok(decoded) => decoded,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(crate::signature_error()),
|
||||
};
|
||||
if base64::engine::general_purpose::STANDARD.encode(decoded.as_slice()) != value {
|
||||
return std::result::Result::Err(crate::signature_error());
|
||||
}
|
||||
if decoded.first() == std::option::Option::Some(&0x81) {
|
||||
return extract_v1_signature(decoded.as_slice());
|
||||
}
|
||||
return extract_legacy_or_v0_signature(decoded.as_slice());
|
||||
}
|
||||
|
||||
fn extract_legacy_or_v0_signature(decoded: &[u8]) -> ksp_core_lib::Result<ksp_store_api::RawTransactionSignature> {
|
||||
let prefix = decode_signature_count(decoded);
|
||||
let (signature_count, prefix_len) = match prefix {
|
||||
std::result::Result::Ok(prefix) => prefix,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if signature_count == 0 {
|
||||
return std::result::Result::Err(crate::signature_error());
|
||||
}
|
||||
let signatures_len = match signature_count.checked_mul(64) {
|
||||
std::option::Option::Some(length) => length,
|
||||
std::option::Option::None => return std::result::Result::Err(crate::signature_error()),
|
||||
};
|
||||
let message_offset = match prefix_len.checked_add(signatures_len) {
|
||||
std::option::Option::Some(offset) => offset,
|
||||
std::option::Option::None => return std::result::Result::Err(crate::signature_error()),
|
||||
};
|
||||
if message_offset >= decoded.len() {
|
||||
return std::result::Result::Err(crate::signature_error());
|
||||
}
|
||||
return copy_signature(decoded, prefix_len);
|
||||
}
|
||||
|
||||
fn extract_v1_signature(decoded: &[u8]) -> ksp_core_lib::Result<ksp_store_api::RawTransactionSignature> {
|
||||
if decoded.len() > 4_096 || decoded.len() < 42 {
|
||||
return std::result::Result::Err(crate::signature_error());
|
||||
}
|
||||
let required_signatures = match decoded.get(1) {
|
||||
std::option::Option::Some(value) => usize::from(*value),
|
||||
std::option::Option::None => return std::result::Result::Err(crate::signature_error()),
|
||||
};
|
||||
let readonly_signed = match decoded.get(2) {
|
||||
std::option::Option::Some(value) => usize::from(*value),
|
||||
std::option::Option::None => return std::result::Result::Err(crate::signature_error()),
|
||||
};
|
||||
let readonly_unsigned = match decoded.get(3) {
|
||||
std::option::Option::Some(value) => usize::from(*value),
|
||||
std::option::Option::None => return std::result::Result::Err(crate::signature_error()),
|
||||
};
|
||||
if required_signatures == 0 || required_signatures > 12 || readonly_signed >= required_signatures {
|
||||
return std::result::Result::Err(crate::signature_error());
|
||||
}
|
||||
let mask_bytes = match decoded.get(4..8) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(crate::signature_error()),
|
||||
};
|
||||
let mask = u32::from_le_bytes([mask_bytes[0], mask_bytes[1], mask_bytes[2], mask_bytes[3]]);
|
||||
if mask & !0x1f != 0 || matches!(mask & 0b11, 1 | 2) {
|
||||
return std::result::Result::Err(crate::signature_error());
|
||||
}
|
||||
let instruction_count = match decoded.get(40) {
|
||||
std::option::Option::Some(value) => usize::from(*value),
|
||||
std::option::Option::None => return std::result::Result::Err(crate::signature_error()),
|
||||
};
|
||||
let address_count = match decoded.get(41) {
|
||||
std::option::Option::Some(value) => usize::from(*value),
|
||||
std::option::Option::None => return std::result::Result::Err(crate::signature_error()),
|
||||
};
|
||||
if instruction_count > 64 || address_count > 64 || address_count < required_signatures + readonly_unsigned {
|
||||
return std::result::Result::Err(crate::signature_error());
|
||||
}
|
||||
let addresses_len = match address_count.checked_mul(32) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(crate::signature_error()),
|
||||
};
|
||||
let mut offset = match 42_usize.checked_add(addresses_len) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(crate::signature_error()),
|
||||
};
|
||||
let config_len = config_value_length(mask);
|
||||
offset = match offset.checked_add(config_len) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(crate::signature_error()),
|
||||
};
|
||||
let headers_len = match instruction_count.checked_mul(4) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(crate::signature_error()),
|
||||
};
|
||||
let headers_end = match offset.checked_add(headers_len) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(crate::signature_error()),
|
||||
};
|
||||
if headers_end > decoded.len() {
|
||||
return std::result::Result::Err(crate::signature_error());
|
||||
}
|
||||
let mut payload_len = 0_usize;
|
||||
for index in 0..instruction_count {
|
||||
let header_offset = offset + (index * 4);
|
||||
let program_index = usize::from(decoded[header_offset]);
|
||||
let account_index_count = usize::from(decoded[header_offset + 1]);
|
||||
let data_len = u16::from_le_bytes([decoded[header_offset + 2], decoded[header_offset + 3]]);
|
||||
if program_index >= address_count {
|
||||
return std::result::Result::Err(crate::signature_error());
|
||||
}
|
||||
payload_len = match payload_len.checked_add(account_index_count).and_then(|value| return value.checked_add(usize::from(data_len))) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(crate::signature_error()),
|
||||
};
|
||||
}
|
||||
let payload_end = match headers_end.checked_add(payload_len) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(crate::signature_error()),
|
||||
};
|
||||
let payload = match decoded.get(headers_end..payload_end) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(crate::signature_error()),
|
||||
};
|
||||
let mut payload_offset = 0_usize;
|
||||
for index in 0..instruction_count {
|
||||
let header_offset = offset + (index * 4);
|
||||
let account_index_count = usize::from(decoded[header_offset + 1]);
|
||||
let data_len = usize::from(u16::from_le_bytes([decoded[header_offset + 2], decoded[header_offset + 3]]));
|
||||
let account_end = match payload_offset.checked_add(account_index_count) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(crate::signature_error()),
|
||||
};
|
||||
let account_indexes = match payload.get(payload_offset..account_end) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(crate::signature_error()),
|
||||
};
|
||||
if account_indexes.iter().any(|value| return usize::from(*value) >= address_count) {
|
||||
return std::result::Result::Err(crate::signature_error());
|
||||
}
|
||||
payload_offset = match account_end.checked_add(data_len) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(crate::signature_error()),
|
||||
};
|
||||
}
|
||||
let signatures_len = match required_signatures.checked_mul(64) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(crate::signature_error()),
|
||||
};
|
||||
let expected_end = match payload_end.checked_add(signatures_len) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(crate::signature_error()),
|
||||
};
|
||||
if expected_end != decoded.len() {
|
||||
return std::result::Result::Err(crate::signature_error());
|
||||
}
|
||||
return copy_signature(decoded, payload_end);
|
||||
}
|
||||
|
||||
fn config_value_length(mask: u32) -> usize {
|
||||
let mut length = 0_usize;
|
||||
if mask & 0b11 == 0b11 {
|
||||
length += 8;
|
||||
}
|
||||
if mask & (1_u32 << 2) != 0 {
|
||||
length += 4;
|
||||
}
|
||||
if mask & (1_u32 << 3) != 0 {
|
||||
length += 4;
|
||||
}
|
||||
if mask & (1_u32 << 4) != 0 {
|
||||
length += 4;
|
||||
}
|
||||
return length;
|
||||
}
|
||||
|
||||
fn copy_signature(decoded: &[u8], offset: usize) -> ksp_core_lib::Result<ksp_store_api::RawTransactionSignature> {
|
||||
let first_end = match offset.checked_add(64) {
|
||||
std::option::Option::Some(end) => end,
|
||||
std::option::Option::None => return std::result::Result::Err(crate::signature_error()),
|
||||
};
|
||||
let first = match decoded.get(offset..first_end) {
|
||||
std::option::Option::Some(first) => first,
|
||||
std::option::Option::None => return std::result::Result::Err(crate::signature_error()),
|
||||
};
|
||||
let mut signature = [0_u8; 64];
|
||||
signature.copy_from_slice(first);
|
||||
return std::result::Result::Ok(ksp_store_api::RawTransactionSignature::new(signature));
|
||||
}
|
||||
|
||||
fn decode_signature_count(bytes: &[u8]) -> ksp_core_lib::Result<(usize, usize)> {
|
||||
let mut value = 0_usize;
|
||||
for index in 0..3_usize {
|
||||
let byte = match bytes.get(index) {
|
||||
std::option::Option::Some(byte) => *byte,
|
||||
std::option::Option::None => return std::result::Result::Err(crate::signature_error()),
|
||||
};
|
||||
let payload = usize::from(byte & 0x7f);
|
||||
let shift = index * 7;
|
||||
value |= payload << shift;
|
||||
if byte & 0x80 == 0 {
|
||||
if index != 0 {
|
||||
let minimum = 1_usize << shift;
|
||||
if value < minimum {
|
||||
return std::result::Result::Err(crate::signature_error());
|
||||
}
|
||||
}
|
||||
if value > usize::from(u16::MAX) {
|
||||
return std::result::Result::Err(crate::signature_error());
|
||||
}
|
||||
return std::result::Result::Ok((value, index + 1));
|
||||
}
|
||||
}
|
||||
return std::result::Result::Err(crate::signature_error());
|
||||
}
|
||||
|
||||
fn base58_digit(byte: u8) -> std::option::Option<u8> {
|
||||
return match byte {
|
||||
b'1'..=b'9' => std::option::Option::Some(byte - b'1'),
|
||||
b'A'..=b'H' => std::option::Option::Some((byte - b'A') + 9),
|
||||
b'J'..=b'N' => std::option::Option::Some((byte - b'J') + 17),
|
||||
b'P'..=b'Z' => std::option::Option::Some((byte - b'P') + 22),
|
||||
b'a'..=b'k' => std::option::Option::Some((byte - b'a') + 33),
|
||||
b'm'..=b'z' => std::option::Option::Some((byte - b'm') + 44),
|
||||
_ => std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/signature.rs"]
|
||||
mod tests;
|
||||
545
crates/ksp-raw-transaction-lib/src/wire.rs
Normal file
545
crates/ksp-raw-transaction-lib/src/wire.rs
Normal file
@@ -0,0 +1,545 @@
|
||||
// file: crates/ksp-raw-transaction-lib/src/wire.rs
|
||||
// version: 3
|
||||
|
||||
use base64::Engine; // rust-rules: trait-import
|
||||
|
||||
const MAX_LEGACY_SHORT_VECTOR_VALUE: usize = u16::MAX as usize;
|
||||
const MAX_V1_ADDRESS_COUNT: usize = 64;
|
||||
const MAX_V1_INSTRUCTION_COUNT: usize = 64;
|
||||
const MAX_V1_SIGNATURE_COUNT: usize = 12;
|
||||
const MAX_V1_TRANSACTION_BYTES: usize = 4_096;
|
||||
|
||||
/// Source-neutral Solana transaction message version used by the exact wire serializer.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum RawSolanaMessageVersion {
|
||||
/// Legacy Solana message without a version prefix.
|
||||
Legacy,
|
||||
/// Versioned transaction message v0, prefixed with `0x80`.
|
||||
V0,
|
||||
/// Transaction V1 / SIMD-0385 message, prefixed with `0x81`.
|
||||
V1,
|
||||
}
|
||||
|
||||
/// Exact three-byte Solana message header.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct RawSolanaMessageHeader {
|
||||
num_required_signatures: u8,
|
||||
num_readonly_signed_accounts: u8,
|
||||
num_readonly_unsigned_accounts: u8,
|
||||
}
|
||||
|
||||
impl crate::RawSolanaMessageHeader {
|
||||
/// Creates an exact Solana message header.
|
||||
#[must_use]
|
||||
pub const fn new(num_required_signatures: u8, num_readonly_signed_accounts: u8, num_readonly_unsigned_accounts: u8) -> Self {
|
||||
return Self { num_required_signatures, num_readonly_signed_accounts, num_readonly_unsigned_accounts };
|
||||
}
|
||||
|
||||
/// Returns the required signature count.
|
||||
#[must_use]
|
||||
pub const fn num_required_signatures(self) -> u8 {
|
||||
return self.num_required_signatures;
|
||||
}
|
||||
|
||||
/// Returns the readonly signed-account count.
|
||||
#[must_use]
|
||||
pub const fn num_readonly_signed_accounts(self) -> u8 {
|
||||
return self.num_readonly_signed_accounts;
|
||||
}
|
||||
|
||||
/// Returns the readonly unsigned-account count.
|
||||
#[must_use]
|
||||
pub const fn num_readonly_unsigned_accounts(self) -> u8 {
|
||||
return self.num_readonly_unsigned_accounts;
|
||||
}
|
||||
}
|
||||
|
||||
/// One source-neutral compiled Solana instruction.
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub struct RawSolanaCompiledInstruction {
|
||||
program_id_index: u8,
|
||||
accounts: std::vec::Vec<u8>,
|
||||
data: std::vec::Vec<u8>,
|
||||
}
|
||||
|
||||
impl crate::RawSolanaCompiledInstruction {
|
||||
/// Creates one compiled instruction while preserving exact ordered account indexes and data bytes.
|
||||
#[must_use]
|
||||
pub fn new(program_id_index: u8, accounts: std::vec::Vec<u8>, data: std::vec::Vec<u8>) -> Self {
|
||||
return Self { program_id_index, accounts, data };
|
||||
}
|
||||
|
||||
/// Returns the program account index.
|
||||
#[must_use]
|
||||
pub const fn program_id_index(&self) -> u8 {
|
||||
return self.program_id_index;
|
||||
}
|
||||
|
||||
/// Returns ordered account indexes.
|
||||
#[must_use]
|
||||
pub fn accounts(&self) -> &[u8] {
|
||||
return self.accounts.as_slice();
|
||||
}
|
||||
|
||||
/// Returns exact instruction data bytes.
|
||||
#[must_use]
|
||||
pub fn data(&self) -> &[u8] {
|
||||
return self.data.as_slice();
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for crate::RawSolanaCompiledInstruction {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter
|
||||
.debug_struct("RawSolanaCompiledInstruction")
|
||||
.field("program_id_index", &self.program_id_index)
|
||||
.field("account_index_count", &self.accounts.len())
|
||||
.field("data_length", &self.data.len())
|
||||
.finish();
|
||||
}
|
||||
}
|
||||
|
||||
/// One source-neutral v0 address-table lookup.
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub struct RawSolanaAddressTableLookup {
|
||||
account_key: [u8; 32],
|
||||
writable_indexes: std::vec::Vec<u8>,
|
||||
readonly_indexes: std::vec::Vec<u8>,
|
||||
}
|
||||
|
||||
impl crate::RawSolanaAddressTableLookup {
|
||||
/// Creates one exact v0 address-table lookup.
|
||||
#[must_use]
|
||||
pub fn new(account_key: [u8; 32], writable_indexes: std::vec::Vec<u8>, readonly_indexes: std::vec::Vec<u8>) -> Self {
|
||||
return Self { account_key, writable_indexes, readonly_indexes };
|
||||
}
|
||||
|
||||
/// Returns the lookup-table account key bytes.
|
||||
#[must_use]
|
||||
pub const fn account_key(&self) -> &[u8; 32] {
|
||||
return &self.account_key;
|
||||
}
|
||||
|
||||
/// Returns ordered writable lookup indexes.
|
||||
#[must_use]
|
||||
pub fn writable_indexes(&self) -> &[u8] {
|
||||
return self.writable_indexes.as_slice();
|
||||
}
|
||||
|
||||
/// Returns ordered readonly lookup indexes.
|
||||
#[must_use]
|
||||
pub fn readonly_indexes(&self) -> &[u8] {
|
||||
return self.readonly_indexes.as_slice();
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for crate::RawSolanaAddressTableLookup {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter
|
||||
.debug_struct("RawSolanaAddressTableLookup")
|
||||
.field("writable_index_count", &self.writable_indexes.len())
|
||||
.field("readonly_index_count", &self.readonly_indexes.len())
|
||||
.finish_non_exhaustive();
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional inline budget configuration carried by Solana Transaction V1.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct RawSolanaTransactionConfig {
|
||||
priority_fee: std::option::Option<u64>,
|
||||
compute_unit_limit: std::option::Option<u32>,
|
||||
loaded_accounts_data_size_limit: std::option::Option<u32>,
|
||||
heap_size: std::option::Option<u32>,
|
||||
}
|
||||
|
||||
impl crate::RawSolanaTransactionConfig {
|
||||
/// Creates an explicit Transaction V1 configuration, including the meaningful all-`None` case.
|
||||
#[must_use]
|
||||
pub const fn new(
|
||||
priority_fee: std::option::Option<u64>,
|
||||
compute_unit_limit: std::option::Option<u32>,
|
||||
loaded_accounts_data_size_limit: std::option::Option<u32>,
|
||||
heap_size: std::option::Option<u32>,
|
||||
) -> Self {
|
||||
return Self { priority_fee, compute_unit_limit, loaded_accounts_data_size_limit, heap_size };
|
||||
}
|
||||
|
||||
/// Returns the optional priority fee.
|
||||
#[must_use]
|
||||
pub const fn priority_fee(self) -> std::option::Option<u64> {
|
||||
return self.priority_fee;
|
||||
}
|
||||
|
||||
/// Returns the optional compute-unit limit.
|
||||
#[must_use]
|
||||
pub const fn compute_unit_limit(self) -> std::option::Option<u32> {
|
||||
return self.compute_unit_limit;
|
||||
}
|
||||
|
||||
/// Returns the optional loaded-account-data-size limit.
|
||||
#[must_use]
|
||||
pub const fn loaded_accounts_data_size_limit(self) -> std::option::Option<u32> {
|
||||
return self.loaded_accounts_data_size_limit;
|
||||
}
|
||||
|
||||
/// Returns the optional heap-size override.
|
||||
#[must_use]
|
||||
pub const fn heap_size(self) -> std::option::Option<u32> {
|
||||
return self.heap_size;
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete source-neutral Solana transaction message required for exact wire serialization.
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub struct RawSolanaTransactionMessage {
|
||||
version: crate::RawSolanaMessageVersion,
|
||||
header: crate::RawSolanaMessageHeader,
|
||||
account_keys: std::vec::Vec<[u8; 32]>,
|
||||
recent_blockhash: [u8; 32],
|
||||
instructions: std::vec::Vec<crate::RawSolanaCompiledInstruction>,
|
||||
address_table_lookups: std::vec::Vec<crate::RawSolanaAddressTableLookup>,
|
||||
config: std::option::Option<crate::RawSolanaTransactionConfig>,
|
||||
}
|
||||
|
||||
impl crate::RawSolanaTransactionMessage {
|
||||
/// Creates one exact source-neutral message.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
version: crate::RawSolanaMessageVersion,
|
||||
header: crate::RawSolanaMessageHeader,
|
||||
account_keys: std::vec::Vec<[u8; 32]>,
|
||||
recent_blockhash: [u8; 32],
|
||||
instructions: std::vec::Vec<crate::RawSolanaCompiledInstruction>,
|
||||
address_table_lookups: std::vec::Vec<crate::RawSolanaAddressTableLookup>,
|
||||
config: std::option::Option<crate::RawSolanaTransactionConfig>,
|
||||
) -> Self {
|
||||
return Self { version, header, account_keys, recent_blockhash, instructions, address_table_lookups, config };
|
||||
}
|
||||
|
||||
/// Returns the message version.
|
||||
#[must_use]
|
||||
pub const fn version(&self) -> crate::RawSolanaMessageVersion {
|
||||
return self.version;
|
||||
}
|
||||
|
||||
/// Returns the message header.
|
||||
#[must_use]
|
||||
pub const fn header(&self) -> crate::RawSolanaMessageHeader {
|
||||
return self.header;
|
||||
}
|
||||
|
||||
/// Returns ordered static account key bytes.
|
||||
#[must_use]
|
||||
pub fn account_keys(&self) -> &[[u8; 32]] {
|
||||
return self.account_keys.as_slice();
|
||||
}
|
||||
|
||||
/// Returns the recent blockhash bytes.
|
||||
#[must_use]
|
||||
pub const fn recent_blockhash(&self) -> &[u8; 32] {
|
||||
return &self.recent_blockhash;
|
||||
}
|
||||
|
||||
/// Returns ordered compiled instructions.
|
||||
#[must_use]
|
||||
pub fn instructions(&self) -> &[crate::RawSolanaCompiledInstruction] {
|
||||
return self.instructions.as_slice();
|
||||
}
|
||||
|
||||
/// Returns ordered v0 address-table lookups.
|
||||
#[must_use]
|
||||
pub fn address_table_lookups(&self) -> &[crate::RawSolanaAddressTableLookup] {
|
||||
return self.address_table_lookups.as_slice();
|
||||
}
|
||||
|
||||
/// Returns optional V1 inline configuration.
|
||||
#[must_use]
|
||||
pub const fn config(&self) -> std::option::Option<crate::RawSolanaTransactionConfig> {
|
||||
return self.config;
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for crate::RawSolanaTransactionMessage {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter
|
||||
.debug_struct("RawSolanaTransactionMessage")
|
||||
.field("version", &self.version)
|
||||
.field("header", &self.header)
|
||||
.field("account_key_count", &self.account_keys.len())
|
||||
.field("instruction_count", &self.instructions.len())
|
||||
.field("address_table_lookup_count", &self.address_table_lookups.len())
|
||||
.field("has_config", &self.config.is_some())
|
||||
.finish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete source-neutral Solana transaction wire material.
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub struct RawSolanaTransactionWire {
|
||||
signatures: std::vec::Vec<[u8; 64]>,
|
||||
message: crate::RawSolanaTransactionMessage,
|
||||
}
|
||||
|
||||
impl crate::RawSolanaTransactionWire {
|
||||
/// Creates one complete transaction wire.
|
||||
#[must_use]
|
||||
pub fn new(signatures: std::vec::Vec<[u8; 64]>, message: crate::RawSolanaTransactionMessage) -> Self {
|
||||
return Self { signatures, message };
|
||||
}
|
||||
|
||||
/// Returns ordered signature bytes.
|
||||
#[must_use]
|
||||
pub fn signatures(&self) -> &[[u8; 64]] {
|
||||
return self.signatures.as_slice();
|
||||
}
|
||||
|
||||
/// Returns the exact source-neutral message.
|
||||
#[must_use]
|
||||
pub const fn message(&self) -> &crate::RawSolanaTransactionMessage {
|
||||
return &self.message;
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for crate::RawSolanaTransactionWire {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter.debug_struct("RawSolanaTransactionWire").field("signature_count", &self.signatures.len()).field("message", &self.message).finish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes one source-neutral Solana transaction to exact canonical wire bytes.
|
||||
pub fn serialize_solana_transaction_wire(transaction: &crate::RawSolanaTransactionWire) -> ksp_core_lib::Result<std::vec::Vec<u8>> {
|
||||
if let std::result::Result::Err(error) = validate_transaction(transaction) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let mut output = std::vec::Vec::new();
|
||||
match transaction.message().version() {
|
||||
crate::RawSolanaMessageVersion::Legacy | crate::RawSolanaMessageVersion::V0 => {
|
||||
if let std::result::Result::Err(error) = encode_short_vec(transaction.signatures().len(), &mut output) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
for signature in transaction.signatures() {
|
||||
output.extend_from_slice(signature);
|
||||
}
|
||||
if transaction.message().version() == crate::RawSolanaMessageVersion::V0 {
|
||||
output.push(0x80);
|
||||
}
|
||||
if let std::result::Result::Err(error) = encode_legacy_or_v0_message(transaction.message(), &mut output) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
},
|
||||
crate::RawSolanaMessageVersion::V1 => {
|
||||
if let std::result::Result::Err(error) = encode_v1_message(transaction, &mut output) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
},
|
||||
}
|
||||
if output.len() > ksp_store_api::MAX_RAW_PAYLOAD_BYTES {
|
||||
return std::result::Result::Err(crate::material_error("wire.bytes").with_context("actual_len", output.len().to_string()));
|
||||
}
|
||||
if transaction.message().version() == crate::RawSolanaMessageVersion::V1 && output.len() > MAX_V1_TRANSACTION_BYTES {
|
||||
return std::result::Result::Err(crate::material_error("wire.bytes").with_context("actual_len", output.len().to_string()));
|
||||
}
|
||||
return std::result::Result::Ok(output);
|
||||
}
|
||||
|
||||
/// Serializes one source-neutral Solana transaction and returns canonical padded standard Base64.
|
||||
pub fn serialize_solana_transaction_wire_base64(transaction: &crate::RawSolanaTransactionWire) -> ksp_core_lib::Result<std::string::String> {
|
||||
let bytes = match crate::serialize_solana_transaction_wire(transaction) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(base64::engine::general_purpose::STANDARD.encode(bytes));
|
||||
}
|
||||
|
||||
fn validate_transaction(transaction: &crate::RawSolanaTransactionWire) -> ksp_core_lib::Result<()> {
|
||||
let message = transaction.message();
|
||||
let required_signatures = usize::from(message.header().num_required_signatures());
|
||||
if required_signatures == 0 || transaction.signatures().len() != required_signatures {
|
||||
return std::result::Result::Err(crate::material_error("wire"));
|
||||
}
|
||||
if usize::from(message.header().num_readonly_signed_accounts()) >= required_signatures {
|
||||
return std::result::Result::Err(crate::material_error("wire"));
|
||||
}
|
||||
if message.account_keys().len() < required_signatures + usize::from(message.header().num_readonly_unsigned_accounts()) {
|
||||
return std::result::Result::Err(crate::material_error("wire"));
|
||||
}
|
||||
let loaded_count = message.address_table_lookups().iter().fold(0_usize, |count, lookup| {
|
||||
return count.saturating_add(lookup.writable_indexes().len()).saturating_add(lookup.readonly_indexes().len());
|
||||
});
|
||||
let account_count = message.account_keys().len().saturating_add(loaded_count);
|
||||
for instruction in message.instructions() {
|
||||
if usize::from(instruction.program_id_index()) >= account_count {
|
||||
return std::result::Result::Err(crate::material_error("wire"));
|
||||
}
|
||||
for account_index in instruction.accounts() {
|
||||
if usize::from(*account_index) >= account_count {
|
||||
return std::result::Result::Err(crate::material_error("wire"));
|
||||
}
|
||||
}
|
||||
}
|
||||
match message.version() {
|
||||
crate::RawSolanaMessageVersion::Legacy => {
|
||||
if !message.address_table_lookups().is_empty() || message.config().is_some() {
|
||||
return std::result::Result::Err(crate::material_error("wire"));
|
||||
}
|
||||
},
|
||||
crate::RawSolanaMessageVersion::V0 => {
|
||||
if message.config().is_some() {
|
||||
return std::result::Result::Err(crate::material_error("wire"));
|
||||
}
|
||||
},
|
||||
crate::RawSolanaMessageVersion::V1 => {
|
||||
if message.config().is_none() || !message.address_table_lookups().is_empty() {
|
||||
return std::result::Result::Err(crate::material_error("wire"));
|
||||
}
|
||||
if transaction.signatures().len() > MAX_V1_SIGNATURE_COUNT
|
||||
|| message.account_keys().len() > MAX_V1_ADDRESS_COUNT
|
||||
|| message.instructions().len() > MAX_V1_INSTRUCTION_COUNT
|
||||
{
|
||||
return std::result::Result::Err(crate::material_error("wire"));
|
||||
}
|
||||
for (index, account) in message.account_keys().iter().enumerate() {
|
||||
if message.account_keys()[..index].contains(account) {
|
||||
return std::result::Result::Err(crate::material_error("wire"));
|
||||
}
|
||||
}
|
||||
for instruction in message.instructions() {
|
||||
if instruction.accounts().len() > usize::from(u8::MAX) || instruction.data().len() > usize::from(u16::MAX) {
|
||||
return std::result::Result::Err(crate::material_error("wire"));
|
||||
}
|
||||
}
|
||||
if let std::option::Option::Some(heap_size) = message.config().and_then(crate::RawSolanaTransactionConfig::heap_size)
|
||||
&& (!(32_768..=262_144).contains(&heap_size) || heap_size % 1_024 != 0)
|
||||
{
|
||||
return std::result::Result::Err(crate::material_error("wire"));
|
||||
}
|
||||
},
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn encode_legacy_or_v0_message(message: &crate::RawSolanaTransactionMessage, output: &mut std::vec::Vec<u8>) -> ksp_core_lib::Result<()> {
|
||||
encode_header(message.header(), output);
|
||||
if let std::result::Result::Err(error) = encode_short_vec(message.account_keys().len(), output) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
for account_key in message.account_keys() {
|
||||
output.extend_from_slice(account_key);
|
||||
}
|
||||
output.extend_from_slice(message.recent_blockhash());
|
||||
if let std::result::Result::Err(error) = encode_short_vec(message.instructions().len(), output) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
for instruction in message.instructions() {
|
||||
output.push(instruction.program_id_index());
|
||||
if let std::result::Result::Err(error) = encode_short_vec(instruction.accounts().len(), output) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
output.extend_from_slice(instruction.accounts());
|
||||
if let std::result::Result::Err(error) = encode_short_vec(instruction.data().len(), output) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
output.extend_from_slice(instruction.data());
|
||||
}
|
||||
if message.version() == crate::RawSolanaMessageVersion::V0 {
|
||||
if let std::result::Result::Err(error) = encode_short_vec(message.address_table_lookups().len(), output) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
for lookup in message.address_table_lookups() {
|
||||
output.extend_from_slice(lookup.account_key());
|
||||
if let std::result::Result::Err(error) = encode_short_vec(lookup.writable_indexes().len(), output) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
output.extend_from_slice(lookup.writable_indexes());
|
||||
if let std::result::Result::Err(error) = encode_short_vec(lookup.readonly_indexes().len(), output) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
output.extend_from_slice(lookup.readonly_indexes());
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn encode_v1_message(transaction: &crate::RawSolanaTransactionWire, output: &mut std::vec::Vec<u8>) -> ksp_core_lib::Result<()> {
|
||||
let message = transaction.message();
|
||||
let config = match message.config() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(crate::material_error("wire")),
|
||||
};
|
||||
output.push(0x81);
|
||||
encode_header(message.header(), output);
|
||||
let mut mask = 0_u32;
|
||||
if config.priority_fee().is_some() {
|
||||
mask |= 0b11;
|
||||
}
|
||||
if config.compute_unit_limit().is_some() {
|
||||
mask |= 1_u32 << 2;
|
||||
}
|
||||
if config.loaded_accounts_data_size_limit().is_some() {
|
||||
mask |= 1_u32 << 3;
|
||||
}
|
||||
if config.heap_size().is_some() {
|
||||
mask |= 1_u32 << 4;
|
||||
}
|
||||
output.extend_from_slice(&mask.to_le_bytes());
|
||||
output.extend_from_slice(message.recent_blockhash());
|
||||
output.push(message.instructions().len() as u8);
|
||||
output.push(message.account_keys().len() as u8);
|
||||
for account_key in message.account_keys() {
|
||||
output.extend_from_slice(account_key);
|
||||
}
|
||||
if let std::option::Option::Some(value) = config.priority_fee() {
|
||||
output.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
if let std::option::Option::Some(value) = config.compute_unit_limit() {
|
||||
output.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
if let std::option::Option::Some(value) = config.loaded_accounts_data_size_limit() {
|
||||
output.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
if let std::option::Option::Some(value) = config.heap_size() {
|
||||
output.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
for instruction in message.instructions() {
|
||||
output.push(instruction.program_id_index());
|
||||
output.push(instruction.accounts().len() as u8);
|
||||
output.extend_from_slice(&(instruction.data().len() as u16).to_le_bytes());
|
||||
}
|
||||
for instruction in message.instructions() {
|
||||
output.extend_from_slice(instruction.accounts());
|
||||
output.extend_from_slice(instruction.data());
|
||||
}
|
||||
for signature in transaction.signatures() {
|
||||
output.extend_from_slice(signature);
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn encode_header(header: crate::RawSolanaMessageHeader, output: &mut std::vec::Vec<u8>) {
|
||||
output.push(header.num_required_signatures());
|
||||
output.push(header.num_readonly_signed_accounts());
|
||||
output.push(header.num_readonly_unsigned_accounts());
|
||||
}
|
||||
|
||||
fn encode_short_vec(value: usize, output: &mut std::vec::Vec<u8>) -> ksp_core_lib::Result<()> {
|
||||
if value > MAX_LEGACY_SHORT_VECTOR_VALUE {
|
||||
return std::result::Result::Err(crate::material_error("wire"));
|
||||
}
|
||||
let mut remaining = value;
|
||||
loop {
|
||||
let mut byte = (remaining & 0x7f) as u8;
|
||||
remaining >>= 7;
|
||||
if remaining != 0 {
|
||||
byte |= 0x80;
|
||||
}
|
||||
output.push(byte);
|
||||
if remaining == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/wire.rs"]
|
||||
mod tests;
|
||||
55
crates/ksp-raw-transaction-lib/tests/dependency_boundary.rs
Normal file
55
crates/ksp-raw-transaction-lib/tests/dependency_boundary.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
// file: crates/ksp-raw-transaction-lib/tests/dependency_boundary.rs
|
||||
// version: 4
|
||||
|
||||
//! Dependency-boundary canaries for the common RAW Transaction foundation.
|
||||
|
||||
fn manifest() -> std::string::String {
|
||||
return std::fs::read_to_string(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml")).unwrap_or_default();
|
||||
}
|
||||
|
||||
fn production_sources() -> std::string::String {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let mut combined = std::string::String::new();
|
||||
for name in ["acquisition.rs", "canonical.rs", "error.rs", "lib.rs", "signature.rs", "wire.rs"] {
|
||||
let source = std::fs::read_to_string(root.join(name)).unwrap_or_default();
|
||||
combined.push_str(source.as_str());
|
||||
}
|
||||
return combined;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_004_manifest_adds_only_base64_to_the_exact_common_dependency_surface() {
|
||||
let manifest = manifest();
|
||||
assert!(manifest.contains("base64.workspace = true"));
|
||||
assert!(manifest.contains("ksp-core-lib = { path = \"../ksp-core-lib\" }"));
|
||||
assert!(manifest.contains("ksp-store-api = { path = \"../ksp-store-api\" }"));
|
||||
assert!(manifest.contains("serde_json = { workspace = true }"));
|
||||
assert!(manifest.contains("sha2 = { workspace = true }"));
|
||||
for forbidden in
|
||||
["ksp-store-lib", "ksp-onchain-transport-lib", "ksp-config-lib", "ksp-job-api", "ksp-job-backfill-lib", "ksp-worker-api", "tokio", "futures"]
|
||||
{
|
||||
assert!(!manifest.contains(forbidden));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_production_sources_keep_transport_runtime_job_worker_and_backend_out() {
|
||||
let sources = production_sources();
|
||||
for forbidden in [
|
||||
"ksp_store_lib",
|
||||
"ksp_onchain_transport_lib",
|
||||
"ksp_config_lib",
|
||||
"ksp_job_api",
|
||||
"ksp_job_backfill_lib",
|
||||
"ksp_worker_api",
|
||||
"ksp_store_postgres_lib",
|
||||
"tokio::",
|
||||
"futures::",
|
||||
"reqwest::",
|
||||
"tonic::",
|
||||
] {
|
||||
assert!(!sources.contains(forbidden));
|
||||
}
|
||||
return;
|
||||
}
|
||||
135
crates/ksp-raw-transaction-lib/tests/public_api.rs
Normal file
135
crates/ksp-raw-transaction-lib/tests/public_api.rs
Normal file
@@ -0,0 +1,135 @@
|
||||
// file: crates/ksp-raw-transaction-lib/tests/public_api.rs
|
||||
// version: 5
|
||||
|
||||
//! Integration canaries for the public common RAW Transaction crate-root surface.
|
||||
|
||||
#[test]
|
||||
fn pre_002_common_raw_contract_is_consumable_from_crate_root() {
|
||||
assert_eq!(ksp_raw_transaction_lib::RAW_TRANSACTION_FORMAT_ID, "ksp.solana.raw_transaction");
|
||||
assert_eq!(ksp_raw_transaction_lib::RAW_TRANSACTION_FORMAT_VERSION, 1);
|
||||
assert_eq!(ksp_raw_transaction_lib::MIN_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES, 64);
|
||||
assert_eq!(ksp_raw_transaction_lib::MAX_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES, 88);
|
||||
let network = match ksp_store_api::RawNetworkId::new("devnet") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let signature = match ksp_raw_transaction_lib::parse_raw_transaction_signature("1".repeat(64).as_str()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let material = ksp_raw_transaction_lib::RawTransactionMaterial::binary_base64(
|
||||
network,
|
||||
signature,
|
||||
5,
|
||||
std::option::Option::None,
|
||||
"AQID",
|
||||
ksp_raw_transaction_lib::RawTransactionWireField::Null,
|
||||
ksp_raw_transaction_lib::RawTransactionWireField::Value(ksp_raw_transaction_lib::RawTransactionVersion::Legacy),
|
||||
ksp_raw_transaction_lib::RawTransactionWireField::Omitted,
|
||||
);
|
||||
let transaction = ksp_raw_transaction_lib::canonicalize_raw_transaction(material);
|
||||
assert!(transaction.is_ok());
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_acquisition_assembly_accepts_store_api_producer_owned_metadata() {
|
||||
let network = match ksp_store_api::RawNetworkId::new("devnet") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let signature = match ksp_raw_transaction_lib::parse_raw_transaction_signature("1".repeat(64).as_str()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let material = ksp_raw_transaction_lib::RawTransactionMaterial::binary_base64(
|
||||
network,
|
||||
signature,
|
||||
5,
|
||||
std::option::Option::None,
|
||||
"AQID",
|
||||
ksp_raw_transaction_lib::RawTransactionWireField::Omitted,
|
||||
ksp_raw_transaction_lib::RawTransactionWireField::Omitted,
|
||||
ksp_raw_transaction_lib::RawTransactionWireField::Omitted,
|
||||
);
|
||||
let transaction = match ksp_raw_transaction_lib::canonicalize_raw_transaction(material) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let provider = match ksp_store_api::RawProvenanceCode::new("fixture") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let protocol = match ksp_store_api::RawProvenanceCode::new("fixture.protocol") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let method = match ksp_store_api::RawProvenanceCode::new("fixture.method") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let received_at = match ksp_store_api::RawTimestamp::from_unix_millis(1) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let provenance = ksp_store_api::RawAcquisitionProvenance::new(provider, protocol, method, ksp_store_api::RawAcquisitionOrigin::Import, received_at);
|
||||
let acquisition = ksp_raw_transaction_lib::assemble_raw_transaction_acquisition(transaction, ksp_store_api::RawObservationKey::new([9_u8; 32]), provenance);
|
||||
assert_eq!(acquisition.transaction().reference(), acquisition.observation().transaction());
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_004_embedded_signature_and_block_material_contract_are_available_from_crate_root() {
|
||||
let encoded = "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let signature = ksp_raw_transaction_lib::extract_raw_transaction_signature_from_binary_base64(encoded);
|
||||
assert!(signature.is_ok());
|
||||
let network = ksp_store_api::RawNetworkId::new("devnet");
|
||||
assert!(network.is_ok());
|
||||
if let std::result::Result::Ok(network) = network {
|
||||
let material = ksp_raw_transaction_lib::RawTransactionMaterial::binary_base64_with_embedded_signature(
|
||||
network,
|
||||
1,
|
||||
std::option::Option::None,
|
||||
encoded,
|
||||
ksp_raw_transaction_lib::RawTransactionWireField::Omitted,
|
||||
ksp_raw_transaction_lib::RawTransactionWireField::Omitted,
|
||||
ksp_raw_transaction_lib::RawTransactionWireField::Value(0),
|
||||
);
|
||||
assert!(material.is_ok());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_006_source_neutral_wire_contract_is_available_from_crate_root() {
|
||||
let message = ksp_raw_transaction_lib::RawSolanaTransactionMessage::new(
|
||||
ksp_raw_transaction_lib::RawSolanaMessageVersion::V1,
|
||||
ksp_raw_transaction_lib::RawSolanaMessageHeader::new(1, 0, 0),
|
||||
vec![[1_u8; 32]],
|
||||
[2_u8; 32],
|
||||
vec![ksp_raw_transaction_lib::RawSolanaCompiledInstruction::new(0, vec![0], vec![3])],
|
||||
vec![],
|
||||
std::option::Option::Some(ksp_raw_transaction_lib::RawSolanaTransactionConfig::default()),
|
||||
);
|
||||
let wire = ksp_raw_transaction_lib::RawSolanaTransactionWire::new(vec![[4_u8; 64]], message);
|
||||
let encoded = ksp_raw_transaction_lib::serialize_solana_transaction_wire_base64(&wire);
|
||||
assert!(encoded.is_ok());
|
||||
if let std::result::Result::Ok(encoded) = encoded {
|
||||
assert!(encoded.starts_with("gQ"));
|
||||
assert!(ksp_raw_transaction_lib::extract_raw_transaction_signature_from_binary_base64(encoded.as_str()).is_ok());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_004_signature_formatter_is_consumable_from_crate_root() {
|
||||
let signature = ksp_store_api::RawTransactionSignature::new([0_u8; 64]);
|
||||
let text = ksp_raw_transaction_lib::format_raw_transaction_signature(&signature);
|
||||
assert_eq!(text, "1".repeat(64));
|
||||
let parsed = ksp_raw_transaction_lib::parse_raw_transaction_signature(text.as_str());
|
||||
assert!(parsed.is_ok());
|
||||
if let std::result::Result::Ok(parsed) = parsed {
|
||||
assert_eq!(parsed, signature);
|
||||
}
|
||||
return;
|
||||
}
|
||||
41
crates/ksp-raw-transaction-lib/tests/release_completeness.rs
Normal file
41
crates/ksp-raw-transaction-lib/tests/release_completeness.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
// file: crates/ksp-raw-transaction-lib/tests/release_completeness.rs
|
||||
// version: 3
|
||||
|
||||
//! Release-completeness canaries for the common RAW Transaction foundation.
|
||||
|
||||
#[test]
|
||||
fn pre_002_production_module_inventory_is_exact() {
|
||||
let source_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let entries = match std::fs::read_dir(source_root) {
|
||||
std::result::Result::Ok(entries) => entries,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let mut names = std::vec::Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(std::ffi::OsStr::to_str) != std::option::Option::Some("rs") {
|
||||
continue;
|
||||
}
|
||||
let std::option::Option::Some(name) = path.file_name().and_then(std::ffi::OsStr::to_str) else {
|
||||
continue;
|
||||
};
|
||||
names.push(name.to_owned());
|
||||
}
|
||||
names.sort();
|
||||
let expected = ["acquisition.rs", "canonical.rs", "error.rs", "lib.rs", "signature.rs", "wire.rs"];
|
||||
assert_eq!(names.len(), expected.len());
|
||||
for (actual, expected_name) in names.iter().zip(expected.iter()) {
|
||||
assert_eq!(actual.as_str(), *expected_name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_common_surface_contains_no_worker_job_or_transport_dependency_or_public_module() {
|
||||
let lib = std::fs::read_to_string(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/lib.rs")).unwrap_or_default();
|
||||
assert!(!lib.contains("pub mod "));
|
||||
for forbidden in ["ksp_worker", "ksp_job", "ksp_onchain_transport", "ksp_config", "ksp_store_lib", "ksp_store_postgres"] {
|
||||
assert!(!lib.contains(forbidden));
|
||||
}
|
||||
return;
|
||||
}
|
||||
28
crates/ksp-raw-transaction-lib/tests/security_hardening.rs
Normal file
28
crates/ksp-raw-transaction-lib/tests/security_hardening.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
// file: crates/ksp-raw-transaction-lib/tests/security_hardening.rs
|
||||
// version: 2
|
||||
|
||||
//! Adversarial canaries for the common RAW Transaction foundation.
|
||||
|
||||
#[test]
|
||||
fn pre_002_signature_errors_never_echo_hostile_input() {
|
||||
let hostile = format!("{}0", "A".repeat(63));
|
||||
let result = ksp_raw_transaction_lib::parse_raw_transaction_signature(hostile.as_str());
|
||||
assert!(result.is_err());
|
||||
if let std::result::Result::Err(error) = result {
|
||||
assert_eq!(error.code(), ksp_raw_transaction_lib::ERROR_CODE_RAW_TRANSACTION_SIGNATURE_INVALID);
|
||||
assert!(!std::format!("{error:?}").contains(hostile.as_str()));
|
||||
assert!(!error.to_string().contains(hostile.as_str()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_public_error_codes_are_stable_and_domain_scoped() {
|
||||
assert_eq!(ksp_raw_transaction_lib::ERROR_CODE_RAW_TRANSACTION_CANONICALIZATION_INVALID.domain(), "raw_transaction");
|
||||
assert_eq!(ksp_raw_transaction_lib::ERROR_CODE_RAW_TRANSACTION_CANONICALIZATION_INVALID.code(), "canonicalization_invalid");
|
||||
assert_eq!(ksp_raw_transaction_lib::ERROR_CODE_RAW_TRANSACTION_MATERIAL_INVALID.domain(), "raw_transaction");
|
||||
assert_eq!(ksp_raw_transaction_lib::ERROR_CODE_RAW_TRANSACTION_MATERIAL_INVALID.code(), "material_invalid");
|
||||
assert_eq!(ksp_raw_transaction_lib::ERROR_CODE_RAW_TRANSACTION_SIGNATURE_INVALID.domain(), "raw_transaction");
|
||||
assert_eq!(ksp_raw_transaction_lib::ERROR_CODE_RAW_TRANSACTION_SIGNATURE_INVALID.code(), "signature_invalid");
|
||||
return;
|
||||
}
|
||||
73
crates/ksp-raw-transaction-lib/unit_tests/acquisition.rs
Normal file
73
crates/ksp-raw-transaction-lib/unit_tests/acquisition.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
// file: crates/ksp-raw-transaction-lib/unit_tests/acquisition.rs
|
||||
// version: 1
|
||||
|
||||
fn canonical_transaction() -> std::option::Option<ksp_store_api::RawTransaction> {
|
||||
let network = match ksp_store_api::RawNetworkId::new("devnet") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
let signature = match crate::parse_raw_transaction_signature("1".repeat(64).as_str()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
let material = crate::RawTransactionMaterial::binary_base64(
|
||||
network,
|
||||
signature,
|
||||
42,
|
||||
std::option::Option::None,
|
||||
"AQID",
|
||||
crate::RawTransactionWireField::Omitted,
|
||||
crate::RawTransactionWireField::Omitted,
|
||||
crate::RawTransactionWireField::Omitted,
|
||||
);
|
||||
return match crate::canonicalize_raw_transaction(material) {
|
||||
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
||||
std::result::Result::Err(_) => std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
fn provenance() -> std::option::Option<ksp_store_api::RawAcquisitionProvenance> {
|
||||
let provider = match ksp_store_api::RawProvenanceCode::new("fixture") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
let protocol = match ksp_store_api::RawProvenanceCode::new("fixture.protocol") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
let method = match ksp_store_api::RawProvenanceCode::new("fixture.method") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
let received_at = match ksp_store_api::RawTimestamp::from_unix_millis(1_700_000_000_000) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
return std::option::Option::Some(ksp_store_api::RawAcquisitionProvenance::new(
|
||||
provider,
|
||||
protocol,
|
||||
method,
|
||||
ksp_store_api::RawAcquisitionOrigin::Import,
|
||||
received_at,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_acquisition_preserves_producer_key_and_exact_transaction_reference() {
|
||||
let transaction = match canonical_transaction() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let provenance = match provenance() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let key = ksp_store_api::RawObservationKey::new([7_u8; 32]);
|
||||
let acquisition = crate::assemble_raw_transaction_acquisition(transaction, key, provenance);
|
||||
assert_eq!(acquisition.observation().observation_key(), key);
|
||||
assert_eq!(acquisition.observation().transaction(), acquisition.transaction().reference());
|
||||
assert_eq!(std::format!("{acquisition:?}"), "RawTransactionAcquisition(..)");
|
||||
let (transaction, observation) = acquisition.into_parts();
|
||||
assert_eq!(observation.transaction(), transaction.reference());
|
||||
return;
|
||||
}
|
||||
247
crates/ksp-raw-transaction-lib/unit_tests/canonical.rs
Normal file
247
crates/ksp-raw-transaction-lib/unit_tests/canonical.rs
Normal file
@@ -0,0 +1,247 @@
|
||||
// file: crates/ksp-raw-transaction-lib/unit_tests/canonical.rs
|
||||
// version: 2
|
||||
|
||||
fn network() -> std::option::Option<ksp_store_api::RawNetworkId> {
|
||||
return match ksp_store_api::RawNetworkId::new("devnet") {
|
||||
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
||||
std::result::Result::Err(_) => std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
fn signature() -> std::option::Option<ksp_store_api::RawTransactionSignature> {
|
||||
let result = crate::parse_raw_transaction_signature("1".repeat(64).as_str());
|
||||
return match result {
|
||||
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
||||
std::result::Result::Err(_) => std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
fn material(
|
||||
block_time: std::option::Option<i64>,
|
||||
transaction_data: std::string::String,
|
||||
meta: crate::RawTransactionWireField<serde_json::Value>,
|
||||
version: crate::RawTransactionWireField<crate::RawTransactionVersion>,
|
||||
transaction_index: crate::RawTransactionWireField<u32>,
|
||||
) -> std::option::Option<crate::RawTransactionMaterial> {
|
||||
let network = match network() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
let signature = match signature() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
return std::option::Option::Some(crate::RawTransactionMaterial::binary_base64(
|
||||
network,
|
||||
signature,
|
||||
123,
|
||||
block_time,
|
||||
transaction_data,
|
||||
meta,
|
||||
version,
|
||||
transaction_index,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_canonical_raw_v1_golden_bytes_and_hash_are_exact() {
|
||||
let material = match material(
|
||||
std::option::Option::Some(1_700_000_000),
|
||||
"AQID".to_owned(),
|
||||
crate::RawTransactionWireField::Value(serde_json::json!({"z": 1, "a": {"y": true, "x": null}})),
|
||||
crate::RawTransactionWireField::Value(crate::RawTransactionVersion::Legacy),
|
||||
crate::RawTransactionWireField::Value(7_u32),
|
||||
) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let transaction = crate::canonicalize_raw_transaction(material);
|
||||
assert!(transaction.is_ok());
|
||||
let transaction = match transaction {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let expected = b"{\"transaction\":[\"AQID\",\"base64\"],\"meta\":{\"a\":{\"x\":null,\"y\":true},\"z\":1},\"version\":\"legacy\",\"transactionIndex\":7}";
|
||||
assert_eq!(transaction.payload().bytes(), expected);
|
||||
assert_eq!(transaction.payload().byte_len(), 112);
|
||||
assert_eq!(transaction.payload().format_id().as_str(), crate::RAW_TRANSACTION_FORMAT_ID);
|
||||
assert_eq!(transaction.payload().format_version(), crate::RAW_TRANSACTION_FORMAT_VERSION);
|
||||
assert_eq!(
|
||||
transaction.payload().content_hash().as_bytes(),
|
||||
&[
|
||||
34, 7, 146, 210, 177, 93, 38, 47, 218, 36, 44, 178, 32, 119, 78, 233, 221, 239, 254, 191, 4, 220, 250, 218, 188, 248, 239, 118, 169, 177, 167, 195
|
||||
]
|
||||
);
|
||||
assert_eq!(transaction.slot(), 123);
|
||||
assert_eq!(transaction.block_time().map(|value| return value.unix_millis()), std::option::Option::Some(1_700_000_000_000));
|
||||
assert_eq!(transaction.reference().network().as_str(), "devnet");
|
||||
assert_eq!(transaction.reference().signature().as_bytes(), &[0_u8; 64]);
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_wire_omitted_null_and_value_states_remain_distinct() {
|
||||
let omitted = match material(
|
||||
std::option::Option::None,
|
||||
"AQID".to_owned(),
|
||||
crate::RawTransactionWireField::Omitted,
|
||||
crate::RawTransactionWireField::Omitted,
|
||||
crate::RawTransactionWireField::Omitted,
|
||||
) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let omitted = super::canonical_payload_bytes(&omitted);
|
||||
assert!(omitted.is_ok());
|
||||
if let std::result::Result::Ok(omitted) = omitted {
|
||||
assert_eq!(omitted, b"{\"transaction\":[\"AQID\",\"base64\"]}");
|
||||
}
|
||||
let nulls = match material(
|
||||
std::option::Option::None,
|
||||
"AQID".to_owned(),
|
||||
crate::RawTransactionWireField::Null,
|
||||
crate::RawTransactionWireField::Null,
|
||||
crate::RawTransactionWireField::Null,
|
||||
) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let nulls = super::canonical_payload_bytes(&nulls);
|
||||
assert!(nulls.is_ok());
|
||||
if let std::result::Result::Ok(nulls) = nulls {
|
||||
assert_eq!(nulls, b"{\"transaction\":[\"AQID\",\"base64\"],\"meta\":null,\"version\":null,\"transactionIndex\":null}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_canonical_json_sorts_objects_preserves_arrays_and_escapes_strings() {
|
||||
let meta = serde_json::json!({
|
||||
"z": [3, 2, 1],
|
||||
"quote": "a\"b\\c\n",
|
||||
"a": {"n": -12.5, "b": false, "a": true}
|
||||
});
|
||||
let material = match material(
|
||||
std::option::Option::None,
|
||||
"A\"B".to_owned(),
|
||||
crate::RawTransactionWireField::Value(meta),
|
||||
crate::RawTransactionWireField::Value(crate::RawTransactionVersion::Number(0)),
|
||||
crate::RawTransactionWireField::Value(u32::MAX),
|
||||
) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let bytes = super::canonical_payload_bytes(&material);
|
||||
assert!(bytes.is_ok());
|
||||
if let std::result::Result::Ok(bytes) = bytes {
|
||||
let expected = b"{\"transaction\":[\"A\\\"B\",\"base64\"],\"meta\":{\"a\":{\"a\":true,\"b\":false,\"n\":-12.5},\"quote\":\"a\\\"b\\\\c\\n\",\"z\":[3,2,1]},\"version\":0,\"transactionIndex\":4294967295}";
|
||||
assert_eq!(bytes, expected);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_block_time_rejects_negative_overflow_and_store_maximum_overrun() {
|
||||
for block_time in [-1_i64, i64::MAX, 253_402_300_800_i64] {
|
||||
let material = match material(
|
||||
std::option::Option::Some(block_time),
|
||||
"AQID".to_owned(),
|
||||
crate::RawTransactionWireField::Omitted,
|
||||
crate::RawTransactionWireField::Omitted,
|
||||
crate::RawTransactionWireField::Omitted,
|
||||
) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let result = crate::canonicalize_raw_transaction(material);
|
||||
assert!(result.is_err());
|
||||
if let std::result::Result::Err(error) = result {
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_RAW_TRANSACTION_MATERIAL_INVALID);
|
||||
}
|
||||
}
|
||||
let maximum = match material(
|
||||
std::option::Option::Some(253_402_300_799_i64),
|
||||
"AQID".to_owned(),
|
||||
crate::RawTransactionWireField::Omitted,
|
||||
crate::RawTransactionWireField::Omitted,
|
||||
crate::RawTransactionWireField::Omitted,
|
||||
) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let maximum = crate::canonicalize_raw_transaction(maximum);
|
||||
assert!(maximum.is_ok());
|
||||
if let std::result::Result::Ok(maximum) = maximum {
|
||||
assert_eq!(maximum.block_time().map(|value| return value.unix_millis()), std::option::Option::Some(253_402_300_799_000));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_payload_bound_is_enforced_by_common_before_store_construction() {
|
||||
let material = match material(
|
||||
std::option::Option::None,
|
||||
"A".repeat(ksp_store_api::MAX_RAW_PAYLOAD_BYTES),
|
||||
crate::RawTransactionWireField::Omitted,
|
||||
crate::RawTransactionWireField::Omitted,
|
||||
crate::RawTransactionWireField::Omitted,
|
||||
) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let result = crate::canonicalize_raw_transaction(material);
|
||||
assert!(result.is_err());
|
||||
if let std::result::Result::Err(error) = result {
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_RAW_TRANSACTION_CANONICALIZATION_INVALID);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_material_and_wire_debug_do_not_render_transaction_or_meta_material() {
|
||||
let secret_transaction = "SECRET_TRANSACTION_BODY";
|
||||
let secret_meta = "SECRET_META_VALUE";
|
||||
let material = match material(
|
||||
std::option::Option::None,
|
||||
secret_transaction.to_owned(),
|
||||
crate::RawTransactionWireField::Value(serde_json::json!({"secret": secret_meta})),
|
||||
crate::RawTransactionWireField::Value(crate::RawTransactionVersion::Legacy),
|
||||
crate::RawTransactionWireField::Value(1_u32),
|
||||
) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let debug = std::format!("{material:?}");
|
||||
assert!(!debug.contains(secret_transaction));
|
||||
assert!(!debug.contains(secret_meta));
|
||||
assert_eq!(std::format!("{:?}", crate::RawTransactionWireField::Value(secret_meta)), "Value(..)");
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_004_embedded_signature_material_preserves_block_fields_and_canonicalizes() {
|
||||
let network = match ksp_store_api::RawNetworkId::new("devnet") {
|
||||
std::result::Result::Ok(network) => network,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let material = crate::RawTransactionMaterial::binary_base64_with_embedded_signature(
|
||||
network,
|
||||
430_000_123,
|
||||
std::option::Option::Some(1_787_072_400),
|
||||
"AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
crate::RawTransactionWireField::Value(serde_json::json!({"err":null,"fee":5000})),
|
||||
crate::RawTransactionWireField::Value(crate::RawTransactionVersion::Legacy),
|
||||
crate::RawTransactionWireField::Value(0_u32),
|
||||
);
|
||||
assert!(material.is_ok());
|
||||
let transaction = match material.and_then(crate::canonicalize_raw_transaction) {
|
||||
std::result::Result::Ok(transaction) => transaction,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(transaction.reference().signature().as_bytes(), &[0_u8; 64]);
|
||||
assert_eq!(transaction.slot(), 430_000_123);
|
||||
assert_eq!(transaction.block_time().map(|value| return value.unix_millis()), std::option::Option::Some(1_787_072_400_000));
|
||||
assert!(transaction.payload().bytes().starts_with(b"{\"transaction\":[\"AQAAAA"));
|
||||
assert!(transaction.payload().bytes().ends_with(b"\"version\":\"legacy\",\"transactionIndex\":0}"));
|
||||
return;
|
||||
}
|
||||
97
crates/ksp-raw-transaction-lib/unit_tests/signature.rs
Normal file
97
crates/ksp-raw-transaction-lib/unit_tests/signature.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
// file: crates/ksp-raw-transaction-lib/unit_tests/signature.rs
|
||||
// version: 4
|
||||
|
||||
use base64::Engine; // rust-rules: trait-import
|
||||
|
||||
#[test]
|
||||
fn pre_002_signature_parser_accepts_exact_sixty_four_zero_bytes() {
|
||||
let text = "1".repeat(crate::MIN_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES);
|
||||
let signature = crate::parse_raw_transaction_signature(text.as_str());
|
||||
assert!(signature.is_ok());
|
||||
if let std::result::Result::Ok(signature) = signature {
|
||||
assert_eq!(signature.as_bytes(), &[0_u8; 64]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_signature_parser_rejects_text_bounds_invalid_base58_and_noncanonical_lengths() {
|
||||
for text in [
|
||||
"1".repeat(crate::MIN_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES - 1),
|
||||
"1".repeat(crate::MAX_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES + 1),
|
||||
format!("{}0", "1".repeat(crate::MIN_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES - 1)),
|
||||
"1".repeat(65),
|
||||
"z".repeat(crate::MAX_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES),
|
||||
] {
|
||||
let result = crate::parse_raw_transaction_signature(text.as_str());
|
||||
assert!(result.is_err());
|
||||
if let std::result::Result::Err(error) = result {
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_RAW_TRANSACTION_SIGNATURE_INVALID);
|
||||
let debug = std::format!("{error:?}");
|
||||
assert!(!debug.contains(text.as_str()));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_004_embedded_signature_extracts_first_signature_from_canonical_transaction_base64() {
|
||||
let encoded = "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let signature = crate::extract_raw_transaction_signature_from_binary_base64(encoded);
|
||||
assert!(signature.is_ok());
|
||||
if let std::result::Result::Ok(signature) = signature {
|
||||
assert_eq!(signature.as_bytes(), &[0_u8; 64]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_004_embedded_signature_rejects_invalid_base64_noncanonical_short_vec_and_truncation() {
|
||||
for encoded in [
|
||||
"not-base64",
|
||||
"gQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
|
||||
"AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
|
||||
"AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
||||
] {
|
||||
let result = crate::extract_raw_transaction_signature_from_binary_base64(encoded);
|
||||
assert!(result.is_err());
|
||||
if let std::result::Result::Err(error) = result {
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_RAW_TRANSACTION_SIGNATURE_INVALID);
|
||||
assert!(!error.to_string().contains(encoded));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_006_embedded_signature_extracts_terminal_v1_signature_and_rejects_trailing_bytes() {
|
||||
let encoded = "gQEAAR8AAABRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUQECQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQgcAAAAAAAAACAAAAAkAAAAAgAAAAQEDAAABAgOqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq";
|
||||
let signature = crate::extract_raw_transaction_signature_from_binary_base64(encoded);
|
||||
assert!(signature.is_ok());
|
||||
if let std::result::Result::Ok(signature) = signature {
|
||||
assert_eq!(signature.as_bytes(), &[0xaa_u8; 64]);
|
||||
}
|
||||
let decoded = base64::engine::general_purpose::STANDARD.decode(encoded);
|
||||
assert!(decoded.is_ok());
|
||||
if let std::result::Result::Ok(mut decoded) = decoded {
|
||||
decoded.push(0);
|
||||
let trailing = base64::engine::general_purpose::STANDARD.encode(decoded);
|
||||
assert!(crate::extract_raw_transaction_signature_from_binary_base64(trailing.as_str()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_004_base58_formatter_round_trips_canonical_signatures() {
|
||||
for bytes in [[0_u8; 64], [1_u8; 64], [0xAB_u8; 64]] {
|
||||
let signature = ksp_store_api::RawTransactionSignature::new(bytes);
|
||||
let text = crate::format_raw_transaction_signature(&signature);
|
||||
assert!((crate::MIN_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES..=crate::MAX_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES).contains(&text.len()));
|
||||
let parsed = crate::parse_raw_transaction_signature(text.as_str());
|
||||
assert!(parsed.is_ok());
|
||||
if let std::result::Result::Ok(parsed) = parsed {
|
||||
assert_eq!(parsed, signature);
|
||||
}
|
||||
}
|
||||
assert_eq!(crate::format_raw_transaction_signature(&ksp_store_api::RawTransactionSignature::new([0_u8; 64])), "1".repeat(64));
|
||||
return;
|
||||
}
|
||||
122
crates/ksp-raw-transaction-lib/unit_tests/wire.rs
Normal file
122
crates/ksp-raw-transaction-lib/unit_tests/wire.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
// file: crates/ksp-raw-transaction-lib/unit_tests/wire.rs
|
||||
// version: 2
|
||||
|
||||
const LEGACY_GOLDEN_BASE64: &str = "AREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREBAAECISEhISEhISEhISEhISEhISEhISEhISEhISEhISEhISEiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIjExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExAQEBAAKquw==";
|
||||
const V0_GOLDEN_BASE64: &str = "ARISEhISEhISEhISEhISEhISEhISEhISEhISEhISEhISEhISEhISEhISEhISEhISEhISEhISEhISEhISEhISEhKAAQAAASMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIBAAEBAcwBJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQBAAA=";
|
||||
const V1_EMPTY_CONFIG_GOLDEN_BASE64: &str = "gQEAAAAAAABSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUgABQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0Orq6urq6urq6urq6urq6urq6urq6urq6urq6urq6urq6urq6urq6urq6urq6urq6urq6urq6urq6urq6urq6ur";
|
||||
const V1_GOLDEN_BASE64: &str = "gQEAAR8AAABRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUQECQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQgcAAAAAAAAACAAAAAkAAAAAgAAAAQEDAAABAgOqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq";
|
||||
|
||||
fn legacy_wire() -> crate::RawSolanaTransactionWire {
|
||||
let message = crate::RawSolanaTransactionMessage::new(
|
||||
crate::RawSolanaMessageVersion::Legacy,
|
||||
crate::RawSolanaMessageHeader::new(1, 0, 1),
|
||||
vec![[0x21_u8; 32], [0x22_u8; 32]],
|
||||
[0x31_u8; 32],
|
||||
vec![crate::RawSolanaCompiledInstruction::new(1, vec![0], vec![0xaa, 0xbb])],
|
||||
vec![],
|
||||
std::option::Option::None,
|
||||
);
|
||||
return crate::RawSolanaTransactionWire::new(vec![[0x11_u8; 64]], message);
|
||||
}
|
||||
|
||||
fn v0_wire() -> crate::RawSolanaTransactionWire {
|
||||
let message = crate::RawSolanaTransactionMessage::new(
|
||||
crate::RawSolanaMessageVersion::V0,
|
||||
crate::RawSolanaMessageHeader::new(1, 0, 0),
|
||||
vec![[0x23_u8; 32]],
|
||||
[0x32_u8; 32],
|
||||
vec![crate::RawSolanaCompiledInstruction::new(0, vec![1], vec![0xcc])],
|
||||
vec![crate::RawSolanaAddressTableLookup::new([0x24_u8; 32], vec![0], vec![])],
|
||||
std::option::Option::None,
|
||||
);
|
||||
return crate::RawSolanaTransactionWire::new(vec![[0x12_u8; 64]], message);
|
||||
}
|
||||
|
||||
fn v1_wire() -> crate::RawSolanaTransactionWire {
|
||||
let message = crate::RawSolanaTransactionMessage::new(
|
||||
crate::RawSolanaMessageVersion::V1,
|
||||
crate::RawSolanaMessageHeader::new(1, 0, 1),
|
||||
vec![[0x41_u8; 32], [0x42_u8; 32]],
|
||||
[0x51_u8; 32],
|
||||
vec![crate::RawSolanaCompiledInstruction::new(1, vec![0], vec![1, 2, 3])],
|
||||
vec![],
|
||||
std::option::Option::Some(crate::RawSolanaTransactionConfig::new(
|
||||
std::option::Option::Some(7),
|
||||
std::option::Option::Some(8),
|
||||
std::option::Option::Some(9),
|
||||
std::option::Option::Some(32_768),
|
||||
)),
|
||||
);
|
||||
return crate::RawSolanaTransactionWire::new(vec![[0xaa_u8; 64]], message);
|
||||
}
|
||||
|
||||
fn v1_empty_config_wire() -> crate::RawSolanaTransactionWire {
|
||||
let message = crate::RawSolanaTransactionMessage::new(
|
||||
crate::RawSolanaMessageVersion::V1,
|
||||
crate::RawSolanaMessageHeader::new(1, 0, 0),
|
||||
vec![[0x43_u8; 32]],
|
||||
[0x52_u8; 32],
|
||||
vec![],
|
||||
vec![],
|
||||
std::option::Option::Some(crate::RawSolanaTransactionConfig::default()),
|
||||
);
|
||||
return crate::RawSolanaTransactionWire::new(vec![[0xab_u8; 64]], message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_006_legacy_v0_and_v1_wire_goldens_are_exact() {
|
||||
let legacy = crate::serialize_solana_transaction_wire_base64(&legacy_wire());
|
||||
let v0 = crate::serialize_solana_transaction_wire_base64(&v0_wire());
|
||||
let v1 = crate::serialize_solana_transaction_wire_base64(&v1_wire());
|
||||
let v1_empty = crate::serialize_solana_transaction_wire_base64(&v1_empty_config_wire());
|
||||
assert!(matches!(legacy.as_deref(), std::result::Result::Ok(value) if value == LEGACY_GOLDEN_BASE64));
|
||||
assert!(matches!(v0.as_deref(), std::result::Result::Ok(value) if value == V0_GOLDEN_BASE64));
|
||||
assert!(matches!(v1.as_deref(), std::result::Result::Ok(value) if value == V1_GOLDEN_BASE64));
|
||||
assert!(matches!(v1_empty.as_deref(), std::result::Result::Ok(value) if value == V1_EMPTY_CONFIG_GOLDEN_BASE64));
|
||||
let v1_bytes = crate::serialize_solana_transaction_wire(&v1_wire());
|
||||
assert!(v1_bytes.is_ok());
|
||||
if let std::result::Result::Ok(v1_bytes) = v1_bytes {
|
||||
assert_eq!(v1_bytes.len(), 198);
|
||||
assert_eq!(v1_bytes.first(), std::option::Option::Some(&0x81));
|
||||
assert_eq!(v1_bytes.get(v1_bytes.len() - 64..), std::option::Option::Some([0xaa_u8; 64].as_slice()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_006_v1_structural_guards_reject_alt_duplicates_and_missing_config() {
|
||||
let mut missing_config = v1_wire();
|
||||
missing_config.message.config = std::option::Option::None;
|
||||
assert!(crate::serialize_solana_transaction_wire(&missing_config).is_err());
|
||||
let mut with_lookup = v1_wire();
|
||||
with_lookup.message.address_table_lookups.push(crate::RawSolanaAddressTableLookup::new([9_u8; 32], vec![], vec![]));
|
||||
assert!(crate::serialize_solana_transaction_wire(&with_lookup).is_err());
|
||||
let mut duplicate = v1_wire();
|
||||
duplicate.message.account_keys[1] = duplicate.message.account_keys[0];
|
||||
assert!(crate::serialize_solana_transaction_wire(&duplicate).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_006_v1_program_index_zero_is_valid_and_bounds_remain_enforced() {
|
||||
let message = crate::RawSolanaTransactionMessage::new(
|
||||
crate::RawSolanaMessageVersion::V1,
|
||||
crate::RawSolanaMessageHeader::new(1, 0, 0),
|
||||
vec![[1_u8; 32]],
|
||||
[2_u8; 32],
|
||||
vec![crate::RawSolanaCompiledInstruction::new(0, vec![0], vec![])],
|
||||
vec![],
|
||||
std::option::Option::Some(crate::RawSolanaTransactionConfig::default()),
|
||||
);
|
||||
let wire = crate::RawSolanaTransactionWire::new(vec![[3_u8; 64]], message);
|
||||
assert!(crate::serialize_solana_transaction_wire(&wire).is_ok());
|
||||
let too_many_addresses = crate::RawSolanaTransactionMessage::new(
|
||||
crate::RawSolanaMessageVersion::V1,
|
||||
crate::RawSolanaMessageHeader::new(1, 0, 0),
|
||||
(0_u8..65_u8).map(|value| return [value; 32]).collect(),
|
||||
[2_u8; 32],
|
||||
vec![],
|
||||
vec![],
|
||||
std::option::Option::Some(crate::RawSolanaTransactionConfig::default()),
|
||||
);
|
||||
let wire = crate::RawSolanaTransactionWire::new(vec![[3_u8; 64]], too_many_addresses);
|
||||
assert!(crate::serialize_solana_transaction_wire(&wire).is_err());
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-store-api/tests/public_api.rs
|
||||
// version: 8
|
||||
// version: 9
|
||||
|
||||
//! Integration canaries for the public `ksp-store-api` surface.
|
||||
|
||||
@@ -30,7 +30,7 @@ fn public_pre_002_core_facade_remains_available_from_crate_root() {
|
||||
|
||||
#[test]
|
||||
fn public_pre_003_raw_transaction_and_observation_are_constructible_from_crate_root() {
|
||||
let network = match ksp_store_api::RawNetworkId::new("mainnet-beta".to_owned()) {
|
||||
let network = match ksp_store_api::RawNetworkId::new("mainnet".to_owned()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
@@ -65,7 +65,7 @@ fn public_pre_003_raw_transaction_and_observation_are_constructible_from_crate_r
|
||||
let observation = ksp_store_api::RawTransactionObservation::new(ksp_store_api::RawObservationKey::new([3_u8; 32]), reference, provenance);
|
||||
assert_eq!(transaction.slot(), 123);
|
||||
assert_eq!(transaction.payload().bytes(), &[1_u8, 2_u8, 3_u8]);
|
||||
assert_eq!(observation.transaction().network().as_str(), "mainnet-beta");
|
||||
assert_eq!(observation.transaction().network().as_str(), "mainnet");
|
||||
assert_eq!(observation.provenance().acquisition_method().as_str(), "getTransaction");
|
||||
assert_eq!(ksp_store_api::ERROR_CODE_RAW_PAYLOAD_INVALID.domain(), "store_api");
|
||||
return;
|
||||
@@ -83,7 +83,7 @@ fn public_pre_003_surface_keeps_backend_and_structural_types_out() {
|
||||
|
||||
#[test]
|
||||
fn public_pre_004_raw_account_state_and_observation_are_constructible_from_crate_root() {
|
||||
let network = match ksp_store_api::RawNetworkId::new("mainnet-beta".to_owned()) {
|
||||
let network = match ksp_store_api::RawNetworkId::new("mainnet".to_owned()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
@@ -159,7 +159,7 @@ fn public_pre_006_query_outcome_and_retention_contracts_are_available_from_crate
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let network = match ksp_store_api::RawNetworkId::new("mainnet-beta".to_owned()) {
|
||||
let network = match ksp_store_api::RawNetworkId::new("mainnet".to_owned()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-store-api/tests/security_hardening.rs
|
||||
// version: 2
|
||||
// version: 3
|
||||
|
||||
//! Adversarial and retention-race canaries for the Store API RAW foundation.
|
||||
|
||||
@@ -62,7 +62,7 @@ fn pre_007_raw_debug_surfaces_do_not_render_payload_hash_signature_or_account_by
|
||||
};
|
||||
assert!(!std::format!("{payload:?}").contains(HOSTILE_MARKER));
|
||||
assert_eq!(std::format!("{:?}", payload.content_hash()), "RawContentHash(..)");
|
||||
let network = match ksp_store_api::RawNetworkId::new("mainnet-beta".to_owned()) {
|
||||
let network = match ksp_store_api::RawNetworkId::new("mainnet".to_owned()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
@@ -125,7 +125,7 @@ fn pre_007_retention_outcome_distinguishes_lost_compare_and_transition_race() {
|
||||
|
||||
#[test]
|
||||
fn v0_3_8_pre_003_inspection_summaries_and_counts_reject_payload_shaped_or_inconsistent_state() {
|
||||
let network = match ksp_store_api::RawNetworkId::new("mainnet-beta".to_owned()) {
|
||||
let network = match ksp_store_api::RawNetworkId::new("mainnet".to_owned()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// file: crates/ksp-store-api/unit_tests/model/raw_account.rs
|
||||
// version: 1
|
||||
// version: 2
|
||||
|
||||
fn network() -> std::option::Option<crate::RawNetworkId> {
|
||||
return match crate::RawNetworkId::new("mainnet-beta".to_owned()) {
|
||||
return match crate::RawNetworkId::new("mainnet".to_owned()) {
|
||||
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
||||
std::result::Result::Err(_) => std::option::Option::None,
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// file: crates/ksp-store-api/unit_tests/model/raw_inspection.rs
|
||||
// version: 2
|
||||
// version: 3
|
||||
|
||||
//! Unit tests for backend-neutral RAW inspection contracts.
|
||||
|
||||
fn network() -> std::option::Option<crate::RawNetworkId> {
|
||||
return match crate::RawNetworkId::new("mainnet-beta".to_owned()) {
|
||||
return match crate::RawNetworkId::new("mainnet".to_owned()) {
|
||||
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
||||
std::result::Result::Err(_) => std::option::Option::None,
|
||||
};
|
||||
@@ -61,7 +61,7 @@ fn inspection_queries_keep_backend_neutral_filters_and_random_access_page() {
|
||||
};
|
||||
let transaction =
|
||||
crate::RawTransactionInspectionQuery::new(network.clone(), slots, crate::RawSortDirection::Descending, crate::RawInspectionPageRequest::new(50, limit));
|
||||
assert_eq!(transaction.network().as_str(), "mainnet-beta");
|
||||
assert_eq!(transaction.network().as_str(), "mainnet");
|
||||
assert_eq!(transaction.page().offset(), 50);
|
||||
assert_eq!(transaction.slots().end_inclusive(), std::option::Option::Some(20));
|
||||
assert_eq!(transaction.direction(), crate::RawSortDirection::Descending);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-store-api/unit_tests/model/raw_pagination.rs
|
||||
// version: 1
|
||||
// version: 2
|
||||
|
||||
#[test]
|
||||
fn page_limit_rejects_only_zero_and_keeps_no_ksp_policy_maximum() {
|
||||
@@ -32,7 +32,7 @@ fn cursor_is_opaque_bounded_and_debug_does_not_render_bytes() {
|
||||
|
||||
#[test]
|
||||
fn transaction_query_preserves_caller_limit_range_direction_and_network() {
|
||||
let network = match crate::RawNetworkId::new("mainnet-beta".to_owned()) {
|
||||
let network = match crate::RawNetworkId::new("mainnet".to_owned()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
@@ -45,7 +45,7 @@ fn transaction_query_preserves_caller_limit_range_direction_and_network() {
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let query = crate::RawTransactionQuery::new(network, slots, crate::RawSortDirection::Ascending, crate::RawPageRequest::first(limit));
|
||||
assert_eq!(query.network().as_str(), "mainnet-beta");
|
||||
assert_eq!(query.network().as_str(), "mainnet");
|
||||
assert_eq!(query.slots().start_inclusive(), std::option::Option::Some(10));
|
||||
assert_eq!(query.page().limit().get(), 5_000_000);
|
||||
assert_eq!(query.direction(), crate::RawSortDirection::Ascending);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-store-api/unit_tests/model/raw_primitives.rs
|
||||
// version: 1
|
||||
// version: 2
|
||||
|
||||
fn code(value: &str) -> std::option::Option<crate::RawProvenanceCode> {
|
||||
return match crate::RawProvenanceCode::new(value.to_owned()) {
|
||||
@@ -17,7 +17,7 @@ fn timestamp(unix_millis: u64) -> std::option::Option<crate::RawTimestamp> {
|
||||
|
||||
#[test]
|
||||
fn raw_codes_are_bounded_nonempty_and_reject_url_like_or_control_values() {
|
||||
let network = crate::RawNetworkId::new("mainnet-beta".to_owned());
|
||||
let network = crate::RawNetworkId::new("mainnet".to_owned());
|
||||
assert!(network.is_ok());
|
||||
let format = crate::RawFormatId::new("ksp.solana.raw_transaction".to_owned());
|
||||
assert!(format.is_ok());
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// file: crates/ksp-store-api/unit_tests/model/raw_retention.rs
|
||||
// version: 2
|
||||
// version: 3
|
||||
|
||||
fn reference() -> std::option::Option<crate::RawTransactionReference> {
|
||||
let network = match crate::RawNetworkId::new("mainnet-beta".to_owned()) {
|
||||
let network = match crate::RawNetworkId::new("mainnet".to_owned()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// file: crates/ksp-store-api/unit_tests/model/raw_transaction.rs
|
||||
// version: 1
|
||||
// version: 2
|
||||
|
||||
fn network() -> std::option::Option<crate::RawNetworkId> {
|
||||
return match crate::RawNetworkId::new("mainnet-beta".to_owned()) {
|
||||
return match crate::RawNetworkId::new("mainnet".to_owned()) {
|
||||
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
||||
std::result::Result::Err(_) => std::option::Option::None,
|
||||
};
|
||||
@@ -57,7 +57,7 @@ fn raw_transaction_identity_is_network_plus_signature_and_payload_remains_whole(
|
||||
let signature = crate::RawTransactionSignature::new([9_u8; 64]);
|
||||
let reference = crate::RawTransactionReference::new(network, signature);
|
||||
let transaction = crate::RawTransaction::new(reference, 42, std::option::Option::None, payload);
|
||||
assert_eq!(transaction.reference().network().as_str(), "mainnet-beta");
|
||||
assert_eq!(transaction.reference().network().as_str(), "mainnet");
|
||||
assert_eq!(transaction.reference().signature(), signature);
|
||||
assert_eq!(transaction.slot(), 42);
|
||||
assert!(transaction.block_time().is_none());
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-store-postgres-lib/unit_tests/raw_account.rs
|
||||
// version: 7
|
||||
// version: 8
|
||||
|
||||
fn network() -> ksp_store_api::RawNetworkId {
|
||||
return match ksp_store_api::RawNetworkId::new("devnet") {
|
||||
@@ -311,7 +311,7 @@ fn pre_004_account_observation_rejects_hostile_rows_without_echoing_values() {
|
||||
#[test]
|
||||
fn pre_004_account_wrong_network_guard_is_pre_io_and_static() {
|
||||
let backend = network();
|
||||
let foreign = match ksp_store_api::RawNetworkId::new("mainnet-beta") {
|
||||
let foreign = match ksp_store_api::RawNetworkId::new("mainnet") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("valid foreign network rejected: {error:?}"),
|
||||
};
|
||||
@@ -343,7 +343,7 @@ fn pre_005_account_acquisition_input_guard_requires_network_and_exact_reference(
|
||||
std::result::Result::Err(error) => panic!("valid account observation rejected: {error:?}"),
|
||||
};
|
||||
assert!(super::ensure_acquisition_inputs(&backend, &valid_state, &valid_observation).is_ok());
|
||||
let foreign = match ksp_store_api::RawNetworkId::new("mainnet-beta") {
|
||||
let foreign = match ksp_store_api::RawNetworkId::new("mainnet") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("valid foreign network rejected: {error:?}"),
|
||||
};
|
||||
@@ -437,7 +437,7 @@ fn pre_005_account_origin_encoding_is_exact_and_static() {
|
||||
#[test]
|
||||
fn pre_006_additional_observation_input_guard_rejects_wrong_network_before_io() {
|
||||
let backend = network();
|
||||
let foreign = match ksp_store_api::RawNetworkId::new("mainnet-beta") {
|
||||
let foreign = match ksp_store_api::RawNetworkId::new("mainnet") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("valid foreign network rejected: {error:?}"),
|
||||
};
|
||||
@@ -532,7 +532,7 @@ fn pre_007_account_cursor_rejects_replay_across_query_context_and_transaction_fa
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("valid account cursor encoding rejected: {error:?}"),
|
||||
};
|
||||
let other_network = page_query("mainnet-beta", Some(pubkey), Some(10), Some(20), ksp_store_api::RawSortDirection::Ascending);
|
||||
let other_network = page_query("mainnet", Some(pubkey), Some(10), Some(20), ksp_store_api::RawSortDirection::Ascending);
|
||||
let other_pubkey =
|
||||
page_query("devnet", Some(ksp_store_api::Pubkey::new_from_array([6_u8; 32])), Some(10), Some(20), ksp_store_api::RawSortDirection::Ascending);
|
||||
let other_direction = page_query("devnet", Some(pubkey), Some(10), Some(20), ksp_store_api::RawSortDirection::Descending);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-store-postgres-lib/unit_tests/raw_transaction.rs
|
||||
// version: 5
|
||||
// version: 6
|
||||
|
||||
fn network() -> ksp_store_api::RawNetworkId {
|
||||
return match ksp_store_api::RawNetworkId::new("devnet") {
|
||||
@@ -271,7 +271,7 @@ fn v0_3_8_pre_004_inspection_summary_rejects_physical_retention_shape_residue()
|
||||
#[test]
|
||||
fn pre_004_wrong_network_is_rejected_by_the_private_pre_io_guard() {
|
||||
let backend_network = network();
|
||||
let other_network = match ksp_store_api::RawNetworkId::new("mainnet-beta") {
|
||||
let other_network = match ksp_store_api::RawNetworkId::new("mainnet") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("valid alternate network rejected: {error:?}"),
|
||||
};
|
||||
@@ -330,7 +330,7 @@ fn pre_005_atomic_acquisition_pre_io_guard_requires_backend_network_and_exact_re
|
||||
let mismatched = observation(other_reference, 4, "publicnode");
|
||||
let mismatch = super::ensure_acquisition_inputs(&backend_network, &raw_transaction, &mismatched);
|
||||
assert_eq!(mismatch.err().map(|value| return value.kind()), std::option::Option::Some(crate::PostgresBackendErrorKind::Conflict));
|
||||
let other_network = match ksp_store_api::RawNetworkId::new("mainnet-beta") {
|
||||
let other_network = match ksp_store_api::RawNetworkId::new("mainnet") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("valid alternate network rejected: {error:?}"),
|
||||
};
|
||||
@@ -508,7 +508,7 @@ fn pre_006_cursor_v1_rejects_replay_across_network_direction_and_range() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("valid cursor encoding rejected: {error:?}"),
|
||||
};
|
||||
let other_network = page_query("mainnet-beta", Some(10), Some(20), ksp_store_api::RawSortDirection::Ascending, None);
|
||||
let other_network = page_query("mainnet", Some(10), Some(20), ksp_store_api::RawSortDirection::Ascending, None);
|
||||
let other_direction = page_query("devnet", Some(10), Some(20), ksp_store_api::RawSortDirection::Descending, None);
|
||||
let other_range = page_query("devnet", Some(11), Some(20), ksp_store_api::RawSortDirection::Ascending, None);
|
||||
for candidate in [&other_network, &other_direction, &other_range] {
|
||||
@@ -640,7 +640,7 @@ fn pre_007_compacted_transitions_are_rejected_by_the_pre_io_guard() {
|
||||
#[test]
|
||||
fn pre_007_retention_transition_pre_io_guard_rejects_wrong_network() {
|
||||
let backend_network = network();
|
||||
let other_network = match ksp_store_api::RawNetworkId::new("mainnet-beta") {
|
||||
let other_network = match ksp_store_api::RawNetworkId::new("mainnet") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("valid alternate network rejected: {error:?}"),
|
||||
};
|
||||
|
||||
14
crates/ksp-worker-api/Cargo.toml
Normal file
14
crates/ksp-worker-api/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
# file: crates/ksp-worker-api/Cargo.toml
|
||||
# version: 1
|
||||
|
||||
[package]
|
||||
name = "ksp-worker-api"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
69
crates/ksp-worker-api/README.md
Normal file
69
crates/ksp-worker-api/README.md
Normal file
@@ -0,0 +1,69 @@
|
||||
<!-- file: crates/ksp-worker-api/README.md -->
|
||||
<!-- version: 2 -->
|
||||
|
||||
# ksp-worker-api
|
||||
|
||||
`ksp-worker-api` fournit les contrats passifs et runtime-neutral communs aux services continus KSP. Elle décrit un Worker observable ; elle n'est ni un runtime de Worker ni une API métier d'acquisition.
|
||||
|
||||
La crate possède l'identité logique d'un Worker, son lifecycle continu, une classification minimale de health/activity, l'intention de stop coopératif et un contrat latest-value fixe pour l'observation. Elle ne possède aucun runtime concret, aucune politique de restart, aucun Job, aucun Transport, aucun Store et aucun contrat Solana.
|
||||
|
||||
## Responsabilités
|
||||
|
||||
La façade crate-root expose :
|
||||
|
||||
- `WorkerId` et `WorkerKindCode`, bornés et validés ;
|
||||
- `WorkerState`, `WorkerHealth` et `WorkerActivity` ;
|
||||
- `WorkerLifecycle`, propriétaire des transitions admises ;
|
||||
- `WorkerStopToken`, cloneable et idempotent ;
|
||||
- `WorkerSnapshotSequence`, strictement monotone et sans wrap silencieux ;
|
||||
- `WorkerSnapshot`, forme commune fixe sans payload métier ;
|
||||
- `WorkerSnapshotSource`, contrat object-safe de lecture courante et attente d'une valeur plus récente ;
|
||||
- les codes d'erreur Worker et les types `Error`/`Result` communs de Core.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
Le lifecycle admis reste explicitement borné :
|
||||
|
||||
```text
|
||||
Created -> Starting | Stopped
|
||||
Starting -> Running | Stopping | Faulted(ErrorCode)
|
||||
Running -> Stopping | Faulted(ErrorCode)
|
||||
Stopping -> Stopped | Faulted(ErrorCode)
|
||||
Stopped -> terminal
|
||||
Faulted -> terminal
|
||||
```
|
||||
|
||||
`Stopped` et `Faulted` sont terminaux et immuables. Une transition invalide retourne `ERROR_CODE_WORKER_TRANSITION_INVALID` sans modifier l'état source.
|
||||
|
||||
## Observation latest-value
|
||||
|
||||
`WorkerSnapshotSource` n'impose ni callback, ni queue d'événements, ni runtime async particulier. Un listener lit d'abord `current()`, mémorise la `WorkerSnapshotSequence`, puis appelle `wait_for_change()` s'il doit attendre une valeur plus récente.
|
||||
|
||||
Les mises à jour intermédiaires peuvent être coalescées : le contrat porte sur la dernière valeur complète, pas sur la livraison de chaque événement. Le snapshot commun ne contient que l'identité, la séquence, le lifecycle, la health et l'activity.
|
||||
|
||||
## Stop
|
||||
|
||||
`WorkerStopToken` représente uniquement une intention coopérative partagée. Il ne tue pas une tâche, ne ferme pas un socket et ne décide pas du résultat terminal. Le runtime concret observe cette intention puis pilote `WorkerLifecycle` selon sa politique de shutdown.
|
||||
|
||||
## Contrôle runtime et restart
|
||||
|
||||
La crate ne possède aucune opération runtime générique `start()`, `stop()`, `restart()`, aucun scheduler, retry/backoff, process manager ou handle d'exécution. `WorkerLifecycle` expose uniquement les transitions d'état détenues par le producer concret ; `WorkerStopToken` exprime uniquement une intention coopérative.
|
||||
|
||||
Un lifecycle/source terminal n'est jamais réanimé ni rebinding vers une nouvelle exécution. Démarrage, arrêt effectif, drain, join, recréation et supervision appartiennent au Worker concret, au caller ou à une couche de contrôle supérieure.
|
||||
|
||||
## Firewall
|
||||
|
||||
La dépendance normale est volontairement minimale :
|
||||
|
||||
```text
|
||||
ksp-worker-api
|
||||
-> ksp-core-lib
|
||||
```
|
||||
|
||||
La crate ne dépend pas de `ksp-job-api`, Tokio, Futures, serde, Logging, Config, Interface, Transport, Store, Tauri ou d'un SDK provider.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [`USAGE.md`](USAGE.md) — utilisation durable des contrats Worker ;
|
||||
- [`../../docs/architecture/003-COMPONENT_CONTRACTS.md`](../../docs/architecture/003-COMPONENT_CONTRACTS.md) — contrats de composants ;
|
||||
- [`../../docs/architecture/009-ACQUISITION_WORKERS_AND_JOBS.md`](../../docs/architecture/009-ACQUISITION_WORKERS_AND_JOBS.md) — séparation Worker/Job et ownership d'acquisition.
|
||||
149
crates/ksp-worker-api/USAGE.md
Normal file
149
crates/ksp-worker-api/USAGE.md
Normal file
@@ -0,0 +1,149 @@
|
||||
<!-- file: crates/ksp-worker-api/USAGE.md -->
|
||||
<!-- version: 2 -->
|
||||
|
||||
# Utilisation de ksp-worker-api
|
||||
|
||||
Cette page décrit la façade publique durable de `ksp-worker-api`. Les consumers utilisent uniquement les exports du crate-root.
|
||||
|
||||
## Construire une identité Worker
|
||||
|
||||
```rust
|
||||
fn worker_identity() -> ksp_worker_api::Result<(ksp_worker_api::WorkerId, ksp_worker_api::WorkerKindCode)> {
|
||||
let id = match ksp_worker_api::WorkerId::new("raw-ingest-mainnet-0001") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let kind = match ksp_worker_api::WorkerKindCode::new("raw_transaction_ingest") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok((id, kind));
|
||||
}
|
||||
```
|
||||
|
||||
`WorkerId` identifie une instance logique observée par un lifecycle/source donné. `WorkerKindCode` identifie une famille de Workers. Les deux sont bornés et utilisent un alphabet sûr. Le `Debug` de `WorkerId` masque sa valeur.
|
||||
|
||||
## Piloter un lifecycle passif
|
||||
|
||||
Le lifecycle ne démarre aucun runtime. L'exemple suivant représente uniquement les transitions publiées par un producer concret lorsqu'il entre en exécution :
|
||||
|
||||
```rust
|
||||
fn running_lifecycle(id: ksp_worker_api::WorkerId, kind: ksp_worker_api::WorkerKindCode) -> ksp_worker_api::Result<ksp_worker_api::WorkerLifecycle> {
|
||||
let mut lifecycle = ksp_worker_api::WorkerLifecycle::new(id, kind);
|
||||
if let std::result::Result::Err(error) = lifecycle.start() {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) = lifecycle.mark_running() {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Ok(lifecycle);
|
||||
}
|
||||
```
|
||||
|
||||
Le producer possède l'autorité de transition. Il ne force jamais un état directement. Une transition invalide retourne une erreur stable et conserve l'état courant.
|
||||
|
||||
Pour un shutdown coopératif après observation du token :
|
||||
|
||||
```rust
|
||||
if let std::result::Result::Err(error) = lifecycle.mark_stopping() {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) = lifecycle.mark_stopped() {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
```
|
||||
|
||||
Pour un fault terminal :
|
||||
|
||||
```rust
|
||||
const IO_FAULT: ksp_worker_api::ErrorCode = ksp_worker_api::ErrorCode::new("example_worker", "io_fault");
|
||||
if let std::result::Result::Err(error) = lifecycle.fault(IO_FAULT) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
```
|
||||
|
||||
`Stopped` et `Faulted(ErrorCode)` sont terminaux. Un ancien lifecycle terminal ne représente jamais une nouvelle exécution.
|
||||
|
||||
## Distinguer lifecycle, health et activity
|
||||
|
||||
`WorkerState` décrit la phase du service. `WorkerHealth` décrit sa qualité opérationnelle. `WorkerActivity` indique seulement `Unknown`, `Idle` ou `Active`.
|
||||
|
||||
```rust
|
||||
let snapshot = ksp_worker_api::WorkerSnapshot::new(
|
||||
lifecycle.id().clone(),
|
||||
lifecycle.kind().clone(),
|
||||
ksp_worker_api::WorkerSnapshotSequence::initial(),
|
||||
lifecycle.state(),
|
||||
ksp_worker_api::WorkerHealth::Healthy,
|
||||
ksp_worker_api::WorkerActivity::Idle,
|
||||
);
|
||||
```
|
||||
|
||||
Le snapshot commun ne porte ni pourcentage, ni total, ni backlog, ni slot, ni transaction, ni métrique métier. Une API Worker concrète peut exposer séparément ses propres métriques.
|
||||
|
||||
## Partager une intention de stop
|
||||
|
||||
```rust
|
||||
let token = ksp_worker_api::WorkerStopToken::new();
|
||||
let listener = token.clone();
|
||||
|
||||
assert!(!listener.is_stop_requested());
|
||||
assert!(token.request_stop());
|
||||
assert!(listener.is_stop_requested());
|
||||
assert!(!token.request_stop());
|
||||
```
|
||||
|
||||
Le premier appel qui change l'intention retourne `true`. Les demandes suivantes sont idempotentes et retournent `false`.
|
||||
|
||||
Le token n'est pas une primitive de kill et ne garantit aucun délai de shutdown. Timeout, drain, join et retry appartiennent au runtime/caller.
|
||||
|
||||
## Démarrer et arrêter un Worker concret
|
||||
|
||||
`ksp-worker-api` n'expose volontairement aucune commande runtime universelle. Une crate concrète peut fournir une surface `start`/`stop` adaptée à son domaine, mais elle utilise les contrats communs pour publier son identité, ses transitions, son état courant et l'intention de stop.
|
||||
|
||||
Un Worker concret ne doit donc pas transformer `WorkerLifecycle` en handle d'exécution ni ajouter des paramètres métier au contrat générique. Les paramètres/configurations propres à une famille de Workers restent dans cette famille ou dans sa couche de composition.
|
||||
|
||||
## Observer un snapshot latest-value
|
||||
|
||||
Un consumer portable peut travailler directement avec le trait object-safe :
|
||||
|
||||
```rust
|
||||
async fn observe(source: &dyn ksp_worker_api::WorkerSnapshotSource) {
|
||||
let current = source.current();
|
||||
let observed = current.sequence();
|
||||
let newer = source.wait_for_change(observed).await;
|
||||
assert!(newer.sequence().is_after(observed));
|
||||
}
|
||||
```
|
||||
|
||||
`wait_for_change` retourne la dernière valeur complète disponible après coalescence éventuelle. Un consumer ne doit pas supposer qu'il recevra chaque mise à jour intermédiaire.
|
||||
|
||||
Un listener tardif commence par `current()`. Tant que la source existe, son snapshot terminal courant reste lisible.
|
||||
|
||||
## Faire avancer une séquence
|
||||
|
||||
```rust
|
||||
let first = ksp_worker_api::WorkerSnapshotSequence::initial();
|
||||
let second = match first.next() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
assert!(second.is_after(first));
|
||||
```
|
||||
|
||||
L'épuisement de `u64` est une erreur explicite ; la séquence ne wrappe jamais silencieusement.
|
||||
|
||||
## Frontières à respecter
|
||||
|
||||
Ne pas ajouter à `ksp-worker-api` :
|
||||
|
||||
```text
|
||||
runtime Tokio/Futures concret
|
||||
Job lifecycle ou checkpoint/backfill
|
||||
Transport, Store, Config ou Logging
|
||||
DTO Solana/provider
|
||||
restart/retry/scheduler/process manager
|
||||
payload métier dans WorkerSnapshot
|
||||
```
|
||||
|
||||
Ces responsabilités appartiennent aux Workers concrets et aux couches de composition/contrôle supérieures.
|
||||
12
crates/ksp-worker-api/src/error.rs
Normal file
12
crates/ksp-worker-api/src/error.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
// file: crates/ksp-worker-api/src/error.rs
|
||||
// version: 1
|
||||
|
||||
/// Error code used when a Worker identifier violates its bounded safe-code contract.
|
||||
pub const ERROR_CODE_WORKER_ID_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("worker_api", "worker_id_invalid");
|
||||
/// Error code used when a Worker kind code violates its bounded safe-code contract.
|
||||
pub const ERROR_CODE_WORKER_KIND_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("worker_api", "worker_kind_invalid");
|
||||
/// Error code used when a Worker snapshot sequence cannot advance without wrapping.
|
||||
pub const ERROR_CODE_WORKER_SNAPSHOT_SEQUENCE_EXHAUSTED: ksp_core_lib::ErrorCode =
|
||||
ksp_core_lib::ErrorCode::new("worker_api", "worker_snapshot_sequence_exhausted");
|
||||
/// Error code used when a requested Worker lifecycle transition is not allowed.
|
||||
pub const ERROR_CODE_WORKER_TRANSITION_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("worker_api", "worker_transition_invalid");
|
||||
76
crates/ksp-worker-api/src/identity.rs
Normal file
76
crates/ksp-worker-api/src/identity.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
// file: crates/ksp-worker-api/src/identity.rs
|
||||
// version: 2
|
||||
|
||||
/// Maximum UTF-8 byte length admitted for one Worker identifier.
|
||||
pub const MAX_WORKER_ID_BYTES: usize = 128;
|
||||
/// Maximum UTF-8 byte length admitted for one Worker kind code.
|
||||
pub const MAX_WORKER_KIND_CODE_BYTES: usize = 128;
|
||||
|
||||
/// Bounded caller-supplied identity of one logical Worker instance.
|
||||
#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub struct WorkerId(std::string::String);
|
||||
|
||||
impl crate::WorkerId {
|
||||
/// Creates one non-empty Worker identifier using the KSP safe-code alphabet.
|
||||
pub fn new(value: impl std::convert::Into<std::string::String>) -> crate::Result<Self> {
|
||||
let value = value.into();
|
||||
if !valid_worker_code(value.as_str(), crate::MAX_WORKER_ID_BYTES) {
|
||||
return std::result::Result::Err(identity_error(crate::ERROR_CODE_WORKER_ID_INVALID, "worker_id"));
|
||||
}
|
||||
return std::result::Result::Ok(Self(value));
|
||||
}
|
||||
|
||||
/// Returns the validated Worker identifier.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
return self.0.as_str();
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for crate::WorkerId {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter.write_str("WorkerId(..)");
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded stable code identifying one concrete family of Workers.
|
||||
#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub struct WorkerKindCode(std::string::String);
|
||||
|
||||
impl crate::WorkerKindCode {
|
||||
/// Creates one non-empty Worker kind using the KSP safe-code alphabet.
|
||||
pub fn new(value: impl std::convert::Into<std::string::String>) -> crate::Result<Self> {
|
||||
let value = value.into();
|
||||
if !valid_worker_code(value.as_str(), crate::MAX_WORKER_KIND_CODE_BYTES) {
|
||||
return std::result::Result::Err(identity_error(crate::ERROR_CODE_WORKER_KIND_INVALID, "worker_kind"));
|
||||
}
|
||||
return std::result::Result::Ok(Self(value));
|
||||
}
|
||||
|
||||
/// Returns the validated stable Worker kind code.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
return self.0.as_str();
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for crate::WorkerKindCode {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter.debug_tuple("WorkerKindCode").field(&self.0).finish();
|
||||
}
|
||||
}
|
||||
|
||||
fn identity_error(code: crate::ErrorCode, field: &'static str) -> crate::Error {
|
||||
return crate::Error::new(code, "invalid bounded Worker identity").with_context("field", field);
|
||||
}
|
||||
|
||||
fn valid_worker_code(value: &str, maximum_len: usize) -> bool {
|
||||
if value.is_empty() || value.len() > maximum_len {
|
||||
return false;
|
||||
}
|
||||
return value.bytes().all(|byte| return byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b':'));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/identity.rs"]
|
||||
mod tests;
|
||||
62
crates/ksp-worker-api/src/lib.rs
Normal file
62
crates/ksp-worker-api/src/lib.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
// file: crates/ksp-worker-api/src/lib.rs
|
||||
// version: 1
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
//! Passive runtime-neutral lifecycle contracts for continuous KSP Workers.
|
||||
//!
|
||||
//! This foundation owns bounded Worker identity, explicit continuous lifecycle
|
||||
//! transitions, cooperative stop intent and a fixed latest-value observation
|
||||
//! contract. Concrete runtimes, restart policy, Transport, Store, Config, Jobs
|
||||
//! and domain-specific Worker behavior remain outside this crate.
|
||||
|
||||
mod error;
|
||||
mod identity;
|
||||
mod lifecycle;
|
||||
mod snapshot;
|
||||
mod stop;
|
||||
|
||||
/// Error code used when a Worker identifier violates its bounded safe-code contract.
|
||||
pub use self::error::ERROR_CODE_WORKER_ID_INVALID;
|
||||
/// Error code used when a Worker kind code violates its bounded safe-code contract.
|
||||
pub use self::error::ERROR_CODE_WORKER_KIND_INVALID;
|
||||
/// Error code used when a Worker snapshot sequence cannot advance without wrapping.
|
||||
pub use self::error::ERROR_CODE_WORKER_SNAPSHOT_SEQUENCE_EXHAUSTED;
|
||||
/// Error code used when a requested Worker lifecycle transition is not allowed.
|
||||
pub use self::error::ERROR_CODE_WORKER_TRANSITION_INVALID;
|
||||
/// Maximum UTF-8 byte length admitted for one Worker identifier.
|
||||
pub use self::identity::MAX_WORKER_ID_BYTES;
|
||||
/// Maximum UTF-8 byte length admitted for one Worker kind code.
|
||||
pub use self::identity::MAX_WORKER_KIND_CODE_BYTES;
|
||||
/// Bounded caller-supplied identity of one logical Worker instance.
|
||||
pub use self::identity::WorkerId;
|
||||
/// Bounded stable code identifying one concrete family of Workers.
|
||||
pub use self::identity::WorkerKindCode;
|
||||
/// Minimal generic activity classification for a continuous Worker.
|
||||
pub use self::lifecycle::WorkerActivity;
|
||||
/// Operational health classification independent from Worker lifecycle phase.
|
||||
pub use self::lifecycle::WorkerHealth;
|
||||
/// Passive owner of one Worker identity and its validated lifecycle state.
|
||||
pub use self::lifecycle::WorkerLifecycle;
|
||||
/// Current lifecycle state of one continuous Worker.
|
||||
pub use self::lifecycle::WorkerState;
|
||||
/// Fixed common latest-value snapshot exposed by every Worker implementation.
|
||||
pub use self::snapshot::WorkerSnapshot;
|
||||
/// Runtime-neutral future returned while observing a latest-value Worker snapshot source.
|
||||
pub use self::snapshot::WorkerSnapshotFuture;
|
||||
/// Monotone sequence attached to one latest-value Worker snapshot stream.
|
||||
pub use self::snapshot::WorkerSnapshotSequence;
|
||||
/// Runtime-neutral read and change-wait contract for one latest-value Worker snapshot stream.
|
||||
pub use self::snapshot::WorkerSnapshotSource;
|
||||
/// Runtime-neutral cloneable token carrying cooperative Worker stop intent.
|
||||
pub use self::stop::WorkerStopToken;
|
||||
/// Common KSP error type used by Worker-facing contracts.
|
||||
pub use ksp_core_lib::Error;
|
||||
/// Stable structured code identifying a KSP error category and condition.
|
||||
pub use ksp_core_lib::ErrorCode;
|
||||
/// Structured contextual field attached to a KSP error.
|
||||
pub use ksp_core_lib::ErrorContext;
|
||||
/// Common KSP result alias using [`Error`].
|
||||
pub use ksp_core_lib::Result;
|
||||
199
crates/ksp-worker-api/src/lifecycle.rs
Normal file
199
crates/ksp-worker-api/src/lifecycle.rs
Normal file
@@ -0,0 +1,199 @@
|
||||
// file: crates/ksp-worker-api/src/lifecycle.rs
|
||||
// version: 2
|
||||
|
||||
/// Current lifecycle state of one continuous Worker.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub enum WorkerState {
|
||||
/// The Worker exists but has not started initialization.
|
||||
Created,
|
||||
/// The Worker is initializing resources before entering steady service.
|
||||
Starting,
|
||||
/// The Worker is actively providing its continuous service.
|
||||
Running,
|
||||
/// The Worker observed stop intent and is draining or releasing resources.
|
||||
Stopping,
|
||||
/// The Worker reached its normal terminal stopped state.
|
||||
Stopped,
|
||||
/// The Worker reached a terminal fault classified by one stable KSP error code.
|
||||
Faulted(crate::ErrorCode),
|
||||
}
|
||||
|
||||
impl crate::WorkerState {
|
||||
/// Returns the stable safe lifecycle code without rendering Worker data.
|
||||
#[must_use]
|
||||
pub const fn code(&self) -> &'static str {
|
||||
return match self {
|
||||
Self::Created => "created",
|
||||
Self::Starting => "starting",
|
||||
Self::Running => "running",
|
||||
Self::Stopping => "stopping",
|
||||
Self::Stopped => "stopped",
|
||||
Self::Faulted(_) => "faulted",
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the terminal fault code when the state is [`Self::Faulted`].
|
||||
#[must_use]
|
||||
pub const fn fault_code(&self) -> std::option::Option<crate::ErrorCode> {
|
||||
return match self {
|
||||
Self::Faulted(code) => std::option::Option::Some(*code),
|
||||
_ => std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
/// Reports whether no later lifecycle transition is permitted.
|
||||
#[must_use]
|
||||
pub const fn is_terminal(&self) -> bool {
|
||||
return matches!(self, Self::Stopped | Self::Faulted(_));
|
||||
}
|
||||
}
|
||||
|
||||
/// Operational health classification independent from Worker lifecycle phase.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub enum WorkerHealth {
|
||||
/// Health has not yet been established or cannot currently be classified.
|
||||
Unknown,
|
||||
/// The Worker is operating within its expected healthy envelope.
|
||||
Healthy,
|
||||
/// The Worker is operating with a known degradation while service remains available.
|
||||
Degraded,
|
||||
/// The Worker is currently unable to satisfy its expected service health contract.
|
||||
Unhealthy,
|
||||
}
|
||||
|
||||
impl crate::WorkerHealth {
|
||||
/// Returns the stable safe code for this health classification.
|
||||
#[must_use]
|
||||
pub const fn code(&self) -> &'static str {
|
||||
return match self {
|
||||
Self::Unknown => "unknown",
|
||||
Self::Healthy => "healthy",
|
||||
Self::Degraded => "degraded",
|
||||
Self::Unhealthy => "unhealthy",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal generic activity classification for a continuous Worker.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub enum WorkerActivity {
|
||||
/// Activity has not yet been established or cannot currently be classified.
|
||||
Unknown,
|
||||
/// The Worker is alive but not currently processing concrete work.
|
||||
Idle,
|
||||
/// The Worker is currently processing concrete work.
|
||||
Active,
|
||||
}
|
||||
|
||||
impl crate::WorkerActivity {
|
||||
/// Returns the stable safe code for this activity classification.
|
||||
#[must_use]
|
||||
pub const fn code(&self) -> &'static str {
|
||||
return match self {
|
||||
Self::Unknown => "unknown",
|
||||
Self::Idle => "idle",
|
||||
Self::Active => "active",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Passive owner of one Worker identity and its validated lifecycle state.
|
||||
#[derive(Eq, PartialEq)]
|
||||
pub struct WorkerLifecycle {
|
||||
id: crate::WorkerId,
|
||||
kind: crate::WorkerKindCode,
|
||||
state: crate::WorkerState,
|
||||
}
|
||||
|
||||
impl crate::WorkerLifecycle {
|
||||
/// Creates one lifecycle in [`WorkerState::Created`] state.
|
||||
#[must_use]
|
||||
pub const fn new(id: crate::WorkerId, kind: crate::WorkerKindCode) -> Self {
|
||||
return Self { id, kind, state: crate::WorkerState::Created };
|
||||
}
|
||||
|
||||
/// Returns the logical Worker identity.
|
||||
#[must_use]
|
||||
pub const fn id(&self) -> &crate::WorkerId {
|
||||
return &self.id;
|
||||
}
|
||||
|
||||
/// Returns the stable Worker family code.
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> &crate::WorkerKindCode {
|
||||
return &self.kind;
|
||||
}
|
||||
|
||||
/// Returns the current lifecycle state.
|
||||
#[must_use]
|
||||
pub const fn state(&self) -> crate::WorkerState {
|
||||
return self.state;
|
||||
}
|
||||
|
||||
/// Begins initialization of a newly created Worker.
|
||||
pub fn start(&mut self) -> crate::Result<()> {
|
||||
return self.transition(crate::WorkerState::Starting);
|
||||
}
|
||||
|
||||
/// Records that initialization completed and steady service is running.
|
||||
pub fn mark_running(&mut self) -> crate::Result<()> {
|
||||
return self.transition(crate::WorkerState::Running);
|
||||
}
|
||||
|
||||
/// Records that a starting or running Worker observed cooperative stop intent.
|
||||
pub fn mark_stopping(&mut self) -> crate::Result<()> {
|
||||
return self.transition(crate::WorkerState::Stopping);
|
||||
}
|
||||
|
||||
/// Records normal terminal stop after draining, or before initialization began.
|
||||
pub fn mark_stopped(&mut self) -> crate::Result<()> {
|
||||
return self.transition(crate::WorkerState::Stopped);
|
||||
}
|
||||
|
||||
/// Records a terminal fault using only a stable KSP error code.
|
||||
pub fn fault(&mut self, code: crate::ErrorCode) -> crate::Result<()> {
|
||||
return self.transition(crate::WorkerState::Faulted(code));
|
||||
}
|
||||
|
||||
fn transition(&mut self, target: crate::WorkerState) -> crate::Result<()> {
|
||||
if !allowed_transition(self.state, target) {
|
||||
return std::result::Result::Err(transition_error(self.state, target));
|
||||
}
|
||||
self.state = target;
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for crate::WorkerLifecycle {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter.debug_struct("WorkerLifecycle").field("id", &self.id).field("kind", &self.kind).field("state", &self.state).finish();
|
||||
}
|
||||
}
|
||||
|
||||
fn allowed_transition(source: crate::WorkerState, target: crate::WorkerState) -> bool {
|
||||
return matches!(
|
||||
(source, target),
|
||||
(crate::WorkerState::Created, crate::WorkerState::Starting)
|
||||
| (crate::WorkerState::Created, crate::WorkerState::Stopped)
|
||||
| (crate::WorkerState::Starting, crate::WorkerState::Running)
|
||||
| (crate::WorkerState::Starting, crate::WorkerState::Stopping)
|
||||
| (crate::WorkerState::Starting, crate::WorkerState::Faulted(_))
|
||||
| (crate::WorkerState::Running, crate::WorkerState::Stopping)
|
||||
| (crate::WorkerState::Running, crate::WorkerState::Faulted(_))
|
||||
| (crate::WorkerState::Stopping, crate::WorkerState::Stopped)
|
||||
| (crate::WorkerState::Stopping, crate::WorkerState::Faulted(_))
|
||||
);
|
||||
}
|
||||
|
||||
fn transition_error(source: crate::WorkerState, target: crate::WorkerState) -> crate::Error {
|
||||
return crate::Error::new(crate::ERROR_CODE_WORKER_TRANSITION_INVALID, "invalid Worker lifecycle transition")
|
||||
.with_context("source_state", source.code())
|
||||
.with_context("target_state", target.code());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/lifecycle.rs"]
|
||||
mod tests;
|
||||
143
crates/ksp-worker-api/src/snapshot.rs
Normal file
143
crates/ksp-worker-api/src/snapshot.rs
Normal file
@@ -0,0 +1,143 @@
|
||||
// file: crates/ksp-worker-api/src/snapshot.rs
|
||||
// version: 3
|
||||
|
||||
/// Monotone sequence attached to one latest-value Worker snapshot stream.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub struct WorkerSnapshotSequence(u64);
|
||||
|
||||
impl crate::WorkerSnapshotSequence {
|
||||
/// Creates the initial sequence position for one Worker snapshot stream.
|
||||
#[must_use]
|
||||
pub const fn initial() -> Self {
|
||||
return Self(0);
|
||||
}
|
||||
|
||||
/// Returns the opaque numeric position carried by this sequence.
|
||||
#[must_use]
|
||||
pub const fn value(&self) -> u64 {
|
||||
return self.0;
|
||||
}
|
||||
|
||||
/// Advances the sequence exactly once or reports exhaustion without wrapping.
|
||||
pub fn next(&self) -> crate::Result<Self> {
|
||||
let next = match self.0.checked_add(1) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(
|
||||
crate::Error::new(crate::ERROR_CODE_WORKER_SNAPSHOT_SEQUENCE_EXHAUSTED, "Worker snapshot sequence exhausted")
|
||||
.with_context("sequence", self.0.to_string()),
|
||||
);
|
||||
},
|
||||
};
|
||||
return std::result::Result::Ok(Self(next));
|
||||
}
|
||||
|
||||
/// Reports whether this sequence is strictly newer than an observed sequence.
|
||||
#[must_use]
|
||||
pub const fn is_after(&self, observed: Self) -> bool {
|
||||
return self.0 > observed.0;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
const fn exhausted_for_test() -> Self {
|
||||
return Self(u64::MAX);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fixed common latest-value snapshot exposed by every Worker implementation.
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub struct WorkerSnapshot {
|
||||
id: crate::WorkerId,
|
||||
kind: crate::WorkerKindCode,
|
||||
sequence: crate::WorkerSnapshotSequence,
|
||||
state: crate::WorkerState,
|
||||
health: crate::WorkerHealth,
|
||||
activity: crate::WorkerActivity,
|
||||
}
|
||||
|
||||
impl crate::WorkerSnapshot {
|
||||
/// Creates one immutable common Worker snapshot from already validated values.
|
||||
#[must_use]
|
||||
pub const fn new(
|
||||
id: crate::WorkerId,
|
||||
kind: crate::WorkerKindCode,
|
||||
sequence: crate::WorkerSnapshotSequence,
|
||||
state: crate::WorkerState,
|
||||
health: crate::WorkerHealth,
|
||||
activity: crate::WorkerActivity,
|
||||
) -> Self {
|
||||
return Self { id, kind, sequence, state, health, activity };
|
||||
}
|
||||
|
||||
/// Returns the logical Worker identity.
|
||||
#[must_use]
|
||||
pub const fn id(&self) -> &crate::WorkerId {
|
||||
return &self.id;
|
||||
}
|
||||
|
||||
/// Returns the stable Worker family code.
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> &crate::WorkerKindCode {
|
||||
return &self.kind;
|
||||
}
|
||||
|
||||
/// Returns the monotone sequence of this latest value.
|
||||
#[must_use]
|
||||
pub const fn sequence(&self) -> crate::WorkerSnapshotSequence {
|
||||
return self.sequence;
|
||||
}
|
||||
|
||||
/// Returns the lifecycle state represented by this snapshot.
|
||||
#[must_use]
|
||||
pub const fn state(&self) -> crate::WorkerState {
|
||||
return self.state;
|
||||
}
|
||||
|
||||
/// Returns the operational health represented by this snapshot.
|
||||
#[must_use]
|
||||
pub const fn health(&self) -> crate::WorkerHealth {
|
||||
return self.health;
|
||||
}
|
||||
|
||||
/// Returns the generic activity represented by this snapshot.
|
||||
#[must_use]
|
||||
pub const fn activity(&self) -> crate::WorkerActivity {
|
||||
return self.activity;
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for crate::WorkerSnapshot {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter
|
||||
.debug_struct("WorkerSnapshot")
|
||||
.field("id", &self.id)
|
||||
.field("kind", &self.kind)
|
||||
.field("sequence", &self.sequence)
|
||||
.field("state", &self.state)
|
||||
.field("health", &self.health)
|
||||
.field("activity", &self.activity)
|
||||
.finish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Runtime-neutral future returned while observing a latest-value Worker snapshot source.
|
||||
pub type WorkerSnapshotFuture<'a> = std::pin::Pin<std::boxed::Box<dyn std::future::Future<Output = crate::WorkerSnapshot> + std::marker::Send + 'a>>;
|
||||
|
||||
/// Runtime-neutral read and change-wait contract for one latest-value Worker snapshot stream.
|
||||
pub trait WorkerSnapshotSource: std::marker::Send + std::marker::Sync {
|
||||
/// Returns the complete current common Worker snapshot without replaying prior updates.
|
||||
#[must_use]
|
||||
fn current(&self) -> crate::WorkerSnapshot;
|
||||
|
||||
/// Waits for a snapshot newer than `observed`, returning the complete current value after coalescing intermediate updates.
|
||||
fn wait_for_change(&self, observed: crate::WorkerSnapshotSequence) -> crate::WorkerSnapshotFuture<'_>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn exhausted_snapshot_sequence() -> crate::WorkerSnapshotSequence {
|
||||
return crate::WorkerSnapshotSequence::exhausted_for_test();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/snapshot.rs"]
|
||||
mod tests;
|
||||
44
crates/ksp-worker-api/src/stop.rs
Normal file
44
crates/ksp-worker-api/src/stop.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
// file: crates/ksp-worker-api/src/stop.rs
|
||||
// version: 2
|
||||
|
||||
/// Runtime-neutral cloneable token carrying cooperative Worker stop intent.
|
||||
#[derive(Clone)]
|
||||
pub struct WorkerStopToken {
|
||||
requested: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
|
||||
impl crate::WorkerStopToken {
|
||||
/// Creates a token with no stop request.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
return Self { requested: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)) };
|
||||
}
|
||||
|
||||
/// Requests stop and returns `true` only for the first request shared by all clones.
|
||||
#[must_use]
|
||||
pub fn request_stop(&self) -> bool {
|
||||
return !self.requested.swap(true, std::sync::atomic::Ordering::AcqRel);
|
||||
}
|
||||
|
||||
/// Reports whether stop has been requested through any clone.
|
||||
#[must_use]
|
||||
pub fn is_stop_requested(&self) -> bool {
|
||||
return self.requested.load(std::sync::atomic::Ordering::Acquire);
|
||||
}
|
||||
}
|
||||
|
||||
impl std::default::Default for crate::WorkerStopToken {
|
||||
fn default() -> Self {
|
||||
return Self::new();
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for crate::WorkerStopToken {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter.debug_struct("WorkerStopToken").field("stop_requested", &self.is_stop_requested()).finish();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/stop.rs"]
|
||||
mod tests;
|
||||
128
crates/ksp-worker-api/tests/dependency_boundary.rs
Normal file
128
crates/ksp-worker-api/tests/dependency_boundary.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
// file: crates/ksp-worker-api/tests/dependency_boundary.rs
|
||||
// version: 3
|
||||
|
||||
//! Dependency and runtime-neutrality canaries for the Worker API foundation.
|
||||
|
||||
#[test]
|
||||
fn pre_002_manifest_has_exact_core_only_dependency_graph() {
|
||||
let manifest = include_str!("../Cargo.toml");
|
||||
assert!(!manifest.contains("[features]"));
|
||||
assert!(!manifest.contains("[dev-dependencies]"));
|
||||
assert!(!manifest.contains("[build-dependencies]"));
|
||||
assert_eq!(manifest.matches("[dependencies]").count(), 1);
|
||||
let dependencies_tail = manifest.split("[dependencies]").nth(1);
|
||||
assert!(dependencies_tail.is_some());
|
||||
let dependencies_tail = match dependencies_tail {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let dependencies = match dependencies_tail.split("[lints]").next() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
assert_eq!(manifest_dependency_names(dependencies), std::vec!["ksp-core-lib"]);
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_production_sources_forbid_job_runtime_domain_and_wire_dependencies() {
|
||||
let sources = [
|
||||
include_str!("../src/error.rs"),
|
||||
include_str!("../src/identity.rs"),
|
||||
include_str!("../src/lib.rs"),
|
||||
include_str!("../src/lifecycle.rs"),
|
||||
include_str!("../src/snapshot.rs"),
|
||||
include_str!("../src/stop.rs"),
|
||||
];
|
||||
for source in sources {
|
||||
for forbidden in [
|
||||
"ksp_config_lib::",
|
||||
"ksp_interface_lib::",
|
||||
"ksp_job_api::",
|
||||
"ksp_logging_lib::",
|
||||
"ksp_offchain_transport_lib::",
|
||||
"ksp_onchain_transport_lib::",
|
||||
"ksp_store_api::",
|
||||
"ksp_store_lib::",
|
||||
"reqwest::",
|
||||
"serde::",
|
||||
"serde_json::",
|
||||
"solana_",
|
||||
"tauri::",
|
||||
"tokio::",
|
||||
"tonic::",
|
||||
concat!("tracing", "::"),
|
||||
] {
|
||||
assert!(!source.contains(forbidden), "forbidden Worker API dependency path detected: {forbidden}");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_fix_001_shared_visible_items_use_crate_root_facade_inside_owner_modules() {
|
||||
let identity = include_str!("../src/identity.rs");
|
||||
let lifecycle = include_str!("../src/lifecycle.rs");
|
||||
let snapshot = include_str!("../src/snapshot.rs");
|
||||
let stop = include_str!("../src/stop.rs");
|
||||
for expected in [
|
||||
"impl crate::WorkerId {",
|
||||
"impl std::fmt::Debug for crate::WorkerId {",
|
||||
"impl crate::WorkerKindCode {",
|
||||
"impl std::fmt::Debug for crate::WorkerKindCode {",
|
||||
] {
|
||||
assert!(identity.contains(expected), "missing crate-root Worker identity impl target: {expected}");
|
||||
}
|
||||
for expected in [
|
||||
"impl crate::WorkerActivity {",
|
||||
"impl crate::WorkerHealth {",
|
||||
"impl crate::WorkerLifecycle {",
|
||||
"impl crate::WorkerState {",
|
||||
"impl std::fmt::Debug for crate::WorkerLifecycle {",
|
||||
] {
|
||||
assert!(lifecycle.contains(expected), "missing crate-root Worker lifecycle impl target: {expected}");
|
||||
}
|
||||
for expected in ["impl crate::WorkerSnapshotSequence {", "impl crate::WorkerSnapshot {", "impl std::fmt::Debug for crate::WorkerSnapshot {"] {
|
||||
assert!(snapshot.contains(expected), "missing crate-root Worker snapshot reference: {expected}");
|
||||
}
|
||||
for expected in [
|
||||
"impl crate::WorkerStopToken {",
|
||||
"impl std::default::Default for crate::WorkerStopToken {",
|
||||
"impl std::fmt::Debug for crate::WorkerStopToken {",
|
||||
] {
|
||||
assert!(stop.contains(expected), "missing crate-root Worker stop impl target: {expected}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_fix_002_private_tuple_constructor_stays_inside_owner_impl() {
|
||||
let snapshot = include_str!("../src/snapshot.rs");
|
||||
assert!(snapshot.contains("const fn exhausted_for_test() -> Self {"));
|
||||
assert!(snapshot.contains("return Self(u64::MAX);"));
|
||||
assert!(snapshot.contains("return crate::WorkerSnapshotSequence::exhausted_for_test();"));
|
||||
assert!(!snapshot.contains("return crate::WorkerSnapshotSequence(u64::MAX);"));
|
||||
return;
|
||||
}
|
||||
|
||||
fn manifest_dependency_names(section: &str) -> std::vec::Vec<&str> {
|
||||
let mut names = std::vec::Vec::new();
|
||||
for line in section.lines() {
|
||||
let content = match line.split('#').next() {
|
||||
std::option::Option::Some(value) => value.trim(),
|
||||
std::option::Option::None => continue,
|
||||
};
|
||||
if content.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let name = match content.split('=').next() {
|
||||
std::option::Option::Some(value) => value.trim().trim_end_matches(".workspace"),
|
||||
std::option::Option::None => continue,
|
||||
};
|
||||
if !name.is_empty() {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
names.sort_unstable();
|
||||
return names;
|
||||
}
|
||||
52
crates/ksp-worker-api/tests/public_api.rs
Normal file
52
crates/ksp-worker-api/tests/public_api.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
// file: crates/ksp-worker-api/tests/public_api.rs
|
||||
// version: 1
|
||||
|
||||
//! External-consumer canaries for the Worker API crate-root facade.
|
||||
|
||||
#[test]
|
||||
fn pre_002_identity_lifecycle_stop_and_snapshot_are_consumable_from_crate_root() {
|
||||
let id = ksp_worker_api::WorkerId::new("worker-public-001");
|
||||
let kind = ksp_worker_api::WorkerKindCode::new("example_worker");
|
||||
assert!(id.is_ok());
|
||||
assert!(kind.is_ok());
|
||||
let id = match id {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let kind = match kind {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let mut lifecycle = ksp_worker_api::WorkerLifecycle::new(id.clone(), kind.clone());
|
||||
assert_eq!(lifecycle.state(), ksp_worker_api::WorkerState::Created);
|
||||
assert!(lifecycle.start().is_ok());
|
||||
assert!(lifecycle.mark_running().is_ok());
|
||||
let stop = ksp_worker_api::WorkerStopToken::new();
|
||||
assert!(stop.request_stop());
|
||||
assert!(lifecycle.mark_stopping().is_ok());
|
||||
let snapshot = ksp_worker_api::WorkerSnapshot::new(
|
||||
id,
|
||||
kind,
|
||||
ksp_worker_api::WorkerSnapshotSequence::initial(),
|
||||
lifecycle.state(),
|
||||
ksp_worker_api::WorkerHealth::Healthy,
|
||||
ksp_worker_api::WorkerActivity::Idle,
|
||||
);
|
||||
assert_eq!(snapshot.state(), ksp_worker_api::WorkerState::Stopping);
|
||||
assert_eq!(snapshot.health(), ksp_worker_api::WorkerHealth::Healthy);
|
||||
assert_eq!(snapshot.activity(), ksp_worker_api::WorkerActivity::Idle);
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_public_bounds_and_error_codes_are_exact() {
|
||||
assert_eq!(ksp_worker_api::MAX_WORKER_ID_BYTES, 128);
|
||||
assert_eq!(ksp_worker_api::MAX_WORKER_KIND_CODE_BYTES, 128);
|
||||
assert_eq!(ksp_worker_api::ERROR_CODE_WORKER_ID_INVALID.domain(), "worker_api");
|
||||
assert_eq!(ksp_worker_api::ERROR_CODE_WORKER_ID_INVALID.code(), "worker_id_invalid");
|
||||
assert_eq!(ksp_worker_api::ERROR_CODE_WORKER_KIND_INVALID.domain(), "worker_api");
|
||||
assert_eq!(ksp_worker_api::ERROR_CODE_WORKER_KIND_INVALID.code(), "worker_kind_invalid");
|
||||
assert_eq!(ksp_worker_api::ERROR_CODE_WORKER_TRANSITION_INVALID.code(), "worker_transition_invalid");
|
||||
assert_eq!(ksp_worker_api::ERROR_CODE_WORKER_SNAPSHOT_SEQUENCE_EXHAUSTED.code(), "worker_snapshot_sequence_exhausted");
|
||||
return;
|
||||
}
|
||||
161
crates/ksp-worker-api/tests/release_completeness.rs
Normal file
161
crates/ksp-worker-api/tests/release_completeness.rs
Normal file
@@ -0,0 +1,161 @@
|
||||
// file: crates/ksp-worker-api/tests/release_completeness.rs
|
||||
// version: 1
|
||||
|
||||
//! Exact frozen-surface and domain-firewall canaries for `ksp-worker-api`.
|
||||
|
||||
#[test]
|
||||
fn pre_003_crate_root_export_inventory_is_exact() {
|
||||
let crate_root = include_str!("../src/lib.rs");
|
||||
let mut actual = std::vec::Vec::new();
|
||||
for line in crate_root.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("pub use ") {
|
||||
actual.push(trimmed);
|
||||
}
|
||||
}
|
||||
actual.sort_unstable();
|
||||
let mut expected = std::vec![
|
||||
"pub use self::error::ERROR_CODE_WORKER_ID_INVALID;",
|
||||
"pub use self::error::ERROR_CODE_WORKER_KIND_INVALID;",
|
||||
"pub use self::error::ERROR_CODE_WORKER_SNAPSHOT_SEQUENCE_EXHAUSTED;",
|
||||
"pub use self::error::ERROR_CODE_WORKER_TRANSITION_INVALID;",
|
||||
"pub use self::identity::MAX_WORKER_ID_BYTES;",
|
||||
"pub use self::identity::MAX_WORKER_KIND_CODE_BYTES;",
|
||||
"pub use self::identity::WorkerId;",
|
||||
"pub use self::identity::WorkerKindCode;",
|
||||
"pub use self::lifecycle::WorkerActivity;",
|
||||
"pub use self::lifecycle::WorkerHealth;",
|
||||
"pub use self::lifecycle::WorkerLifecycle;",
|
||||
"pub use self::lifecycle::WorkerState;",
|
||||
"pub use self::snapshot::WorkerSnapshot;",
|
||||
"pub use self::snapshot::WorkerSnapshotFuture;",
|
||||
"pub use self::snapshot::WorkerSnapshotSequence;",
|
||||
"pub use self::snapshot::WorkerSnapshotSource;",
|
||||
"pub use self::stop::WorkerStopToken;",
|
||||
"pub use ksp_core_lib::Error;",
|
||||
"pub use ksp_core_lib::ErrorCode;",
|
||||
"pub use ksp_core_lib::ErrorContext;",
|
||||
"pub use ksp_core_lib::Result;",
|
||||
];
|
||||
expected.sort_unstable();
|
||||
assert_eq!(actual, expected);
|
||||
assert!(!crate_root.contains("pub mod "));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_003_production_module_inventory_is_exact() -> std::io::Result<()> {
|
||||
let source_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let entries = match std::fs::read_dir(source_root) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut names = std::vec::Vec::new();
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let file_type = match entry.file_type() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if !file_type.is_file() {
|
||||
continue;
|
||||
}
|
||||
let name = match entry.file_name().into_string() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => continue,
|
||||
};
|
||||
if name.ends_with(".rs") {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
names.sort_unstable();
|
||||
assert_eq!(names, std::vec!["error.rs", "identity.rs", "lib.rs", "lifecycle.rs", "snapshot.rs", "stop.rs"]);
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_003_fixed_snapshot_shape_contains_only_common_worker_dimensions() {
|
||||
let snapshot = include_str!("../src/snapshot.rs");
|
||||
let struct_start = match snapshot.find("pub struct WorkerSnapshot {") {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let after_start = &snapshot[struct_start..];
|
||||
let struct_end = match after_start.find("\n}") {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let definition = &after_start[..struct_end];
|
||||
for required in ["id:", "kind:", "sequence:", "state:", "health:", "activity:"] {
|
||||
assert!(definition.contains(required), "missing frozen Worker snapshot field: {required}");
|
||||
}
|
||||
for forbidden in ["String", "Vec<", "payload", "slot", "signature", "provider", "endpoint", "checkpoint", "backfill", "transaction"] {
|
||||
assert!(!definition.contains(forbidden), "domain or arbitrary payload leaked into frozen Worker snapshot: {forbidden}");
|
||||
}
|
||||
let fields = definition.lines().filter(|line| {
|
||||
let line = line.trim_start();
|
||||
return line.starts_with("id:")
|
||||
|| line.starts_with("kind:")
|
||||
|| line.starts_with("sequence:")
|
||||
|| line.starts_with("state:")
|
||||
|| line.starts_with("health:")
|
||||
|| line.starts_with("activity:");
|
||||
});
|
||||
assert_eq!(fields.count(), 6);
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_003_surface_remains_runtime_neutral_job_independent_and_domain_free() {
|
||||
let sources = [
|
||||
include_str!("../src/error.rs"),
|
||||
include_str!("../src/identity.rs"),
|
||||
include_str!("../src/lib.rs"),
|
||||
include_str!("../src/lifecycle.rs"),
|
||||
include_str!("../src/snapshot.rs"),
|
||||
include_str!("../src/stop.rs"),
|
||||
];
|
||||
for source in sources {
|
||||
for forbidden in [
|
||||
"Backfill",
|
||||
"RawTransaction",
|
||||
"WorkerHandle",
|
||||
"checkpoint",
|
||||
"endpoint",
|
||||
"futures::",
|
||||
"futures_util::",
|
||||
"ksp_config_lib::",
|
||||
"ksp_interface_lib::",
|
||||
"ksp_job_api::",
|
||||
"ksp_logging_lib::",
|
||||
"ksp_offchain_transport_lib::",
|
||||
"ksp_onchain_transport_lib::",
|
||||
"ksp_store_api::",
|
||||
"ksp_store_lib::",
|
||||
"provider",
|
||||
"reqwest::",
|
||||
"serde::",
|
||||
"serde_json::",
|
||||
"slot",
|
||||
"solana_",
|
||||
"tauri::",
|
||||
"tokio::",
|
||||
"tonic::",
|
||||
concat!("tracing", "::"),
|
||||
] {
|
||||
assert!(!source.contains(forbidden), "forbidden frozen Worker API concern detected: {forbidden}");
|
||||
}
|
||||
}
|
||||
let snapshot = include_str!("../src/snapshot.rs");
|
||||
assert!(snapshot.contains("pub trait WorkerSnapshotSource: std::marker::Send + std::marker::Sync"));
|
||||
assert!(snapshot.contains("std::future::Future"));
|
||||
assert!(!snapshot.contains("std::sync::mpsc"));
|
||||
assert!(!snapshot.contains("VecDeque"));
|
||||
let lifecycle = include_str!("../src/lifecycle.rs");
|
||||
assert!(lifecycle.contains("#[derive(Eq, PartialEq)]\npub struct WorkerLifecycle"));
|
||||
assert!(!lifecycle.contains("#[derive(Clone, Eq, PartialEq)]\npub struct WorkerLifecycle"));
|
||||
return;
|
||||
}
|
||||
185
crates/ksp-worker-api/tests/security_hardening.rs
Normal file
185
crates/ksp-worker-api/tests/security_hardening.rs
Normal file
@@ -0,0 +1,185 @@
|
||||
// file: crates/ksp-worker-api/tests/security_hardening.rs
|
||||
// version: 1
|
||||
|
||||
//! Adversarial lifecycle, stop and redaction canaries for the frozen Worker API.
|
||||
|
||||
const HOSTILE_MARKER: &str = "WORKER-IDENTITY-SECRET-CANARY";
|
||||
const TEST_FAULT: ksp_worker_api::ErrorCode = ksp_worker_api::ErrorCode::new("worker_test", "fault");
|
||||
|
||||
fn running_lifecycle(id_value: &str) -> std::option::Option<ksp_worker_api::WorkerLifecycle> {
|
||||
let id = match ksp_worker_api::WorkerId::new(id_value) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
let kind = match ksp_worker_api::WorkerKindCode::new("continuous_worker") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
let mut lifecycle = ksp_worker_api::WorkerLifecycle::new(id, kind);
|
||||
if lifecycle.start().is_err() {
|
||||
return std::option::Option::None;
|
||||
}
|
||||
if lifecycle.mark_running().is_err() {
|
||||
return std::option::Option::None;
|
||||
}
|
||||
return std::option::Option::Some(lifecycle);
|
||||
}
|
||||
|
||||
fn assert_all_mutators_reject(lifecycle: &mut ksp_worker_api::WorkerLifecycle, terminal: ksp_worker_api::WorkerState) {
|
||||
assert!(lifecycle.start().is_err());
|
||||
assert!(lifecycle.mark_running().is_err());
|
||||
assert!(lifecycle.mark_stopping().is_err());
|
||||
assert!(lifecycle.mark_stopped().is_err());
|
||||
assert!(lifecycle.fault(TEST_FAULT).is_err());
|
||||
assert_eq!(lifecycle.state(), terminal);
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_003_worker_identity_lifecycle_and_snapshot_debug_redact_hostile_identity() {
|
||||
let lifecycle = running_lifecycle(HOSTILE_MARKER);
|
||||
assert!(lifecycle.is_some());
|
||||
let lifecycle = match lifecycle {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let id = lifecycle.id();
|
||||
assert_eq!(std::format!("{id:?}"), "WorkerId(..)");
|
||||
let lifecycle_debug = std::format!("{lifecycle:?}");
|
||||
assert!(lifecycle_debug.contains("WorkerLifecycle"));
|
||||
assert!(lifecycle_debug.contains("continuous_worker"));
|
||||
assert!(lifecycle_debug.contains("Running"));
|
||||
assert!(!lifecycle_debug.contains(HOSTILE_MARKER));
|
||||
let snapshot = ksp_worker_api::WorkerSnapshot::new(
|
||||
lifecycle.id().clone(),
|
||||
lifecycle.kind().clone(),
|
||||
ksp_worker_api::WorkerSnapshotSequence::initial(),
|
||||
lifecycle.state(),
|
||||
ksp_worker_api::WorkerHealth::Healthy,
|
||||
ksp_worker_api::WorkerActivity::Active,
|
||||
);
|
||||
let snapshot_debug = std::format!("{snapshot:?}");
|
||||
assert!(snapshot_debug.contains("WorkerSnapshot"));
|
||||
assert!(snapshot_debug.contains("continuous_worker"));
|
||||
assert!(snapshot_debug.contains("Healthy"));
|
||||
assert!(snapshot_debug.contains("Active"));
|
||||
assert!(!snapshot_debug.contains(HOSTILE_MARKER));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_003_identity_errors_never_echo_hostile_values() {
|
||||
let hostile = "WORKER-SECRET/CANARY";
|
||||
let rejected = ksp_worker_api::WorkerId::new(hostile);
|
||||
assert!(rejected.is_err());
|
||||
let error = match rejected {
|
||||
std::result::Result::Err(value) => value,
|
||||
std::result::Result::Ok(_) => return,
|
||||
};
|
||||
assert_eq!(error.code(), ksp_worker_api::ERROR_CODE_WORKER_ID_INVALID);
|
||||
assert!(!std::format!("{error}").contains(hostile));
|
||||
assert!(!std::format!("{error:?}").contains(hostile));
|
||||
for context in error.context() {
|
||||
assert!(!context.value().contains(hostile));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_003_stopped_and_faulted_are_immutable_under_all_public_mutators() {
|
||||
let id = match ksp_worker_api::WorkerId::new("terminal-created") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let kind = match ksp_worker_api::WorkerKindCode::new("terminal_worker") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let mut stopped = ksp_worker_api::WorkerLifecycle::new(id, kind);
|
||||
assert!(stopped.mark_stopped().is_ok());
|
||||
assert_all_mutators_reject(&mut stopped, ksp_worker_api::WorkerState::Stopped);
|
||||
let faulted = running_lifecycle("terminal-faulted");
|
||||
assert!(faulted.is_some());
|
||||
let mut faulted = match faulted {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
assert!(faulted.fault(TEST_FAULT).is_ok());
|
||||
assert_all_mutators_reject(&mut faulted, ksp_worker_api::WorkerState::Faulted(TEST_FAULT));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_003_stop_fault_terminal_orders_are_first_valid_terminal_wins() {
|
||||
let direct_fault = running_lifecycle("race-direct-fault");
|
||||
assert!(direct_fault.is_some());
|
||||
let mut direct_fault = match direct_fault {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
assert!(direct_fault.fault(TEST_FAULT).is_ok());
|
||||
assert!(direct_fault.mark_stopping().is_err());
|
||||
assert_eq!(direct_fault.state(), ksp_worker_api::WorkerState::Faulted(TEST_FAULT));
|
||||
let stopping_fault = running_lifecycle("race-stopping-fault");
|
||||
assert!(stopping_fault.is_some());
|
||||
let mut stopping_fault = match stopping_fault {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
assert!(stopping_fault.mark_stopping().is_ok());
|
||||
assert!(stopping_fault.fault(TEST_FAULT).is_ok());
|
||||
assert!(stopping_fault.mark_stopped().is_err());
|
||||
assert_eq!(stopping_fault.state(), ksp_worker_api::WorkerState::Faulted(TEST_FAULT));
|
||||
let stopping_stopped = running_lifecycle("race-stopping-stopped");
|
||||
assert!(stopping_stopped.is_some());
|
||||
let mut stopping_stopped = match stopping_stopped {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
assert!(stopping_stopped.mark_stopping().is_ok());
|
||||
assert!(stopping_stopped.mark_stopped().is_ok());
|
||||
assert!(stopping_stopped.fault(TEST_FAULT).is_err());
|
||||
assert_eq!(stopping_stopped.state(), ksp_worker_api::WorkerState::Stopped);
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_003_stop_token_is_send_sync_cross_thread_and_cannot_reanimate_terminal_lifecycle() {
|
||||
fn require_send_sync<T: std::marker::Send + std::marker::Sync>() {}
|
||||
require_send_sync::<ksp_worker_api::WorkerStopToken>();
|
||||
let lifecycle = running_lifecycle("cross-thread-stop");
|
||||
assert!(lifecycle.is_some());
|
||||
let mut lifecycle = match lifecycle {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
assert!(lifecycle.fault(TEST_FAULT).is_ok());
|
||||
let token = ksp_worker_api::WorkerStopToken::new();
|
||||
let worker_token = token.clone();
|
||||
let thread = std::thread::spawn(move || return worker_token.request_stop());
|
||||
let first = match thread.join() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert!(first);
|
||||
assert!(token.is_stop_requested());
|
||||
assert!(!token.request_stop());
|
||||
assert!(lifecycle.mark_stopping().is_err());
|
||||
assert_eq!(lifecycle.state(), ksp_worker_api::WorkerState::Faulted(TEST_FAULT));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_003_public_worker_primitives_keep_promised_send_sync_contracts() {
|
||||
fn require_send_sync<T: std::marker::Send + std::marker::Sync>() {}
|
||||
require_send_sync::<ksp_worker_api::WorkerId>();
|
||||
require_send_sync::<ksp_worker_api::WorkerKindCode>();
|
||||
require_send_sync::<ksp_worker_api::WorkerState>();
|
||||
require_send_sync::<ksp_worker_api::WorkerHealth>();
|
||||
require_send_sync::<ksp_worker_api::WorkerActivity>();
|
||||
require_send_sync::<ksp_worker_api::WorkerLifecycle>();
|
||||
require_send_sync::<ksp_worker_api::WorkerSnapshotSequence>();
|
||||
require_send_sync::<ksp_worker_api::WorkerSnapshot>();
|
||||
require_send_sync::<ksp_worker_api::WorkerStopToken>();
|
||||
return;
|
||||
}
|
||||
219
crates/ksp-worker-api/tests/snapshot_source.rs
Normal file
219
crates/ksp-worker-api/tests/snapshot_source.rs
Normal file
@@ -0,0 +1,219 @@
|
||||
// file: crates/ksp-worker-api/tests/snapshot_source.rs
|
||||
// version: 1
|
||||
|
||||
//! External std-only latest-value source, object-safety and resynchronization canaries.
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TestSnapshotSource {
|
||||
state: std::sync::Arc<TestState>,
|
||||
}
|
||||
|
||||
struct TestState {
|
||||
current: std::sync::Mutex<TestInner>,
|
||||
}
|
||||
|
||||
struct TestInner {
|
||||
snapshot: ksp_worker_api::WorkerSnapshot,
|
||||
waiters: std::vec::Vec<std::task::Waker>,
|
||||
}
|
||||
|
||||
impl TestSnapshotSource {
|
||||
fn new(snapshot: ksp_worker_api::WorkerSnapshot) -> Self {
|
||||
return Self {
|
||||
state: std::sync::Arc::new(TestState { current: std::sync::Mutex::new(TestInner { snapshot, waiters: std::vec::Vec::new() }) }),
|
||||
};
|
||||
}
|
||||
|
||||
fn publish(&self, state: ksp_worker_api::WorkerState, health: ksp_worker_api::WorkerHealth, activity: ksp_worker_api::WorkerActivity) -> bool {
|
||||
let mut current = match self.state.current.lock() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
let sequence = match current.snapshot.sequence().next() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return false,
|
||||
};
|
||||
let id = current.snapshot.id().clone();
|
||||
let kind = current.snapshot.kind().clone();
|
||||
current.snapshot = ksp_worker_api::WorkerSnapshot::new(id, kind, sequence, state, health, activity);
|
||||
let waiters = std::mem::take(&mut current.waiters);
|
||||
std::mem::drop(current);
|
||||
for waiter in waiters {
|
||||
waiter.wake();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
struct TestWaitFuture<'a> {
|
||||
source: &'a TestSnapshotSource,
|
||||
observed: ksp_worker_api::WorkerSnapshotSequence,
|
||||
}
|
||||
|
||||
impl std::future::Future for TestWaitFuture<'_> {
|
||||
type Output = ksp_worker_api::WorkerSnapshot;
|
||||
|
||||
fn poll(self: std::pin::Pin<&mut Self>, context: &mut std::task::Context<'_>) -> std::task::Poll<Self::Output> {
|
||||
let mut current = match self.source.state.current.lock() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
if current.snapshot.sequence().is_after(self.observed) {
|
||||
return std::task::Poll::Ready(current.snapshot.clone());
|
||||
}
|
||||
if !current.waiters.iter().any(|registered| return registered.will_wake(context.waker())) {
|
||||
current.waiters.push(context.waker().clone());
|
||||
}
|
||||
return std::task::Poll::Pending;
|
||||
}
|
||||
}
|
||||
|
||||
impl ksp_worker_api::WorkerSnapshotSource for TestSnapshotSource {
|
||||
fn current(&self) -> ksp_worker_api::WorkerSnapshot {
|
||||
let current = match self.state.current.lock() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
return current.snapshot.clone();
|
||||
}
|
||||
|
||||
fn wait_for_change(&self, observed: ksp_worker_api::WorkerSnapshotSequence) -> ksp_worker_api::WorkerSnapshotFuture<'_> {
|
||||
return std::boxed::Box::pin(TestWaitFuture { source: self, observed });
|
||||
}
|
||||
}
|
||||
|
||||
struct WakeProbe {
|
||||
woken: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl WakeProbe {
|
||||
fn new() -> Self {
|
||||
return Self { woken: std::sync::atomic::AtomicBool::new(false) };
|
||||
}
|
||||
|
||||
fn is_woken(&self) -> bool {
|
||||
return self.woken.load(std::sync::atomic::Ordering::Acquire);
|
||||
}
|
||||
}
|
||||
|
||||
impl std::task::Wake for WakeProbe {
|
||||
fn wake(self: std::sync::Arc<Self>) {
|
||||
self.woken.store(true, std::sync::atomic::Ordering::Release);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_snapshot(future: &mut ksp_worker_api::WorkerSnapshotFuture<'_>, wake: &std::sync::Arc<WakeProbe>) -> std::task::Poll<ksp_worker_api::WorkerSnapshot> {
|
||||
let waker = std::task::Waker::from(wake.clone());
|
||||
let mut context = std::task::Context::from_waker(&waker);
|
||||
return std::future::Future::poll(future.as_mut(), &mut context);
|
||||
}
|
||||
|
||||
fn initial_snapshot() -> std::option::Option<ksp_worker_api::WorkerSnapshot> {
|
||||
let id = match ksp_worker_api::WorkerId::new("external-source-001") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
let kind = match ksp_worker_api::WorkerKindCode::new("external_test_worker") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
return std::option::Option::Some(ksp_worker_api::WorkerSnapshot::new(
|
||||
id,
|
||||
kind,
|
||||
ksp_worker_api::WorkerSnapshotSequence::initial(),
|
||||
ksp_worker_api::WorkerState::Running,
|
||||
ksp_worker_api::WorkerHealth::Healthy,
|
||||
ksp_worker_api::WorkerActivity::Idle,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_003_snapshot_source_is_object_safe_send_sync_and_externally_implementable() {
|
||||
fn require_send_sync<T: std::marker::Send + std::marker::Sync>() {}
|
||||
require_send_sync::<TestSnapshotSource>();
|
||||
let snapshot = initial_snapshot();
|
||||
assert!(snapshot.is_some());
|
||||
let source = match snapshot {
|
||||
std::option::Option::Some(value) => TestSnapshotSource::new(value),
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let object: &dyn ksp_worker_api::WorkerSnapshotSource = &source;
|
||||
let current = object.current();
|
||||
assert_eq!(current.sequence().value(), 0);
|
||||
let mut wait = object.wait_for_change(current.sequence());
|
||||
fn require_send<T: std::marker::Send>(_: &T) {}
|
||||
require_send(&wait);
|
||||
let wake = std::sync::Arc::new(WakeProbe::new());
|
||||
assert!(matches!(poll_snapshot(&mut wait, &wake), std::task::Poll::Pending));
|
||||
assert!(source.publish(ksp_worker_api::WorkerState::Running, ksp_worker_api::WorkerHealth::Healthy, ksp_worker_api::WorkerActivity::Active));
|
||||
assert!(wake.is_woken());
|
||||
let changed = poll_snapshot(&mut wait, &wake);
|
||||
assert!(matches!(changed, std::task::Poll::Ready(_)));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_003_slow_and_independent_listeners_coalesce_to_latest_value() {
|
||||
let snapshot = initial_snapshot();
|
||||
assert!(snapshot.is_some());
|
||||
let source = match snapshot {
|
||||
std::option::Option::Some(value) => TestSnapshotSource::new(value),
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let observed = ksp_worker_api::WorkerSnapshotSource::current(&source).sequence();
|
||||
let mut slow = ksp_worker_api::WorkerSnapshotSource::wait_for_change(&source, observed);
|
||||
let mut fast = ksp_worker_api::WorkerSnapshotSource::wait_for_change(&source, observed);
|
||||
let slow_wake = std::sync::Arc::new(WakeProbe::new());
|
||||
let fast_wake = std::sync::Arc::new(WakeProbe::new());
|
||||
assert!(matches!(poll_snapshot(&mut slow, &slow_wake), std::task::Poll::Pending));
|
||||
assert!(matches!(poll_snapshot(&mut fast, &fast_wake), std::task::Poll::Pending));
|
||||
assert!(source.publish(ksp_worker_api::WorkerState::Running, ksp_worker_api::WorkerHealth::Degraded, ksp_worker_api::WorkerActivity::Active));
|
||||
assert!(slow_wake.is_woken());
|
||||
assert!(fast_wake.is_woken());
|
||||
let fast_value = match poll_snapshot(&mut fast, &fast_wake) {
|
||||
std::task::Poll::Ready(value) => value,
|
||||
std::task::Poll::Pending => return,
|
||||
};
|
||||
assert_eq!(fast_value.sequence().value(), 1);
|
||||
assert_eq!(fast_value.health(), ksp_worker_api::WorkerHealth::Degraded);
|
||||
assert!(source.publish(ksp_worker_api::WorkerState::Running, ksp_worker_api::WorkerHealth::Healthy, ksp_worker_api::WorkerActivity::Idle));
|
||||
let slow_value = match poll_snapshot(&mut slow, &slow_wake) {
|
||||
std::task::Poll::Ready(value) => value,
|
||||
std::task::Poll::Pending => return,
|
||||
};
|
||||
assert_eq!(slow_value.sequence().value(), 2);
|
||||
assert_eq!(slow_value.health(), ksp_worker_api::WorkerHealth::Healthy);
|
||||
assert_eq!(slow_value.activity(), ksp_worker_api::WorkerActivity::Idle);
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_003_late_listener_resynchronizes_and_terminal_snapshot_remains_current() {
|
||||
let snapshot = initial_snapshot();
|
||||
assert!(snapshot.is_some());
|
||||
let source = match snapshot {
|
||||
std::option::Option::Some(value) => TestSnapshotSource::new(value),
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
assert!(source.publish(ksp_worker_api::WorkerState::Running, ksp_worker_api::WorkerHealth::Degraded, ksp_worker_api::WorkerActivity::Active));
|
||||
assert!(source.publish(ksp_worker_api::WorkerState::Stopping, ksp_worker_api::WorkerHealth::Healthy, ksp_worker_api::WorkerActivity::Idle));
|
||||
let current = ksp_worker_api::WorkerSnapshotSource::current(&source);
|
||||
assert_eq!(current.sequence().value(), 2);
|
||||
assert_eq!(current.state(), ksp_worker_api::WorkerState::Stopping);
|
||||
let mut wait = ksp_worker_api::WorkerSnapshotSource::wait_for_change(&source, current.sequence());
|
||||
let wake = std::sync::Arc::new(WakeProbe::new());
|
||||
assert!(matches!(poll_snapshot(&mut wait, &wake), std::task::Poll::Pending));
|
||||
assert!(source.publish(ksp_worker_api::WorkerState::Stopped, ksp_worker_api::WorkerHealth::Healthy, ksp_worker_api::WorkerActivity::Idle));
|
||||
assert!(wake.is_woken());
|
||||
let terminal = match poll_snapshot(&mut wait, &wake) {
|
||||
std::task::Poll::Ready(value) => value,
|
||||
std::task::Poll::Pending => return,
|
||||
};
|
||||
assert_eq!(terminal.sequence().value(), 3);
|
||||
assert_eq!(terminal.state(), ksp_worker_api::WorkerState::Stopped);
|
||||
assert!(terminal.state().is_terminal());
|
||||
let retained = ksp_worker_api::WorkerSnapshotSource::current(&source);
|
||||
assert_eq!(retained, terminal);
|
||||
return;
|
||||
}
|
||||
64
crates/ksp-worker-api/unit_tests/identity.rs
Normal file
64
crates/ksp-worker-api/unit_tests/identity.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
// file: crates/ksp-worker-api/unit_tests/identity.rs
|
||||
// version: 1
|
||||
|
||||
#[test]
|
||||
fn pre_002_identity_accepts_safe_codes_at_exact_bounds() {
|
||||
let worker_id = crate::WorkerId::new("a".repeat(crate::MAX_WORKER_ID_BYTES));
|
||||
let kind = crate::WorkerKindCode::new("continuous.raw:example-1");
|
||||
assert!(worker_id.is_ok());
|
||||
assert!(kind.is_ok());
|
||||
let worker_id = match worker_id {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let kind = match kind {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(worker_id.as_str().len(), crate::MAX_WORKER_ID_BYTES);
|
||||
assert_eq!(kind.as_str(), "continuous.raw:example-1");
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_identity_rejects_empty_oversized_and_unsafe_values() {
|
||||
for value in [
|
||||
std::string::String::new(),
|
||||
"a".repeat(crate::MAX_WORKER_ID_BYTES + 1),
|
||||
"unsafe/value".to_string(),
|
||||
"unsafe\\value".to_string(),
|
||||
"space value".to_string(),
|
||||
"unicode-é".to_string(),
|
||||
] {
|
||||
let rejected = crate::WorkerId::new(value);
|
||||
assert!(rejected.is_err());
|
||||
let error = match rejected {
|
||||
std::result::Result::Err(value) => value,
|
||||
std::result::Result::Ok(_) => continue,
|
||||
};
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_WORKER_ID_INVALID);
|
||||
assert_eq!(error.context().len(), 1);
|
||||
assert_eq!(error.context()[0].key(), "field");
|
||||
assert_eq!(error.context()[0].value(), "worker_id");
|
||||
}
|
||||
for value in [std::string::String::new(), "b".repeat(crate::MAX_WORKER_KIND_CODE_BYTES + 1), "worker kind".to_string()] {
|
||||
let rejected = crate::WorkerKindCode::new(value);
|
||||
assert!(rejected.is_err());
|
||||
let error = match rejected {
|
||||
std::result::Result::Err(value) => value,
|
||||
std::result::Result::Ok(_) => continue,
|
||||
};
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_WORKER_KIND_INVALID);
|
||||
assert_eq!(error.context()[0].value(), "worker_kind");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_private_validator_uses_exact_safe_code_alphabet() {
|
||||
assert!(super::valid_worker_code("AZaz09_.:-", 10));
|
||||
for rejected in ["slash/value", "back\\slash", "space value", "line\nbreak", "é"] {
|
||||
assert!(!super::valid_worker_code(rejected, crate::MAX_WORKER_ID_BYTES));
|
||||
}
|
||||
return;
|
||||
}
|
||||
151
crates/ksp-worker-api/unit_tests/lifecycle.rs
Normal file
151
crates/ksp-worker-api/unit_tests/lifecycle.rs
Normal file
@@ -0,0 +1,151 @@
|
||||
// file: crates/ksp-worker-api/unit_tests/lifecycle.rs
|
||||
// version: 1
|
||||
|
||||
const TEST_FAULT: crate::ErrorCode = crate::ErrorCode::new("worker_test", "fault");
|
||||
|
||||
fn new_lifecycle() -> std::option::Option<crate::WorkerLifecycle> {
|
||||
let id = match crate::WorkerId::new("worker-001") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
let kind = match crate::WorkerKindCode::new("continuous_worker") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
return std::option::Option::Some(crate::WorkerLifecycle::new(id, kind));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_lifecycle_accepts_every_planned_terminal_path() {
|
||||
let lifecycle = new_lifecycle();
|
||||
assert!(lifecycle.is_some());
|
||||
let mut lifecycle = match lifecycle {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
assert!(lifecycle.mark_stopped().is_ok());
|
||||
assert_eq!(lifecycle.state(), crate::WorkerState::Stopped);
|
||||
assert!(lifecycle.state().is_terminal());
|
||||
let lifecycle = new_lifecycle();
|
||||
assert!(lifecycle.is_some());
|
||||
let mut lifecycle = match lifecycle {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
assert!(lifecycle.start().is_ok());
|
||||
assert!(lifecycle.mark_running().is_ok());
|
||||
assert!(lifecycle.mark_stopping().is_ok());
|
||||
assert!(lifecycle.mark_stopped().is_ok());
|
||||
assert_eq!(lifecycle.state(), crate::WorkerState::Stopped);
|
||||
for fault_from_running in [false, true] {
|
||||
let lifecycle = new_lifecycle();
|
||||
assert!(lifecycle.is_some());
|
||||
let mut lifecycle = match lifecycle {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
assert!(lifecycle.start().is_ok());
|
||||
if fault_from_running {
|
||||
assert!(lifecycle.mark_running().is_ok());
|
||||
}
|
||||
assert!(lifecycle.fault(TEST_FAULT).is_ok());
|
||||
assert_eq!(lifecycle.state(), crate::WorkerState::Faulted(TEST_FAULT));
|
||||
assert_eq!(lifecycle.state().fault_code(), std::option::Option::Some(TEST_FAULT));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_stopping_allows_normal_stop_or_fault() {
|
||||
for terminal in [crate::WorkerState::Stopped, crate::WorkerState::Faulted(TEST_FAULT)] {
|
||||
let lifecycle = new_lifecycle();
|
||||
assert!(lifecycle.is_some());
|
||||
let mut lifecycle = match lifecycle {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
assert!(lifecycle.start().is_ok());
|
||||
assert!(lifecycle.mark_running().is_ok());
|
||||
assert!(lifecycle.mark_stopping().is_ok());
|
||||
let result = match terminal {
|
||||
crate::WorkerState::Stopped => lifecycle.mark_stopped(),
|
||||
crate::WorkerState::Faulted(code) => lifecycle.fault(code),
|
||||
_ => return,
|
||||
};
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(lifecycle.state(), terminal);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_invalid_transition_preserves_source_state_and_reports_safe_context() {
|
||||
let lifecycle = new_lifecycle();
|
||||
assert!(lifecycle.is_some());
|
||||
let mut lifecycle = match lifecycle {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let rejected = lifecycle.mark_running();
|
||||
assert!(rejected.is_err());
|
||||
assert_eq!(lifecycle.state(), crate::WorkerState::Created);
|
||||
let error = match rejected {
|
||||
std::result::Result::Err(value) => value,
|
||||
std::result::Result::Ok(_) => return,
|
||||
};
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_WORKER_TRANSITION_INVALID);
|
||||
assert_eq!(error.context().len(), 2);
|
||||
assert_eq!(error.context()[0].key(), "source_state");
|
||||
assert_eq!(error.context()[0].value(), "created");
|
||||
assert_eq!(error.context()[1].key(), "target_state");
|
||||
assert_eq!(error.context()[1].value(), "running");
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_transition_matrix_is_exact() {
|
||||
let states = [
|
||||
crate::WorkerState::Created,
|
||||
crate::WorkerState::Starting,
|
||||
crate::WorkerState::Running,
|
||||
crate::WorkerState::Stopping,
|
||||
crate::WorkerState::Stopped,
|
||||
crate::WorkerState::Faulted(TEST_FAULT),
|
||||
];
|
||||
for source in states {
|
||||
for target in states {
|
||||
let expected = matches!(
|
||||
(source, target),
|
||||
(crate::WorkerState::Created, crate::WorkerState::Starting)
|
||||
| (crate::WorkerState::Created, crate::WorkerState::Stopped)
|
||||
| (crate::WorkerState::Starting, crate::WorkerState::Running)
|
||||
| (crate::WorkerState::Starting, crate::WorkerState::Stopping)
|
||||
| (crate::WorkerState::Starting, crate::WorkerState::Faulted(_))
|
||||
| (crate::WorkerState::Running, crate::WorkerState::Stopping)
|
||||
| (crate::WorkerState::Running, crate::WorkerState::Faulted(_))
|
||||
| (crate::WorkerState::Stopping, crate::WorkerState::Stopped)
|
||||
| (crate::WorkerState::Stopping, crate::WorkerState::Faulted(_))
|
||||
);
|
||||
assert_eq!(super::allowed_transition(source, target), expected, "unexpected transition matrix cell: {source:?} -> {target:?}");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_health_activity_and_state_codes_are_stable() {
|
||||
assert_eq!(crate::WorkerState::Created.code(), "created");
|
||||
assert_eq!(crate::WorkerState::Starting.code(), "starting");
|
||||
assert_eq!(crate::WorkerState::Running.code(), "running");
|
||||
assert_eq!(crate::WorkerState::Stopping.code(), "stopping");
|
||||
assert_eq!(crate::WorkerState::Stopped.code(), "stopped");
|
||||
assert_eq!(crate::WorkerState::Faulted(TEST_FAULT).code(), "faulted");
|
||||
assert_eq!(crate::WorkerHealth::Unknown.code(), "unknown");
|
||||
assert_eq!(crate::WorkerHealth::Healthy.code(), "healthy");
|
||||
assert_eq!(crate::WorkerHealth::Degraded.code(), "degraded");
|
||||
assert_eq!(crate::WorkerHealth::Unhealthy.code(), "unhealthy");
|
||||
assert_eq!(crate::WorkerActivity::Unknown.code(), "unknown");
|
||||
assert_eq!(crate::WorkerActivity::Idle.code(), "idle");
|
||||
assert_eq!(crate::WorkerActivity::Active.code(), "active");
|
||||
return;
|
||||
}
|
||||
66
crates/ksp-worker-api/unit_tests/snapshot.rs
Normal file
66
crates/ksp-worker-api/unit_tests/snapshot.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
// file: crates/ksp-worker-api/unit_tests/snapshot.rs
|
||||
// version: 1
|
||||
|
||||
#[test]
|
||||
fn pre_002_snapshot_sequence_advances_strictly_and_orders_positions() {
|
||||
let first = crate::WorkerSnapshotSequence::initial();
|
||||
assert_eq!(first.value(), 0);
|
||||
let second = first.next();
|
||||
assert!(second.is_ok());
|
||||
let second = match second {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(second.value(), 1);
|
||||
assert!(second.is_after(first));
|
||||
assert!(!first.is_after(second));
|
||||
assert!(!first.is_after(first));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_snapshot_sequence_exhaustion_is_explicit_and_non_wrapping() {
|
||||
let exhausted = super::exhausted_snapshot_sequence();
|
||||
let result = exhausted.next();
|
||||
assert!(result.is_err());
|
||||
let error = match result {
|
||||
std::result::Result::Err(value) => value,
|
||||
std::result::Result::Ok(_) => return,
|
||||
};
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_WORKER_SNAPSHOT_SEQUENCE_EXHAUSTED);
|
||||
assert_eq!(error.context().len(), 1);
|
||||
assert_eq!(error.context()[0].key(), "sequence");
|
||||
assert_eq!(error.context()[0].value(), u64::MAX.to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_snapshot_preserves_exact_common_dimensions() {
|
||||
let id = crate::WorkerId::new("worker-001");
|
||||
let kind = crate::WorkerKindCode::new("example_worker");
|
||||
assert!(id.is_ok());
|
||||
assert!(kind.is_ok());
|
||||
let id = match id {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let kind = match kind {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let snapshot = crate::WorkerSnapshot::new(
|
||||
id,
|
||||
kind,
|
||||
crate::WorkerSnapshotSequence::initial(),
|
||||
crate::WorkerState::Running,
|
||||
crate::WorkerHealth::Healthy,
|
||||
crate::WorkerActivity::Active,
|
||||
);
|
||||
assert_eq!(snapshot.id().as_str(), "worker-001");
|
||||
assert_eq!(snapshot.kind().as_str(), "example_worker");
|
||||
assert_eq!(snapshot.sequence().value(), 0);
|
||||
assert_eq!(snapshot.state(), crate::WorkerState::Running);
|
||||
assert_eq!(snapshot.health(), crate::WorkerHealth::Healthy);
|
||||
assert_eq!(snapshot.activity(), crate::WorkerActivity::Active);
|
||||
return;
|
||||
}
|
||||
22
crates/ksp-worker-api/unit_tests/stop.rs
Normal file
22
crates/ksp-worker-api/unit_tests/stop.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
// file: crates/ksp-worker-api/unit_tests/stop.rs
|
||||
// version: 1
|
||||
|
||||
#[test]
|
||||
fn pre_002_stop_token_is_shared_and_idempotent() {
|
||||
let token = crate::WorkerStopToken::new();
|
||||
let clone = token.clone();
|
||||
assert!(!token.is_stop_requested());
|
||||
assert!(clone.request_stop());
|
||||
assert!(token.is_stop_requested());
|
||||
assert!(!token.request_stop());
|
||||
assert!(!clone.request_stop());
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_default_stop_token_starts_without_stop_request() {
|
||||
let token = crate::WorkerStopToken::default();
|
||||
assert!(!token.is_stop_requested());
|
||||
assert_eq!(std::format!("{token:?}"), "WorkerStopToken { stop_requested: false }");
|
||||
return;
|
||||
}
|
||||
21
crates/ksp-worker-raw-transaction-ingest-lib/Cargo.toml
Normal file
21
crates/ksp-worker-raw-transaction-ingest-lib/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
# file: crates/ksp-worker-raw-transaction-ingest-lib/Cargo.toml
|
||||
# version: 2
|
||||
|
||||
[package]
|
||||
name = "ksp-worker-raw-transaction-ingest-lib"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||
ksp-logging-lib = { path = "../ksp-logging-lib" }
|
||||
ksp-onchain-transport-lib = { path = "../ksp-onchain-transport-lib" }
|
||||
ksp-raw-transaction-lib = { path = "../ksp-raw-transaction-lib" }
|
||||
ksp-store-lib = { path = "../ksp-store-lib", default-features = false }
|
||||
ksp-worker-api = { path = "../ksp-worker-api" }
|
||||
sha2 = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
341
crates/ksp-worker-raw-transaction-ingest-lib/README.md
Normal file
341
crates/ksp-worker-raw-transaction-ingest-lib/README.md
Normal file
@@ -0,0 +1,341 @@
|
||||
<!-- file: crates/ksp-worker-raw-transaction-ingest-lib/README.md -->
|
||||
<!-- version: 12 -->
|
||||
|
||||
# ksp-worker-raw-transaction-ingest-lib
|
||||
|
||||
`ksp-worker-raw-transaction-ingest-lib` est le Worker concret KSP chargé de l'alimentation continue de la couche RAW Transaction.
|
||||
|
||||
La crate possède deux niveaux publics complémentaires :
|
||||
|
||||
```text
|
||||
RawTransactionIngestWorker::start
|
||||
-> fondation source-neutral, sans source productive
|
||||
|
||||
RawTransactionIngestWorker::start_with_runtime_resources
|
||||
-> même runtime + 1..32 sources productives supervisées simultanément
|
||||
Yellowstone, WS standard logsSubscribe, WS standard blockSubscribe, Helius transactionSubscribe et/ou HTTP block polling
|
||||
+ hydration HTTP getTransaction lorsque la source produit une référence
|
||||
```
|
||||
|
||||
Le Worker reste indépendant de Config et de tout backend Store physique. Le caller compose les ressources Transport et Store, puis les remet à la crate par ses façades publiques.
|
||||
|
||||
## Pipeline productif actuel
|
||||
|
||||
Les verticales live productives sont :
|
||||
|
||||
```text
|
||||
Yellowstone standard subscribe
|
||||
-> Transaction / TransactionStatus / Block
|
||||
-> signal source-neutral (network, signature, slot, commitment, provenance sûre)
|
||||
|
||||
Solana standard WS logsSubscribe
|
||||
-> context.slot + signature
|
||||
-> signal source-neutral (network, signature, slot, commitment, provenance sûre)
|
||||
-> coalescence bornée par (network, signature, commitment)
|
||||
-> HTTP getTransaction observed, Base64, maxSupportedTransactionVersion=1
|
||||
|
||||
Solana standard WS blockSubscribe
|
||||
-> Full + Base64 + maxSupportedTransactionVersion=1 + showRewards=false
|
||||
-> qualification explicite Legacy / V0 / V1
|
||||
-> matériau Common RAW direct par transaction du bloc
|
||||
|
||||
Helius transactionSubscribe
|
||||
-> Full + Base64 + maxSupportedTransactionVersion=1 + showRewards=false
|
||||
-> signature + slot + transactionIndex uniquement dans le signal Worker
|
||||
-> coalescence bornée par (network, signature, commitment)
|
||||
-> HTTP getTransaction observed avant Common RAW
|
||||
|
||||
Solana HTTP live block polling
|
||||
-> getSlot borne le run courant
|
||||
-> getBlocksWithLimit découvre les blocs live
|
||||
-> getBlock observed matérialise Full/Base64 Legacy/V0/V1
|
||||
-> matériau Common RAW direct par transaction du bloc
|
||||
|
||||
les chemins productifs
|
||||
-> ksp-raw-transaction-lib
|
||||
-> admission centrale bornée
|
||||
-> ksp-store-lib
|
||||
-> RawTransaction + RawTransactionObservation
|
||||
```
|
||||
|
||||
`BlockMeta` et `Slot` ne produisent pas de RAW directement. Ils servent uniquement à la projection de continuité du run.
|
||||
|
||||
La qualification reste conservative : même lorsqu'une update Yellowstone contient une transaction complète côté protobuf, le Worker hydrate actuellement les signaux transactionnels par HTTP `getTransaction` avant de construire le RAW canonique. Il n'existe donc pas de second canonicaliseur Yellowstone.
|
||||
|
||||
## Contrat de source Yellowstone + HTTP
|
||||
|
||||
`RawTransactionIngestYellowstoneSource::new` reçoit :
|
||||
|
||||
```text
|
||||
YellowstoneGrpcChannel
|
||||
YellowstoneSubscribeRequest
|
||||
HttpTransportPool
|
||||
HttpRoleName d'hydration
|
||||
```
|
||||
|
||||
La construction est sans I/O et refuse notamment :
|
||||
|
||||
- une requête Yellowstone invalide ;
|
||||
- l'absence de famille porteuse d'ingestion ;
|
||||
- un commitment `Processed` ou implicite ;
|
||||
- un réseau Yellowstone non représentable ;
|
||||
- une provenance provider/endpoint non représentable ;
|
||||
- l'absence d'une route HTTP compatible pour `getTransaction` sur le même réseau.
|
||||
|
||||
Le runtime-resource aggregate public accepte une collection validée de 1 à 32 sources logiques et les démarre simultanément sous un supervisor privé. La collection est validée entièrement avant spawn ; aucun sous-ensemble silencieux, source primaire implicite ou standby n'est choisi. La collection interne, les `source_key`, les URLs, les filtres et les clients inférieurs ne sont pas exposés.
|
||||
|
||||
Le supervisor possède toutes les tâches source. Une perte de source n'est plus assimilée automatiquement à un fault : lorsqu'elle porte une plage de continuité bornée, le Worker l'inscrit dans son ledger run-local puis n'autorise la continuation des siblings que si la coverage passée de cette perte est réconciliée et si les sources encore actives couvrent explicitement tout le `TargetCoverage` futur. Une perte sans plage sûre, une coverage insuffisante ou un gap encore ouvert reste terminal. Le Worker ne respawn jamais lui-même une source Transport.
|
||||
|
||||
Un inventaire privé `source_key -> latest processing/source state`, borné à 32 entrées, agrège la projection run-local. La frontier agrégée reste conservative : elle n'expose un `processing_frontier_slot` que lorsque toutes les sources en possèdent un, choisit le minimum des frontiers connus et le plus ancien pending. Les sources reference-bearing partagent en plus un registre global d'hydration borné : une même clé `(network, signature, commitment)` ne déclenche qu'un leader HTTP, puis chaque signal source conserve sa propre provenance lors de la finalisation.
|
||||
|
||||
## Contrat de source Standard Logs + HTTP
|
||||
|
||||
`RawTransactionIngestStandardLogsSource::new` reçoit :
|
||||
|
||||
```text
|
||||
WsEndpointSettings kind solana_standard
|
||||
SolanaLogsSubscribeFilter
|
||||
SolanaCommitment Confirmed ou Finalized
|
||||
HttpTransportPool
|
||||
HttpRoleName d'hydration
|
||||
```
|
||||
|
||||
La construction est sans I/O. Elle refuse un endpoint WS invalide ou non standard, `Processed`, un réseau/provenance non représentable et l'absence de route HTTP `getTransaction` compatible sur le même réseau. Le filtre `All`, `AllWithVotes` ou `Mentions(pubkey)` participe uniquement à une empreinte privée ; le pubkey d'un filtre `Mentions` n'est pas recopié dans `Debug`, snapshot ou provenance textuelle.
|
||||
|
||||
Au runtime, le Worker ouvre `SolanaStandardWsSession::connect`, puis `logs_subscribe`. Les lignes de logs et `err` restent dans Transport et ne sont jamais stockées dans le signal Worker. Seuls `context.slot` et `signature` sont projetés vers l'hydration commune. Reconnect, resubscribe et backpressure de la subscription restent possédés par Transport.
|
||||
|
||||
## Contrat de source Standard Block direct RAW
|
||||
|
||||
`RawTransactionIngestStandardBlockSource::new` reçoit :
|
||||
|
||||
```text
|
||||
WsEndpointSettings kind solana_standard
|
||||
SolanaBlockSubscribeFilter
|
||||
SolanaCommitment Confirmed ou Finalized
|
||||
```
|
||||
|
||||
Le runtime ouvre la `SolanaStandardWsSession` existante et demande exactement `Base64`, `Full`, `maxSupportedTransactionVersion = 1` et `showRewards = false`. Aucun `HttpTransportPool` n'est attaché à cette source : les transactions dont la version est explicitement `Legacy`, `0` ou `1` sont transformées directement en `RawTransactionMaterial` à partir du wire Base64 du bloc, avec signature embarquée, slot, block time, meta, version et index de transaction.
|
||||
|
||||
La qualification est fermée : une version omise/nulle ou supérieure à `1`, une transaction non Base64, `block: null`, une erreur distante de notification, un slot de contexte incohérent ou un champ transactions absent/nul termine la source par une erreur sûre. Aucun de ces cas n'est converti en bloc vide, en succès silencieux ou en progression artificielle de frontier.
|
||||
|
||||
Pour un bloc multi-transaction, le slot n'est projeté settled qu'après l'admission réussie de toutes ses transactions. Cette voie RAW-direct n'incrémente pas `hydration_pending`; un stop ou une erreur au milieu du bloc ne produit aucune progression artificielle. Reconnect, resubscribe et backpressure WebSocket restent possédés par Transport.
|
||||
|
||||
## Contrat de source Helius Transaction + HTTP
|
||||
|
||||
`RawTransactionIngestHeliusTransactionSource::new` reçoit :
|
||||
|
||||
```text
|
||||
WsEndpointSettings kind helius_laserstream
|
||||
HeliusTransactionSubscribeFilter
|
||||
SolanaCommitment Confirmed ou Finalized
|
||||
HttpTransportPool
|
||||
HttpRoleName d'hydration
|
||||
```
|
||||
|
||||
La construction est sans I/O. Elle réutilise le contrat Transport existant et impose `Full`, `Base64`, `showRewards = false` et `maxSupportedTransactionVersion = 1`. Le Worker ne lit pas Config, ne lit pas `KSP_SECRET_HELIUS_API_KEY` et ne code aucun tier provider ; l'URL résolue et le credential restent dans l'endpoint Transport fourni par le caller.
|
||||
|
||||
Au runtime, `HeliusLaserStreamWsSession::connect` puis `transaction_subscribe` sont utilisés. Seule une notification `Full` conforme au mode demandé est admise ; une forme `Signature`, `Unknown` ou future devient une faute source sûre. Le payload Helius `transaction` n'est jamais copié dans l'état Worker : la projection conserve uniquement signature, slot et transaction index, puis réutilise le coordinateur d'hydration commun `getTransaction observed`.
|
||||
|
||||
La clé logique Helius inclut réseau, identités provider/endpoint sûres, commitment et empreinte privée du filtre. Les listes de pubkeys du filtre sont normalisées avant hash afin que leur ordre ne crée pas artificiellement deux sources logiques ; le rôle HTTP d'hydration reste exclu de l'identité live.
|
||||
|
||||
## Contrat de source HTTP Block Polling
|
||||
|
||||
`RawTransactionIngestHttpBlockPollingSource::new` reçoit :
|
||||
|
||||
```text
|
||||
HttpTransportPool
|
||||
HttpRoleName de polling
|
||||
SolanaCommitment Confirmed ou Finalized
|
||||
```
|
||||
|
||||
La construction est sans I/O et vérifie que le rôle HTTP possède, sur un seul réseau, les capacités `getSlot`, `getBlocksWithLimit` et `getBlock`. La cadence est bornée entre 100 ms et 30 s, avec 1 s par défaut ; la découverte est bornée entre 1 et 1024 blocs par cycle, avec 128 par défaut. Ces réglages de cadence ne font pas partie de l'identité logique de la source.
|
||||
|
||||
Au démarrage, le premier `getSlot` fixe la borne inférieure du run. Le Worker ne demande aucun slot antérieur. Chaque cycle relit le tip, découvre les blocs disponibles avec `getBlocksWithLimit`, puis matérialise chaque slot listé par `getBlock observed` en `Full + Base64 + maxSupportedTransactionVersion = 1 + showRewards = false`. Seules les transactions Legacy/V0/V1 explicitement qualifiées entrent directement dans Common RAW.
|
||||
|
||||
Un slot listé dont `getBlock` retourne `null` reste la tête de reprise du cycle suivant ; il n'est ni considéré vide ni marqué settled. Les slots absents de la liste de découverte sont traités comme non produits/skipped pour ce run. Le slot n'est settled qu'après admission réussie de toutes ses transactions. Le polling reste run-local : aucun checkpoint durable, aucun scan avant la borne initiale et aucun Backfill implicite ne sont créés. Les retries/reroutages HTTP restent possédés par `ksp-onchain-transport-lib`.
|
||||
|
||||
## Runtime et lifecycle
|
||||
|
||||
Le Worker s'exécute sur le runtime Tokio courant du caller. Il ne crée pas de runtime global et n'expose aucun `JoinHandle` public.
|
||||
|
||||
`RawTransactionIngestHandle` permet de :
|
||||
|
||||
- demander un stop coopératif et idempotent ;
|
||||
- lire une source de snapshots concrets latest-value ;
|
||||
- utiliser la même source via `WorkerSnapshotSource` ;
|
||||
- attendre le terminal après drain et join des tâches possédées.
|
||||
|
||||
Le shutdown est borné par `shutdown_drain_timeout`. Le supervisor multi-source relaie le stop à toutes les sources et les rejoint avant de rendre son résultat au supervisor Worker ; les tâches source, hydration et persistence possédées sont ensuite drainées ou abort+join avant publication terminale. Si la deadline expire, l'abort du wrapper source détruit aussi son `JoinSet` interne et annule ses tâches imbriquées avant le terminal. Une faute déjà observée n'est pas remplacée par un stop concurrent, sauf le `drain_timeout` terminal lorsqu'une récupération bornée dépasse sa deadline. L'abandon terminal d'une hydration retire son pending run-local sans le convertir artificiellement en travail `settled`.
|
||||
|
||||
## Admission, coalescence et backpressure
|
||||
|
||||
La queue centrale est un `tokio::sync::mpsc` privé borné par `admission_queue_capacity`. Les sources internes subissent la backpressure ; aucune queue non bornée ni silent drop n'est autorisé.
|
||||
|
||||
Le Worker possède des coordinateurs source-neutral Yellowstone, Standard Logs et Helius Transaction branchés sur un registre global d'hydration partagé. Standard Block et HTTP Block Polling n'entrent pas dans ce registre lorsqu'une transaction est direct-qualified.
|
||||
|
||||
Les sources reference-bearing reçoivent des quotas déterministes dont la somme reste exactement dans les bornes techniques configurées :
|
||||
|
||||
```text
|
||||
somme pending source signals <= admission_queue_capacity
|
||||
somme hydration tasks in flight <= persistence_concurrency
|
||||
source reference-bearing active => quota pending >= 1 et quota in-flight >= 1
|
||||
```
|
||||
|
||||
Pour garantir simultanément ces bornes et l'absence de starvation structurelle, le démarrage échoue avant spawn si le nombre de sources reference-bearing dépasse `admission_queue_capacity` ou `persistence_concurrency`. Un sémaphore global protège en plus l'ouverture effective des hydrations HTTP.
|
||||
|
||||
Les signaux partageant le même `(network, signature, commitment)` sont coalescés cross-source avant le fan-out HTTP. La publication du résultat partagé notifie les followers sous le verrou de registry avant de retirer la clé : une nouvelle génération de leader ne peut donc pas s'intercaler entre retrait et notification. Après canonicalisation, une cache run-local bornée sérialise les acquisitions de même `(network, signature)` : la première passe par l'écriture atomique entity + observation, les suivantes de contenu canonique identique ajoutent uniquement leur observation déterministe. Une divergence de slot, block time, format ou hash canonique devient un content conflict terminal ; aucune majorité, préférence provider ou overwrite n'est appliqué. Le Store conserve son guard durable final.
|
||||
|
||||
Les retries/reroutages HTTP appartiennent à `ksp-onchain-transport-lib`. Le Worker ne possède pas une seconde boucle de retry autour de `getTransaction`.
|
||||
|
||||
Le trafic nominal et le trafic de réparation partagent le même registre global d'hydration, les mêmes permits, la même admission et la même persistence. Un gate de fairness privé alterne les deux classes lorsqu'elles attendent simultanément, sans réserver une fraction fixe de capacité ; une capacité existante de `1` doit donc encore permettre la progression des deux classes.
|
||||
|
||||
## Processing frontier run-local
|
||||
|
||||
Le snapshot expose :
|
||||
|
||||
```text
|
||||
hydration_pending
|
||||
processing_frontier_slot
|
||||
oldest_pending_slot
|
||||
```
|
||||
|
||||
Cette frontier mesure uniquement le traitement des signaux réellement observés pendant le run courant. Elle n'est ni un checkpoint durable, ni une preuve de complétude blockchain, ni un curseur de Backfill.
|
||||
|
||||
Un signal transactionnel devient pending après validation de sa clé d'hydration et insertion dans le coordinateur. Il devient settled pour la source lorsque :
|
||||
|
||||
```text
|
||||
getTransaction -> Missing
|
||||
ou
|
||||
getTransaction -> Available puis ingress envoyé avec succès vers l'admission centrale
|
||||
```
|
||||
|
||||
Un `BlockMeta` ou `Slot` continuity-only est settled localement sans produire de RAW. La frontier n'avance jamais à travers le plus ancien pending connu.
|
||||
|
||||
## Reconnect, replay, gaps et réparation run-local
|
||||
|
||||
Le reconnect/replay Yellowstone appartient à Transport. Le Worker n'écrit pas `from_slot`, ne traite pas directement `SubscribeReplayInfo` et ne transforme jamais une simple reconnexion en preuve de continuité.
|
||||
|
||||
Le Worker maintient séparément la processing frontier et une continuity frontier gap-aware. Les gaps sont des intervalles inclusifs bornés du run courant ; ils ne constituent ni une campagne historique ni une liste présumée de transactions manquantes. Les bornes internes sont :
|
||||
|
||||
```text
|
||||
open gaps <= 64
|
||||
range d'un gap <= 4096 slots
|
||||
discovery HTTP par fenêtre <= 512 slots
|
||||
getBlock logiques concurrents <= 4
|
||||
repair actif simultané <= 1
|
||||
```
|
||||
|
||||
Les mécanismes admissibles restent conservatifs : replay Transport lorsqu'il est réellement adressable, preuve de coverage d'une autre source compatible, scan HTTP borné, récupération de bloc produit et hydration d'une référence connue. Une réponse `getTransaction = null` reste une obligation manquante et `getBlock = null` pour un slot prouvé produit ne devient jamais une preuve d'absence.
|
||||
|
||||
Les types publics source-neutral `RawTransactionIngestGapId`, `RawTransactionIngestGapState`, `RawTransactionIngestGapReason`, `RawTransactionIngestRepairMethod` et `RawTransactionIngestGapSnapshot` permettent d'observer les gaps sans exposer `source_key`, provider, endpoint, filtre, signature ou payload. Le snapshot concret expose notamment :
|
||||
|
||||
```text
|
||||
gaps()
|
||||
open_gap_count()
|
||||
repairing_gap_count()
|
||||
repaired_gap_total()
|
||||
unresolved_gap_total()
|
||||
replay_repair_total()
|
||||
redundant_coverage_repair_total()
|
||||
http_scan_repair_total()
|
||||
repair_block_fetch_total()
|
||||
repair_transaction_hydration_total()
|
||||
oldest_open_gap_start_slot()
|
||||
```
|
||||
|
||||
La liste détaillée reste bornée ; tous les gaps ouverts sont retenus et les entrées réparées récentes peuvent occuper la capacité restante. Les compteurs utilisent une arithmétique checked.
|
||||
|
||||
Le snapshot conserve également les informations source-neutral suivantes :
|
||||
|
||||
```text
|
||||
source_total
|
||||
source_active
|
||||
source_reconnecting
|
||||
source_failed
|
||||
source_state
|
||||
source_failure_total
|
||||
backpressure_wait_total
|
||||
source_reconnect_total
|
||||
source_replay_attempt_total
|
||||
source_continuity_gap_total
|
||||
```
|
||||
|
||||
`RawTransactionIngestSourceState` distingue `Active`, `Reconnecting`, `Closing`, `Closed` et `Failed`. Un replay attempt, une redelivery de frontière et une coverage d'intervalle restent des preuves différentes.
|
||||
|
||||
La health publique est volontairement stricte dès que la policy de continuité est active : reconnect en cours, gap ouvert, continuity frontier différente de la processing frontier ou `TargetCoverage` futur non couvert donnent `Unhealthy`. `Healthy` exige toutes les sources attendues actives et aucune lacune de continuité. `Degraded` n'est permis qu'après perte de source explicitement réconciliée, lorsque les sources restantes couvrent encore tout le `TargetCoverage` futur.
|
||||
|
||||
Une source perdue peut donc rester absente uniquement si sa perte est bornée, si ses gaps sont fermés, si la continuity frontier rejoint la processing frontier et si la coverage future reste prouvée. Dans tous les autres cas, le Worker fault avec une erreur source sûre. Il ne lance jamais `ksp-job-backfill-lib` ni une campagne historique automatique.
|
||||
|
||||
## Persistence Store
|
||||
|
||||
La persistance passe exclusivement par `ksp-store-lib` avec `default-features = false`. Aucun backend physique n'est importé directement.
|
||||
|
||||
L'écriture utilise le mode normal atomique `RawTransaction + RawTransactionObservation`. Les outcomes distingués incluent :
|
||||
|
||||
```text
|
||||
entity inserted
|
||||
entity already present
|
||||
entity skipped purged
|
||||
observation inserted
|
||||
observation already present
|
||||
observation not recorded for purged entity
|
||||
content conflict
|
||||
store failure
|
||||
```
|
||||
|
||||
Un content conflict est terminal et n'est jamais converti en succès idempotent.
|
||||
|
||||
## Snapshots et erreurs
|
||||
|
||||
`RawTransactionIngestSnapshotSource` est latest-value : les lecteurs peuvent rater des transitions intermédiaires mais récupèrent toujours la dernière projection complète et monotone.
|
||||
|
||||
Les codes Worker publics sont :
|
||||
|
||||
```text
|
||||
worker_raw_transaction_ingest.settings_invalid
|
||||
worker_raw_transaction_ingest.runtime_invalid
|
||||
worker_raw_transaction_ingest.store_failed
|
||||
worker_raw_transaction_ingest.content_conflict
|
||||
worker_raw_transaction_ingest.counter_exhausted
|
||||
worker_raw_transaction_ingest.source_failed
|
||||
worker_raw_transaction_ingest.drain_timeout
|
||||
```
|
||||
|
||||
Les diagnostics et `Debug` ne recopient pas de payload RAW, signature, URL, credential, filtre provider, texte backend/provider arbitraire ou client inférieur. Les agrégations de compteurs de continuité multi-source utilisent une arithmétique vérifiée ; un overflow devient `counter_exhausted` au lieu d'être saturé silencieusement.
|
||||
|
||||
## Dépendances
|
||||
|
||||
Les dépendances normales sont exactement :
|
||||
|
||||
```text
|
||||
ksp-core-lib
|
||||
ksp-logging-lib
|
||||
ksp-onchain-transport-lib
|
||||
ksp-raw-transaction-lib
|
||||
ksp-store-lib (default-features = false)
|
||||
ksp-worker-api
|
||||
sha2
|
||||
tokio (macros, rt, sync, time)
|
||||
```
|
||||
|
||||
La crate ne dépend pas de Config, Job, `ksp-store-api` directement, backend Store concret, `reqwest`, `tonic`, `yellowstone-grpc-proto` ou Tauri.
|
||||
|
||||
## Hors périmètre
|
||||
|
||||
La verticale actuelle ne possède pas :
|
||||
|
||||
- sélection Config interne, reconfiguration dynamique ou policy de failover entre sources ;
|
||||
- checkpoint persistent de processing frontier ;
|
||||
- campagne de réparation historique automatique ;
|
||||
- application Desk ou process autonome ;
|
||||
- décodage STRUCTURAL/DECODED/DOMAIN.
|
||||
|
||||
Les futures extensions doivent conserver la séparation avec `ksp-job-backfill-lib` et réutiliser les mêmes contrats Common RAW/Store.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [`USAGE.md`](USAGE.md) — utilisation de la façade publique ;
|
||||
- [`../ksp-worker-api/README.md`](../ksp-worker-api/README.md) — contrats Worker génériques ;
|
||||
- [`../ksp-raw-transaction-lib/README.md`](../ksp-raw-transaction-lib/README.md) — canonicalisation RAW commune ;
|
||||
- [`../../docs/architecture/009-ACQUISITION_WORKERS_AND_JOBS.md`](../../docs/architecture/009-ACQUISITION_WORKERS_AND_JOBS.md) — séparation Worker/Job ;
|
||||
- [`../../docs/architecture/011-RAW_TRANSACTION_ACQUISITION.md`](../../docs/architecture/011-RAW_TRANSACTION_ACQUISITION.md) — architecture d'acquisition RawTransaction.
|
||||
430
crates/ksp-worker-raw-transaction-ingest-lib/USAGE.md
Normal file
430
crates/ksp-worker-raw-transaction-ingest-lib/USAGE.md
Normal file
@@ -0,0 +1,430 @@
|
||||
<!-- file: crates/ksp-worker-raw-transaction-ingest-lib/USAGE.md -->
|
||||
<!-- version: 12 -->
|
||||
|
||||
# Utilisation de ksp-worker-raw-transaction-ingest-lib
|
||||
|
||||
Cette page décrit la façade publique de `ksp-worker-raw-transaction-ingest-lib`. Le caller possède la composition du runtime Tokio, du `Store` et des ressources Transport ; le Worker ne lit pas Config, ne construit pas un backend physique et ne lit pas de secret depuis l'environnement.
|
||||
|
||||
## Construire les settings
|
||||
|
||||
Les identités sont validées par leurs couches propriétaires :
|
||||
|
||||
```rust
|
||||
fn worker_settings() -> ksp_core_lib::Result<ksp_worker_raw_transaction_ingest_lib::RawTransactionIngestSettings> {
|
||||
let network = match ksp_store_lib::RawNetworkId::new("mainnet") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let worker_id = match ksp_worker_api::WorkerId::new("raw-ingest-mainnet-0001") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(ksp_worker_raw_transaction_ingest_lib::RawTransactionIngestSettings::with_defaults(network, worker_id));
|
||||
}
|
||||
```
|
||||
|
||||
Le Worker kind est fixe :
|
||||
|
||||
```rust
|
||||
assert_eq!(ksp_worker_raw_transaction_ingest_lib::RAW_TRANSACTION_INGEST_WORKER_KIND_CODE, "raw_transaction_ingest");
|
||||
```
|
||||
|
||||
Pour des limites explicites :
|
||||
|
||||
```rust
|
||||
let settings = ksp_worker_raw_transaction_ingest_lib::RawTransactionIngestSettings::new(
|
||||
network,
|
||||
worker_id,
|
||||
512,
|
||||
16,
|
||||
std::time::Duration::from_secs(10),
|
||||
);
|
||||
```
|
||||
|
||||
Bornes publiques :
|
||||
|
||||
```text
|
||||
admission_queue_capacity 1 ..= 65_536 défaut 256
|
||||
persistence_concurrency 1 ..= 64 défaut 8
|
||||
shutdown_drain_timeout 100 ms ..= 30 s défaut 5 s
|
||||
```
|
||||
|
||||
Une valeur hors borne retourne `worker_raw_transaction_ingest.settings_invalid` avec uniquement le nom stable du champ invalide.
|
||||
|
||||
## Choisir le mode de démarrage
|
||||
|
||||
### Fondation sans source productive
|
||||
|
||||
`RawTransactionIngestWorker::start(settings, store)` démarre la fondation runtime sans source Transport. Ce mode reste utile aux tests/compositions qui veulent uniquement le lifecycle, les snapshots et le contrat de shutdown.
|
||||
|
||||
```rust
|
||||
let handle = match ksp_worker_raw_transaction_ingest_lib::RawTransactionIngestWorker::start(settings, store) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
```
|
||||
|
||||
### Source Yellowstone productive
|
||||
|
||||
Pour l'ingestion live, le caller compose d'abord les ressources via les crates propriétaires, puis construit :
|
||||
|
||||
```rust
|
||||
fn runtime_resources(
|
||||
yellowstone_channel: ksp_onchain_transport_lib::YellowstoneGrpcChannel,
|
||||
subscribe_request: ksp_onchain_transport_lib::YellowstoneSubscribeRequest,
|
||||
http_pool: ksp_onchain_transport_lib::HttpTransportPool,
|
||||
hydration_role: ksp_onchain_transport_lib::HttpRoleName,
|
||||
) -> ksp_core_lib::Result<ksp_worker_raw_transaction_ingest_lib::RawTransactionIngestRuntimeResources> {
|
||||
let source = match ksp_worker_raw_transaction_ingest_lib::RawTransactionIngestYellowstoneSource::new(
|
||||
yellowstone_channel,
|
||||
subscribe_request,
|
||||
http_pool,
|
||||
hydration_role,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(ksp_worker_raw_transaction_ingest_lib::RawTransactionIngestRuntimeResources::new(source));
|
||||
}
|
||||
```
|
||||
|
||||
Puis :
|
||||
|
||||
```rust
|
||||
let handle = match ksp_worker_raw_transaction_ingest_lib::RawTransactionIngestWorker::start_with_runtime_resources(
|
||||
settings,
|
||||
store,
|
||||
runtime_resources,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
```
|
||||
|
||||
Les deux entrées exigent un runtime Tokio courant et un `Store` portant exactement le même `RawNetworkId` que les settings. Le démarrage avec ressources exige également que chaque source composée cible ce même réseau. L'agrégat accepte 1 à 32 sources logiques et les lance simultanément ; les doublons d'identité et les mélanges de réseaux sont refusés avant spawn. Il n'existe pas de source primaire, standby ou fallback implicite : toute source configurée fait partie du run.
|
||||
|
||||
### Composer plusieurs sources simultanées
|
||||
|
||||
Une fois un premier `RawTransactionIngestRuntimeResources` construit, le caller ajoute les autres sources avec les méthodes `try_push_*` correspondant à leur capability. Exemple conceptuel :
|
||||
|
||||
```rust
|
||||
let mut resources = ksp_worker_raw_transaction_ingest_lib::RawTransactionIngestRuntimeResources::new(yellowstone_source);
|
||||
if let std::result::Result::Err(error) = resources.try_push_standard_logs_source(standard_logs_source) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) = resources.try_push_http_block_polling_source(http_polling_source) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
```
|
||||
|
||||
La validation est transactionnelle à chaque ajout : limite globale 32, réseau unique et `source_key` logique unique. Au démarrage, toutes les sources présentes sont supervisées ensemble. Une défaillance de source ne permet la continuation des siblings que lorsqu'une plage de perte sûre est disponible, que cette perte est entièrement réconciliée et que les sources encore actives prouvent tout le `TargetCoverage` futur. Sinon le Worker arrête et joint les autres sources. Aucune équivalence n'est déduite du seul provider, protocole ou nom de filtre.
|
||||
|
||||
### Source Standard Logs productive
|
||||
|
||||
Une source standard Solana WS se compose ainsi :
|
||||
|
||||
```rust
|
||||
fn standard_logs_runtime_resources(
|
||||
ws_endpoint: ksp_onchain_transport_lib::WsEndpointSettings,
|
||||
filter: ksp_onchain_transport_lib::SolanaLogsSubscribeFilter,
|
||||
commitment: ksp_onchain_transport_lib::SolanaCommitment,
|
||||
http_pool: ksp_onchain_transport_lib::HttpTransportPool,
|
||||
hydration_role: ksp_onchain_transport_lib::HttpRoleName,
|
||||
) -> ksp_core_lib::Result<ksp_worker_raw_transaction_ingest_lib::RawTransactionIngestRuntimeResources> {
|
||||
let source = match ksp_worker_raw_transaction_ingest_lib::RawTransactionIngestStandardLogsSource::new(
|
||||
ws_endpoint,
|
||||
filter,
|
||||
commitment,
|
||||
http_pool,
|
||||
hydration_role,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(ksp_worker_raw_transaction_ingest_lib::RawTransactionIngestRuntimeResources::from_standard_logs_source(source));
|
||||
}
|
||||
```
|
||||
|
||||
`ws_endpoint` doit être un endpoint Transport valide de kind `solana_standard`. Le commitment doit être explicitement `Confirmed` ou `Finalized`. Le pool HTTP doit exposer `getTransaction` via le rôle indiqué sur le même réseau. `All`, `AllWithVotes` et `Mentions(pubkey)` sont acceptés par le contrat Transport ; la valeur du filtre reste privée dans le Worker.
|
||||
|
||||
### Source Standard Block productive
|
||||
|
||||
Une source `blockSubscribe` standard se compose sans pool HTTP :
|
||||
|
||||
```rust
|
||||
fn standard_block_runtime_resources(
|
||||
ws_endpoint: ksp_onchain_transport_lib::WsEndpointSettings,
|
||||
filter: ksp_onchain_transport_lib::SolanaBlockSubscribeFilter,
|
||||
commitment: ksp_onchain_transport_lib::SolanaCommitment,
|
||||
) -> ksp_core_lib::Result<ksp_worker_raw_transaction_ingest_lib::RawTransactionIngestRuntimeResources> {
|
||||
let source = match ksp_worker_raw_transaction_ingest_lib::RawTransactionIngestStandardBlockSource::new(ws_endpoint, filter, commitment) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(ksp_worker_raw_transaction_ingest_lib::RawTransactionIngestRuntimeResources::from_standard_block_source(source));
|
||||
}
|
||||
```
|
||||
|
||||
Le Worker demande `Base64`, `Full`, `maxSupportedTransactionVersion = 1` et `showRewards = false`. Une transaction n'est RAW-direct que si sa version est explicitement `Legacy`, `0` ou `1`. Une version omise/nulle ou supérieure, `block: null`, une erreur de bloc, un champ transactions absent/nul ou une transaction non Base64 provoque une faute sûre ; ces cas ne sont jamais assimilés à une progression vide.
|
||||
|
||||
### Source Helius Transaction productive
|
||||
|
||||
Une source Helius LaserStream `transactionSubscribe` se compose avec le même pattern caller-owned :
|
||||
|
||||
```rust
|
||||
fn helius_transaction_runtime_resources(
|
||||
ws_endpoint: ksp_onchain_transport_lib::WsEndpointSettings,
|
||||
filter: ksp_onchain_transport_lib::HeliusTransactionSubscribeFilter,
|
||||
commitment: ksp_onchain_transport_lib::SolanaCommitment,
|
||||
http_pool: ksp_onchain_transport_lib::HttpTransportPool,
|
||||
hydration_role: ksp_onchain_transport_lib::HttpRoleName,
|
||||
) -> ksp_core_lib::Result<ksp_worker_raw_transaction_ingest_lib::RawTransactionIngestRuntimeResources> {
|
||||
let source = match ksp_worker_raw_transaction_ingest_lib::RawTransactionIngestHeliusTransactionSource::new(
|
||||
ws_endpoint,
|
||||
filter,
|
||||
commitment,
|
||||
http_pool,
|
||||
hydration_role,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(ksp_worker_raw_transaction_ingest_lib::RawTransactionIngestRuntimeResources::from_helius_transaction_source(source));
|
||||
}
|
||||
```
|
||||
|
||||
`ws_endpoint` doit être un endpoint Transport de kind `helius_laserstream`. Le caller supérieur résout éventuellement `KSP_SECRET_HELIUS_API_KEY` via Config avant de construire l'endpoint ; le Worker ne lit jamais l'environnement ni Config. Le commitment est limité à `Confirmed`/`Finalized` et la route HTTP doit supporter `getTransaction` sur le même réseau.
|
||||
|
||||
Le Worker demande la forme Helius `Full` avec `Base64`, `showRewards = false` et `maxSupportedTransactionVersion = 1`, mais ne fait pas confiance au nested payload pour construire directement le Common RAW. Il conserve seulement signature/slot/index et hydrate par `getTransaction observed`. Une notification d'une autre forme est fail-closed.
|
||||
|
||||
### Source HTTP Block Polling productive
|
||||
|
||||
Une source HTTP live peut être composée sans WebSocket ni gRPC :
|
||||
|
||||
```rust
|
||||
fn http_block_polling_runtime_resources(
|
||||
http_pool: ksp_onchain_transport_lib::HttpTransportPool,
|
||||
polling_role: ksp_onchain_transport_lib::HttpRoleName,
|
||||
commitment: ksp_onchain_transport_lib::SolanaCommitment,
|
||||
) -> ksp_core_lib::Result<ksp_worker_raw_transaction_ingest_lib::RawTransactionIngestRuntimeResources> {
|
||||
let source = match ksp_worker_raw_transaction_ingest_lib::RawTransactionIngestHttpBlockPollingSource::new(
|
||||
http_pool,
|
||||
polling_role,
|
||||
commitment,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(ksp_worker_raw_transaction_ingest_lib::RawTransactionIngestRuntimeResources::from_http_block_polling_source(source));
|
||||
}
|
||||
```
|
||||
|
||||
Le rôle HTTP doit disposer, sur un même réseau, de `getSlot`, `getBlocksWithLimit` et `getBlock`. Le commitment est limité à `Confirmed`/`Finalized`. Par défaut, le Worker interroge toutes les secondes et borne la découverte à 128 blocs par cycle. `new_with_limits` permet de choisir une cadence entre 100 ms et 30 s et une limite entre 1 et 1024 blocs par cycle ; les limites de débit physiques restent celles de Transport.
|
||||
|
||||
Au démarrage du run, la première valeur `getSlot(commitment)` devient la borne inférieure stricte du poller. Il ne demande jamais de slot antérieur. `getBlocksWithLimit` détermine les slots réellement matérialisables, puis `getBlock observed` produit directement le Common RAW en Full/Base64 pour Legacy/V0/V1. Un slot listé dont `getBlock` retourne `null` reste la tête de reprise du prochain cycle et n'est jamais transformé en progression silencieuse.
|
||||
|
||||
## Préparer la source Yellowstone
|
||||
|
||||
La `YellowstoneSubscribeRequest` doit :
|
||||
|
||||
- être valide selon Transport ;
|
||||
- contenir au moins une famille transaction-bearing admise par le Worker ;
|
||||
- utiliser explicitement `Confirmed` ou `Finalized` ;
|
||||
- rester compatible avec le réseau du `YellowstoneGrpcChannel`.
|
||||
|
||||
Le `HttpTransportPool` doit posséder au moins une route compatible avec le rôle d'hydration et `getTransaction` sur le même réseau. La construction de `RawTransactionIngestYellowstoneSource` vérifie ces invariants sans ouvrir la connexion réseau.
|
||||
|
||||
Le caller ne passe pas de signature, `program_id`, plage de slots ou limite historique au Worker. Ces paramètres appartiennent à un Job Backfill, pas au service continu.
|
||||
|
||||
## Comprendre la pipeline live
|
||||
|
||||
Les familles productives sont traitées ainsi :
|
||||
|
||||
```text
|
||||
Yellowstone Transaction -> signal -> HTTP getTransaction -> Common RAW -> admission
|
||||
Yellowstone TransactionStatus -> signal -> HTTP getTransaction -> Common RAW -> admission
|
||||
Yellowstone Block -> un signal par transaction -> HTTP getTransaction -> Common RAW -> admission
|
||||
Standard WS logsSubscribe -> context.slot + signature -> HTTP getTransaction -> Common RAW -> admission
|
||||
Standard WS blockSubscribe -> Full/Base64 Legacy|V0|V1 -> Common RAW direct par transaction -> admission
|
||||
Helius transactionSubscribe -> Full envelope -> signature/slot/index -> HTTP getTransaction -> Common RAW -> admission
|
||||
HTTP live block polling -> getSlot -> getBlocksWithLimit -> getBlock observed -> Common RAW direct Legacy|V0|V1 -> admission
|
||||
Yellowstone BlockMeta -> continuity-only
|
||||
Yellowstone Slot -> continuity-only
|
||||
Yellowstone Account/Ping/Pong/Entry -> sans RAW Transaction dans cette verticale
|
||||
```
|
||||
|
||||
Les signaux de même `(network, signature, commitment)` sont coalescés globalement avant l'hydration HTTP. Si plusieurs sources produisent ensuite la même transaction canonique, le Worker conserve une seule entité RAW et enregistre séparément les observations déterministes propres à chaque source. Le Worker ne possède pas une boucle de retry HTTP : reroutage/retry/backoff restent dans `ksp-onchain-transport-lib`.
|
||||
|
||||
Les sources qui nécessitent `getTransaction` partagent des quotas bornés et déterministes. Pour une composition valide :
|
||||
|
||||
```text
|
||||
nombre de sources avec hydration <= admission_queue_capacity
|
||||
nombre de sources avec hydration <= persistence_concurrency
|
||||
somme de leurs pending <= admission_queue_capacity
|
||||
somme de leurs tâches d'hydration actives <= persistence_concurrency
|
||||
```
|
||||
|
||||
Ces contraintes garantissent au moins une part à chaque source reference-bearing sans introduire de scheduler pondéré. Si les settings ne permettent pas cette répartition, le démarrage échoue avant spawn avec `runtime_invalid`; le caller doit augmenter la capacité concernée ou réduire le nombre de sources nécessitant une hydration.
|
||||
|
||||
Pour une même identité `(network, signature)`, une divergence canonique de slot, block time, format ou hash est un `content_conflict`. Le Worker ne choisit ni majorité ni provider préféré et n'écrase pas un contenu divergent ; le Store reste l'autorité durable finale du conflit.
|
||||
|
||||
## Observer le snapshot concret
|
||||
|
||||
```rust
|
||||
let source = handle.snapshot_source();
|
||||
let current = source.current();
|
||||
|
||||
let sequence = current.worker_snapshot().sequence();
|
||||
let state = current.worker_snapshot().state();
|
||||
let queue_depth = current.admission_queue_depth();
|
||||
let in_flight = current.in_flight_persistence();
|
||||
let hydration_pending = current.hydration_pending();
|
||||
let frontier = current.processing_frontier_slot();
|
||||
let oldest_pending = current.oldest_pending_slot();
|
||||
let source_total = current.source_total();
|
||||
let source_active = current.source_active();
|
||||
let source_reconnecting = current.source_reconnecting();
|
||||
let source_failed = current.source_failed();
|
||||
let source_state = current.source_state();
|
||||
```
|
||||
|
||||
Pour attendre une valeur plus récente :
|
||||
|
||||
```rust
|
||||
let observed = source.current().worker_snapshot().sequence();
|
||||
let newer = source.wait_for_change(observed).await;
|
||||
assert!(newer.worker_snapshot().sequence().is_after(observed));
|
||||
```
|
||||
|
||||
Le flux est latest-value : les transitions intermédiaires peuvent être coalescées. Ne pas l'utiliser comme journal exhaustif.
|
||||
|
||||
## Utiliser la projection Worker API
|
||||
|
||||
La même source implémente `ksp_worker_api::WorkerSnapshotSource` :
|
||||
|
||||
```rust
|
||||
let source = handle.worker_snapshot_source();
|
||||
let current = ksp_worker_api::WorkerSnapshotSource::current(&source);
|
||||
let observed = current.sequence();
|
||||
let newer = ksp_worker_api::WorkerSnapshotSource::wait_for_change(&source, observed).await;
|
||||
assert!(newer.sequence().is_after(observed));
|
||||
```
|
||||
|
||||
La projection commune contient seulement les dimensions génériques Worker. Les compteurs et frontiers spécifiques restent sur `RawTransactionIngestSnapshot`.
|
||||
|
||||
## Lire les compteurs
|
||||
|
||||
```rust
|
||||
let snapshot = handle.snapshot_source().current();
|
||||
|
||||
let admitted = snapshot.admitted_total();
|
||||
let canonicalized = snapshot.canonicalized_total();
|
||||
let persisted = snapshot.persisted_total();
|
||||
let inserted = snapshot.entity_inserted_total();
|
||||
let existing = snapshot.entity_already_present_total();
|
||||
let purged = snapshot.entity_skipped_purged_total();
|
||||
let observations = snapshot.observation_inserted_total();
|
||||
let conflicts = snapshot.content_conflict_total();
|
||||
let store_failures = snapshot.store_failure_total();
|
||||
let source_failures = snapshot.source_failure_total();
|
||||
let backpressure = snapshot.backpressure_wait_total();
|
||||
let reconnects = snapshot.source_reconnect_total();
|
||||
let replay_attempts = snapshot.source_replay_attempt_total();
|
||||
let proven_gaps = snapshot.source_continuity_gap_total();
|
||||
let gaps = snapshot.gaps();
|
||||
let open_gaps = snapshot.open_gap_count();
|
||||
let repairing_gaps = snapshot.repairing_gap_count();
|
||||
let repaired_gaps = snapshot.repaired_gap_total();
|
||||
let unresolved_gaps = snapshot.unresolved_gap_total();
|
||||
let oldest_gap = snapshot.oldest_open_gap_start_slot();
|
||||
```
|
||||
|
||||
`admission_queue_depth()`, `in_flight_persistence()`, `hydration_pending()`, `source_total()`, `source_active()`, `source_reconnecting()` et `source_failed()` sont des gauges latest-value. Les compteurs cumulés et les agrégats multi-source ne wrapent ni ne saturent silencieusement ; l'épuisement est terminal avec `worker_raw_transaction_ingest.counter_exhausted`.
|
||||
|
||||
## Interpréter la processing frontier
|
||||
|
||||
`processing_frontier_slot()` est la plus haute slot de travail source réellement observé qui n'est pas bloquée par un pending plus ancien connu. `oldest_pending_slot()` expose ce plus ancien pending lorsqu'il existe.
|
||||
|
||||
Cette frontier est strictement run-local :
|
||||
|
||||
```text
|
||||
elle ne prouve pas que toutes les transactions blockchain d'une slot ont été observées
|
||||
elle ne prouve pas la persistence durable des ingress déjà envoyés à l'admission
|
||||
elle n'est pas persistée entre deux runs
|
||||
elle n'est pas un checkpoint Backfill
|
||||
```
|
||||
|
||||
Un `Missing` HTTP règle le signal du point de vue source-processing sans créer de RAW. Un ingress `Available` n'est réglé qu'après envoi réussi vers l'admission centrale. Une hydration abandonnée au shutdown est retirée des pending sans faire avancer artificiellement la frontier.
|
||||
|
||||
## Interpréter reconnect et replay
|
||||
|
||||
`source_state()` peut retourner `Active`, `Reconnecting`, `Closing`, `Closed` ou `Failed` comme état agrégé source-neutral. Les gauges `source_total()`, `source_active()`, `source_reconnecting()` et `source_failed()` permettent d'interpréter une composition multi-source sans exposer provider, endpoint, filtre ou `source_key`.
|
||||
|
||||
Lorsque la policy de continuité est active, la health commune est plus stricte qu'une simple lecture des états source. Pendant `Running`, reconnect en cours, gap ouvert, continuity frontier différente de la processing frontier ou `TargetCoverage` futur non couvert donnent `Unhealthy`. Toutes les sources attendues `Active` avec continuité réconciliée permettent `Healthy`. Une source `Failed` ne donne `Degraded` que si sa perte est entièrement réconciliée et si les sources restantes couvrent encore tout le `TargetCoverage` futur. Une transition Worker `Faulted` reste `Unhealthy`.
|
||||
|
||||
Les compteurs ont des sémantiques distinctes :
|
||||
|
||||
```text
|
||||
source_reconnect_total reconnects automatiques réussis observés
|
||||
source_replay_attempt_total tentatives de reconnect portant une demande de replay
|
||||
source_continuity_gap_total gaps de rétention prouvés par Transport
|
||||
```
|
||||
|
||||
Une tentative de replay n'est pas une preuve de continuité. Le Worker ne choisit pas `from_slot` et ne traite pas directement `SubscribeReplayInfo` ; ces mécanismes appartiennent à Transport.
|
||||
|
||||
Un gap est projeté via `gaps()` avec un état `Pending`, `Repairing`, `Repaired` ou `Unresolved`, une raison source-neutral et éventuellement la dernière méthode de réparation. Les mécanismes publics décrits par `RawTransactionIngestRepairMethod` sont `Replay`, `RedundantCoverage`, `HttpScan`, `BlockFetch` et `TransactionHydration`.
|
||||
|
||||
Une perte source ou un gap de rétention ne déclenche jamais `ksp-job-backfill-lib`. Le Worker continue seulement lorsqu'il peut prouver la réconciliation passée et la coverage future dans les bornes du run courant ; sinon il fault.
|
||||
|
||||
## Demander un stop et attendre le terminal
|
||||
|
||||
```rust
|
||||
let accepted = handle.request_stop();
|
||||
let terminal = handle.wait_terminal().await;
|
||||
|
||||
match terminal {
|
||||
std::result::Result::Ok(state) => {
|
||||
assert!(state.is_terminal());
|
||||
}
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
|
||||
if accepted {
|
||||
// La première demande de stop a été remise au runtime vivant.
|
||||
}
|
||||
```
|
||||
|
||||
`request_stop()` est idempotent. Le terminal n'est publié qu'après le drain borné et la récupération des tâches possédées. Si la deadline de drain expire, toutes les tâches source/persistence encore possédées sont abortées puis jointes avant publication terminale ; une persistence libérée après ce terminal ne peut donc pas produire une complétion tardive. Une faute source déjà observée reste prioritaire face à un stop concurrent, sauf si le drain lui-même expire et devient le terminal `drain_timeout`.
|
||||
|
||||
## Interpréter les faults
|
||||
|
||||
```text
|
||||
settings_invalid settings techniques hors contrat
|
||||
runtime_invalid invariant runtime/lifecycle impossible
|
||||
store_failed erreur Store non-conflict classifiée
|
||||
content_conflict contenu canonique incompatible avec l'identité durable
|
||||
counter_exhausted compteur/séquence monotone arrivé à sa borne
|
||||
source_failed source/replay/hydration terminée par une erreur classifiée
|
||||
drain_timeout drain de shutdown hors deadline
|
||||
```
|
||||
|
||||
Un consumer doit traiter l'`ErrorCode` comme contrat stable et ne pas dépendre d'un texte backend/provider arbitraire.
|
||||
|
||||
## Composition supérieure
|
||||
|
||||
Le pattern attendu est :
|
||||
|
||||
```text
|
||||
Config / application / service owner
|
||||
-> résout endpoints, credentials et rôles
|
||||
-> construit une source Transport Yellowstone, Standard Logs, Standard Block, Helius Transaction ou HTTP Block Polling
|
||||
-> construit HttpTransportPool + rôle HTTP adapté à la source
|
||||
-> construit le Store
|
||||
-> construit RawTransactionIngestRuntimeResources
|
||||
-> construit RawTransactionIngestSettings
|
||||
-> démarre RawTransactionIngestWorker::start_with_runtime_resources
|
||||
-> observe RawTransactionIngestSnapshotSource
|
||||
-> demande stop lorsque nécessaire
|
||||
```
|
||||
|
||||
Le Worker ne reçoit pas de requête historique métier et ne dépend pas de Config. Une campagne `signature/program_id/plage/limite` appartient à `ksp-job-backfill-lib`.
|
||||
110
crates/ksp-worker-raw-transaction-ingest-lib/src/admission.rs
Normal file
110
crates/ksp-worker-raw-transaction-ingest-lib/src/admission.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
// file: crates/ksp-worker-raw-transaction-ingest-lib/src/admission.rs
|
||||
// version: 3
|
||||
|
||||
use sha2::Digest; // rust-rules: trait-import
|
||||
|
||||
const RAW_TRANSACTION_INGEST_OBSERVATION_DOMAIN: &[u8] = b"ksp.raw_transaction_ingest.observation.v1\0";
|
||||
|
||||
/// Crate-private source-neutral ingress admitted by the central bounded Worker queue.
|
||||
pub(crate) struct RawTransactionIngress {
|
||||
/// Complete Common RAW material supplied by one private source task.
|
||||
pub(crate) material: ksp_raw_transaction_lib::RawTransactionMaterial,
|
||||
/// Logical network expected to match both Worker settings and canonical material.
|
||||
pub(crate) network: ksp_store_lib::RawNetworkId,
|
||||
/// Safe source-independent provenance attached to the acquisition observation.
|
||||
pub(crate) provenance: ksp_store_lib::RawAcquisitionProvenance,
|
||||
/// Opaque deterministic source-owned key material used only for Worker observation-key derivation.
|
||||
pub(crate) source_key: [u8; 32],
|
||||
}
|
||||
|
||||
/// Receiver side of the bounded central RAW transaction admission queue owned by the Worker supervisor.
|
||||
pub(crate) struct RawTransactionAdmission {
|
||||
backpressure_wait_observed: bool,
|
||||
capacity: usize,
|
||||
receiver: tokio::sync::mpsc::Receiver<crate::RawTransactionIngress>,
|
||||
}
|
||||
|
||||
impl crate::RawTransactionAdmission {
|
||||
/// Creates one bounded admission queue and returns its crate-private source sender.
|
||||
#[must_use]
|
||||
pub(crate) fn new(capacity: usize) -> (crate::RawTransactionAdmission, tokio::sync::mpsc::Sender<crate::RawTransactionIngress>) {
|
||||
let (sender, receiver) = tokio::sync::mpsc::channel(capacity);
|
||||
return (Self { backpressure_wait_observed: false, capacity, receiver }, sender);
|
||||
}
|
||||
|
||||
/// Returns the latest receiver-side queue depth without consuming an ingress entry.
|
||||
#[must_use]
|
||||
pub(crate) fn queue_depth(&self) -> usize {
|
||||
return self.receiver.len();
|
||||
}
|
||||
|
||||
/// Closes new admissions while preserving already queued ingress for deterministic drain.
|
||||
pub(crate) fn close(&mut self) {
|
||||
self.receiver.close();
|
||||
return;
|
||||
}
|
||||
|
||||
/// Receives and converts one queued ingress into a common RAW transaction acquisition.
|
||||
pub(crate) async fn receive(
|
||||
&mut self,
|
||||
expected_network: &ksp_store_lib::RawNetworkId,
|
||||
) -> ksp_core_lib::Result<std::option::Option<ksp_raw_transaction_lib::RawTransactionAcquisition>> {
|
||||
self.backpressure_wait_observed = self.receiver.len() == self.capacity;
|
||||
let ingress = match self.receiver.recv().await {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Ok(std::option::Option::None),
|
||||
};
|
||||
let acquisition = canonicalize_ingress(expected_network, ingress);
|
||||
return match acquisition {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(std::option::Option::Some(value)),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
};
|
||||
}
|
||||
|
||||
/// Takes and clears the source-neutral signal that the previous dequeue observed the bounded queue at full capacity.
|
||||
#[must_use]
|
||||
pub(crate) fn take_backpressure_wait_observed(&mut self) -> bool {
|
||||
let observed = self.backpressure_wait_observed;
|
||||
self.backpressure_wait_observed = false;
|
||||
return observed;
|
||||
}
|
||||
}
|
||||
|
||||
fn canonicalize_ingress(
|
||||
expected_network: &ksp_store_lib::RawNetworkId,
|
||||
ingress: crate::RawTransactionIngress,
|
||||
) -> ksp_core_lib::Result<ksp_raw_transaction_lib::RawTransactionAcquisition> {
|
||||
if &ingress.network != expected_network {
|
||||
return std::result::Result::Err(crate::runtime_error("admission.network_mismatch"));
|
||||
}
|
||||
let transaction = ksp_raw_transaction_lib::canonicalize_raw_transaction(ingress.material);
|
||||
let transaction = match transaction {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(crate::runtime_error("admission.canonicalization_invalid")),
|
||||
};
|
||||
if transaction.reference().network() != expected_network {
|
||||
return std::result::Result::Err(crate::runtime_error("admission.material_network_mismatch"));
|
||||
}
|
||||
let observation_key = observation_key(transaction.reference(), &ingress.source_key);
|
||||
return std::result::Result::Ok(ksp_raw_transaction_lib::assemble_raw_transaction_acquisition(transaction, observation_key, ingress.provenance));
|
||||
}
|
||||
|
||||
fn hash_bytes(hasher: &mut sha2::Sha256, value: &[u8]) {
|
||||
hasher.update((value.len() as u64).to_be_bytes());
|
||||
hasher.update(value);
|
||||
return;
|
||||
}
|
||||
|
||||
fn observation_key(reference: &ksp_store_lib::RawTransactionReference, source_key: &[u8; 32]) -> ksp_store_lib::RawObservationKey {
|
||||
let mut hasher = sha2::Sha256::new();
|
||||
hasher.update(RAW_TRANSACTION_INGEST_OBSERVATION_DOMAIN);
|
||||
hash_bytes(&mut hasher, reference.network().as_str().as_bytes());
|
||||
hash_bytes(&mut hasher, reference.signature().as_bytes());
|
||||
hash_bytes(&mut hasher, source_key);
|
||||
let bytes: [u8; 32] = hasher.finalize().into();
|
||||
return ksp_store_lib::RawObservationKey::new(bytes);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/admission.rs"]
|
||||
mod tests;
|
||||
1238
crates/ksp-worker-raw-transaction-ingest-lib/src/continuity.rs
Normal file
1238
crates/ksp-worker-raw-transaction-ingest-lib/src/continuity.rs
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user