Compare commits
203 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3de0488cab | |||
| 2460162243 | |||
| 1b9104d64b | |||
| 80be36bfdd | |||
| e8d2382ac3 | |||
| 749e53edc2 | |||
| 7377a6f6cd | |||
| 45601ce386 | |||
| 7d490eb5d8 | |||
| ca018095bf | |||
| 0dd722ffca | |||
| 75e8030b07 | |||
| c24e4c48f8 | |||
| c7cfb9557d | |||
| c27428d6c0 | |||
| dfa01b1381 | |||
| 5fc7f65b2c | |||
| bbf804b7fc | |||
| 13687ec2fe | |||
| b756da5149 | |||
| 04234926ce | |||
| bfc69f5631 | |||
| 902b5fba99 | |||
| fccb7d876c | |||
| 50d4142797 | |||
| eb6dbc31e8 | |||
| fb8b585aa2 | |||
| dad18c16d5 | |||
| a704a5722e | |||
| dfa3723a7f | |||
| 6bd593f467 | |||
| 03ad0ba063 | |||
| d95b1095e5 | |||
| bcce89f076 | |||
| 66d5b03495 | |||
| 7af751c888 | |||
| 9f2d5ea704 | |||
| 8428b75b96 | |||
| 282b616a0e | |||
| 12eaf7a3de | |||
| f28e4d87f0 | |||
| ba2e52f841 | |||
| 5f1938c7c3 | |||
| 5e2f959c01 | |||
| 8da04717b6 | |||
| c094828704 | |||
| ce658e58f7 | |||
| 7f0a642972 | |||
| 84ab2b3651 | |||
| 1f0b202135 | |||
| aa56a12846 | |||
| 10996f11f7 | |||
| 1352a61d70 | |||
| 66a3926adb | |||
| 917e602a87 | |||
| 56fadb364a | |||
| 8fa1c8ce8d | |||
| c17e78c6a8 | |||
| 61bf7ba468 | |||
| 9712c7e1f7 | |||
| a560df80ce | |||
| bb56574824 | |||
| 8363c8bdc9 | |||
| b375f263dc | |||
| e1fe419028 | |||
| 9ec4f26874 | |||
| 80eea82398 | |||
| e39cf656b2 | |||
| c4f56d9e85 | |||
| c1dbaad88d | |||
| d93184d41d | |||
| de3cec6a23 | |||
| 0a4cddafc4 | |||
| 6d2b1401aa | |||
| 0a8fb5dd1b | |||
| a8c90107b5 | |||
| ced653bfc0 | |||
| 153014be9d | |||
| 8d4b3b67fb | |||
| 6cc94ebb46 | |||
| 8e8f1f0b4a | |||
| 78e015413b | |||
| 2f0eb316f5 | |||
| 55325ffd32 | |||
| ad02b6f13c | |||
| 47b12e14e6 | |||
| c43d4d7c38 | |||
| b76baf6835 | |||
| c83e3261d0 | |||
| 28ca5bdac5 | |||
| afd4c770f9 | |||
| ecc5607fd4 | |||
| 11e4cde77f | |||
| 62ed72f2b5 | |||
| 1a07574bae | |||
| 29a888d1dc | |||
| 3d307be645 | |||
| 7dfe892467 | |||
| 2cf00e9426 | |||
| 4eba9be48c | |||
| bebfc63b75 | |||
| 0319794e59 | |||
| bcf2f16c05 | |||
| 27d4cb1f36 | |||
| c1f61a380f | |||
| 5e5ed6d1ab | |||
| 31461cf9be | |||
| 8c8b7ee986 | |||
| 5bfe6f0820 | |||
| 8c4e835fdd | |||
| 695d1080bd | |||
| e774a065b4 | |||
| 29f27ae109 | |||
| 0ee28eeb95 | |||
| 900444bff5 | |||
| 99477d0d2b | |||
| 9148920f42 | |||
| e42688794a | |||
| c73e4c0aec | |||
| e701267135 | |||
| 4f71dbc62d | |||
| ca6af3d904 | |||
| 233aaf9bd8 | |||
| d3bb026345 | |||
| d363991f78 | |||
| f0493df09e | |||
| fe46cbe3bd | |||
| f1e365ec97 | |||
| 2042482592 | |||
| 7d0fe8ac5f | |||
| a00e485ebb | |||
| 9d351dd1c3 | |||
| 5ac2197b6e | |||
| c5a70a2eff | |||
| e7c3379f44 | |||
| afc50c1e48 | |||
| 5a165296d9 | |||
| 0d01017c31 | |||
| d1d683d101 | |||
| f7e4ea09bc | |||
| 9a7eda1aa6 | |||
| db1f8d4a53 | |||
| 9852613426 | |||
| 81eb295a09 | |||
| b5a1293953 | |||
| 251f9f9ad8 | |||
| 98708df6e0 | |||
| 2db3c4be78 | |||
| 69294a153f | |||
| 2fdff17651 | |||
| 87d7314bf4 | |||
| 5aeff5ca14 | |||
| 98093d859b | |||
| 4021d08541 | |||
| 5c97a772be | |||
| 60afb51451 | |||
| f4413ebbb0 | |||
| be2a06bc80 | |||
| 2dbddfe367 | |||
| 126e8abfcd | |||
| 3fd540a9db | |||
| 4f92fb03f1 | |||
| f42fa8f4f1 | |||
| a8e980b225 | |||
| afaa9c0239 | |||
| 7d456b41dc | |||
| 0edeed1c48 | |||
| be5e3464ee | |||
| 1967e845b0 | |||
| af807afff5 | |||
| 7f567bc1bb | |||
| 09aacc36ab | |||
| 15b129ad04 | |||
| 6ddf2b4995 | |||
| dcabceb52f | |||
| 51bcf870d3 | |||
| c6c5793c61 | |||
| d98ee736be | |||
| 39dbd86f1e | |||
| c938b2409e | |||
| 5b30bb9948 | |||
| 813a45385a | |||
| 21ed5f88da | |||
| 3198cc5666 | |||
| 4568d94a62 | |||
| d32d8de3a5 | |||
| a9fb6a7ac8 | |||
| 433e69272a | |||
| affa18a6e9 | |||
| 05aec88178 | |||
| b32de48c0b | |||
| 15e3402e1c | |||
| dca0a4b809 | |||
| 20320dba7b | |||
| e4bbc78a41 | |||
| feb9befb35 | |||
| a038194679 | |||
| 3172cda241 | |||
| 61fba107ef | |||
| 835de48cb7 | |||
| 21de590aea | |||
| 3081bd1085 | |||
| bdcab1e1a7 |
43
.env.example
43
.env.example
@@ -1,10 +1,19 @@
|
||||
# file: .env.example
|
||||
# version: 6
|
||||
# version: 13
|
||||
|
||||
# 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.
|
||||
KSP_LOGS_DIRECTORY=logs
|
||||
|
||||
# PostgreSQL URI for the Devnet Store target.
|
||||
KSP_SECRET_STORE_DEVNET_POSTGRES_URI=postgresql://...
|
||||
|
||||
# PostgreSQL URI for the Mainnet Store target.
|
||||
KSP_SECRET_STORE_MAINNET_POSTGRES_URI=postgresql://...
|
||||
|
||||
# PostgreSQL URI for the Testnet Store target.
|
||||
KSP_SECRET_STORE_TESTNET_POSTGRES_URI=postgresql://...
|
||||
|
||||
# KSP Wallet root directory. Used by config/std.wallet.json before an optional profile subdirectory is appended.
|
||||
# The committed Wallet document falls back to "wallets" when neither the process environment nor .env defines this variable.
|
||||
KSP_WALLETS_DIRECTORY=wallets
|
||||
@@ -34,10 +43,42 @@ KSP_PUBLIC_SOLANA_MAINNET_WS_URL=wss://api.mainnet-beta.solana.com
|
||||
# Keep provider credentials in a KSP_SECRET_* variable; do not copy a real credential-bearing URL into committed JSON.
|
||||
# KSP_SECRET_SOLANA_HTTP_URL=https://provider.example/?api-key=replace-me
|
||||
|
||||
# PublicNode Mainnet personal token used as secret x-token metadata by the committed Mainnet Yellowstone gRPC profile.
|
||||
# Keep the real value only in the process environment or local .env; do not assume that a Testnet token is authorized on Mainnet.
|
||||
# KSP_SECRET_PUBLICNODE_MAINNET_GRPC_X_TOKEN=replace-me
|
||||
|
||||
# PublicNode Testnet personal token used as secret x-token metadata by the committed Testnet Yellowstone gRPC profile.
|
||||
# Keep the real value only in the process environment or local .env; do not assume that a Mainnet token is authorized on Testnet.
|
||||
# KSP_SECRET_PUBLICNODE_TESTNET_GRPC_X_TOKEN=replace-me
|
||||
|
||||
# OrbitFlare Solana Free License Key used as secret x-token metadata by the committed Devnet Yellowstone gRPC profile.
|
||||
# Keep the real ORBIT-* value only in the process environment or local .env; do not substitute the Customer API X-ORBIT-KEY.
|
||||
# KSP_SECRET_ORBITFLARE_DEVNET_GRPC_X_TOKEN=replace-me
|
||||
|
||||
# Helius API key used by the LaserStream WebSocket endpoint in config/examples/std.transport.example.json.
|
||||
# Keep the real credential only in the process environment or local .env; never commit it.
|
||||
# KSP_SECRET_HELIUS_API_KEY=replace-me
|
||||
|
||||
# Birdeye Standard API key used by the optional `all_free` profile in config/std.offchain_transport.json.
|
||||
# Keep the real credential only in the process environment or local .env; never commit it.
|
||||
# KSP_SECRET_BIRDEYE_API_KEY=replace-me
|
||||
|
||||
# CoinGecko Demo API key used by the optional `all_free` Off-chain Transport profile.
|
||||
# Keep the real credential only in the process environment or local .env; never commit it.
|
||||
# KSP_SECRET_COINGECKO_DEMO_API_KEY=replace-me
|
||||
|
||||
# CoinMarketCap Basic API key used by the optional `all_free` Off-chain Transport profile.
|
||||
# Keep the real credential only in the process environment or local .env; never commit it.
|
||||
# KSP_SECRET_COINMARKETCAP_API_KEY=replace-me
|
||||
|
||||
# Public Solana pair address used by DexScreener for explicit SOL/USD pair lookup in the optional `all_free` profile.
|
||||
# This value is intentionally public and must identify the exact pair chosen by the operator.
|
||||
# KSP_PUBLIC_DEXSCREENER_SOL_USD_PAIR_ADDRESS=replace-with-solana-pair-pubkey
|
||||
|
||||
# Jupiter Free API key used by the optional `all_free` Off-chain Transport profile.
|
||||
# Keep the real credential only in the process environment or local .env; never commit it.
|
||||
# KSP_SECRET_JUPITER_API_KEY=replace-me
|
||||
|
||||
# Fade-in duration in milliseconds used by the common KSP desk splash lifecycle.
|
||||
KSP_DESK_SPLASH_FADE_IN_MS=300
|
||||
|
||||
|
||||
108
CHANGELOG.md
108
CHANGELOG.md
@@ -1,10 +1,116 @@
|
||||
<!-- file: CHANGELOG.md -->
|
||||
<!-- version: 12 -->
|
||||
<!-- version: 25 -->
|
||||
|
||||
# Changelog KSP
|
||||
|
||||
## 0.3.5 — Interface acquisition events partagés — 2026-08-31
|
||||
|
||||
`0.3.5` étend `ksp-interface-lib` avec deux familles passives d'acquisition réellement partagées, sans transformer Interface en runtime, en event bus ou en seconde couche RAW. `SlotLifecycleEvent` expose un `slot` et un `SlotLifecycleStage` non exhaustif limité à `Processed`, `FirstShredReceived`, `Completed`, `CreatedBank`, `Dead`, `OptimisticallyConfirmed` et `Rooted`. La normalisation conserve la sémantique commune : les notifications Solana `optimisticConfirmation` et Yellowstone `Confirmed` convergent vers `OptimisticallyConfirmed`, tandis que Solana `root` et Yellowstone `Finalized` convergent vers `Rooted`; les différences d'ordre, de complétude, de replay et de transport restent la responsabilité du producteur/Transport.
|
||||
|
||||
La seconde famille matérialise le fait minimal d'exécution transactionnelle partagé par plusieurs sources. `TransactionSignature` possède exactement 64 octets et un `Debug` redacted, `TransactionExecutionOutcome` distingue seulement `Succeeded` et `Failed`, et `TransactionExecutionEvent` transporte uniquement `slot + signature + outcome`. Les logs, erreurs provider détaillées, commitments, indexes, timestamps, filtres et payloads complets restent Transport-owned. Les snapshots HTTP `getSignatureStatuses`, les transitions one-shot `signatureSubscribe`, un éventuel `TransactionLogEvent`, les votes et les entrées Yellowstone ne sont pas fusionnés artificiellement dans ce contrat.
|
||||
|
||||
La frontière d'ownership reste stricte : les DTOs wire/provider demeurent dans `ksp-onchain-transport-lib`, les événements passifs provider-neutral sont Interface-owned, et les modèles persistants/replayables `RawTransaction` / `RawAccountState` restent `ksp-store-api`. `ksp-interface-lib` conserve exactement `ksp-core-lib` comme seule dépendance normale, sans feature propre, dev/build dependency, serde, codec, logging ou runtime. Les canaris publics, external consumer, inventaires exacts et hardening vérifient également qu'aucun second RAW, metadata source ou payload hostile n'entre dans la surface Interface.
|
||||
|
||||
Les gates de clôture passent audits Rust/Markdown, `cargo check --workspace`, Clippy all-targets, tests ciblés Interface/Program API, `cargo test --workspace` et graphes Cargo. `cargo tree -p ksp-interface-lib --edges normal` confirme le chemin `ksp-interface-lib -> ksp-core-lib -> solana-pubkey -> solana-address`; le graphe features ne montre aucune feature propre Interface et les doublons éventuels restent ceux du workspace global. La documentation durable a été réconciliée et `TransactionLogEvent` est conservé comme idée différée soumise à un nouveau gate consumer/bornes.
|
||||
|
||||
`prompts/025-V0_3_6_START_PROMPT.md` ouvre `0.3.6` sur le développement parallèle de `ksp-job-api` et `ksp-job-backfill-lib`. La release doit reprendre fonctionnellement le backfill historique kbot3 sans en copier le code : audit obligatoire de l'archive historique, `RawTransaction` par adresse via `getSignaturesForAddress` + `getTransaction`, directions/anchors et déduplication, hydratation/persistence via les façades KSP, frontier/checkpoint/reprise, cancellation/concurrency bornées, idempotence et distinctions missing/conflit. `ksp-job-api` doit en parallèle stabiliser lifecycle/progress/outcome et un contrat de notifications/listeners borné pour qu'une couche supérieure puisse visualiser l'état sans parser les logs. Le ROADMAP enchaîne ensuite l'app de backfill/inspection `0.3.7`, `ksp-worker-api` `0.3.8`, `ksp-worker-live-transactions-retriever-lib` `0.3.9` puis une application de monitoring/visualisation Jobs + Workers `0.3.10`, avec reprise du même pattern d'observabilité côté Worker sans fusionner les sémantiques Job/Worker/Store.
|
||||
|
||||
## 0.3.4 — Store/PostgreSQL RawAccountState + complétude RAW — 2026-08-31
|
||||
|
||||
`0.3.4` complète la seconde vertical slice RAW physique sur le couple `ksp-store-lib` / `ksp-store-postgres-lib` et ferme la conformance PostgreSQL des **10 capabilities** backend-agnostic de `ksp-store-api` : les six capabilities `RawTransaction*` acquises en `0.3.3` restent intactes et les quatre capabilities `RawAccountStateRead`, `RawAccountStateWrite`, `RawAccountObservationRead` et `RawAccountObservationWrite` sont désormais implémentées par `PostgresBackend` puis dispatchées par la façade `Store`. La séparation reste stricte : les consommateurs ordinaires passent par `ksp-store-lib`, le backend PostgreSQL conserve SQL/driver/pool/TLS/migrations privés, et la façade reste compilable/testable sans backend via `--no-default-features`.
|
||||
|
||||
La migration additive V002 introduit `ksp_raw_account_states` et `ksp_raw_account_observations` au-dessus de V000/V001 sans modifier leurs bytes. L'identité canonique account est `(pubkey, slot, state_hash)` : plusieurs états d'un même compte dans un même slot restent représentables lorsque `state_hash` diffère, tandis qu'une collision sur la référence complète déclenche une comparaison exacte de `lamports`, `owner`, `executable`, `rent_epoch` et `data` avant de conclure à l'idempotence ou à `store_api.raw_conflict`. Les `u64` physiques utilisent `NUMERIC(20,0)`, les clés/hashes/signatures utilisent des `BYTEA` de largeur contrainte, et les bytes account restent complets jusqu'à la borne KSP de 16 MiB. V002 contient exactement 32 ressources gérées et son checksum final est `ff21605ed45f7ab4c0f92bbb692700b4118a9488b04d50a31d259ac59bdb550e`; V000 et V001 restent respectivement `d29068b8c13b9dc0cc9ef6aaadd0fa12d41e0fe4c56541a1118c4bfc846a1450` et `31488cda2f08f3f46c4cdbdbb6c18c243662fada02eac4487040c8735d72cc51`.
|
||||
|
||||
L'acquisition `RawAccountState + RawAccountObservation` est transactionnelle : `INSERT ... ON CONFLICT DO NOTHING`, lecture/verrouillage du canonical en collision, comparaison exacte et rollback complet lorsque l'observation diverge. Une observation supplémentaire vérifie la référence existante sous transaction et ne crée jamais implicitement son state. Les métadonnées Yellowstone `is_startup`, `transaction_signature` et `write_version` restent optionnelles et observation-only ; aucune FK transaction n'est inventée. La navigation account utilise une keyset `(slot, pubkey, state_hash)` ASC/DESC, avec filtre pubkey optionnel et cursor KSPA V1 opaque de 109 octets lié au réseau, au filtre, au range, à la direction et à la famille afin d'empêcher les replays cross-query/cross-family. Aucun `OFFSET`, plafond métier de batch, index owner/provider/time ou lifecycle destructif account n'est introduit.
|
||||
|
||||
Les gates de clôture valident audits Rust/Markdown, `cargo check --workspace`, Clippy all-targets, tests ciblés Store/API/PostgreSQL/Config, `cargo test --workspace`, façade sans feature PostgreSQL et graphes Cargo. Le live `postgres_raw_account_live` passe sur **PostgreSQL 17** avec bootstrap V000/V001/V002, round-trip des bytes et du domaine `u64`, idempotence/conflit, états distincts même pubkey+slot, observations/metadata optionnelles, pagination/cursors, concurrence, annulation/rollback et coexistence `RawTransaction`. La réconciliation documentaire finale est ensuite validée sans réouvrir code ni migrations.
|
||||
|
||||
`prompts/024-V0_3_5_START_PROMPT.md` ouvre `0.3.5` sur `ksp-interface-lib` uniquement. Cette release doit réauditer les surfaces d'acquisition actuelles et matérialiser seulement les modèles passifs/event-only réellement partagés, sans recopier `RawTransaction`/`RawAccountState`, sans créer un event bus et sans déplacer les DTOs provider/transport. Les candidats logs, slot/root/slotsUpdates, transaction status et vote sont traités par matrice sémantique ; une famille reste reportée si la convergence ou le consumer réel n'est pas démontré. L'archive historique kbot3 n'est pas requise pour ce gate : les sources de vérité sont la base KSP stable et les contrats officiels actuels des transports concernés.
|
||||
|
||||
## 0.3.3 — Store/PostgreSQL RawTransaction vertical slice — 2026-08-30
|
||||
|
||||
`0.3.3` complète la première vertical slice RAW physique sur le couple `ksp-store-lib` / `ksp-store-postgres-lib` sans modifier les contrats backend-agnostic acquis dans `ksp-store-api`. `PostgresBackend` et la façade `Store` implémentent désormais les six capabilities `RawTransactionRead`, `RawTransactionWrite`, `RawTransactionObservationRead`, `RawTransactionObservationWrite`, `RawTransactionRetentionRead` et `RawTransactionRetentionWrite`. Une base PostgreSQL reste liée à un unique `RawNetworkId` par `ksp_store_identity`; le mauvais réseau est refusé avant I/O, les slots `u64` sont conservés exactement en `NUMERIC(20,0)`, et la migration logique V001 reste découpée en ressources tables/contraintes/indexes avec vérification de compatibilité du schéma effectif.
|
||||
|
||||
La persistence canonique utilise une transaction PostgreSQL unique pour `RawTransaction + observation`, tente l'insert sous contrainte d'unicité puis compare le contenu réel sous verrou en cas de collision : contenu identique -> idempotence, divergence -> `store_api.raw_conflict`. Les observations supplémentaires conservent leur provenance complète et ne créent jamais implicitement un canonical absent. La lecture reconstruit `Full` depuis le payload chaud et `Archived` depuis la relation archive ; `Purged` reste un tombstone minimal. La navigation est une keyset stricte `(slot, signature)` ASC/DESC sans `OFFSET`, avec cursor V1 opaque de 109 octets lié au réseau, à la direction, aux bornes et à la dernière clé. Aucun plafond métier de batch n'est introduit : seule la borne physique PostgreSQL de `LIMIT requested + 1` est appliquée.
|
||||
|
||||
La rétention physique supportée est `Full -> Archived -> Purged`, sérialisée par `FOR UPDATE`, avec outcomes `Applied`, `AlreadyAtTarget` et `ExpectedStateMismatch`. Le mode normal respecte le tombstone après purge ; `ForceRehydrate` reste explicite et atomique. `Compacted` demeure volontairement non supporté physiquement et retourne `store.postgres_retention_compaction_unsupported` plutôt que de présenter TOAST ou une compression implicite comme contrat KSP. Les erreurs backend sont réduites à des classes/phases statiques puis projetées vers des codes Store/API stables, sans URI, SQL, SQLSTATE, bind ni texte serveur.
|
||||
|
||||
La preuve PostgreSQL réelle a conduit à durcir l'introspection de schéma PostgreSQL 17 : canonicalisation ciblée des CHECK numériques reconstruits par le catalogue, conservation des littéraux texte, restauration des helpers de classification de schéma et distinction d'un drift d'une migration déjà enregistrée lorsque `schema_autoupdate=false`. Les ressources SQL V000/V001 et leurs checksums sont restés inchangés pendant ces corrections (`V000 d29068b8c13b9dc0cc9ef6aaadd0fa12d41e0fe4c56541a1118c4bfc846a1450`, `V001 31488cda2f08f3f46c4cdbdbb6c18c243662fada02eac4487040c8735d72cc51`).
|
||||
|
||||
Le gate technique final `pre.011` passe audits Rust/Markdown, `cargo check --workspace`, Clippy all-targets, les tests ciblés Store/API/PostgreSQL/Config, les tests et checks façade avec `--no-default-features`, `cargo test --workspace` et les graphes Cargo. Le live `postgres_raw_transaction_live` est ensuite rejoué avec succès sur **PostgreSQL 17**, couvrant bootstrap/drift-repair, atomicité, concurrence identique/divergente, rollback sur collision et annulation, pagination/cursor, rétention/races, ForceRehydrate et réouverture durable. Aucun build Tauri supplémentaire n'est requis : `0.3.3` ne change ni resources applicatives ni packaging desktop. `RawAccountState` PostgreSQL et la complétude RAW restent réservés à `0.3.4`. `prompts/023-V0_3_4_START_PROMPT.md` ouvre cette slice suivante sur les quatre capabilities `RawAccount*`, une migration additive au-dessus de V000/V001, puis la conformance finale des dix capabilities RAW ; l'archive historique kbot3 y reste une source de comparaison ciblée account/observation, jamais une architecture à recopier.
|
||||
|
||||
## 0.3.2 — Store/PostgreSQL runtime foundation — 2026-08-30
|
||||
|
||||
`0.3.2` introduit ensemble `ksp-store-lib` et `ksp-store-postgres-lib` comme fondation runtime/backend PostgreSQL au-dessus des contrats backend-agnostic de `ksp-store-api`, sans encore implémenter de capability RAW métier. La façade `Store` conserve un backend connu `Postgres` activé par défaut via Cargo feature, reste compilable avec `--no-default-features`, lie chaque instance à exactement un `RawNetworkId` et n'expose aucun `Pool`, `Client`, `Row`, `Statement`, driver ou SQL physique. `ksp-config-lib` possède désormais `std.store` avec trois targets nommés `devnet`, `mainnet` et `testnet`, chacun associé à un réseau explicite et à une URI PostgreSQL Secret indépendante ; Store/backend ne lisent directement ni `.env`, ni `KSP_*`, ni `PG*`, ni `.pgpass`.
|
||||
|
||||
Le backend de référence utilise `tokio-postgres 0.7.18`, `deadpool-postgres 0.14.2`, `tokio-postgres-rustls 0.14.0`, Rustls 0.23 avec roots système/AWS-LC et des modes TLS KSP limités à `Disabled` et `VerifyFull`. Les URI sont parsées puis normalisées sous la policy typée KSP, le pool et les deadlines connect/wait/create/recycle/shutdown sont bornés, et une ouverture réussie exige une connexion physique puis un bootstrap valide. Le moteur de migrations privé commence par `V000__bootstrap.sql`, enregistre version/nom/SHA-256 dans `ksp_store_schema_migrations`, sérialise les runners par advisory transaction lock borné et refuse checksum mismatch ou schema plus récent sans down automatique. La façade fournit également `runtime_snapshot()` et `health().await` avec une projection portable/redacted de readiness et des compteurs pool sûrs.
|
||||
|
||||
Les canaris de hardening verrouillent 84 exports crate-root côté façade, la frontière backend/Config, l'absence de bypass environnement, la redaction des URI/erreurs, la matrice de features et l'absence de persistence `RawTransaction`/`RawAccountState`. Le gate technique final a été rejoué après `cargo clean` : audits Rust/Markdown, `cargo check --workspace`, Clippy, tests ciblés de toutes les crates, `cargo test --workspace`, graphes Cargo et les trois builds Tauri Linux passent. Le smoke PostgreSQL réel passe sur **PostgreSQL 17** et prouve bootstrap initial/idempotent/concurrent, mismatch/recovery checksum, rollback transactionnel, health `Ready` et fermeture bornée ; la policy de fondation reste PostgreSQL >= 15 sans maximum KSP artificiel.
|
||||
|
||||
`prompts/022-V0_3_3_START_PROMPT.md` ouvre ensuite uniquement la vertical slice PostgreSQL `RawTransaction` complète sur les mêmes crates et la même fondation : six capabilities transaction/observation/rétention, atomicité, idempotence/conflit, get/list cursorisé, tombstone et `ForceRehydrate`. `RawAccountState` PostgreSQL et la complétude RAW restent réservés à `0.3.4`. L'archive historique `khadhroony-bot3_v0.5.3-pre.005-fix010.zip` reste obligatoire au `pre.001` de `0.3.3`, mais seulement pour réauditer l'ancien schéma/repository RAW transaction comme source d'héritage, jamais comme autorité KSP.
|
||||
|
||||
## 0.3.1 — Store API RAW foundation — 2026-08-29
|
||||
|
||||
`0.3.1` introduit `ksp-store-api` comme contrat backend-agnostic de persistence N1 RAW, sans runtime Store ni backend physique. La release stabilise deux familles réellement convergentes : `RawTransaction` avec payload canonique opaque/versionné, identité réseau+signature et observations d’acquisition séparées, puis `RawAccountState` avec bytes complets, identité réseau+pubkey+slot+hash et observations pouvant conserver les enrichissements Yellowstone sans les confondre avec l’état canonique. `TransactionStatusObservation` reste reporté faute de convergence sémantique suffisante entre snapshot HTTP, transition WebSocket et update Yellowstone ; `logsSubscribe`, slot/root/slotsUpdates et vote restent event-only candidats, `RawBlock` reste une idée conditionnelle et Yellowstone `Entry` reste rejeté de la taxonomie active.
|
||||
|
||||
La façade publique conserve un modèle objet sans SQL ni rows backend, des primitives de provenance/hash/timestamps bornées, des queries cursorisées sans plafond métier KSP arbitraire, des outcomes d’idempotence/conflit, dix capabilities fines object-safe et un lifecycle logique de rétention transactionnelle `Full -> Compacted -> Archived -> Purged`. Le tombstone minimal empêche le rebackfill normal après purge tandis que `ForceRehydrate` reste une intention distincte ; une course de compare-and-transition est représentée par `ExpectedStateMismatch` plutôt que par un overwrite silencieux. Le backlog, le batch-size, la priorité, la policy de processing, la compression/archive physique, les notifications runtime et les couches STRUCTURAL/DECODED/DOMAIN restent hors Store API. Le graphe normal final de `ksp-store-api` reste strictement limité à `ksp-core-lib`; aucun PostgreSQL, Tokio, serde, Config, Transport, Program, Logging ou backend concret n’entre dans la crate.
|
||||
|
||||
Les canaris de clôture verrouillent 60 exports crate-root, 10 capabilities, l’inventaire exact des modules RAW, l’implémentabilité par un backend externe, les bornes adversariales, la redaction des `Debug`, la frontière Interface/Store et l’absence de surface N2/N3/N4. Le gate technique de référence a été exécuté après `cargo clean` et passe audits Rust/Markdown, `cargo check --workspace`, Clippy, tests ciblés des crates, `cargo test --workspace`, builds Tauri des trois Desk et graphes Cargo ; les gates documentaires suivants restent également verts. Le redécoupage final prépare trois releases Store/PostgreSQL où `ksp-store-lib` et `ksp-store-postgres-lib` avancent toujours ensemble : `0.3.2` pour la fondation runtime/backend, `0.3.3` pour la vertical slice `RawTransaction`, puis `0.3.4` pour `RawAccountState` et la complétude RAW. `prompts/021-V0_3_2_START_PROMPT.md` ouvre donc uniquement la fondation conjointe Store/PostgreSQL, avec `tokio-postgres` comme driver retenu mais pooling, TLS, migrations et Config à réauditer avant implémentation lourde.
|
||||
|
||||
## 0.2.14 — Program API foundation — 2026-08-28
|
||||
|
||||
`0.2.14` introduit `ksp-program-api` comme première API publique extensible du domaine Program, volontairement limitée au décodage d’instructions et indépendante des runtimes supérieurs. La façade réexporte les contrats Core/Interface nécessaires puis possède `ProgramInstructionRecognition` (`NoMatch`, `ProgramMatch`, `ExactMatch`), `ProgramInstructionDecodeOutcome<Decoded>` (`Decoded`, `Unsupported`) et le trait `ProgramInstructionDecoder: Send + Sync`. L’output `Decoded` reste possédé par l’implémentation et ne reçoit aucun bound implicite `Debug`, `Clone`, `Send` ou `Sync`; les erreurs réelles restent dans le `Result` Core. Les Program IDs sont des `Pubkey` opaques : une implémentation externe peut prendre en charge un programme absent du registry Core sans enum centrale fermée, `Any`, JSON, descriptor global ni registry runtime.
|
||||
|
||||
Le graphe normal final reste strictement `ksp-program-api -> ksp-core-lib + ksp-interface-lib`, Interface dépendant elle-même de Core. Aucun `ksp-program-lib`, codec, serde, logging, réseau, filesystem, environnement, Store, Materializer, Wallet, Config ou Tauri n’entre dans cette foundation. Les canaris public API, implémentation externe, dependency firewall, release completeness et hardening couvrent notamment l’inventaire exact de dix exports crate-root, les trois modules de production, un Program Pubkey non enregistré, l’input Interface maximal de 255 accounts / 10 240 bytes, l’absence d’echo automatique d’un payload hostile et l’absence de claim `dyn` hétérogène. `pre.005-fix.001` corrige uniquement un faux positif cross-crate du scanner Logging provoqué par le motif de test recherché, sans changement fonctionnel. Les gates `pre.006` et `pre.007` passent ensuite audits Rust/Markdown, `cargo check`, Clippy, les 18 tests Program API, ownership Logging et le workspace complet avant la réconciliation documentaire finale.
|
||||
|
||||
`prompts/020-V0_3_1_START_PROMPT.md` ouvre `0.3.1 — Store RAW foundation` exclusivement depuis le tag stable `v0.2.14`. Le gate `pre.001` exige également l’archive historique `khadhroony-bot3_v0.5.3-pre.005-fix010.zip` afin d’auditer l’ancien `ks-store`, ses migrations PostgreSQL et ses contrats N1/N2/N3 sous une matrice `REPRENDRE / REDESSINER / REPORTER / REJETER`. L’archive reste une source historique uniquement : `0.3.1` doit créer `ksp-store-api` et `ksp-store-lib` avec PostgreSQL de référence et persistence **RAW seulement**, sans aspirer les contrats CORE/DECODE/SPECIALIZED, les jobs/workers, l’Interface `0.3.2` ni la configuration produit.
|
||||
|
||||
## 0.2.13 — Interface / wire foundation — 2026-08-28
|
||||
|
||||
`0.2.13` introduit `ksp-interface-lib` comme première façade wire officielle KSP, volontairement passive et Program-facing. La surface stable réexporte le `Pubkey` canonique de Core, ajoute `ProgramAccountMeta` et `ProgramInstruction` à champs privés avec accessors explicites, conserve l’ordre et les doublons des account metas, accepte les Program Pubkeys opaques et borne l’admission à **255 account metas** et **10 240 octets** de data. Les deux bornes sont des limites d’admission Interface et ne prétendent pas garantir à elles seules le fit d’une transaction Solana top-level. Les erreurs réutilisent le contrat Core `Error/Result` avec uniquement `field`, `actual_len` et `maximum_len`, tandis que le `Debug` de l’instruction n’expose que `program_id`, `account_count` et `data_len`.
|
||||
|
||||
La dependency direction finale reste strictement `ksp-interface-lib -> ksp-core-lib -> solana-pubkey`. Aucun `serde`, codec générique (`borsh`, `wincode`, `bincode`), `solana-instruction`, runtime réseau ou logging n’est ajouté à cette foundation. Les canaris public API, consumer externe, dependency firewall et release completeness verrouillent les six exports crate-root, les quatre modules de production, l’absence de narrowing/codec caché, le transfert des `Vec` admis sans réallocation interne et la non-répétition de matériel hostile dans les erreurs. `pre.005-fix.001` corrige uniquement un faux positif du scanner Logging provoqué par le littéral de test `tracing::`, sans changement de production ni de dépendance. Les gates `pre.006` et `pre.007` passent ensuite audits Rust/Markdown, `cargo check`, Clippy, tests Interface, ownership Logging, workspace complet et graphes Cargo avant la réconciliation documentaire finale.
|
||||
|
||||
`prompts/019-V0_2_14_START_PROMPT.md` ouvre `0.2.14 — Program API foundation` exclusivement depuis le tag stable `v0.2.13`. Le gate `pre.001` de cette prochaine release exige aussi l’archive historique `khadhroony-bot3_v0.5.3-pre.005-fix010.zip` afin d’auditer les anciens contrats decoder/executor sous une matrice `REPRENDRE / REDESSINER / REPORTER / REJETER`, sans faire de kbot3 une autorité architecturale ni recréer son monolithe `ks-lib`.
|
||||
|
||||
## 0.2.12 — SOL Prices Desk + projection prix Wallet — 2026-08-27
|
||||
|
||||
`0.2.12` ajoute `ksp-app-solprices-desk`, troisième Desk Tauri spécialisé de KSP, comme HID strictement provider-neutral au-dessus de `ksp-offchain-transport-lib`. Son composite dédié sélectionne `std.logging` et `std.offchain_transport`; la vue Prices expose l’inventaire des huit providers SOL/USD, disponibilité, sémantique, auth, prix exact sous forme de chaîne, timestamps provider/KSP distincts et retry deadline sans inventer de fraîcheur. Les refresh manuel individuel, sélectionné et global passent exclusivement par `MarketPriceService`; les batchs sont bornés à `1..=64`, les états `in_flight` restent backend-owned et aucun polling, scheduler, fallback, moyenne ou prix canonique n’est introduit dans le Desk. Le frontend conserve l’absence de réseau/persistence navigateur et journalise les interactions sans prix, credential, URL, header ou payload provider.
|
||||
|
||||
La même release étend `ksp-app-wallet-desk` de façon volontairement minimale. Le refresh de balance lance `getBalance` et le refresh SOL/USD générique, puis calcule côté Rust une moyenne arithmétique uniquement à partir des observations réussies du refresh courant et l’équivalent USD exact du solde, sans `f64`/`f32`. Le prix reste auxiliaire : une indisponibilité Off-chain n’invalide jamais une balance Solana réussie et produit simplement `N.A.`. Le Wallet ne connaît aucun provider concret, n’expose aucune commande Market Price dédiée au frontend et n’ajoute aucune valorisation persistée ou logique de consensus.
|
||||
|
||||
Le hardening final ajoute une garde IPC hostile avant parsing pour les sélections de plus de 64 providers, un smoke live de composition `SOL Prices composite -> Config -> MarketPriceService -> refresh_all`, les canaris de frontières et les builds Tauri Linux. `pre.009-fix.001` corrige en outre la politique de version desktop : la version KSP/bundle est Cargo-owned, `tauri.conf.json` omet désormais `version` et les `package.json` privés ne sont plus forcés à suivre chaque bump workspace. Le gate opérateur passe audits Rust/Markdown, `cargo check`, Clippy, tests ciblés, `cargo test --workspace`, smoke live, puis builds `.deb`/`.rpm`/`.AppImage` pour SOL Prices, Wallet et Config Desk.
|
||||
|
||||
La clôture documentaire réconcilie README/USAGE, architectures, plan et validation, puis prépare `prompts/018-V0_2_13_START_PROMPT.md`. Ce prompt ouvre `0.2.13 — Interface foundation` exclusivement depuis le tag stable `v0.2.12`. Une reconnaissance préalable de l’archive historique `khadhroony-bot3` fournit des idées de contrats wire et d’extension, mais `0.2.13-pre.001` doit les réauditer contre l’architecture KSP actuelle avant toute implémentation : `ksp-interface-lib` porte les données/wires publics, `ksp-program-api` reste réservé au comportement extensible de `0.2.14`, et les implémentations officielles `ksp-program-lib` arrivent plus tard.
|
||||
|
||||
## 0.2.11 — Off-chain price transport — 2026-08-26
|
||||
|
||||
`0.2.11` crée `ksp-offchain-transport-lib` avec une première surface volontairement bornée à **SOL/USD** et huit adapters REST V1 : CoinGecko, CoinMarketCap, CoinPaprika, Kraken, Coinbase Exchange, Jupiter Price V3, Birdeye et DexScreener. Les adapters utilisent `reqwest` directement, sans SDK provider, avec origines HTTPS possédées par la crate, DTOs wire privés et prix normalisés par `MarketPriceDecimal` sans passage canonique par `f64`. `MarketPriceObservation` conserve paire, sémantique, provenance et timestamps réellement disponibles afin de ne pas confondre prix agrégé, dernier trade d'exchange, heuristique Solana, spot provider et prix DEX pair. DexScreener exige une paire Solana explicite et ne réalise aucune discovery ou sélection automatique de pool.
|
||||
|
||||
La crate possède désormais le registry provider-neutral, les descriptors/capabilities, les limites et quotas informatifs, les états `MarketPriceProviderAvailability`, le cooldown et le service générique `MarketPriceService`. Les consumers disposent de `registry`, `refresh`, `refresh_many` et `refresh_all`; le batch V1 reste séquentiel et déterministe, ne dort jamais pour attendre un rate limit et n'introduit ni fallback, ni consensus, ni moyenne KSP. Les erreurs provider sont normalisées en états génériques et les credentials/endpoints/raw bodies restent absents des projections et diagnostics publics.
|
||||
|
||||
Config ajoute `std.offchain_transport` V1, son schema, les profils `public_keyless` et `all_free`, ainsi que `ResolvedOffchainTransportConfig` qui construit le service sans dépendance inverse. Les credentials CoinGecko/CoinMarketCap/Jupiter/Birdeye restent sous `KSP_SECRET_*`; la paire DexScreener utilise une provenance `KSP_PUBLIC_*`. Les deux applications desktop existantes embarquent les nouvelles resources Config sans encore consommer fonctionnellement le prix. Les canaris de sécurité verrouillent notamment l'absence d'URL/rate-limit provider configurable au-dessus de Config, de lecture directe d'environnement dans Off-chain Transport et de fuite de clés par `Debug`.
|
||||
|
||||
Le gate technique final a passé les audits Rust/Markdown, `cargo check`, Clippy, les tests ciblés Config/Off-chain, le workspace complet et les graphes Cargo. Le premier smoke live keyless a isolé une erreur de query CoinMarketCap (`ids=5426` au lieu de `id=5426`); `pre.011-fix.001` la corrige pour les modes Keyless et Basic, puis le re-smoke passe **7/7** providers keyless. Le smoke keyed Birdeye/CoinGecko Demo/CoinMarketCap Basic/Jupiter Free reste explicitement `SKIP opérateur`. `prompts/017-V0_2_12_START_PROMPT.md` ouvre ensuite `0.2.12 — SOL Prices Desk + intégration prix Wallet Desk` uniquement depuis le tag stable `v0.2.11`, avec audit/sizing desktop strict avant toute implémentation UI lourde.
|
||||
|
||||
## 0.2.10 — OrbitFlare Yellowstone gRPC — 2026-08-25
|
||||
|
||||
`0.2.10` ajoute OrbitFlare comme provider Yellowstone gRPC sur le moteur N1 et le standard N2 acquis dans `0.2.9`, sans modifier le moteur gRPC ni créer de façade provider inutile. L'audit et le smoke live ont établi le contrat réel du service Solana Free Devnet : endpoint `http://devnet.rpc.orbitflare.com:10000`, authentification par metadata gRPC secrète `x-token` portant la License Key OrbitFlare, et émission du Ping Yellowstone standard. Config Transport V3 représente ce provider avec `provider = orbitflare`, `cluster = devnet`, `protocol = solana_yellowstone` et `secret_metadata`, tandis que `.env.example` inventorie `KSP_SECRET_ORBITFLARE_DEVNET_GRPC_X_TOKEN` sans valeur réelle.
|
||||
|
||||
La première tentative live sans metadata a volontairement échoué avec `Unauthenticated`, ce qui a conduit à `pre.002-fix.001` après audit des sources OrbitFlare actuelles. Le smoke corrigé reçoit la License Key uniquement par stdin opérateur, ouvre `Subscribe`, observe un Slot non nul et un `SubscribeUpdate::Ping`, puis ferme la session proprement. Le même smoke est repassé au gate technique final `pre.003`. La clôture conserve donc N1/N2 inchangés, n'ajoute ni heartbeat OrbitFlare, ni SDK provider, ni Config V4, et confirme que la réponse automatique au Ping standard existante suffit.
|
||||
|
||||
Le gate final conserve l'ensemble du workspace vert : audits Rust/Markdown, `cargo check`, Clippy, tests Config et Transport, `cargo test --workspace`, canaris de dépendances et graphes Cargo. Helius LaserStream gRPC, audité comme largement wire-compatible Yellowstone mais non raisonnablement live-testable sans abonnement payant, est reporté dans les TODO provider sans numéro de release. La séquence active passe donc à `0.2.11 — off-chain price transport`; `prompts/016-V0_2_11_START_PROMPT.md` l'ouvre exclusivement depuis le tag stable `v0.2.10` avec audit actuel des sources de prix, de leurs sémantiques et de leur testabilité avant toute implémentation lourde.
|
||||
|
||||
Ce changelog résume uniquement les releases KSP considérées comme stables, dans l'ordre chronologique décroissant. Les détails de chaque livraison restent dans `deltas/`.
|
||||
|
||||
## 0.2.9 — Yellowstone gRPC standard/provider-neutral — 2026-08-24
|
||||
|
||||
`0.2.9` ajoute à `ksp-onchain-transport-lib` un moteur Yellowstone gRPC partagé construit directement sur Tonic/Protobuf, avec `yellowstone-grpc-proto` comme contrat wire upstream et sans `yellowstone-grpc-client` dans le runtime KSP. La surface Solana standard reste provider-neutral et couvre les sept unary retenues (`SubscribeReplayInfo`, `Ping`, `GetLatestBlockhash`, `GetBlockHeight`, `GetSlot`, `IsBlockhashValid`, `GetVersion`), `Subscribe` bidi, les familles accounts/slots/transactions/transaction status/blocks/block meta/entry, les neuf variantes d'update standard retenues, les filtres/common fields actuels, le backpressure, les limites de messages, le half-close/shutdown, ainsi qu'un reconnect/replay prudent depuis le dernier slot observé. Aucune garantie exactly-once, lossless ou d'ordre global gapless n'est introduite ; gaps et duplicates restent observables. `SubscribeDeshred` reste explicitement hors du standard KSP de cette release.
|
||||
|
||||
Config passe à `std.transport` V3, backward-readable avec V1 HTTP et V2 HTTP+WebSocket, et sépare les axes `provider` / `protocol` ainsi que `metadata` / `secret_metadata` sans créer de dépendance Transport -> Config/env. PublicNode constitue la première intégration provider : profils Yellowstone Mainnet et Testnet sur `https://solana-yellowstone-grpc.publicnode.com:443` et `https://solana-testnet-yellowstone-grpc.publicnode.com:443`, avec authentification `x-token` résolue par Config depuis des variables `KSP_SECRET_*`. Le smoke Transport pur reçoit les credentials uniquement par stdin opérateur, n'en journalise aucun et valide en live `Subscribe -> Slot` sur les deux réseaux ; le même personal token a été utilisé avec succès pour Mainnet et Testnet, tout en conservant deux entrées Config distinctes par flexibilité opérationnelle.
|
||||
|
||||
La clôture conserve les surfaces antérieures HTTP (**52 current typed + 14 historiques**), WebSocket Solana standard (**9 familles / 18 opérations**) et Helius LaserStream WebSocket. Le gate final comprend **383/383** tests unit Transport, **49/49** public API, **43/43** release completeness, **4/4** doctests, `cargo test --workspace`, les canaris de dépendances, le smoke PublicNode live **2/2 PASS** et l'inspection des graphes Cargo (`yellowstone-grpc-proto 12.6.0`, `tonic` / `tonic-prost 0.14.6`, `prost` / `prost-types 0.14.4`). `prompts/015-V0_2_10_START_PROMPT.md` ouvre ensuite `0.2.10 — OrbitFlare Yellowstone gRPC` uniquement depuis le tag stable `v0.2.9`, avec audit provider actuel, threat model et sizing en `pre.001` avant toute extension provider lourde.
|
||||
|
||||
## 0.2.8 — Helius LaserStream WebSocket — 2026-08-23
|
||||
|
||||
`0.2.8` étend `ksp-onchain-transport-lib` avec une façade `HeliusLaserStreamWsSession` dédiée qui réutilise le même `WsSession` physique/actor que le WebSocket Solana standard, sans second client, socket, registry ou scheduler. La surface stable Helius réutilise les sept familles standard actuellement retenues (`account`, `logs`, `program`, `root`, `signature`, `slot`, `slotsUpdates`) et ajoute l’extension typée `transactionSubscribe` / `transactionUnsubscribe`; `block` et `vote` restent absents de la façade Helius et `slotsUpdates` conserve son statut unstable. Le heartbeat provider est possédé par l’actor partagé et émet un WebSocket Ping control frame toutes les 60 secondes uniquement pour `WsProtocolKind::HeliusLaserStream`.
|
||||
|
||||
40
Cargo.toml
40
Cargo.toml
@@ -1,12 +1,12 @@
|
||||
# file: Cargo.toml
|
||||
# version: 236
|
||||
# version: 392
|
||||
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/ksp-app-config-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-wallet-lib"]
|
||||
members = ["crates/ksp-app-config-desk", "crates/ksp-app-solprices-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-interface-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"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.8"
|
||||
version = "0.3.5"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
|
||||
@@ -14,32 +14,42 @@ authors = ["SinuS von SifriduS <sinus@sasedev.net>"]
|
||||
publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
argon2 = { version = "^0.5", default-features = false }
|
||||
argon2 = { version = "^0.6", default-features = false }
|
||||
base64 = { version = "^0.23" }
|
||||
chacha20poly1305 = { version = "^0.11", default-features = false }
|
||||
chrono = { version = "^0.4", default-features = false }
|
||||
deadpool-postgres = { version = "^0.14", default-features = false }
|
||||
directories = { version = "^6.0" }
|
||||
ed25519-dalek = { version = "^3.0", default-features = false }
|
||||
getrandom = { version = "^0.4", default-features = false }
|
||||
base64 = { version = "^0.23" }
|
||||
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 }
|
||||
reqwest = { version = "^0.13", default-features = false }
|
||||
rustls = { version = "^0.23", default-features = false }
|
||||
rustls-native-certs = { version = "^0.8", default-features = false }
|
||||
serde = { version = "^1.0" }
|
||||
serde_json = { version = "^1.0" }
|
||||
jsonschema = { version = "^0.51", default-features = false }
|
||||
reqwest = { version = "^0.13", default-features = false }
|
||||
sha2 = { version = "^0.11", default-features = false }
|
||||
solana-keypair = { version = "^3.1", default-features = false }
|
||||
solana-pubkey = { version = "^4.3", default-features = false }
|
||||
tracing = { version = "^0.1", default-features = false }
|
||||
tracing-subscriber = { version = "^0.3", default-features = false }
|
||||
tracing-appender = { version = "^0.2", default-features = false }
|
||||
tokio = { version = "^1.53", default-features = false }
|
||||
tokio-tungstenite = { version = "^0.30", default-features = false }
|
||||
tempfile = { version = "^3.27" }
|
||||
chrono = { version = "^0.4", default-features = false }
|
||||
tauri = { version = "^2.11" }
|
||||
tauri-build = { version = "^2.6" }
|
||||
tauri-plugin-dialog = { version = "^2.7" }
|
||||
tauri-plugin-tracing = { version = "^0.3" }
|
||||
tempfile = { version = "^3.27" }
|
||||
tracing = { version = "^0.1", default-features = false }
|
||||
tracing-subscriber = { version = "^0.3", default-features = false }
|
||||
tracing-appender = { version = "^0.2", default-features = false }
|
||||
tokio = { version = "^1.53", default-features = false }
|
||||
tokio-postgres = { version = "^0.7", default-features = false }
|
||||
tokio-postgres-rustls = { version = "^0.14", default-features = false }
|
||||
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 }
|
||||
zeroize = { version = "^1.9" }
|
||||
|
||||
[workspace.lints.rust]
|
||||
|
||||
84
ROADMAP.md
84
ROADMAP.md
@@ -1,5 +1,5 @@
|
||||
<!-- file: ROADMAP.md -->
|
||||
<!-- version: 83 -->
|
||||
<!-- version: 100 -->
|
||||
|
||||
# Roadmap KSP
|
||||
|
||||
@@ -53,11 +53,22 @@ Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues. U
|
||||
- [X] `0.2.6` — `ksp-app-wallet-desk` + `.kspwallet` V2 stables : composition Config/Wallet/HTTP/Logging, lifecycle VIEW/OWNER, balance, administration/import/export, wire binaire V2, APIs multi-version, migration V1 -> V2 explicite et runtime Tauri packagé user-writable validés ; bundles Linux `.deb`/`.rpm`/`.AppImage` produits avant publication. Plan clôturé : `docs/plans/013-V0_2_6_WALLET_DESK_PLAN.md`.
|
||||
- [X] `0.2.7` — WebSocket Solana standard stable : 9 familles subscribe/unsubscribe typées (18/18 opérations), sessions physiques multiples explicites, subscriptions logiques typées, lifecycle/reconnect/resubscribe/backpressure/shutdown bornés, Config V2, non-régression HTTP 52+14, compliance finale, smoke WebSocket Devnet et audit de dépendances validés ; publication `rel.001` et prompt `0.2.8` prêts.
|
||||
- [X] `0.2.8` — Helius LaserStream WebSocket stable : façade provider dédiée sur l’actor WebSocket partagé, sept familles standard réutilisées (`account/logs/program/root/signature/slot/slotsUpdates`) + `transactionSubscribe`/`transactionUnsubscribe`, `block/vote` absents, heartbeat Ping 60 s Helius-only, Config V2/secrets redacted, lifecycle adversarial, compliance HTTP 52+14 / Standard WS 18/18 et graphes Cargo finaux validés ; prompt `0.2.9` prêt.
|
||||
- [ ] `0.2.9` — Ajouter une première fondation Yellowstone gRPC standard/provider-neutral ; dimensionner la surface exacte à `pre.001` selon la documentation normative actuelle.
|
||||
- [ ] `0.2.10` — Introduire `ksp-offchain-transport-lib` avec un premier lecteur de prix, au minimum SOL/USD et SOL/EUR.
|
||||
- [ ] `0.2.11` — Introduire une petite application desk de visualisation/validation des prix offchain, puis intégrer cette capacité dans `ksp-app-wallet-desk` sans dupliquer la logique de récupération/normalisation possédée par le composant spécialisé.
|
||||
- [ ] `0.2.12` — Introduire la première surface de `ksp-interface-lib`, comprenant une API wire publique utilisable par les implémentations officielles et externes.
|
||||
- [ ] `0.2.13` — Introduire `ksp-program-api` comme premier contrat Program extensible, sans imposer encore `ksp-program-lib` complet.
|
||||
- [X] `0.2.9` — Yellowstone gRPC standard/provider-neutral stable : moteur Tonic/Protobuf KSP partagé, sept unary standard retenues, `Subscribe` bidi et neuf variantes d’update, lifecycle/backpressure/reconnect/replay bornés sans promesse lossless, Config Transport V3 backward V1/V2 avec provider/protocol séparés, profils PublicNode Mainnet/Testnet authentifiés par `x-token`, smoke live `Subscribe -> Slot` 2/2 PASS et graphes Cargo finaux inspectés ; `SubscribeDeshred` reste hors scope.
|
||||
- [X] `0.2.10` — OrbitFlare Yellowstone gRPC stable : profil Config V3 Devnet, License Key injectée comme metadata secrète `x-token`, smoke live `Subscribe -> Slot + Ping` validé deux fois, sans modification du moteur N1/N2 ni heartbeat provider.
|
||||
- [X] `0.2.11` — Off-chain price transport stable : `ksp-offchain-transport-lib` expose SOL/USD via huit adapters REST `reqwest` sans SDK provider, décimal exact, sémantiques/provenance explicites, registry/availability/rate limits et refresh single/many/all génériques ; Config `std.offchain_transport` construit le service sans dépendance inverse, DexScreener reste lié à une paire explicite sans discovery, aucun consensus/fallback automatique n’est introduit, et le smoke live keyless final passe 7/7 après correction CoinMarketCap V2.
|
||||
- [X] `0.2.12` — SOL Prices Desk + projection prix Wallet stables : HID provider-neutral avec refresh row/selected/all et batch `1..=64`, observations/timestamps exacts sans polling/consensus, puis refresh balance Wallet enrichi d’une moyenne SOL/USD consumer-owned et d’un équivalent USD exact best-effort ; smoke live de composition, workspace complet et bundles Tauri Linux validés.
|
||||
- [X] `0.2.13` — Interface / wire foundation stable : `ksp-interface-lib` expose `Pubkey`, `ProgramAccountMeta` et `ProgramInstruction` passifs, admission bornée à 255 account metas / 10 240 bytes, erreurs et `Debug` sans payload hostile, façade crate-root et consumer externe canaris ; graphe strict `Interface -> Core`, sans serde/codec générique, `solana-instruction`, réseau ni logging runtime.
|
||||
- [X] `0.2.14` — Program API foundation stable : `ksp-program-api` expose une surface instruction-only ouverte avec `ProgramInstructionRecognition`, `ProgramInstructionDecodeOutcome<Decoded>` et `ProgramInstructionDecoder`; implémentation externe avec Program Pubkey opaque validée, 10 exports crate-root / 3 modules de production verrouillés, graphe strict Core + Interface, sans registry/runtime/serde/codec/Store/Materializer/Execution. Le prompt `0.3.1` prépare Store RAW-only.
|
||||
|
||||
### TODO/IDEAS — providers Yellowstone non planifiés
|
||||
|
||||
- [ ] **TODO** — Helius LaserStream gRPC : réauditer lorsque l'accès live gRPC est raisonnablement disponible ; conserver N1/N2 Yellowstone inchangés, vérifier auth/endpoints/Subscribe/Ping/replay/from_slot/erreurs provider et traiter les preprocessed transactions comme extension Helius séparée.
|
||||
- [ ] **TODO** — eRPC : réauditer accès, auth/IP policy, capabilities et produits complémentaires avant toute décision d’implémentation.
|
||||
- [ ] **TODO** — Triton : réauditer la frontière Yellowstone upstream / extensions Triton, notamment Deshred et futures extensions.
|
||||
- [ ] **TODO** — Alchemy : réauditer auth, replay, limites et capabilities Yellowstone avant toute intégration.
|
||||
- [ ] **TODO** — QuickNode : réauditer auth, compression, `from_slot`, filtres et limites de plan.
|
||||
- [ ] **TODO** — Chainstack : réauditer networks, auth, add-on et capabilities Yellowstone.
|
||||
- [ ] **IDEAS** — Tatum, Shyft, Solinfra, NodeFlare et autres providers : conserver comme candidats exploratoires sans numéro de release ni engagement d’implémentation.
|
||||
|
||||
### Règles Transport pour toute la série
|
||||
|
||||
@@ -69,33 +80,56 @@ Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues. U
|
||||
|
||||
## Architecture de données — progression canonique
|
||||
|
||||
La chaîne durable cible est :
|
||||
La progression durable cible est :
|
||||
|
||||
```text
|
||||
RAW -> CORE -> DECODE -> SPECIALIZED
|
||||
RAW -> STRUCTURAL -> DECODED -> DOMAIN
|
||||
```
|
||||
|
||||
- **RAW** et **CORE** ne nécessitent aucun décodage Program.
|
||||
- À la fin de chaque couche horizontale RAW/CORE, ajouter les jobs/workers/apps nécessaires pour la rendre réellement exploitable avant d'ouvrir la couche suivante.
|
||||
- À partir de **DECODE**, avancer verticalement groupe par groupe : wire -> decode -> matérialisation -> projection spécialisée si utile -> préparation d'exécution -> policy -> execution -> scénarios Devnet.
|
||||
- **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 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
|
||||
|
||||
- [ ] `0.3.1` — Introduire `ksp-store-api` + `ksp-store-lib` avec PostgreSQL de référence et **modèles/persistence RAW uniquement**.
|
||||
- [ ] `0.3.2` — Étendre `ksp-interface-lib` avec les wires génériques nécessaires aux acquisitions et à la future normalisation CORE.
|
||||
- [ ] `0.3.3` — Introduire `ksp-job-api` et un job de backfill historique concret.
|
||||
- [ ] `0.3.4` — Introduire une application spécialisée de backfill/inspection RAW.
|
||||
- [ ] Compléter ensuite la couche RAW avec le worker/service live, son contrôle et les outils d'exploitation réellement nécessaires avant de passer à CORE.
|
||||
- [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.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.
|
||||
- [X] `0.3.5` — `ksp-interface-lib` étendu avec deux familles passives réellement partagées : `SlotLifecycleEvent` (`Processed`, `FirstShredReceived`, `Completed`, `CreatedBank`, `Dead`, `OptimisticallyConfirmed`, `Rooted`) et `TransactionExecutionEvent` (`slot + TransactionSignature[64] + Succeeded/Failed`). Interface reste Core-only, provider-neutral, sans serde/codec/runtime/event bus et sans duplication de `RawTransaction`/`RawAccountState`; les DTOs riches restent Transport-owned et les candidats non convergents restent différés.
|
||||
- [ ] `0.3.6` — Introduire en parallèle `ksp-job-api` et `ksp-job-backfill-lib` : reprendre fonctionnellement le backfill historique kbot3 sur les abstractions KSP actuelles (`ksp-onchain-transport-lib` + `ksp-store-lib`), fermer une première verticale `RawTransaction` historique par adresse avec directions/anchors, déduplication, hydratation, idempotence, frontier/checkpoint/reprise, cancellation et concurrency bornées, et stabiliser un lifecycle/progress/outcome avec notifications/listeners sûrs pour les couches supérieures.
|
||||
- [ ] `0.3.7` — Introduire une application spécialisée de backfill/inspection RAW consommant `ksp-job-api` : lancement/annulation contrôlés, état et progression live, compteurs, checkpoint/frontière, outcome terminal et inspection RAW sans parser les logs ni connaître les providers/backends physiques.
|
||||
- [ ] `0.3.8` — Introduire `ksp-worker-api` comme API générique de lifecycle/health/progression pour services continus, en reprenant le pattern de notifications/listeners stabilisé par Job tout en gardant les sémantiques Worker distinctes des jobs terminables et des wake-ups Store post-commit.
|
||||
- [ ] `0.3.9` — Introduire `ksp-worker-live-transactions-retriever-lib` pour l'acquisition continue `RawTransaction` via les surfaces live de `ksp-onchain-transport-lib`, persistence par `ksp-store-lib`, reprise/backpressure/idempotence et notifications `ksp-worker-api`, sans decode Program ni dépendance backend/provider directe.
|
||||
- [ ] `0.3.10` — Introduire une application de monitoring/visualisation Jobs + Workers : vue graphique des lifecycles, health/progression, rates/backpressure/retries sûrs, checkpoints et outcomes ; l'application consomme les APIs publiques Job/Worker et ne devient ni scheduler caché ni source de vérité du backlog.
|
||||
|
||||
## Série CORE suivante
|
||||
### TODO/IDEAS — taxonomie N1, processing et rétention
|
||||
|
||||
- [ ] Définir la persistence CORE canonique Solana générique.
|
||||
- [ ] Implémenter `RAW -> CORE` sans decoder Program : blocs, slots, signatures, transactions/messages, comptes, instructions/CPI brutes, logs/meta et relations structurelles.
|
||||
- [ ] Ajouter replay/backfill RAW -> CORE.
|
||||
- [ ] Ajouter worker/service CORE.
|
||||
- [ ] Ajouter l'application de contrôle/inspection CORE utile.
|
||||
- [ ] **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.
|
||||
- [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-*`.
|
||||
- [X] slot/root/slotsUpdates — `0.3.5` stabilise `SlotLifecycleEvent` pour l’intersection réellement partagée, sans persistence Store par défaut et sans promettre l’ordre/complétude du flux.
|
||||
- [ ] **TODO** — vote realtime : reste hors Interface tant qu’aucun consumer transversal et aucune sémantique provider-neutral suffisamment précise ne justifient un contrat partagé.
|
||||
- [ ] **IDEA** — `RawBlock` : ne rouvrir que si une information block-level non reconstructible devient nécessaire ; `getBlock` doit d’abord être traité comme source de `RawTransaction`, pas comme invitation à recopier le ledger en blocs.
|
||||
- [ ] **REJET ACTUEL** — Yellowstone `Entry` : trop bas niveau et aucune destination replay/decomposition/event métier justifiant un modèle KSP n’est identifiée.
|
||||
- [ ] **TODO** — processing ledger : reprendre l’idée kbot2/kbot3 `stage + processor identity/version + input identity/hash + terminal status`, sans faire d’un `processed: bool` la preuve durable unique ; prévoir force replay/version upgrades lorsque les processors seront ouverts.
|
||||
- [X] lifecycle RAW logique — `RawRetentionState`, tombstone minimal, normal-skip et force-rehydrate sont stabilisés en `0.3.1` pour `RawTransaction`.
|
||||
- [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.
|
||||
|
||||
## Séries DECODE/SPECIALIZED/EXECUTION — progression verticale
|
||||
## 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.
|
||||
- [ ] Ajouter replay/backfill RAW -> STRUCTURAL avec processing versionné.
|
||||
- [ ] Ajouter worker/service STRUCTURAL et l'application de contrôle/inspection utile.
|
||||
|
||||
## Séries DECODED/DOMAIN/EXECUTION — progression verticale
|
||||
|
||||
### Priorité 1 — Solana Core Programs
|
||||
|
||||
@@ -108,7 +142,7 @@ RAW -> CORE -> DECODE -> SPECIALIZED
|
||||
- [ ] SPL Token.
|
||||
- [ ] Associated Token Account.
|
||||
- [ ] Token-2022 et extensions pertinentes.
|
||||
- [ ] Pour chaque famille : decode -> materialize -> specialized -> prepare -> policy -> execute -> scenarios.
|
||||
- [ ] Pour chaque famille : decode -> materialize -> domain projection -> prepare -> policy -> execute -> scenarios.
|
||||
|
||||
### Priorité 3 — Metadata token
|
||||
|
||||
@@ -132,7 +166,7 @@ RAW -> CORE -> DECODE -> SPECIALIZED
|
||||
|
||||
- [ ] Après les premiers groupes Meteora/Raydium/Pump/Orca, introduire une petite `ksp-app-market-desk` spécialisée.
|
||||
- [ ] Visualiser tokens, pools/markets, liquidité, swaps/trades, prix, volumes, OHLC/candles et activité live/récente lorsque disponible.
|
||||
- [ ] Lire les projections SPECIALIZED KSP ; ne pas reconstruire la logique protocolaire dans l'UI.
|
||||
- [ ] Lire les projections DOMAIN KSP ; ne pas reconstruire la logique protocolaire dans l'UI.
|
||||
|
||||
### Routing
|
||||
|
||||
|
||||
51
config/composite.ksp-app-solprices-desk.json
Normal file
51
config/composite.ksp-app-solprices-desk.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"default_profile": "public_keyless",
|
||||
"profiles": [
|
||||
{
|
||||
"profile_id": "public_keyless",
|
||||
"documents": [
|
||||
{
|
||||
"component_id": "logging",
|
||||
"file_id": "cfg.std.logging",
|
||||
"profile_id": "console_info"
|
||||
},
|
||||
{
|
||||
"component_id": "offchain_transport",
|
||||
"file_id": "cfg.std.offchain_transport",
|
||||
"profile_id": "public_keyless"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"profile_id": "all_free",
|
||||
"documents": [
|
||||
{
|
||||
"component_id": "logging",
|
||||
"file_id": "cfg.std.logging",
|
||||
"profile_id": "console_info"
|
||||
},
|
||||
{
|
||||
"component_id": "offchain_transport",
|
||||
"file_id": "cfg.std.offchain_transport",
|
||||
"profile_id": "all_free"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"profile_id": "tests",
|
||||
"documents": [
|
||||
{
|
||||
"component_id": "logging",
|
||||
"file_id": "cfg.std.logging",
|
||||
"profile_id": "console_info"
|
||||
},
|
||||
{
|
||||
"component_id": "offchain_transport",
|
||||
"file_id": "cfg.std.offchain_transport",
|
||||
"profile_id": "public_keyless"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -10,6 +10,11 @@
|
||||
"file_id": "cfg.std.logging",
|
||||
"profile_id": "supertrace"
|
||||
},
|
||||
{
|
||||
"component_id": "offchain_transport",
|
||||
"file_id": "cfg.std.offchain_transport",
|
||||
"profile_id": "public_keyless"
|
||||
},
|
||||
{
|
||||
"component_id": "transport",
|
||||
"file_id": "cfg.std.transport",
|
||||
@@ -30,6 +35,11 @@
|
||||
"file_id": "cfg.std.logging",
|
||||
"profile_id": "supertrace"
|
||||
},
|
||||
{
|
||||
"component_id": "offchain_transport",
|
||||
"file_id": "cfg.std.offchain_transport",
|
||||
"profile_id": "public_keyless"
|
||||
},
|
||||
{
|
||||
"component_id": "transport",
|
||||
"file_id": "cfg.std.transport",
|
||||
@@ -50,6 +60,11 @@
|
||||
"file_id": "cfg.std.logging",
|
||||
"profile_id": "supertrace"
|
||||
},
|
||||
{
|
||||
"component_id": "offchain_transport",
|
||||
"file_id": "cfg.std.offchain_transport",
|
||||
"profile_id": "public_keyless"
|
||||
},
|
||||
{
|
||||
"component_id": "transport",
|
||||
"file_id": "cfg.std.transport",
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
"file_id": "cfg.std.logging",
|
||||
"profile_id": "supertrace"
|
||||
},
|
||||
{
|
||||
"component_id": "offchain_transport",
|
||||
"file_id": "cfg.std.offchain_transport",
|
||||
"profile_id": "public_keyless"
|
||||
},
|
||||
{
|
||||
"component_id": "transport",
|
||||
"file_id": "cfg.std.transport",
|
||||
@@ -30,6 +35,11 @@
|
||||
"file_id": "cfg.std.logging",
|
||||
"profile_id": "supertrace"
|
||||
},
|
||||
{
|
||||
"component_id": "offchain_transport",
|
||||
"file_id": "cfg.std.offchain_transport",
|
||||
"profile_id": "public_keyless"
|
||||
},
|
||||
{
|
||||
"component_id": "transport",
|
||||
"file_id": "cfg.std.transport",
|
||||
@@ -50,6 +60,11 @@
|
||||
"file_id": "cfg.std.logging",
|
||||
"profile_id": "supertrace"
|
||||
},
|
||||
{
|
||||
"component_id": "offchain_transport",
|
||||
"file_id": "cfg.std.offchain_transport",
|
||||
"profile_id": "public_keyless"
|
||||
},
|
||||
{
|
||||
"component_id": "transport",
|
||||
"file_id": "cfg.std.transport",
|
||||
|
||||
43
config/examples/std.offchain_transport.example.json
Normal file
43
config/examples/std.offchain_transport.example.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"default_profile": "custom_free",
|
||||
"profiles": [
|
||||
{
|
||||
"profile_id": "custom_free",
|
||||
"market_price": {
|
||||
"birdeye": {
|
||||
"enabled": true,
|
||||
"api_key": "${KSP_SECRET_BIRDEYE_API_KEY}"
|
||||
},
|
||||
"coinbase_exchange": {
|
||||
"enabled": true
|
||||
},
|
||||
"coingecko": {
|
||||
"enabled": true,
|
||||
"access_mode": "demo",
|
||||
"api_key": "${KSP_SECRET_COINGECKO_DEMO_API_KEY}"
|
||||
},
|
||||
"coinmarketcap": {
|
||||
"enabled": true,
|
||||
"access_mode": "basic",
|
||||
"api_key": "${KSP_SECRET_COINMARKETCAP_API_KEY}"
|
||||
},
|
||||
"coinpaprika": {
|
||||
"enabled": true
|
||||
},
|
||||
"dexscreener": {
|
||||
"enabled": true,
|
||||
"sol_usd_pair_address": "${KSP_PUBLIC_DEXSCREENER_SOL_USD_PAIR_ADDRESS}"
|
||||
},
|
||||
"jupiter": {
|
||||
"enabled": true,
|
||||
"access_mode": "free",
|
||||
"api_key": "${KSP_SECRET_JUPITER_API_KEY}"
|
||||
},
|
||||
"kraken": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
81
config/examples/std.store.example.json
Normal file
81
config/examples/std.store.example.json
Normal file
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"format_version": 2,
|
||||
"default_profile": "devnet",
|
||||
"profiles": [
|
||||
{
|
||||
"profile_id": "devnet",
|
||||
"network": "devnet",
|
||||
"backend": "postgres",
|
||||
"postgres": {
|
||||
"connection_uri": "${KSP_SECRET_STORE_DEVNET_POSTGRES_URI:-postgresql://localhost/ksp_devnet}",
|
||||
"pool": {
|
||||
"max_connections": 8,
|
||||
"connect_timeout_ms": 10000,
|
||||
"wait_timeout_ms": 5000,
|
||||
"create_timeout_ms": 10000,
|
||||
"recycle_timeout_ms": 5000
|
||||
},
|
||||
"tls": {
|
||||
"mode": "verify_full"
|
||||
},
|
||||
"bootstrap": {
|
||||
"schema_autocreate": true,
|
||||
"schema_autoupdate": true,
|
||||
"migration_timeout_ms": 30000,
|
||||
"migration_lock_timeout_ms": 10000
|
||||
},
|
||||
"shutdown_timeout_ms": 5000
|
||||
}
|
||||
},
|
||||
{
|
||||
"profile_id": "mainnet",
|
||||
"network": "mainnet-beta",
|
||||
"backend": "postgres",
|
||||
"postgres": {
|
||||
"connection_uri": "${KSP_SECRET_STORE_MAINNET_POSTGRES_URI:-postgresql://localhost/ksp_mainnet}",
|
||||
"pool": {
|
||||
"max_connections": 8,
|
||||
"connect_timeout_ms": 10000,
|
||||
"wait_timeout_ms": 5000,
|
||||
"create_timeout_ms": 10000,
|
||||
"recycle_timeout_ms": 5000
|
||||
},
|
||||
"tls": {
|
||||
"mode": "verify_full"
|
||||
},
|
||||
"bootstrap": {
|
||||
"schema_autocreate": true,
|
||||
"schema_autoupdate": true,
|
||||
"migration_timeout_ms": 30000,
|
||||
"migration_lock_timeout_ms": 10000
|
||||
},
|
||||
"shutdown_timeout_ms": 5000
|
||||
}
|
||||
},
|
||||
{
|
||||
"profile_id": "testnet",
|
||||
"network": "testnet",
|
||||
"backend": "postgres",
|
||||
"postgres": {
|
||||
"connection_uri": "${KSP_SECRET_STORE_TESTNET_POSTGRES_URI:-postgresql://localhost/ksp_testnet}",
|
||||
"pool": {
|
||||
"max_connections": 8,
|
||||
"connect_timeout_ms": 10000,
|
||||
"wait_timeout_ms": 5000,
|
||||
"create_timeout_ms": 10000,
|
||||
"recycle_timeout_ms": 5000
|
||||
},
|
||||
"tls": {
|
||||
"mode": "verify_full"
|
||||
},
|
||||
"bootstrap": {
|
||||
"schema_autocreate": true,
|
||||
"schema_autoupdate": true,
|
||||
"migration_timeout_ms": 30000,
|
||||
"migration_lock_timeout_ms": 10000
|
||||
},
|
||||
"shutdown_timeout_ms": 5000
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
366
config/schemas/std.offchain_transport.schema.json
Normal file
366
config/schemas/std.offchain_transport.schema.json
Normal file
@@ -0,0 +1,366 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "urn:ksp:schema:std.offchain_transport:v1",
|
||||
"title": "KSP standard Off-chain Transport configuration",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"format_version",
|
||||
"default_profile",
|
||||
"profiles"
|
||||
],
|
||||
"properties": {
|
||||
"format_version": {
|
||||
"const": 1
|
||||
},
|
||||
"default_profile": {
|
||||
"$ref": "#/$defs/profileId"
|
||||
},
|
||||
"profiles": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/profile"
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"profileId": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9][a-z0-9._-]*$"
|
||||
},
|
||||
"enabledOnly": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"enabled"
|
||||
],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"coingecko": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"enabled",
|
||||
"access_mode"
|
||||
],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"access_mode": {
|
||||
"enum": [
|
||||
"demo",
|
||||
"keyless"
|
||||
]
|
||||
},
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"access_mode": {
|
||||
"const": "keyless"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"access_mode"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"not": {
|
||||
"required": [
|
||||
"api_key"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"const": true
|
||||
},
|
||||
"access_mode": {
|
||||
"const": "demo"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"access_mode"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"required": [
|
||||
"api_key"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"coinmarketcap": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"enabled",
|
||||
"access_mode"
|
||||
],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"access_mode": {
|
||||
"enum": [
|
||||
"basic",
|
||||
"keyless"
|
||||
]
|
||||
},
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"access_mode": {
|
||||
"const": "keyless"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"access_mode"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"not": {
|
||||
"required": [
|
||||
"api_key"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"const": true
|
||||
},
|
||||
"access_mode": {
|
||||
"const": "basic"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"access_mode"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"required": [
|
||||
"api_key"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"jupiter": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"enabled",
|
||||
"access_mode"
|
||||
],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"access_mode": {
|
||||
"enum": [
|
||||
"free",
|
||||
"keyless"
|
||||
]
|
||||
},
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"access_mode": {
|
||||
"const": "keyless"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"access_mode"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"not": {
|
||||
"required": [
|
||||
"api_key"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"const": true
|
||||
},
|
||||
"access_mode": {
|
||||
"const": "free"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"access_mode"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"required": [
|
||||
"api_key"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"birdeye": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"enabled"
|
||||
],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"const": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"required": [
|
||||
"api_key"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"dexscreener": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"enabled"
|
||||
],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"sol_usd_pair_address": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"const": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"required": [
|
||||
"sol_usd_pair_address"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"marketPrice": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"birdeye",
|
||||
"coinbase_exchange",
|
||||
"coingecko",
|
||||
"coinmarketcap",
|
||||
"coinpaprika",
|
||||
"dexscreener",
|
||||
"jupiter",
|
||||
"kraken"
|
||||
],
|
||||
"properties": {
|
||||
"birdeye": {
|
||||
"$ref": "#/$defs/birdeye"
|
||||
},
|
||||
"coinbase_exchange": {
|
||||
"$ref": "#/$defs/enabledOnly"
|
||||
},
|
||||
"coingecko": {
|
||||
"$ref": "#/$defs/coingecko"
|
||||
},
|
||||
"coinmarketcap": {
|
||||
"$ref": "#/$defs/coinmarketcap"
|
||||
},
|
||||
"coinpaprika": {
|
||||
"$ref": "#/$defs/enabledOnly"
|
||||
},
|
||||
"dexscreener": {
|
||||
"$ref": "#/$defs/dexscreener"
|
||||
},
|
||||
"jupiter": {
|
||||
"$ref": "#/$defs/jupiter"
|
||||
},
|
||||
"kraken": {
|
||||
"$ref": "#/$defs/enabledOnly"
|
||||
}
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"profile_id",
|
||||
"market_price"
|
||||
],
|
||||
"properties": {
|
||||
"profile_id": {
|
||||
"$ref": "#/$defs/profileId"
|
||||
},
|
||||
"market_price": {
|
||||
"$ref": "#/$defs/marketPrice"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
277
config/schemas/std.store.schema.json
Normal file
277
config/schemas/std.store.schema.json
Normal file
@@ -0,0 +1,277 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "urn:ksp:schema:std.store:v2",
|
||||
"title": "KSP standard Store configuration",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"format_version",
|
||||
"default_profile",
|
||||
"profiles"
|
||||
],
|
||||
"properties": {
|
||||
"format_version": {
|
||||
"enum": [
|
||||
1,
|
||||
2
|
||||
]
|
||||
},
|
||||
"default_profile": {
|
||||
"$ref": "#/$defs/profileId"
|
||||
},
|
||||
"profiles": {
|
||||
"type": "array",
|
||||
"minItems": 1
|
||||
}
|
||||
},
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"format_version": {
|
||||
"const": 1
|
||||
},
|
||||
"profiles": {
|
||||
"items": {
|
||||
"$ref": "#/$defs/profileV1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"format_version": {
|
||||
"const": 2
|
||||
},
|
||||
"profiles": {
|
||||
"items": {
|
||||
"$ref": "#/$defs/profileV2"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"$defs": {
|
||||
"profileId": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9][a-z0-9._-]*$"
|
||||
},
|
||||
"networkId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[A-Za-z0-9_.:-]+$"
|
||||
},
|
||||
"duration100To60000": {
|
||||
"type": "integer",
|
||||
"minimum": 100,
|
||||
"maximum": 60000
|
||||
},
|
||||
"profileV1": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"profile_id",
|
||||
"network",
|
||||
"backend",
|
||||
"postgres"
|
||||
],
|
||||
"properties": {
|
||||
"profile_id": {
|
||||
"$ref": "#/$defs/profileId"
|
||||
},
|
||||
"network": {
|
||||
"$ref": "#/$defs/networkId"
|
||||
},
|
||||
"backend": {
|
||||
"const": "postgres"
|
||||
},
|
||||
"postgres": {
|
||||
"$ref": "#/$defs/postgresV1"
|
||||
}
|
||||
}
|
||||
},
|
||||
"profileV2": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"profile_id",
|
||||
"network",
|
||||
"backend",
|
||||
"postgres"
|
||||
],
|
||||
"properties": {
|
||||
"profile_id": {
|
||||
"$ref": "#/$defs/profileId"
|
||||
},
|
||||
"network": {
|
||||
"$ref": "#/$defs/networkId"
|
||||
},
|
||||
"backend": {
|
||||
"const": "postgres"
|
||||
},
|
||||
"postgres": {
|
||||
"$ref": "#/$defs/postgresV2"
|
||||
}
|
||||
}
|
||||
},
|
||||
"postgresV1": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"connection_uri",
|
||||
"pool",
|
||||
"tls",
|
||||
"bootstrap",
|
||||
"shutdown_timeout_ms"
|
||||
],
|
||||
"properties": {
|
||||
"connection_uri": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"pool": {
|
||||
"$ref": "#/$defs/pool"
|
||||
},
|
||||
"tls": {
|
||||
"$ref": "#/$defs/tls"
|
||||
},
|
||||
"bootstrap": {
|
||||
"$ref": "#/$defs/bootstrapV1"
|
||||
},
|
||||
"shutdown_timeout_ms": {
|
||||
"type": "integer",
|
||||
"minimum": 100,
|
||||
"maximum": 30000
|
||||
}
|
||||
}
|
||||
},
|
||||
"postgresV2": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"connection_uri",
|
||||
"pool",
|
||||
"tls",
|
||||
"bootstrap",
|
||||
"shutdown_timeout_ms"
|
||||
],
|
||||
"properties": {
|
||||
"connection_uri": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"pool": {
|
||||
"$ref": "#/$defs/pool"
|
||||
},
|
||||
"tls": {
|
||||
"$ref": "#/$defs/tls"
|
||||
},
|
||||
"bootstrap": {
|
||||
"$ref": "#/$defs/bootstrapV2"
|
||||
},
|
||||
"shutdown_timeout_ms": {
|
||||
"type": "integer",
|
||||
"minimum": 100,
|
||||
"maximum": 30000
|
||||
}
|
||||
}
|
||||
},
|
||||
"pool": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"max_connections",
|
||||
"connect_timeout_ms",
|
||||
"wait_timeout_ms",
|
||||
"create_timeout_ms",
|
||||
"recycle_timeout_ms"
|
||||
],
|
||||
"properties": {
|
||||
"max_connections": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 64
|
||||
},
|
||||
"connect_timeout_ms": {
|
||||
"$ref": "#/$defs/duration100To60000"
|
||||
},
|
||||
"wait_timeout_ms": {
|
||||
"$ref": "#/$defs/duration100To60000"
|
||||
},
|
||||
"create_timeout_ms": {
|
||||
"$ref": "#/$defs/duration100To60000"
|
||||
},
|
||||
"recycle_timeout_ms": {
|
||||
"$ref": "#/$defs/duration100To60000"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tls": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"mode"
|
||||
],
|
||||
"properties": {
|
||||
"mode": {
|
||||
"enum": [
|
||||
"disabled",
|
||||
"verify_full"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"bootstrapV1": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"auto_migrate",
|
||||
"migration_timeout_ms",
|
||||
"migration_lock_timeout_ms"
|
||||
],
|
||||
"properties": {
|
||||
"auto_migrate": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"migration_timeout_ms": {
|
||||
"type": "integer",
|
||||
"minimum": 1000,
|
||||
"maximum": 300000
|
||||
},
|
||||
"migration_lock_timeout_ms": {
|
||||
"type": "integer",
|
||||
"minimum": 100,
|
||||
"maximum": 120000
|
||||
}
|
||||
}
|
||||
},
|
||||
"bootstrapV2": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_autocreate",
|
||||
"schema_autoupdate",
|
||||
"migration_timeout_ms",
|
||||
"migration_lock_timeout_ms"
|
||||
],
|
||||
"properties": {
|
||||
"schema_autocreate": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"schema_autoupdate": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"migration_timeout_ms": {
|
||||
"type": "integer",
|
||||
"minimum": 1000,
|
||||
"maximum": 300000
|
||||
},
|
||||
"migration_lock_timeout_ms": {
|
||||
"type": "integer",
|
||||
"minimum": 100,
|
||||
"maximum": 120000
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "urn:ksp:schema:std.transport:v2",
|
||||
"title": "KSP standard HTTP + WebSocket Transport configuration",
|
||||
"$id": "urn:ksp:schema:std.transport:v3",
|
||||
"title": "KSP standard HTTP + WebSocket + Yellowstone gRPC Transport configuration",
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/documentV1"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/documentV2"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/documentV3"
|
||||
}
|
||||
],
|
||||
"$defs": {
|
||||
@@ -455,6 +458,275 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"grpcPositiveMs": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 300000
|
||||
},
|
||||
"grpcCapacity": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 65536
|
||||
},
|
||||
"grpcMessageSize": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 536870912
|
||||
},
|
||||
"grpcReconnect": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"max_retries",
|
||||
"initial_backoff_ms",
|
||||
"max_backoff_ms"
|
||||
],
|
||||
"properties": {
|
||||
"max_retries": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 100
|
||||
},
|
||||
"initial_backoff_ms": {
|
||||
"$ref": "#/$defs/grpcPositiveMs"
|
||||
},
|
||||
"max_backoff_ms": {
|
||||
"$ref": "#/$defs/grpcPositiveMs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"grpcReconnectOverride": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1,
|
||||
"properties": {
|
||||
"max_retries": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 100
|
||||
},
|
||||
"initial_backoff_ms": {
|
||||
"$ref": "#/$defs/grpcPositiveMs"
|
||||
},
|
||||
"max_backoff_ms": {
|
||||
"$ref": "#/$defs/grpcPositiveMs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"grpcSession": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"connect_timeout_ms",
|
||||
"unary_timeout_ms",
|
||||
"close_timeout_ms",
|
||||
"reconnect",
|
||||
"request_channel_capacity",
|
||||
"update_channel_capacity",
|
||||
"max_inbound_message_size_bytes",
|
||||
"max_outbound_message_size_bytes"
|
||||
],
|
||||
"properties": {
|
||||
"connect_timeout_ms": {
|
||||
"$ref": "#/$defs/grpcPositiveMs"
|
||||
},
|
||||
"unary_timeout_ms": {
|
||||
"$ref": "#/$defs/grpcPositiveMs"
|
||||
},
|
||||
"close_timeout_ms": {
|
||||
"$ref": "#/$defs/grpcPositiveMs"
|
||||
},
|
||||
"reconnect": {
|
||||
"$ref": "#/$defs/grpcReconnect"
|
||||
},
|
||||
"request_channel_capacity": {
|
||||
"$ref": "#/$defs/grpcCapacity"
|
||||
},
|
||||
"update_channel_capacity": {
|
||||
"$ref": "#/$defs/grpcCapacity"
|
||||
},
|
||||
"max_inbound_message_size_bytes": {
|
||||
"$ref": "#/$defs/grpcMessageSize"
|
||||
},
|
||||
"max_outbound_message_size_bytes": {
|
||||
"$ref": "#/$defs/grpcMessageSize"
|
||||
}
|
||||
}
|
||||
},
|
||||
"grpcSessionOverride": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1,
|
||||
"properties": {
|
||||
"connect_timeout_ms": {
|
||||
"$ref": "#/$defs/grpcPositiveMs"
|
||||
},
|
||||
"unary_timeout_ms": {
|
||||
"$ref": "#/$defs/grpcPositiveMs"
|
||||
},
|
||||
"close_timeout_ms": {
|
||||
"$ref": "#/$defs/grpcPositiveMs"
|
||||
},
|
||||
"reconnect": {
|
||||
"$ref": "#/$defs/grpcReconnectOverride"
|
||||
},
|
||||
"request_channel_capacity": {
|
||||
"$ref": "#/$defs/grpcCapacity"
|
||||
},
|
||||
"update_channel_capacity": {
|
||||
"$ref": "#/$defs/grpcCapacity"
|
||||
},
|
||||
"max_inbound_message_size_bytes": {
|
||||
"$ref": "#/$defs/grpcMessageSize"
|
||||
},
|
||||
"max_outbound_message_size_bytes": {
|
||||
"$ref": "#/$defs/grpcMessageSize"
|
||||
}
|
||||
}
|
||||
},
|
||||
"grpcMetadata": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"key",
|
||||
"value"
|
||||
],
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[0-9a-z_.-]+$"
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"maxLength": 8192
|
||||
}
|
||||
}
|
||||
},
|
||||
"grpcEndpoint": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"name",
|
||||
"enabled",
|
||||
"provider",
|
||||
"cluster",
|
||||
"protocol",
|
||||
"url"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"$ref": "#/$defs/descriptor"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"provider": {
|
||||
"$ref": "#/$defs/descriptor"
|
||||
},
|
||||
"cluster": {
|
||||
"$ref": "#/$defs/descriptor"
|
||||
},
|
||||
"protocol": {
|
||||
"const": "solana_yellowstone"
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 8192
|
||||
},
|
||||
"metadata": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"$ref": "#/$defs/grpcMetadata"
|
||||
}
|
||||
},
|
||||
"secret_metadata": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"$ref": "#/$defs/grpcMetadata"
|
||||
}
|
||||
},
|
||||
"session": {
|
||||
"$ref": "#/$defs/grpcSessionOverride"
|
||||
}
|
||||
}
|
||||
},
|
||||
"profileV3": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"profile_id",
|
||||
"endpoints",
|
||||
"ws_endpoints"
|
||||
],
|
||||
"properties": {
|
||||
"profile_id": {
|
||||
"$ref": "#/$defs/profileId"
|
||||
},
|
||||
"endpoints": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/httpEndpoint"
|
||||
}
|
||||
},
|
||||
"ws_endpoints": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/wsEndpoint"
|
||||
}
|
||||
},
|
||||
"grpc_endpoints": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 128,
|
||||
"items": {
|
||||
"$ref": "#/$defs/grpcEndpoint"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"documentV3": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"format_version",
|
||||
"retry",
|
||||
"ws_defaults",
|
||||
"grpc_defaults",
|
||||
"default_profile",
|
||||
"profiles"
|
||||
],
|
||||
"properties": {
|
||||
"format_version": {
|
||||
"const": 3
|
||||
},
|
||||
"retry": {
|
||||
"$ref": "#/$defs/retry"
|
||||
},
|
||||
"ws_defaults": {
|
||||
"$ref": "#/$defs/wsSession"
|
||||
},
|
||||
"grpc_defaults": {
|
||||
"$ref": "#/$defs/grpcSession"
|
||||
},
|
||||
"default_profile": {
|
||||
"$ref": "#/$defs/profileId"
|
||||
},
|
||||
"profiles": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/profileV3"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
75
config/std.offchain_transport.json
Normal file
75
config/std.offchain_transport.json
Normal file
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"default_profile": "public_keyless",
|
||||
"profiles": [
|
||||
{
|
||||
"profile_id": "public_keyless",
|
||||
"market_price": {
|
||||
"birdeye": {
|
||||
"enabled": false
|
||||
},
|
||||
"coinbase_exchange": {
|
||||
"enabled": true
|
||||
},
|
||||
"coingecko": {
|
||||
"enabled": true,
|
||||
"access_mode": "keyless"
|
||||
},
|
||||
"coinmarketcap": {
|
||||
"enabled": true,
|
||||
"access_mode": "keyless"
|
||||
},
|
||||
"coinpaprika": {
|
||||
"enabled": true
|
||||
},
|
||||
"dexscreener": {
|
||||
"enabled": false
|
||||
},
|
||||
"jupiter": {
|
||||
"enabled": true,
|
||||
"access_mode": "keyless"
|
||||
},
|
||||
"kraken": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"profile_id": "all_free",
|
||||
"market_price": {
|
||||
"birdeye": {
|
||||
"enabled": true,
|
||||
"api_key": "${KSP_SECRET_BIRDEYE_API_KEY}"
|
||||
},
|
||||
"coinbase_exchange": {
|
||||
"enabled": true
|
||||
},
|
||||
"coingecko": {
|
||||
"enabled": true,
|
||||
"access_mode": "demo",
|
||||
"api_key": "${KSP_SECRET_COINGECKO_DEMO_API_KEY}"
|
||||
},
|
||||
"coinmarketcap": {
|
||||
"enabled": true,
|
||||
"access_mode": "basic",
|
||||
"api_key": "${KSP_SECRET_COINMARKETCAP_API_KEY}"
|
||||
},
|
||||
"coinpaprika": {
|
||||
"enabled": true
|
||||
},
|
||||
"dexscreener": {
|
||||
"enabled": true,
|
||||
"sol_usd_pair_address": "${KSP_PUBLIC_DEXSCREENER_SOL_USD_PAIR_ADDRESS}"
|
||||
},
|
||||
"jupiter": {
|
||||
"enabled": true,
|
||||
"access_mode": "free",
|
||||
"api_key": "${KSP_SECRET_JUPITER_API_KEY}"
|
||||
},
|
||||
"kraken": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
81
config/std.store.json
Normal file
81
config/std.store.json
Normal file
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"format_version": 2,
|
||||
"default_profile": "devnet",
|
||||
"profiles": [
|
||||
{
|
||||
"profile_id": "devnet",
|
||||
"network": "devnet",
|
||||
"backend": "postgres",
|
||||
"postgres": {
|
||||
"connection_uri": "${KSP_SECRET_STORE_DEVNET_POSTGRES_URI:-postgresql://localhost/ksp_devnet}",
|
||||
"pool": {
|
||||
"max_connections": 8,
|
||||
"connect_timeout_ms": 10000,
|
||||
"wait_timeout_ms": 5000,
|
||||
"create_timeout_ms": 10000,
|
||||
"recycle_timeout_ms": 5000
|
||||
},
|
||||
"tls": {
|
||||
"mode": "verify_full"
|
||||
},
|
||||
"bootstrap": {
|
||||
"schema_autocreate": true,
|
||||
"schema_autoupdate": true,
|
||||
"migration_timeout_ms": 30000,
|
||||
"migration_lock_timeout_ms": 10000
|
||||
},
|
||||
"shutdown_timeout_ms": 5000
|
||||
}
|
||||
},
|
||||
{
|
||||
"profile_id": "mainnet",
|
||||
"network": "mainnet-beta",
|
||||
"backend": "postgres",
|
||||
"postgres": {
|
||||
"connection_uri": "${KSP_SECRET_STORE_MAINNET_POSTGRES_URI:-postgresql://localhost/ksp_mainnet}",
|
||||
"pool": {
|
||||
"max_connections": 8,
|
||||
"connect_timeout_ms": 10000,
|
||||
"wait_timeout_ms": 5000,
|
||||
"create_timeout_ms": 10000,
|
||||
"recycle_timeout_ms": 5000
|
||||
},
|
||||
"tls": {
|
||||
"mode": "verify_full"
|
||||
},
|
||||
"bootstrap": {
|
||||
"schema_autocreate": true,
|
||||
"schema_autoupdate": true,
|
||||
"migration_timeout_ms": 30000,
|
||||
"migration_lock_timeout_ms": 10000
|
||||
},
|
||||
"shutdown_timeout_ms": 5000
|
||||
}
|
||||
},
|
||||
{
|
||||
"profile_id": "testnet",
|
||||
"network": "testnet",
|
||||
"backend": "postgres",
|
||||
"postgres": {
|
||||
"connection_uri": "${KSP_SECRET_STORE_TESTNET_POSTGRES_URI:-postgresql://localhost/ksp_testnet}",
|
||||
"pool": {
|
||||
"max_connections": 8,
|
||||
"connect_timeout_ms": 10000,
|
||||
"wait_timeout_ms": 5000,
|
||||
"create_timeout_ms": 10000,
|
||||
"recycle_timeout_ms": 5000
|
||||
},
|
||||
"tls": {
|
||||
"mode": "verify_full"
|
||||
},
|
||||
"bootstrap": {
|
||||
"schema_autocreate": true,
|
||||
"schema_autoupdate": true,
|
||||
"migration_timeout_ms": 30000,
|
||||
"migration_lock_timeout_ms": 10000
|
||||
},
|
||||
"shutdown_timeout_ms": 5000
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"format_version": 2,
|
||||
"format_version": 3,
|
||||
"retry": {
|
||||
"max_retries": 2,
|
||||
"initial_backoff_ms": 100,
|
||||
@@ -22,6 +22,20 @@
|
||||
"max_frame_size_bytes": 16777216,
|
||||
"max_write_buffer_size_bytes": 1048576
|
||||
},
|
||||
"grpc_defaults": {
|
||||
"connect_timeout_ms": 10000,
|
||||
"unary_timeout_ms": 10000,
|
||||
"close_timeout_ms": 5000,
|
||||
"reconnect": {
|
||||
"max_retries": 5,
|
||||
"initial_backoff_ms": 250,
|
||||
"max_backoff_ms": 5000
|
||||
},
|
||||
"request_channel_capacity": 128,
|
||||
"update_channel_capacity": 256,
|
||||
"max_inbound_message_size_bytes": 67108864,
|
||||
"max_outbound_message_size_bytes": 67108864
|
||||
},
|
||||
"default_profile": "devnet_public",
|
||||
"profiles": [
|
||||
{
|
||||
@@ -65,6 +79,64 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"profile_id": "orbitflare_devnet",
|
||||
"endpoints": [
|
||||
{
|
||||
"name": "solana_devnet_public",
|
||||
"enabled": true,
|
||||
"provider": "solana-public",
|
||||
"cluster": "devnet",
|
||||
"url": "${KSP_PUBLIC_SOLANA_DEVNET_HTTP_URL:-https://api.devnet.solana.com}",
|
||||
"connect_timeout_ms": 5000,
|
||||
"request_timeout_ms": 15000,
|
||||
"max_idle_connections_per_host": 8,
|
||||
"roles": [
|
||||
{
|
||||
"role": "default",
|
||||
"enabled": true,
|
||||
"request_kinds": [
|
||||
"*"
|
||||
],
|
||||
"priority": 100,
|
||||
"limits": {
|
||||
"requests_per_second": 5,
|
||||
"burst_capacity": 10,
|
||||
"max_concurrent_requests": 8,
|
||||
"pause_after_rate_limit_ms": 1000
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ws_endpoints": [
|
||||
{
|
||||
"name": "solana_devnet_public_ws",
|
||||
"enabled": true,
|
||||
"provider": "solana-public",
|
||||
"cluster": "devnet",
|
||||
"kind": "solana_standard",
|
||||
"url": "${KSP_PUBLIC_SOLANA_DEVNET_WS_URL:-wss://api.devnet.solana.com}"
|
||||
}
|
||||
],
|
||||
"grpc_endpoints": [
|
||||
{
|
||||
"name": "orbitflare_solana_devnet_yellowstone",
|
||||
"enabled": true,
|
||||
"provider": "orbitflare",
|
||||
"cluster": "devnet",
|
||||
"protocol": "solana_yellowstone",
|
||||
"url": "http://devnet.rpc.orbitflare.com:10000",
|
||||
"metadata": [],
|
||||
"secret_metadata": [
|
||||
{
|
||||
"key": "x-token",
|
||||
"value": "${KSP_SECRET_ORBITFLARE_DEVNET_GRPC_X_TOKEN}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"profile_id": "mainnet_public",
|
||||
"endpoints": [
|
||||
@@ -105,6 +177,120 @@
|
||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_WS_URL:-wss://api.mainnet-beta.solana.com}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"profile_id": "publicnode_mainnet",
|
||||
"endpoints": [
|
||||
{
|
||||
"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}",
|
||||
"connect_timeout_ms": 5000,
|
||||
"request_timeout_ms": 15000,
|
||||
"max_idle_connections_per_host": 8,
|
||||
"roles": [
|
||||
{
|
||||
"role": "default",
|
||||
"enabled": true,
|
||||
"request_kinds": [
|
||||
"*"
|
||||
],
|
||||
"priority": 100,
|
||||
"limits": {
|
||||
"requests_per_second": 5,
|
||||
"burst_capacity": 10,
|
||||
"max_concurrent_requests": 8,
|
||||
"pause_after_rate_limit_ms": 1000
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ws_endpoints": [
|
||||
{
|
||||
"name": "solana_mainnet_public_ws",
|
||||
"enabled": true,
|
||||
"provider": "solana-public",
|
||||
"cluster": "mainnet-beta",
|
||||
"kind": "solana_standard",
|
||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_WS_URL:-wss://api.mainnet-beta.solana.com}"
|
||||
}
|
||||
],
|
||||
"grpc_endpoints": [
|
||||
{
|
||||
"name": "publicnode_solana_mainnet_yellowstone",
|
||||
"enabled": true,
|
||||
"provider": "publicnode",
|
||||
"cluster": "mainnet-beta",
|
||||
"protocol": "solana_yellowstone",
|
||||
"url": "https://solana-yellowstone-grpc.publicnode.com:443",
|
||||
"secret_metadata": [
|
||||
{
|
||||
"key": "x-token",
|
||||
"value": "${KSP_SECRET_PUBLICNODE_MAINNET_GRPC_X_TOKEN}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"profile_id": "publicnode_testnet",
|
||||
"endpoints": [
|
||||
{
|
||||
"name": "solana_testnet_public",
|
||||
"enabled": true,
|
||||
"provider": "solana-public",
|
||||
"cluster": "testnet",
|
||||
"url": "https://api.testnet.solana.com",
|
||||
"connect_timeout_ms": 5000,
|
||||
"request_timeout_ms": 15000,
|
||||
"max_idle_connections_per_host": 8,
|
||||
"roles": [
|
||||
{
|
||||
"role": "default",
|
||||
"enabled": true,
|
||||
"request_kinds": [
|
||||
"*"
|
||||
],
|
||||
"priority": 100,
|
||||
"limits": {
|
||||
"requests_per_second": 5,
|
||||
"burst_capacity": 10,
|
||||
"max_concurrent_requests": 8,
|
||||
"pause_after_rate_limit_ms": 1000
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ws_endpoints": [
|
||||
{
|
||||
"name": "solana_testnet_public_ws",
|
||||
"enabled": true,
|
||||
"provider": "solana-public",
|
||||
"cluster": "testnet",
|
||||
"kind": "solana_standard",
|
||||
"url": "wss://api.testnet.solana.com"
|
||||
}
|
||||
],
|
||||
"grpc_endpoints": [
|
||||
{
|
||||
"name": "publicnode_solana_testnet_yellowstone",
|
||||
"enabled": true,
|
||||
"provider": "publicnode",
|
||||
"cluster": "testnet",
|
||||
"protocol": "solana_yellowstone",
|
||||
"url": "https://solana-testnet-yellowstone-grpc.publicnode.com:443",
|
||||
"secret_metadata": [
|
||||
{
|
||||
"key": "x-token",
|
||||
"value": "${KSP_SECRET_PUBLICNODE_TESTNET_GRPC_X_TOKEN}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# file: crates/ksp-app-config-desk/Cargo.toml
|
||||
# version: 8
|
||||
# version: 9
|
||||
|
||||
[package]
|
||||
name = "ksp-app-config-desk"
|
||||
@@ -22,6 +22,7 @@ path = "src/main.rs"
|
||||
tauri-build.workspace = true
|
||||
|
||||
[dependencies]
|
||||
chrono = { workspace = true, features = ["std", "now"] }
|
||||
fs2.workspace = true
|
||||
ksp-config-lib = { path = "../ksp-config-lib" }
|
||||
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||
@@ -30,7 +31,6 @@ serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
tauri.workspace = true
|
||||
tauri-plugin-tracing.workspace = true
|
||||
chrono = { workspace = true, features = ["std", "now"] }
|
||||
tokio = { workspace = true, features = ["time"] }
|
||||
ts-rs.workspace = true
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<!-- file: crates/ksp-app-config-desk/frontend/splash.html -->
|
||||
<!-- version: 2 -->
|
||||
<!-- version: 3 -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
||||
<link rel="icon" href="imgs/logo.png">
|
||||
<link rel="stylesheet" href="sass/splash.scss">
|
||||
<title>KSP Config Desk — Chargement</title>
|
||||
</head>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "KSP Config Desk",
|
||||
"version": "0.2.6",
|
||||
"identifier": "com.sasedev.ksp-app-config-desk",
|
||||
"build": {
|
||||
"beforeDevCommand": {
|
||||
@@ -53,12 +52,17 @@
|
||||
"icons/favicon.ico"
|
||||
],
|
||||
"resources": {
|
||||
"../../config/composite.ksp-app-solprices-desk.json": "config/composite.ksp-app-solprices-desk.json",
|
||||
"../../config/composite.ksp-app-wallet-desk.json": "config/composite.ksp-app-wallet-desk.json",
|
||||
"../../config/std.logging.json": "config/std.logging.json",
|
||||
"../../config/std.offchain_transport.json": "config/std.offchain_transport.json",
|
||||
"../../config/std.store.json": "config/std.store.json",
|
||||
"../../config/std.transport.json": "config/std.transport.json",
|
||||
"../../config/std.wallet.json": "config/std.wallet.json",
|
||||
"../../config/schemas/composite.schema.json": "config/schemas/composite.schema.json",
|
||||
"../../config/schemas/std.logging.schema.json": "config/schemas/std.logging.schema.json",
|
||||
"../../config/schemas/std.offchain_transport.schema.json": "config/schemas/std.offchain_transport.schema.json",
|
||||
"../../config/schemas/std.store.schema.json": "config/schemas/std.store.schema.json",
|
||||
"../../config/schemas/std.transport.schema.json": "config/schemas/std.transport.schema.json",
|
||||
"../../config/schemas/std.wallet.schema.json": "config/schemas/std.wallet.schema.json"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-config-desk/tests/desktop_contract.rs
|
||||
// version: 7
|
||||
// version: 11
|
||||
|
||||
//! Desktop build/shell contract audits for Config Desk.
|
||||
|
||||
@@ -26,43 +26,6 @@ fn read_json(path: &std::path::Path) -> serde_json::Value {
|
||||
};
|
||||
}
|
||||
|
||||
fn parse_semver_core(value: &str) -> std::option::Option<(u64, u64, u64)> {
|
||||
let core = match value.split_once('-') {
|
||||
std::option::Option::Some((core, _)) => core,
|
||||
std::option::Option::None => value,
|
||||
};
|
||||
let mut parts = core.split('.');
|
||||
let major = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
let minor = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
let patch = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
if parts.next().is_some() {
|
||||
return std::option::Option::None;
|
||||
}
|
||||
return std::option::Option::Some((major, minor, patch));
|
||||
}
|
||||
|
||||
fn assert_packaged_version_floor(value: std::option::Option<&str>, field: &str) {
|
||||
assert!(value.is_some(), "{field} packaged version must exist");
|
||||
let value = match value {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let parsed = parse_semver_core(value);
|
||||
assert!(parsed.is_some(), "{field} packaged version must be SemVer-like");
|
||||
if let std::option::Option::Some(parsed) = parsed {
|
||||
assert!(parsed >= (0, 2, 6), "{field} packaged version must be >= 0.2.6");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tauri_and_frontend_build_contracts_remain_explicit() {
|
||||
let root = app_root();
|
||||
@@ -141,17 +104,31 @@ fn pre_014_template_uses_sidebar_navigation_and_kbot_style_splash_contract() {
|
||||
fn pre_018_packaged_runtime_bundles_config_resources_and_activates_shared_writable_root() {
|
||||
let root = app_root();
|
||||
let tauri = read_json(root.join("tauri.conf.json").as_path());
|
||||
let tauri_version = tauri.pointer("/version").and_then(serde_json::Value::as_str);
|
||||
assert_packaged_version_floor(tauri_version, "tauri.conf.json");
|
||||
assert!(tauri.pointer("/version").is_none(), "Config Desk must let Tauri derive the bundle version from Cargo");
|
||||
let package = read_json(root.join("package.json").as_path());
|
||||
let package_version = package.pointer("/version").and_then(serde_json::Value::as_str);
|
||||
assert_packaged_version_floor(package_version, "package.json");
|
||||
assert_eq!(tauri_version, package_version, "desktop package metadata versions must remain synchronized");
|
||||
assert_eq!(package.pointer("/private").and_then(serde_json::Value::as_bool), std::option::Option::Some(true));
|
||||
let resources = tauri.pointer("/bundle/resources").and_then(serde_json::Value::as_object);
|
||||
assert!(resources.is_some(), "packaged Config resources map must exist");
|
||||
if let std::option::Option::Some(resources) = resources {
|
||||
assert_eq!(resources.len(), 8);
|
||||
assert_eq!(resources.len(), 13);
|
||||
assert_eq!(
|
||||
resources.get("../../config/composite.ksp-app-solprices-desk.json").and_then(serde_json::Value::as_str),
|
||||
std::option::Option::Some("config/composite.ksp-app-solprices-desk.json"),
|
||||
);
|
||||
assert_eq!(resources.get("../../config/std.logging.json").and_then(serde_json::Value::as_str), std::option::Option::Some("config/std.logging.json"));
|
||||
assert_eq!(
|
||||
resources.get("../../config/std.offchain_transport.json").and_then(serde_json::Value::as_str),
|
||||
std::option::Option::Some("config/std.offchain_transport.json"),
|
||||
);
|
||||
assert_eq!(
|
||||
resources.get("../../config/schemas/std.offchain_transport.schema.json").and_then(serde_json::Value::as_str),
|
||||
std::option::Option::Some("config/schemas/std.offchain_transport.schema.json"),
|
||||
);
|
||||
assert_eq!(resources.get("../../config/std.store.json").and_then(serde_json::Value::as_str), std::option::Option::Some("config/std.store.json"),);
|
||||
assert_eq!(
|
||||
resources.get("../../config/schemas/std.store.schema.json").and_then(serde_json::Value::as_str),
|
||||
std::option::Option::Some("config/schemas/std.store.schema.json"),
|
||||
);
|
||||
assert_eq!(
|
||||
resources.get("../../config/schemas/std.wallet.schema.json").and_then(serde_json::Value::as_str),
|
||||
std::option::Option::Some("config/schemas/std.wallet.schema.json"),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-config-desk/unit_tests/profiles.rs
|
||||
// version: 6
|
||||
// version: 8
|
||||
|
||||
#[test]
|
||||
fn profile_inventory_exposes_registered_profile_documents() {
|
||||
@@ -15,12 +15,18 @@ fn profile_inventory_exposes_registered_profile_documents() {
|
||||
assert!(inventory.iter().any(|document| -> bool {
|
||||
return document.file_id == ksp_config_lib::FILE_ID_STD_TRANSPORT;
|
||||
}));
|
||||
assert!(inventory.iter().any(|document| -> bool {
|
||||
return document.file_id == ksp_config_lib::FILE_ID_STD_STORE;
|
||||
}));
|
||||
assert!(inventory.iter().any(|document| -> bool {
|
||||
return document.file_id == ksp_config_lib::FILE_ID_STD_WALLET;
|
||||
}));
|
||||
assert!(inventory.iter().any(|document| -> bool {
|
||||
return document.file_id == ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK;
|
||||
}));
|
||||
assert!(inventory.iter().any(|document| -> bool {
|
||||
return document.file_id == ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_SOLPRICES_DESK;
|
||||
}));
|
||||
for document in inventory {
|
||||
assert!(!document.default_profile.is_empty());
|
||||
assert!(document.profile_ids.iter().any(|profile_id| -> bool {
|
||||
|
||||
41
crates/ksp-app-solprices-desk/Cargo.toml
Normal file
41
crates/ksp-app-solprices-desk/Cargo.toml
Normal file
@@ -0,0 +1,41 @@
|
||||
# file: crates/ksp-app-solprices-desk/Cargo.toml
|
||||
# version: 2
|
||||
|
||||
[package]
|
||||
name = "ksp-app-solprices-desk"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "ksp_app_solprices_desk_lib"
|
||||
path = "src/lib.rs"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[[bin]]
|
||||
name = "ksp-app-solprices-desk"
|
||||
path = "src/main.rs"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build.workspace = true
|
||||
|
||||
[dependencies]
|
||||
chrono = { workspace = true, features = ["std", "now"] }
|
||||
fs2.workspace = true
|
||||
ksp-config-lib = { path = "../ksp-config-lib" }
|
||||
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||
ksp-logging-lib = { path = "../ksp-logging-lib" }
|
||||
ksp-offchain-transport-lib = { path = "../ksp-offchain-transport-lib" }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
tauri.workspace = true
|
||||
tauri-plugin-tracing.workspace = true
|
||||
tokio = { workspace = true, features = ["time"] }
|
||||
ts-rs.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
154
crates/ksp-app-solprices-desk/README.md
Normal file
154
crates/ksp-app-solprices-desk/README.md
Normal file
@@ -0,0 +1,154 @@
|
||||
<!-- file: crates/ksp-app-solprices-desk/README.md -->
|
||||
<!-- version: 2 -->
|
||||
|
||||
# `ksp-app-solprices-desk`
|
||||
|
||||
`ksp-app-solprices-desk` est l'application desktop spécialisée de visualisation et de refresh manuel des prix SOL/USD fournis par `ksp-offchain-transport-lib`.
|
||||
|
||||
L'application reste une **HID provider-neutral** : elle consomme le registry, les observations, les disponibilités et les opérations génériques du service Off-chain sans connaître les endpoints, credentials, limites ou wire formats propres aux providers.
|
||||
|
||||
## Package
|
||||
|
||||
```text
|
||||
package : ksp-app-solprices-desk
|
||||
lib : ksp_app_solprices_desk_lib
|
||||
bin : ksp-app-solprices-desk
|
||||
```
|
||||
|
||||
Le binaire reste un launcher mince. La bibliothèque applicative possède l'assemblage Tauri, le bootstrap Config/Logging/Off-chain, l'état de présentation en mémoire et les commands Tauri.
|
||||
|
||||
## Responsabilités
|
||||
|
||||
```text
|
||||
ksp-config-lib -> composite, profils, secrets et runtime packagé
|
||||
ksp-offchain-transport-lib -> providers, HTTP, registry, availability, limits et refresh
|
||||
ksp-logging-lib -> logging/tracing applicatif
|
||||
ksp-app-solprices-desk -> orchestration Tauri + projections provider-neutral sûres
|
||||
```
|
||||
|
||||
Le Desk ne possède aucun client HTTP provider et ne réimplémente ni admission, ni cooldown, ni retry provider.
|
||||
|
||||
## Vues
|
||||
|
||||
### Prices
|
||||
|
||||
La vue `Prices` affiche l'inventaire SOL/USD dans l'ordre stable du registry et conserve chaque observation séparée.
|
||||
|
||||
La projection comprend notamment :
|
||||
|
||||
- identité/display provider et paire ;
|
||||
- sémantique et mode d'authentification ;
|
||||
- availability générique ;
|
||||
- prix exact sous forme de `String` ;
|
||||
- timestamp provider lorsqu'il existe ;
|
||||
- timestamp de réception KSP ;
|
||||
- `retry_at` lorsqu'il existe ;
|
||||
- état `Refreshing` local à la présentation.
|
||||
|
||||
Aucune moyenne, aucun fallback automatique et aucun prix canonique ne sont produits par cette application.
|
||||
|
||||
Les actions manuelles sont :
|
||||
|
||||
```text
|
||||
Refresh
|
||||
Refresh selected
|
||||
Refresh all
|
||||
Select all providers
|
||||
Clear selection
|
||||
```
|
||||
|
||||
Le batch applicatif est borné à `1..=64` IDs opaques avant parsing/dispatch. Les doublons, IDs inconnus et conflits `in_flight` sont rejetés avant mutation partielle.
|
||||
|
||||
### Diagnostics
|
||||
|
||||
La vue `Diagnostics` expose uniquement des informations de runtime sûres : identité/version applicative, profils résolus et compte de providers. Aucun endpoint, header, API key, payload provider ou prix n'est journalisé comme diagnostic brut.
|
||||
|
||||
## Config et profils
|
||||
|
||||
Le composite dédié est :
|
||||
|
||||
```text
|
||||
cfg.composite.ksp-app-solprices-desk
|
||||
```
|
||||
|
||||
Il compose :
|
||||
|
||||
```text
|
||||
logging
|
||||
Off-chain Transport
|
||||
```
|
||||
|
||||
Profils committed :
|
||||
|
||||
```text
|
||||
public_keyless profil par défaut
|
||||
all_free modes gratuits incluant les credentials Config requis
|
||||
tests profil déterministe de composition
|
||||
```
|
||||
|
||||
Le frontend ne sélectionne ni provider concret ni secret. La résolution des credentials reste entièrement possédée par Config.
|
||||
|
||||
## Frontières desktop
|
||||
|
||||
Le frontend utilise uniquement :
|
||||
|
||||
```text
|
||||
core:default
|
||||
tracing:default
|
||||
```
|
||||
|
||||
Il ne possède aucun accès direct filesystem, HTTP, shell ou persistence navigateur. Les refresh passent uniquement par les commands Rust centralisées dans `tauri.rs`.
|
||||
|
||||
Il n'existe aucun polling, auto-refresh ou scheduler prix dans le Desk. Les deadlines/cooldowns affichés proviennent de `ksp-offchain-transport-lib`.
|
||||
|
||||
## Ports et build
|
||||
|
||||
Les ports réservés sont :
|
||||
|
||||
```text
|
||||
Vite HTTP : 1434
|
||||
Vite WS : 1435
|
||||
```
|
||||
|
||||
Le frontend build est isolé sous :
|
||||
|
||||
```text
|
||||
builds/khadhroony-solana-project/ksp-app-solprices-desk/dist
|
||||
```
|
||||
|
||||
La version du binaire et des bundles Tauri provient de la version Cargo résolue. `tauri.conf.json` ne duplique pas cette version ; `package.json` reste un manifeste frontend privé non autoritatif pour la release KSP.
|
||||
|
||||
## Runtime packagé
|
||||
|
||||
Les onze resources Config/Schemas enregistrées sont embarquées dans le bundle. Au démarrage release, `ksp-config-lib` prépare la racine KSP user-writable commune, seed les documents absents, resynchronise les schemas et ne bundle jamais `.env`.
|
||||
|
||||
## Développement et validation
|
||||
|
||||
```bash
|
||||
(cd crates/ksp-app-solprices-desk && cargo tauri dev)
|
||||
```
|
||||
|
||||
Gate Rust courant :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-app-solprices-desk
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Smoke live de composition opt-in :
|
||||
|
||||
```bash
|
||||
cargo test -p ksp-app-solprices-desk --test market_price_composition_live_smoke -- --ignored --nocapture
|
||||
```
|
||||
|
||||
Build production :
|
||||
|
||||
```bash
|
||||
(cd crates/ksp-app-solprices-desk && cargo tauri build)
|
||||
```
|
||||
|
||||
Voir également [`USAGE.md`](USAGE.md), [`../ksp-offchain-transport-lib/README.md`](../ksp-offchain-transport-lib/README.md) et [`../../docs/validation/015-V0_2_12_SOL_PRICES_DESK.md`](../../docs/validation/015-V0_2_12_SOL_PRICES_DESK.md).
|
||||
149
crates/ksp-app-solprices-desk/USAGE.md
Normal file
149
crates/ksp-app-solprices-desk/USAGE.md
Normal file
@@ -0,0 +1,149 @@
|
||||
<!-- file: crates/ksp-app-solprices-desk/USAGE.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Utilisation de `ksp-app-solprices-desk`
|
||||
|
||||
## 1. Lancement de développement
|
||||
|
||||
Depuis la racine du workspace :
|
||||
|
||||
```bash
|
||||
(cd crates/ksp-app-solprices-desk && cargo tauri dev)
|
||||
```
|
||||
|
||||
Le launcher debug normalise son current working directory vers la racine du workspace avant le bootstrap Config.
|
||||
|
||||
Les ports réservés sont :
|
||||
|
||||
```text
|
||||
Vite HTTP : 1434
|
||||
Vite WS : 1435
|
||||
```
|
||||
|
||||
## 2. Profil de prix
|
||||
|
||||
Le composite `cfg.composite.ksp-app-solprices-desk` utilise `public_keyless` par défaut.
|
||||
|
||||
Profils committed :
|
||||
|
||||
```text
|
||||
public_keyless providers utilisables sans secret
|
||||
all_free modes gratuits avec credentials Config lorsqu'ils sont requis
|
||||
tests composition de test
|
||||
```
|
||||
|
||||
La sélection d'un autre profil appartient aux mécanismes Config ; l'interface ne demande jamais directement une API key et ne choisit pas d'endpoint provider.
|
||||
|
||||
## 3. Vue Prices
|
||||
|
||||
La vue `Prices` présente une row par provider du registry Off-chain.
|
||||
|
||||
Les informations principales sont :
|
||||
|
||||
- paire SOL/USD ;
|
||||
- sémantique de prix ;
|
||||
- mode d'authentification ;
|
||||
- availability ;
|
||||
- prix exact lorsqu'une observation existe ;
|
||||
- timestamp provider lorsqu'il est réellement fourni ;
|
||||
- timestamp de réception KSP ;
|
||||
- prochaine deadline `retry_at` lorsqu'elle existe.
|
||||
|
||||
`Not supplied` signifie qu'un provider n'a pas fourni de timestamp exploitable. Cette absence n'est jamais remplacée artificiellement par le timestamp KSP.
|
||||
|
||||
## 4. Refresh individuel
|
||||
|
||||
Le bouton `Refresh` d'une row déclenche uniquement le provider correspondant via `MarketPriceService::refresh`.
|
||||
|
||||
Pendant l'appel, la row passe temporairement à `Refreshing`. Le Desk ne garde aucun mutex de présentation pendant l'I/O réseau.
|
||||
|
||||
Si le service ne produit pas de nouvelle observation, la dernière observation réussie peut rester visible tandis que l'availability/retry est actualisée.
|
||||
|
||||
## 5. Refresh sélectionné
|
||||
|
||||
Cocher une ou plusieurs rows puis utiliser :
|
||||
|
||||
```text
|
||||
Refresh selected
|
||||
```
|
||||
|
||||
La sélection est reconstruite dans l'ordre courant du registry. Les doublons, IDs inconnus, conflits `in_flight`, batch vide et batch supérieur à 64 IDs sont rejetés côté Rust avant dispatch partiel.
|
||||
|
||||
Les contrôles :
|
||||
|
||||
```text
|
||||
Select all providers
|
||||
Clear selection
|
||||
```
|
||||
|
||||
ne déclenchent aucun accès réseau par eux-mêmes.
|
||||
|
||||
## 6. Refresh global
|
||||
|
||||
```text
|
||||
Refresh all
|
||||
```
|
||||
|
||||
appelle la surface générique `MarketPriceService::refresh_all` et conserve l'ordre déterministe du service.
|
||||
|
||||
Les providers disabled, en cooldown ou temporairement indisponibles restent classifiés par Off-chain Transport. Le frontend ne contourne jamais ces règles et n'implémente aucun retry local.
|
||||
|
||||
## 7. Lecture des états
|
||||
|
||||
Les états visibles sont provider-neutral, notamment :
|
||||
|
||||
```text
|
||||
Ready
|
||||
Cooling down
|
||||
Authentication unavailable
|
||||
Disabled
|
||||
Misconfigured
|
||||
Quota unavailable
|
||||
Temporarily unavailable
|
||||
Refreshing
|
||||
```
|
||||
|
||||
Le résumé en haut de la vue indique les nombres de providers, rows ready, observations disponibles, états nécessitant attention et refresh en cours.
|
||||
|
||||
Aucune moyenne ou fusion de prix n'est calculée dans SOL Prices Desk.
|
||||
|
||||
## 8. Diagnostics et sécurité
|
||||
|
||||
La vue `Diagnostics` expose uniquement les informations sûres de composition/runtime.
|
||||
|
||||
Le frontend :
|
||||
|
||||
- ne fait aucun `fetch`/XHR provider ;
|
||||
- ne possède aucun endpoint ou header provider ;
|
||||
- ne stocke aucun secret dans `localStorage`/`sessionStorage` ;
|
||||
- n'utilise aucun scheduler ou polling prix ;
|
||||
- ne journalise aucun prix ou payload provider brut.
|
||||
|
||||
Les actions utilisateur sont instrumentées via le bridge Logging KSP.
|
||||
|
||||
## 9. Runtime packagé
|
||||
|
||||
En build release, les documents Config et schemas enregistrés sont embarqués comme resources Tauri. `ksp-config-lib` prépare ensuite la racine KSP user-writable commune ; les Config utilisateur existantes sont conservées, les schemas sont resynchronisés et `.env` n'est jamais embarqué.
|
||||
|
||||
La version de bundle est dérivée de Cargo. `package.json` reste un manifeste frontend privé.
|
||||
|
||||
## 10. Validation de publication
|
||||
|
||||
Avant le build :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.12
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-app-solprices-desk
|
||||
cargo test --workspace
|
||||
cargo test -p ksp-app-solprices-desk --test market_price_composition_live_smoke -- --ignored --nocapture
|
||||
```
|
||||
|
||||
Le build production est :
|
||||
|
||||
```bash
|
||||
(cd crates/ksp-app-solprices-desk && cargo tauri build)
|
||||
```
|
||||
12
crates/ksp-app-solprices-desk/build.rs
Normal file
12
crates/ksp-app-solprices-desk/build.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
// file: crates/ksp-app-solprices-desk/build.rs
|
||||
// version: 1
|
||||
|
||||
//! Build script for the KSP SOL prices desktop application.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
13
crates/ksp-app-solprices-desk/capabilities/default.json
Normal file
13
crates/ksp-app-solprices-desk/capabilities/default.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default capability for KSP SOL Prices Desk",
|
||||
"windows": [
|
||||
"splash",
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"tracing:default"
|
||||
]
|
||||
}
|
||||
BIN
crates/ksp-app-solprices-desk/frontend/fonts/DOS_Amazigh.ttf
Normal file
BIN
crates/ksp-app-solprices-desk/frontend/fonts/DOS_Amazigh.ttf
Normal file
Binary file not shown.
25
crates/ksp-app-solprices-desk/frontend/fonts/README.md
Normal file
25
crates/ksp-app-solprices-desk/frontend/fonts/README.md
Normal file
@@ -0,0 +1,25 @@
|
||||
<!-- file: crates/ksp-app-solprices-desk/frontend/fonts/README.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Fonts du splash SOL Prices Desk
|
||||
|
||||
SOL Prices Desk réutilise le gabarit de splash KSP déjà stabilisé dans Config Desk et Wallet Desk et conserve la police locale :
|
||||
|
||||
```text
|
||||
DOS_Amazigh.ttf
|
||||
font-family: Dos Amazigh
|
||||
```
|
||||
|
||||
Destination :
|
||||
|
||||
```text
|
||||
crates/ksp-app-solprices-desk/frontend/fonts/DOS_Amazigh.ttf
|
||||
```
|
||||
|
||||
Empreinte SHA-256 de l'asset repris du gabarit :
|
||||
|
||||
```text
|
||||
a01ff4b5a0d699db7cefcf5336bf0e379ff6fbf0d096087571a13c8782f9aa02
|
||||
```
|
||||
|
||||
`splash.scss` déclare `Dos Amazigh` avec `@font-face` et l'applique au titre `#app-name`.
|
||||
BIN
crates/ksp-app-solprices-desk/frontend/imgs/logo.png
Normal file
BIN
crates/ksp-app-solprices-desk/frontend/imgs/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
BIN
crates/ksp-app-solprices-desk/frontend/imgs/splash.png
Normal file
BIN
crates/ksp-app-solprices-desk/frontend/imgs/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 611 KiB |
188
crates/ksp-app-solprices-desk/frontend/main.html
Normal file
188
crates/ksp-app-solprices-desk/frontend/main.html
Normal file
@@ -0,0 +1,188 @@
|
||||
<!-- file: crates/ksp-app-solprices-desk/frontend/main.html -->
|
||||
<!-- version: 8 -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
||||
<link rel="icon" href="imgs/logo.png">
|
||||
<link rel="stylesheet" href="sass/main.scss">
|
||||
<title>SOL Prices Desk — Prices</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<nav class="navbar h-100 py-0 bg-light text-dark">
|
||||
<div class="container-fluid px-4 flex-nowrap">
|
||||
<div class="navbar-brand d-flex align-items-center me-4 flex-nowrap">
|
||||
<img alt="KSP" src="imgs/logo.png" class="app-logo">
|
||||
<span class="ps-2 fs-4 fw-semibold text-primary text-nowrap">SOL Prices Desk</span>
|
||||
<span class="mx-2 fs-5 text-body-secondary" aria-hidden="true">—</span>
|
||||
<span id="headerViewTitle" class="fs-5 text-body text-nowrap">Prices</span>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="app-main container-fluid p-0">
|
||||
<div class="row g-0 h-100 flex-nowrap">
|
||||
<aside class="col-auto app-sidebar border-end bg-body-tertiary">
|
||||
<div class="app-sidebar-scroll h-100" data-simplebar>
|
||||
<nav class="nav nav-pills flex-column gap-1 p-3" aria-label="Navigation SOL Prices Desk">
|
||||
<button class="nav-link text-start active" type="button" data-view="prices">
|
||||
<i class="fa-solid fa-dollar-sign me-2" aria-hidden="true"></i>Prices
|
||||
</button>
|
||||
<button class="nav-link text-start" type="button" data-view="diagnostics">
|
||||
<i class="fa-solid fa-stethoscope me-2" aria-hidden="true"></i>Diagnostics
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
<section class="col app-content app-scrollable h-100" data-simplebar>
|
||||
<div class="container-fluid px-4 py-4">
|
||||
<div class="card app-shell-card mx-auto shadow-sm border-0">
|
||||
<div class="card-body p-4">
|
||||
<section data-view-panel="prices">
|
||||
<div class="d-flex align-items-start justify-content-between gap-3 flex-wrap mb-3">
|
||||
<div>
|
||||
<h1 class="h3 mb-1">Prices</h1>
|
||||
<p class="text-body-secondary mb-0">Inventaire provider-neutral SOL/USD. Les observations restent distinctes : aucune moyenne, aucun fallback automatique et aucun polling.</p>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap app-market-toolbar">
|
||||
<span id="selectedProviderCount" class="small text-body-secondary" aria-live="polite">0 selected</span>
|
||||
<button id="clearSelectionButton" class="btn btn-sm btn-outline-secondary" type="button" disabled>
|
||||
<i class="fa-solid fa-xmark me-1" aria-hidden="true"></i>Clear selection
|
||||
</button>
|
||||
<button id="refreshSelectedButton" class="btn btn-sm btn-outline-primary" type="button" disabled>
|
||||
<i class="fa-solid fa-list-check me-1" aria-hidden="true"></i>Refresh selected
|
||||
</button>
|
||||
<button id="refreshAllButton" class="btn btn-sm btn-primary" type="button" disabled>
|
||||
<i class="fa-solid fa-arrows-rotate me-1" aria-hidden="true"></i>Refresh all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pricesSummary" class="row g-2 mb-3" aria-label="Résumé des providers">
|
||||
<div class="col-6 col-lg">
|
||||
<div class="app-market-summary-card">
|
||||
<span class="app-market-summary-label">Providers</span>
|
||||
<strong id="pricesSummaryProviders">0</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg">
|
||||
<div class="app-market-summary-card">
|
||||
<span class="app-market-summary-label">Ready</span>
|
||||
<strong id="pricesSummaryReady">0</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg">
|
||||
<div class="app-market-summary-card">
|
||||
<span class="app-market-summary-label">Observed</span>
|
||||
<strong id="pricesSummaryObserved">0</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg">
|
||||
<div class="app-market-summary-card">
|
||||
<span class="app-market-summary-label">Attention</span>
|
||||
<strong id="pricesSummaryAttention">0</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg">
|
||||
<div class="app-market-summary-card">
|
||||
<span class="app-market-summary-label">Refreshing</span>
|
||||
<strong id="pricesSummaryLoading">0</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pricesLoading" class="app-placeholder">
|
||||
<div>
|
||||
<i class="fa-solid fa-spinner fa-spin text-body-secondary mb-3" aria-hidden="true"></i>
|
||||
<h2 class="h5">Chargement du registry</h2>
|
||||
<p class="text-body-secondary mb-0">Projection de l'inventaire provider-neutral depuis le runtime backend.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pricesEmpty" class="app-placeholder" hidden>
|
||||
<div>
|
||||
<i class="fa-solid fa-table-list text-body-secondary mb-3" aria-hidden="true"></i>
|
||||
<h2 class="h5">Aucun provider configuré</h2>
|
||||
<p class="text-body-secondary mb-0">Le runtime Off-chain ne contient actuellement aucune ligne provider.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pricesDiagnostic" class="alert alert-warning mb-3" role="status" aria-live="polite" hidden></div>
|
||||
<div id="pricesTableContainer" class="table-responsive app-market-table-container" hidden>
|
||||
<table id="marketPriceTable" class="table table-hover align-middle mb-0 app-market-table">
|
||||
<caption class="visually-hidden">Providers SOL/USD, disponibilité, prix exacts, fraîcheur et actions de refresh.</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="app-market-selection-column">
|
||||
<input id="selectAllProviders" class="form-check-input" type="checkbox" aria-label="Select all providers" disabled>
|
||||
</th>
|
||||
<th scope="col">Provider</th>
|
||||
<th scope="col">Pair</th>
|
||||
<th scope="col" class="d-none d-xl-table-cell">Semantics</th>
|
||||
<th scope="col" class="d-none d-xl-table-cell">Auth</th>
|
||||
<th scope="col">Availability</th>
|
||||
<th scope="col">Exact price</th>
|
||||
<th scope="col" class="app-market-time-column">Observation times</th>
|
||||
<th scope="col" class="app-market-time-column">Retry</th>
|
||||
<th scope="col" class="text-end">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="marketPriceRows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<section data-view-panel="diagnostics" hidden>
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-1">Diagnostics</h1>
|
||||
<p class="text-body-secondary mb-0">État sûr du bootstrap Config, Logging et runtime prix provider-neutral.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header fw-semibold">Runtime</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0 app-runtime-list">
|
||||
<dt class="col-sm-5">Version</dt>
|
||||
<dd id="runtimeVersion" class="col-sm-7">—</dd>
|
||||
<dt class="col-sm-5">Phase</dt>
|
||||
<dd id="runtimeShellPhase" class="col-sm-7">—</dd>
|
||||
<dt class="col-sm-5">Documents Config</dt>
|
||||
<dd id="runtimeConfigDocuments" class="col-sm-7">—</dd>
|
||||
<dt class="col-sm-5">Profil composite</dt>
|
||||
<dd id="runtimeCompositeProfile" class="col-sm-7">—</dd>
|
||||
<dt class="col-sm-5">Profil Off-chain</dt>
|
||||
<dd id="runtimeOffchainProfile" class="col-sm-7">—</dd>
|
||||
<dt class="col-sm-5">Providers</dt>
|
||||
<dd id="runtimeProviderCount" class="col-sm-7">—</dd>
|
||||
<dt class="col-sm-5">Providers ready</dt>
|
||||
<dd id="runtimeReadyProviderCount" class="col-sm-7">—</dd>
|
||||
<dt class="col-sm-5">Providers unavailable</dt>
|
||||
<dd id="runtimeUnavailableProviderCount" class="col-sm-7">—</dd>
|
||||
<dt class="col-sm-5">Profil Logging</dt>
|
||||
<dd id="runtimeLoggingProfile" class="col-sm-7">—</dd>
|
||||
<dt class="col-sm-5">Fallback Logging</dt>
|
||||
<dd id="runtimeLoggingFallback" class="col-sm-7">—</dd>
|
||||
</dl>
|
||||
<div id="runtimeDiagnostic" class="alert alert-warning mt-3 mb-0" role="status" aria-live="polite" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="app-footer bg-dark text-light">
|
||||
<div class="container h-100 d-flex align-items-center justify-content-center">
|
||||
<small>© 2026 SASEDEV</small>
|
||||
</div>
|
||||
</footer>
|
||||
<script type="module" src="ts/main.ts" defer></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
235
crates/ksp-app-solprices-desk/frontend/sass/_app.scss
Normal file
235
crates/ksp-app-solprices-desk/frontend/sass/_app.scss
Normal file
@@ -0,0 +1,235 @@
|
||||
// file: crates/ksp-app-solprices-desk/frontend/sass/_app.scss
|
||||
// version: 4
|
||||
|
||||
$app-header-height: 72px;
|
||||
$app-footer-height: 42px;
|
||||
$app-sidebar-width: 250px;
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: $gray-100;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
position: fixed;
|
||||
inset: 0 0 auto;
|
||||
height: $app-header-height;
|
||||
z-index: 1020;
|
||||
border-bottom: 2px solid rgba($primary, 0.3);
|
||||
}
|
||||
|
||||
.app-footer {
|
||||
position: fixed;
|
||||
inset: auto 0 0;
|
||||
height: $app-footer-height;
|
||||
z-index: 1020;
|
||||
border-top: 2px solid rgba($primary, 0.3);
|
||||
}
|
||||
|
||||
.app-main {
|
||||
position: relative;
|
||||
height: calc(100vh - $app-header-height - $app-footer-height);
|
||||
margin-top: $app-header-height;
|
||||
margin-bottom: $app-footer-height;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-main > .row {
|
||||
flex-wrap: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-logo {
|
||||
display: block;
|
||||
width: auto;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
.app-scrollable {
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.app-shell-card {
|
||||
max-width: 1320px;
|
||||
}
|
||||
|
||||
.app-runtime-list dd,
|
||||
.app-market-code,
|
||||
.app-market-price,
|
||||
.app-market-time-exact {
|
||||
font-family: var(--bs-font-monospace);
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
width: $app-sidebar-width;
|
||||
min-width: $app-sidebar-width;
|
||||
max-width: $app-sidebar-width;
|
||||
}
|
||||
|
||||
.app-sidebar-scroll,
|
||||
.app-content {
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-sidebar .nav-link {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-placeholder {
|
||||
min-height: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
padding: 2rem;
|
||||
border: 1px dashed var(--bs-border-color);
|
||||
border-radius: var(--bs-border-radius-lg);
|
||||
color: var(--bs-secondary-color);
|
||||
background: var(--bs-body-bg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-placeholder i {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.app-market-toolbar {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.app-market-summary-card {
|
||||
display: flex;
|
||||
min-height: 58px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.65rem 0.8rem;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: var(--bs-border-radius);
|
||||
background: var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
.app-market-summary-card strong {
|
||||
min-width: 2ch;
|
||||
font-size: 1.15rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.app-market-summary-label {
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.app-market-table-container {
|
||||
max-height: min(62vh, 720px);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: var(--bs-border-radius);
|
||||
}
|
||||
|
||||
.app-market-table {
|
||||
min-width: 1080px;
|
||||
}
|
||||
|
||||
.app-market-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: var(--bs-tertiary-bg);
|
||||
box-shadow: inset 0 -1px 0 var(--bs-border-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-market-table tbody tr[data-availability="ready"] {
|
||||
--bs-table-accent-bg: rgba($success, 0.025);
|
||||
}
|
||||
|
||||
.app-market-table tbody tr[data-availability="cooling_down"],
|
||||
.app-market-table tbody tr[data-availability="temporarily_unavailable"] {
|
||||
--bs-table-accent-bg: rgba($warning, 0.04);
|
||||
}
|
||||
|
||||
.app-market-table tbody tr[data-availability="authentication_unavailable"],
|
||||
.app-market-table tbody tr[data-availability="misconfigured"] {
|
||||
--bs-table-accent-bg: rgba($danger, 0.035);
|
||||
}
|
||||
|
||||
.app-market-selection-column {
|
||||
width: 42px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-market-time-column {
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.app-market-provider-name,
|
||||
.app-market-price {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-market-code,
|
||||
.app-market-time-exact {
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.app-market-price {
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-market-time {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.app-market-time-line {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.35rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.app-market-time-label {
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.app-market-time-value {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-market-row-loading {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
.app-sidebar {
|
||||
width: 190px;
|
||||
min-width: 190px;
|
||||
max-width: 190px;
|
||||
}
|
||||
|
||||
.app-market-toolbar {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
160
crates/ksp-app-solprices-desk/frontend/sass/_bootswatch.scss
Normal file
160
crates/ksp-app-solprices-desk/frontend/sass/_bootswatch.scss
Normal file
@@ -0,0 +1,160 @@
|
||||
// file: crates/ksp-app-solprices-desk/frontend/sass/_bootswatch.scss
|
||||
// version: 1
|
||||
|
||||
// Pulse 5.3.8
|
||||
// Bootswatch
|
||||
|
||||
|
||||
// Variables
|
||||
|
||||
// Buttons
|
||||
|
||||
.btn {
|
||||
|
||||
&:focus,
|
||||
&:active,
|
||||
&:active:focus,
|
||||
&.active:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&-secondary {
|
||||
color: $gray-900;
|
||||
background-color: $white;
|
||||
border-color: #ccc;
|
||||
|
||||
&:hover {
|
||||
color: $gray-900;
|
||||
background-color: $gray-300;
|
||||
border-color: $gray-500;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
color: tint-color($gray-900, 5%);
|
||||
background-color: $white;
|
||||
border-color: tint-color(#ccc, 5%);
|
||||
}
|
||||
}
|
||||
|
||||
&-warning {
|
||||
color: $white;
|
||||
}
|
||||
|
||||
&-primary:focus {
|
||||
box-shadow: 0 0 5px tint-color($primary, 10%);
|
||||
}
|
||||
|
||||
&-secondary:focus {
|
||||
box-shadow: 0 0 5px $gray-400;
|
||||
}
|
||||
|
||||
&-success:focus {
|
||||
box-shadow: 0 0 5px tint-color($success, 10%);
|
||||
}
|
||||
|
||||
&-info:focus {
|
||||
box-shadow: 0 0 5px tint-color($info, 10%);
|
||||
}
|
||||
|
||||
&-warning:focus {
|
||||
box-shadow: 0 0 5px tint-color($warning, 10%);
|
||||
}
|
||||
|
||||
&-danger:focus {
|
||||
box-shadow: 0 0 5px tint-color($danger, 10%);
|
||||
}
|
||||
|
||||
&.disabled:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Tables
|
||||
|
||||
.table .thead-dark th {
|
||||
background-color: $secondary;
|
||||
border-color: $table-border-color;
|
||||
}
|
||||
|
||||
.table-primary,
|
||||
.table-secondary,
|
||||
.table-success,
|
||||
.table-warning,
|
||||
.table-danger,
|
||||
.table-info,
|
||||
.table-light {
|
||||
--#{$prefix}table-color: #{$body-color};
|
||||
}
|
||||
|
||||
// Forms
|
||||
|
||||
.form-control:focus {
|
||||
box-shadow: 0 0 5px rgba(100, 65, 164, .4);
|
||||
}
|
||||
|
||||
// Navs
|
||||
|
||||
.nav-tabs {
|
||||
|
||||
.nav-link,
|
||||
.nav-link.active {
|
||||
border-width: 0 0 1px;
|
||||
}
|
||||
|
||||
.nav-link:hover,
|
||||
.nav-link.active,
|
||||
.nav-link.active:hover,
|
||||
.nav-link.active:focus {
|
||||
border-bottom: 1px solid $primary;
|
||||
}
|
||||
|
||||
.nav-item+.nav-item {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
&-item.active {
|
||||
color: $gray-700;
|
||||
}
|
||||
}
|
||||
|
||||
// Indicators
|
||||
|
||||
.badge {
|
||||
&.bg-light {
|
||||
color: $dark;
|
||||
}
|
||||
}
|
||||
|
||||
// Progress bars
|
||||
|
||||
.progress {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
// Containers
|
||||
|
||||
.list-group {
|
||||
&-item {
|
||||
color: rgba(255, 255, 255, .8);
|
||||
|
||||
&.active,
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: $white;
|
||||
}
|
||||
|
||||
&.active {
|
||||
font-weight: 700;
|
||||
|
||||
&:hover {
|
||||
background-color: $list-group-hover-bg;
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled:hover {
|
||||
color: $list-group-disabled-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// file: crates/ksp-app-solprices-desk/frontend/sass/_fontawesome.scss
|
||||
// version: 1
|
||||
|
||||
//@use '@fortawesome/fontawesome-free/scss/variables' with (
|
||||
// // customizing $font-path - make sure it points to where your webfonts are stored in your project
|
||||
// $font-path: '../webfonts',
|
||||
//);
|
||||
@use '@fortawesome/fontawesome-free/scss/variables' with (
|
||||
// use fonts from @fortawesome/fontawesome-free
|
||||
$font-path: '@fortawesome/fontawesome-free/webfonts',
|
||||
);
|
||||
// load Font Awesome core
|
||||
@use '@fortawesome/fontawesome-free/scss/fontawesome';
|
||||
|
||||
// load and make available Font Awesome helpers (mixins, functions, and variables)
|
||||
@use '@fortawesome/fontawesome-free/scss/fa' as fa;
|
||||
@use '@fortawesome/fontawesome-free/scss/brands' as fa-brands;
|
||||
@use '@fortawesome/fontawesome-free/scss/regular' as fa-regular;
|
||||
@use '@fortawesome/fontawesome-free/scss/solid' as fa-solid;
|
||||
248
crates/ksp-app-solprices-desk/frontend/sass/_simplebar.scss
Normal file
248
crates/ksp-app-solprices-desk/frontend/sass/_simplebar.scss
Normal file
@@ -0,0 +1,248 @@
|
||||
// file: crates/ksp-app-solprices-desk/frontend/sass/_simplebar.scss
|
||||
// version: 1
|
||||
|
||||
/* Rtl support */
|
||||
[data-simplebar] {
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
align-content: flex-start;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.simplebar-wrapper {
|
||||
overflow: hidden;
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
max-width: inherit;
|
||||
max-height: inherit;
|
||||
}
|
||||
|
||||
.simplebar-mask {
|
||||
direction: inherit;
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
// z-index: 0;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.simplebar-offset {
|
||||
direction: inherit !important;
|
||||
box-sizing: inherit !important;
|
||||
resize: none !important;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.simplebar-content-wrapper {
|
||||
direction: inherit;
|
||||
box-sizing: border-box !important;
|
||||
position: relative;
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
overflow: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.simplebar-hide-scrollbar {
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
position: fixed;
|
||||
left: 0;
|
||||
visibility: hidden;
|
||||
overflow-y: scroll;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.simplebar-content {
|
||||
&:before {
|
||||
content: ' ';
|
||||
display: table;
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: ' ';
|
||||
display: table;
|
||||
}
|
||||
}
|
||||
|
||||
.simplebar-placeholder {
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.simplebar-height-auto-observer-wrapper {
|
||||
box-sizing: inherit !important;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
max-width: 1px;
|
||||
position: relative;
|
||||
float: left;
|
||||
max-height: 1px;
|
||||
overflow: hidden;
|
||||
// z-index: -1;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
pointer-events: none;
|
||||
flex-grow: inherit;
|
||||
flex-shrink: 0;
|
||||
flex-basis: 0;
|
||||
}
|
||||
|
||||
.simplebar-height-auto-observer {
|
||||
box-sizing: inherit;
|
||||
display: block;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 1000%;
|
||||
width: 1000%;
|
||||
min-height: 1px;
|
||||
min-width: 1px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
// z-index: -1;
|
||||
}
|
||||
|
||||
.simplebar-track {
|
||||
// z-index: 1;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-simplebar].simplebar-dragging {
|
||||
pointer-events: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
|
||||
.simplebar-content {
|
||||
pointer-events: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.simplebar-track {
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
|
||||
.simplebar-scrollbar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
min-height: 10px;
|
||||
|
||||
|
||||
&:before {
|
||||
position: absolute;
|
||||
content: '';
|
||||
background: black;
|
||||
border-radius: 7px;
|
||||
left: 2px;
|
||||
right: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s 0.5s linear;
|
||||
top: 2px;
|
||||
bottom: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.simplebar-scrollbar.simplebar-visible {
|
||||
&:before {
|
||||
opacity: 0.5;
|
||||
transition-delay: 0s;
|
||||
transition-duration: 0s;
|
||||
}
|
||||
}
|
||||
|
||||
.simplebar-track.simplebar-vertical {
|
||||
top: 0;
|
||||
width: 11px;
|
||||
}
|
||||
|
||||
.simplebar-track.simplebar-horizontal {
|
||||
left: 0;
|
||||
height: 11px;
|
||||
|
||||
.simplebar-scrollbar {
|
||||
right: auto;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
min-height: 0;
|
||||
min-width: 10px;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
[data-simplebar-direction='rtl'] {
|
||||
.simplebar-track.simplebar-vertical {
|
||||
right: auto;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.simplebar-dummy-scrollbar-size {
|
||||
direction: rtl;
|
||||
position: fixed;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
height: 500px;
|
||||
width: 500px;
|
||||
overflow-y: hidden;
|
||||
overflow-x: scroll;
|
||||
-ms-overflow-style: scrollbar !important;
|
||||
|
||||
>div {
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
margin: 10px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.simplebar-hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
95
crates/ksp-app-solprices-desk/frontend/sass/_variables.scss
Normal file
95
crates/ksp-app-solprices-desk/frontend/sass/_variables.scss
Normal file
@@ -0,0 +1,95 @@
|
||||
// file: crates/ksp-app-solprices-desk/frontend/sass/_variables.scss
|
||||
// version: 1
|
||||
|
||||
// Pulse 5.3.8
|
||||
// Bootswatch
|
||||
|
||||
$theme: "pulse" !default;
|
||||
|
||||
//
|
||||
// Color system
|
||||
//
|
||||
|
||||
$white: #fff !default;
|
||||
$gray-100: #fafafa !default;
|
||||
$gray-200: #f9f8fc !default;
|
||||
$gray-300: #ededed !default;
|
||||
$gray-400: #cbc8d0 !default;
|
||||
$gray-500: #adb5bd !default;
|
||||
$gray-600: #868e96 !default;
|
||||
$gray-700: #444 !default;
|
||||
$gray-800: #343a40 !default;
|
||||
$gray-900: #17141f !default;
|
||||
$black: #000 !default;
|
||||
|
||||
$blue: #007bff !default;
|
||||
$indigo: #6610f2 !default;
|
||||
$purple: #593196 !default;
|
||||
$pink: #e83e8c !default;
|
||||
$red: #fc3939 !default;
|
||||
$orange: #fd7e14 !default;
|
||||
$yellow: #efa31d !default;
|
||||
$green: #13b955 !default;
|
||||
$teal: #20c997 !default;
|
||||
$cyan: #009cdc !default;
|
||||
|
||||
$primary: $purple !default;
|
||||
$secondary: #a991d4 !default;
|
||||
$success: $green !default;
|
||||
$info: $cyan !default;
|
||||
$warning: $yellow !default;
|
||||
$danger: $red !default;
|
||||
$light: $gray-200 !default;
|
||||
$dark: $gray-900 !default;
|
||||
|
||||
$min-contrast-ratio: 2.1 !default;
|
||||
|
||||
// Options
|
||||
|
||||
$enable-rounded: false !default;
|
||||
|
||||
// Body
|
||||
|
||||
$body-color: $gray-700 !default;
|
||||
|
||||
// Links
|
||||
|
||||
$link-hover-color: $primary !default;
|
||||
|
||||
// Tables
|
||||
|
||||
$table-color: initial !default;
|
||||
|
||||
$table-border-color: rgba(0, 0, 0, .05) !default;
|
||||
|
||||
// Forms
|
||||
|
||||
$input-focus-border-color: $primary !default;
|
||||
|
||||
// Dropdowns
|
||||
|
||||
$dropdown-link-hover-color: $white !default;
|
||||
$dropdown-link-hover-bg: $primary !default;
|
||||
|
||||
// Navs
|
||||
|
||||
$nav-tabs-border-color: $gray-300 !default;
|
||||
$nav-tabs-link-hover-border-color: $primary !default;
|
||||
|
||||
// Navbar
|
||||
|
||||
$navbar-padding-y: 1.2rem !default;
|
||||
|
||||
// Progress bars
|
||||
|
||||
$progress-bg: $gray-300 !default;
|
||||
$progress-bar-bg: $primary !default;
|
||||
|
||||
// List group
|
||||
|
||||
$list-group-bg: $gray-900 !default;
|
||||
$list-group-border-color: transparent !default;
|
||||
$list-group-hover-bg: lighten($list-group-bg, 10%) !default;
|
||||
$list-group-active-color: $white !default;
|
||||
$list-group-active-bg: $list-group-bg !default;
|
||||
$list-group-disabled-color: lighten($list-group-bg, 30%) !default;
|
||||
10
crates/ksp-app-solprices-desk/frontend/sass/main.scss
Normal file
10
crates/ksp-app-solprices-desk/frontend/sass/main.scss
Normal file
@@ -0,0 +1,10 @@
|
||||
// file: crates/ksp-app-solprices-desk/frontend/sass/main.scss
|
||||
// version: 1
|
||||
|
||||
@import "bootstrap/scss/functions";
|
||||
@import "variables";
|
||||
@import "fontawesome";
|
||||
@import "simplebar";
|
||||
@import "bootstrap/scss/bootstrap";
|
||||
@import "bootswatch";
|
||||
@import "app";
|
||||
137
crates/ksp-app-solprices-desk/frontend/sass/splash.scss
Normal file
137
crates/ksp-app-solprices-desk/frontend/sass/splash.scss
Normal file
@@ -0,0 +1,137 @@
|
||||
// file: crates/ksp-app-solprices-desk/frontend/sass/splash.scss
|
||||
// version: 2
|
||||
|
||||
@font-face {
|
||||
font-family: "Dos Amazigh";
|
||||
src: url("../fonts/DOS_Amazigh.ttf") format("truetype");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
#splash-container {
|
||||
position: relative;
|
||||
width: 960px;
|
||||
max-width: 100%;
|
||||
height: 637px;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
#splash-image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
object-fit: contain;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#app-name {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
z-index: 2;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #fff;
|
||||
font-family: "Dos Amazigh", sans-serif;
|
||||
font-size: 76px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
text-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#debug-info {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 3;
|
||||
width: 100%;
|
||||
max-height: 30%;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
#messages-container {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 3;
|
||||
max-height: 30%;
|
||||
overflow-y: auto;
|
||||
padding: 10px 10px 36px;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
#debug-info::-webkit-scrollbar,
|
||||
#messages-container::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.splash-message {
|
||||
margin-bottom: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
animation: splashMessageFadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.splash-message.info { background: rgba(33, 150, 243, 0.22); }
|
||||
.splash-message.warning { background: rgba(255, 152, 0, 0.22); }
|
||||
.splash-message.error { background: rgba(244, 67, 54, 0.22); }
|
||||
.splash-message.success { background: rgba(76, 175, 80, 0.22); }
|
||||
|
||||
@keyframes splashMessageFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
24
crates/ksp-app-solprices-desk/frontend/splash.html
Normal file
24
crates/ksp-app-solprices-desk/frontend/splash.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<!-- file: crates/ksp-app-solprices-desk/frontend/splash.html -->
|
||||
<!-- version: 2 -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
||||
<link rel="icon" href="imgs/logo.png">
|
||||
<link rel="stylesheet" href="sass/splash.scss">
|
||||
<title>KSP SOL Prices Desk — Chargement</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="splash-container" style="opacity: 0;">
|
||||
<img id="splash-image" src="imgs/splash.png" alt="Chargement de KSP SOL Prices Desk">
|
||||
<div id="app-name">SOL Prices Desk</div>
|
||||
<div id="debug-info" aria-live="off" hidden></div>
|
||||
<div id="messages-container" aria-live="polite"></div>
|
||||
</div>
|
||||
<script type="module" src="ts/splash.ts" defer></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
110
crates/ksp-app-solprices-desk/frontend/ts/frontend_log.ts
Normal file
110
crates/ksp-app-solprices-desk/frontend/ts/frontend_log.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
// file: crates/ksp-app-solprices-desk/frontend/ts/frontend_log.ts
|
||||
// version: 1
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { FrontendLogPayloadDto } from "./bindings/ksp_app_solprices_desk/frontend_logging/FrontendLogPayloadDto.ts";
|
||||
|
||||
export type FrontendLogLevel = "trace" | "debug" | "info" | "warn" | "error";
|
||||
export type FrontendLogTargetId = "frontend" | "main" | "splash";
|
||||
|
||||
type ConsoleMethod = (...items: unknown[]) => void;
|
||||
|
||||
const originalConsole = {
|
||||
trace: console.trace.bind(console),
|
||||
debug: console.debug.bind(console),
|
||||
log: console.log.bind(console),
|
||||
info: console.info.bind(console),
|
||||
warn: console.warn.bind(console),
|
||||
error: console.error.bind(console),
|
||||
};
|
||||
|
||||
function stringifyItem(item: unknown): string {
|
||||
if (item instanceof Error) {
|
||||
return item.stack ?? item.message;
|
||||
}
|
||||
if (typeof item === "string") {
|
||||
return item;
|
||||
}
|
||||
try {
|
||||
const serialized = JSON.stringify(item);
|
||||
return serialized ?? String(item);
|
||||
} catch {
|
||||
return String(item);
|
||||
}
|
||||
}
|
||||
|
||||
function formatMessage(items: unknown[]): string {
|
||||
return items.map(item => stringifyItem(item)).join(" ");
|
||||
}
|
||||
|
||||
async function sendFrontendLog(level: FrontendLogLevel, targetId: FrontendLogTargetId, message: string): Promise<void> {
|
||||
const payload: FrontendLogPayloadDto = {
|
||||
level,
|
||||
targetId,
|
||||
message,
|
||||
};
|
||||
await invoke("emit_frontend_log", { payload });
|
||||
}
|
||||
|
||||
function writeOriginalConsole(level: FrontendLogLevel, message: string): void {
|
||||
return level === "trace"
|
||||
? originalConsole.trace(message)
|
||||
: level === "debug"
|
||||
? originalConsole.debug(message)
|
||||
: level === "info"
|
||||
? originalConsole.info(message)
|
||||
: level === "warn"
|
||||
? originalConsole.warn(message)
|
||||
: originalConsole.error(message);
|
||||
}
|
||||
|
||||
export async function emitFrontendLog(level: FrontendLogLevel, targetId: FrontendLogTargetId, message: string): Promise<void> {
|
||||
writeOriginalConsole(level, message);
|
||||
await sendFrontendLog(level, targetId, message);
|
||||
}
|
||||
|
||||
export function frontendTrace(targetId: FrontendLogTargetId, ...items: unknown[]): void {
|
||||
const message = formatMessage(items);
|
||||
originalConsole.trace(message);
|
||||
void sendFrontendLog("trace", targetId, message).catch(caughtError => originalConsole.error("KSP frontend logging bridge failed", caughtError));
|
||||
}
|
||||
|
||||
export function frontendDebug(targetId: FrontendLogTargetId, ...items: unknown[]): void {
|
||||
const message = formatMessage(items);
|
||||
originalConsole.debug(message);
|
||||
void sendFrontendLog("debug", targetId, message).catch(caughtError => originalConsole.error("KSP frontend logging bridge failed", caughtError));
|
||||
}
|
||||
|
||||
export function frontendInfo(targetId: FrontendLogTargetId, ...items: unknown[]): void {
|
||||
const message = formatMessage(items);
|
||||
originalConsole.info(message);
|
||||
void sendFrontendLog("info", targetId, message).catch(caughtError => originalConsole.error("KSP frontend logging bridge failed", caughtError));
|
||||
}
|
||||
|
||||
export function frontendWarn(targetId: FrontendLogTargetId, ...items: unknown[]): void {
|
||||
const message = formatMessage(items);
|
||||
originalConsole.warn(message);
|
||||
void sendFrontendLog("warn", targetId, message).catch(caughtError => originalConsole.error("KSP frontend logging bridge failed", caughtError));
|
||||
}
|
||||
|
||||
export function frontendError(targetId: FrontendLogTargetId, ...items: unknown[]): void {
|
||||
const message = formatMessage(items);
|
||||
originalConsole.error(message);
|
||||
void sendFrontendLog("error", targetId, message).catch(caughtError => originalConsole.error("KSP frontend logging bridge failed", caughtError));
|
||||
}
|
||||
|
||||
function buildConsoleBridge(level: FrontendLogLevel, targetId: FrontendLogTargetId, original: ConsoleMethod): ConsoleMethod {
|
||||
return (...items: unknown[]) => {
|
||||
original(...items);
|
||||
void sendFrontendLog(level, targetId, formatMessage(items)).catch(caughtError => originalConsole.error("KSP frontend logging bridge failed", caughtError));
|
||||
};
|
||||
}
|
||||
|
||||
export function installFrontendConsoleBridge(targetId: FrontendLogTargetId): void {
|
||||
console.trace = buildConsoleBridge("trace", targetId, originalConsole.trace);
|
||||
console.debug = buildConsoleBridge("debug", targetId, originalConsole.debug);
|
||||
console.log = buildConsoleBridge("info", targetId, originalConsole.log);
|
||||
console.info = buildConsoleBridge("info", targetId, originalConsole.info);
|
||||
console.warn = buildConsoleBridge("warn", targetId, originalConsole.warn);
|
||||
console.error = buildConsoleBridge("error", targetId, originalConsole.error);
|
||||
}
|
||||
17
crates/ksp-app-solprices-desk/frontend/ts/invoke.ts
Normal file
17
crates/ksp-app-solprices-desk/frontend/ts/invoke.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// file: crates/ksp-app-solprices-desk/frontend/ts/invoke.ts
|
||||
// version: 1
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { frontendDebug, frontendError, frontendTrace, type FrontendLogTargetId } from "./frontend_log";
|
||||
|
||||
export async function invokeKsp<T>(targetId: FrontendLogTargetId, command: string, args?: Record<string, unknown>): Promise<T> {
|
||||
frontendDebug(targetId, "Frontend IPC command requested", { command });
|
||||
try {
|
||||
const result = await invoke<T>(command, args);
|
||||
frontendTrace(targetId, "Frontend IPC command completed", { command });
|
||||
return result;
|
||||
} catch (caughtError) {
|
||||
frontendError(targetId, "Frontend IPC command failed", { command });
|
||||
throw caughtError;
|
||||
}
|
||||
}
|
||||
597
crates/ksp-app-solprices-desk/frontend/ts/main.ts
Normal file
597
crates/ksp-app-solprices-desk/frontend/ts/main.ts
Normal file
@@ -0,0 +1,597 @@
|
||||
// file: crates/ksp-app-solprices-desk/frontend/ts/main.ts
|
||||
// version: 8
|
||||
|
||||
import "bootstrap";
|
||||
import ResizeObserver from "resize-observer-polyfill";
|
||||
import "simplebar";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import type { MarketPriceRuntimeStatusDto } from "./bindings/ksp_app_solprices_desk/dto_common/MarketPriceRuntimeStatusDto.ts";
|
||||
import type { MarketPriceProviderRowDto } from "./bindings/ksp_app_solprices_desk/market_price_runtime/MarketPriceProviderRowDto.ts";
|
||||
import type { MarketPriceRefreshManyRequestDto } from "./bindings/ksp_app_solprices_desk/market_price_runtime/MarketPriceRefreshManyRequestDto.ts";
|
||||
import type { MarketPriceRefreshResultDto } from "./bindings/ksp_app_solprices_desk/market_price_runtime/MarketPriceRefreshResultDto.ts";
|
||||
import { frontendDebug, frontendInfo, frontendTrace, frontendWarn, installFrontendConsoleBridge } from "./frontend_log";
|
||||
import { invokeKsp } from "./invoke";
|
||||
|
||||
(window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver;
|
||||
installFrontendConsoleBridge("main");
|
||||
|
||||
type ViewId = "prices" | "diagnostics";
|
||||
|
||||
type AvailabilityPresentation = {
|
||||
badgeClass: string;
|
||||
iconClass: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const viewTitles: Record<ViewId, string> = {
|
||||
prices: "Prices",
|
||||
diagnostics: "Diagnostics",
|
||||
};
|
||||
|
||||
const availabilityPresentations: Record<string, AvailabilityPresentation> = {
|
||||
authentication_unavailable: { badgeClass: "text-bg-danger", iconClass: "fa-key", label: "Authentication unavailable" },
|
||||
cooling_down: { badgeClass: "text-bg-warning", iconClass: "fa-clock", label: "Cooling down" },
|
||||
disabled: { badgeClass: "text-bg-secondary", iconClass: "fa-ban", label: "Disabled" },
|
||||
misconfigured: { badgeClass: "text-bg-danger", iconClass: "fa-triangle-exclamation", label: "Misconfigured" },
|
||||
quota_unavailable: { badgeClass: "text-bg-warning", iconClass: "fa-gauge-high", label: "Quota unavailable" },
|
||||
ready: { badgeClass: "text-bg-success", iconClass: "fa-circle-check", label: "Ready" },
|
||||
temporarily_unavailable: { badgeClass: "text-bg-warning", iconClass: "fa-cloud-arrow-down", label: "Temporarily unavailable" },
|
||||
unknown: { badgeClass: "text-bg-secondary", iconClass: "fa-circle-question", label: "Unknown" },
|
||||
};
|
||||
|
||||
const authLabels: Record<string, string> = {
|
||||
none: "Keyless",
|
||||
optional_api_key: "Optional API key",
|
||||
required_api_key: "API key required",
|
||||
unknown: "Unknown",
|
||||
};
|
||||
|
||||
const semanticsLabels: Record<string, string> = {
|
||||
aggregated_market: "Aggregated market",
|
||||
dex_pair_usd: "DEX pair USD",
|
||||
exchange_last_trade: "Exchange last trade",
|
||||
solana_heuristic: "Solana heuristic",
|
||||
solana_spot: "Solana spot",
|
||||
unknown: "Unknown",
|
||||
};
|
||||
|
||||
const jsDateMaxUnixMillis = 8_640_000_000_000_000n;
|
||||
|
||||
let currentMarketPriceRows: MarketPriceProviderRowDto[] = [];
|
||||
const selectedProviderIds = new Set<string>();
|
||||
|
||||
function isViewId(value: string | undefined): value is ViewId {
|
||||
return value === "prices" || value === "diagnostics";
|
||||
}
|
||||
|
||||
function activateView(viewId: ViewId, source: "startup" | "user"): void {
|
||||
document.querySelectorAll<HTMLElement>("[data-view-panel]").forEach(panel => {
|
||||
panel.hidden = panel.dataset.viewPanel !== viewId;
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-view]").forEach(button => {
|
||||
button.classList.toggle("active", button.dataset.view === viewId);
|
||||
});
|
||||
const header = document.querySelector<HTMLElement>("#headerViewTitle");
|
||||
if (header) {
|
||||
header.textContent = viewTitles[viewId];
|
||||
}
|
||||
document.title = `SOL Prices Desk — ${viewTitles[viewId]}`;
|
||||
if (source !== "startup") {
|
||||
frontendDebug("main", "SOL Prices Desk view activated", { viewId, source });
|
||||
}
|
||||
}
|
||||
|
||||
function bindNavigation(): void {
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-view]").forEach(button => {
|
||||
button.addEventListener("click", () => {
|
||||
const viewId = button.dataset.view;
|
||||
frontendDebug("main", "SOL Prices Desk navigation control clicked", { viewId: viewId ?? "unknown" });
|
||||
if (isViewId(viewId)) {
|
||||
activateView(viewId, "user");
|
||||
}
|
||||
});
|
||||
});
|
||||
frontendTrace("main", "SOL Prices Desk navigation handlers installed");
|
||||
}
|
||||
|
||||
function clearPricesDiagnostic(): void {
|
||||
const diagnostic = document.querySelector<HTMLElement>("#pricesDiagnostic");
|
||||
if (diagnostic) {
|
||||
diagnostic.hidden = true;
|
||||
diagnostic.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
function showPricesDiagnostic(message: string): void {
|
||||
const diagnostic = document.querySelector<HTMLElement>("#pricesDiagnostic");
|
||||
if (diagnostic) {
|
||||
diagnostic.hidden = false;
|
||||
diagnostic.textContent = message;
|
||||
}
|
||||
}
|
||||
|
||||
function selectedProviderIdsInRowOrder(): string[] {
|
||||
return currentMarketPriceRows.filter(provider => selectedProviderIds.has(provider.providerId)).map(provider => provider.providerId);
|
||||
}
|
||||
|
||||
function synchronizeSelectedProviders(): void {
|
||||
const currentIds = new Set(currentMarketPriceRows.map(provider => provider.providerId));
|
||||
for (const providerId of selectedProviderIds) {
|
||||
if (!currentIds.has(providerId)) {
|
||||
selectedProviderIds.delete(providerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateSummary(): void {
|
||||
const summaryValues: Record<string, number> = {
|
||||
pricesSummaryProviders: currentMarketPriceRows.length,
|
||||
pricesSummaryReady: currentMarketPriceRows.filter(provider => provider.availability === "ready" && !provider.loading).length,
|
||||
pricesSummaryObserved: currentMarketPriceRows.filter(provider => provider.refreshed).length,
|
||||
pricesSummaryAttention: currentMarketPriceRows.filter(provider => provider.availability !== "ready").length,
|
||||
pricesSummaryLoading: currentMarketPriceRows.filter(provider => provider.loading).length,
|
||||
};
|
||||
for (const [elementId, value] of Object.entries(summaryValues)) {
|
||||
const element = document.querySelector<HTMLElement>(`#${elementId}`);
|
||||
if (element) {
|
||||
element.textContent = value.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function synchronizeBatchControls(): void {
|
||||
const selectedCount = selectedProviderIdsInRowOrder().length;
|
||||
const providerCount = currentMarketPriceRows.length;
|
||||
const anyLoading = currentMarketPriceRows.some(provider => provider.loading);
|
||||
const selectedButton = document.querySelector<HTMLButtonElement>("#refreshSelectedButton");
|
||||
const allButton = document.querySelector<HTMLButtonElement>("#refreshAllButton");
|
||||
const clearButton = document.querySelector<HTMLButtonElement>("#clearSelectionButton");
|
||||
const selectAll = document.querySelector<HTMLInputElement>("#selectAllProviders");
|
||||
const selectedCountLabel = document.querySelector<HTMLElement>("#selectedProviderCount");
|
||||
if (selectedButton) {
|
||||
selectedButton.disabled = selectedCount === 0 || anyLoading;
|
||||
}
|
||||
if (allButton) {
|
||||
allButton.disabled = providerCount === 0 || anyLoading;
|
||||
}
|
||||
if (clearButton) {
|
||||
clearButton.disabled = selectedCount === 0 || anyLoading;
|
||||
}
|
||||
if (selectAll) {
|
||||
selectAll.disabled = providerCount === 0 || anyLoading;
|
||||
selectAll.checked = providerCount !== 0 && selectedCount === providerCount;
|
||||
selectAll.indeterminate = selectedCount > 0 && selectedCount < providerCount;
|
||||
}
|
||||
if (selectedCountLabel) {
|
||||
selectedCountLabel.textContent = `${selectedCount} selected`;
|
||||
}
|
||||
updateSummary();
|
||||
}
|
||||
|
||||
function markProvidersLoading(providerIds: string[]): void {
|
||||
const loadingIds = new Set(providerIds);
|
||||
currentMarketPriceRows = currentMarketPriceRows.map(provider => {
|
||||
return loadingIds.has(provider.providerId) ? { ...provider, loading: true } : provider;
|
||||
});
|
||||
renderMarketPriceRows(currentMarketPriceRows);
|
||||
}
|
||||
|
||||
function applyRefreshResult(result: MarketPriceRefreshResultDto): void {
|
||||
const refreshedRows = new Map<string, MarketPriceProviderRowDto>();
|
||||
for (const provider of result.rows) {
|
||||
refreshedRows.set(provider.providerId, provider);
|
||||
}
|
||||
currentMarketPriceRows = currentMarketPriceRows.map(provider => refreshedRows.get(provider.providerId) ?? provider);
|
||||
renderMarketPriceRows(currentMarketPriceRows);
|
||||
}
|
||||
|
||||
async function recoverMarketPriceRows(message: string): Promise<void> {
|
||||
showPricesDiagnostic(message);
|
||||
try {
|
||||
await loadMarketPrices();
|
||||
} catch {
|
||||
frontendWarn("main", "SOL Prices Desk market-price refresh recovery reload failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshMarketPrice(providerId: string): Promise<void> {
|
||||
frontendDebug("main", "SOL Prices Desk market-price refresh control clicked", { providerId });
|
||||
clearPricesDiagnostic();
|
||||
markProvidersLoading([providerId]);
|
||||
try {
|
||||
const refreshed = await invokeKsp<MarketPriceProviderRowDto>("main", "refresh_market_price", { providerId });
|
||||
currentMarketPriceRows = currentMarketPriceRows.map(provider => {
|
||||
return provider.providerId === refreshed.providerId ? refreshed : provider;
|
||||
});
|
||||
renderMarketPriceRows(currentMarketPriceRows);
|
||||
frontendTrace("main", "SOL Prices Desk market-price refresh row applied", { providerId });
|
||||
} catch {
|
||||
frontendWarn("main", "SOL Prices Desk market-price refresh failed", { providerId });
|
||||
await recoverMarketPriceRows("Le provider sélectionné n'a pas pu être rafraîchi. L'état précédent est conservé lorsqu'il reste disponible.");
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSelectedMarketPrices(): Promise<void> {
|
||||
const providerIds = selectedProviderIdsInRowOrder();
|
||||
frontendDebug("main", "SOL Prices Desk selected market-price refresh control clicked", { selectedCount: providerIds.length });
|
||||
if (providerIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
clearPricesDiagnostic();
|
||||
markProvidersLoading(providerIds);
|
||||
const request: MarketPriceRefreshManyRequestDto = { providerIds };
|
||||
try {
|
||||
const result = await invokeKsp<MarketPriceRefreshResultDto>("main", "refresh_market_prices", { request });
|
||||
applyRefreshResult(result);
|
||||
frontendTrace("main", "SOL Prices Desk selected market-price refresh result applied", {
|
||||
requestedCount: result.requestedCount,
|
||||
refreshedCount: result.refreshedCount,
|
||||
});
|
||||
} catch {
|
||||
frontendWarn("main", "SOL Prices Desk selected market-price refresh failed", { selectedCount: providerIds.length });
|
||||
await recoverMarketPriceRows("La sélection n'a pas pu être rafraîchie. Les observations précédentes restent visibles avec leur état réel.");
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAllMarketPrices(): Promise<void> {
|
||||
frontendDebug("main", "SOL Prices Desk global market-price refresh control clicked", { providerCount: currentMarketPriceRows.length });
|
||||
if (currentMarketPriceRows.length === 0) {
|
||||
return;
|
||||
}
|
||||
clearPricesDiagnostic();
|
||||
markProvidersLoading(currentMarketPriceRows.map(provider => provider.providerId));
|
||||
try {
|
||||
const result = await invokeKsp<MarketPriceRefreshResultDto>("main", "refresh_all_market_prices");
|
||||
applyRefreshResult(result);
|
||||
frontendTrace("main", "SOL Prices Desk global market-price refresh result applied", {
|
||||
requestedCount: result.requestedCount,
|
||||
refreshedCount: result.refreshedCount,
|
||||
});
|
||||
} catch {
|
||||
frontendWarn("main", "SOL Prices Desk global market-price refresh failed", { providerCount: currentMarketPriceRows.length });
|
||||
await recoverMarketPriceRows("Le refresh global n'a pas pu être terminé. Les observations précédentes restent visibles avec leur état réel.");
|
||||
}
|
||||
}
|
||||
|
||||
function appendSelectionCell(row: HTMLTableRowElement, provider: MarketPriceProviderRowDto): void {
|
||||
const cell = document.createElement("td");
|
||||
cell.className = "app-market-selection-column";
|
||||
const checkbox = document.createElement("input");
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.className = "form-check-input";
|
||||
checkbox.checked = selectedProviderIds.has(provider.providerId);
|
||||
checkbox.disabled = provider.loading;
|
||||
checkbox.setAttribute("aria-label", `Select ${provider.displayName}`);
|
||||
checkbox.addEventListener("change", () => {
|
||||
if (checkbox.checked) {
|
||||
selectedProviderIds.add(provider.providerId);
|
||||
} else {
|
||||
selectedProviderIds.delete(provider.providerId);
|
||||
}
|
||||
frontendDebug("main", "SOL Prices Desk market-price selection control changed", {
|
||||
providerId: provider.providerId,
|
||||
selected: checkbox.checked,
|
||||
});
|
||||
synchronizeBatchControls();
|
||||
});
|
||||
cell.append(checkbox);
|
||||
row.append(cell);
|
||||
}
|
||||
|
||||
function appendProviderCell(row: HTMLTableRowElement, provider: MarketPriceProviderRowDto): void {
|
||||
const cell = document.createElement("td");
|
||||
const name = document.createElement("div");
|
||||
name.className = "app-market-provider-name";
|
||||
name.textContent = provider.displayName;
|
||||
const code = document.createElement("div");
|
||||
code.className = "app-market-code";
|
||||
code.textContent = provider.providerId;
|
||||
cell.append(name, code);
|
||||
row.append(cell);
|
||||
}
|
||||
|
||||
function appendBadgeCell(row: HTMLTableRowElement, label: string, badgeClass = "text-bg-light", className?: string): void {
|
||||
const cell = document.createElement("td");
|
||||
if (className) {
|
||||
cell.className = className;
|
||||
}
|
||||
const badge = document.createElement("span");
|
||||
badge.className = `badge ${badgeClass}`;
|
||||
badge.textContent = label;
|
||||
cell.append(badge);
|
||||
row.append(cell);
|
||||
}
|
||||
|
||||
function availabilityPresentation(provider: MarketPriceProviderRowDto): AvailabilityPresentation {
|
||||
if (provider.loading) {
|
||||
return { badgeClass: "text-bg-info", iconClass: "fa-spinner fa-spin", label: "Refreshing" };
|
||||
}
|
||||
return availabilityPresentations[provider.availability] ?? availabilityPresentations.unknown;
|
||||
}
|
||||
|
||||
function appendAvailabilityCell(row: HTMLTableRowElement, provider: MarketPriceProviderRowDto): void {
|
||||
const cell = document.createElement("td");
|
||||
const presentation = availabilityPresentation(provider);
|
||||
const badge = document.createElement("span");
|
||||
badge.className = `badge ${presentation.badgeClass}`;
|
||||
const icon = document.createElement("i");
|
||||
icon.className = `fa-solid ${presentation.iconClass} me-1`;
|
||||
icon.setAttribute("aria-hidden", "true");
|
||||
badge.append(icon, document.createTextNode(presentation.label));
|
||||
cell.append(badge);
|
||||
row.append(cell);
|
||||
}
|
||||
|
||||
function appendPriceCell(row: HTMLTableRowElement, provider: MarketPriceProviderRowDto): void {
|
||||
const cell = document.createElement("td");
|
||||
if (provider.price === null) {
|
||||
const empty = document.createElement("span");
|
||||
empty.className = "text-body-secondary";
|
||||
empty.textContent = "No observation";
|
||||
cell.append(empty);
|
||||
} else {
|
||||
const price = document.createElement("span");
|
||||
price.className = "app-market-price";
|
||||
price.textContent = provider.price;
|
||||
const currency = document.createElement("small");
|
||||
currency.className = "text-body-secondary ms-1";
|
||||
currency.textContent = "USD";
|
||||
cell.append(price, currency);
|
||||
}
|
||||
row.append(cell);
|
||||
}
|
||||
|
||||
function formatUnixMillis(value: string): { exact: string; iso: string; local: string } | null {
|
||||
try {
|
||||
const exact = BigInt(value);
|
||||
if (exact < -jsDateMaxUnixMillis || exact > jsDateMaxUnixMillis) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(Number(exact));
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
exact: value,
|
||||
iso: date.toISOString(),
|
||||
local: date.toLocaleString("fr-CH", { dateStyle: "short", timeStyle: "medium", hour12: false }),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function appendTimestampValue(container: HTMLElement, label: string, value: string | null, emptyLabel: string): void {
|
||||
const line = document.createElement("div");
|
||||
line.className = "app-market-time-line";
|
||||
const prefix = document.createElement("span");
|
||||
prefix.className = "app-market-time-label";
|
||||
prefix.textContent = label;
|
||||
const valueContainer = document.createElement("span");
|
||||
valueContainer.className = "app-market-time-value";
|
||||
if (value === null) {
|
||||
valueContainer.classList.add("text-body-secondary");
|
||||
valueContainer.textContent = emptyLabel;
|
||||
} else {
|
||||
const formatted = formatUnixMillis(value);
|
||||
if (formatted === null) {
|
||||
const exact = document.createElement("span");
|
||||
exact.className = "app-market-time-exact";
|
||||
exact.textContent = `${value} ms`;
|
||||
valueContainer.append(exact);
|
||||
} else {
|
||||
const time = document.createElement("time");
|
||||
time.dateTime = formatted.iso;
|
||||
time.textContent = formatted.local;
|
||||
time.title = formatted.iso;
|
||||
const exact = document.createElement("span");
|
||||
exact.className = "app-market-time-exact d-block";
|
||||
exact.textContent = `${formatted.exact} ms`;
|
||||
valueContainer.append(time, exact);
|
||||
}
|
||||
}
|
||||
line.append(prefix, valueContainer);
|
||||
container.append(line);
|
||||
}
|
||||
|
||||
function appendObservationTimeCell(row: HTMLTableRowElement, provider: MarketPriceProviderRowDto): void {
|
||||
const cell = document.createElement("td");
|
||||
cell.className = "app-market-time-column";
|
||||
const time = document.createElement("div");
|
||||
time.className = "app-market-time";
|
||||
appendTimestampValue(time, "Provider", provider.providerTimestampUnixMillis, "Not supplied");
|
||||
appendTimestampValue(time, "KSP", provider.receivedAtUnixMillis, "—");
|
||||
cell.append(time);
|
||||
row.append(cell);
|
||||
}
|
||||
|
||||
function appendRetryCell(row: HTMLTableRowElement, provider: MarketPriceProviderRowDto): void {
|
||||
const cell = document.createElement("td");
|
||||
cell.className = "app-market-time-column";
|
||||
const time = document.createElement("div");
|
||||
time.className = "app-market-time";
|
||||
appendTimestampValue(time, "Retry", provider.retryAtUnixMillis, "—");
|
||||
cell.append(time);
|
||||
row.append(cell);
|
||||
}
|
||||
|
||||
function appendRefreshCell(row: HTMLTableRowElement, provider: MarketPriceProviderRowDto): void {
|
||||
const cell = document.createElement("td");
|
||||
cell.className = "text-end";
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "btn btn-sm btn-outline-primary text-nowrap";
|
||||
button.disabled = provider.loading;
|
||||
button.dataset.providerId = provider.providerId;
|
||||
button.setAttribute("aria-label", `Refresh ${provider.displayName}`);
|
||||
button.innerHTML = provider.loading
|
||||
? '<i class="fa-solid fa-spinner fa-spin me-1" aria-hidden="true"></i>Refreshing…'
|
||||
: '<i class="fa-solid fa-rotate me-1" aria-hidden="true"></i>Refresh';
|
||||
button.addEventListener("click", () => {
|
||||
void refreshMarketPrice(provider.providerId);
|
||||
});
|
||||
cell.append(button);
|
||||
row.append(cell);
|
||||
}
|
||||
|
||||
function renderMarketPriceRows(rows: MarketPriceProviderRowDto[]): void {
|
||||
const loading = document.querySelector<HTMLElement>("#pricesLoading");
|
||||
const empty = document.querySelector<HTMLElement>("#pricesEmpty");
|
||||
const tableContainer = document.querySelector<HTMLElement>("#pricesTableContainer");
|
||||
const tableBody = document.querySelector<HTMLTableSectionElement>("#marketPriceRows");
|
||||
if (loading) {
|
||||
loading.hidden = true;
|
||||
}
|
||||
if (!tableBody || !tableContainer || !empty) {
|
||||
frontendWarn("main", "SOL Prices Desk market-price table elements are missing");
|
||||
return;
|
||||
}
|
||||
currentMarketPriceRows = rows;
|
||||
synchronizeSelectedProviders();
|
||||
tableBody.replaceChildren();
|
||||
empty.hidden = rows.length !== 0;
|
||||
tableContainer.hidden = rows.length === 0;
|
||||
for (const provider of rows) {
|
||||
const row = document.createElement("tr");
|
||||
row.dataset.providerId = provider.providerId;
|
||||
row.dataset.availability = provider.availability;
|
||||
row.classList.toggle("app-market-row-loading", provider.loading);
|
||||
appendSelectionCell(row, provider);
|
||||
appendProviderCell(row, provider);
|
||||
appendBadgeCell(row, provider.pair, "text-bg-primary");
|
||||
appendBadgeCell(row, semanticsLabels[provider.semantics] ?? semanticsLabels.unknown, "text-bg-light", "d-none d-xl-table-cell");
|
||||
appendBadgeCell(row, authLabels[provider.authMode] ?? authLabels.unknown, "text-bg-light", "d-none d-xl-table-cell");
|
||||
appendAvailabilityCell(row, provider);
|
||||
appendPriceCell(row, provider);
|
||||
appendObservationTimeCell(row, provider);
|
||||
appendRetryCell(row, provider);
|
||||
appendRefreshCell(row, provider);
|
||||
tableBody.append(row);
|
||||
}
|
||||
synchronizeBatchControls();
|
||||
frontendTrace("main", "SOL Prices Desk market-price registry rows rendered", {
|
||||
rowCount: rows.length,
|
||||
observedCount: rows.filter(provider => provider.refreshed).length,
|
||||
attentionCount: rows.filter(provider => provider.availability !== "ready").length,
|
||||
});
|
||||
}
|
||||
|
||||
function selectAllProviders(selected: boolean): void {
|
||||
selectedProviderIds.clear();
|
||||
if (selected) {
|
||||
for (const provider of currentMarketPriceRows) {
|
||||
selectedProviderIds.add(provider.providerId);
|
||||
}
|
||||
}
|
||||
frontendDebug("main", "SOL Prices Desk select-all providers control changed", {
|
||||
selected,
|
||||
selectedCount: selectedProviderIds.size,
|
||||
});
|
||||
renderMarketPriceRows(currentMarketPriceRows);
|
||||
}
|
||||
|
||||
function clearProviderSelection(): void {
|
||||
const selectedCount = selectedProviderIds.size;
|
||||
frontendDebug("main", "SOL Prices Desk clear provider selection control clicked", { selectedCount });
|
||||
selectedProviderIds.clear();
|
||||
renderMarketPriceRows(currentMarketPriceRows);
|
||||
}
|
||||
|
||||
function bindMarketPriceControls(): void {
|
||||
const selectedButton = document.querySelector<HTMLButtonElement>("#refreshSelectedButton");
|
||||
const allButton = document.querySelector<HTMLButtonElement>("#refreshAllButton");
|
||||
const clearButton = document.querySelector<HTMLButtonElement>("#clearSelectionButton");
|
||||
const selectAll = document.querySelector<HTMLInputElement>("#selectAllProviders");
|
||||
selectedButton?.addEventListener("click", () => {
|
||||
void refreshSelectedMarketPrices();
|
||||
});
|
||||
allButton?.addEventListener("click", () => {
|
||||
void refreshAllMarketPrices();
|
||||
});
|
||||
clearButton?.addEventListener("click", () => {
|
||||
clearProviderSelection();
|
||||
});
|
||||
selectAll?.addEventListener("change", () => {
|
||||
selectAllProviders(selectAll.checked);
|
||||
});
|
||||
synchronizeBatchControls();
|
||||
frontendTrace("main", "SOL Prices Desk market-price controls installed");
|
||||
}
|
||||
|
||||
function renderRuntimeStatus(status: MarketPriceRuntimeStatusDto): void {
|
||||
const values: Record<string, string> = {
|
||||
runtimeCompositeProfile: status.activeCompositeProfile,
|
||||
runtimeOffchainProfile: status.activeOffchainProfile,
|
||||
runtimeVersion: status.applicationVersion,
|
||||
runtimeShellPhase: status.shellPhase,
|
||||
runtimeConfigDocuments: status.configDocumentCount.toString(),
|
||||
runtimeProviderCount: status.providerCount.toString(),
|
||||
runtimeReadyProviderCount: status.readyProviderCount.toString(),
|
||||
runtimeUnavailableProviderCount: status.unavailableProviderCount.toString(),
|
||||
runtimeLoggingProfile: status.activeLoggingProfile ?? "fallback",
|
||||
runtimeLoggingFallback: status.fallbackLoggingActive ? "oui" : "non",
|
||||
};
|
||||
for (const [elementId, value] of Object.entries(values)) {
|
||||
const element = document.querySelector<HTMLElement>(`#${elementId}`);
|
||||
if (element) {
|
||||
element.textContent = value;
|
||||
}
|
||||
}
|
||||
const diagnostic = document.querySelector<HTMLElement>("#runtimeDiagnostic");
|
||||
if (diagnostic) {
|
||||
diagnostic.hidden = status.startupDiagnostic === null;
|
||||
diagnostic.textContent = status.startupDiagnostic === null ? "" : `${status.startupDiagnostic.domain}:${status.startupDiagnostic.code} — ${status.startupDiagnostic.message}`;
|
||||
}
|
||||
frontendTrace("main", "SOL Prices Desk market-price runtime status rendered", {
|
||||
fallbackLoggingActive: status.fallbackLoggingActive,
|
||||
configDocumentCount: status.configDocumentCount,
|
||||
providerCount: status.providerCount,
|
||||
readyProviderCount: status.readyProviderCount,
|
||||
unavailableProviderCount: status.unavailableProviderCount,
|
||||
});
|
||||
}
|
||||
|
||||
async function loadMarketPrices(): Promise<void> {
|
||||
frontendTrace("main", "SOL Prices Desk provider registry load started");
|
||||
const rows = await invokeKsp<MarketPriceProviderRowDto[]>("main", "list_market_prices");
|
||||
currentMarketPriceRows = rows;
|
||||
renderMarketPriceRows(rows);
|
||||
frontendTrace("main", "SOL Prices Desk provider registry load completed", { rowCount: rows.length });
|
||||
}
|
||||
|
||||
async function loadRuntimeStatus(): Promise<void> {
|
||||
const status = await invokeKsp<MarketPriceRuntimeStatusDto>("main", "get_runtime_status");
|
||||
renderRuntimeStatus(status);
|
||||
}
|
||||
|
||||
async function initializeMain(): Promise<void> {
|
||||
const windowLabel = getCurrentWindow().label;
|
||||
frontendInfo("main", "SOL Prices Desk main frontend loaded", { windowLabel });
|
||||
bindNavigation();
|
||||
bindMarketPriceControls();
|
||||
activateView("prices", "startup");
|
||||
try {
|
||||
await loadRuntimeStatus();
|
||||
} catch {
|
||||
const diagnostic = document.querySelector<HTMLElement>("#runtimeDiagnostic");
|
||||
if (diagnostic) {
|
||||
diagnostic.hidden = false;
|
||||
diagnostic.textContent = "Le statut runtime initial n'a pas pu être chargé.";
|
||||
}
|
||||
frontendWarn("main", "SOL Prices Desk startup runtime status load failed");
|
||||
}
|
||||
try {
|
||||
await loadMarketPrices();
|
||||
} catch {
|
||||
const loading = document.querySelector<HTMLElement>("#pricesLoading");
|
||||
if (loading) {
|
||||
loading.hidden = true;
|
||||
}
|
||||
showPricesDiagnostic("L'inventaire provider-neutral n'a pas pu être chargé.");
|
||||
frontendWarn("main", "SOL Prices Desk provider registry load failed");
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
void initializeMain();
|
||||
});
|
||||
131
crates/ksp-app-solprices-desk/frontend/ts/splash.ts
Normal file
131
crates/ksp-app-solprices-desk/frontend/ts/splash.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
// file: crates/ksp-app-solprices-desk/frontend/ts/splash.ts
|
||||
// version: 1
|
||||
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import type { SplashOrderDto } from "./bindings/ksp_app_solprices_desk/splash/SplashOrderDto.ts";
|
||||
import { frontendDebug, frontendError, frontendInfo, frontendTrace, installFrontendConsoleBridge } from "./frontend_log";
|
||||
import { invokeKsp } from "./invoke";
|
||||
|
||||
installFrontendConsoleBridge("splash");
|
||||
|
||||
let activeOpacityFrame: number | null = null;
|
||||
|
||||
function easeInOut(value: number): number {
|
||||
return value < 0.5 ? 2 * value * value : 1 - Math.pow(-2 * value + 2, 2) / 2;
|
||||
}
|
||||
|
||||
function normalizeDurationMs(value: number | null): number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
|
||||
}
|
||||
|
||||
async function animateOpacity(element: HTMLElement, fromOpacity: number, toOpacity: number, durationMs: number): Promise<void> {
|
||||
frontendTrace("splash", "Splash opacity animation started", { fromOpacity, toOpacity, durationMs });
|
||||
if (activeOpacityFrame !== null) {
|
||||
cancelAnimationFrame(activeOpacityFrame);
|
||||
activeOpacityFrame = null;
|
||||
}
|
||||
element.style.opacity = fromOpacity.toString();
|
||||
element.style.willChange = "opacity";
|
||||
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
|
||||
await new Promise<void>(resolve => {
|
||||
const startedAt = performance.now();
|
||||
const opacityDelta = toOpacity - fromOpacity;
|
||||
const updateOpacity = (currentTime: number): void => {
|
||||
const elapsedMs = currentTime - startedAt;
|
||||
const rawProgress = durationMs === 0 ? 1 : Math.min(elapsedMs / durationMs, 1);
|
||||
element.style.opacity = (fromOpacity + opacityDelta * easeInOut(rawProgress)).toString();
|
||||
if (rawProgress >= 1) {
|
||||
element.style.opacity = toOpacity.toString();
|
||||
element.style.willChange = "auto";
|
||||
activeOpacityFrame = null;
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
activeOpacityFrame = requestAnimationFrame(updateOpacity);
|
||||
};
|
||||
activeOpacityFrame = requestAnimationFrame(updateOpacity);
|
||||
});
|
||||
frontendTrace("splash", "Splash opacity animation completed", { toOpacity, durationMs });
|
||||
}
|
||||
|
||||
function scrollToLatest(element: HTMLElement): void {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
}
|
||||
|
||||
function addMessage(message: string, status: string | null): void {
|
||||
const container = document.querySelector<HTMLElement>("#messages-container");
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const line = document.createElement("div");
|
||||
const safeStatus = status === "warning" || status === "error" || status === "success" ? status : "info";
|
||||
line.className = `splash-message ${safeStatus}`;
|
||||
line.textContent = message;
|
||||
container.appendChild(line);
|
||||
scrollToLatest(container);
|
||||
}
|
||||
|
||||
function addDebugMessage(message: string): void {
|
||||
const container = document.querySelector<HTMLElement>("#debug-info");
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
container.hidden = false;
|
||||
const line = document.createElement("div");
|
||||
line.textContent = `${new Date().toLocaleTimeString()}: ${message}`;
|
||||
container.appendChild(line);
|
||||
scrollToLatest(container);
|
||||
}
|
||||
|
||||
async function handleSplashOrder(order: SplashOrderDto): Promise<void> {
|
||||
frontendTrace("splash", "Splash order received", { action: order.action });
|
||||
const container = document.querySelector<HTMLElement>("#splash-container");
|
||||
if (order.action === "add_message" && order.message) {
|
||||
addMessage(order.message, order.status);
|
||||
return;
|
||||
}
|
||||
if (order.action === "add_debug" && order.message) {
|
||||
addDebugMessage(order.message);
|
||||
return;
|
||||
}
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
if (order.action === "fade_in") {
|
||||
await animateOpacity(container, 0, 1, normalizeDurationMs(order.durationMs));
|
||||
return;
|
||||
}
|
||||
if (order.action === "fade_out") {
|
||||
await animateOpacity(container, 1, 0, normalizeDurationMs(order.durationMs));
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeSplash(): Promise<void> {
|
||||
const windowLabel = getCurrentWindow().label;
|
||||
frontendInfo("splash", "SOL Prices Desk splash frontend loaded", { windowLabel });
|
||||
const container = document.querySelector<HTMLElement>("#splash-container");
|
||||
if (container) {
|
||||
container.style.opacity = "0";
|
||||
container.style.willChange = "opacity";
|
||||
frontendTrace("splash", "Splash container prepared for managed fade-in");
|
||||
}
|
||||
await listen<SplashOrderDto>("ksp-splash-order", event => {
|
||||
void handleSplashOrder(event.payload);
|
||||
});
|
||||
frontendDebug("splash", "Splash lifecycle listener installed");
|
||||
try {
|
||||
await invokeKsp<void>("splash", "splash_frontend_ready");
|
||||
} catch {
|
||||
addMessage("Le lifecycle du splash n'a pas pu démarrer.", "error");
|
||||
if (container) {
|
||||
container.style.opacity = "1";
|
||||
container.style.willChange = "auto";
|
||||
}
|
||||
frontendError("splash", "Splash readiness command failed");
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
void initializeSplash();
|
||||
});
|
||||
BIN
crates/ksp-app-solprices-desk/icons/favicon.ico
Normal file
BIN
crates/ksp-app-solprices-desk/icons/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 448 KiB |
BIN
crates/ksp-app-solprices-desk/icons/favicon.png
Normal file
BIN
crates/ksp-app-solprices-desk/icons/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
26
crates/ksp-app-solprices-desk/package.json
Normal file
26
crates/ksp-app-solprices-desk/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "ksp-app-solprices-desk",
|
||||
"private": true,
|
||||
"version": "0.2.12-pre.9",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fltsci/tauri-plugin-tracing": "^0.3",
|
||||
"@fortawesome/fontawesome-free": "^7.3",
|
||||
"@tauri-apps/api": "^2.11",
|
||||
"bootstrap": "^5.3",
|
||||
"resize-observer-polyfill": "^1.5",
|
||||
"simplebar": "^6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.11",
|
||||
"@types/bootstrap": "^5.2",
|
||||
"@types/node": "^26.1",
|
||||
"sass-embedded": "^1.102",
|
||||
"typescript": "^7.0",
|
||||
"vite": "^8.2"
|
||||
}
|
||||
}
|
||||
158
crates/ksp-app-solprices-desk/src/app_state.rs
Normal file
158
crates/ksp-app-solprices-desk/src/app_state.rs
Normal file
@@ -0,0 +1,158 @@
|
||||
// file: crates/ksp-app-solprices-desk/src/app_state.rs
|
||||
// version: 6
|
||||
|
||||
//! Shared backend state owned by the SOL Prices Desk Tauri application.
|
||||
|
||||
/// Shared SOL Prices Desk application state managed by Tauri.
|
||||
pub(crate) struct AppState {
|
||||
config_management: ksp_config_lib::ConfigManagement,
|
||||
logging_runtime: std::sync::Mutex<LoggingRuntimeState>,
|
||||
market_price_runtime: crate::MarketPriceRuntime,
|
||||
splash_settings: crate::SplashSettings,
|
||||
splash_sequence_started: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl crate::AppState {
|
||||
/// Initializes Config ownership, composite-managed Logging and the provider-neutral market-price runtime.
|
||||
pub(crate) fn initialize(arguments: &[std::ffi::OsString]) -> ksp_core_lib::Result<Self> {
|
||||
let config_management = crate::config_management(arguments);
|
||||
let config_management = match config_management {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let runtime_identity = crate::launch_identity();
|
||||
let runtime_identity = match runtime_identity {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let logging_startup = crate::initialize_logging(&config_management, &runtime_identity);
|
||||
let logging_startup = match logging_startup {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let offchain_transport_startup = crate::initialize_offchain_transport(&config_management);
|
||||
let offchain_transport_startup = match offchain_transport_startup {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let market_price_runtime = crate::MarketPriceRuntime::new(offchain_transport_startup);
|
||||
let market_price_runtime = match market_price_runtime {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let splash_settings = crate::SplashSettings::load();
|
||||
let splash_settings = match splash_settings {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WINDOWS, error_domain = error.code().domain(), error_code = error.code().code(), "managed splash timings are invalid; using transient in-memory defaults");
|
||||
crate::SplashSettings::fallback()
|
||||
},
|
||||
};
|
||||
ksp_logging_lib::debug!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WINDOWS, minimum_ms = splash_settings.minimum_ms(), minimum_source = splash_settings.minimum_source(), fade_in_ms = splash_settings.fade_in_ms(), fade_in_source = splash_settings.fade_in_source(), fade_out_ms = splash_settings.fade_out_ms(), fade_out_source = splash_settings.fade_out_source(), expected_backend_lifecycle_ms = splash_settings.expected_backend_lifecycle_ms(), "resolved SOL Prices Desk splash timings");
|
||||
return std::result::Result::Ok(Self {
|
||||
config_management,
|
||||
logging_runtime: std::sync::Mutex::new(LoggingRuntimeState {
|
||||
guard: logging_startup.guard,
|
||||
active_profile_id: logging_startup.active_profile_id,
|
||||
fallback_active: logging_startup.fallback_active,
|
||||
startup_diagnostic: logging_startup.startup_diagnostic,
|
||||
}),
|
||||
market_price_runtime,
|
||||
splash_settings,
|
||||
splash_sequence_started: std::sync::atomic::AtomicBool::new(false),
|
||||
});
|
||||
}
|
||||
|
||||
/// Lists the current provider-neutral market-price rows without triggering network refresh.
|
||||
pub(crate) fn list_market_prices(&self) -> ksp_core_lib::Result<std::vec::Vec<crate::MarketPriceProviderRowDto>> {
|
||||
return self.market_price_runtime.list_rows();
|
||||
}
|
||||
|
||||
/// Refreshes one provider by opaque identifier and returns its updated safe row projection.
|
||||
pub(crate) async fn refresh_market_price(&self, provider_id: String) -> ksp_core_lib::Result<crate::MarketPriceProviderRowDto> {
|
||||
return self.market_price_runtime.refresh_one(provider_id).await;
|
||||
}
|
||||
|
||||
/// Refreshes a selected provider set in deterministic request order and returns only safe updated rows/counts.
|
||||
pub(crate) async fn refresh_market_prices(
|
||||
&self,
|
||||
request: crate::MarketPriceRefreshManyRequestDto,
|
||||
) -> ksp_core_lib::Result<crate::MarketPriceRefreshResultDto> {
|
||||
return self.market_price_runtime.refresh_many(request.into_provider_ids()).await;
|
||||
}
|
||||
|
||||
/// Refreshes every configured provider in the deterministic order owned by Off-chain Transport.
|
||||
pub(crate) async fn refresh_all_market_prices(&self) -> ksp_core_lib::Result<crate::MarketPriceRefreshResultDto> {
|
||||
return self.market_price_runtime.refresh_all().await;
|
||||
}
|
||||
|
||||
/// Builds the safe runtime status exposed by the SOL Prices Desk shell.
|
||||
pub(crate) fn runtime_status(&self) -> ksp_core_lib::Result<crate::MarketPriceRuntimeStatusDto> {
|
||||
let document_count = self.config_management.engine().registry().descriptors().count();
|
||||
let document_count = u32::try_from(document_count);
|
||||
let document_count = match document_count {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(
|
||||
crate::ERROR_CODE_APP_STATE_INVALID,
|
||||
"Config registry contains too many descriptors for the SOL Prices Desk runtime DTO",
|
||||
)
|
||||
.with_source(error),
|
||||
);
|
||||
},
|
||||
};
|
||||
let provider_counts = self.market_price_runtime.provider_counts();
|
||||
let (provider_count, ready_provider_count, unavailable_provider_count) = match provider_counts {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let runtime = self.logging_runtime.lock();
|
||||
let runtime = match runtime {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => {
|
||||
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||
crate::ERROR_CODE_APP_STATE_LOCK_FAILED,
|
||||
"SOL Prices Desk Logging runtime state lock is poisoned",
|
||||
));
|
||||
},
|
||||
};
|
||||
let _keep_guard_alive = &runtime.guard;
|
||||
let startup = self.market_price_runtime.startup();
|
||||
let offchain = startup.resolved();
|
||||
return std::result::Result::Ok(crate::MarketPriceRuntimeStatusDto {
|
||||
active_composite_profile: startup.composite_profile_id().to_owned(),
|
||||
active_logging_profile: runtime.active_profile_id.clone(),
|
||||
active_offchain_profile: offchain.profile_id().to_owned(),
|
||||
application_version: env!("CARGO_PKG_VERSION").to_owned(),
|
||||
config_document_count: document_count,
|
||||
fallback_logging_active: runtime.fallback_active,
|
||||
provider_count,
|
||||
ready_provider_count,
|
||||
shell_phase: "pre.007-ux-instrumentation".to_owned(),
|
||||
startup_diagnostic: runtime.startup_diagnostic.clone(),
|
||||
unavailable_provider_count,
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns the resolved splash timings captured during application bootstrap.
|
||||
#[must_use]
|
||||
pub(crate) const fn splash_settings(&self) -> crate::SplashSettings {
|
||||
return self.splash_settings;
|
||||
}
|
||||
|
||||
/// Marks the one-shot splash lifecycle as started and reports whether this caller won the transition.
|
||||
pub(crate) fn begin_splash_sequence(&self) -> bool {
|
||||
return self
|
||||
.splash_sequence_started
|
||||
.compare_exchange(false, true, std::sync::atomic::Ordering::AcqRel, std::sync::atomic::Ordering::Acquire)
|
||||
.is_ok();
|
||||
}
|
||||
}
|
||||
|
||||
struct LoggingRuntimeState {
|
||||
guard: ksp_logging_lib::LoggingGuard,
|
||||
active_profile_id: std::option::Option<String>,
|
||||
fallback_active: bool,
|
||||
startup_diagnostic: std::option::Option<crate::CommandErrorDto>,
|
||||
}
|
||||
197
crates/ksp-app-solprices-desk/src/bootstrap.rs
Normal file
197
crates/ksp-app-solprices-desk/src/bootstrap.rs
Normal file
@@ -0,0 +1,197 @@
|
||||
// file: crates/ksp-app-solprices-desk/src/bootstrap.rs
|
||||
// version: 2
|
||||
|
||||
//! Config composite and Logging bootstrap for SOL Prices Desk.
|
||||
|
||||
/// Crate-internal Logging startup state shared by the application state.
|
||||
pub(crate) struct LoggingStartup {
|
||||
/// Guard that keeps the installed Logging runtime alive.
|
||||
pub(crate) guard: ksp_logging_lib::LoggingGuard,
|
||||
/// Active managed profile when Config resolved one successfully.
|
||||
pub(crate) active_profile_id: std::option::Option<String>,
|
||||
/// Whether a transient in-memory fallback was installed.
|
||||
pub(crate) fallback_active: bool,
|
||||
/// Safe startup diagnostic retained for the shell.
|
||||
pub(crate) startup_diagnostic: std::option::Option<crate::CommandErrorDto>,
|
||||
}
|
||||
|
||||
enum LoggingStartupPlan {
|
||||
Managed {
|
||||
active_profile_id: String,
|
||||
settings: ksp_logging_lib::LoggingSettings,
|
||||
},
|
||||
Fallback {
|
||||
initial_error: ksp_core_lib::Error,
|
||||
diagnostic: crate::CommandErrorDto,
|
||||
settings: ksp_logging_lib::LoggingSettings,
|
||||
},
|
||||
}
|
||||
|
||||
/// Builds the Config management facade from the common KSP CLI bootstrap contract.
|
||||
pub(crate) fn config_management(arguments: &[std::ffi::OsString]) -> ksp_core_lib::Result<ksp_config_lib::ConfigManagement> {
|
||||
let bootstrap = ksp_config_lib::ConfigBootstrapOptions::from_args(arguments);
|
||||
let bootstrap = match bootstrap {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let registry = ksp_config_lib::ConfigFileRegistry::from_args(arguments);
|
||||
let registry = match registry {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let engine = ksp_config_lib::ConfigDocumentEngine::new(bootstrap, registry);
|
||||
return std::result::Result::Ok(ksp_config_lib::ConfigManagement::new(engine));
|
||||
}
|
||||
|
||||
/// Loads the concrete SOL Prices Desk Config composite using its registered logical `file_id` and autonomous default profile.
|
||||
pub(crate) fn load_solprices_desk_composite(management: &ksp_config_lib::ConfigManagement) -> ksp_core_lib::Result<ksp_config_lib::ResolvedConfigComposite> {
|
||||
let file_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_SOLPRICES_DESK);
|
||||
let file_id = match file_id {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return management.engine().load_resolved_composite(&file_id, std::option::Option::None);
|
||||
}
|
||||
|
||||
/// Returns one required standard profile from the SOL Prices Desk composite after validating the referenced logical `file_id`.
|
||||
pub(crate) fn required_composite_component_profile(
|
||||
composite: &ksp_config_lib::ResolvedConfigComposite,
|
||||
component_id: &str,
|
||||
expected_file_id: &str,
|
||||
) -> ksp_core_lib::Result<ksp_config_lib::ResolvedConfigProfile> {
|
||||
let component = composite.component(component_id);
|
||||
let component = match component {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_CONFIG_COMPOSITE_INVALID, "SOL Prices Desk composite is missing a required component")
|
||||
.with_context("composite_file_id", composite.file_id().as_str())
|
||||
.with_context("composite_profile_id", composite.profile_id())
|
||||
.with_context("component_id", component_id),
|
||||
);
|
||||
},
|
||||
};
|
||||
if component.resolved().file_id().as_str() != expected_file_id {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(
|
||||
crate::ERROR_CODE_CONFIG_COMPOSITE_INVALID,
|
||||
"SOL Prices Desk composite component references an unexpected Config document",
|
||||
)
|
||||
.with_context("composite_file_id", composite.file_id().as_str())
|
||||
.with_context("composite_profile_id", composite.profile_id())
|
||||
.with_context("component_id", component_id)
|
||||
.with_context("expected_file_id", expected_file_id)
|
||||
.with_context("actual_file_id", component.resolved().file_id().as_str()),
|
||||
);
|
||||
}
|
||||
return std::result::Result::Ok(component.resolved().clone());
|
||||
}
|
||||
|
||||
/// Initializes Logging from the SOL Prices Desk composite, with the same bounded fallback policy as the other KSP desks.
|
||||
pub(crate) fn initialize_logging(
|
||||
management: &ksp_config_lib::ConfigManagement,
|
||||
runtime_identity: &ksp_logging_lib::LoggingRuntimeIdentity,
|
||||
) -> ksp_core_lib::Result<crate::LoggingStartup> {
|
||||
let plan = resolve_logging_startup(management);
|
||||
return match plan {
|
||||
LoggingStartupPlan::Managed { active_profile_id, settings } => {
|
||||
let guard = ksp_logging_lib::initialize_with_identity(&settings, runtime_identity);
|
||||
match guard {
|
||||
std::result::Result::Ok(guard) => {
|
||||
ksp_logging_lib::info!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_BOOTSTRAP, active_profile = active_profile_id.as_str(), "initialized SOL Prices Desk logging from composite-managed configuration");
|
||||
std::result::Result::Ok(LoggingStartup {
|
||||
guard,
|
||||
active_profile_id: std::option::Option::Some(active_profile_id),
|
||||
fallback_active: false,
|
||||
startup_diagnostic: std::option::Option::None,
|
||||
})
|
||||
},
|
||||
std::result::Result::Err(error) => initialize_fallback_logging(error, runtime_identity),
|
||||
}
|
||||
},
|
||||
LoggingStartupPlan::Fallback { initial_error, diagnostic, settings } => {
|
||||
initialize_planned_fallback_logging(initial_error, diagnostic, settings, runtime_identity)
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fn fallback_logging_settings() -> ksp_logging_lib::LoggingSettings {
|
||||
return ksp_logging_lib::LoggingSettings::new(
|
||||
ksp_logging_lib::LogFilterLevel::Info,
|
||||
ksp_logging_lib::SpanEvents::Off,
|
||||
std::option::Option::Some(ksp_logging_lib::ConsoleSettings::stderr()),
|
||||
std::vec::Vec::new(),
|
||||
);
|
||||
}
|
||||
|
||||
fn resolve_logging_startup(management: &ksp_config_lib::ConfigManagement) -> LoggingStartupPlan {
|
||||
let environment = ksp_config_lib::ConfigEnvironment::load();
|
||||
let environment = match environment {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return fallback_startup_plan(error),
|
||||
};
|
||||
let composite = crate::load_solprices_desk_composite(management);
|
||||
let composite = match composite {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return fallback_startup_plan(error),
|
||||
};
|
||||
let logging_profile = crate::required_composite_component_profile(&composite, crate::COMPOSITE_COMPONENT_ID_LOGGING, ksp_config_lib::FILE_ID_STD_LOGGING);
|
||||
let logging_profile = match logging_profile {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return fallback_startup_plan(error),
|
||||
};
|
||||
let offchain_profile = crate::required_composite_component_profile(
|
||||
&composite,
|
||||
crate::COMPOSITE_COMPONENT_ID_OFFCHAIN_TRANSPORT,
|
||||
ksp_config_lib::FILE_ID_STD_OFFCHAIN_TRANSPORT,
|
||||
);
|
||||
if let std::result::Result::Err(error) = offchain_profile {
|
||||
return fallback_startup_plan(error);
|
||||
}
|
||||
let resolved = management.engine().resolve_logging_config_profile(&logging_profile, &environment);
|
||||
let resolved = match resolved {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return fallback_startup_plan(error),
|
||||
};
|
||||
return LoggingStartupPlan::Managed { active_profile_id: resolved.profile_id().to_owned(), settings: resolved.into_settings() };
|
||||
}
|
||||
|
||||
fn fallback_startup_plan(initial_error: ksp_core_lib::Error) -> LoggingStartupPlan {
|
||||
let diagnostic = crate::CommandErrorDto::from_error(&initial_error);
|
||||
return LoggingStartupPlan::Fallback { initial_error, diagnostic, settings: fallback_logging_settings() };
|
||||
}
|
||||
|
||||
fn initialize_fallback_logging(
|
||||
initial_error: ksp_core_lib::Error,
|
||||
runtime_identity: &ksp_logging_lib::LoggingRuntimeIdentity,
|
||||
) -> ksp_core_lib::Result<crate::LoggingStartup> {
|
||||
let diagnostic = crate::CommandErrorDto::from_error(&initial_error);
|
||||
return initialize_planned_fallback_logging(initial_error, diagnostic, fallback_logging_settings(), runtime_identity);
|
||||
}
|
||||
|
||||
fn initialize_planned_fallback_logging(
|
||||
initial_error: ksp_core_lib::Error,
|
||||
diagnostic: crate::CommandErrorDto,
|
||||
settings: ksp_logging_lib::LoggingSettings,
|
||||
runtime_identity: &ksp_logging_lib::LoggingRuntimeIdentity,
|
||||
) -> ksp_core_lib::Result<crate::LoggingStartup> {
|
||||
let guard = ksp_logging_lib::initialize_with_identity(&settings, runtime_identity);
|
||||
let guard = match guard {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(fallback_error) => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_LOGGING_BOOTSTRAP_FAILED, "Cannot initialize SOL Prices Desk fallback Logging runtime")
|
||||
.with_context("initial_error_domain", initial_error.code().domain())
|
||||
.with_context("initial_error_code", initial_error.code().code())
|
||||
.with_source(fallback_error),
|
||||
);
|
||||
},
|
||||
};
|
||||
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_BOOTSTRAP, error_domain = diagnostic.domain.as_str(), error_code = diagnostic.code.as_str(), "managed Logging configuration is unavailable; using transient in-memory fallback");
|
||||
return std::result::Result::Ok(LoggingStartup {
|
||||
guard,
|
||||
active_profile_id: std::option::Option::None,
|
||||
fallback_active: true,
|
||||
startup_diagnostic: std::option::Option::Some(diagnostic),
|
||||
});
|
||||
}
|
||||
27
crates/ksp-app-solprices-desk/src/constants.rs
Normal file
27
crates/ksp-app-solprices-desk/src/constants.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
// file: crates/ksp-app-solprices-desk/src/constants.rs
|
||||
// version: 2
|
||||
|
||||
//! Application-owned composition identifiers, tracing targets and domains.
|
||||
|
||||
/// Composite-local identifier for the standard Logging component.
|
||||
pub(crate) const COMPOSITE_COMPONENT_ID_LOGGING: &str = "logging";
|
||||
/// Composite-local identifier for the standard Off-chain Transport component.
|
||||
pub(crate) const COMPOSITE_COMPONENT_ID_OFFCHAIN_TRANSPORT: &str = "offchain_transport";
|
||||
/// Structured domain used while bootstrapping Config and Logging.
|
||||
pub(crate) const TRACING_DOMAIN_BOOTSTRAP: &str = "solprices.bootstrap";
|
||||
/// Structured domain used by technical frontend events.
|
||||
pub(crate) const TRACING_DOMAIN_FRONTEND: &str = "frontend";
|
||||
/// Structured domain used by the Config-selected Off-chain Transport runtime.
|
||||
pub(crate) const TRACING_DOMAIN_OFFCHAIN_TRANSPORT: &str = "solprices.offchain_transport";
|
||||
/// Structured domain used by the SOL Prices Desk shell.
|
||||
pub(crate) const TRACING_DOMAIN_SHELL: &str = "solprices.shell";
|
||||
/// Structured domain used by Tauri window lifecycle operations.
|
||||
pub(crate) const TRACING_DOMAIN_WINDOWS: &str = "desktop.window";
|
||||
/// Owning target for backend events emitted by SOL Prices Desk.
|
||||
pub(crate) const TRACING_TARGET: &str = "ksp-app-solprices-desk";
|
||||
/// Owning target for generic frontend events emitted through the KSP bridge.
|
||||
pub(crate) const TRACING_TARGET_FRONTEND: &str = "ksp-app-solprices-desk.frontend";
|
||||
/// Owning target for main-window frontend events.
|
||||
pub(crate) const TRACING_TARGET_FRONTEND_MAIN: &str = "ksp-app-solprices-desk.frontend.main";
|
||||
/// Owning target for splash-window frontend events.
|
||||
pub(crate) const TRACING_TARGET_FRONTEND_SPLASH: &str = "ksp-app-solprices-desk.frontend.splash";
|
||||
64
crates/ksp-app-solprices-desk/src/dto_common.rs
Normal file
64
crates/ksp-app-solprices-desk/src/dto_common.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
// file: crates/ksp-app-solprices-desk/src/dto_common.rs
|
||||
// version: 3
|
||||
|
||||
//! Common Tauri DTOs shared by the SOL Prices Desk shell.
|
||||
|
||||
use ts_rs::TS; // rust-rules: trait-import
|
||||
|
||||
/// Safe command error projection that never serializes arbitrary KSP error context or source values.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_solprices_desk/dto_common/CommandErrorDto.ts")]
|
||||
pub(crate) struct CommandErrorDto {
|
||||
/// Stable KSP error domain.
|
||||
pub(crate) domain: String,
|
||||
/// Stable KSP error code within the domain.
|
||||
pub(crate) code: String,
|
||||
/// Human-readable error message without arbitrary context fields.
|
||||
pub(crate) message: String,
|
||||
}
|
||||
|
||||
impl crate::CommandErrorDto {
|
||||
/// Builds a bounded safe projection from a KSP error.
|
||||
#[must_use]
|
||||
pub(crate) fn from_error(error: &ksp_core_lib::Error) -> Self {
|
||||
return Self {
|
||||
domain: error.code().domain().to_owned(),
|
||||
code: error.code().code().to_owned(),
|
||||
message: error.message().to_owned(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Safe provider-neutral application/runtime snapshot exposed to the SOL Prices Desk shell.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_solprices_desk/dto_common/MarketPriceRuntimeStatusDto.ts")]
|
||||
pub(crate) struct MarketPriceRuntimeStatusDto {
|
||||
/// SOL Prices Desk composite profile selected for this launch.
|
||||
pub(crate) active_composite_profile: String,
|
||||
/// Active configured Logging profile, or `None` while transient fallback Logging is active.
|
||||
pub(crate) active_logging_profile: std::option::Option<String>,
|
||||
/// Standard Off-chain Transport profile selected by the active composite.
|
||||
pub(crate) active_offchain_profile: String,
|
||||
/// Cargo application version.
|
||||
pub(crate) application_version: String,
|
||||
/// Number of logical Config resources registered by the current runtime.
|
||||
pub(crate) config_document_count: u32,
|
||||
/// Whether SOL Prices Desk had to install its transient in-memory Logging fallback.
|
||||
pub(crate) fallback_logging_active: bool,
|
||||
/// Total number of configured provider-neutral market-price rows.
|
||||
pub(crate) provider_count: u32,
|
||||
/// Number of providers currently classified as immediately ready.
|
||||
pub(crate) ready_provider_count: u32,
|
||||
/// Current implementation phase exposed by the shell.
|
||||
pub(crate) shell_phase: String,
|
||||
/// Safe startup diagnostic that caused fallback Logging, when applicable.
|
||||
pub(crate) startup_diagnostic: std::option::Option<CommandErrorDto>,
|
||||
/// Number of providers currently not classified as immediately ready.
|
||||
pub(crate) unavailable_provider_count: u32,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/dto_common.rs"]
|
||||
mod tests;
|
||||
35
crates/ksp-app-solprices-desk/src/errors.rs
Normal file
35
crates/ksp-app-solprices-desk/src/errors.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
// file: crates/ksp-app-solprices-desk/src/errors.rs
|
||||
// version: 3
|
||||
|
||||
//! Application-local error codes for SOL Prices Desk composition and desktop runtime surfaces.
|
||||
|
||||
/// Shared SOL Prices Desk application state is internally inconsistent.
|
||||
pub(crate) const ERROR_CODE_APP_STATE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("solprices_desk", "app_state_invalid");
|
||||
/// Shared SOL Prices Desk runtime state cannot be locked safely.
|
||||
pub(crate) const ERROR_CODE_APP_STATE_LOCK_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("solprices_desk", "app_state_lock_failed");
|
||||
/// The SOL Prices Desk Config composite is missing or misroutes one required standard component.
|
||||
pub(crate) const ERROR_CODE_CONFIG_COMPOSITE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("solprices_desk", "config_composite_invalid");
|
||||
/// Frontend logging requested an unsupported level.
|
||||
pub(crate) const ERROR_CODE_FRONTEND_LOG_LEVEL_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("solprices_desk", "frontend_log_level_invalid");
|
||||
/// Frontend logging requested a target outside the application whitelist.
|
||||
pub(crate) const ERROR_CODE_FRONTEND_LOG_TARGET_INVALID: ksp_core_lib::ErrorCode =
|
||||
ksp_core_lib::ErrorCode::new("solprices_desk", "frontend_log_target_invalid");
|
||||
/// SOL Prices Desk could not install the managed Logging runtime or its safe fallback.
|
||||
pub(crate) const ERROR_CODE_LOGGING_BOOTSTRAP_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("solprices_desk", "logging_bootstrap_failed");
|
||||
/// One requested market-price provider already has an explicit refresh in flight.
|
||||
pub(crate) const ERROR_CODE_MARKET_PRICE_REFRESH_CONFLICT: ksp_core_lib::ErrorCode =
|
||||
ksp_core_lib::ErrorCode::new("solprices_desk", "market_price_refresh_conflict");
|
||||
/// A selected market-price refresh contains duplicate or unknown provider identifiers.
|
||||
pub(crate) const ERROR_CODE_MARKET_PRICE_SELECTION_INVALID: ksp_core_lib::ErrorCode =
|
||||
ksp_core_lib::ErrorCode::new("solprices_desk", "market_price_selection_invalid");
|
||||
/// Splash readiness was invoked from a window other than the splash window.
|
||||
pub(crate) const ERROR_CODE_SPLASH_ORIGIN_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("solprices_desk", "splash_origin_invalid");
|
||||
/// A KSP desk splash environment duration is malformed or exceeds its safety bound.
|
||||
pub(crate) const ERROR_CODE_SPLASH_SETTING_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("solprices_desk", "splash_setting_invalid");
|
||||
/// Tauri runtime assembly or execution failed.
|
||||
pub(crate) const ERROR_CODE_TAURI_RUNTIME_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("solprices_desk", "tauri_runtime_failed");
|
||||
/// A required Tauri window is missing from the configured application runtime.
|
||||
pub(crate) const ERROR_CODE_TAURI_WINDOW_MISSING: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("solprices_desk", "tauri_window_missing");
|
||||
/// A Tauri window show/focus/destroy/event operation failed.
|
||||
pub(crate) const ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED: ksp_core_lib::ErrorCode =
|
||||
ksp_core_lib::ErrorCode::new("solprices_desk", "tauri_window_operation_failed");
|
||||
205
crates/ksp-app-solprices-desk/src/frontend_logging.rs
Normal file
205
crates/ksp-app-solprices-desk/src/frontend_logging.rs
Normal file
@@ -0,0 +1,205 @@
|
||||
// file: crates/ksp-app-solprices-desk/src/frontend_logging.rs
|
||||
// version: 1
|
||||
|
||||
//! KSP-owned bridge for technical log events emitted by SOL Prices Desk frontend scripts.
|
||||
|
||||
use ts_rs::TS; // rust-rules: trait-import
|
||||
|
||||
/// Log payload sent by SOL Prices Desk frontend scripts.
|
||||
#[derive(Clone, Debug, serde::Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_solprices_desk/frontend_logging/FrontendLogPayloadDto.ts")]
|
||||
pub(crate) struct FrontendLogPayloadDto {
|
||||
/// Lowercase KSP log level.
|
||||
pub(crate) level: String,
|
||||
/// Whitelisted logical frontend target identifier.
|
||||
pub(crate) target_id: String,
|
||||
/// Rendered technical frontend message.
|
||||
pub(crate) message: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum FrontendLogLevel {
|
||||
Trace,
|
||||
Debug,
|
||||
Info,
|
||||
Warn,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum FrontendLogTarget {
|
||||
Frontend,
|
||||
Main,
|
||||
Splash,
|
||||
}
|
||||
|
||||
/// Emits one validated frontend event through the KSP Logging facade.
|
||||
pub(crate) fn emit_frontend_log_event(payload: FrontendLogPayloadDto) -> ksp_core_lib::Result<()> {
|
||||
let level = parse_level(payload.level.as_str());
|
||||
let level = match level {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let target = parse_target(payload.target_id.as_str());
|
||||
let target = match target {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
emit_validated_frontend_log(level, target, payload.message.as_str());
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn parse_level(level: &str) -> ksp_core_lib::Result<FrontendLogLevel> {
|
||||
return match level.trim().to_ascii_lowercase().as_str() {
|
||||
"trace" => std::result::Result::Ok(FrontendLogLevel::Trace),
|
||||
"debug" => std::result::Result::Ok(FrontendLogLevel::Debug),
|
||||
"info" => std::result::Result::Ok(FrontendLogLevel::Info),
|
||||
"warn" => std::result::Result::Ok(FrontendLogLevel::Warn),
|
||||
"error" => std::result::Result::Ok(FrontendLogLevel::Error),
|
||||
_ => std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_FRONTEND_LOG_LEVEL_INVALID, "Frontend log level is not supported")),
|
||||
};
|
||||
}
|
||||
|
||||
fn parse_target(target_id: &str) -> ksp_core_lib::Result<FrontendLogTarget> {
|
||||
return match target_id.trim().to_ascii_lowercase().as_str() {
|
||||
"frontend" => std::result::Result::Ok(FrontendLogTarget::Frontend),
|
||||
"main" => std::result::Result::Ok(FrontendLogTarget::Main),
|
||||
"splash" => std::result::Result::Ok(FrontendLogTarget::Splash),
|
||||
_ => {
|
||||
std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_FRONTEND_LOG_TARGET_INVALID, "Frontend log target identifier is not supported"))
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fn emit_validated_frontend_log(level: FrontendLogLevel, target: FrontendLogTarget, message: &str) {
|
||||
return match target {
|
||||
FrontendLogTarget::Frontend => emit_frontend_target(level, message),
|
||||
FrontendLogTarget::Main => emit_main_target(level, message),
|
||||
FrontendLogTarget::Splash => emit_splash_target(level, message),
|
||||
};
|
||||
}
|
||||
|
||||
fn emit_frontend_target(level: FrontendLogLevel, message: &str) {
|
||||
return match level {
|
||||
FrontendLogLevel::Trace => ksp_logging_lib::trace!(
|
||||
target: crate::TRACING_TARGET_FRONTEND,
|
||||
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||
action = "frontend_log",
|
||||
target_id = "frontend",
|
||||
"{message}"
|
||||
),
|
||||
FrontendLogLevel::Debug => ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET_FRONTEND,
|
||||
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||
action = "frontend_log",
|
||||
target_id = "frontend",
|
||||
"{message}"
|
||||
),
|
||||
FrontendLogLevel::Info => ksp_logging_lib::info!(
|
||||
target: crate::TRACING_TARGET_FRONTEND,
|
||||
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||
action = "frontend_log",
|
||||
target_id = "frontend",
|
||||
"{message}"
|
||||
),
|
||||
FrontendLogLevel::Warn => ksp_logging_lib::warn!(
|
||||
target: crate::TRACING_TARGET_FRONTEND,
|
||||
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||
action = "frontend_log",
|
||||
target_id = "frontend",
|
||||
"{message}"
|
||||
),
|
||||
FrontendLogLevel::Error => ksp_logging_lib::error!(
|
||||
target: crate::TRACING_TARGET_FRONTEND,
|
||||
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||
action = "frontend_log",
|
||||
target_id = "frontend",
|
||||
"{message}"
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
fn emit_main_target(level: FrontendLogLevel, message: &str) {
|
||||
return match level {
|
||||
FrontendLogLevel::Trace => ksp_logging_lib::trace!(
|
||||
target: crate::TRACING_TARGET_FRONTEND_MAIN,
|
||||
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||
action = "frontend_log",
|
||||
target_id = "main",
|
||||
"{message}"
|
||||
),
|
||||
FrontendLogLevel::Debug => ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET_FRONTEND_MAIN,
|
||||
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||
action = "frontend_log",
|
||||
target_id = "main",
|
||||
"{message}"
|
||||
),
|
||||
FrontendLogLevel::Info => ksp_logging_lib::info!(
|
||||
target: crate::TRACING_TARGET_FRONTEND_MAIN,
|
||||
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||
action = "frontend_log",
|
||||
target_id = "main",
|
||||
"{message}"
|
||||
),
|
||||
FrontendLogLevel::Warn => ksp_logging_lib::warn!(
|
||||
target: crate::TRACING_TARGET_FRONTEND_MAIN,
|
||||
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||
action = "frontend_log",
|
||||
target_id = "main",
|
||||
"{message}"
|
||||
),
|
||||
FrontendLogLevel::Error => ksp_logging_lib::error!(
|
||||
target: crate::TRACING_TARGET_FRONTEND_MAIN,
|
||||
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||
action = "frontend_log",
|
||||
target_id = "main",
|
||||
"{message}"
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
fn emit_splash_target(level: FrontendLogLevel, message: &str) {
|
||||
return match level {
|
||||
FrontendLogLevel::Trace => ksp_logging_lib::trace!(
|
||||
target: crate::TRACING_TARGET_FRONTEND_SPLASH,
|
||||
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||
action = "frontend_log",
|
||||
target_id = "splash",
|
||||
"{message}"
|
||||
),
|
||||
FrontendLogLevel::Debug => ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET_FRONTEND_SPLASH,
|
||||
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||
action = "frontend_log",
|
||||
target_id = "splash",
|
||||
"{message}"
|
||||
),
|
||||
FrontendLogLevel::Info => ksp_logging_lib::info!(
|
||||
target: crate::TRACING_TARGET_FRONTEND_SPLASH,
|
||||
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||
action = "frontend_log",
|
||||
target_id = "splash",
|
||||
"{message}"
|
||||
),
|
||||
FrontendLogLevel::Warn => ksp_logging_lib::warn!(
|
||||
target: crate::TRACING_TARGET_FRONTEND_SPLASH,
|
||||
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||
action = "frontend_log",
|
||||
target_id = "splash",
|
||||
"{message}"
|
||||
),
|
||||
FrontendLogLevel::Error => ksp_logging_lib::error!(
|
||||
target: crate::TRACING_TARGET_FRONTEND_SPLASH,
|
||||
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||
action = "frontend_log",
|
||||
target_id = "splash",
|
||||
"{message}"
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/frontend_logging.rs"]
|
||||
mod tests;
|
||||
120
crates/ksp-app-solprices-desk/src/lib.rs
Normal file
120
crates/ksp-app-solprices-desk/src/lib.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
// file: crates/ksp-app-solprices-desk/src/lib.rs
|
||||
// version: 4
|
||||
|
||||
//! Tauri desktop application scaffold for provider-neutral SOL/USD price visualization.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
mod app_state;
|
||||
mod bootstrap;
|
||||
mod constants;
|
||||
mod dto_common;
|
||||
mod errors;
|
||||
mod frontend_logging;
|
||||
mod logging_runtime;
|
||||
mod market_price_runtime;
|
||||
mod offchain_runtime;
|
||||
mod splash;
|
||||
mod tauri;
|
||||
mod tw_main;
|
||||
mod tw_splash;
|
||||
|
||||
/// Runs the KSP SOL prices desktop application.
|
||||
pub use self::tauri::run;
|
||||
|
||||
/// Shared SOL Prices Desk application state managed by Tauri.
|
||||
pub(crate) use self::app_state::AppState;
|
||||
/// Crate-internal Logging startup state shared by the application state.
|
||||
pub(crate) use self::bootstrap::LoggingStartup;
|
||||
/// Builds the Config management facade from the common KSP CLI bootstrap contract.
|
||||
pub(crate) use self::bootstrap::config_management;
|
||||
/// Initializes the scaffold Logging runtime from Config, with a bounded in-memory fallback.
|
||||
pub(crate) use self::bootstrap::initialize_logging;
|
||||
/// Loads the SOL Prices Desk composite selected by its registered logical file identifier.
|
||||
pub(crate) use self::bootstrap::load_solprices_desk_composite;
|
||||
/// Resolves one required standard Config profile from the SOL Prices Desk composite.
|
||||
pub(crate) use self::bootstrap::required_composite_component_profile;
|
||||
/// Composite-local identifier for the standard Logging component.
|
||||
pub(crate) use self::constants::COMPOSITE_COMPONENT_ID_LOGGING;
|
||||
/// Composite-local identifier for the standard Off-chain Transport component.
|
||||
pub(crate) use self::constants::COMPOSITE_COMPONENT_ID_OFFCHAIN_TRANSPORT;
|
||||
/// Structured domain used while bootstrapping Config and Logging.
|
||||
pub(crate) use self::constants::TRACING_DOMAIN_BOOTSTRAP;
|
||||
/// Structured domain used by technical frontend events.
|
||||
pub(crate) use self::constants::TRACING_DOMAIN_FRONTEND;
|
||||
/// Structured domain used by the Config-selected Off-chain Transport runtime.
|
||||
pub(crate) use self::constants::TRACING_DOMAIN_OFFCHAIN_TRANSPORT;
|
||||
/// Structured domain used by the SOL Prices Desk shell.
|
||||
pub(crate) use self::constants::TRACING_DOMAIN_SHELL;
|
||||
/// Structured domain used by Tauri window lifecycle operations.
|
||||
pub(crate) use self::constants::TRACING_DOMAIN_WINDOWS;
|
||||
/// Owning target for backend events emitted by SOL Prices Desk.
|
||||
pub(crate) use self::constants::TRACING_TARGET;
|
||||
/// Owning target for generic frontend events emitted through the KSP bridge.
|
||||
pub(crate) use self::constants::TRACING_TARGET_FRONTEND;
|
||||
/// Owning target for main-window frontend events.
|
||||
pub(crate) use self::constants::TRACING_TARGET_FRONTEND_MAIN;
|
||||
/// Owning target for splash-window frontend events.
|
||||
pub(crate) use self::constants::TRACING_TARGET_FRONTEND_SPLASH;
|
||||
/// Safe command error projection exposed to Tauri commands.
|
||||
pub(crate) use self::dto_common::CommandErrorDto;
|
||||
/// Safe provider-neutral runtime snapshot exposed to the shell.
|
||||
pub(crate) use self::dto_common::MarketPriceRuntimeStatusDto;
|
||||
/// Shared SOL Prices Desk application state is internally inconsistent.
|
||||
pub(crate) use self::errors::ERROR_CODE_APP_STATE_INVALID;
|
||||
/// Shared SOL Prices Desk runtime state cannot be locked safely.
|
||||
pub(crate) use self::errors::ERROR_CODE_APP_STATE_LOCK_FAILED;
|
||||
/// The SOL Prices Desk Config composite is missing or misroutes a required component.
|
||||
pub(crate) use self::errors::ERROR_CODE_CONFIG_COMPOSITE_INVALID;
|
||||
/// Frontend logging requested an unsupported level.
|
||||
pub(crate) use self::errors::ERROR_CODE_FRONTEND_LOG_LEVEL_INVALID;
|
||||
/// Frontend logging requested a target outside the application whitelist.
|
||||
pub(crate) use self::errors::ERROR_CODE_FRONTEND_LOG_TARGET_INVALID;
|
||||
/// SOL Prices Desk could not install the managed Logging runtime or its safe fallback.
|
||||
pub(crate) use self::errors::ERROR_CODE_LOGGING_BOOTSTRAP_FAILED;
|
||||
/// One requested market-price provider already has an explicit refresh in flight.
|
||||
pub(crate) use self::errors::ERROR_CODE_MARKET_PRICE_REFRESH_CONFLICT;
|
||||
/// A selected market-price refresh contains duplicate or unknown provider identifiers.
|
||||
pub(crate) use self::errors::ERROR_CODE_MARKET_PRICE_SELECTION_INVALID;
|
||||
/// Splash readiness was invoked from a window other than the splash window.
|
||||
pub(crate) use self::errors::ERROR_CODE_SPLASH_ORIGIN_INVALID;
|
||||
/// A KSP desk splash environment duration is malformed or exceeds its safety bound.
|
||||
pub(crate) use self::errors::ERROR_CODE_SPLASH_SETTING_INVALID;
|
||||
/// Tauri runtime assembly or execution failed.
|
||||
pub(crate) use self::errors::ERROR_CODE_TAURI_RUNTIME_FAILED;
|
||||
/// A required Tauri window is missing from the configured application runtime.
|
||||
pub(crate) use self::errors::ERROR_CODE_TAURI_WINDOW_MISSING;
|
||||
/// A Tauri window show/focus/destroy/event operation failed.
|
||||
pub(crate) use self::errors::ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED;
|
||||
/// Log payload sent by SOL Prices Desk frontend scripts.
|
||||
pub(crate) use self::frontend_logging::FrontendLogPayloadDto;
|
||||
/// Emits one validated frontend event through the KSP Logging facade.
|
||||
pub(crate) use self::frontend_logging::emit_frontend_log_event;
|
||||
/// Creates the stable runtime identity for this SOL Prices Desk process launch.
|
||||
pub(crate) use self::logging_runtime::launch_identity;
|
||||
/// Safe provider-neutral market-price row exposed to the frontend.
|
||||
pub(crate) use self::market_price_runtime::MarketPriceProviderRowDto;
|
||||
/// Bounded provider-neutral request used by the selected-provider refresh command.
|
||||
pub(crate) use self::market_price_runtime::MarketPriceRefreshManyRequestDto;
|
||||
/// Safe result returned by selected/global market-price refresh commands.
|
||||
pub(crate) use self::market_price_runtime::MarketPriceRefreshResultDto;
|
||||
/// Backend application runtime owning the Config-selected service and in-memory market-price presentation state.
|
||||
pub(crate) use self::market_price_runtime::MarketPriceRuntime;
|
||||
/// Resolved composite and Off-chain Transport configuration retained by SOL Prices Desk.
|
||||
pub(crate) use self::offchain_runtime::OffchainTransportStartup;
|
||||
/// Resolves the composite-selected Off-chain Transport profile and constructs the Config-owned service.
|
||||
pub(crate) use self::offchain_runtime::initialize_offchain_transport;
|
||||
/// Command emitted by Rust to the splash frontend.
|
||||
pub(crate) use self::splash::SplashOrderDto;
|
||||
/// Runtime timings used by the common desk splash lifecycle.
|
||||
pub(crate) use self::splash::SplashSettings;
|
||||
/// Resolves the required main window or returns a typed error.
|
||||
pub(crate) use self::tw_main::require_main_window;
|
||||
/// Shows and focuses the main SOL Prices Desk window.
|
||||
pub(crate) use self::tw_main::show_main_window;
|
||||
/// Resolves the required splash window or returns a typed error.
|
||||
pub(crate) use self::tw_splash::require_splash_window;
|
||||
/// Starts the one-shot splash lifecycle after the splash frontend reports readiness.
|
||||
pub(crate) use self::tw_splash::splash_frontend_ready_service;
|
||||
10
crates/ksp-app-solprices-desk/src/logging_runtime.rs
Normal file
10
crates/ksp-app-solprices-desk/src/logging_runtime.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
// file: crates/ksp-app-solprices-desk/src/logging_runtime.rs
|
||||
// version: 1
|
||||
|
||||
//! SOL Prices Desk Logging runtime identity helpers.
|
||||
|
||||
/// Creates the stable runtime identity for this SOL Prices Desk process launch.
|
||||
pub(crate) fn launch_identity() -> ksp_core_lib::Result<ksp_logging_lib::LoggingRuntimeIdentity> {
|
||||
let timestamp = format!("{}-p{}", chrono::Utc::now().format("%Y%m%d-%H%M%S%.3fZ"), std::process::id());
|
||||
return ksp_logging_lib::LoggingRuntimeIdentity::new(crate::TRACING_TARGET, timestamp);
|
||||
}
|
||||
58
crates/ksp-app-solprices-desk/src/main.rs
Normal file
58
crates/ksp-app-solprices-desk/src/main.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
// file: crates/ksp-app-solprices-desk/src/main.rs
|
||||
// version: 1
|
||||
|
||||
//! Binary entry point for the KSP SOL prices desktop application.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![warn(missing_docs)]
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use fs2::FileExt; // rust-rules: trait-import
|
||||
|
||||
fn main() -> std::process::ExitCode {
|
||||
let working_directory = configure_runtime_working_directory();
|
||||
if let std::result::Result::Err(error) = working_directory {
|
||||
eprintln!("cannot configure SOL Prices Desk runtime working directory: {error}");
|
||||
return std::process::ExitCode::FAILURE;
|
||||
}
|
||||
let mut lock_path = std::env::temp_dir();
|
||||
lock_path.push("com_sasedev_ksp_app_solprices_desk.lock");
|
||||
let lock_file = match std::fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(&lock_path) {
|
||||
std::result::Result::Ok(file) => file,
|
||||
std::result::Result::Err(error) => {
|
||||
eprintln!("cannot create application lock '{}': {error}", lock_path.display());
|
||||
return std::process::ExitCode::FAILURE;
|
||||
},
|
||||
};
|
||||
if let std::result::Result::Err(error) = lock_file.try_lock_exclusive() {
|
||||
if error.kind() == std::io::ErrorKind::WouldBlock {
|
||||
eprintln!("another ksp-app-solprices-desk instance is already running");
|
||||
return std::process::ExitCode::FAILURE;
|
||||
}
|
||||
eprintln!("cannot acquire application lock '{}': {error}", lock_path.display());
|
||||
return std::process::ExitCode::FAILURE;
|
||||
}
|
||||
let _lock_file = lock_file;
|
||||
let arguments = std::env::args_os().collect::<std::vec::Vec<std::ffi::OsString>>();
|
||||
let run_result = ksp_app_solprices_desk_lib::run(arguments.as_slice());
|
||||
return match run_result {
|
||||
std::result::Result::Ok(()) => std::process::ExitCode::SUCCESS,
|
||||
std::result::Result::Err(error) => {
|
||||
eprintln!("application error: {error}");
|
||||
std::process::ExitCode::FAILURE
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fn configure_runtime_working_directory() -> std::io::Result<()> {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
return std::env::set_current_dir(workspace_root);
|
||||
}
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
}
|
||||
611
crates/ksp-app-solprices-desk/src/market_price_runtime.rs
Normal file
611
crates/ksp-app-solprices-desk/src/market_price_runtime.rs
Normal file
@@ -0,0 +1,611 @@
|
||||
// file: crates/ksp-app-solprices-desk/src/market_price_runtime.rs
|
||||
// version: 4
|
||||
|
||||
//! Provider-neutral market-price presentation runtime owned by SOL Prices Desk.
|
||||
|
||||
use ts_rs::TS; // rust-rules: trait-import
|
||||
|
||||
const MARKET_PRICE_REFRESH_MAX_PROVIDER_IDS: usize = 64;
|
||||
|
||||
/// Safe provider-neutral row projected to the SOL Prices Desk frontend.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_solprices_desk/market_price_runtime/MarketPriceProviderRowDto.ts")]
|
||||
pub(crate) struct MarketPriceProviderRowDto {
|
||||
/// Generic authentication capability code for the configured provider mode.
|
||||
pub(crate) auth_mode: String,
|
||||
/// Generic current availability code.
|
||||
pub(crate) availability: String,
|
||||
/// Safe provider display name supplied by Off-chain Transport.
|
||||
pub(crate) display_name: String,
|
||||
/// Whether one refresh is currently in flight for this provider.
|
||||
pub(crate) loading: bool,
|
||||
/// Stable V1 pair label.
|
||||
pub(crate) pair: String,
|
||||
/// Exact canonical SOL/USD decimal string, when an observation exists.
|
||||
pub(crate) price: std::option::Option<String>,
|
||||
/// Opaque provider identifier accepted by refresh commands.
|
||||
pub(crate) provider_id: String,
|
||||
/// Provider-supplied observation timestamp in exact Unix milliseconds, when genuinely supplied.
|
||||
pub(crate) provider_timestamp_unix_millis: std::option::Option<String>,
|
||||
/// KSP receipt timestamp in exact Unix milliseconds, when an observation exists.
|
||||
pub(crate) received_at_unix_millis: std::option::Option<String>,
|
||||
/// Whether this row currently retains at least one successful observation.
|
||||
pub(crate) refreshed: bool,
|
||||
/// Known next retry timestamp in exact Unix milliseconds for cooldown/temporary states.
|
||||
pub(crate) retry_at_unix_millis: std::option::Option<String>,
|
||||
/// Generic price-semantics code retained from the provider descriptor.
|
||||
pub(crate) semantics: String,
|
||||
}
|
||||
|
||||
/// Bounded provider-neutral request used by the selected-provider refresh command.
|
||||
#[derive(Debug, serde::Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_solprices_desk/market_price_runtime/MarketPriceRefreshManyRequestDto.ts")]
|
||||
pub(crate) struct MarketPriceRefreshManyRequestDto {
|
||||
/// Opaque provider identifiers requested by the frontend in deterministic row order.
|
||||
pub(crate) provider_ids: std::vec::Vec<String>,
|
||||
}
|
||||
|
||||
impl crate::MarketPriceRefreshManyRequestDto {
|
||||
/// Consumes the IPC DTO and returns the opaque provider identifiers for authoritative backend validation.
|
||||
pub(crate) fn into_provider_ids(self) -> std::vec::Vec<String> {
|
||||
return self.provider_ids;
|
||||
}
|
||||
}
|
||||
|
||||
/// Safe result of one selected/global refresh operation.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_solprices_desk/market_price_runtime/MarketPriceRefreshResultDto.ts")]
|
||||
pub(crate) struct MarketPriceRefreshResultDto {
|
||||
/// Number of provider refreshes that produced a new normalized observation.
|
||||
pub(crate) refreshed_count: u32,
|
||||
/// Number of providers included in the validated operation.
|
||||
pub(crate) requested_count: u32,
|
||||
/// Updated provider-neutral rows in the exact dispatch/outcome order.
|
||||
pub(crate) rows: std::vec::Vec<MarketPriceProviderRowDto>,
|
||||
}
|
||||
|
||||
/// Backend application runtime that owns the Config-selected Off-chain service and current in-memory presentation state.
|
||||
pub(crate) struct MarketPriceRuntime {
|
||||
presentation: std::sync::Mutex<MarketPricePresentationState>,
|
||||
startup: crate::OffchainTransportStartup,
|
||||
}
|
||||
|
||||
impl crate::MarketPriceRuntime {
|
||||
/// Builds the application presentation runtime from the already Config-resolved Off-chain startup.
|
||||
pub(crate) fn new(startup: crate::OffchainTransportStartup) -> ksp_core_lib::Result<Self> {
|
||||
let registry = startup.resolved().service().registry();
|
||||
let mut entries = std::collections::BTreeMap::new();
|
||||
for entry in registry.entries() {
|
||||
let previous = entries
|
||||
.insert(entry.descriptor().id().as_str().to_owned(), MarketPricePresentationEntry { loading: false, observation: std::option::Option::None });
|
||||
if previous.is_some() {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "SOL Prices Desk market-price runtime contains duplicate provider state")
|
||||
.with_context("provider_id", entry.descriptor().id().as_str()),
|
||||
);
|
||||
}
|
||||
}
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT,
|
||||
provider_count = entries.len(),
|
||||
"initialized SOL Prices Desk market-price presentation runtime"
|
||||
);
|
||||
return std::result::Result::Ok(Self { presentation: std::sync::Mutex::new(MarketPricePresentationState { entries }), startup });
|
||||
}
|
||||
|
||||
/// Returns the Config/bootstrap metadata retained by the price runtime.
|
||||
#[must_use]
|
||||
pub(crate) const fn startup(&self) -> &crate::OffchainTransportStartup {
|
||||
return &self.startup;
|
||||
}
|
||||
|
||||
/// Returns total, ready and unavailable provider counts from the detached generic registry snapshot.
|
||||
pub(crate) fn provider_counts(&self) -> ksp_core_lib::Result<(u32, u32, u32)> {
|
||||
let registry = self.startup.resolved().service().registry();
|
||||
let total = count_to_u32(registry.len(), "provider_count");
|
||||
let total = match total {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut ready_count = 0usize;
|
||||
for entry in registry.entries() {
|
||||
if matches!(entry.state().availability(), ksp_offchain_transport_lib::MarketPriceProviderAvailability::Ready) {
|
||||
ready_count += 1;
|
||||
}
|
||||
}
|
||||
let ready = count_to_u32(ready_count, "ready_provider_count");
|
||||
let ready = match ready {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let unavailable = total.saturating_sub(ready);
|
||||
return std::result::Result::Ok((total, ready, unavailable));
|
||||
}
|
||||
|
||||
/// Projects the current registry and in-memory observation state as deterministic frontend rows without network dispatch.
|
||||
pub(crate) fn list_rows(&self) -> ksp_core_lib::Result<std::vec::Vec<crate::MarketPriceProviderRowDto>> {
|
||||
let registry = self.startup.resolved().service().registry();
|
||||
let presentation = lock_presentation(&self.presentation);
|
||||
let presentation = match presentation {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut rows = std::vec::Vec::with_capacity(registry.len());
|
||||
for entry in registry.entries() {
|
||||
let provider_id = entry.descriptor().id().as_str();
|
||||
let presentation_entry = presentation.entries.get(provider_id);
|
||||
let presentation_entry = match presentation_entry {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "SOL Prices Desk market-price runtime is missing one registry row")
|
||||
.with_context("provider_id", provider_id),
|
||||
);
|
||||
},
|
||||
};
|
||||
rows.push(project_registry_entry(entry, presentation_entry));
|
||||
}
|
||||
return std::result::Result::Ok(rows);
|
||||
}
|
||||
|
||||
/// Refreshes one opaque provider through Off-chain Transport and updates only its in-memory presentation row.
|
||||
pub(crate) async fn refresh_one(&self, provider_id: String) -> ksp_core_lib::Result<crate::MarketPriceProviderRowDto> {
|
||||
let provider_id = ksp_offchain_transport_lib::MarketPriceProviderId::new(provider_id);
|
||||
let provider_id = match provider_id {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let provider_ids = [provider_id.clone()];
|
||||
let loading = self.begin_refresh(provider_ids.as_slice());
|
||||
if let std::result::Result::Err(error) = loading {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT,
|
||||
provider_id = provider_id.as_str(),
|
||||
"started SOL Prices Desk single-provider market-price refresh"
|
||||
);
|
||||
let outcome = self.startup.resolved().service().refresh(&provider_id).await;
|
||||
let outcome = match outcome {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
let cleared = self.clear_loading(provider_ids.as_slice());
|
||||
if let std::result::Result::Err(clear_error) = cleared {
|
||||
return std::result::Result::Err(clear_error);
|
||||
}
|
||||
return std::result::Result::Err(error);
|
||||
},
|
||||
};
|
||||
let row = self.apply_outcome(&outcome);
|
||||
let row = match row {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
let cleared = self.clear_loading(provider_ids.as_slice());
|
||||
if let std::result::Result::Err(clear_error) = cleared {
|
||||
return std::result::Result::Err(clear_error);
|
||||
}
|
||||
return std::result::Result::Err(error);
|
||||
},
|
||||
};
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT,
|
||||
provider_id = provider_id.as_str(),
|
||||
refreshed = outcome.refreshed(),
|
||||
availability = row.availability.as_str(),
|
||||
"completed SOL Prices Desk single-provider market-price refresh"
|
||||
);
|
||||
return std::result::Result::Ok(row);
|
||||
}
|
||||
|
||||
/// Refreshes a frontend-selected provider set in deterministic request order through the generic Off-chain service.
|
||||
pub(crate) async fn refresh_many(&self, provider_ids: std::vec::Vec<String>) -> ksp_core_lib::Result<crate::MarketPriceRefreshResultDto> {
|
||||
let provider_ids = parse_provider_ids(provider_ids);
|
||||
let provider_ids = match provider_ids {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let loading = self.begin_refresh(provider_ids.as_slice());
|
||||
if let std::result::Result::Err(error) = loading {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT,
|
||||
provider_count = provider_ids.len(),
|
||||
"started SOL Prices Desk selected-provider market-price refresh"
|
||||
);
|
||||
let outcomes = self.startup.resolved().service().refresh_many(provider_ids.as_slice()).await;
|
||||
let outcomes = match outcomes {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
let cleared = self.clear_loading(provider_ids.as_slice());
|
||||
if let std::result::Result::Err(clear_error) = cleared {
|
||||
return std::result::Result::Err(clear_error);
|
||||
}
|
||||
return std::result::Result::Err(error);
|
||||
},
|
||||
};
|
||||
let result = self.apply_outcomes(outcomes.as_slice());
|
||||
let result = match result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
let cleared = self.clear_loading(provider_ids.as_slice());
|
||||
if let std::result::Result::Err(clear_error) = cleared {
|
||||
return std::result::Result::Err(clear_error);
|
||||
}
|
||||
return std::result::Result::Err(error);
|
||||
},
|
||||
};
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT,
|
||||
provider_count = result.requested_count,
|
||||
refreshed_count = result.refreshed_count,
|
||||
"completed SOL Prices Desk selected-provider market-price refresh"
|
||||
);
|
||||
return std::result::Result::Ok(result);
|
||||
}
|
||||
|
||||
/// Refreshes every configured provider through the generic Off-chain service in its stable provider order.
|
||||
pub(crate) async fn refresh_all(&self) -> ksp_core_lib::Result<crate::MarketPriceRefreshResultDto> {
|
||||
let registry = self.startup.resolved().service().registry();
|
||||
let provider_ids = registry.entries().iter().map(|entry| return entry.descriptor().id().clone()).collect::<std::vec::Vec<_>>();
|
||||
let loading = self.begin_refresh(provider_ids.as_slice());
|
||||
if let std::result::Result::Err(error) = loading {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT,
|
||||
provider_count = provider_ids.len(),
|
||||
"started SOL Prices Desk global market-price refresh"
|
||||
);
|
||||
let outcomes = self.startup.resolved().service().refresh_all().await;
|
||||
let outcomes = match outcomes {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
let cleared = self.clear_loading(provider_ids.as_slice());
|
||||
if let std::result::Result::Err(clear_error) = cleared {
|
||||
return std::result::Result::Err(clear_error);
|
||||
}
|
||||
return std::result::Result::Err(error);
|
||||
},
|
||||
};
|
||||
let result = self.apply_outcomes(outcomes.as_slice());
|
||||
let result = match result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
let cleared = self.clear_loading(provider_ids.as_slice());
|
||||
if let std::result::Result::Err(clear_error) = cleared {
|
||||
return std::result::Result::Err(clear_error);
|
||||
}
|
||||
return std::result::Result::Err(error);
|
||||
},
|
||||
};
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT,
|
||||
provider_count = result.requested_count,
|
||||
refreshed_count = result.refreshed_count,
|
||||
"completed SOL Prices Desk global market-price refresh"
|
||||
);
|
||||
return std::result::Result::Ok(result);
|
||||
}
|
||||
|
||||
fn apply_outcome(&self, outcome: &ksp_offchain_transport_lib::MarketPriceRefreshOutcome) -> ksp_core_lib::Result<crate::MarketPriceProviderRowDto> {
|
||||
let registry = self.startup.resolved().service().registry();
|
||||
let registry_entry = registry.entry(outcome.provider_id());
|
||||
let registry_entry = match registry_entry {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "SOL Prices Desk refresh outcome references an unknown registry provider")
|
||||
.with_context("provider_id", outcome.provider_id().as_str()),
|
||||
);
|
||||
},
|
||||
};
|
||||
let presentation = lock_presentation(&self.presentation);
|
||||
let mut presentation = match presentation {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let applied = apply_outcome_to_state(&mut presentation, outcome);
|
||||
if let std::result::Result::Err(error) = applied {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let presentation_entry = presentation.entries.get(outcome.provider_id().as_str());
|
||||
return match presentation_entry {
|
||||
std::option::Option::Some(value) => std::result::Result::Ok(project_registry_entry(registry_entry, value)),
|
||||
std::option::Option::None => std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "SOL Prices Desk market-price runtime is missing refreshed provider state")
|
||||
.with_context("provider_id", outcome.provider_id().as_str()),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
fn apply_outcomes(&self, outcomes: &[ksp_offchain_transport_lib::MarketPriceRefreshOutcome]) -> ksp_core_lib::Result<crate::MarketPriceRefreshResultDto> {
|
||||
let registry = self.startup.resolved().service().registry();
|
||||
let presentation = lock_presentation(&self.presentation);
|
||||
let mut presentation = match presentation {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut rows = std::vec::Vec::with_capacity(outcomes.len());
|
||||
let mut refreshed_count = 0usize;
|
||||
for outcome in outcomes {
|
||||
let registry_entry = registry.entry(outcome.provider_id());
|
||||
let registry_entry = match registry_entry {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(
|
||||
crate::ERROR_CODE_APP_STATE_INVALID,
|
||||
"SOL Prices Desk refresh outcome references an unknown registry provider",
|
||||
)
|
||||
.with_context("provider_id", outcome.provider_id().as_str()),
|
||||
);
|
||||
},
|
||||
};
|
||||
let applied = apply_outcome_to_state(&mut presentation, outcome);
|
||||
if let std::result::Result::Err(error) = applied {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if outcome.refreshed() {
|
||||
refreshed_count += 1;
|
||||
}
|
||||
let presentation_entry = presentation.entries.get(outcome.provider_id().as_str());
|
||||
let presentation_entry = match presentation_entry {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(
|
||||
crate::ERROR_CODE_APP_STATE_INVALID,
|
||||
"SOL Prices Desk market-price runtime is missing refreshed provider state",
|
||||
)
|
||||
.with_context("provider_id", outcome.provider_id().as_str()),
|
||||
);
|
||||
},
|
||||
};
|
||||
rows.push(project_registry_entry(registry_entry, presentation_entry));
|
||||
}
|
||||
let requested_count = count_to_u32(outcomes.len(), "refresh_requested_count");
|
||||
let requested_count = match requested_count {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let refreshed_count = count_to_u32(refreshed_count, "refresh_success_count");
|
||||
let refreshed_count = match refreshed_count {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(crate::MarketPriceRefreshResultDto { refreshed_count, requested_count, rows });
|
||||
}
|
||||
|
||||
fn begin_refresh(&self, provider_ids: &[ksp_offchain_transport_lib::MarketPriceProviderId]) -> ksp_core_lib::Result<()> {
|
||||
let presentation = lock_presentation(&self.presentation);
|
||||
let mut presentation = match presentation {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return begin_refresh_in_state(&mut presentation, provider_ids);
|
||||
}
|
||||
|
||||
fn clear_loading(&self, provider_ids: &[ksp_offchain_transport_lib::MarketPriceProviderId]) -> ksp_core_lib::Result<()> {
|
||||
let presentation = lock_presentation(&self.presentation);
|
||||
let mut presentation = match presentation {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
for provider_id in provider_ids {
|
||||
let cleared = set_loading_in_state(&mut presentation, provider_id.as_str(), false);
|
||||
if let std::result::Result::Err(error) = cleared {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
struct MarketPricePresentationEntry {
|
||||
loading: bool,
|
||||
observation: std::option::Option<ksp_offchain_transport_lib::MarketPriceObservation>,
|
||||
}
|
||||
|
||||
struct MarketPricePresentationState {
|
||||
entries: std::collections::BTreeMap<String, MarketPricePresentationEntry>,
|
||||
}
|
||||
|
||||
fn apply_outcome_to_state(
|
||||
presentation: &mut MarketPricePresentationState,
|
||||
outcome: &ksp_offchain_transport_lib::MarketPriceRefreshOutcome,
|
||||
) -> ksp_core_lib::Result<()> {
|
||||
let entry = presentation.entries.get_mut(outcome.provider_id().as_str());
|
||||
let entry = match entry {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "SOL Prices Desk market-price presentation state is missing refresh provider")
|
||||
.with_context("provider_id", outcome.provider_id().as_str()),
|
||||
);
|
||||
},
|
||||
};
|
||||
entry.loading = false;
|
||||
if let std::option::Option::Some(observation) = outcome.observation() {
|
||||
entry.observation = std::option::Option::Some(observation.clone());
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn auth_mode_code(auth_mode: ksp_offchain_transport_lib::MarketPriceProviderAuthMode) -> &'static str {
|
||||
return match auth_mode {
|
||||
ksp_offchain_transport_lib::MarketPriceProviderAuthMode::None => "none",
|
||||
ksp_offchain_transport_lib::MarketPriceProviderAuthMode::OptionalApiKey => "optional_api_key",
|
||||
ksp_offchain_transport_lib::MarketPriceProviderAuthMode::RequiredApiKey => "required_api_key",
|
||||
_ => "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
fn availability_code(availability: ksp_offchain_transport_lib::MarketPriceProviderAvailability) -> &'static str {
|
||||
return match availability {
|
||||
ksp_offchain_transport_lib::MarketPriceProviderAvailability::AuthenticationUnavailable => "authentication_unavailable",
|
||||
ksp_offchain_transport_lib::MarketPriceProviderAvailability::CoolingDown { .. } => "cooling_down",
|
||||
ksp_offchain_transport_lib::MarketPriceProviderAvailability::Disabled => "disabled",
|
||||
ksp_offchain_transport_lib::MarketPriceProviderAvailability::Misconfigured => "misconfigured",
|
||||
ksp_offchain_transport_lib::MarketPriceProviderAvailability::QuotaUnavailable => "quota_unavailable",
|
||||
ksp_offchain_transport_lib::MarketPriceProviderAvailability::Ready => "ready",
|
||||
ksp_offchain_transport_lib::MarketPriceProviderAvailability::TemporarilyUnavailable { .. } => "temporarily_unavailable",
|
||||
_ => "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
fn begin_refresh_in_state(
|
||||
presentation: &mut MarketPricePresentationState,
|
||||
provider_ids: &[ksp_offchain_transport_lib::MarketPriceProviderId],
|
||||
) -> ksp_core_lib::Result<()> {
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for provider_id in provider_ids {
|
||||
if !seen.insert(provider_id.as_str().to_owned()) {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(
|
||||
crate::ERROR_CODE_MARKET_PRICE_SELECTION_INVALID,
|
||||
"SOL Prices Desk market-price refresh selection contains a duplicate provider",
|
||||
)
|
||||
.with_context("provider_id", provider_id.as_str()),
|
||||
);
|
||||
}
|
||||
let entry = presentation.entries.get(provider_id.as_str());
|
||||
let entry = match entry {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(
|
||||
crate::ERROR_CODE_MARKET_PRICE_SELECTION_INVALID,
|
||||
"SOL Prices Desk market-price refresh selection contains an unknown provider",
|
||||
)
|
||||
.with_context("provider_id", provider_id.as_str()),
|
||||
);
|
||||
},
|
||||
};
|
||||
if entry.loading {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(
|
||||
crate::ERROR_CODE_MARKET_PRICE_REFRESH_CONFLICT,
|
||||
"SOL Prices Desk market-price provider already has a refresh in flight",
|
||||
)
|
||||
.with_context("provider_id", provider_id.as_str()),
|
||||
);
|
||||
}
|
||||
}
|
||||
for provider_id in provider_ids {
|
||||
let updated = set_loading_in_state(presentation, provider_id.as_str(), true);
|
||||
if let std::result::Result::Err(error) = updated {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn count_to_u32(value: usize, field: &'static str) -> ksp_core_lib::Result<u32> {
|
||||
let converted = u32::try_from(value);
|
||||
return match converted {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "SOL Prices Desk market-price runtime count exceeds DTO bounds")
|
||||
.with_context("field", field)
|
||||
.with_source(error),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
fn lock_presentation(
|
||||
presentation: &std::sync::Mutex<MarketPricePresentationState>,
|
||||
) -> ksp_core_lib::Result<std::sync::MutexGuard<'_, MarketPricePresentationState>> {
|
||||
return match presentation.lock() {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(_) => std::result::Result::Err(ksp_core_lib::Error::new(
|
||||
crate::ERROR_CODE_APP_STATE_LOCK_FAILED,
|
||||
"SOL Prices Desk market-price presentation state lock is poisoned",
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
fn parse_provider_ids(provider_ids: std::vec::Vec<String>) -> ksp_core_lib::Result<std::vec::Vec<ksp_offchain_transport_lib::MarketPriceProviderId>> {
|
||||
if provider_ids.is_empty() || provider_ids.len() > MARKET_PRICE_REFRESH_MAX_PROVIDER_IDS {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(
|
||||
crate::ERROR_CODE_MARKET_PRICE_SELECTION_INVALID,
|
||||
"SOL Prices Desk market-price refresh selection has an invalid provider count",
|
||||
)
|
||||
.with_context("provider_count", provider_ids.len().to_string())
|
||||
.with_context("max_provider_count", MARKET_PRICE_REFRESH_MAX_PROVIDER_IDS.to_string()),
|
||||
);
|
||||
}
|
||||
let mut parsed = std::vec::Vec::with_capacity(provider_ids.len());
|
||||
for provider_id in provider_ids {
|
||||
let provider_id = ksp_offchain_transport_lib::MarketPriceProviderId::new(provider_id);
|
||||
let provider_id = match provider_id {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
parsed.push(provider_id);
|
||||
}
|
||||
return std::result::Result::Ok(parsed);
|
||||
}
|
||||
|
||||
fn project_registry_entry(
|
||||
entry: &ksp_offchain_transport_lib::MarketPriceProviderRegistryEntry,
|
||||
presentation: &MarketPricePresentationEntry,
|
||||
) -> crate::MarketPriceProviderRowDto {
|
||||
let descriptor = entry.descriptor();
|
||||
let availability = entry.state().availability();
|
||||
let observation = presentation.observation.as_ref();
|
||||
return crate::MarketPriceProviderRowDto {
|
||||
auth_mode: auth_mode_code(descriptor.auth_mode()).to_owned(),
|
||||
availability: availability_code(availability).to_owned(),
|
||||
display_name: descriptor.display_name().to_owned(),
|
||||
loading: presentation.loading,
|
||||
pair: ksp_offchain_transport_lib::MarketPricePair::SolUsd.code().to_owned(),
|
||||
price: observation.map(|value| return value.price().to_canonical_string()),
|
||||
provider_id: descriptor.id().as_str().to_owned(),
|
||||
provider_timestamp_unix_millis: observation.and_then(|value| return value.provider_timestamp()).map(|value| return value.unix_millis().to_string()),
|
||||
received_at_unix_millis: observation.map(|value| return value.received_at().unix_millis().to_string()),
|
||||
refreshed: observation.is_some(),
|
||||
retry_at_unix_millis: availability.retry_at().map(|value| return value.unix_millis().to_string()),
|
||||
semantics: semantics_code(descriptor.semantics()).to_owned(),
|
||||
};
|
||||
}
|
||||
|
||||
fn semantics_code(semantics: ksp_offchain_transport_lib::MarketPriceSemantics) -> &'static str {
|
||||
return match semantics {
|
||||
ksp_offchain_transport_lib::MarketPriceSemantics::AggregatedMarket => "aggregated_market",
|
||||
ksp_offchain_transport_lib::MarketPriceSemantics::DexPairUsd => "dex_pair_usd",
|
||||
ksp_offchain_transport_lib::MarketPriceSemantics::ExchangeLastTrade => "exchange_last_trade",
|
||||
ksp_offchain_transport_lib::MarketPriceSemantics::SolanaHeuristic => "solana_heuristic",
|
||||
ksp_offchain_transport_lib::MarketPriceSemantics::SolanaSpot => "solana_spot",
|
||||
_ => "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
fn set_loading_in_state(presentation: &mut MarketPricePresentationState, provider_id: &str, loading: bool) -> ksp_core_lib::Result<()> {
|
||||
let entry = presentation.entries.get_mut(provider_id);
|
||||
return match entry {
|
||||
std::option::Option::Some(value) => {
|
||||
value.loading = loading;
|
||||
std::result::Result::Ok(())
|
||||
},
|
||||
std::option::Option::None => std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "SOL Prices Desk market-price presentation state is missing provider")
|
||||
.with_context("provider_id", provider_id),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/market_price_runtime.rs"]
|
||||
mod tests;
|
||||
59
crates/ksp-app-solprices-desk/src/offchain_runtime.rs
Normal file
59
crates/ksp-app-solprices-desk/src/offchain_runtime.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
// file: crates/ksp-app-solprices-desk/src/offchain_runtime.rs
|
||||
// version: 1
|
||||
|
||||
//! Config-owned Off-chain Transport bootstrap for SOL Prices Desk.
|
||||
|
||||
/// Resolved SOL Prices Desk composite and Off-chain Transport configuration retained by application state.
|
||||
pub(crate) struct OffchainTransportStartup {
|
||||
composite_profile_id: String,
|
||||
resolved: ksp_config_lib::ResolvedOffchainTransportConfig,
|
||||
}
|
||||
|
||||
impl crate::OffchainTransportStartup {
|
||||
/// Returns the composite profile selected for this application launch.
|
||||
#[must_use]
|
||||
pub(crate) fn composite_profile_id(&self) -> &str {
|
||||
return self.composite_profile_id.as_str();
|
||||
}
|
||||
|
||||
/// Returns the Config-resolved Off-chain Transport runtime configuration.
|
||||
#[must_use]
|
||||
pub(crate) const fn resolved(&self) -> &ksp_config_lib::ResolvedOffchainTransportConfig {
|
||||
return &self.resolved;
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the composite-selected Off-chain Transport profile and constructs the provider-neutral service owned by Config.
|
||||
pub(crate) fn initialize_offchain_transport(management: &ksp_config_lib::ConfigManagement) -> ksp_core_lib::Result<crate::OffchainTransportStartup> {
|
||||
let environment = ksp_config_lib::ConfigEnvironment::load();
|
||||
let environment = match environment {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let composite = crate::load_solprices_desk_composite(management);
|
||||
let composite = match composite {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let logging_profile = crate::required_composite_component_profile(&composite, crate::COMPOSITE_COMPONENT_ID_LOGGING, ksp_config_lib::FILE_ID_STD_LOGGING);
|
||||
if let std::result::Result::Err(error) = logging_profile {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let offchain_profile = crate::required_composite_component_profile(
|
||||
&composite,
|
||||
crate::COMPOSITE_COMPONENT_ID_OFFCHAIN_TRANSPORT,
|
||||
ksp_config_lib::FILE_ID_STD_OFFCHAIN_TRANSPORT,
|
||||
);
|
||||
let offchain_profile = match offchain_profile {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let resolved = management.engine().resolve_offchain_transport_config_profile(&offchain_profile, &environment);
|
||||
let resolved = match resolved {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let provider_count = resolved.service().registry().len();
|
||||
ksp_logging_lib::info!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT, composite_profile = composite.profile_id(), offchain_profile = resolved.profile_id(), provider_count, "initialized SOL Prices Desk Off-chain Transport from composite-managed configuration");
|
||||
return std::result::Result::Ok(crate::OffchainTransportStartup { composite_profile_id: composite.profile_id().to_owned(), resolved });
|
||||
}
|
||||
214
crates/ksp-app-solprices-desk/src/splash.rs
Normal file
214
crates/ksp-app-solprices-desk/src/splash.rs
Normal file
@@ -0,0 +1,214 @@
|
||||
// file: crates/ksp-app-solprices-desk/src/splash.rs
|
||||
// version: 1
|
||||
|
||||
//! Common splash settings and frontend event contracts for SOL Prices Desk.
|
||||
|
||||
use ts_rs::TS; // rust-rules: trait-import
|
||||
|
||||
const DEFAULT_SPLASH_FADE_IN_MS: u32 = 300;
|
||||
const DEFAULT_SPLASH_FADE_OUT_MS: u32 = 300;
|
||||
const DEFAULT_SPLASH_MINIMUM_MS: u64 = 1200;
|
||||
const ENV_SPLASH_FADE_IN_MS: &str = "KSP_DESK_SPLASH_FADE_IN_MS";
|
||||
const ENV_SPLASH_FADE_OUT_MS: &str = "KSP_DESK_SPLASH_FADE_OUT_MS";
|
||||
const ENV_SPLASH_MINIMUM_MS: &str = "KSP_DESK_SPLASH_MINIMUM_MS";
|
||||
const MAX_SPLASH_FADE_MS: u32 = 10_000;
|
||||
const MAX_SPLASH_MINIMUM_MS: u64 = 60_000;
|
||||
|
||||
/// Runtime timings used by the common desk splash lifecycle.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct SplashSettings {
|
||||
fade_in_ms: u32,
|
||||
minimum_ms: u64,
|
||||
fade_out_ms: u32,
|
||||
fade_in_source: ksp_config_lib::ConfigEnvironmentSource,
|
||||
minimum_source: ksp_config_lib::ConfigEnvironmentSource,
|
||||
fade_out_source: ksp_config_lib::ConfigEnvironmentSource,
|
||||
}
|
||||
|
||||
impl SplashSettings {
|
||||
/// Resolves splash timings through the Config-owned environment snapshot.
|
||||
pub(crate) fn load() -> ksp_core_lib::Result<Self> {
|
||||
let environment = ksp_config_lib::ConfigEnvironment::load();
|
||||
let environment = match environment {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let fade_in = environment.resolve_variable(ENV_SPLASH_FADE_IN_MS, std::option::Option::Some("300"));
|
||||
let fade_in = match fade_in {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let minimum = environment.resolve_variable(ENV_SPLASH_MINIMUM_MS, std::option::Option::Some("1200"));
|
||||
let minimum = match minimum {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let fade_out = environment.resolve_variable(ENV_SPLASH_FADE_OUT_MS, std::option::Option::Some("300"));
|
||||
let fade_out = match fade_out {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let fade_in_ms = parse_u32_setting(ENV_SPLASH_FADE_IN_MS, fade_in.value(), MAX_SPLASH_FADE_MS);
|
||||
let fade_in_ms = match fade_in_ms {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let minimum_ms = parse_u64_setting(ENV_SPLASH_MINIMUM_MS, minimum.value(), MAX_SPLASH_MINIMUM_MS);
|
||||
let minimum_ms = match minimum_ms {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let fade_out_ms = parse_u32_setting(ENV_SPLASH_FADE_OUT_MS, fade_out.value(), MAX_SPLASH_FADE_MS);
|
||||
let fade_out_ms = match fade_out_ms {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(Self {
|
||||
fade_in_ms,
|
||||
minimum_ms,
|
||||
fade_out_ms,
|
||||
fade_in_source: fade_in.source(),
|
||||
minimum_source: minimum.source(),
|
||||
fade_out_source: fade_out.source(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns safe in-memory timings used when the managed environment cannot be resolved.
|
||||
#[must_use]
|
||||
pub(crate) const fn fallback() -> Self {
|
||||
return Self {
|
||||
fade_in_ms: DEFAULT_SPLASH_FADE_IN_MS,
|
||||
minimum_ms: DEFAULT_SPLASH_MINIMUM_MS,
|
||||
fade_out_ms: DEFAULT_SPLASH_FADE_OUT_MS,
|
||||
fade_in_source: ksp_config_lib::ConfigEnvironmentSource::Fallback,
|
||||
minimum_source: ksp_config_lib::ConfigEnvironmentSource::Fallback,
|
||||
fade_out_source: ksp_config_lib::ConfigEnvironmentSource::Fallback,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the configured fade-in duration.
|
||||
#[must_use]
|
||||
pub(crate) const fn fade_in_ms(self) -> u32 {
|
||||
return self.fade_in_ms;
|
||||
}
|
||||
|
||||
/// Returns the minimum fully-visible splash duration.
|
||||
#[must_use]
|
||||
pub(crate) const fn minimum_ms(self) -> u64 {
|
||||
return self.minimum_ms;
|
||||
}
|
||||
|
||||
/// Returns the configured fade-out duration.
|
||||
#[must_use]
|
||||
pub(crate) const fn fade_out_ms(self) -> u32 {
|
||||
return self.fade_out_ms;
|
||||
}
|
||||
|
||||
/// Returns the safe provenance code for the fade-in duration.
|
||||
#[must_use]
|
||||
pub(crate) const fn fade_in_source(self) -> &'static str {
|
||||
return environment_source_code(self.fade_in_source);
|
||||
}
|
||||
|
||||
/// Returns the safe provenance code for the minimum duration.
|
||||
#[must_use]
|
||||
pub(crate) const fn minimum_source(self) -> &'static str {
|
||||
return environment_source_code(self.minimum_source);
|
||||
}
|
||||
|
||||
/// Returns the safe provenance code for the fade-out duration.
|
||||
#[must_use]
|
||||
pub(crate) const fn fade_out_source(self) -> &'static str {
|
||||
return environment_source_code(self.fade_out_source);
|
||||
}
|
||||
|
||||
/// Returns the expected additive backend lifecycle from readiness through fade-out.
|
||||
#[must_use]
|
||||
pub(crate) fn expected_backend_lifecycle_ms(self) -> u64 {
|
||||
return u64::from(self.fade_in_ms) + self.minimum_ms + u64::from(self.fade_out_ms);
|
||||
}
|
||||
}
|
||||
|
||||
/// Command emitted by Rust to the splash frontend.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_solprices_desk/splash/SplashOrderDto.ts")]
|
||||
pub(crate) struct SplashOrderDto {
|
||||
/// Splash action name.
|
||||
pub(crate) action: String,
|
||||
/// Optional status text.
|
||||
pub(crate) message: std::option::Option<String>,
|
||||
/// Optional message severity used by the bottom message feed.
|
||||
pub(crate) status: std::option::Option<String>,
|
||||
/// Optional animation duration in milliseconds.
|
||||
pub(crate) duration_ms: std::option::Option<u32>,
|
||||
}
|
||||
|
||||
impl SplashOrderDto {
|
||||
/// Creates a new `SplashOrderDto` value.
|
||||
#[must_use]
|
||||
pub(crate) fn new(action: &str, message: std::option::Option<&str>, status: std::option::Option<&str>, duration_ms: std::option::Option<u32>) -> Self {
|
||||
return Self {
|
||||
action: action.to_owned(),
|
||||
message: message.map(std::string::ToString::to_string),
|
||||
status: status.map(std::string::ToString::to_string),
|
||||
duration_ms,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const fn environment_source_code(source: ksp_config_lib::ConfigEnvironmentSource) -> &'static str {
|
||||
return match source {
|
||||
ksp_config_lib::ConfigEnvironmentSource::Process => "process",
|
||||
ksp_config_lib::ConfigEnvironmentSource::DotEnv => "dotenv",
|
||||
ksp_config_lib::ConfigEnvironmentSource::Fallback => "fallback",
|
||||
};
|
||||
}
|
||||
|
||||
fn parse_u64_setting(variable_name: &str, value: &str, maximum: u64) -> ksp_core_lib::Result<u64> {
|
||||
let parsed = value.parse::<u64>();
|
||||
let parsed = match parsed {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_SPLASH_SETTING_INVALID, "KSP desk splash duration is not a valid unsigned integer")
|
||||
.with_context("variable_name", variable_name)
|
||||
.with_source(error),
|
||||
);
|
||||
},
|
||||
};
|
||||
if parsed > maximum {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_SPLASH_SETTING_INVALID, "KSP desk splash duration exceeds the allowed bound")
|
||||
.with_context("variable_name", variable_name)
|
||||
.with_context("maximum_ms", maximum.to_string()),
|
||||
);
|
||||
}
|
||||
return std::result::Result::Ok(parsed);
|
||||
}
|
||||
|
||||
fn parse_u32_setting(variable_name: &str, value: &str, maximum: u32) -> ksp_core_lib::Result<u32> {
|
||||
let parsed = value.parse::<u32>();
|
||||
let parsed = match parsed {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_SPLASH_SETTING_INVALID, "KSP desk splash duration is not a valid unsigned integer")
|
||||
.with_context("variable_name", variable_name)
|
||||
.with_source(error),
|
||||
);
|
||||
},
|
||||
};
|
||||
if parsed > maximum {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_SPLASH_SETTING_INVALID, "KSP desk splash duration exceeds the allowed bound")
|
||||
.with_context("variable_name", variable_name)
|
||||
.with_context("maximum_ms", maximum.to_string()),
|
||||
);
|
||||
}
|
||||
return std::result::Result::Ok(parsed);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/splash.rs"]
|
||||
mod tests;
|
||||
204
crates/ksp-app-solprices-desk/src/tauri.rs
Normal file
204
crates/ksp-app-solprices-desk/src/tauri.rs
Normal file
@@ -0,0 +1,204 @@
|
||||
// file: crates/ksp-app-solprices-desk/src/tauri.rs
|
||||
// version: 6
|
||||
|
||||
//! Tauri runtime assembly for the KSP SOL prices desktop application.
|
||||
|
||||
/// Runs the SOL prices desktop application.
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run(arguments: &[std::ffi::OsString]) -> ksp_core_lib::Result<()> {
|
||||
let context = tauri::generate_context!();
|
||||
let runtime_layout = configure_packaged_runtime(&context);
|
||||
if let std::result::Result::Err(error) = runtime_layout {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let app_state = crate::AppState::initialize(arguments);
|
||||
let app_state = match app_state {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut builder = tauri::Builder::default();
|
||||
builder = configure_state(builder, app_state);
|
||||
builder = configure_plugins(builder);
|
||||
builder = configure_commands(builder);
|
||||
builder = configure_setup(builder);
|
||||
let run_result = builder.run(context);
|
||||
return match run_result {
|
||||
std::result::Result::Ok(()) => std::result::Result::Ok(()),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_RUNTIME_FAILED, "Cannot run KSP SOL Prices Desk Tauri runtime")
|
||||
.with_context("tauri_error", error.to_string()),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
fn configure_packaged_runtime(context: &tauri::Context<tauri::Wry>) -> ksp_core_lib::Result<()> {
|
||||
if cfg!(debug_assertions) {
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
let resource_root = tauri::utils::platform::resource_dir(context.package_info(), &tauri::Env::default());
|
||||
let resource_root = match resource_root {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_RUNTIME_FAILED, "Cannot resolve packaged Tauri resource directory")
|
||||
.with_context("tauri_error", error.to_string()),
|
||||
);
|
||||
},
|
||||
};
|
||||
let layout = ksp_config_lib::prepare_packaged_runtime(resource_root.as_path());
|
||||
let layout = match layout {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let working_directory = std::env::set_current_dir(layout.runtime_root());
|
||||
return match working_directory {
|
||||
std::result::Result::Ok(()) => std::result::Result::Ok(()),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_RUNTIME_FAILED, "Cannot activate packaged KSP runtime directory")
|
||||
.with_context("runtime_root", layout.runtime_root().to_string_lossy().into_owned())
|
||||
.with_source(error),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
fn configure_state(builder: tauri::Builder<tauri::Wry>, app_state: crate::AppState) -> tauri::Builder<tauri::Wry> {
|
||||
return builder.manage(app_state);
|
||||
}
|
||||
|
||||
fn configure_plugins(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
|
||||
let tracing_plugin = tauri_plugin_tracing::Builder::new().build::<tauri::Wry>();
|
||||
return builder.plugin(tracing_plugin);
|
||||
}
|
||||
|
||||
#[allow(clippy::question_mark_used)] // Tauri generates the question-mark operator internally for async command dispatch.
|
||||
fn configure_commands(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
|
||||
return builder.invoke_handler(tauri::generate_handler![
|
||||
emit_frontend_log,
|
||||
get_runtime_status,
|
||||
list_market_prices,
|
||||
refresh_all_market_prices,
|
||||
refresh_market_price,
|
||||
refresh_market_prices,
|
||||
splash_frontend_ready,
|
||||
]);
|
||||
}
|
||||
|
||||
fn configure_setup(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
|
||||
return builder.setup(|app| {
|
||||
let splash = crate::require_splash_window(app);
|
||||
if let std::result::Result::Err(error) = splash {
|
||||
return std::result::Result::Err(std::boxed::Box::new(error));
|
||||
}
|
||||
let main = crate::require_main_window(app);
|
||||
if let std::result::Result::Err(error) = main {
|
||||
return std::result::Result::Err(std::boxed::Box::new(error));
|
||||
}
|
||||
ksp_logging_lib::debug!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_SHELL, "SOL Prices Desk Tauri shell setup completed");
|
||||
return std::result::Result::Ok(());
|
||||
});
|
||||
}
|
||||
|
||||
fn project_command_error(command: &'static str, domain: &'static str, error: &ksp_core_lib::Error) -> crate::CommandErrorDto {
|
||||
ksp_logging_lib::warn!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = domain,
|
||||
command = command,
|
||||
error_domain = error.code().domain(),
|
||||
error_code = error.code().code(),
|
||||
"SOL Prices Desk Tauri command failed"
|
||||
);
|
||||
return crate::CommandErrorDto::from_error(error);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn emit_frontend_log(payload: crate::FrontendLogPayloadDto) -> std::result::Result<(), crate::CommandErrorDto> {
|
||||
let result = crate::emit_frontend_log_event(payload);
|
||||
return match result {
|
||||
std::result::Result::Ok(()) => std::result::Result::Ok(()),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(project_command_error("emit_frontend_log", crate::TRACING_DOMAIN_FRONTEND, &error)),
|
||||
};
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_runtime_status(state: tauri::State<'_, crate::AppState>) -> std::result::Result<crate::MarketPriceRuntimeStatusDto, crate::CommandErrorDto> {
|
||||
let result = state.runtime_status();
|
||||
return match result {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(project_command_error("get_runtime_status", crate::TRACING_DOMAIN_SHELL, &error)),
|
||||
};
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn list_market_prices(
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
) -> std::result::Result<std::vec::Vec<crate::MarketPriceProviderRowDto>, crate::CommandErrorDto> {
|
||||
let result = state.list_market_prices();
|
||||
return match result {
|
||||
std::result::Result::Ok(value) => {
|
||||
ksp_logging_lib::trace!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT,
|
||||
row_count = value.len(),
|
||||
"projected SOL Prices Desk market-price registry rows"
|
||||
);
|
||||
std::result::Result::Ok(value)
|
||||
},
|
||||
std::result::Result::Err(error) => {
|
||||
std::result::Result::Err(project_command_error("list_market_prices", crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT, &error))
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn refresh_all_market_prices(
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
) -> std::result::Result<crate::MarketPriceRefreshResultDto, crate::CommandErrorDto> {
|
||||
let result = state.refresh_all_market_prices().await;
|
||||
return match result {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => {
|
||||
std::result::Result::Err(project_command_error("refresh_all_market_prices", crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT, &error))
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn refresh_market_price(
|
||||
provider_id: String,
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
) -> std::result::Result<crate::MarketPriceProviderRowDto, crate::CommandErrorDto> {
|
||||
let result = state.refresh_market_price(provider_id).await;
|
||||
return match result {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => {
|
||||
std::result::Result::Err(project_command_error("refresh_market_price", crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT, &error))
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn refresh_market_prices(
|
||||
request: crate::MarketPriceRefreshManyRequestDto,
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
) -> std::result::Result<crate::MarketPriceRefreshResultDto, crate::CommandErrorDto> {
|
||||
let result = state.refresh_market_prices(request).await;
|
||||
return match result {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => {
|
||||
std::result::Result::Err(project_command_error("refresh_market_prices", crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT, &error))
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn splash_frontend_ready(
|
||||
app: tauri::AppHandle,
|
||||
webview_window: tauri::WebviewWindow,
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
) -> std::result::Result<(), crate::CommandErrorDto> {
|
||||
let result = crate::splash_frontend_ready_service(app, webview_window, &state).await;
|
||||
return match result {
|
||||
std::result::Result::Ok(()) => std::result::Result::Ok(()),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(project_command_error("splash_frontend_ready", crate::TRACING_DOMAIN_WINDOWS, &error)),
|
||||
};
|
||||
}
|
||||
53
crates/ksp-app-solprices-desk/src/tw_main.rs
Normal file
53
crates/ksp-app-solprices-desk/src/tw_main.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
// file: crates/ksp-app-solprices-desk/src/tw_main.rs
|
||||
// version: 1
|
||||
|
||||
//! Tauri-window helpers for the SOL Prices Desk main window.
|
||||
|
||||
use tauri::Manager; // rust-rules: trait-import
|
||||
|
||||
/// Crate-internal `WINDOW_LABEL_MAIN` constant.
|
||||
pub(crate) const WINDOW_LABEL_MAIN: &str = "main";
|
||||
|
||||
/// Resolves the required main window or returns a typed error.
|
||||
pub(crate) fn require_main_window(manager: &impl Manager<tauri::Wry>) -> ksp_core_lib::Result<tauri::WebviewWindow> {
|
||||
let window = manager.get_webview_window(WINDOW_LABEL_MAIN);
|
||||
return match window {
|
||||
std::option::Option::Some(value) => std::result::Result::Ok(value),
|
||||
std::option::Option::None => std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_WINDOW_MISSING, "SOL Prices Desk main window is missing")
|
||||
.with_context("window_label", WINDOW_LABEL_MAIN),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/// Shows main window.
|
||||
pub(crate) fn show_main_window(app: &tauri::AppHandle) -> ksp_core_lib::Result<()> {
|
||||
let window = crate::require_main_window(app);
|
||||
let window = match window {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let shown = window.show();
|
||||
if let std::result::Result::Err(error) = shown {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED, "Cannot show SOL Prices Desk main window")
|
||||
.with_context("window_label", WINDOW_LABEL_MAIN)
|
||||
.with_source(error),
|
||||
);
|
||||
}
|
||||
let focused = window.set_focus();
|
||||
if let std::result::Result::Err(error) = focused {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED, "Cannot focus SOL Prices Desk main window")
|
||||
.with_context("window_label", WINDOW_LABEL_MAIN)
|
||||
.with_source(error),
|
||||
);
|
||||
}
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_WINDOWS,
|
||||
window_label = WINDOW_LABEL_MAIN,
|
||||
"main window shown and focused"
|
||||
);
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
173
crates/ksp-app-solprices-desk/src/tw_splash.rs
Normal file
173
crates/ksp-app-solprices-desk/src/tw_splash.rs
Normal file
@@ -0,0 +1,173 @@
|
||||
// file: crates/ksp-app-solprices-desk/src/tw_splash.rs
|
||||
// version: 1
|
||||
|
||||
//! Tauri-window lifecycle for the SOL Prices Desk splash window.
|
||||
|
||||
use tauri::Emitter; // rust-rules: trait-import
|
||||
use tauri::Manager; // rust-rules: trait-import
|
||||
|
||||
/// Crate-internal splash window label.
|
||||
pub(crate) const WINDOW_LABEL_SPLASH: &str = "splash";
|
||||
|
||||
const SPLASH_EVENT_NAME: &str = "ksp-splash-order";
|
||||
|
||||
/// Resolves the required splash window or returns a typed error.
|
||||
pub(crate) fn require_splash_window(manager: &impl Manager<tauri::Wry>) -> ksp_core_lib::Result<tauri::WebviewWindow> {
|
||||
let window = manager.get_webview_window(WINDOW_LABEL_SPLASH);
|
||||
return match window {
|
||||
std::option::Option::Some(value) => std::result::Result::Ok(value),
|
||||
std::option::Option::None => std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_WINDOW_MISSING, "SOL Prices Desk splash window is missing")
|
||||
.with_context("window_label", WINDOW_LABEL_SPLASH),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/// Starts the one-shot splash lifecycle after the splash frontend reports readiness.
|
||||
pub(crate) async fn splash_frontend_ready_service(
|
||||
app: tauri::AppHandle,
|
||||
invoking_window: tauri::WebviewWindow,
|
||||
state: &crate::AppState,
|
||||
) -> ksp_core_lib::Result<()> {
|
||||
if invoking_window.label() != WINDOW_LABEL_SPLASH {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_SPLASH_ORIGIN_INVALID, "Splash readiness may only originate from the splash window")
|
||||
.with_context("window_label", invoking_window.label()),
|
||||
);
|
||||
}
|
||||
if !state.begin_splash_sequence() {
|
||||
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WINDOWS, window_label = WINDOW_LABEL_SPLASH, "duplicate splash frontend readiness ignored");
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
let settings = state.splash_settings();
|
||||
let lifecycle_started = std::time::Instant::now();
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_WINDOWS,
|
||||
window_label = WINDOW_LABEL_SPLASH,
|
||||
fade_in_ms = settings.fade_in_ms(),
|
||||
fade_in_source = settings.fade_in_source(),
|
||||
minimum_ms = settings.minimum_ms(),
|
||||
minimum_source = settings.minimum_source(),
|
||||
fade_out_ms = settings.fade_out_ms(),
|
||||
fade_out_source = settings.fade_out_source(),
|
||||
expected_backend_lifecycle_ms = settings.expected_backend_lifecycle_ms(),
|
||||
"splash frontend readiness accepted; starting splash to main lifecycle"
|
||||
);
|
||||
let emitted = emit_order(
|
||||
&invoking_window,
|
||||
crate::SplashOrderDto::new("fade_in", std::option::Option::None, std::option::Option::None, std::option::Option::Some(settings.fade_in_ms())),
|
||||
);
|
||||
if let std::result::Result::Err(error) = emitted {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let emitted = emit_message(&invoking_window, "Initialisation de SOL Prices Desk...", "info");
|
||||
if let std::result::Result::Err(error) = emitted {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let emitted = emit_debug(&invoking_window, "Listener frontend prêt; fade-in piloté par Rust.");
|
||||
if let std::result::Result::Err(error) = emitted {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
sleep_and_log(&invoking_window, "fade-in", u64::from(settings.fade_in_ms()), lifecycle_started).await;
|
||||
let emitted = emit_message(&invoking_window, "Configuration KSP chargée.", "success");
|
||||
if let std::result::Result::Err(error) = emitted {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let emitted = emit_debug(&invoking_window, "Configuration et provenance des timings résolues via ksp-config-lib.");
|
||||
if let std::result::Result::Err(error) = emitted {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let emitted = emit_message(&invoking_window, "Runtime Logging initialisé.", "success");
|
||||
if let std::result::Result::Err(error) = emitted {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let emitted = emit_message(&invoking_window, "Scaffold desktop prêt.", "success");
|
||||
if let std::result::Result::Err(error) = emitted {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
sleep_and_log(&invoking_window, "minimum", settings.minimum_ms(), lifecycle_started).await;
|
||||
let emitted = emit_message(&invoking_window, "Initialisation terminée.", "success");
|
||||
if let std::result::Result::Err(error) = emitted {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let emitted = emit_order(
|
||||
&invoking_window,
|
||||
crate::SplashOrderDto::new("fade_out", std::option::Option::None, std::option::Option::None, std::option::Option::Some(settings.fade_out_ms())),
|
||||
);
|
||||
if let std::result::Result::Err(error) = emitted {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
sleep_and_log(&invoking_window, "fade-out", u64::from(settings.fade_out_ms()), lifecycle_started).await;
|
||||
let show_main = crate::show_main_window(&app);
|
||||
if let std::result::Result::Err(error) = show_main {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let destroyed = invoking_window.destroy();
|
||||
if let std::result::Result::Err(error) = destroyed {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED, "Cannot destroy SOL Prices Desk splash window")
|
||||
.with_context("window_label", WINDOW_LABEL_SPLASH)
|
||||
.with_source(error),
|
||||
);
|
||||
}
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_WINDOWS,
|
||||
window_label = WINDOW_LABEL_SPLASH,
|
||||
expected_backend_lifecycle_ms = settings.expected_backend_lifecycle_ms(),
|
||||
actual_backend_lifecycle_ms = lifecycle_started.elapsed().as_secs_f64() * 1000.0,
|
||||
"splash window destroyed after main activation"
|
||||
);
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn emit_message(window: &tauri::WebviewWindow, message: &str, status: &str) -> ksp_core_lib::Result<()> {
|
||||
return emit_order(
|
||||
window,
|
||||
crate::SplashOrderDto::new("add_message", std::option::Option::Some(message), std::option::Option::Some(status), std::option::Option::None),
|
||||
);
|
||||
}
|
||||
|
||||
fn emit_debug(window: &tauri::WebviewWindow, message: &str) -> ksp_core_lib::Result<()> {
|
||||
if !cfg!(debug_assertions) {
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
return emit_order(
|
||||
window,
|
||||
crate::SplashOrderDto::new("add_debug", std::option::Option::Some(message), std::option::Option::None, std::option::Option::None),
|
||||
);
|
||||
}
|
||||
|
||||
async fn sleep_and_log(window: &tauri::WebviewWindow, phase: &str, duration_ms: u64, lifecycle_started: std::time::Instant) {
|
||||
let wait_started = std::time::Instant::now();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(duration_ms)).await;
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_WINDOWS,
|
||||
window_label = window.label(),
|
||||
phase,
|
||||
configured_wait_ms = duration_ms,
|
||||
actual_wait_ms = wait_started.elapsed().as_secs_f64() * 1000.0,
|
||||
lifecycle_elapsed_ms = lifecycle_started.elapsed().as_secs_f64() * 1000.0,
|
||||
"splash lifecycle wait completed"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
fn emit_order(window: &tauri::WebviewWindow, order: crate::SplashOrderDto) -> ksp_core_lib::Result<()> {
|
||||
let action = order.action.clone();
|
||||
let emitted = window.emit(SPLASH_EVENT_NAME, order);
|
||||
return match emitted {
|
||||
std::result::Result::Ok(()) => {
|
||||
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WINDOWS, window_label = WINDOW_LABEL_SPLASH, action = action.as_str(), "splash order emitted");
|
||||
std::result::Result::Ok(())
|
||||
},
|
||||
std::result::Result::Err(error) => std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED, "Cannot emit SOL Prices Desk splash order")
|
||||
.with_context("window_label", WINDOW_LABEL_SPLASH)
|
||||
.with_context("action", action)
|
||||
.with_source(error),
|
||||
),
|
||||
};
|
||||
}
|
||||
70
crates/ksp-app-solprices-desk/tauri.conf.json
Normal file
70
crates/ksp-app-solprices-desk/tauri.conf.json
Normal file
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "KSP SOL Prices Desk",
|
||||
"identifier": "com.sasedev.ksp-app-solprices-desk",
|
||||
"build": {
|
||||
"beforeDevCommand": {
|
||||
"script": "npm run dev",
|
||||
"cwd": "."
|
||||
},
|
||||
"devUrl": "http://localhost:1434",
|
||||
"beforeBuildCommand": {
|
||||
"script": "npm run build",
|
||||
"cwd": "."
|
||||
},
|
||||
"frontendDist": "../../../builds/khadhroony-solana-project/ksp-app-solprices-desk/dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "splash",
|
||||
"url": "splash.html",
|
||||
"title": "Chargement — KSP SOL Prices Desk",
|
||||
"width": 960,
|
||||
"height": 637,
|
||||
"resizable": false,
|
||||
"decorations": false,
|
||||
"transparent": true,
|
||||
"center": true,
|
||||
"alwaysOnTop": true
|
||||
},
|
||||
{
|
||||
"label": "main",
|
||||
"url": "main.html",
|
||||
"title": "KSP SOL Prices Desk",
|
||||
"width": 1500,
|
||||
"height": 960,
|
||||
"minWidth": 1024,
|
||||
"minHeight": 768,
|
||||
"center": true,
|
||||
"visible": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/favicon.png",
|
||||
"icons/favicon.ico"
|
||||
],
|
||||
"resources": {
|
||||
"../../config/composite.ksp-app-solprices-desk.json": "config/composite.ksp-app-solprices-desk.json",
|
||||
"../../config/composite.ksp-app-wallet-desk.json": "config/composite.ksp-app-wallet-desk.json",
|
||||
"../../config/std.logging.json": "config/std.logging.json",
|
||||
"../../config/std.offchain_transport.json": "config/std.offchain_transport.json",
|
||||
"../../config/std.store.json": "config/std.store.json",
|
||||
"../../config/std.transport.json": "config/std.transport.json",
|
||||
"../../config/std.wallet.json": "config/std.wallet.json",
|
||||
"../../config/schemas/composite.schema.json": "config/schemas/composite.schema.json",
|
||||
"../../config/schemas/std.logging.schema.json": "config/schemas/std.logging.schema.json",
|
||||
"../../config/schemas/std.offchain_transport.schema.json": "config/schemas/std.offchain_transport.schema.json",
|
||||
"../../config/schemas/std.store.schema.json": "config/schemas/std.store.schema.json",
|
||||
"../../config/schemas/std.transport.schema.json": "config/schemas/std.transport.schema.json",
|
||||
"../../config/schemas/std.wallet.schema.json": "config/schemas/std.wallet.schema.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
94
crates/ksp-app-solprices-desk/tests/config_composition.rs
Normal file
94
crates/ksp-app-solprices-desk/tests/config_composition.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
// file: crates/ksp-app-solprices-desk/tests/config_composition.rs
|
||||
// version: 2
|
||||
|
||||
//! Config composition contracts for SOL Prices Desk `0.2.12-pre.003`.
|
||||
|
||||
fn workspace_engine() -> ksp_core_lib::Result<ksp_config_lib::ConfigDocumentEngine> {
|
||||
let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let bootstrap = ksp_config_lib::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
|
||||
let bootstrap = match bootstrap {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let registry = ksp_config_lib::ConfigFileRegistry::defaults();
|
||||
return match registry {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(ksp_config_lib::ConfigDocumentEngine::new(bootstrap, value)),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
};
|
||||
}
|
||||
|
||||
fn assert_component(composite: &ksp_config_lib::ResolvedConfigComposite, component_id: &str, file_id: &str, profile_id: &str) {
|
||||
let component = composite.component(component_id);
|
||||
assert!(component.is_some(), "missing component {component_id}");
|
||||
if let std::option::Option::Some(component) = component {
|
||||
assert_eq!(component.resolved().file_id().as_str(), file_id);
|
||||
assert_eq!(component.resolved().profile_id(), profile_id);
|
||||
assert_eq!(component.resolved().selection_source(), ksp_config_lib::ConfigProfileSelectionSource::Composite);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solprices_desk_composite_profiles_select_logging_and_offchain_transport_by_file_id() {
|
||||
let engine = workspace_engine();
|
||||
assert!(engine.is_ok(), "workspace Config engine should be constructible: {engine:?}");
|
||||
let engine = match engine {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let file_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_SOLPRICES_DESK);
|
||||
assert!(file_id.is_ok(), "SOL Prices Desk composite file_id should be valid: {file_id:?}");
|
||||
let file_id = match file_id {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
for (selection, expected_composite_profile, expected_offchain_profile) in [
|
||||
(std::option::Option::None, "public_keyless", "public_keyless"),
|
||||
(std::option::Option::Some("all_free"), "all_free", "all_free"),
|
||||
(std::option::Option::Some("tests"), "tests", "public_keyless"),
|
||||
] {
|
||||
let composite = engine.load_resolved_composite(&file_id, selection);
|
||||
assert!(composite.is_ok(), "committed SOL Prices Desk composite profile should resolve: {composite:?}");
|
||||
if let std::result::Result::Ok(composite) = composite {
|
||||
assert_eq!(composite.profile_id(), expected_composite_profile);
|
||||
assert_component(&composite, "logging", ksp_config_lib::FILE_ID_STD_LOGGING, "console_info");
|
||||
assert_component(&composite, "offchain_transport", ksp_config_lib::FILE_ID_STD_OFFCHAIN_TRANSPORT, expected_offchain_profile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solprices_desk_default_composite_builds_provider_neutral_offchain_service() {
|
||||
let engine = workspace_engine();
|
||||
let engine = match engine {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let file_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_SOLPRICES_DESK);
|
||||
let file_id = match file_id {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let composite = engine.load_resolved_composite(&file_id, std::option::Option::None);
|
||||
let composite = match composite {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let component = composite.component("offchain_transport");
|
||||
let component = match component {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let environment = ksp_config_lib::ConfigEnvironment::load();
|
||||
assert!(environment.is_ok(), "Config environment should load for the public_keyless profile");
|
||||
let environment = match environment {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let resolved = engine.resolve_offchain_transport_config_profile(component.resolved(), &environment);
|
||||
assert!(resolved.is_ok(), "public_keyless composite-selected Off-chain Transport should map: {resolved:?}");
|
||||
if let std::result::Result::Ok(resolved) = resolved {
|
||||
assert_eq!(resolved.profile_id(), "public_keyless");
|
||||
assert_eq!(resolved.selection_source(), ksp_config_lib::ConfigProfileSelectionSource::Composite);
|
||||
assert_eq!(resolved.service().registry().len(), 8);
|
||||
}
|
||||
}
|
||||
253
crates/ksp-app-solprices-desk/tests/desktop_contract.rs
Normal file
253
crates/ksp-app-solprices-desk/tests/desktop_contract.rs
Normal file
@@ -0,0 +1,253 @@
|
||||
// file: crates/ksp-app-solprices-desk/tests/desktop_contract.rs
|
||||
// version: 10
|
||||
|
||||
//! Desktop scaffold, shared-template and Config packaging contract audits for SOL Prices Desk `0.2.12`.
|
||||
|
||||
fn app_root() -> std::path::PathBuf {
|
||||
return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
}
|
||||
|
||||
fn read_json(path: &std::path::Path) -> serde_json::Value {
|
||||
let source = std::fs::read_to_string(path);
|
||||
assert!(source.is_ok(), "unable to read {}", path.display());
|
||||
let source = match source {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return serde_json::Value::Null,
|
||||
};
|
||||
let parsed = serde_json::from_str::<serde_json::Value>(source.as_str());
|
||||
assert!(parsed.is_ok(), "unable to parse {}", path.display());
|
||||
return match parsed {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => serde_json::Value::Null,
|
||||
};
|
||||
}
|
||||
|
||||
fn read_text(path: &std::path::Path) -> String {
|
||||
let source = std::fs::read_to_string(path);
|
||||
assert!(source.is_ok(), "unable to read {}", path.display());
|
||||
return match source {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => String::new(),
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_shell_uses_reserved_ports_windows_and_owned_backend_paths() {
|
||||
let root = app_root();
|
||||
let tauri = read_json(root.join("tauri.conf.json").as_path());
|
||||
assert_eq!(tauri.pointer("/productName").and_then(serde_json::Value::as_str), std::option::Option::Some("KSP SOL Prices Desk"));
|
||||
assert_eq!(tauri.pointer("/identifier").and_then(serde_json::Value::as_str), std::option::Option::Some("com.sasedev.ksp-app-solprices-desk"));
|
||||
assert!(tauri.pointer("/version").is_none(), "SOL Prices Desk must let Tauri derive the bundle version from Cargo");
|
||||
let package = read_json(root.join("package.json").as_path());
|
||||
assert_eq!(package.pointer("/private").and_then(serde_json::Value::as_bool), std::option::Option::Some(true));
|
||||
assert_eq!(tauri.pointer("/build/devUrl").and_then(serde_json::Value::as_str), std::option::Option::Some("http://localhost:1434"));
|
||||
assert_eq!(tauri.pointer("/build/beforeDevCommand/script").and_then(serde_json::Value::as_str), std::option::Option::Some("npm run dev"));
|
||||
assert_eq!(tauri.pointer("/build/beforeDevCommand/cwd").and_then(serde_json::Value::as_str), std::option::Option::Some("."));
|
||||
assert_eq!(tauri.pointer("/build/beforeBuildCommand/script").and_then(serde_json::Value::as_str), std::option::Option::Some("npm run build"));
|
||||
assert_eq!(tauri.pointer("/build/beforeBuildCommand/cwd").and_then(serde_json::Value::as_str), std::option::Option::Some("."));
|
||||
assert_eq!(
|
||||
tauri.pointer("/build/frontendDist").and_then(serde_json::Value::as_str),
|
||||
std::option::Option::Some("../../../builds/khadhroony-solana-project/ksp-app-solprices-desk/dist"),
|
||||
);
|
||||
let windows = tauri.pointer("/app/windows").and_then(serde_json::Value::as_array);
|
||||
assert!(windows.is_some());
|
||||
let labels = match windows {
|
||||
std::option::Option::Some(values) => {
|
||||
values.iter().filter_map(|window| return window.get("label").and_then(serde_json::Value::as_str)).collect::<std::vec::Vec<_>>()
|
||||
},
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
assert_eq!(labels, ["splash", "main"]);
|
||||
let vite = read_text(root.join("vite.config.ts").as_path());
|
||||
assert!(vite.contains("port: 1434"));
|
||||
assert!(vite.contains("port: 1435"));
|
||||
assert!(vite.contains("strictPort: true"));
|
||||
assert!(!vite.contains("ksp-app-wallet-desk"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_package_is_mixed_lib_bin_and_frontend_is_scaffold_only() {
|
||||
let root = app_root();
|
||||
let manifest = read_text(root.join("Cargo.toml").as_path());
|
||||
assert!(manifest.contains("name = \"ksp-app-solprices-desk\""));
|
||||
assert!(manifest.contains("name = \"ksp_app_solprices_desk_lib\""));
|
||||
assert!(manifest.contains("[[bin]]"));
|
||||
let main_rs = read_text(root.join("src/main.rs").as_path());
|
||||
assert!(main_rs.contains("ksp_app_solprices_desk_lib::run"));
|
||||
let lib_rs = read_text(root.join("src/lib.rs").as_path());
|
||||
assert!(lib_rs.contains("pub use self::tauri::run"));
|
||||
let tauri_rs = read_text(root.join("src/tauri.rs").as_path());
|
||||
assert!(tauri_rs.contains("get_runtime_status"));
|
||||
assert!(!tauri_rs.contains("fn runtime_status("));
|
||||
let main_html = read_text(root.join("frontend/main.html").as_path());
|
||||
assert!(main_html.contains("data-view=\"prices\""));
|
||||
assert!(main_html.contains("data-view=\"diagnostics\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_004_packaging_contains_current_thirteen_config_resources() {
|
||||
let root = app_root();
|
||||
let tauri = read_json(root.join("tauri.conf.json").as_path());
|
||||
let resources = tauri.pointer("/bundle/resources").and_then(serde_json::Value::as_object);
|
||||
assert!(resources.is_some());
|
||||
if let std::option::Option::Some(resources) = resources {
|
||||
assert_eq!(resources.len(), 13);
|
||||
assert_eq!(
|
||||
resources.get("../../config/composite.ksp-app-solprices-desk.json").and_then(serde_json::Value::as_str),
|
||||
std::option::Option::Some("config/composite.ksp-app-solprices-desk.json"),
|
||||
);
|
||||
assert!(resources.contains_key("../../config/std.offchain_transport.json"));
|
||||
assert!(resources.contains_key("../../config/schemas/std.offchain_transport.schema.json"));
|
||||
assert!(resources.contains_key("../../config/std.store.json"));
|
||||
assert!(resources.contains_key("../../config/schemas/std.store.schema.json"));
|
||||
}
|
||||
let tauri_source = read_text(root.join("src/tauri.rs").as_path());
|
||||
assert!(tauri_source.contains("ksp_config_lib::prepare_packaged_runtime"));
|
||||
assert!(tauri_source.contains("std::env::set_current_dir(layout.runtime_root())"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_fix_001_reuses_the_common_ksp_desktop_template() {
|
||||
let root = app_root();
|
||||
let package = read_text(root.join("package.json").as_path());
|
||||
for dependency in ["@fltsci/tauri-plugin-tracing", "@fortawesome/fontawesome-free", "@tauri-apps/api", "bootstrap", "resize-observer-polyfill", "simplebar"]
|
||||
{
|
||||
assert!(package.contains(dependency), "missing common frontend dependency {dependency}");
|
||||
}
|
||||
let main_typescript = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||
assert!(main_typescript.contains(r#"import ResizeObserver from "resize-observer-polyfill""#));
|
||||
assert!(main_typescript.contains(".ResizeObserver = ResizeObserver"));
|
||||
assert!(main_typescript.contains("getCurrentWindow().label"));
|
||||
assert!(main_typescript.contains(r#""get_runtime_status""#));
|
||||
let main_html = read_text(root.join("frontend/main.html").as_path());
|
||||
assert!(main_html.contains(r#"<footer class="app-footer bg-dark text-light">"#));
|
||||
assert!(main_html.contains(r#"class="app-placeholder""#));
|
||||
assert!(main_html.contains(r#"class="row mb-0 app-runtime-list""#));
|
||||
assert!(!main_html.contains("runtimeVersionBadge"));
|
||||
let splash_html = read_text(root.join("frontend/splash.html").as_path());
|
||||
assert!(splash_html.contains("<title>KSP SOL Prices Desk — Chargement</title>"));
|
||||
let splash_scss = read_text(root.join("frontend/sass/splash.scss").as_path());
|
||||
assert!(splash_scss.contains("DOS_Amazigh.ttf"));
|
||||
assert!(splash_scss.contains(r#"font-family: "Dos Amazigh", sans-serif"#));
|
||||
let font_manifest = read_text(root.join("frontend/fonts/README.md").as_path());
|
||||
assert!(font_manifest.contains("a01ff4b5a0d699db7cefcf5336bf0e379ff6fbf0d096087571a13c8782f9aa02"));
|
||||
let font = std::fs::read(root.join("frontend/fonts/DOS_Amazigh.ttf"));
|
||||
let config_font = std::fs::read(root.join("../ksp-app-config-desk/frontend/fonts/DOS_Amazigh.ttf"));
|
||||
let wallet_font = std::fs::read(root.join("../ksp-app-wallet-desk/frontend/fonts/DOS_Amazigh.ttf"));
|
||||
assert!(font.is_ok());
|
||||
assert!(config_font.is_ok());
|
||||
assert!(wallet_font.is_ok());
|
||||
if let (std::result::Result::Ok(font), std::result::Result::Ok(config_font), std::result::Result::Ok(wallet_font)) = (font, config_font, wallet_font) {
|
||||
assert_eq!(font, config_font);
|
||||
assert_eq!(font, wallet_font);
|
||||
}
|
||||
for relative in ["_bootswatch.scss", "_fontawesome.scss", "_simplebar.scss", "_variables.scss", "main.scss", "splash.scss"] {
|
||||
let sol = read_text(root.join("frontend/sass").join(relative).as_path());
|
||||
let config = read_text(root.join("../ksp-app-config-desk/frontend/sass").join(relative).as_path());
|
||||
let wallet = read_text(root.join("../ksp-app-wallet-desk/frontend/sass").join(relative).as_path());
|
||||
let sol_body = sol.lines().skip(3).collect::<std::vec::Vec<_>>().join("\n");
|
||||
let config_body = config.lines().skip(3).collect::<std::vec::Vec<_>>().join("\n");
|
||||
let wallet_body = wallet.lines().skip(3).collect::<std::vec::Vec<_>>().join("\n");
|
||||
assert_eq!(sol_body, config_body, "SOL Prices Desk diverges from Config Desk template in {relative}");
|
||||
assert_eq!(sol_body, wallet_body, "SOL Prices Desk diverges from Wallet Desk template in {relative}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_005_market_price_runtime_preserves_single_refresh_contract() {
|
||||
let root = app_root();
|
||||
let manifest = read_text(root.join("Cargo.toml").as_path());
|
||||
assert!(manifest.contains(r#"ksp-offchain-transport-lib = { path = "../ksp-offchain-transport-lib" }"#));
|
||||
assert!(!manifest.contains("reqwest"));
|
||||
let runtime_rs = read_text(root.join("src/market_price_runtime.rs").as_path());
|
||||
assert!(runtime_rs.contains("pub(crate) async fn refresh_one"));
|
||||
assert!(runtime_rs.contains("service().refresh(&provider_id).await"));
|
||||
assert!(runtime_rs.contains("self.begin_refresh(provider_ids.as_slice())"));
|
||||
assert!(runtime_rs.contains("apply_outcome_to_state"));
|
||||
let tauri_rs = read_text(root.join("src/tauri.rs").as_path());
|
||||
assert!(tauri_rs.contains("refresh_market_price"));
|
||||
let main_html = read_text(root.join("frontend/main.html").as_path());
|
||||
assert!(main_html.contains(r#"id="marketPriceTable""#));
|
||||
assert!(main_html.contains(">Action</th>"));
|
||||
let main_typescript = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||
assert!(main_typescript.contains(r#""refresh_market_price""#));
|
||||
assert!(main_typescript.contains("SOL Prices Desk market-price refresh control clicked"));
|
||||
assert!(main_typescript.contains("provider.providerId"));
|
||||
assert!(main_typescript.contains("provider.loading"));
|
||||
assert!(main_typescript.contains("Refreshing…"));
|
||||
assert!(!main_typescript.contains("parseFloat("));
|
||||
assert!(!main_typescript.contains("Number(provider.price"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_006_market_price_runtime_adds_selected_and_global_refresh_without_polling() {
|
||||
let root = app_root();
|
||||
let lib_rs = read_text(root.join("src/lib.rs").as_path());
|
||||
assert!(lib_rs.contains("MarketPriceRefreshManyRequestDto"));
|
||||
assert!(lib_rs.contains("MarketPriceRefreshResultDto"));
|
||||
let runtime_rs = read_text(root.join("src/market_price_runtime.rs").as_path());
|
||||
assert!(runtime_rs.contains("pub(crate) async fn refresh_many"));
|
||||
assert!(runtime_rs.contains("service().refresh_many(provider_ids.as_slice()).await"));
|
||||
assert!(runtime_rs.contains("pub(crate) async fn refresh_all"));
|
||||
assert!(runtime_rs.contains("service().refresh_all().await"));
|
||||
assert!(runtime_rs.contains("begin_refresh_in_state"));
|
||||
let tauri_rs = read_text(root.join("src/tauri.rs").as_path());
|
||||
assert!(tauri_rs.contains("refresh_market_prices"));
|
||||
assert!(tauri_rs.contains("refresh_all_market_prices"));
|
||||
let main_html = read_text(root.join("frontend/main.html").as_path());
|
||||
assert!(main_html.contains(r#"id="refreshSelectedButton""#));
|
||||
assert!(main_html.contains(r#"id="refreshAllButton""#));
|
||||
assert!(main_html.contains(r#"id="selectedProviderCount""#));
|
||||
let main_typescript = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||
assert!(main_typescript.contains(r#""refresh_market_prices""#));
|
||||
assert!(main_typescript.contains(r#""refresh_all_market_prices""#));
|
||||
assert!(main_typescript.contains("selectedProviderIdsInRowOrder"));
|
||||
assert!(main_typescript.contains("SOL Prices Desk selected market-price refresh control clicked"));
|
||||
assert!(main_typescript.contains("SOL Prices Desk global market-price refresh control clicked"));
|
||||
for forbidden in ["setInterval(", "setTimeout(", "requestAnimationFrame("] {
|
||||
assert!(!main_typescript.contains(forbidden), "pre.006 must not add consumer scheduling: {forbidden}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_007_prices_view_is_responsive_and_projects_generic_states_without_price_coercion() {
|
||||
let root = app_root();
|
||||
let main_html = read_text(root.join("frontend/main.html").as_path());
|
||||
for required in [
|
||||
r#"id="pricesSummaryProviders""#,
|
||||
r#"id="pricesSummaryReady""#,
|
||||
r#"id="pricesSummaryObserved""#,
|
||||
r#"id="pricesSummaryAttention""#,
|
||||
r#"id="pricesSummaryLoading""#,
|
||||
r#"id="selectAllProviders""#,
|
||||
r#"id="clearSelectionButton""#,
|
||||
"Observation times",
|
||||
"app-market-table-container",
|
||||
"visually-hidden",
|
||||
] {
|
||||
assert!(main_html.contains(required), "pre.007 Prices UX is missing {required}");
|
||||
}
|
||||
let main_typescript = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||
for required in [
|
||||
"availabilityPresentations",
|
||||
"Authentication unavailable",
|
||||
"Cooling down",
|
||||
"Quota unavailable",
|
||||
"Temporarily unavailable",
|
||||
"formatUnixMillis",
|
||||
"BigInt(value)",
|
||||
"Not supplied",
|
||||
"selectAllProviders",
|
||||
"clearProviderSelection",
|
||||
"pricesSummaryObserved",
|
||||
"app-market-row-loading",
|
||||
] {
|
||||
assert!(main_typescript.contains(required), "pre.007 Prices UX is missing {required}");
|
||||
}
|
||||
assert!(!main_typescript.contains("parseFloat("));
|
||||
assert!(!main_typescript.contains("Number(provider.price"));
|
||||
let app_scss = read_text(root.join("frontend/sass/_app.scss").as_path());
|
||||
for required in ["position: sticky", "font-variant-numeric: tabular-nums", "app-market-summary-card", "app-market-table-container"] {
|
||||
assert!(app_scss.contains(required), "pre.007 responsive styling is missing {required}");
|
||||
}
|
||||
}
|
||||
201
crates/ksp-app-solprices-desk/tests/desktop_security.rs
Normal file
201
crates/ksp-app-solprices-desk/tests/desktop_security.rs
Normal file
@@ -0,0 +1,201 @@
|
||||
// file: crates/ksp-app-solprices-desk/tests/desktop_security.rs
|
||||
// version: 8
|
||||
|
||||
//! Security, ownership and frontend instrumentation canaries for SOL Prices Desk.
|
||||
|
||||
fn app_root() -> std::path::PathBuf {
|
||||
return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
}
|
||||
|
||||
fn collect_files(directory: &std::path::Path, extension: &str, files: &mut std::vec::Vec<std::path::PathBuf>) {
|
||||
let entries = std::fs::read_dir(directory);
|
||||
let entries = match entries {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => continue,
|
||||
};
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect_files(path.as_path(), extension, files);
|
||||
continue;
|
||||
}
|
||||
if path.extension().and_then(std::ffi::OsStr::to_str) == std::option::Option::Some(extension) {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_text(path: &std::path::Path) -> String {
|
||||
let source = std::fs::read_to_string(path);
|
||||
assert!(source.is_ok(), "unable to read {}", path.display());
|
||||
return match source {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => String::new(),
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_capability_surface_is_core_plus_tracing_only() {
|
||||
let root = app_root();
|
||||
let capability = read_text(root.join("capabilities/default.json").as_path());
|
||||
assert!(capability.contains("\"core:default\""));
|
||||
assert!(capability.contains("\"tracing:default\""));
|
||||
for forbidden in ["dialog:", "fs:", "http:", "shell:"] {
|
||||
assert!(!capability.contains(forbidden));
|
||||
}
|
||||
let manifest = read_text(root.join("Cargo.toml").as_path());
|
||||
assert!(manifest.contains("tauri-plugin-tracing.workspace = true"));
|
||||
assert!(!manifest.contains("tauri-plugin-dialog"));
|
||||
assert!(manifest.contains("ksp-offchain-transport-lib"));
|
||||
assert!(!manifest.contains("reqwest"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_frontend_has_no_network_persistence_or_native_dialog_surface() {
|
||||
let root = app_root();
|
||||
let mut typescript = std::vec::Vec::new();
|
||||
collect_files(root.join("frontend/ts").as_path(), "ts", &mut typescript);
|
||||
assert!(!typescript.is_empty());
|
||||
for path in typescript {
|
||||
let source = read_text(path.as_path());
|
||||
for forbidden in ["fetch(", "XMLHttpRequest", "localStorage", "sessionStorage", "window.alert(", "window.confirm(", "window.prompt("] {
|
||||
assert!(!source.contains(forbidden), "{} contains forbidden scaffold surface {forbidden}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_005_tauri_commands_remain_centralized_and_single_refresh_is_preserved() {
|
||||
let root = app_root();
|
||||
let mut rust_files = std::vec::Vec::new();
|
||||
collect_files(root.join("src").as_path(), "rs", &mut rust_files);
|
||||
let mut command_count = 0usize;
|
||||
for path in rust_files {
|
||||
let source = read_text(path.as_path());
|
||||
let count = source.matches("#[tauri::command]").count();
|
||||
if path.file_name().and_then(std::ffi::OsStr::to_str) == std::option::Option::Some("tauri.rs") {
|
||||
command_count += count;
|
||||
} else {
|
||||
assert_eq!(count, 0, "{} declares a Tauri command outside tauri.rs", path.display());
|
||||
}
|
||||
if path.file_name().and_then(std::ffi::OsStr::to_str) != std::option::Option::Some("tauri.rs") {
|
||||
assert!(!source.contains("tauri::generate_handler!"), "{} registers Tauri handlers outside tauri.rs", path.display());
|
||||
}
|
||||
}
|
||||
assert!(command_count >= 5);
|
||||
let tauri = read_text(root.join("src/tauri.rs").as_path());
|
||||
assert!(tauri.contains("refresh_market_price"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_005_frontend_refresh_interactions_are_logged_without_business_values() {
|
||||
let root = app_root();
|
||||
let main = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||
let invoke = read_text(root.join("frontend/ts/invoke.ts").as_path());
|
||||
assert!(main.contains(r#"button.addEventListener("click""#));
|
||||
assert!(main.contains("SOL Prices Desk navigation control clicked"));
|
||||
assert!(main.contains("frontendDebug"));
|
||||
assert!(main.contains("SOL Prices Desk view activated"));
|
||||
assert!(invoke.contains("Frontend IPC command requested"));
|
||||
assert!(invoke.contains("Frontend IPC command completed"));
|
||||
assert!(invoke.contains("frontendTrace"));
|
||||
assert!(main.contains("SOL Prices Desk provider registry load started"));
|
||||
assert!(main.contains("SOL Prices Desk market-price registry rows rendered"));
|
||||
assert!(main.contains("SOL Prices Desk market-price refresh control clicked"));
|
||||
assert!(main.contains("SOL Prices Desk market-price refresh row applied"));
|
||||
assert!(main.contains("SOL Prices Desk market-price refresh failed"));
|
||||
for forbidden in ["JSON.stringify(status)", "JSON.stringify(payload)", "apiKey", "authorization"] {
|
||||
assert!(!main.contains(forbidden), "main frontend logging must not serialize business/secret values: {forbidden}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_006_selected_and_global_refresh_commands_are_centralized_and_bounded() {
|
||||
let root = app_root();
|
||||
let tauri = read_text(root.join("src/tauri.rs").as_path());
|
||||
assert_eq!(tauri.matches("#[tauri::command]").count(), 7);
|
||||
assert!(tauri.contains("refresh_market_price"));
|
||||
assert!(tauri.contains("refresh_market_prices"));
|
||||
assert!(tauri.contains("refresh_all_market_prices"));
|
||||
let runtime = read_text(root.join("src/market_price_runtime.rs").as_path());
|
||||
assert!(runtime.contains("ERROR_CODE_MARKET_PRICE_REFRESH_CONFLICT"));
|
||||
assert!(runtime.contains("ERROR_CODE_MARKET_PRICE_SELECTION_INVALID"));
|
||||
assert!(runtime.contains("refresh_many(provider_ids.as_slice()).await"));
|
||||
assert!(runtime.contains("refresh_all().await"));
|
||||
assert!(!runtime.contains("tokio::time::sleep"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_006_frontend_batch_controls_are_logged_without_price_or_provider_payloads() {
|
||||
let root = app_root();
|
||||
let main = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||
assert!(main.contains("SOL Prices Desk market-price selection control changed"));
|
||||
assert!(main.contains("SOL Prices Desk selected market-price refresh control clicked"));
|
||||
assert!(main.contains("SOL Prices Desk global market-price refresh control clicked"));
|
||||
assert!(main.contains("requestedCount: result.requestedCount"));
|
||||
assert!(main.contains("refreshedCount: result.refreshedCount"));
|
||||
for forbidden in ["JSON.stringify(result)", "JSON.stringify(currentMarketPriceRows)", "price: provider.price", "apiKey", "authorization"] {
|
||||
assert!(!main.contains(forbidden), "batch frontend logging must not serialize price/provider/secret values: {forbidden}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_007_all_frontend_controls_and_backend_command_failures_are_instrumented_safely() {
|
||||
let root = app_root();
|
||||
let main = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||
for required in [
|
||||
"SOL Prices Desk navigation control clicked",
|
||||
"SOL Prices Desk market-price selection control changed",
|
||||
"SOL Prices Desk select-all providers control changed",
|
||||
"SOL Prices Desk clear provider selection control clicked",
|
||||
"SOL Prices Desk market-price refresh control clicked",
|
||||
"SOL Prices Desk selected market-price refresh control clicked",
|
||||
"SOL Prices Desk global market-price refresh control clicked",
|
||||
] {
|
||||
assert!(main.contains(required), "pre.007 frontend control instrumentation is missing {required}");
|
||||
}
|
||||
for forbidden in [
|
||||
"JSON.stringify(currentMarketPriceRows)",
|
||||
"JSON.stringify(result)",
|
||||
"price: provider.price",
|
||||
"providerTimestampUnixMillis:",
|
||||
"receivedAtUnixMillis:",
|
||||
"retryAtUnixMillis:",
|
||||
"apiKey",
|
||||
"authorization",
|
||||
] {
|
||||
assert!(!main.contains(forbidden), "pre.007 frontend instrumentation must not log business/secret values: {forbidden}");
|
||||
}
|
||||
let tauri = read_text(root.join("src/tauri.rs").as_path());
|
||||
assert!(tauri.contains("fn project_command_error"));
|
||||
assert!(tauri.contains("error_domain = error.code().domain()"));
|
||||
assert!(tauri.contains("error_code = error.code().code()"));
|
||||
assert!(tauri.contains("SOL Prices Desk Tauri command failed"));
|
||||
assert!(!tauri.contains("error_message = error"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_007_remains_manual_without_browser_persistence_or_price_scheduler() {
|
||||
let root = app_root();
|
||||
let main = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||
for forbidden in ["setInterval(", "setTimeout(", "requestAnimationFrame(", "localStorage", "sessionStorage", "fetch(", "XMLHttpRequest"] {
|
||||
assert!(!main.contains(forbidden), "pre.007 must remain manual and backend-owned: {forbidden}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_009_hardening_bounds_hostile_batches_and_preserves_manual_backend_owned_refresh() {
|
||||
let root = app_root();
|
||||
let runtime = read_text(root.join("src/market_price_runtime.rs").as_path());
|
||||
assert!(runtime.contains("const MARKET_PRICE_REFRESH_MAX_PROVIDER_IDS: usize = 64;"));
|
||||
assert!(runtime.contains("provider_ids.is_empty() || provider_ids.len() > MARKET_PRICE_REFRESH_MAX_PROVIDER_IDS"));
|
||||
assert!(runtime.contains("ERROR_CODE_MARKET_PRICE_SELECTION_INVALID"));
|
||||
let main = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||
for forbidden in ["setInterval(", "setTimeout(", "requestAnimationFrame(", "fetch(", "XMLHttpRequest", "localStorage", "sessionStorage"] {
|
||||
assert!(!main.contains(forbidden), "release hardening must preserve manual backend-owned refresh: {forbidden}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// file: crates/ksp-app-solprices-desk/tests/market_price_composition_live_smoke.rs
|
||||
// version: 1
|
||||
|
||||
//! Opt-in live smoke proving SOL Prices Desk composite -> Config -> generic MarketPriceService composition.
|
||||
|
||||
fn workspace_engine() -> ksp_core_lib::Result<ksp_config_lib::ConfigDocumentEngine> {
|
||||
let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let bootstrap = ksp_config_lib::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
|
||||
let bootstrap = match bootstrap {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let registry = ksp_config_lib::ConfigFileRegistry::defaults();
|
||||
return match registry {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(ksp_config_lib::ConfigDocumentEngine::new(bootstrap, value)),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
};
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
#[ignore = "opt-in live SOL Prices Desk composition smoke; resolves the committed public_keyless composite and performs external SOL/USD HTTPS refreshes"]
|
||||
async fn committed_solprices_composite_refreshes_at_least_one_keyless_provider() {
|
||||
let engine = workspace_engine();
|
||||
assert!(engine.is_ok(), "workspace Config engine should be constructible");
|
||||
let engine = match engine {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let file_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_SOLPRICES_DESK);
|
||||
assert!(file_id.is_ok(), "SOL Prices Desk composite file_id should be valid");
|
||||
let file_id = match file_id {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let composite = engine.load_resolved_composite(&file_id, std::option::Option::None);
|
||||
assert!(composite.is_ok(), "committed SOL Prices Desk composite should resolve");
|
||||
let composite = match composite {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(composite.profile_id(), "public_keyless");
|
||||
let component = composite.component("offchain_transport");
|
||||
assert!(component.is_some(), "SOL Prices Desk composite should contain Off-chain Transport");
|
||||
let component = match component {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let environment = ksp_config_lib::ConfigEnvironment::load();
|
||||
assert!(environment.is_ok(), "Config-owned environment snapshot should load");
|
||||
let environment = match environment {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let resolved = engine.resolve_offchain_transport_config_profile(component.resolved(), &environment);
|
||||
assert!(resolved.is_ok(), "composite-selected public_keyless Off-chain Transport should map");
|
||||
let resolved = match resolved {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(resolved.profile_id(), "public_keyless");
|
||||
assert_eq!(resolved.selection_source(), ksp_config_lib::ConfigProfileSelectionSource::Composite);
|
||||
let registry = resolved.service().registry();
|
||||
assert_eq!(registry.len(), 8);
|
||||
let outcomes = resolved.service().refresh_all().await;
|
||||
assert!(outcomes.is_ok(), "generic public_keyless refresh_all should complete without provider-specific application branching");
|
||||
let outcomes = match outcomes {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(outcomes.len(), registry.len());
|
||||
let refreshed_count = outcomes.iter().filter(|outcome| return outcome.refreshed()).count();
|
||||
assert!(refreshed_count > 0, "at least one public_keyless provider should produce one live SOL/USD observation");
|
||||
for outcome in outcomes {
|
||||
assert!(registry.entry(outcome.provider_id()).is_some(), "live outcome provider must belong to the generic registry");
|
||||
if let std::option::Option::Some(observation) = outcome.observation() {
|
||||
assert_eq!(observation.pair(), ksp_offchain_transport_lib::MarketPricePair::SolUsd);
|
||||
assert_eq!(observation.provider_id(), outcome.provider_id());
|
||||
}
|
||||
}
|
||||
}
|
||||
14
crates/ksp-app-solprices-desk/tests/public_api.rs
Normal file
14
crates/ksp-app-solprices-desk/tests/public_api.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
// file: crates/ksp-app-solprices-desk/tests/public_api.rs
|
||||
// version: 1
|
||||
|
||||
//! Public API checks for the KSP SOL prices desktop application library.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
#[test]
|
||||
fn run_entry_point_is_available_from_crate_root() {
|
||||
let run_entry_point: fn(&[std::ffi::OsString]) -> ksp_core_lib::Result<()> = ksp_app_solprices_desk_lib::run;
|
||||
assert_eq!(std::mem::size_of_val(&run_entry_point), std::mem::size_of::<fn(&[std::ffi::OsString]) -> ksp_core_lib::Result<()>>());
|
||||
}
|
||||
31
crates/ksp-app-solprices-desk/tsconfig.json
Normal file
31
crates/ksp-app-solprices-desk/tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"types": [
|
||||
"vite/client",
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"frontend",
|
||||
"vite.config.ts"
|
||||
]
|
||||
}
|
||||
11
crates/ksp-app-solprices-desk/unit_tests/dto_common.rs
Normal file
11
crates/ksp-app-solprices-desk/unit_tests/dto_common.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
// file: crates/ksp-app-solprices-desk/unit_tests/dto_common.rs
|
||||
// version: 1
|
||||
|
||||
#[test]
|
||||
fn command_error_projection_excludes_context_values() {
|
||||
let error = ksp_core_lib::Error::new(ksp_core_lib::ErrorCode::new("test", "failure"), "safe message").with_context("secret_canary", "must-not-be-exported");
|
||||
let dto = crate::CommandErrorDto::from_error(&error);
|
||||
assert_eq!(dto.domain, "test");
|
||||
assert_eq!(dto.code, "failure");
|
||||
assert_eq!(dto.message, "safe message");
|
||||
}
|
||||
40
crates/ksp-app-solprices-desk/unit_tests/frontend_logging.rs
Normal file
40
crates/ksp-app-solprices-desk/unit_tests/frontend_logging.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
// file: crates/ksp-app-solprices-desk/unit_tests/frontend_logging.rs
|
||||
// version: 1
|
||||
|
||||
#[test]
|
||||
fn frontend_log_levels_are_explicit_and_case_insensitive() {
|
||||
assert!(matches!(super::parse_level(" TRACE "), std::result::Result::Ok(super::FrontendLogLevel::Trace)));
|
||||
assert!(matches!(super::parse_level("debug"), std::result::Result::Ok(super::FrontendLogLevel::Debug)));
|
||||
assert!(matches!(super::parse_level("Info"), std::result::Result::Ok(super::FrontendLogLevel::Info)));
|
||||
assert!(matches!(super::parse_level("warn"), std::result::Result::Ok(super::FrontendLogLevel::Warn)));
|
||||
assert!(matches!(super::parse_level("ERROR"), std::result::Result::Ok(super::FrontendLogLevel::Error)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_frontend_log_level_is_rejected() {
|
||||
let result = super::parse_level("notice");
|
||||
assert!(result.is_err());
|
||||
let error = match result {
|
||||
std::result::Result::Ok(_) => return,
|
||||
std::result::Result::Err(error) => error,
|
||||
};
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_FRONTEND_LOG_LEVEL_INVALID);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frontend_log_targets_are_whitelisted() {
|
||||
assert!(matches!(super::parse_target("frontend"), std::result::Result::Ok(super::FrontendLogTarget::Frontend)));
|
||||
assert!(matches!(super::parse_target(" MAIN "), std::result::Result::Ok(super::FrontendLogTarget::Main)));
|
||||
assert!(matches!(super::parse_target("Splash"), std::result::Result::Ok(super::FrontendLogTarget::Splash)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arbitrary_frontend_log_target_is_rejected() {
|
||||
let result = super::parse_target("ksp-app-solprices-desk.test.logging");
|
||||
assert!(result.is_err());
|
||||
let error = match result {
|
||||
std::result::Result::Ok(_) => return,
|
||||
std::result::Result::Err(error) => error,
|
||||
};
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_FRONTEND_LOG_TARGET_INVALID);
|
||||
}
|
||||
302
crates/ksp-app-solprices-desk/unit_tests/market_price_runtime.rs
Normal file
302
crates/ksp-app-solprices-desk/unit_tests/market_price_runtime.rs
Normal file
@@ -0,0 +1,302 @@
|
||||
// file: crates/ksp-app-solprices-desk/unit_tests/market_price_runtime.rs
|
||||
// version: 4
|
||||
|
||||
fn descriptor() -> std::option::Option<ksp_offchain_transport_lib::MarketPriceProviderDescriptor> {
|
||||
let provider_id = ksp_offchain_transport_lib::MarketPriceProviderId::new("test-provider");
|
||||
let provider_id = match provider_id {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
let rate_limit = ksp_offchain_transport_lib::MarketPriceProviderRateLimit::fixed(
|
||||
10,
|
||||
60,
|
||||
std::option::Option::None,
|
||||
ksp_offchain_transport_lib::MarketPriceProviderRateLimitScope::Unspecified,
|
||||
);
|
||||
let rate_limit = match rate_limit {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
let descriptor = ksp_offchain_transport_lib::MarketPriceProviderDescriptor::new(
|
||||
provider_id,
|
||||
"Test Provider",
|
||||
ksp_offchain_transport_lib::MarketPriceSemantics::AggregatedMarket,
|
||||
ksp_offchain_transport_lib::MarketPriceProviderAuthMode::None,
|
||||
rate_limit,
|
||||
std::option::Option::None,
|
||||
true,
|
||||
);
|
||||
return match descriptor {
|
||||
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
||||
std::result::Result::Err(_) => std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_codes_cover_current_provider_neutral_enums() {
|
||||
assert_eq!(super::auth_mode_code(ksp_offchain_transport_lib::MarketPriceProviderAuthMode::None), "none");
|
||||
assert_eq!(super::auth_mode_code(ksp_offchain_transport_lib::MarketPriceProviderAuthMode::OptionalApiKey), "optional_api_key");
|
||||
assert_eq!(super::auth_mode_code(ksp_offchain_transport_lib::MarketPriceProviderAuthMode::RequiredApiKey), "required_api_key");
|
||||
assert_eq!(
|
||||
super::availability_code(ksp_offchain_transport_lib::MarketPriceProviderAvailability::AuthenticationUnavailable),
|
||||
"authentication_unavailable"
|
||||
);
|
||||
assert_eq!(
|
||||
super::availability_code(ksp_offchain_transport_lib::MarketPriceProviderAvailability::CoolingDown {
|
||||
retry_at: ksp_offchain_transport_lib::MarketPriceTimestamp::from_unix_millis(123),
|
||||
}),
|
||||
"cooling_down"
|
||||
);
|
||||
assert_eq!(super::availability_code(ksp_offchain_transport_lib::MarketPriceProviderAvailability::Disabled), "disabled");
|
||||
assert_eq!(super::availability_code(ksp_offchain_transport_lib::MarketPriceProviderAvailability::Misconfigured), "misconfigured");
|
||||
assert_eq!(super::availability_code(ksp_offchain_transport_lib::MarketPriceProviderAvailability::QuotaUnavailable), "quota_unavailable");
|
||||
assert_eq!(super::availability_code(ksp_offchain_transport_lib::MarketPriceProviderAvailability::Ready), "ready");
|
||||
assert_eq!(
|
||||
super::availability_code(ksp_offchain_transport_lib::MarketPriceProviderAvailability::TemporarilyUnavailable { retry_at: std::option::Option::None }),
|
||||
"temporarily_unavailable"
|
||||
);
|
||||
assert_eq!(super::semantics_code(ksp_offchain_transport_lib::MarketPriceSemantics::AggregatedMarket), "aggregated_market");
|
||||
assert_eq!(super::semantics_code(ksp_offchain_transport_lib::MarketPriceSemantics::DexPairUsd), "dex_pair_usd");
|
||||
assert_eq!(super::semantics_code(ksp_offchain_transport_lib::MarketPriceSemantics::ExchangeLastTrade), "exchange_last_trade");
|
||||
assert_eq!(super::semantics_code(ksp_offchain_transport_lib::MarketPriceSemantics::SolanaHeuristic), "solana_heuristic");
|
||||
assert_eq!(super::semantics_code(ksp_offchain_transport_lib::MarketPriceSemantics::SolanaSpot), "solana_spot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_registry_row_contains_no_observation_or_loading_state() {
|
||||
let descriptor = descriptor();
|
||||
assert!(descriptor.is_some());
|
||||
let descriptor = match descriptor {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let entry =
|
||||
ksp_offchain_transport_lib::MarketPriceProviderRegistryEntry::new(descriptor, ksp_offchain_transport_lib::MarketPriceProviderAvailability::Ready);
|
||||
let presentation = super::MarketPricePresentationEntry { loading: false, observation: std::option::Option::None };
|
||||
let row = super::project_registry_entry(&entry, &presentation);
|
||||
assert_eq!(row.provider_id, "test-provider");
|
||||
assert_eq!(row.display_name, "Test Provider");
|
||||
assert_eq!(row.pair, "SOL/USD");
|
||||
assert_eq!(row.auth_mode, "none");
|
||||
assert_eq!(row.availability, "ready");
|
||||
assert_eq!(row.semantics, "aggregated_market");
|
||||
assert!(!row.loading);
|
||||
assert!(!row.refreshed);
|
||||
assert_eq!(row.price, std::option::Option::None);
|
||||
assert_eq!(row.provider_timestamp_unix_millis, std::option::Option::None);
|
||||
assert_eq!(row.received_at_unix_millis, std::option::Option::None);
|
||||
assert_eq!(row.retry_at_unix_millis, std::option::Option::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observation_projection_keeps_exact_decimal_and_distinct_timestamps() {
|
||||
let descriptor = descriptor();
|
||||
assert!(descriptor.is_some());
|
||||
let descriptor = match descriptor {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let provider_id = descriptor.id().clone();
|
||||
let entry = ksp_offchain_transport_lib::MarketPriceProviderRegistryEntry::new(
|
||||
descriptor,
|
||||
ksp_offchain_transport_lib::MarketPriceProviderAvailability::CoolingDown {
|
||||
retry_at: ksp_offchain_transport_lib::MarketPriceTimestamp::from_unix_millis(400),
|
||||
},
|
||||
);
|
||||
let price = ksp_offchain_transport_lib::MarketPriceDecimal::parse("123.4500");
|
||||
assert!(price.is_ok());
|
||||
let price = match price {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let provenance = ksp_offchain_transport_lib::MarketPriceProvenance::new("unit-test");
|
||||
assert!(provenance.is_ok());
|
||||
let provenance = match provenance {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let observation = ksp_offchain_transport_lib::MarketPriceObservation::new(
|
||||
provider_id,
|
||||
price,
|
||||
ksp_offchain_transport_lib::MarketPriceSemantics::AggregatedMarket,
|
||||
ksp_offchain_transport_lib::MarketPriceTimestamp::from_unix_millis(100),
|
||||
ksp_offchain_transport_lib::MarketPriceTimestamp::from_unix_millis(300),
|
||||
std::option::Option::Some(ksp_offchain_transport_lib::MarketPriceTimestamp::from_unix_millis(200)),
|
||||
provenance,
|
||||
);
|
||||
assert!(observation.is_ok());
|
||||
let observation = match observation {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let presentation = super::MarketPricePresentationEntry { loading: true, observation: std::option::Option::Some(observation) };
|
||||
let row = super::project_registry_entry(&entry, &presentation);
|
||||
assert_eq!(row.price.as_deref(), std::option::Option::Some("123.45"));
|
||||
assert_eq!(row.provider_timestamp_unix_millis.as_deref(), std::option::Option::Some("200"));
|
||||
assert_eq!(row.received_at_unix_millis.as_deref(), std::option::Option::Some("300"));
|
||||
assert_eq!(row.retry_at_unix_millis.as_deref(), std::option::Option::Some("400"));
|
||||
assert!(row.loading);
|
||||
assert!(row.refreshed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_refresh_clears_loading_without_inventing_observation() -> ksp_core_lib::Result<()> {
|
||||
let settings = match ksp_offchain_transport_lib::MarketPriceCoinPaprikaSettings::new(false) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let service =
|
||||
match ksp_offchain_transport_lib::MarketPriceService::new(std::vec![ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinPaprika(settings),]) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let provider_id = match ksp_offchain_transport_lib::MarketPriceProviderId::new("coinpaprika") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut entries = std::collections::BTreeMap::new();
|
||||
entries.insert(provider_id.as_str().to_owned(), super::MarketPricePresentationEntry { loading: true, observation: std::option::Option::None });
|
||||
let mut presentation = super::MarketPricePresentationState { entries };
|
||||
let outcome = match service.refresh(&provider_id).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
assert!(!outcome.refreshed());
|
||||
let applied = super::apply_outcome_to_state(&mut presentation, &outcome);
|
||||
if let std::result::Result::Err(error) = applied {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let entry = presentation.entries.get(provider_id.as_str());
|
||||
assert!(entry.is_some());
|
||||
if let std::option::Option::Some(entry) = entry {
|
||||
assert!(!entry.loading);
|
||||
assert!(entry.observation.is_none());
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_refresh_outcome_preserves_last_successful_observation() -> ksp_core_lib::Result<()> {
|
||||
let settings = match ksp_offchain_transport_lib::MarketPriceCoinPaprikaSettings::new(false) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let service =
|
||||
match ksp_offchain_transport_lib::MarketPriceService::new(std::vec![ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinPaprika(settings),]) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let provider_id = match ksp_offchain_transport_lib::MarketPriceProviderId::new("coinpaprika") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let price = match ksp_offchain_transport_lib::MarketPriceDecimal::parse("101.2500") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let provenance = match ksp_offchain_transport_lib::MarketPriceProvenance::new("unit-test") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let observation = match ksp_offchain_transport_lib::MarketPriceObservation::new(
|
||||
provider_id.clone(),
|
||||
price,
|
||||
ksp_offchain_transport_lib::MarketPriceSemantics::AggregatedMarket,
|
||||
ksp_offchain_transport_lib::MarketPriceTimestamp::from_unix_millis(10),
|
||||
ksp_offchain_transport_lib::MarketPriceTimestamp::from_unix_millis(30),
|
||||
std::option::Option::Some(ksp_offchain_transport_lib::MarketPriceTimestamp::from_unix_millis(20)),
|
||||
provenance,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut entries = std::collections::BTreeMap::new();
|
||||
entries.insert(provider_id.as_str().to_owned(), super::MarketPricePresentationEntry { loading: true, observation: std::option::Option::Some(observation) });
|
||||
let mut presentation = super::MarketPricePresentationState { entries };
|
||||
let outcome = match service.refresh(&provider_id).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
assert!(!outcome.refreshed());
|
||||
let applied = super::apply_outcome_to_state(&mut presentation, &outcome);
|
||||
if let std::result::Result::Err(error) = applied {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let entry = presentation.entries.get(provider_id.as_str());
|
||||
assert!(entry.is_some());
|
||||
if let std::option::Option::Some(entry) = entry {
|
||||
assert!(!entry.loading);
|
||||
assert_eq!(entry.observation.as_ref().map(|value| return value.price().to_canonical_string()).as_deref(), std::option::Option::Some("101.25"));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_loading_validation_rejects_duplicates_without_partial_state_mutation() -> ksp_core_lib::Result<()> {
|
||||
let provider_id = match ksp_offchain_transport_lib::MarketPriceProviderId::new("coinpaprika") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut entries = std::collections::BTreeMap::new();
|
||||
entries.insert(provider_id.as_str().to_owned(), super::MarketPricePresentationEntry { loading: false, observation: std::option::Option::None });
|
||||
let mut presentation = super::MarketPricePresentationState { entries };
|
||||
let result = super::begin_refresh_in_state(&mut presentation, &[provider_id.clone(), provider_id.clone()]);
|
||||
assert!(result.is_err());
|
||||
let entry = presentation.entries.get(provider_id.as_str());
|
||||
assert!(entry.is_some());
|
||||
if let std::option::Option::Some(entry) = entry {
|
||||
assert!(!entry.loading);
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_loading_validation_rejects_in_flight_provider_without_marking_other_rows() -> ksp_core_lib::Result<()> {
|
||||
let coinpaprika = match ksp_offchain_transport_lib::MarketPriceProviderId::new("coinpaprika") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let kraken = match ksp_offchain_transport_lib::MarketPriceProviderId::new("kraken") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut entries = std::collections::BTreeMap::new();
|
||||
entries.insert(coinpaprika.as_str().to_owned(), super::MarketPricePresentationEntry { loading: false, observation: std::option::Option::None });
|
||||
entries.insert(kraken.as_str().to_owned(), super::MarketPricePresentationEntry { loading: true, observation: std::option::Option::None });
|
||||
let mut presentation = super::MarketPricePresentationState { entries };
|
||||
let result = super::begin_refresh_in_state(&mut presentation, &[coinpaprika.clone(), kraken.clone()]);
|
||||
assert!(result.is_err());
|
||||
let coinpaprika_entry = presentation.entries.get(coinpaprika.as_str());
|
||||
let kraken_entry = presentation.entries.get(kraken.as_str());
|
||||
assert!(coinpaprika_entry.is_some());
|
||||
assert!(kraken_entry.is_some());
|
||||
if let std::option::Option::Some(entry) = coinpaprika_entry {
|
||||
assert!(!entry.loading);
|
||||
}
|
||||
if let std::option::Option::Some(entry) = kraken_entry {
|
||||
assert!(entry.loading);
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_batch_size_is_rejected_before_provider_id_parsing() {
|
||||
let provider_ids = (0..=super::MARKET_PRICE_REFRESH_MAX_PROVIDER_IDS)
|
||||
.map(|index| return std::format!("invalid provider payload {index}"))
|
||||
.collect::<std::vec::Vec<_>>();
|
||||
let result = super::parse_provider_ids(provider_ids);
|
||||
assert!(result.is_err());
|
||||
if let std::result::Result::Err(error) = result {
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_MARKET_PRICE_SELECTION_INVALID);
|
||||
assert_eq!(error.context().len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_batch_is_rejected_at_the_application_boundary() {
|
||||
let result = super::parse_provider_ids(std::vec::Vec::new());
|
||||
assert!(result.is_err());
|
||||
if let std::result::Result::Err(error) = result {
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_MARKET_PRICE_SELECTION_INVALID);
|
||||
}
|
||||
}
|
||||
36
crates/ksp-app-solprices-desk/unit_tests/splash.rs
Normal file
36
crates/ksp-app-solprices-desk/unit_tests/splash.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
// file: crates/ksp-app-solprices-desk/unit_tests/splash.rs
|
||||
// version: 1
|
||||
|
||||
#[test]
|
||||
fn fallback_splash_settings_are_short_and_ordered() {
|
||||
let settings = crate::SplashSettings::fallback();
|
||||
assert_eq!(settings.fade_in_ms(), 300);
|
||||
assert_eq!(settings.minimum_ms(), 1200);
|
||||
assert_eq!(settings.fade_out_ms(), 300);
|
||||
assert_eq!(settings.expected_backend_lifecycle_ms(), 1800);
|
||||
assert_eq!(settings.fade_in_source(), "fallback");
|
||||
assert_eq!(settings.minimum_source(), "fallback");
|
||||
assert_eq!(settings.fade_out_source(), "fallback");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splash_order_contract_keeps_action_message_status_and_duration() {
|
||||
let order =
|
||||
crate::SplashOrderDto::new("add_message", std::option::Option::Some("ready"), std::option::Option::Some("success"), std::option::Option::Some(300));
|
||||
assert_eq!(order.action, "add_message");
|
||||
assert_eq!(order.message.as_deref(), std::option::Option::Some("ready"));
|
||||
assert_eq!(order.status.as_deref(), std::option::Option::Some("success"));
|
||||
assert_eq!(order.duration_ms, std::option::Option::Some(300));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splash_duration_parser_rejects_invalid_or_unbounded_values() {
|
||||
let malformed = super::parse_u64_setting("KSP_DESK_SPLASH_MINIMUM_MS", "invalid", 60_000);
|
||||
let oversized_minimum = super::parse_u64_setting("KSP_DESK_SPLASH_MINIMUM_MS", "60001", 60_000);
|
||||
let oversized_fade_in = super::parse_u32_setting("KSP_DESK_SPLASH_FADE_IN_MS", "10001", 10_000);
|
||||
let oversized_fade_out = super::parse_u32_setting("KSP_DESK_SPLASH_FADE_OUT_MS", "10001", 10_000);
|
||||
assert!(malformed.is_err());
|
||||
assert!(oversized_minimum.is_err());
|
||||
assert!(oversized_fade_in.is_err());
|
||||
assert!(oversized_fade_out.is_err());
|
||||
}
|
||||
72
crates/ksp-app-solprices-desk/vite.config.ts
Normal file
72
crates/ksp-app-solprices-desk/vite.config.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
// file: crates/ksp-app-solprices-desk/vite.config.ts
|
||||
// version: 1
|
||||
|
||||
import { NodePackageImporter } from "sass-embedded";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolve } from "node:path";
|
||||
import { defineConfig, normalizePath } from "vite";
|
||||
|
||||
const appRoot = fileURLToPath(new URL(".", import.meta.url));
|
||||
const frontendRoot = normalizePath(resolve(appRoot, "frontend"));
|
||||
const frontendDist = normalizePath(resolve(appRoot, "../../../builds/khadhroony-solana-project/ksp-app-solprices-desk/dist"));
|
||||
const devHost = process.env.TAURI_DEV_HOST;
|
||||
|
||||
export default defineConfig({
|
||||
clearScreen: false,
|
||||
root: frontendRoot,
|
||||
publicDir: false,
|
||||
input: {
|
||||
main: normalizePath(resolve(frontendRoot, "main.html")),
|
||||
splash: normalizePath(resolve(frontendRoot, "splash.html")),
|
||||
},
|
||||
build: {
|
||||
outDir: frontendDist,
|
||||
emptyOutDir: true,
|
||||
minify: true,
|
||||
sourcemap: false,
|
||||
cssCodeSplit: true,
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
entryFileNames: "js/[name]-[hash].js",
|
||||
chunkFileNames: "js/chunks/[name]-[hash].js",
|
||||
assetFileNames: assetInfo => {
|
||||
const originalName = assetInfo.names[0] ?? "";
|
||||
const extension = originalName.substring(originalName.lastIndexOf(".") + 1).toLowerCase();
|
||||
if (extension === "css") {
|
||||
return "css/[name]-[hash][extname]";
|
||||
}
|
||||
if (["eot", "otf", "ttf", "woff", "woff2"].includes(extension)) {
|
||||
return "fonts/[name]-[hash][extname]";
|
||||
}
|
||||
if (["png", "jpg", "jpeg", "gif", "svg", "webp", "ico"].includes(extension)) {
|
||||
return "imgs/[name][extname]";
|
||||
}
|
||||
return "otherassets/[name][extname]";
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
quietDeps: true,
|
||||
silenceDeprecations: ["import", "color-functions", "global-builtin"],
|
||||
verbose: false,
|
||||
importers: [new NodePackageImporter()],
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 1434,
|
||||
strictPort: true,
|
||||
host: devHost || false,
|
||||
ws: {
|
||||
protocol: "ws",
|
||||
host: devHost || "localhost",
|
||||
port: 1435,
|
||||
},
|
||||
watch: {
|
||||
ignored: ["**/src/**"],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
# file: crates/ksp-app-wallet-desk/Cargo.toml
|
||||
# version: 5
|
||||
# version: 6
|
||||
|
||||
[package]
|
||||
name = "ksp-app-wallet-desk"
|
||||
@@ -27,6 +27,7 @@ fs2.workspace = true
|
||||
ksp-config-lib = { path = "../ksp-config-lib" }
|
||||
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||
ksp-logging-lib = { path = "../ksp-logging-lib" }
|
||||
ksp-offchain-transport-lib = { path = "../ksp-offchain-transport-lib" }
|
||||
ksp-onchain-transport-lib = { path = "../ksp-onchain-transport-lib" }
|
||||
ksp-wallet-lib = { path = "../ksp-wallet-lib" }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: crates/ksp-app-wallet-desk/README.md -->
|
||||
<!-- version: 3 -->
|
||||
<!-- version: 4 -->
|
||||
|
||||
# `ksp-app-wallet-desk`
|
||||
|
||||
@@ -21,6 +21,7 @@ Wallet Desk reste une couche d'interface et de composition. Il ne possède ni le
|
||||
ksp-config-lib -> configuration/composite/.env
|
||||
ksp-wallet-lib -> format, crypto, VIEW/OWNER, import/export, migration
|
||||
ksp-onchain-transport-lib -> RPC Solana HTTP
|
||||
ksp-offchain-transport-lib -> prix SOL/USD provider-neutral
|
||||
ksp-logging-lib -> logging/tracing applicatif
|
||||
ksp-app-wallet-desk -> orchestration Tauri + projections sûres
|
||||
```
|
||||
@@ -64,6 +65,19 @@ La surface validée comprend :
|
||||
- export OWNER Solana CLI JSON/Base58 no-clobber ;
|
||||
- wire V2 par défaut tout en conservant la compatibilité V1.
|
||||
|
||||
## Prix SOL/USD auxiliaire
|
||||
|
||||
Le refresh de balance compose deux capacités backend : `getBalance` et `MarketPriceService::refresh_all`. Les observations réussies du refresh courant sont moyennées côté Rust sans `f64`; la balance SOL reste autoritative et le prix reste auxiliaire.
|
||||
|
||||
Le frontend reçoit uniquement :
|
||||
|
||||
```text
|
||||
SOL/USD moyen
|
||||
Équivalent USD de la balance
|
||||
```
|
||||
|
||||
ou `N.A.` lorsque le prix n'est pas disponible. Cette moyenne est une projection locale à Wallet Desk : elle ne devient ni un consensus, ni un fallback, ni un prix canonique de `ksp-offchain-transport-lib`. Aucun provider ID ou détail provider n'est exposé au frontend Wallet.
|
||||
|
||||
## Frontières desktop
|
||||
|
||||
Le frontend utilise Bootstrap, Font Awesome, DataTables/Select, SimpleBar et le bridge Logging KSP. Les capabilities Tauri du guest restent minimales :
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: crates/ksp-app-wallet-desk/USAGE.md -->
|
||||
<!-- version: 2 -->
|
||||
<!-- version: 3 -->
|
||||
|
||||
# Utilisation de `ksp-app-wallet-desk`
|
||||
|
||||
@@ -82,9 +82,21 @@ migrate_wallet_file_v1_to_v2_in_place
|
||||
|
||||
Elles authentifient OWNER, reconstruisent un document cryptographique V2 neuf et conservent l'identité Solana ainsi que les metadata/note IDs. Lorsque VIEW est activé, un password VIEW cible doit être fourni. La copie est no-clobber ; le remplacement in-place est stale-protected.
|
||||
|
||||
## 7. Balance Solana
|
||||
## 7. Balance Solana et prix auxiliaire
|
||||
|
||||
Une session VIEW ou OWNER autorisée peut demander `getBalance` via `ksp-onchain-transport-lib`. Le frontend ne choisit ni Pubkey arbitraire ni URL RPC : ces valeurs sont dérivées de l'état Rust autorisé et de Config.
|
||||
Une session VIEW ou OWNER autorisée peut rafraîchir sa balance. Le backend compose alors `getBalance` via `ksp-onchain-transport-lib` et un `refresh_all` SOL/USD via `ksp-offchain-transport-lib`. Le frontend ne choisit ni Pubkey arbitraire, ni URL RPC, ni provider prix : ces valeurs sont dérivées de l'état Rust autorisé et de Config.
|
||||
|
||||
Lorsque des observations SOL/USD sont produites par le refresh courant, Wallet Desk calcule côté Rust une moyenne arithmétique exacte puis l'équivalent USD de la balance. Aucun `f64` n'est utilisé comme vérité métier.
|
||||
|
||||
L'interface affiche :
|
||||
|
||||
```text
|
||||
Balance SOL
|
||||
SOL/USD moyen
|
||||
Équivalent USD
|
||||
```
|
||||
|
||||
Si aucune observation prix exploitable n'est disponible, les deux champs USD affichent `N.A.` sans transformer un `getBalance` réussi en erreur Wallet. La moyenne est strictement une projection de consommation Wallet ; elle ne modifie pas le contrat provider-neutral de `ksp-offchain-transport-lib`.
|
||||
|
||||
## 8. Runtime packagé
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: crates/ksp-app-wallet-desk/frontend/main.html -->
|
||||
<!-- version: 5 -->
|
||||
<!-- version: 7 -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
@@ -53,7 +53,7 @@
|
||||
<h1 id="viewTitle" class="h3 mb-1">Dashboard</h1>
|
||||
<p class="text-body-secondary mb-0">Identité autorisée et balance SOL via le Transport HTTP composite sont actives.</p>
|
||||
</div>
|
||||
<span class="badge text-bg-secondary">0.2.6</span>
|
||||
<span id="appVersionBadge" class="badge text-bg-secondary">—</span>
|
||||
</div>
|
||||
<section data-view-panel="dashboard">
|
||||
<div class="row g-3">
|
||||
@@ -97,6 +97,8 @@
|
||||
<dt class="col-5">Alias</dt><dd id="currentWalletAlias" class="col-7 text-break">—</dd>
|
||||
<dt class="col-5">Notes</dt><dd id="currentWalletNotes" class="col-7 text-break">—</dd>
|
||||
<dt class="col-5">Balance SOL</dt><dd id="currentWalletBalanceSol" class="col-7">—</dd>
|
||||
<dt class="col-5">SOL/USD moyen</dt><dd id="currentWalletSolUsdAverage" class="col-7">N.A.</dd>
|
||||
<dt class="col-5">Équivalent USD</dt><dd id="currentWalletSolUsdEquivalent" class="col-7">N.A.</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
@@ -256,6 +258,8 @@
|
||||
<dt class="col-md-4">Notes</dt><dd id="detailsWalletNotes" class="col-md-8 text-break">—</dd>
|
||||
<dt class="col-md-4">Lamports</dt><dd id="detailsBalanceLamports" class="col-md-8">—</dd>
|
||||
<dt class="col-md-4">SOL</dt><dd id="detailsBalanceSol" class="col-md-8">—</dd>
|
||||
<dt class="col-md-4">SOL/USD moyen</dt><dd id="detailsSolUsdAverage" class="col-md-8">N.A.</dd>
|
||||
<dt class="col-md-4">Équivalent USD</dt><dd id="detailsSolUsdEquivalent" class="col-md-8">N.A.</dd>
|
||||
<dt class="col-md-4">RPC slot</dt><dd id="detailsBalanceSlot" class="col-md-8">—</dd>
|
||||
<dt class="col-md-4">RPC api_version</dt><dd id="detailsBalanceApiVersion" class="col-md-8">—</dd>
|
||||
<dt class="col-md-4">Transport</dt><dd id="detailsBalanceTransport" class="col-md-8">—</dd>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-wallet-desk/frontend/ts/main.ts
|
||||
// version: 16
|
||||
// version: 18
|
||||
|
||||
import { Modal } from "bootstrap";
|
||||
import DataTable from "datatables.net-bs5";
|
||||
@@ -378,8 +378,12 @@ function clearStrongViewSensitiveInputs(): void {
|
||||
|
||||
function clearBalanceProjection(): void {
|
||||
setText("#currentWalletBalanceSol", "—");
|
||||
setText("#currentWalletSolUsdAverage", "N.A.");
|
||||
setText("#currentWalletSolUsdEquivalent", "N.A.");
|
||||
setText("#detailsBalanceLamports", "—");
|
||||
setText("#detailsBalanceSol", "—");
|
||||
setText("#detailsSolUsdAverage", "N.A.");
|
||||
setText("#detailsSolUsdEquivalent", "N.A.");
|
||||
setText("#detailsBalanceSlot", "—");
|
||||
setText("#detailsBalanceApiVersion", "—");
|
||||
setText("#detailsBalanceTransport", "—");
|
||||
@@ -1379,13 +1383,17 @@ function renderWalletBalance(balance: WalletBalanceDto): void {
|
||||
return;
|
||||
}
|
||||
setText("#currentWalletBalanceSol", balance.sol);
|
||||
setText("#currentWalletSolUsdAverage", balance.solUsdAverage ? `${balance.solUsdAverage} USD/SOL` : "N.A.");
|
||||
setText("#currentWalletSolUsdEquivalent", balance.solUsdEquivalent ? `${balance.solUsdEquivalent} USD` : "N.A.");
|
||||
setText("#detailsBalanceLamports", balance.lamports);
|
||||
setText("#detailsBalanceSol", balance.sol);
|
||||
setText("#detailsSolUsdAverage", balance.solUsdAverage ? `${balance.solUsdAverage} USD/SOL` : "N.A.");
|
||||
setText("#detailsSolUsdEquivalent", balance.solUsdEquivalent ? `${balance.solUsdEquivalent} USD` : "N.A.");
|
||||
setText("#detailsBalanceSlot", balance.slot.toString());
|
||||
setText("#detailsBalanceApiVersion", balance.apiVersion ?? "—");
|
||||
setText("#detailsBalanceTransport", `${balance.transportProfile} / ${balance.transportRole}`);
|
||||
setText("#balanceStatus", "Balance réseau actualisée.");
|
||||
frontendInfo("main", "Wallet balance rendered", { lamports: balance.lamports, slot: balance.slot, walletId: balance.walletId });
|
||||
setText("#balanceStatus", "Balance réseau, prix moyen SOL/USD et équivalent USD actualisés.");
|
||||
frontendInfo("main", "Wallet balance rendered", { averageAvailable: balance.solUsdAverage !== null, equivalentAvailable: balance.solUsdEquivalent !== null, lamports: balance.lamports, slot: balance.slot, walletId: balance.walletId });
|
||||
}
|
||||
|
||||
async function refreshWalletBalance(): Promise<void> {
|
||||
@@ -1393,8 +1401,8 @@ async function refreshWalletBalance(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
const walletId = activeWalletId;
|
||||
setText("#balanceStatus", "Interrogation getBalance en cours…");
|
||||
frontendDebug("main", "Wallet balance refresh requested", { walletId });
|
||||
setText("#balanceStatus", "Interrogation getBalance et refresh SOL/USD en cours…");
|
||||
frontendDebug("main", "Wallet balance and SOL/USD refresh requested", { walletId });
|
||||
try {
|
||||
const balance = await invokeKsp<WalletBalanceDto>("main", "refresh_wallet_balance");
|
||||
renderWalletBalance(balance);
|
||||
@@ -1415,6 +1423,7 @@ function bindBalanceActions(): void {
|
||||
}
|
||||
|
||||
function renderRuntimeStatus(status: RuntimeStatusDto): void {
|
||||
setText("#appVersionBadge", status.applicationVersion);
|
||||
setText("#runtimeVersion", status.applicationVersion);
|
||||
setText("#runtimeCompositeProfile", status.activeCompositeProfile);
|
||||
setText("#runtimeLoggingProfile", status.activeLoggingProfile ?? "fallback transitoire");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ksp-app-wallet-desk",
|
||||
"private": true,
|
||||
"version": "0.2.6",
|
||||
"version": "0.2.12-pre.9",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-wallet-desk/src/app_state.rs
|
||||
// version: 19
|
||||
// version: 21
|
||||
|
||||
//! Shared backend state owned by the Wallet Desk Tauri application.
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
pub(crate) struct AppState {
|
||||
config_management: ksp_config_lib::ConfigManagement,
|
||||
logging_runtime: std::sync::Mutex<LoggingRuntimeState>,
|
||||
offchain_runtime: std::option::Option<crate::OffchainTransportRuntime>,
|
||||
splash_settings: crate::SplashSettings,
|
||||
splash_sequence_started: std::sync::atomic::AtomicBool,
|
||||
transport_runtime: crate::TransportRuntime,
|
||||
@@ -41,6 +42,20 @@ impl AppState {
|
||||
return std::result::Result::Err(error);
|
||||
},
|
||||
};
|
||||
let offchain_runtime = crate::initialize_offchain_transport(&config_management);
|
||||
let offchain_runtime = match offchain_runtime {
|
||||
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
||||
std::result::Result::Err(error) => {
|
||||
ksp_logging_lib::warn!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT,
|
||||
error_domain = error.code().domain(),
|
||||
error_code = error.code().code(),
|
||||
"Wallet Desk Off-chain Transport is unavailable; SOL/USD average will be N.A."
|
||||
);
|
||||
std::option::Option::None
|
||||
},
|
||||
};
|
||||
let wallet_config_startup = crate::initialize_wallet_config(&config_management);
|
||||
let wallet_config_startup = match wallet_config_startup {
|
||||
std::result::Result::Ok(value) => value,
|
||||
@@ -66,6 +81,7 @@ impl AppState {
|
||||
fallback_active: logging_startup.fallback_active,
|
||||
startup_diagnostic: logging_startup.startup_diagnostic,
|
||||
}),
|
||||
offchain_runtime,
|
||||
splash_settings,
|
||||
splash_sequence_started: std::sync::atomic::AtomicBool::new(false),
|
||||
transport_runtime,
|
||||
@@ -1126,8 +1142,10 @@ impl AppState {
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed),
|
||||
std::option::Option::None,
|
||||
);
|
||||
ksp_logging_lib::debug!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_TRANSPORT, wallet_id = context.wallet_id.as_str(), capability = context.capability, transport_profile = self.transport_runtime.profile_id(), transport_role = self.transport_runtime.role().as_str(), "Wallet balance refresh started");
|
||||
let result = self.transport_runtime.pool().get_balance(self.transport_runtime.role(), &context.pubkey, std::option::Option::Some(&config)).await;
|
||||
ksp_logging_lib::debug!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_TRANSPORT, wallet_id = context.wallet_id.as_str(), capability = context.capability, transport_profile = self.transport_runtime.profile_id(), transport_role = self.transport_runtime.role().as_str(), "Wallet balance and auxiliary SOL/USD refresh started");
|
||||
let balance_future = self.transport_runtime.pool().get_balance(self.transport_runtime.role(), &context.pubkey, std::option::Option::Some(&config));
|
||||
let price_future = self.refresh_sol_usd_average();
|
||||
let (result, sol_usd_average) = tokio::join!(balance_future, price_future);
|
||||
let result = match result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
@@ -1142,19 +1160,59 @@ impl AppState {
|
||||
));
|
||||
}
|
||||
let lamports = result.value();
|
||||
let sol_usd_equivalent = crate::sol_usd_equivalent(lamports, sol_usd_average.as_deref());
|
||||
let dto = crate::WalletBalanceDto {
|
||||
api_version: result.context().api_version().map(str::to_owned),
|
||||
lamports: lamports.to_string(),
|
||||
slot: result.context().slot(),
|
||||
sol: crate::format_lamports_as_sol(lamports),
|
||||
sol_usd_average,
|
||||
sol_usd_equivalent,
|
||||
transport_profile: self.transport_runtime.profile_id().to_owned(),
|
||||
transport_role: self.transport_runtime.role().as_str().to_owned(),
|
||||
wallet_id: context.wallet_id.clone(),
|
||||
};
|
||||
ksp_logging_lib::info!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_TRANSPORT, wallet_id = context.wallet_id.as_str(), capability = context.capability, lamports, slot = dto.slot, "Wallet balance refresh succeeded");
|
||||
ksp_logging_lib::info!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_TRANSPORT, wallet_id = context.wallet_id.as_str(), capability = context.capability, lamports, slot = dto.slot, sol_usd_average_available = dto.sol_usd_average.is_some(), sol_usd_equivalent_available = dto.sol_usd_equivalent.is_some(), "Wallet balance refresh succeeded");
|
||||
return std::result::Result::Ok(dto);
|
||||
}
|
||||
|
||||
async fn refresh_sol_usd_average(&self) -> std::option::Option<String> {
|
||||
let runtime = match &self.offchain_runtime {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
let outcomes = runtime.service().refresh_all().await;
|
||||
let outcomes = match outcomes {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
ksp_logging_lib::warn!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT,
|
||||
offchain_profile = runtime.profile_id(),
|
||||
error_domain = error.code().domain(),
|
||||
error_code = error.code().code(),
|
||||
"Wallet Desk SOL/USD provider refresh failed; average is unavailable"
|
||||
);
|
||||
return std::option::Option::None;
|
||||
},
|
||||
};
|
||||
let prices = outcomes
|
||||
.iter()
|
||||
.filter_map(|outcome| return outcome.observation().map(|observation| return observation.price()))
|
||||
.collect::<std::vec::Vec<_>>();
|
||||
let average = crate::average_sol_usd_prices(prices.as_slice());
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT,
|
||||
offchain_profile = runtime.profile_id(),
|
||||
provider_count = outcomes.len(),
|
||||
observed_provider_count = prices.len(),
|
||||
average_available = average.is_some(),
|
||||
"Wallet Desk SOL/USD refresh completed"
|
||||
);
|
||||
return average;
|
||||
}
|
||||
|
||||
fn authorized_balance_context(&self) -> ksp_core_lib::Result<WalletBalanceContext> {
|
||||
let session = self.wallet_session.lock();
|
||||
let session = match session {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
// file: crates/ksp-app-wallet-desk/src/constants.rs
|
||||
// version: 8
|
||||
// version: 9
|
||||
|
||||
//! Logging targets, domains and composite component identifiers owned by Wallet Desk.
|
||||
|
||||
/// Composite-local identifier for the Logging standard document.
|
||||
pub(crate) const COMPOSITE_COMPONENT_ID_LOGGING: &str = "logging";
|
||||
/// Composite-local identifier for the Off-chain Transport standard document.
|
||||
pub(crate) const COMPOSITE_COMPONENT_ID_OFFCHAIN_TRANSPORT: &str = "offchain_transport";
|
||||
/// Composite-local identifier for the HTTP Transport standard document.
|
||||
pub(crate) const COMPOSITE_COMPONENT_ID_TRANSPORT: &str = "transport";
|
||||
/// Composite-local identifier for the Wallet standard document.
|
||||
@@ -13,6 +15,8 @@ pub(crate) const COMPOSITE_COMPONENT_ID_WALLET: &str = "wallet";
|
||||
pub(crate) const TRACING_DOMAIN_BOOTSTRAP: &str = "wallet.bootstrap";
|
||||
/// Structured domain used by technical frontend events.
|
||||
pub(crate) const TRACING_DOMAIN_FRONTEND: &str = "frontend";
|
||||
/// Structured domain used by Wallet Desk Off-chain Transport and SOL/USD average operations.
|
||||
pub(crate) const TRACING_DOMAIN_OFFCHAIN_TRANSPORT: &str = "wallet.offchain_transport";
|
||||
/// Structured domain used by the Wallet Desk shell.
|
||||
pub(crate) const TRACING_DOMAIN_SHELL: &str = "wallet.shell";
|
||||
/// Structured domain used by Wallet Desk HTTP Transport and balance operations.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-wallet-desk/src/lib.rs
|
||||
// version: 13
|
||||
// version: 15
|
||||
|
||||
//! Tauri desktop application shell for KSP Wallet management and inspection.
|
||||
|
||||
@@ -14,6 +14,7 @@ mod dto_common;
|
||||
mod errors;
|
||||
mod frontend_logging;
|
||||
mod logging_runtime;
|
||||
mod offchain_runtime;
|
||||
mod splash;
|
||||
mod tauri;
|
||||
mod transport_runtime;
|
||||
@@ -47,6 +48,8 @@ pub(crate) use self::bootstrap::load_wallet_desk_composite;
|
||||
pub(crate) use self::bootstrap::required_composite_component_profile;
|
||||
/// Composite-local identifier for the Logging standard document.
|
||||
pub(crate) use self::constants::COMPOSITE_COMPONENT_ID_LOGGING;
|
||||
/// Composite-local identifier for the Off-chain Transport standard document.
|
||||
pub(crate) use self::constants::COMPOSITE_COMPONENT_ID_OFFCHAIN_TRANSPORT;
|
||||
/// Composite-local identifier for the HTTP Transport standard document.
|
||||
pub(crate) use self::constants::COMPOSITE_COMPONENT_ID_TRANSPORT;
|
||||
/// Composite-local identifier for the Wallet standard document.
|
||||
@@ -55,6 +58,8 @@ pub(crate) use self::constants::COMPOSITE_COMPONENT_ID_WALLET;
|
||||
pub(crate) use self::constants::TRACING_DOMAIN_BOOTSTRAP;
|
||||
/// Structured domain used by technical frontend events.
|
||||
pub(crate) use self::constants::TRACING_DOMAIN_FRONTEND;
|
||||
/// Structured domain used by Wallet Desk Off-chain Transport and SOL/USD average operations.
|
||||
pub(crate) use self::constants::TRACING_DOMAIN_OFFCHAIN_TRANSPORT;
|
||||
/// Structured domain used by the Wallet Desk shell.
|
||||
pub(crate) use self::constants::TRACING_DOMAIN_SHELL;
|
||||
/// Structured domain used by Wallet Desk HTTP Transport and balance operations.
|
||||
@@ -137,6 +142,10 @@ pub(crate) use self::frontend_logging::FrontendLogPayloadDto;
|
||||
pub(crate) use self::frontend_logging::emit_frontend_log_event;
|
||||
/// Creates the stable runtime identity for this Wallet Desk process launch.
|
||||
pub(crate) use self::logging_runtime::launch_identity;
|
||||
/// Provider-neutral Off-chain Transport runtime used by the Wallet balance projection.
|
||||
pub(crate) use self::offchain_runtime::OffchainTransportRuntime;
|
||||
/// Resolves the optional composite-selected Off-chain Transport runtime.
|
||||
pub(crate) use self::offchain_runtime::initialize_offchain_transport;
|
||||
/// Command emitted by Rust to the splash frontend.
|
||||
pub(crate) use self::splash::SplashOrderDto;
|
||||
/// Runtime timings used by the common desk splash lifecycle.
|
||||
@@ -155,8 +164,12 @@ pub(crate) use self::tw_splash::require_splash_window;
|
||||
pub(crate) use self::tw_splash::splash_frontend_ready_service;
|
||||
/// Safe balance snapshot for the currently authorized Wallet.
|
||||
pub(crate) use self::wallet_balance::WalletBalanceDto;
|
||||
/// Computes the Wallet Desk SOL/USD arithmetic mean without floating point.
|
||||
pub(crate) use self::wallet_balance::average_sol_usd_prices;
|
||||
/// Formats exact lamports as a decimal SOL string without floating point.
|
||||
pub(crate) use self::wallet_balance::format_lamports_as_sol;
|
||||
/// Computes the exact USD equivalent of a lamport balance from the auxiliary SOL/USD average.
|
||||
pub(crate) use self::wallet_balance::sol_usd_equivalent;
|
||||
/// Resolved Wallet Config and directory preparation status captured during application bootstrap.
|
||||
pub(crate) use self::wallet_config::WalletConfigStartup;
|
||||
/// Resolves the composite-selected Wallet Config and prepares its application-owned directory tree.
|
||||
|
||||
60
crates/ksp-app-wallet-desk/src/offchain_runtime.rs
Normal file
60
crates/ksp-app-wallet-desk/src/offchain_runtime.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
// file: crates/ksp-app-wallet-desk/src/offchain_runtime.rs
|
||||
// version: 1
|
||||
|
||||
//! Optional composite-selected Off-chain Transport runtime used by Wallet Desk SOL/USD presentation.
|
||||
|
||||
/// Safe provider-neutral Off-chain Transport runtime retained by Wallet Desk when Config resolution succeeds.
|
||||
pub(crate) struct OffchainTransportRuntime {
|
||||
resolved: ksp_config_lib::ResolvedOffchainTransportConfig,
|
||||
}
|
||||
|
||||
impl OffchainTransportRuntime {
|
||||
/// Returns the generic market-price service built and owned by Config.
|
||||
#[must_use]
|
||||
pub(crate) const fn service(&self) -> &ksp_offchain_transport_lib::MarketPriceService {
|
||||
return self.resolved.service();
|
||||
}
|
||||
|
||||
/// Returns the composite-selected Off-chain Transport profile identifier.
|
||||
#[must_use]
|
||||
pub(crate) fn profile_id(&self) -> &str {
|
||||
return self.resolved.profile_id();
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the Wallet Desk Off-chain Transport component through Config.
|
||||
pub(crate) fn initialize_offchain_transport(management: &ksp_config_lib::ConfigManagement) -> ksp_core_lib::Result<OffchainTransportRuntime> {
|
||||
let environment = ksp_config_lib::ConfigEnvironment::load();
|
||||
let environment = match environment {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let composite = crate::load_wallet_desk_composite(management);
|
||||
let composite = match composite {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let profile = crate::required_composite_component_profile(
|
||||
&composite,
|
||||
crate::COMPOSITE_COMPONENT_ID_OFFCHAIN_TRANSPORT,
|
||||
ksp_config_lib::FILE_ID_STD_OFFCHAIN_TRANSPORT,
|
||||
);
|
||||
let profile = match profile {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let resolved = management.engine().resolve_offchain_transport_config_profile(&profile, &environment);
|
||||
let resolved = match resolved {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let provider_count = resolved.service().registry().len();
|
||||
ksp_logging_lib::info!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT,
|
||||
offchain_profile = resolved.profile_id(),
|
||||
provider_count,
|
||||
"initialized Wallet Desk Off-chain Transport from composite-managed configuration"
|
||||
);
|
||||
return std::result::Result::Ok(OffchainTransportRuntime { resolved });
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-wallet-desk/src/wallet_balance.rs
|
||||
// version: 1
|
||||
// version: 4
|
||||
|
||||
//! Safe Wallet balance projection produced by the typed HTTP Transport path.
|
||||
|
||||
@@ -18,6 +18,10 @@ pub(crate) struct WalletBalanceDto {
|
||||
pub(crate) slot: u64,
|
||||
/// Exact decimal SOL presentation derived from lamports without floating point.
|
||||
pub(crate) sol: String,
|
||||
/// Arithmetic mean of successful SOL/USD observations produced by this refresh, or unavailable when no provider returned one.
|
||||
pub(crate) sol_usd_average: std::option::Option<String>,
|
||||
/// Exact USD equivalent of the current SOL balance using `sol_usd_average`, or unavailable when the auxiliary price is unavailable.
|
||||
pub(crate) sol_usd_equivalent: std::option::Option<String>,
|
||||
/// Composite-selected Transport profile used for the RPC.
|
||||
pub(crate) transport_profile: String,
|
||||
/// Logical Transport role used for the RPC.
|
||||
@@ -34,6 +38,150 @@ pub(crate) fn format_lamports_as_sol(lamports: u64) -> String {
|
||||
return format!("{whole}.{fractional:09}");
|
||||
}
|
||||
|
||||
/// Computes a deterministic SOL/USD arithmetic mean without IEEE-754 conversion.
|
||||
///
|
||||
/// The output is rounded half-up to at most eight fractional digits and trailing zeroes are removed. Arithmetic overflow degrades to `None`, which Wallet Desk
|
||||
/// renders as `N.A.` without affecting the authoritative SOL balance.
|
||||
#[must_use]
|
||||
pub(crate) fn average_sol_usd_prices(prices: &[ksp_offchain_transport_lib::MarketPriceDecimal]) -> std::option::Option<String> {
|
||||
const OUTPUT_SCALE: u8 = 8;
|
||||
if prices.is_empty() {
|
||||
return std::option::Option::None;
|
||||
}
|
||||
let max_scale = prices.iter().map(ksp_offchain_transport_lib::MarketPriceDecimal::scale).max();
|
||||
let max_scale = match max_scale {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
let mut sum = 0_u128;
|
||||
for price in prices {
|
||||
let exponent = u32::from(max_scale.saturating_sub(price.scale()));
|
||||
let factor = 10_u128.checked_pow(exponent);
|
||||
let factor = match factor {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
let coefficient = price.coefficient().checked_mul(factor);
|
||||
let coefficient = match coefficient {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
sum = match sum.checked_add(coefficient) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
}
|
||||
let count = match u128::try_from(prices.len()) {
|
||||
std::result::Result::Ok(value) if value > 0 => value,
|
||||
_ => return std::option::Option::None,
|
||||
};
|
||||
let target_scale = OUTPUT_SCALE;
|
||||
let (numerator, denominator) = if max_scale <= target_scale {
|
||||
let factor = 10_u128.checked_pow(u32::from(target_scale - max_scale));
|
||||
let factor = match factor {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
let numerator = match sum.checked_mul(factor) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
(numerator, count)
|
||||
} else {
|
||||
let factor = 10_u128.checked_pow(u32::from(max_scale - target_scale));
|
||||
let factor = match factor {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
let denominator = match count.checked_mul(factor) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
(sum, denominator)
|
||||
};
|
||||
let quotient = numerator / denominator;
|
||||
let remainder = numerator % denominator;
|
||||
let round_up = remainder >= denominator.div_ceil(2);
|
||||
let coefficient = if round_up {
|
||||
match quotient.checked_add(1) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
}
|
||||
} else {
|
||||
quotient
|
||||
};
|
||||
return std::option::Option::Some(format_scaled_decimal(coefficient, target_scale));
|
||||
}
|
||||
|
||||
/// Computes the exact USD equivalent of a lamport balance from the current SOL/USD average.
|
||||
///
|
||||
/// The output is rounded half-up to at most eight fractional digits. Missing or invalid auxiliary price data and arithmetic overflow degrade to `None`; the
|
||||
/// authoritative SOL balance remains unaffected.
|
||||
#[must_use]
|
||||
pub(crate) fn sol_usd_equivalent(lamports: u64, sol_usd_average: std::option::Option<&str>) -> std::option::Option<String> {
|
||||
const LAMPORT_SCALE: u8 = 9;
|
||||
const OUTPUT_SCALE: u8 = 8;
|
||||
let source = match sol_usd_average {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
let average = match ksp_offchain_transport_lib::MarketPriceDecimal::parse(source) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
let coefficient = match u128::from(lamports).checked_mul(average.coefficient()) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
let source_scale = u16::from(LAMPORT_SCALE) + u16::from(average.scale());
|
||||
let target_scale = u16::from(OUTPUT_SCALE);
|
||||
let rounded_coefficient = if source_scale <= target_scale {
|
||||
let exponent = u32::from(target_scale - source_scale);
|
||||
let factor = match 10_u128.checked_pow(exponent) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
match coefficient.checked_mul(factor) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
}
|
||||
} else {
|
||||
let exponent = u32::from(source_scale - target_scale);
|
||||
let divisor = match 10_u128.checked_pow(exponent) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
let quotient = coefficient / divisor;
|
||||
let remainder = coefficient % divisor;
|
||||
if remainder >= divisor.div_ceil(2) {
|
||||
match quotient.checked_add(1) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
}
|
||||
} else {
|
||||
quotient
|
||||
}
|
||||
};
|
||||
return std::option::Option::Some(format_scaled_decimal(rounded_coefficient, OUTPUT_SCALE));
|
||||
}
|
||||
|
||||
fn format_scaled_decimal(mut coefficient: u128, mut scale: u8) -> String {
|
||||
while scale > 0 && coefficient.is_multiple_of(10) {
|
||||
coefficient /= 10;
|
||||
scale -= 1;
|
||||
}
|
||||
let digits = coefficient.to_string();
|
||||
if scale == 0 {
|
||||
return digits;
|
||||
}
|
||||
let scale = usize::from(scale);
|
||||
if digits.len() > scale {
|
||||
let split = digits.len() - scale;
|
||||
return format!("{}.{}", &digits[..split], &digits[split..]);
|
||||
}
|
||||
return format!("0.{}{}", "0".repeat(scale - digits.len()), digits);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/wallet_balance.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "KSP Wallet Desk",
|
||||
"version": "0.2.6",
|
||||
"identifier": "com.sasedev.ksp-app-wallet-desk",
|
||||
"build": {
|
||||
"beforeDevCommand": {
|
||||
@@ -53,12 +52,17 @@
|
||||
"icons/favicon.ico"
|
||||
],
|
||||
"resources": {
|
||||
"../../config/composite.ksp-app-solprices-desk.json": "config/composite.ksp-app-solprices-desk.json",
|
||||
"../../config/composite.ksp-app-wallet-desk.json": "config/composite.ksp-app-wallet-desk.json",
|
||||
"../../config/std.logging.json": "config/std.logging.json",
|
||||
"../../config/std.offchain_transport.json": "config/std.offchain_transport.json",
|
||||
"../../config/std.store.json": "config/std.store.json",
|
||||
"../../config/std.transport.json": "config/std.transport.json",
|
||||
"../../config/std.wallet.json": "config/std.wallet.json",
|
||||
"../../config/schemas/composite.schema.json": "config/schemas/composite.schema.json",
|
||||
"../../config/schemas/std.logging.schema.json": "config/schemas/std.logging.schema.json",
|
||||
"../../config/schemas/std.offchain_transport.schema.json": "config/schemas/std.offchain_transport.schema.json",
|
||||
"../../config/schemas/std.store.schema.json": "config/schemas/std.store.schema.json",
|
||||
"../../config/schemas/std.transport.schema.json": "config/schemas/std.transport.schema.json",
|
||||
"../../config/schemas/std.wallet.schema.json": "config/schemas/std.wallet.schema.json"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-wallet-desk/tests/config_composition.rs
|
||||
// version: 7
|
||||
// version: 8
|
||||
|
||||
//! Config composition contracts introduced for Wallet Desk pre.003.
|
||||
|
||||
@@ -18,7 +18,7 @@ fn workspace_engine() -> ksp_core_lib::Result<ksp_config_lib::ConfigDocumentEngi
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wallet_desk_composite_selects_logging_transport_and_wallet_by_file_id() {
|
||||
fn wallet_desk_composite_selects_logging_offchain_transport_transport_and_wallet_by_file_id() {
|
||||
let engine = workspace_engine();
|
||||
assert!(engine.is_ok(), "workspace Config engine should be constructible: {engine:?}");
|
||||
if let std::result::Result::Ok(engine) = engine {
|
||||
@@ -31,6 +31,7 @@ fn wallet_desk_composite_selects_logging_transport_and_wallet_by_file_id() {
|
||||
assert_eq!(composite.profile_id(), "devnet");
|
||||
assert_component(&composite, "logging", ksp_config_lib::FILE_ID_STD_LOGGING, "supertrace");
|
||||
assert_wallet_desk_supertrace_profile(&composite);
|
||||
assert_component(&composite, "offchain_transport", ksp_config_lib::FILE_ID_STD_OFFCHAIN_TRANSPORT, "public_keyless");
|
||||
assert_component(&composite, "transport", ksp_config_lib::FILE_ID_STD_TRANSPORT, "devnet_public");
|
||||
assert_component(&composite, "wallet", ksp_config_lib::FILE_ID_STD_WALLET, "default");
|
||||
}
|
||||
@@ -172,3 +173,42 @@ fn wallet_desk_composite_transport_profile_builds_get_balance_pool() {
|
||||
assert!(selection.is_ok(), "getBalance should route through the composite-selected Transport pool: {selection:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wallet_desk_composite_offchain_profile_builds_provider_neutral_service() {
|
||||
let engine = workspace_engine();
|
||||
assert!(engine.is_ok(), "workspace Config engine should be constructible: {engine:?}");
|
||||
let engine = match engine {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let file_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK);
|
||||
let file_id = match file_id {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let composite = engine.load_resolved_composite(&file_id, std::option::Option::None);
|
||||
let composite = match composite {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let component = composite.component("offchain_transport");
|
||||
assert!(component.is_some(), "Wallet Desk composite should expose Off-chain Transport");
|
||||
let component = match component {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let environment = ksp_config_lib::ConfigEnvironment::load();
|
||||
assert!(environment.is_ok(), "public Config environment snapshot should load");
|
||||
let environment = match environment {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let resolved = engine.resolve_offchain_transport_config_profile(component.resolved(), &environment);
|
||||
assert!(resolved.is_ok(), "composite-selected Off-chain Transport should map");
|
||||
if let std::result::Result::Ok(resolved) = resolved {
|
||||
assert_eq!(resolved.profile_id(), "public_keyless");
|
||||
assert_eq!(resolved.selection_source(), ksp_config_lib::ConfigProfileSelectionSource::Composite);
|
||||
assert_eq!(resolved.service().registry().len(), 8);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-wallet-desk/tests/desktop_contract.rs
|
||||
// version: 22
|
||||
// version: 31
|
||||
|
||||
//! Desktop build, shell and Config-status contract audits for Wallet Desk.
|
||||
|
||||
@@ -31,43 +31,6 @@ fn read_json(path: &std::path::Path) -> serde_json::Value {
|
||||
};
|
||||
}
|
||||
|
||||
fn parse_semver_core(value: &str) -> std::option::Option<(u64, u64, u64)> {
|
||||
let core = match value.split_once('-') {
|
||||
std::option::Option::Some((core, _)) => core,
|
||||
std::option::Option::None => value,
|
||||
};
|
||||
let mut parts = core.split('.');
|
||||
let major = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
let minor = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
let patch = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
if parts.next().is_some() {
|
||||
return std::option::Option::None;
|
||||
}
|
||||
return std::option::Option::Some((major, minor, patch));
|
||||
}
|
||||
|
||||
fn assert_packaged_version_floor(value: std::option::Option<&str>, field: &str) {
|
||||
assert!(value.is_some(), "{field} packaged version must exist");
|
||||
let value = match value {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let parsed = parse_semver_core(value);
|
||||
assert!(parsed.is_some(), "{field} packaged version must be SemVer-like");
|
||||
if let std::option::Option::Some(parsed) = parsed {
|
||||
assert!(parsed >= (0, 2, 6), "{field} packaged version must be >= 0.2.6");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tauri_shell_uses_reserved_wallet_desk_ports_and_template_windows() {
|
||||
let root = app_root();
|
||||
@@ -77,6 +40,10 @@ fn tauri_shell_uses_reserved_wallet_desk_ports_and_template_windows() {
|
||||
assert_eq!(tauri.pointer("/build/beforeDevCommand/cwd").and_then(serde_json::Value::as_str), std::option::Option::Some("."));
|
||||
assert_eq!(tauri.pointer("/build/beforeBuildCommand/script").and_then(serde_json::Value::as_str), std::option::Option::Some("npm run build"));
|
||||
assert_eq!(tauri.pointer("/build/beforeBuildCommand/cwd").and_then(serde_json::Value::as_str), std::option::Option::Some("."));
|
||||
assert_eq!(
|
||||
tauri.pointer("/build/frontendDist").and_then(serde_json::Value::as_str),
|
||||
std::option::Option::Some("../../../builds/khadhroony-solana-project/ksp-app-wallet-desk/dist"),
|
||||
);
|
||||
assert_eq!(tauri.pointer("/app/security/csp"), std::option::Option::Some(&serde_json::Value::Null));
|
||||
let manifest = read_text(root.join("Cargo.toml").as_path());
|
||||
assert!(manifest.contains("name = \"ksp-app-wallet-desk\""));
|
||||
@@ -459,20 +426,17 @@ fn pre_017_wallet_desk_open_paths_remain_non_migrating() {
|
||||
fn pre_018_packaged_runtime_bundles_config_resources_and_keeps_wallet_desk_version_coherent() {
|
||||
let root = app_root();
|
||||
let tauri = read_json(root.join("tauri.conf.json").as_path());
|
||||
let tauri_version = tauri.pointer("/version").and_then(serde_json::Value::as_str);
|
||||
assert_packaged_version_floor(tauri_version, "tauri.conf.json");
|
||||
assert!(tauri.pointer("/version").is_none(), "Wallet Desk must let Tauri derive the bundle version from Cargo");
|
||||
let package = read_json(root.join("package.json").as_path());
|
||||
let package_version = package.pointer("/version").and_then(serde_json::Value::as_str);
|
||||
assert_packaged_version_floor(package_version, "package.json");
|
||||
assert_eq!(tauri_version, package_version, "desktop package metadata versions must remain synchronized");
|
||||
let packaged_version = match package_version {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
assert_eq!(package.pointer("/private").and_then(serde_json::Value::as_bool), std::option::Option::Some(true));
|
||||
let resources = tauri.pointer("/bundle/resources").and_then(serde_json::Value::as_object);
|
||||
assert!(resources.is_some(), "packaged Wallet Desk Config resources map must exist");
|
||||
if let std::option::Option::Some(resources) = resources {
|
||||
assert_eq!(resources.len(), 8);
|
||||
assert_eq!(resources.len(), 13);
|
||||
assert_eq!(
|
||||
resources.get("../../config/composite.ksp-app-solprices-desk.json").and_then(serde_json::Value::as_str),
|
||||
std::option::Option::Some("config/composite.ksp-app-solprices-desk.json"),
|
||||
);
|
||||
assert_eq!(
|
||||
resources.get("../../config/composite.ksp-app-wallet-desk.json").and_then(serde_json::Value::as_str),
|
||||
std::option::Option::Some("config/composite.ksp-app-wallet-desk.json"),
|
||||
@@ -481,11 +445,58 @@ fn pre_018_packaged_runtime_bundles_config_resources_and_keeps_wallet_desk_versi
|
||||
resources.get("../../config/schemas/composite.schema.json").and_then(serde_json::Value::as_str),
|
||||
std::option::Option::Some("config/schemas/composite.schema.json"),
|
||||
);
|
||||
assert_eq!(
|
||||
resources.get("../../config/std.offchain_transport.json").and_then(serde_json::Value::as_str),
|
||||
std::option::Option::Some("config/std.offchain_transport.json"),
|
||||
);
|
||||
assert_eq!(resources.get("../../config/std.store.json").and_then(serde_json::Value::as_str), std::option::Option::Some("config/std.store.json"),);
|
||||
assert_eq!(
|
||||
resources.get("../../config/schemas/std.store.schema.json").and_then(serde_json::Value::as_str),
|
||||
std::option::Option::Some("config/schemas/std.store.schema.json"),
|
||||
);
|
||||
assert_eq!(
|
||||
resources.get("../../config/schemas/std.offchain_transport.schema.json").and_then(serde_json::Value::as_str),
|
||||
std::option::Option::Some("config/schemas/std.offchain_transport.schema.json"),
|
||||
);
|
||||
}
|
||||
let main = read_text(root.join("frontend/main.html").as_path());
|
||||
assert!(main.contains(packaged_version));
|
||||
let main_ts = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||
assert!(main.contains(r#"id="appVersionBadge""#));
|
||||
assert!(main_ts.contains("setText(\"#appVersionBadge\", status.applicationVersion);"));
|
||||
let tauri_source = read_text(root.join("src/tauri.rs").as_path());
|
||||
assert!(tauri_source.contains("ksp_config_lib::prepare_packaged_runtime"));
|
||||
assert!(tauri_source.contains("tauri::utils::platform::resource_dir"));
|
||||
assert!(tauri_source.contains("std::env::set_current_dir(layout.runtime_root())"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v0_2_12_pre_008_wallet_balance_refresh_adds_average_and_exact_usd_equivalent() {
|
||||
let root = app_root();
|
||||
let manifest = read_text(root.join("Cargo.toml").as_path());
|
||||
let composite = read_text(root.join("../../config/composite.ksp-app-wallet-desk.json").as_path());
|
||||
let state = read_text(root.join("src/app_state.rs").as_path());
|
||||
let balance = read_text(root.join("src/wallet_balance.rs").as_path());
|
||||
let main = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||
let html = read_text(root.join("frontend/main.html").as_path());
|
||||
assert!(manifest.contains(r#"ksp-offchain-transport-lib = { path = "../ksp-offchain-transport-lib" }"#));
|
||||
assert!(composite.contains(r#""component_id": "offchain_transport""#));
|
||||
assert!(composite.contains(r#""profile_id": "public_keyless""#));
|
||||
assert!(state.contains("initialize_offchain_transport"));
|
||||
assert!(state.contains("refresh_sol_usd_average"));
|
||||
assert!(state.contains("tokio::join!"));
|
||||
assert!(state.contains("refresh_all().await"));
|
||||
assert!(balance.contains("sol_usd_average"));
|
||||
assert!(balance.contains("sol_usd_equivalent"));
|
||||
assert!(balance.contains("average_sol_usd_prices"));
|
||||
assert!(html.contains(r#"id="currentWalletSolUsdAverage""#));
|
||||
assert!(html.contains(r#"id="currentWalletSolUsdEquivalent""#));
|
||||
assert!(html.contains(r#"id="detailsSolUsdAverage""#));
|
||||
assert!(html.contains(r#"id="detailsSolUsdEquivalent""#));
|
||||
assert!(main.contains(r#"balance.solUsdAverage ? `${balance.solUsdAverage} USD/SOL` : "N.A.""#));
|
||||
assert!(main.contains(r#"balance.solUsdEquivalent ? `${balance.solUsdEquivalent} USD` : "N.A.""#));
|
||||
assert!(!main.contains("parseFloat("));
|
||||
assert!(!main.contains("Number(balance.sol"));
|
||||
assert!(!main.contains("refresh_market_price"));
|
||||
assert!(!main.contains("refresh_all_market_prices"));
|
||||
assert!(!main.contains("fetch("));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-wallet-desk/tests/desktop_security.rs
|
||||
// version: 14
|
||||
// version: 15
|
||||
|
||||
//! Static desktop security contracts for the Wallet Desk pre.002 shell.
|
||||
|
||||
@@ -122,9 +122,14 @@ fn balance_refresh_never_accepts_pubkey_or_endpoint_url_from_frontend() {
|
||||
let refresh = &main[refresh_start.unwrap_or_default()..refresh_end.unwrap_or(main.len())];
|
||||
assert!(refresh.contains("refresh_wallet_balance"));
|
||||
assert!(!refresh.contains("pubkey"));
|
||||
assert!(!refresh.contains("providerId"));
|
||||
assert!(!refresh.contains("providerIds"));
|
||||
assert!(!main.contains("transportUrl"));
|
||||
assert!(!main.contains("endpointUrl"));
|
||||
assert!(state.contains("wallet.pubkey()"));
|
||||
assert!(state.contains("refresh_all().await"));
|
||||
assert!(state.contains("average_sol_usd_prices"));
|
||||
assert!(!state.contains("parse::<f64>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-wallet-desk/tests/release_compliance.rs
|
||||
// version: 5
|
||||
// version: 10
|
||||
|
||||
//! Release-wide deterministic compliance canaries for Wallet Desk.
|
||||
|
||||
@@ -81,6 +81,7 @@ fn backend_keeps_config_wallet_transport_and_logging_ownership_boundaries() {
|
||||
assert!(source.contains("ksp_config_lib::ConfigEnvironment::load"));
|
||||
assert!(source.contains("ksp_wallet_lib::"));
|
||||
assert!(source.contains("ksp_onchain_transport_lib::"));
|
||||
assert!(source.contains("ksp_offchain_transport_lib::"));
|
||||
assert!(source.contains("ksp_logging_lib::"));
|
||||
}
|
||||
|
||||
@@ -198,7 +199,7 @@ fn packaged_resources_include_only_registered_config_sources_and_schemas() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
assert_eq!(resources.len(), 8);
|
||||
assert_eq!(resources.len(), 13);
|
||||
for (source, destination) in resources {
|
||||
let destination = destination.as_str();
|
||||
assert!(destination.is_some(), "resource destination must be textual");
|
||||
@@ -214,3 +215,29 @@ fn packaged_resources_include_only_registered_config_sources_and_schemas() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v0_2_12_wallet_market_price_projection_remains_provider_neutral_and_exact() {
|
||||
let root = app_root();
|
||||
let source = read_rust_source_tree(root.join("src").as_path());
|
||||
for forbidden in [
|
||||
"MarketPriceBirdeye",
|
||||
"MarketPriceCoinbaseExchange",
|
||||
"MarketPriceCoinGecko",
|
||||
"MarketPriceCoinMarketCap",
|
||||
"MarketPriceCoinPaprika",
|
||||
"MarketPriceDexScreener",
|
||||
"MarketPriceJupiter",
|
||||
"MarketPriceKraken",
|
||||
] {
|
||||
assert!(!source.contains(forbidden), "Wallet Desk must not branch on concrete market-price providers: {forbidden}");
|
||||
}
|
||||
let balance = read_text(root.join("src/wallet_balance.rs").as_path());
|
||||
assert!(!balance.contains("f64"));
|
||||
assert!(!balance.contains("f32"));
|
||||
let main = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||
assert!(!main.contains("parseFloat("));
|
||||
assert!(!main.contains("Number(balance.sol"));
|
||||
assert!(!main.contains("providerId"));
|
||||
assert!(!main.contains("refresh_market_price"));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-wallet-desk/unit_tests/wallet_balance.rs
|
||||
// version: 1
|
||||
// version: 3
|
||||
|
||||
//! Unit tests for exact Wallet balance presentation.
|
||||
|
||||
@@ -11,3 +11,33 @@ fn lamports_formatting_is_exact_and_uses_nine_fractional_digits() {
|
||||
assert_eq!(crate::format_lamports_as_sol(1_234_567_890), "1.234567890");
|
||||
assert_eq!(crate::format_lamports_as_sol(u64::MAX), "18446744073.709551615");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sol_usd_average_is_deterministic_rounded_and_never_uses_f64() {
|
||||
let values = ["103.96", "104.16", "104.07981394241013", "104.07595797523913", "103.92807280061784", "103.9"];
|
||||
let prices = values.iter().filter_map(|value| return ksp_offchain_transport_lib::MarketPriceDecimal::parse(value).ok()).collect::<std::vec::Vec<_>>();
|
||||
assert_eq!(prices.len(), values.len());
|
||||
assert_eq!(crate::average_sol_usd_prices(prices.as_slice()), std::option::Option::Some("104.01730745".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sol_usd_average_returns_none_without_observation_and_handles_integer_fraction() {
|
||||
assert_eq!(crate::average_sol_usd_prices(&[]), std::option::Option::None);
|
||||
let one = ksp_offchain_transport_lib::MarketPriceDecimal::parse("1");
|
||||
let two = ksp_offchain_transport_lib::MarketPriceDecimal::parse("2");
|
||||
assert!(one.is_ok());
|
||||
assert!(two.is_ok());
|
||||
if let (std::result::Result::Ok(one), std::result::Result::Ok(two)) = (one, two) {
|
||||
assert_eq!(crate::average_sol_usd_prices(&[one, two]), std::option::Option::Some("1.5".to_owned()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sol_usd_equivalent_is_exact_rounded_and_unavailable_without_average() {
|
||||
assert_eq!(crate::sol_usd_equivalent(0, std::option::Option::Some("104.01730745")), std::option::Option::Some("0".to_owned()));
|
||||
assert_eq!(crate::sol_usd_equivalent(1_000_000_000, std::option::Option::Some("104.01730745")), std::option::Option::Some("104.01730745".to_owned()),);
|
||||
assert_eq!(crate::sol_usd_equivalent(1_500_000_000, std::option::Option::Some("104.01730745")), std::option::Option::Some("156.02596118".to_owned()),);
|
||||
assert_eq!(crate::sol_usd_equivalent(1, std::option::Option::Some("104.01730745")), std::option::Option::Some("0.0000001".to_owned()));
|
||||
assert_eq!(crate::sol_usd_equivalent(1_000_000_000, std::option::Option::None), std::option::Option::None);
|
||||
assert_eq!(crate::sol_usd_equivalent(1_000_000_000, std::option::Option::Some("invalid")), std::option::Option::None);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# file: crates/ksp-config-lib/Cargo.toml
|
||||
# version: 7
|
||||
# version: 10
|
||||
|
||||
[package]
|
||||
name = "ksp-config-lib"
|
||||
@@ -9,12 +9,14 @@ repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
directories.workspace = true
|
||||
jsonschema.workspace = true
|
||||
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||
ksp-logging-lib = { path = "../ksp-logging-lib" }
|
||||
ksp-offchain-transport-lib = { path = "../ksp-offchain-transport-lib" }
|
||||
ksp-onchain-transport-lib = { path = "../ksp-onchain-transport-lib" }
|
||||
ksp-store-lib = { path = "../ksp-store-lib", default-features = false }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
jsonschema.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: crates/ksp-config-lib/README.md -->
|
||||
<!-- version: 8 -->
|
||||
<!-- version: 11 -->
|
||||
|
||||
# ksp-config-lib
|
||||
|
||||
@@ -23,7 +23,9 @@ La crate centralise les documents JSON, leurs schemas, les profils et compositio
|
||||
- la classification `Public`, `Internal`, `Secret` ;
|
||||
- les représentations réelle et sûre/redacted ainsi que la provenance des valeurs résolues ;
|
||||
- l'adapter du document Logging effectif vers `ksp_logging_lib::LoggingSettings` ;
|
||||
- l'adapter du document Transport V1/V2 vers `HttpTransportSettings` et, en V2, `WsTransportSettings`, y compris redaction/provenance des URLs `KSP_SECRET_*` ;
|
||||
- l'adapter du document Transport V1/V2/V3 vers `HttpTransportSettings`, `WsTransportSettings` et, en V3, `YellowstoneGrpcTransportSettings`, y compris redaction/provenance des URLs `KSP_SECRET_*` ;
|
||||
- l'adapter de `cfg.std.offchain_transport` vers `ksp_offchain_transport_lib::MarketPriceService`, avec contrôle de provenance des credentials/public fields et sans rendre les limites provider configurables ;
|
||||
- l'adapter de `cfg.std.store` vers `ksp_store_lib::StoreSettings`, avec sélection d'un target nommé, réseau explicite et URI PostgreSQL à provenance `Secret` ;
|
||||
- la surface de management pour inspecter et réparer les sources Config enregistrées, modifier `std.logging.json`, consulter les rapports d'environnement, révéler explicitement une valeur réelle et modifier `.env` ;
|
||||
- les écritures atomiques JSON/`.env` et la protection des permissions `.env` ;
|
||||
- les audits workspace empêchant les bypass d'ownership Config et les oublis dans `.env.example`.
|
||||
@@ -33,12 +35,17 @@ La crate centralise les documents JSON, leurs schemas, les profils et compositio
|
||||
Le registre par défaut connaît :
|
||||
|
||||
```text
|
||||
cfg.composite.ksp-app-wallet-desk -> config/composite.ksp-app-wallet-desk.json
|
||||
cfg.composite.ksp-app-solprices-desk -> config/composite.ksp-app-solprices-desk.json
|
||||
cfg.composite.ksp-app-wallet-desk -> config/composite.ksp-app-wallet-desk.json
|
||||
cfg.std.logging -> config/std.logging.json
|
||||
cfg.std.offchain_transport -> config/std.offchain_transport.json
|
||||
cfg.std.store -> config/std.store.json
|
||||
cfg.std.transport -> config/std.transport.json
|
||||
cfg.std.wallet -> config/std.wallet.json
|
||||
schema.composite -> config/schemas/composite.schema.json
|
||||
schema.std.logging -> config/schemas/std.logging.schema.json
|
||||
schema.std.offchain_transport -> config/schemas/std.offchain_transport.schema.json
|
||||
schema.std.store -> config/schemas/std.store.schema.json
|
||||
schema.std.transport -> config/schemas/std.transport.schema.json
|
||||
schema.std.wallet -> config/schemas/std.wallet.schema.json
|
||||
```
|
||||
@@ -47,7 +54,7 @@ schema.std.wallet -> config/schemas/std.wallet.schema.json
|
||||
|
||||
`ConfigManagement::read_source()` permet d'inspecter le texte brut d'un document Config enregistré même lorsque ce document est invalide. `save_source_candidate()` complète cette frontière : le candidat brut est parsé, validé contre son schema et les invariants sémantiques KSP, puis persisté atomiquement uniquement après validation complète. Le `file_id` doit appartenir au registre et désigner un document Config ; aucun path arbitraire n'est accepté.
|
||||
|
||||
`config/examples/composite.example.json` conserve l’exemple générique. `config/composite.ksp-app-wallet-desk.json` est le premier composite runtime concret : il sélectionne Logging, Transport et Wallet par `file_id`, sans dépendre de leurs filenames physiques.
|
||||
`config/examples/composite.example.json` conserve l’exemple générique. Les composites runtime committed restent possédés par Config : `config/composite.ksp-app-solprices-desk.json` sélectionne Logging + Off-chain Transport, tandis que `config/composite.ksp-app-wallet-desk.json` sélectionne Logging + Off-chain Transport + On-chain Transport + Wallet. Tous référencent leurs documents par `file_id`, sans dépendre de filenames physiques.
|
||||
|
||||
Le fichier local d'environnement est :
|
||||
|
||||
@@ -65,11 +72,11 @@ Les autres crates et applications KSP ne doivent pas :
|
||||
- parser ou écrire directement `.env` ;
|
||||
- ouvrir directement les documents Config connus par leur filename physique ;
|
||||
- réimplémenter la sélection de profils, les compositions ou les placeholders ;
|
||||
- reconstruire elles-mêmes la configuration Logging, Transport ou Wallet depuis le JSON.
|
||||
- reconstruire elles-mêmes la configuration Logging, On-chain Transport, Off-chain Transport, Store ou Wallet depuis le JSON.
|
||||
|
||||
`ksp-config-lib` dépend de `ksp-core-lib` pour `Error`/`Result`, de `ksp-logging-lib` pour les événements Config utiles et le contrat `LoggingSettings`, et de `ksp-onchain-transport-lib` pour construire le contrat runtime Transport dans la direction Config -> Transport. Le document Wallet reste un contrat de chemins/profils Config et n’introduit aucune dépendance Config -> `ksp-wallet-lib`.
|
||||
`ksp-config-lib` dépend de `ksp-core-lib` pour `Error`/`Result`, de `ksp-logging-lib` pour les événements Config utiles et le contrat `LoggingSettings`, de `ksp-onchain-transport-lib` pour construire le contrat runtime On-chain Transport et de `ksp-offchain-transport-lib` pour construire le service market-price dans la direction Config -> Transport et de `ksp-store-lib` avec `default-features = false` pour construire les settings Store dans la direction Config -> Store sans forcer un backend physique. Le document Wallet reste un contrat de chemins/profils Config et n’introduit aucune dépendance Config -> `ksp-wallet-lib`.
|
||||
|
||||
La dépendance inverse est interdite : `ksp-core-lib`, `ksp-logging-lib` et `ksp-onchain-transport-lib` ne dépendent pas de Config.
|
||||
La dépendance inverse est interdite : `ksp-core-lib`, `ksp-logging-lib`, `ksp-onchain-transport-lib`, `ksp-offchain-transport-lib` et les crates Store ne dépendent pas de Config.
|
||||
|
||||
Config ne possède pas le `LoggingGuard`. L'application ou le service qui orchestre le runtime construit la configuration effective puis possède le lifecycle `ksp_logging_lib::initialize/reinitialize`.
|
||||
|
||||
@@ -81,7 +88,7 @@ Un secret reste accessible au runtime ou au management lorsqu'un consumer autori
|
||||
|
||||
Les méthodes `reveal_*` constituent un opt-in explicite au réel. L'authentification/autorisation de l'utilisateur humain appartient à l'application appelante et les valeurs retournées par ces méthodes ne doivent jamais être journalisées.
|
||||
|
||||
Le document Logging refuse les valeurs de sensibilité `Secret` dans sa configuration effective. Le document Transport les accepte pour les URLs HTTP et WebSocket : la valeur réelle est transmise au runtime légitime, tandis que la projection sûre et les `Debug` restent redacted. `std.wallet` refuse également toute sensibilité `Secret` pour `wallets_directory`/`wallets_subdirectory`; les passwords Wallet restent un autre flux Config et ne sont jamais stockés dans ce JSON.
|
||||
Le document Logging refuse les valeurs de sensibilité `Secret` dans sa configuration effective. Le document Transport accepte les valeurs secrètes pour les URLs HTTP/WebSocket et, en V3, pour `grpc_endpoints[].secret_metadata[]` : la valeur réelle est transmise au runtime légitime, tandis que la projection sûre et les `Debug` restent redacted. Les metadata gRPC publiques et secrètes sont séparées et leur provenance Config est contrôlée avant mapping. `std.offchain_transport` exige une provenance `Secret` pour les API keys effectives et une provenance `Public` pour la paire DexScreener lorsqu'elle vient de l'environnement ; il ne permet ni URL provider arbitraire ni override de rate limit. `std.store` exige une provenance `Secret` pour chaque URI PostgreSQL effective et conserve des targets réseau-spécifiques indépendants (`devnet`, `mainnet`, `testnet`) sans exposer l'URI dans les projections sûres. `std.wallet` refuse également toute sensibilité `Secret` pour `wallets_directory`/`wallets_subdirectory`; les passwords Wallet restent un autre flux Config et ne sont jamais stockés dans ce JSON.
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -89,7 +96,10 @@ Le document Logging refuse les valeurs de sensibilité `Secret` dans sa configur
|
||||
- [`TODO.md`](TODO.md) — points explicitement différés ;
|
||||
- [`../../docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md`](../../docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md) — plan historique détaillé de la fondation Config ;
|
||||
- [`../../config/std.logging.json`](../../config/std.logging.json) — document standard Logging ;
|
||||
- [`../../config/std.transport.json`](../../config/std.transport.json) — document standard Transport V2 HTTP + WebSocket, avec lecture backward du V1 HTTP-only ;
|
||||
- [`../../config/std.transport.json`](../../config/std.transport.json) — document standard Transport V3 HTTP + WebSocket + Yellowstone gRPC, avec lecture backward des V1/V2 ;
|
||||
- [`../../config/std.offchain_transport.json`](../../config/std.offchain_transport.json) — document standard Off-chain Transport V1, actuellement limité au domaine `market_price` SOL/USD ;
|
||||
- [`../../config/std.store.json`](../../config/std.store.json) — targets Store PostgreSQL Devnet/Mainnet/Testnet et settings runtime bornés ;
|
||||
- [`../../config/std.wallet.json`](../../config/std.wallet.json) — racine Wallet globale et sous-répertoire optionnel par profil ;
|
||||
- [`../../config/composite.ksp-app-wallet-desk.json`](../../config/composite.ksp-app-wallet-desk.json) — composition Logging/Transport/Wallet de Wallet Desk ;
|
||||
- [`../../config/composite.ksp-app-solprices-desk.json`](../../config/composite.ksp-app-solprices-desk.json) — composition Logging/Off-chain Transport de SOL Prices Desk ;
|
||||
- [`../../config/composite.ksp-app-wallet-desk.json`](../../config/composite.ksp-app-wallet-desk.json) — composition Logging/Off-chain Transport/On-chain Transport/Wallet de Wallet Desk ;
|
||||
- [`../../.env.example`](../../.env.example) — inventaire versionné des variables d'environnement runtime.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: crates/ksp-config-lib/USAGE.md -->
|
||||
<!-- version: 11 -->
|
||||
<!-- version: 14 -->
|
||||
|
||||
# Utilisation de ksp-config-lib
|
||||
|
||||
@@ -28,8 +28,11 @@ Les arguments compris par Config sont :
|
||||
```text
|
||||
--cfgpath=/path/to/config
|
||||
--schemapath=/path/to/schemas
|
||||
--filemap=cfg.composite.ksp-app-solprices-desk=my-solprices-desk.json
|
||||
--filemap=cfg.composite.ksp-app-wallet-desk=my-wallet-desk.json
|
||||
--filemap=cfg.std.logging=my-logging.json
|
||||
--filemap=cfg.std.offchain_transport=my-offchain-transport.json
|
||||
--filemap=cfg.std.store=my-store.json
|
||||
--filemap=cfg.std.transport=my-transport.json
|
||||
--filemap=cfg.std.wallet=my-wallet.json
|
||||
```
|
||||
@@ -125,9 +128,9 @@ Un `logs_directory` relatif est ancré sur le current working directory du proce
|
||||
|
||||
Les `files[].path` restent relatifs sous le root Logging, y compris après interpolation.
|
||||
|
||||
### 4.1 Construire le Transport HTTP + WebSocket depuis Config
|
||||
### 4.1 Construire le Transport HTTP + WebSocket + Yellowstone gRPC depuis Config
|
||||
|
||||
Config possède également l'adapter du document `std.transport` vers le contrat runtime de `ksp-onchain-transport-lib` :
|
||||
Config possède l'adapter du document `std.transport` vers les contrats runtime de `ksp-onchain-transport-lib` :
|
||||
|
||||
```rust
|
||||
let transport = match engine.load_resolved_transport_config(std::option::Option::None, &environment) {
|
||||
@@ -137,18 +140,30 @@ let transport = match engine.load_resolved_transport_config(std::option::Option:
|
||||
|
||||
let http_settings = transport.http_settings();
|
||||
let ws_settings = transport.ws_settings();
|
||||
let _ = (http_settings, ws_settings);
|
||||
let grpc_settings = transport.grpc_settings();
|
||||
let _ = (http_settings, ws_settings, grpc_settings);
|
||||
```
|
||||
|
||||
`std.transport` V2 conserve `retry` et `profiles[].endpoints[]` pour HTTP, ajoute `ws_defaults` et `profiles[].ws_endpoints[]`, puis accepte les protocoles WebSocket `kind = "solana_standard"` et `kind = "helius_laserstream"`. Ce second discriminateur appartient exclusivement au namespace WebSocket et mappe vers `WsProtocolKind::HeliusLaserStream`; il ne préfigure aucun contrat LaserStream gRPC. Un `ws_endpoints[].session` optionnel surcharge seulement les paramètres génériques de `WsSessionSettings`.
|
||||
`std.transport` V3 conserve intégralement les shapes V1/V2, ajoute `grpc_defaults` au niveau global et permet `profiles[].grpc_endpoints[]`. Les profils V3 peuvent rester HTTP + WebSocket seulement : l'absence de `grpc_endpoints` mappe vers `None` et n'invente aucun endpoint. La lecture V1 HTTP-only et V2 HTTP + WebSocket reste stricte et backward-compatible.
|
||||
|
||||
Pour Helius LaserStream WebSocket, l'exemple versionné couvre explicitement les deux réseaux supportés par ce contrat : mainnet via `wss://mainnet.helius-rpc.com/?api-key=${KSP_SECRET_HELIUS_API_KEY:-replace-me}` et devnet via `wss://devnet.helius-rpc.com/?api-key=${KSP_SECRET_HELIUS_API_KEY:-replace-me}`. Les deux réseaux vivent dans des profils Config distincts afin de ne pas mélanger des clusters dans un même profil logique. Config reste l'unique propriétaire de `KSP_SECRET_HELIUS_API_KEY` : il résout la clé dans l'URL effective et transmet au Transport un `WsEndpointUrl` utilisable au runtime. Dans `safe_value`, Config conserve les segments littéraux non sensibles d'une chaîne composée et remplace uniquement chaque segment secret par `********` ; les projections deviennent donc respectivement `wss://mainnet.helius-rpc.com/?api-key=********` et `wss://devnet.helius-rpc.com/?api-key=********`. Les représentations `Debug` restent sûres et n'exposent jamais la clé réelle. Transport ne lit jamais directement l'environnement.
|
||||
Un endpoint gRPC V3 sépare explicitement :
|
||||
|
||||
Le même schema enregistré conserve la lecture stricte du V1 historique : dans ce cas `http_settings()` reste disponible et `ws_settings()` retourne `None`. Aucun `WsTransportSettings` vide n'est inventé pour simuler l'absence de WebSocket.
|
||||
```text
|
||||
provider = description de l'opérateur/exécution, par exemple publicnode
|
||||
protocol = solana_yellowstone
|
||||
metadata = metadata non secrète
|
||||
secret_metadata = metadata dont la valeur doit avoir une provenance KSP_SECRET_*/KSPB_SECRET_*
|
||||
```
|
||||
|
||||
Les scalaires `*_ms` restent des valeurs Config et sont convertis en `std::time::Duration` par l'adapter. Les URLs HTTP et WebSocket peuvent provenir de `KSP_PUBLIC_*` ou de `KSP_SECRET_*`; dans ce dernier cas la valeur réelle reste disponible au runtime Transport, mais `ResolvedTransportConfig::effective().safe_value()` et les représentations `Debug` sont redacted.
|
||||
`protocol` n'est pas un nouveau `WsProtocolKind` et ne transforme pas PublicNode en protocole. Config résout les placeholders, vérifie la classe de sensibilité des metadata puis construit `YellowstoneGrpcTransportSettings`. Transport ne lit jamais l'environnement. Les valeurs de `secret_metadata` sont disponibles au runtime mais redacted dans `safe_value` et dans les représentations `Debug`.
|
||||
|
||||
La dépendance reste unidirectionnelle : Config connaît les contrats Transport pour les construire ; Transport ne connaît ni Config, ni `.env`, ni les variables KSP.
|
||||
Le provider affiche `solana-yellowstone-grpc.publicnode.com:443` ; le profil versionné `publicnode_mainnet` le représente sous la forme URL TLS requise par Transport, `https://solana-yellowstone-grpc.publicnode.com:443`, sans credential. Aucun hostname Testnet n'est versionné tant que sa valeur exacte n'est pas confirmée depuis une source opérateur/officielle.
|
||||
|
||||
L'accesseur historique `into_transport_settings()` conserve volontairement son tuple `(HTTP, Option<WS>)`. Un consumer ayant besoin des trois backends utilise `into_all_transport_settings()` ou les accesseurs séparés afin de ne pas casser silencieusement les consumers V2.
|
||||
|
||||
Les protocoles WebSocket restent `kind = "solana_standard"` et `kind = "helius_laserstream"`. Helius LaserStream WebSocket conserve ses URLs Config-owned et sa clé `KSP_SECRET_HELIUS_API_KEY`; cette surface est indépendante de Yellowstone gRPC.
|
||||
|
||||
Les scalaires `*_ms` restent des valeurs Config et sont convertis en `std::time::Duration` par l'adapter. La dépendance reste unidirectionnelle : Config connaît les contrats Transport pour les construire ; Transport ne connaît ni Config, ni `.env`, ni les variables KSP.
|
||||
|
||||
### 4.2 Résoudre le répertoire Wallet depuis Config
|
||||
|
||||
@@ -170,6 +185,87 @@ let _ = (root, profile_subdirectory, effective);
|
||||
|
||||
Les chemins Wallet refusent toute valeur `KSP_SECRET_*`. Les futurs `KSP_SECRET_WALLET_PASS_*` constituent un flux de secrets distinct et ne sont pas des champs de `std.wallet.json`.
|
||||
|
||||
### 4.3 Construire le service Off-chain Transport depuis Config
|
||||
|
||||
Le document `cfg.std.offchain_transport` possède actuellement le domaine `market_price`. Config résout les placeholders, vérifie la provenance des API keys et de la paire DexScreener, puis construit directement `MarketPriceService` :
|
||||
|
||||
```rust
|
||||
let offchain = match engine.load_resolved_offchain_transport_config(
|
||||
std::option::Option::None,
|
||||
&environment,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
|
||||
for entry in offchain.service().registry().entries() {
|
||||
println!(
|
||||
"{} {:?}",
|
||||
entry.descriptor().display_name(),
|
||||
entry.state().availability(),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Le profil versionné `public_keyless` ne requiert aucun secret. Le profil `all_free` utilise les credentials `KSP_SECRET_*` et la paire `KSP_PUBLIC_DEXSCREENER_SOL_USD_PAIR_ADDRESS` inventoriés dans `.env.example`. Une API key littérale ou issue d'une provenance non secrète est refusée par l'adapter effectif ; une paire DexScreener issue d'une provenance Secret est également refusée.
|
||||
|
||||
Config ne permet pas de fournir `base_url`, `endpoint_url`, `rate_limit` ou `requests_per` aux branches provider. Les origines et cadences sûres restent possédées par `ksp-offchain-transport-lib`. La direction de dépendance reste donc :
|
||||
|
||||
```text
|
||||
ksp-config-lib -> ksp-offchain-transport-lib
|
||||
```
|
||||
|
||||
Off-chain Transport ne lit ni `.env`, ni `KSP_*`, ni les documents Config. `ksp-app-solprices-desk` reçoit le service déjà composé puis utilise uniquement la surface provider-neutral `registry()`, `refresh`, `refresh_many` et `refresh_all`.
|
||||
|
||||
### 4.4 Construire le Store depuis Config
|
||||
|
||||
`cfg.std.store` définit des targets nommés. Chaque target sélectionne exactement un réseau logique, un backend et une URI PostgreSQL distincte. Config résout les secrets puis construit le contrat backend-neutral `ksp_store_lib::StoreSettings` sans activer la feature PostgreSQL du consumer :
|
||||
|
||||
```rust
|
||||
let store_config = match engine.load_resolved_store_config(
|
||||
std::option::Option::Some("devnet"),
|
||||
&environment,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
|
||||
let target_id = store_config.target_id();
|
||||
let network = store_config.settings().network();
|
||||
let _ = (target_id, network);
|
||||
|
||||
let store_settings = store_config.into_settings();
|
||||
let store = match ksp_store_lib::Store::open(store_settings).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let _health = store.health().await;
|
||||
let closed = store.close().await;
|
||||
if let std::result::Result::Err(error) = closed {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
```
|
||||
|
||||
Targets committed :
|
||||
|
||||
```text
|
||||
devnet -> network devnet -> KSP_SECRET_STORE_DEVNET_POSTGRES_URI
|
||||
mainnet -> network mainnet-beta -> KSP_SECRET_STORE_MAINNET_POSTGRES_URI
|
||||
testnet -> network testnet -> KSP_SECRET_STORE_TESTNET_POSTGRES_URI
|
||||
```
|
||||
|
||||
`default_profile = "devnet"` choisit un seul target. La sélection d'un autre target se fait par le `profile_id` explicite ; `ksp-store-lib` ne multiplexe pas plusieurs bases ou réseaux dans une même instance.
|
||||
|
||||
Chaque `connection_uri` doit provenir d'un placeholder `KSP_SECRET_*`/`KSPB_SECRET_*`. Une URI littérale ou issue d'une variable non secrète est rejetée par l'adapter effectif. La valeur réelle est transmise au runtime Store, mais `ResolvedStoreConfig`, `StoreSettings` et les projections sûres ne l'affichent pas.
|
||||
|
||||
La direction de dépendance reste :
|
||||
|
||||
```text
|
||||
ksp-config-lib -> ksp-store-lib (default-features = false)
|
||||
ksp-store-lib -X-> ksp-config-lib
|
||||
ksp-store-postgres-lib -X-> ksp-config-lib
|
||||
```
|
||||
|
||||
## 5. Profils et composites
|
||||
|
||||
Pour un document standard profilé :
|
||||
@@ -193,7 +289,7 @@ let component = match composite.component("wallet") {
|
||||
let wallet = engine.resolve_wallet_config_profile(component.resolved(), &environment);
|
||||
```
|
||||
|
||||
La même forme existe pour Logging via `resolve_logging_config_profile`. Le composite concret `cfg.composite.ksp-app-wallet-desk` référence actuellement `logging`, `transport` et `wallet`; Wallet Desk valide ces trois frontières au bootstrap.
|
||||
La même forme existe pour Logging via `resolve_logging_config_profile`. Le composite `cfg.composite.ksp-app-solprices-desk` référence `logging` et `offchain_transport`. Le composite `cfg.composite.ksp-app-wallet-desk` référence `logging`, `offchain_transport`, `transport` et `wallet`; chaque application valide ses frontières de composition au bootstrap.
|
||||
|
||||
## 6. Management de `std.logging.json`
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-config-lib/src/lib.rs
|
||||
// version: 17
|
||||
// version: 21
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
@@ -9,8 +9,9 @@
|
||||
//!
|
||||
//! The `0.1.3` surface owns bootstrap roots, the logical file registry, JSON/JSON Schema validation, standard-document profiles, generic composites and
|
||||
//! KSP/KSPB environment resolution through process + `.env` + fallback precedence. Resolved values preserve real/safe representations, sensitivity and
|
||||
//! provenance. Standard Logging, HTTP Transport and Wallet documents map explicitly to their runtime consumer contracts, while the management surface
|
||||
//! provides typed Logging mutation, safe environment reports, explicit privileged reveal calls and atomic JSON/`.env` persistence.
|
||||
//! provenance. Standard Logging, on-chain Transport (HTTP/WebSocket/Yellowstone gRPC), Store and Wallet documents map explicitly to their runtime consumer
|
||||
//! contracts, while the management surface provides typed Logging mutation, safe environment reports, explicit privileged reveal calls and atomic
|
||||
//! JSON/`.env` persistence.
|
||||
|
||||
mod bootstrap;
|
||||
mod composite;
|
||||
@@ -20,11 +21,13 @@ mod environment;
|
||||
mod error;
|
||||
mod logging;
|
||||
mod management;
|
||||
mod offchain_transport;
|
||||
mod packaging;
|
||||
mod persistence;
|
||||
mod profile;
|
||||
mod registry;
|
||||
mod sensitivity;
|
||||
mod store;
|
||||
mod transport;
|
||||
mod wallet;
|
||||
|
||||
@@ -126,6 +129,8 @@ pub use self::management::LoggingOutputFilterConfig;
|
||||
pub use self::management::LoggingProfileConfig;
|
||||
/// Typed source contract for one global Logging target override.
|
||||
pub use self::management::LoggingTargetFilterConfig;
|
||||
/// Effective standard Off-chain Transport configuration resolved from Config.
|
||||
pub use self::offchain_transport::ResolvedOffchainTransportConfig;
|
||||
/// Writable KSP runtime roots prepared from packaged Config resources.
|
||||
pub use self::packaging::PackagedRuntimeLayout;
|
||||
/// Prepares the shared writable KSP desktop runtime from immutable packaged resources.
|
||||
@@ -146,6 +151,8 @@ pub use self::registry::ConfigFileId;
|
||||
pub use self::registry::ConfigFileKind;
|
||||
/// Registry of KSP-known logical Config files and their replaceable physical filenames.
|
||||
pub use self::registry::ConfigFileRegistry;
|
||||
/// Default physical filename for the SOL Prices Desk composite configuration document.
|
||||
pub use self::registry::DEFAULT_COMPOSITE_KSP_APP_SOLPRICES_DESK_FILENAME;
|
||||
/// Default physical filename for the Wallet Desk composite configuration document.
|
||||
pub use self::registry::DEFAULT_COMPOSITE_KSP_APP_WALLET_DESK_FILENAME;
|
||||
/// Default physical filename for the generic composite JSON Schema document.
|
||||
@@ -154,27 +161,45 @@ pub use self::registry::DEFAULT_COMPOSITE_SCHEMA_FILENAME;
|
||||
pub use self::registry::DEFAULT_STD_LOGGING_FILENAME;
|
||||
/// Default physical filename for the standard Logging JSON Schema document.
|
||||
pub use self::registry::DEFAULT_STD_LOGGING_SCHEMA_FILENAME;
|
||||
/// Default physical filename for the standard HTTP + WebSocket Transport configuration document.
|
||||
/// Default physical filename for the standard Off-chain Transport configuration document.
|
||||
pub use self::registry::DEFAULT_STD_OFFCHAIN_TRANSPORT_FILENAME;
|
||||
/// Default physical filename for the standard Off-chain Transport JSON Schema document.
|
||||
pub use self::registry::DEFAULT_STD_OFFCHAIN_TRANSPORT_SCHEMA_FILENAME;
|
||||
/// Default physical filename for the standard Store configuration document.
|
||||
pub use self::registry::DEFAULT_STD_STORE_FILENAME;
|
||||
/// Default physical filename for the standard Store JSON Schema document.
|
||||
pub use self::registry::DEFAULT_STD_STORE_SCHEMA_FILENAME;
|
||||
/// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document.
|
||||
pub use self::registry::DEFAULT_STD_TRANSPORT_FILENAME;
|
||||
/// Default physical filename for the standard HTTP + WebSocket Transport JSON Schema document.
|
||||
/// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport JSON Schema document.
|
||||
pub use self::registry::DEFAULT_STD_TRANSPORT_SCHEMA_FILENAME;
|
||||
/// Default physical filename for the standard Wallet configuration document.
|
||||
pub use self::registry::DEFAULT_STD_WALLET_FILENAME;
|
||||
/// Default physical filename for the standard Wallet JSON Schema document.
|
||||
pub use self::registry::DEFAULT_STD_WALLET_SCHEMA_FILENAME;
|
||||
/// Logical file identifier for the SOL Prices Desk composite configuration document.
|
||||
pub use self::registry::FILE_ID_COMPOSITE_KSP_APP_SOLPRICES_DESK;
|
||||
/// Logical file identifier for the Wallet Desk composite configuration document.
|
||||
pub use self::registry::FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK;
|
||||
/// Logical file identifier for the generic composite JSON Schema document.
|
||||
pub use self::registry::FILE_ID_SCHEMA_COMPOSITE;
|
||||
/// Logical file identifier for the standard Logging JSON Schema document.
|
||||
pub use self::registry::FILE_ID_SCHEMA_STD_LOGGING;
|
||||
/// Logical file identifier for the standard HTTP + WebSocket Transport JSON Schema document.
|
||||
/// Logical file identifier for the standard Off-chain Transport JSON Schema document.
|
||||
pub use self::registry::FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT;
|
||||
/// Logical file identifier for the standard Store JSON Schema document.
|
||||
pub use self::registry::FILE_ID_SCHEMA_STD_STORE;
|
||||
/// Logical file identifier for the standard HTTP + WebSocket + Yellowstone gRPC Transport JSON Schema document.
|
||||
pub use self::registry::FILE_ID_SCHEMA_STD_TRANSPORT;
|
||||
/// Logical file identifier for the standard Wallet JSON Schema document.
|
||||
pub use self::registry::FILE_ID_SCHEMA_STD_WALLET;
|
||||
/// Logical file identifier for the standard Logging configuration document.
|
||||
pub use self::registry::FILE_ID_STD_LOGGING;
|
||||
/// Logical file identifier for the standard HTTP + WebSocket Transport configuration document.
|
||||
/// Logical file identifier for the standard Off-chain Transport configuration document.
|
||||
pub use self::registry::FILE_ID_STD_OFFCHAIN_TRANSPORT;
|
||||
/// Logical file identifier for the standard Store configuration document.
|
||||
pub use self::registry::FILE_ID_STD_STORE;
|
||||
/// Logical file identifier for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document.
|
||||
pub use self::registry::FILE_ID_STD_TRANSPORT;
|
||||
/// Logical file identifier for the standard Wallet configuration document.
|
||||
pub use self::registry::FILE_ID_STD_WALLET;
|
||||
@@ -188,7 +213,9 @@ pub use self::sensitivity::REDACTED_CONFIG_VALUE;
|
||||
pub use self::sensitivity::ResolvedConfigJson;
|
||||
/// One resolved Config string preserving real/safe representations and provenance.
|
||||
pub use self::sensitivity::ResolvedConfigText;
|
||||
/// Effective standard Transport configuration mapped to HTTP and optional WebSocket runtime settings.
|
||||
/// Effective standard Store configuration mapped to backend-neutral Store settings.
|
||||
pub use self::store::ResolvedStoreConfig;
|
||||
/// Effective standard Transport configuration mapped to HTTP plus optional WebSocket and Yellowstone gRPC runtime settings.
|
||||
pub use self::transport::ResolvedTransportConfig;
|
||||
/// Effective standard Wallet configuration resolved to validated filesystem roots.
|
||||
pub use self::wallet::ResolvedWalletConfig;
|
||||
|
||||
520
crates/ksp-config-lib/src/offchain_transport.rs
Normal file
520
crates/ksp-config-lib/src/offchain_transport.rs
Normal file
@@ -0,0 +1,520 @@
|
||||
// file: crates/ksp-config-lib/src/offchain_transport.rs
|
||||
// version: 2
|
||||
|
||||
//! Adapter from Config-owned Off-chain Transport documents to the provider-agnostic market-price runtime service.
|
||||
|
||||
/// Effective standard Off-chain Transport configuration resolved from Config.
|
||||
pub struct ResolvedOffchainTransportConfig {
|
||||
effective: crate::ResolvedConfigJson,
|
||||
file_id: crate::ConfigFileId,
|
||||
profile_id: String,
|
||||
selection_source: crate::ConfigProfileSelectionSource,
|
||||
service: ksp_offchain_transport_lib::MarketPriceService,
|
||||
source_path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl crate::ResolvedOffchainTransportConfig {
|
||||
/// Returns the detailed environment-resolved effective Config view with secret-safe diagnostics.
|
||||
#[must_use]
|
||||
pub const fn effective(&self) -> &crate::ResolvedConfigJson {
|
||||
return &self.effective;
|
||||
}
|
||||
|
||||
/// Returns the logical Config document identifier used by this runtime configuration.
|
||||
#[must_use]
|
||||
pub const fn file_id(&self) -> &crate::ConfigFileId {
|
||||
return &self.file_id;
|
||||
}
|
||||
|
||||
/// Returns the selected standard Off-chain Transport profile identifier.
|
||||
#[must_use]
|
||||
pub fn profile_id(&self) -> &str {
|
||||
return self.profile_id.as_str();
|
||||
}
|
||||
|
||||
/// Returns the source that selected the standard Off-chain Transport profile.
|
||||
#[must_use]
|
||||
pub const fn selection_source(&self) -> crate::ConfigProfileSelectionSource {
|
||||
return self.selection_source;
|
||||
}
|
||||
|
||||
/// Returns the provider-agnostic market-price runtime service.
|
||||
#[must_use]
|
||||
pub const fn service(&self) -> &ksp_offchain_transport_lib::MarketPriceService {
|
||||
return &self.service;
|
||||
}
|
||||
|
||||
/// Returns the physical source Config document path.
|
||||
#[must_use]
|
||||
pub fn source_path(&self) -> &std::path::Path {
|
||||
return self.source_path.as_path();
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for crate::ResolvedOffchainTransportConfig {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let registry = self.service.registry();
|
||||
return formatter
|
||||
.debug_struct("ResolvedOffchainTransportConfig")
|
||||
.field("effective", &self.effective)
|
||||
.field("file_id", &self.file_id)
|
||||
.field("profile_id", &self.profile_id)
|
||||
.field("provider_registry", ®istry)
|
||||
.field("selection_source", &self.selection_source)
|
||||
.field("source_path", &self.source_path)
|
||||
.finish();
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::ConfigDocumentEngine {
|
||||
/// Loads the standard Off-chain Transport document and maps one profile to the generic market-price runtime service.
|
||||
pub fn load_resolved_offchain_transport_config(
|
||||
&self,
|
||||
requested_profile: std::option::Option<&str>,
|
||||
environment: &crate::ConfigEnvironment,
|
||||
) -> ksp_core_lib::Result<crate::ResolvedOffchainTransportConfig> {
|
||||
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_OFFCHAIN_TRANSPORT);
|
||||
let file_id = match file_id {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let profile = self.load_resolved_profile(&file_id, requested_profile);
|
||||
let profile = match profile {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return resolve_offchain_transport_profile(&profile, environment);
|
||||
}
|
||||
|
||||
/// Maps an already resolved standard Off-chain Transport profile to the runtime service while preserving composite selection provenance.
|
||||
pub fn resolve_offchain_transport_config_profile(
|
||||
&self,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
environment: &crate::ConfigEnvironment,
|
||||
) -> ksp_core_lib::Result<crate::ResolvedOffchainTransportConfig> {
|
||||
if profile.file_id().as_str() != crate::FILE_ID_STD_OFFCHAIN_TRANSPORT {
|
||||
return std::result::Result::Err(effective_error(profile, "resolved Config profile does not reference the standard Off-chain Transport document"));
|
||||
}
|
||||
let descriptor = self.registry().descriptor(profile.file_id());
|
||||
if let std::result::Result::Err(error) = descriptor {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return resolve_offchain_transport_profile(profile, environment);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveOffchainTransportSource {
|
||||
format_version: u32,
|
||||
market_price: EffectiveMarketPriceSource,
|
||||
profile_id: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveMarketPriceSource {
|
||||
birdeye: EffectiveBirdeyeSource,
|
||||
coinbase_exchange: EffectiveEnabledSource,
|
||||
coingecko: EffectiveCoinGeckoSource,
|
||||
coinmarketcap: EffectiveCoinMarketCapSource,
|
||||
coinpaprika: EffectiveEnabledSource,
|
||||
dexscreener: EffectiveDexScreenerSource,
|
||||
jupiter: EffectiveJupiterSource,
|
||||
kraken: EffectiveEnabledSource,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveEnabledSource {
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveBirdeyeSource {
|
||||
api_key: std::option::Option<String>,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveCoinGeckoSource {
|
||||
access_mode: ksp_offchain_transport_lib::MarketPriceCoinGeckoAccessMode,
|
||||
api_key: std::option::Option<String>,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveCoinMarketCapSource {
|
||||
access_mode: ksp_offchain_transport_lib::MarketPriceCoinMarketCapAccessMode,
|
||||
api_key: std::option::Option<String>,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveDexScreenerSource {
|
||||
enabled: bool,
|
||||
sol_usd_pair_address: std::option::Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveJupiterSource {
|
||||
access_mode: ksp_offchain_transport_lib::MarketPriceJupiterAccessMode,
|
||||
api_key: std::option::Option<String>,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
fn resolve_offchain_transport_profile(
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
environment: &crate::ConfigEnvironment,
|
||||
) -> ksp_core_lib::Result<crate::ResolvedOffchainTransportConfig> {
|
||||
ksp_logging_lib::trace!(
|
||||
target: crate::TRACING_TARGET,
|
||||
profile_id = profile.profile_id(),
|
||||
"mapping standard Off-chain Transport Config profile"
|
||||
);
|
||||
let effective = profile.resolve_effective_environment_detailed(environment);
|
||||
let effective = match effective {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let source = serde_json::from_value::<EffectiveOffchainTransportSource>(effective.value().clone());
|
||||
let source = match source {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(
|
||||
effective_error(profile, "effective Off-chain Transport Config cannot be decoded into the runtime adapter contract").with_source(error),
|
||||
);
|
||||
},
|
||||
};
|
||||
if source.format_version != 1 {
|
||||
return std::result::Result::Err(effective_error(profile, "effective Off-chain Transport format_version is unsupported"));
|
||||
}
|
||||
if source.profile_id != profile.profile_id() {
|
||||
return std::result::Result::Err(effective_error(profile, "effective Off-chain Transport profile_id does not match the selected profile"));
|
||||
}
|
||||
let setups = map_market_price_setups(source.market_price, &effective, profile);
|
||||
let setups = match setups {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let service = ksp_offchain_transport_lib::MarketPriceService::new(setups);
|
||||
let service = match service {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(offchain_contract_error(
|
||||
profile,
|
||||
"effective market-price providers fail the Off-chain Transport runtime contract",
|
||||
&error,
|
||||
));
|
||||
},
|
||||
};
|
||||
let provider_count = service.registry().entries().len();
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
profile_id = profile.profile_id(),
|
||||
format_version = source.format_version,
|
||||
provider_count,
|
||||
"mapped standard Off-chain Transport Config to market-price service"
|
||||
);
|
||||
return std::result::Result::Ok(crate::ResolvedOffchainTransportConfig {
|
||||
effective,
|
||||
file_id: profile.file_id().clone(),
|
||||
profile_id: profile.profile_id().to_owned(),
|
||||
selection_source: profile.selection_source(),
|
||||
service,
|
||||
source_path: profile.path().to_path_buf(),
|
||||
});
|
||||
}
|
||||
|
||||
fn map_market_price_setups(
|
||||
source: EffectiveMarketPriceSource,
|
||||
effective: &crate::ResolvedConfigJson,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
) -> ksp_core_lib::Result<std::vec::Vec<ksp_offchain_transport_lib::MarketPriceProviderSetup>> {
|
||||
let birdeye = map_birdeye(source.birdeye, effective, profile);
|
||||
let birdeye = match birdeye {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let coinbase_exchange = ksp_offchain_transport_lib::MarketPriceCoinbaseExchangeSettings::new(source.coinbase_exchange.enabled);
|
||||
let coinbase_exchange = match coinbase_exchange {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(provider_contract_error(profile, "coinbase_exchange", &error)),
|
||||
};
|
||||
let coingecko = map_coingecko(source.coingecko, effective, profile);
|
||||
let coingecko = match coingecko {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let coinmarketcap = map_coinmarketcap(source.coinmarketcap, effective, profile);
|
||||
let coinmarketcap = match coinmarketcap {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let coinpaprika = ksp_offchain_transport_lib::MarketPriceCoinPaprikaSettings::new(source.coinpaprika.enabled);
|
||||
let coinpaprika = match coinpaprika {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(provider_contract_error(profile, "coinpaprika", &error)),
|
||||
};
|
||||
let dexscreener = map_dexscreener(source.dexscreener, effective, profile);
|
||||
let dexscreener = match dexscreener {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let jupiter = map_jupiter(source.jupiter, effective, profile);
|
||||
let jupiter = match jupiter {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let kraken = ksp_offchain_transport_lib::MarketPriceKrakenSettings::new(source.kraken.enabled);
|
||||
let kraken = match kraken {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(provider_contract_error(profile, "kraken", &error)),
|
||||
};
|
||||
return std::result::Result::Ok(std::vec![
|
||||
ksp_offchain_transport_lib::MarketPriceProviderSetup::Birdeye(birdeye),
|
||||
ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinbaseExchange(coinbase_exchange),
|
||||
ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinGecko(coingecko),
|
||||
ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinMarketCap(coinmarketcap),
|
||||
ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinPaprika(coinpaprika),
|
||||
ksp_offchain_transport_lib::MarketPriceProviderSetup::DexScreener(dexscreener),
|
||||
ksp_offchain_transport_lib::MarketPriceProviderSetup::Jupiter(jupiter),
|
||||
ksp_offchain_transport_lib::MarketPriceProviderSetup::Kraken(kraken),
|
||||
]);
|
||||
}
|
||||
|
||||
fn map_birdeye(
|
||||
source: EffectiveBirdeyeSource,
|
||||
effective: &crate::ResolvedConfigJson,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
) -> ksp_core_lib::Result<ksp_offchain_transport_lib::MarketPriceBirdeyeSettings> {
|
||||
if source.api_key.is_some()
|
||||
&& let std::result::Result::Err(error) = validate_secret_field_provenance(effective, "/market_price/birdeye/api_key", profile, "birdeye")
|
||||
{
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let settings = ksp_offchain_transport_lib::MarketPriceBirdeyeSettings::new(source.enabled, source.api_key);
|
||||
return match settings {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(provider_contract_error(profile, "birdeye", &error)),
|
||||
};
|
||||
}
|
||||
|
||||
fn map_coingecko(
|
||||
source: EffectiveCoinGeckoSource,
|
||||
effective: &crate::ResolvedConfigJson,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
) -> ksp_core_lib::Result<ksp_offchain_transport_lib::MarketPriceCoinGeckoSettings> {
|
||||
if source.api_key.is_some()
|
||||
&& let std::result::Result::Err(error) = validate_secret_field_provenance(effective, "/market_price/coingecko/api_key", profile, "coingecko")
|
||||
{
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let settings = match source.access_mode {
|
||||
ksp_offchain_transport_lib::MarketPriceCoinGeckoAccessMode::Demo => {
|
||||
ksp_offchain_transport_lib::MarketPriceCoinGeckoSettings::demo(source.enabled, source.api_key)
|
||||
},
|
||||
ksp_offchain_transport_lib::MarketPriceCoinGeckoAccessMode::Keyless => {
|
||||
ksp_offchain_transport_lib::MarketPriceCoinGeckoSettings::keyless(source.enabled)
|
||||
},
|
||||
_ => {
|
||||
return std::result::Result::Err(
|
||||
effective_error(profile, "Off-chain Transport CoinGecko access mode is not supported by this Config adapter")
|
||||
.with_context("provider", "coingecko")
|
||||
.with_context("field", "access_mode"),
|
||||
);
|
||||
},
|
||||
};
|
||||
return match settings {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(provider_contract_error(profile, "coingecko", &error)),
|
||||
};
|
||||
}
|
||||
|
||||
fn map_coinmarketcap(
|
||||
source: EffectiveCoinMarketCapSource,
|
||||
effective: &crate::ResolvedConfigJson,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
) -> ksp_core_lib::Result<ksp_offchain_transport_lib::MarketPriceCoinMarketCapSettings> {
|
||||
if source.api_key.is_some()
|
||||
&& let std::result::Result::Err(error) = validate_secret_field_provenance(effective, "/market_price/coinmarketcap/api_key", profile, "coinmarketcap")
|
||||
{
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let settings = match source.access_mode {
|
||||
ksp_offchain_transport_lib::MarketPriceCoinMarketCapAccessMode::Basic => {
|
||||
ksp_offchain_transport_lib::MarketPriceCoinMarketCapSettings::basic(source.enabled, source.api_key)
|
||||
},
|
||||
ksp_offchain_transport_lib::MarketPriceCoinMarketCapAccessMode::Keyless => {
|
||||
ksp_offchain_transport_lib::MarketPriceCoinMarketCapSettings::keyless(source.enabled)
|
||||
},
|
||||
_ => {
|
||||
return std::result::Result::Err(
|
||||
effective_error(profile, "Off-chain Transport CoinMarketCap access mode is not supported by this Config adapter")
|
||||
.with_context("provider", "coinmarketcap")
|
||||
.with_context("field", "access_mode"),
|
||||
);
|
||||
},
|
||||
};
|
||||
return match settings {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(provider_contract_error(profile, "coinmarketcap", &error)),
|
||||
};
|
||||
}
|
||||
|
||||
fn map_dexscreener(
|
||||
source: EffectiveDexScreenerSource,
|
||||
effective: &crate::ResolvedConfigJson,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
) -> ksp_core_lib::Result<ksp_offchain_transport_lib::MarketPriceDexScreenerSettings> {
|
||||
if source.sol_usd_pair_address.is_some()
|
||||
&& let std::result::Result::Err(error) =
|
||||
validate_public_field_provenance(effective, "/market_price/dexscreener/sol_usd_pair_address", profile, "dexscreener")
|
||||
{
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let settings = ksp_offchain_transport_lib::MarketPriceDexScreenerSettings::new(source.enabled, source.sol_usd_pair_address);
|
||||
return match settings {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(provider_contract_error(profile, "dexscreener", &error)),
|
||||
};
|
||||
}
|
||||
|
||||
fn map_jupiter(
|
||||
source: EffectiveJupiterSource,
|
||||
effective: &crate::ResolvedConfigJson,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
) -> ksp_core_lib::Result<ksp_offchain_transport_lib::MarketPriceJupiterSettings> {
|
||||
if source.api_key.is_some()
|
||||
&& let std::result::Result::Err(error) = validate_secret_field_provenance(effective, "/market_price/jupiter/api_key", profile, "jupiter")
|
||||
{
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let settings = match source.access_mode {
|
||||
ksp_offchain_transport_lib::MarketPriceJupiterAccessMode::Free => {
|
||||
ksp_offchain_transport_lib::MarketPriceJupiterSettings::free(source.enabled, source.api_key)
|
||||
},
|
||||
ksp_offchain_transport_lib::MarketPriceJupiterAccessMode::Keyless => ksp_offchain_transport_lib::MarketPriceJupiterSettings::keyless(source.enabled),
|
||||
_ => {
|
||||
return std::result::Result::Err(
|
||||
effective_error(profile, "Off-chain Transport Jupiter access mode is not supported by this Config adapter")
|
||||
.with_context("provider", "jupiter")
|
||||
.with_context("field", "access_mode"),
|
||||
);
|
||||
},
|
||||
};
|
||||
return match settings {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(provider_contract_error(profile, "jupiter", &error)),
|
||||
};
|
||||
}
|
||||
|
||||
fn validate_secret_field_provenance(
|
||||
effective: &crate::ResolvedConfigJson,
|
||||
pointer: &str,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
provider: &'static str,
|
||||
) -> ksp_core_lib::Result<()> {
|
||||
let provenance = match effective.provenance_at(pointer) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(
|
||||
effective_error(profile, "Off-chain Transport secret provenance is unavailable")
|
||||
.with_context("provider", provider)
|
||||
.with_context("field", pointer),
|
||||
);
|
||||
},
|
||||
};
|
||||
let mut has_secret_environment = false;
|
||||
for item in provenance {
|
||||
let variable_name = match item.variable_name() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => continue,
|
||||
};
|
||||
let sensitivity = crate::ConfigSensitivity::from_variable_name(variable_name);
|
||||
let sensitivity = match sensitivity {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if !sensitivity.is_secret() {
|
||||
return std::result::Result::Err(
|
||||
effective_error(profile, "Off-chain Transport credentials may reference only secret environment variables")
|
||||
.with_context("provider", provider)
|
||||
.with_context("field", pointer),
|
||||
);
|
||||
}
|
||||
has_secret_environment = true;
|
||||
}
|
||||
if !has_secret_environment {
|
||||
return std::result::Result::Err(
|
||||
effective_error(profile, "Off-chain Transport credentials require secret environment provenance")
|
||||
.with_context("provider", provider)
|
||||
.with_context("field", pointer),
|
||||
);
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn validate_public_field_provenance(
|
||||
effective: &crate::ResolvedConfigJson,
|
||||
pointer: &str,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
provider: &'static str,
|
||||
) -> ksp_core_lib::Result<()> {
|
||||
let provenance = match effective.provenance_at(pointer) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(
|
||||
effective_error(profile, "Off-chain Transport public provenance is unavailable")
|
||||
.with_context("provider", provider)
|
||||
.with_context("field", pointer),
|
||||
);
|
||||
},
|
||||
};
|
||||
for item in provenance {
|
||||
let variable_name = match item.variable_name() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => continue,
|
||||
};
|
||||
let sensitivity = crate::ConfigSensitivity::from_variable_name(variable_name);
|
||||
let sensitivity = match sensitivity {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if sensitivity != crate::ConfigSensitivity::Public {
|
||||
return std::result::Result::Err(
|
||||
effective_error(profile, "public Off-chain Transport fields may reference only public environment variables")
|
||||
.with_context("provider", provider)
|
||||
.with_context("field", pointer),
|
||||
);
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn provider_contract_error(profile: &crate::ResolvedConfigProfile, provider: &'static str, error: &ksp_core_lib::Error) -> ksp_core_lib::Error {
|
||||
return offchain_contract_error(profile, "effective market-price provider settings fail the Off-chain Transport runtime contract", error)
|
||||
.with_context("provider", provider);
|
||||
}
|
||||
|
||||
fn offchain_contract_error(profile: &crate::ResolvedConfigProfile, reason: &'static str, error: &ksp_core_lib::Error) -> ksp_core_lib::Error {
|
||||
return effective_error(profile, reason)
|
||||
.with_context("offchain_transport_error_domain", error.code().domain())
|
||||
.with_context("offchain_transport_error_code", error.code().code());
|
||||
}
|
||||
|
||||
fn effective_error(profile: &crate::ResolvedConfigProfile, reason: &'static str) -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID, "effective Config cannot be mapped to the requested runtime contract")
|
||||
.with_context("file_id", profile.file_id().as_str())
|
||||
.with_context("profile_id", profile.profile_id())
|
||||
.with_context("reason", reason);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/offchain_transport.rs"]
|
||||
mod tests;
|
||||
@@ -1,8 +1,10 @@
|
||||
// file: crates/ksp-config-lib/src/registry.rs
|
||||
// version: 9
|
||||
// version: 13
|
||||
|
||||
/// Bootstrap argument used to replace a known Config filename mapping.
|
||||
pub const ARG_FILE_MAP: &str = "--filemap";
|
||||
/// Default physical filename for the SOL Prices Desk composite configuration document.
|
||||
pub const DEFAULT_COMPOSITE_KSP_APP_SOLPRICES_DESK_FILENAME: &str = "composite.ksp-app-solprices-desk.json";
|
||||
/// Default physical filename for the Wallet Desk composite configuration document.
|
||||
pub const DEFAULT_COMPOSITE_KSP_APP_WALLET_DESK_FILENAME: &str = "composite.ksp-app-wallet-desk.json";
|
||||
/// Default physical filename for the generic composite JSON Schema document.
|
||||
@@ -11,27 +13,45 @@ pub const DEFAULT_COMPOSITE_SCHEMA_FILENAME: &str = "composite.schema.json";
|
||||
pub const DEFAULT_STD_LOGGING_FILENAME: &str = "std.logging.json";
|
||||
/// Default physical filename for the standard Logging JSON Schema document.
|
||||
pub const DEFAULT_STD_LOGGING_SCHEMA_FILENAME: &str = "std.logging.schema.json";
|
||||
/// Default physical filename for the standard HTTP + WebSocket Transport configuration document.
|
||||
/// Default physical filename for the standard Off-chain Transport configuration document.
|
||||
pub const DEFAULT_STD_OFFCHAIN_TRANSPORT_FILENAME: &str = "std.offchain_transport.json";
|
||||
/// Default physical filename for the standard Off-chain Transport JSON Schema document.
|
||||
pub const DEFAULT_STD_OFFCHAIN_TRANSPORT_SCHEMA_FILENAME: &str = "std.offchain_transport.schema.json";
|
||||
/// Default physical filename for the standard Store configuration document.
|
||||
pub const DEFAULT_STD_STORE_FILENAME: &str = "std.store.json";
|
||||
/// Default physical filename for the standard Store JSON Schema document.
|
||||
pub const DEFAULT_STD_STORE_SCHEMA_FILENAME: &str = "std.store.schema.json";
|
||||
/// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document.
|
||||
pub const DEFAULT_STD_TRANSPORT_FILENAME: &str = "std.transport.json";
|
||||
/// Default physical filename for the standard HTTP + WebSocket Transport JSON Schema document.
|
||||
/// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport JSON Schema document.
|
||||
pub const DEFAULT_STD_TRANSPORT_SCHEMA_FILENAME: &str = "std.transport.schema.json";
|
||||
/// Default physical filename for the standard Wallet configuration document.
|
||||
pub const DEFAULT_STD_WALLET_FILENAME: &str = "std.wallet.json";
|
||||
/// Default physical filename for the standard Wallet JSON Schema document.
|
||||
pub const DEFAULT_STD_WALLET_SCHEMA_FILENAME: &str = "std.wallet.schema.json";
|
||||
/// Logical file identifier for the SOL Prices Desk composite configuration document.
|
||||
pub const FILE_ID_COMPOSITE_KSP_APP_SOLPRICES_DESK: &str = "cfg.composite.ksp-app-solprices-desk";
|
||||
/// Logical file identifier for the Wallet Desk composite configuration document.
|
||||
pub const FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK: &str = "cfg.composite.ksp-app-wallet-desk";
|
||||
/// Logical file identifier for the generic composite JSON Schema document.
|
||||
pub const FILE_ID_SCHEMA_COMPOSITE: &str = "schema.composite";
|
||||
/// Logical file identifier for the standard Logging JSON Schema document.
|
||||
pub const FILE_ID_SCHEMA_STD_LOGGING: &str = "schema.std.logging";
|
||||
/// Logical file identifier for the standard HTTP + WebSocket Transport JSON Schema document.
|
||||
/// Logical file identifier for the standard Off-chain Transport JSON Schema document.
|
||||
pub const FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT: &str = "schema.std.offchain_transport";
|
||||
/// Logical file identifier for the standard Store JSON Schema document.
|
||||
pub const FILE_ID_SCHEMA_STD_STORE: &str = "schema.std.store";
|
||||
/// Logical file identifier for the standard HTTP + WebSocket + Yellowstone gRPC Transport JSON Schema document.
|
||||
pub const FILE_ID_SCHEMA_STD_TRANSPORT: &str = "schema.std.transport";
|
||||
/// Logical file identifier for the standard Wallet JSON Schema document.
|
||||
pub const FILE_ID_SCHEMA_STD_WALLET: &str = "schema.std.wallet";
|
||||
/// Logical file identifier for the standard Logging configuration document.
|
||||
pub const FILE_ID_STD_LOGGING: &str = "cfg.std.logging";
|
||||
/// Logical file identifier for the standard HTTP + WebSocket Transport configuration document.
|
||||
/// Logical file identifier for the standard Off-chain Transport configuration document.
|
||||
pub const FILE_ID_STD_OFFCHAIN_TRANSPORT: &str = "cfg.std.offchain_transport";
|
||||
/// Logical file identifier for the standard Store configuration document.
|
||||
pub const FILE_ID_STD_STORE: &str = "cfg.std.store";
|
||||
/// Logical file identifier for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document.
|
||||
pub const FILE_ID_STD_TRANSPORT: &str = "cfg.std.transport";
|
||||
/// Logical file identifier for the standard Wallet configuration document.
|
||||
pub const FILE_ID_STD_WALLET: &str = "cfg.std.wallet";
|
||||
@@ -140,13 +160,23 @@ pub struct ConfigFileRegistry {
|
||||
impl ConfigFileRegistry {
|
||||
/// Creates the registry containing the KSP default file mappings known in the current release.
|
||||
pub fn defaults() -> ksp_core_lib::Result<Self> {
|
||||
let composite = ConfigFileDescriptor::new(
|
||||
let solprices_composite = ConfigFileDescriptor::new(
|
||||
FILE_ID_COMPOSITE_KSP_APP_SOLPRICES_DESK,
|
||||
ConfigFileKind::Config,
|
||||
DEFAULT_COMPOSITE_KSP_APP_SOLPRICES_DESK_FILENAME,
|
||||
std::option::Option::Some(FILE_ID_SCHEMA_COMPOSITE),
|
||||
);
|
||||
let solprices_composite = match solprices_composite {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let wallet_composite = ConfigFileDescriptor::new(
|
||||
FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK,
|
||||
ConfigFileKind::Config,
|
||||
DEFAULT_COMPOSITE_KSP_APP_WALLET_DESK_FILENAME,
|
||||
std::option::Option::Some(FILE_ID_SCHEMA_COMPOSITE),
|
||||
);
|
||||
let composite = match composite {
|
||||
let wallet_composite = match wallet_composite {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
@@ -172,6 +202,42 @@ impl ConfigFileRegistry {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let offchain_transport = ConfigFileDescriptor::new(
|
||||
FILE_ID_STD_OFFCHAIN_TRANSPORT,
|
||||
ConfigFileKind::Config,
|
||||
DEFAULT_STD_OFFCHAIN_TRANSPORT_FILENAME,
|
||||
std::option::Option::Some(FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT),
|
||||
);
|
||||
let offchain_transport = match offchain_transport {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let offchain_transport_schema = ConfigFileDescriptor::new(
|
||||
FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT,
|
||||
ConfigFileKind::Schema,
|
||||
DEFAULT_STD_OFFCHAIN_TRANSPORT_SCHEMA_FILENAME,
|
||||
std::option::Option::None,
|
||||
);
|
||||
let offchain_transport_schema = match offchain_transport_schema {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let store = ConfigFileDescriptor::new(
|
||||
FILE_ID_STD_STORE,
|
||||
ConfigFileKind::Config,
|
||||
DEFAULT_STD_STORE_FILENAME,
|
||||
std::option::Option::Some(FILE_ID_SCHEMA_STD_STORE),
|
||||
);
|
||||
let store = match store {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let store_schema =
|
||||
ConfigFileDescriptor::new(FILE_ID_SCHEMA_STD_STORE, ConfigFileKind::Schema, DEFAULT_STD_STORE_SCHEMA_FILENAME, std::option::Option::None);
|
||||
let store_schema = match store_schema {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let transport = ConfigFileDescriptor::new(
|
||||
FILE_ID_STD_TRANSPORT,
|
||||
ConfigFileKind::Config,
|
||||
@@ -204,7 +270,21 @@ impl ConfigFileRegistry {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return crate::build_registry([composite, composite_schema, logging, logging_schema, transport, transport_schema, wallet, wallet_schema]);
|
||||
return crate::build_registry([
|
||||
solprices_composite,
|
||||
wallet_composite,
|
||||
composite_schema,
|
||||
logging,
|
||||
logging_schema,
|
||||
offchain_transport,
|
||||
offchain_transport_schema,
|
||||
store,
|
||||
store_schema,
|
||||
transport,
|
||||
transport_schema,
|
||||
wallet,
|
||||
wallet_schema,
|
||||
]);
|
||||
}
|
||||
|
||||
/// Creates the default registry and applies repeatable `--filemap=<file_id>=<filename>` overrides from raw process arguments.
|
||||
|
||||
305
crates/ksp-config-lib/src/store.rs
Normal file
305
crates/ksp-config-lib/src/store.rs
Normal file
@@ -0,0 +1,305 @@
|
||||
// file: crates/ksp-config-lib/src/store.rs
|
||||
// version: 3
|
||||
|
||||
/// Effective standard Store configuration mapped to `ksp_store_lib::StoreSettings`.
|
||||
pub struct ResolvedStoreConfig {
|
||||
effective: crate::ResolvedConfigJson,
|
||||
file_id: crate::ConfigFileId,
|
||||
profile_id: String,
|
||||
selection_source: crate::ConfigProfileSelectionSource,
|
||||
settings: ksp_store_lib::StoreSettings,
|
||||
source_path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl ResolvedStoreConfig {
|
||||
/// Returns the detailed environment-resolved Config view.
|
||||
#[must_use]
|
||||
pub const fn effective(&self) -> &crate::ResolvedConfigJson {
|
||||
return &self.effective;
|
||||
}
|
||||
|
||||
/// Returns the logical Config file identifier used by this runtime configuration.
|
||||
#[must_use]
|
||||
pub const fn file_id(&self) -> &crate::ConfigFileId {
|
||||
return &self.file_id;
|
||||
}
|
||||
|
||||
/// Returns the selected standard Store profile identifier.
|
||||
///
|
||||
/// For `std.store`, the profile identifier is also the stable named Store target identifier.
|
||||
#[must_use]
|
||||
pub fn profile_id(&self) -> &str {
|
||||
return self.profile_id.as_str();
|
||||
}
|
||||
|
||||
/// Returns the selected named Store target identifier.
|
||||
#[must_use]
|
||||
pub fn target_id(&self) -> &str {
|
||||
return self.profile_id.as_str();
|
||||
}
|
||||
|
||||
/// Returns the source that selected the standard Store profile.
|
||||
#[must_use]
|
||||
pub const fn selection_source(&self) -> crate::ConfigProfileSelectionSource {
|
||||
return self.selection_source;
|
||||
}
|
||||
|
||||
/// Borrows the backend-neutral Store settings without exposing the connection URI.
|
||||
#[must_use]
|
||||
pub const fn settings(&self) -> &ksp_store_lib::StoreSettings {
|
||||
return &self.settings;
|
||||
}
|
||||
|
||||
/// Consumes the resolved Config and returns the Store-owned runtime settings.
|
||||
#[must_use]
|
||||
pub fn into_settings(self) -> ksp_store_lib::StoreSettings {
|
||||
return self.settings;
|
||||
}
|
||||
|
||||
/// Returns the physical source Config document path.
|
||||
#[must_use]
|
||||
pub fn source_path(&self) -> &std::path::Path {
|
||||
return self.source_path.as_path();
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ResolvedStoreConfig {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter
|
||||
.debug_struct("ResolvedStoreConfig")
|
||||
.field("effective", &self.effective)
|
||||
.field("file_id", &self.file_id)
|
||||
.field("profile_id", &self.profile_id)
|
||||
.field("selection_source", &self.selection_source)
|
||||
.field("settings", &self.settings)
|
||||
.field("source_path", &self.source_path)
|
||||
.finish();
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::ConfigDocumentEngine {
|
||||
/// Loads `std.store`, resolves one profile/environment and maps it to backend-neutral Store settings.
|
||||
pub fn load_resolved_store_config(
|
||||
&self,
|
||||
requested_profile: std::option::Option<&str>,
|
||||
environment: &crate::ConfigEnvironment,
|
||||
) -> ksp_core_lib::Result<ResolvedStoreConfig> {
|
||||
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_STORE);
|
||||
let file_id = match file_id {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let profile = self.load_resolved_profile(&file_id, requested_profile);
|
||||
let profile = match profile {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return resolve_store_profile(&profile, environment);
|
||||
}
|
||||
|
||||
/// Maps an already resolved `cfg.std.store` profile while preserving its selection provenance.
|
||||
pub fn resolve_store_config_profile(
|
||||
&self,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
environment: &crate::ConfigEnvironment,
|
||||
) -> ksp_core_lib::Result<ResolvedStoreConfig> {
|
||||
if profile.file_id().as_str() != crate::FILE_ID_STD_STORE {
|
||||
return std::result::Result::Err(effective_error(profile, "resolved Config profile does not reference the standard Store document"));
|
||||
}
|
||||
let descriptor = self.registry().descriptor(profile.file_id());
|
||||
if let std::result::Result::Err(error) = descriptor {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return resolve_store_profile(profile, environment);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveStoreSource {
|
||||
backend: String,
|
||||
format_version: u32,
|
||||
network: String,
|
||||
postgres: EffectivePostgresSource,
|
||||
profile_id: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectivePostgresSource {
|
||||
bootstrap: EffectivePostgresBootstrapSource,
|
||||
connection_uri: String,
|
||||
pool: EffectivePostgresPoolSource,
|
||||
shutdown_timeout_ms: u64,
|
||||
tls: EffectivePostgresTlsSource,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectivePostgresPoolSource {
|
||||
connect_timeout_ms: u64,
|
||||
create_timeout_ms: u64,
|
||||
max_connections: u32,
|
||||
recycle_timeout_ms: u64,
|
||||
wait_timeout_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectivePostgresTlsSource {
|
||||
mode: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectivePostgresBootstrapSource {
|
||||
auto_migrate: std::option::Option<bool>,
|
||||
migration_lock_timeout_ms: u64,
|
||||
migration_timeout_ms: u64,
|
||||
schema_autocreate: std::option::Option<bool>,
|
||||
schema_autoupdate: std::option::Option<bool>,
|
||||
}
|
||||
|
||||
fn resolve_store_profile(profile: &crate::ResolvedConfigProfile, environment: &crate::ConfigEnvironment) -> ksp_core_lib::Result<ResolvedStoreConfig> {
|
||||
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, profile_id = profile.profile_id(), "mapping standard Store Config profile");
|
||||
let effective = profile.resolve_effective_environment_detailed(environment);
|
||||
let effective = match effective {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let provenance = validate_connection_uri_provenance(&effective, profile);
|
||||
if let std::result::Result::Err(error) = provenance {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let source = serde_json::from_value::<EffectiveStoreSource>(effective.value().clone());
|
||||
let source = match source {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(
|
||||
effective_error(profile, "effective Store Config cannot be decoded into the runtime adapter contract").with_source(error),
|
||||
);
|
||||
},
|
||||
};
|
||||
if source.format_version != 1 && source.format_version != 2 {
|
||||
return std::result::Result::Err(effective_error(profile, "effective Store format_version is unsupported"));
|
||||
}
|
||||
if source.profile_id != profile.profile_id() {
|
||||
return std::result::Result::Err(effective_error(profile, "effective Store profile_id does not match the selected profile"));
|
||||
}
|
||||
if source.backend != "postgres" {
|
||||
return std::result::Result::Err(effective_error(profile, "effective Store backend is unsupported").with_context("backend", source.backend));
|
||||
}
|
||||
let tls_mode = match source.postgres.tls.mode.as_str() {
|
||||
"disabled" => ksp_store_lib::PostgresTlsMode::Disabled,
|
||||
"verify_full" => ksp_store_lib::PostgresTlsMode::VerifyFull,
|
||||
_ => return std::result::Result::Err(effective_error(profile, "effective Store PostgreSQL TLS mode is unsupported")),
|
||||
};
|
||||
let pool = ksp_store_lib::PostgresPoolSettings::new(
|
||||
source.postgres.pool.max_connections,
|
||||
std::time::Duration::from_millis(source.postgres.pool.connect_timeout_ms),
|
||||
std::time::Duration::from_millis(source.postgres.pool.wait_timeout_ms),
|
||||
std::time::Duration::from_millis(source.postgres.pool.create_timeout_ms),
|
||||
std::time::Duration::from_millis(source.postgres.pool.recycle_timeout_ms),
|
||||
);
|
||||
let bootstrap = match source.format_version {
|
||||
1 => {
|
||||
let auto_migrate =
|
||||
match (source.postgres.bootstrap.auto_migrate, source.postgres.bootstrap.schema_autocreate, source.postgres.bootstrap.schema_autoupdate) {
|
||||
(std::option::Option::Some(value), std::option::Option::None, std::option::Option::None) => value,
|
||||
_ => return std::result::Result::Err(effective_error(profile, "effective Store V1 bootstrap policy is invalid")),
|
||||
};
|
||||
ksp_store_lib::PostgresBootstrapSettings::new(
|
||||
auto_migrate,
|
||||
std::time::Duration::from_millis(source.postgres.bootstrap.migration_timeout_ms),
|
||||
std::time::Duration::from_millis(source.postgres.bootstrap.migration_lock_timeout_ms),
|
||||
)
|
||||
},
|
||||
2 => {
|
||||
let (schema_autocreate, schema_autoupdate) =
|
||||
match (source.postgres.bootstrap.auto_migrate, source.postgres.bootstrap.schema_autocreate, source.postgres.bootstrap.schema_autoupdate) {
|
||||
(std::option::Option::None, std::option::Option::Some(autocreate), std::option::Option::Some(autoupdate)) => (autocreate, autoupdate),
|
||||
_ => return std::result::Result::Err(effective_error(profile, "effective Store V2 bootstrap policy is invalid")),
|
||||
};
|
||||
ksp_store_lib::PostgresBootstrapSettings::with_schema_policy(
|
||||
schema_autocreate,
|
||||
schema_autoupdate,
|
||||
std::time::Duration::from_millis(source.postgres.bootstrap.migration_timeout_ms),
|
||||
std::time::Duration::from_millis(source.postgres.bootstrap.migration_lock_timeout_ms),
|
||||
)
|
||||
},
|
||||
_ => return std::result::Result::Err(effective_error(profile, "effective Store format_version is unsupported")),
|
||||
};
|
||||
let network = ksp_store_lib::RawNetworkId::new(source.network);
|
||||
let network = match network {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(effective_error(profile, "effective Store network identifier is invalid")),
|
||||
};
|
||||
let postgres = ksp_store_lib::PostgresStoreSettings::new(source.postgres.connection_uri, pool, tls_mode, bootstrap);
|
||||
let settings = ksp_store_lib::StoreSettings::new(
|
||||
network,
|
||||
ksp_store_lib::StoreBackendSettings::Postgres(postgres),
|
||||
std::time::Duration::from_millis(source.postgres.shutdown_timeout_ms),
|
||||
);
|
||||
if let std::result::Result::Err(error) = settings.validate() {
|
||||
return std::result::Result::Err(store_contract_error(profile, &error));
|
||||
}
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
profile_id = profile.profile_id(),
|
||||
network = settings.network().as_str(),
|
||||
backend = settings.backend_kind().code(),
|
||||
"mapped standard Store Config to Store settings"
|
||||
);
|
||||
return std::result::Result::Ok(ResolvedStoreConfig {
|
||||
effective,
|
||||
file_id: profile.file_id().clone(),
|
||||
profile_id: profile.profile_id().to_owned(),
|
||||
selection_source: profile.selection_source(),
|
||||
settings,
|
||||
source_path: profile.path().to_path_buf(),
|
||||
});
|
||||
}
|
||||
|
||||
fn validate_connection_uri_provenance(effective: &crate::ResolvedConfigJson, profile: &crate::ResolvedConfigProfile) -> ksp_core_lib::Result<()> {
|
||||
let provenance = match effective.provenance_at("/postgres/connection_uri") {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(effective_error(profile, "Store PostgreSQL connection URI provenance is unavailable")),
|
||||
};
|
||||
let mut has_secret_environment = false;
|
||||
for item in provenance {
|
||||
let variable_name = match item.variable_name() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => continue,
|
||||
};
|
||||
let sensitivity = crate::ConfigSensitivity::from_variable_name(variable_name);
|
||||
let sensitivity = match sensitivity {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if !sensitivity.is_secret() {
|
||||
return std::result::Result::Err(effective_error(profile, "Store PostgreSQL connection URI may reference only secret environment variables"));
|
||||
}
|
||||
has_secret_environment = true;
|
||||
}
|
||||
if !has_secret_environment {
|
||||
return std::result::Result::Err(effective_error(profile, "Store PostgreSQL connection URI requires secret environment provenance"));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn store_contract_error(profile: &crate::ResolvedConfigProfile, error: &ksp_core_lib::Error) -> ksp_core_lib::Error {
|
||||
return effective_error(profile, "effective Store settings fail the Store runtime contract")
|
||||
.with_context("store_error_domain", error.code().domain())
|
||||
.with_context("store_error_code", error.code().code());
|
||||
}
|
||||
|
||||
fn effective_error(profile: &crate::ResolvedConfigProfile, reason: &'static str) -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID, "effective Config cannot be mapped to the requested runtime contract")
|
||||
.with_context("file_id", profile.file_id().as_str())
|
||||
.with_context("profile_id", profile.profile_id())
|
||||
.with_context("reason", reason);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/store.rs"]
|
||||
mod tests;
|
||||
@@ -1,7 +1,7 @@
|
||||
// file: crates/ksp-config-lib/src/transport.rs
|
||||
// version: 4
|
||||
// version: 5
|
||||
|
||||
/// Effective standard on-chain Transport configuration resolved from Config and mapped to HTTP and optional WebSocket runtime contracts.
|
||||
/// Effective standard on-chain Transport configuration resolved from Config and mapped to HTTP plus optional WebSocket and Yellowstone gRPC runtime contracts.
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub struct ResolvedTransportConfig {
|
||||
file_id: crate::ConfigFileId,
|
||||
@@ -11,6 +11,7 @@ pub struct ResolvedTransportConfig {
|
||||
effective: crate::ResolvedConfigJson,
|
||||
settings: ksp_onchain_transport_lib::HttpTransportSettings,
|
||||
ws_settings: std::option::Option<ksp_onchain_transport_lib::WsTransportSettings>,
|
||||
grpc_settings: std::option::Option<ksp_onchain_transport_lib::YellowstoneGrpcTransportSettings>,
|
||||
}
|
||||
|
||||
impl ResolvedTransportConfig {
|
||||
@@ -61,7 +62,7 @@ impl ResolvedTransportConfig {
|
||||
return &self.settings;
|
||||
}
|
||||
|
||||
/// Returns validated WebSocket Transport settings when the selected document uses format V2.
|
||||
/// Returns validated WebSocket Transport settings when the selected document uses format V2 or V3.
|
||||
///
|
||||
/// Backward-compatible V1 HTTP-only documents return [`std::option::Option::None`].
|
||||
#[must_use]
|
||||
@@ -69,19 +70,42 @@ impl ResolvedTransportConfig {
|
||||
return self.ws_settings.as_ref();
|
||||
}
|
||||
|
||||
/// Returns validated Yellowstone gRPC Transport settings when the selected V3 profile declares gRPC endpoints.
|
||||
///
|
||||
/// V1/V2 documents and V3 profiles without `grpc_endpoints` return [`std::option::Option::None`].
|
||||
#[must_use]
|
||||
pub fn grpc_settings(&self) -> std::option::Option<&ksp_onchain_transport_lib::YellowstoneGrpcTransportSettings> {
|
||||
return self.grpc_settings.as_ref();
|
||||
}
|
||||
|
||||
/// Consumes this resolved Config and returns the mapped runtime HTTP Transport settings.
|
||||
#[must_use]
|
||||
pub fn into_settings(self) -> ksp_onchain_transport_lib::HttpTransportSettings {
|
||||
return self.settings;
|
||||
}
|
||||
|
||||
/// Consumes this resolved Config and returns both HTTP and optional WebSocket runtime settings.
|
||||
/// Consumes this resolved Config and returns HTTP plus optional WebSocket runtime settings.
|
||||
///
|
||||
/// This compatibility accessor intentionally preserves the V2 tuple shape. Use [`Self::into_all_transport_settings`] when Yellowstone gRPC settings are
|
||||
/// also required.
|
||||
#[must_use]
|
||||
pub fn into_transport_settings(
|
||||
self,
|
||||
) -> (ksp_onchain_transport_lib::HttpTransportSettings, std::option::Option<ksp_onchain_transport_lib::WsTransportSettings>) {
|
||||
return (self.settings, self.ws_settings);
|
||||
}
|
||||
|
||||
/// Consumes this resolved Config and returns HTTP plus optional WebSocket and Yellowstone gRPC runtime settings.
|
||||
#[must_use]
|
||||
pub fn into_all_transport_settings(
|
||||
self,
|
||||
) -> (
|
||||
ksp_onchain_transport_lib::HttpTransportSettings,
|
||||
std::option::Option<ksp_onchain_transport_lib::WsTransportSettings>,
|
||||
std::option::Option<ksp_onchain_transport_lib::YellowstoneGrpcTransportSettings>,
|
||||
) {
|
||||
return (self.settings, self.ws_settings, self.grpc_settings);
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ResolvedTransportConfig {
|
||||
@@ -94,15 +118,17 @@ impl std::fmt::Debug for ResolvedTransportConfig {
|
||||
.field("selection_source", &self.selection_source)
|
||||
.field("effective", &self.effective)
|
||||
.field("has_ws_settings", &self.ws_settings.is_some())
|
||||
.field("has_grpc_settings", &self.grpc_settings.is_some())
|
||||
.finish_non_exhaustive();
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::ConfigDocumentEngine {
|
||||
/// Loads the standard Transport document, selects a profile, resolves environment placeholders and maps HTTP plus optional WebSocket runtime settings.
|
||||
/// Loads the standard Transport document, selects a profile, resolves environment placeholders and maps HTTP plus optional WebSocket and Yellowstone
|
||||
/// gRPC runtime settings.
|
||||
///
|
||||
/// `requested_profile = None` uses the document `default_profile`; `Some(profile_id)` requests an explicit profile. Secret endpoint URLs are allowed
|
||||
/// because Transport URL wrappers own runtime redaction. V1 documents remain HTTP-only; V2 documents require WebSocket defaults and endpoints.
|
||||
/// because Transport URL wrappers own runtime redaction. V1 remains HTTP-only, V2 adds WebSocket and V3 can additionally map Yellowstone gRPC.
|
||||
pub fn load_resolved_transport_config(
|
||||
&self,
|
||||
requested_profile: std::option::Option<&str>,
|
||||
@@ -121,7 +147,8 @@ impl crate::ConfigDocumentEngine {
|
||||
return resolve_transport_profile(&profile, environment);
|
||||
}
|
||||
|
||||
/// Maps an already resolved standard Transport profile to HTTP plus optional WebSocket runtime adapters while preserving selection provenance.
|
||||
/// Maps an already resolved standard Transport profile to HTTP plus optional WebSocket and Yellowstone gRPC runtime adapters while preserving
|
||||
/// selection provenance.
|
||||
///
|
||||
/// This entry point is intended for profiles selected by a composite. The profile must reference `cfg.std.transport`.
|
||||
pub fn resolve_transport_config_profile(
|
||||
@@ -148,9 +175,13 @@ struct EffectiveTransportSource {
|
||||
retry: EffectiveRetrySource,
|
||||
#[serde(default)]
|
||||
ws_defaults: std::option::Option<EffectiveWsSessionSource>,
|
||||
#[serde(default)]
|
||||
grpc_defaults: std::option::Option<EffectiveGrpcSessionSource>,
|
||||
endpoints: std::vec::Vec<EffectiveEndpointSource>,
|
||||
#[serde(default)]
|
||||
ws_endpoints: std::option::Option<std::vec::Vec<EffectiveWsEndpointSource>>,
|
||||
#[serde(default)]
|
||||
grpc_endpoints: std::option::Option<std::vec::Vec<EffectiveGrpcEndpointSource>>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
@@ -255,6 +286,72 @@ struct EffectiveWsReconnectOverrideSource {
|
||||
max_backoff_ms: std::option::Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveGrpcReconnectSource {
|
||||
max_retries: u32,
|
||||
initial_backoff_ms: u64,
|
||||
max_backoff_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveGrpcReconnectOverrideSource {
|
||||
max_retries: std::option::Option<u32>,
|
||||
initial_backoff_ms: std::option::Option<u64>,
|
||||
max_backoff_ms: std::option::Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveGrpcSessionSource {
|
||||
connect_timeout_ms: u64,
|
||||
unary_timeout_ms: u64,
|
||||
close_timeout_ms: u64,
|
||||
reconnect: EffectiveGrpcReconnectSource,
|
||||
request_channel_capacity: usize,
|
||||
update_channel_capacity: usize,
|
||||
max_inbound_message_size_bytes: usize,
|
||||
max_outbound_message_size_bytes: usize,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveGrpcSessionOverrideSource {
|
||||
connect_timeout_ms: std::option::Option<u64>,
|
||||
unary_timeout_ms: std::option::Option<u64>,
|
||||
close_timeout_ms: std::option::Option<u64>,
|
||||
reconnect: std::option::Option<EffectiveGrpcReconnectOverrideSource>,
|
||||
request_channel_capacity: std::option::Option<usize>,
|
||||
update_channel_capacity: std::option::Option<usize>,
|
||||
max_inbound_message_size_bytes: std::option::Option<usize>,
|
||||
max_outbound_message_size_bytes: std::option::Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveGrpcMetadataSource {
|
||||
key: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveGrpcEndpointSource {
|
||||
name: String,
|
||||
enabled: bool,
|
||||
provider: String,
|
||||
cluster: String,
|
||||
protocol: String,
|
||||
url: String,
|
||||
#[serde(default)]
|
||||
metadata: std::vec::Vec<EffectiveGrpcMetadataSource>,
|
||||
#[serde(default)]
|
||||
secret_metadata: std::vec::Vec<EffectiveGrpcMetadataSource>,
|
||||
#[serde(default)]
|
||||
session: std::option::Option<EffectiveGrpcSessionOverrideSource>,
|
||||
}
|
||||
|
||||
fn resolve_transport_profile(profile: &crate::ResolvedConfigProfile, environment: &crate::ConfigEnvironment) -> ksp_core_lib::Result<ResolvedTransportConfig> {
|
||||
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, profile_id = profile.profile_id(), "mapping standard Transport Config profile");
|
||||
let effective = profile.resolve_effective_environment_detailed(environment);
|
||||
@@ -294,16 +391,26 @@ fn resolve_transport_profile(profile: &crate::ResolvedConfigProfile, environment
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let grpc_settings = map_optional_grpc_settings(format_version, source.grpc_defaults, source.grpc_endpoints, &effective, profile);
|
||||
let grpc_settings = match grpc_settings {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let ws_endpoint_count = match ws_settings.as_ref() {
|
||||
std::option::Option::Some(value) => value.endpoints().len(),
|
||||
std::option::Option::None => 0_usize,
|
||||
};
|
||||
let grpc_endpoint_count = match grpc_settings.as_ref() {
|
||||
std::option::Option::Some(value) => value.endpoints().len(),
|
||||
std::option::Option::None => 0_usize,
|
||||
};
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
profile_id = profile.profile_id(),
|
||||
format_version,
|
||||
http_endpoint_count = settings.endpoints().len(),
|
||||
ws_endpoint_count,
|
||||
grpc_endpoint_count,
|
||||
"mapped standard Transport Config to runtime settings"
|
||||
);
|
||||
return std::result::Result::Ok(ResolvedTransportConfig {
|
||||
@@ -314,6 +421,7 @@ fn resolve_transport_profile(profile: &crate::ResolvedConfigProfile, environment
|
||||
effective,
|
||||
settings,
|
||||
ws_settings,
|
||||
grpc_settings,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -328,18 +436,22 @@ fn map_optional_ws_settings(
|
||||
if defaults.is_some() || sources.is_some() {
|
||||
std::result::Result::Err(effective_error(profile, "Transport V1 must remain HTTP-only"))
|
||||
} else {
|
||||
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, profile_id = profile.profile_id(), "mapped backward-compatible Transport V1 without WebSocket settings");
|
||||
ksp_logging_lib::trace!(
|
||||
target: crate::TRACING_TARGET,
|
||||
profile_id = profile.profile_id(),
|
||||
"mapped backward-compatible Transport V1 without WebSocket settings"
|
||||
);
|
||||
std::result::Result::Ok(std::option::Option::None)
|
||||
}
|
||||
},
|
||||
2 => {
|
||||
2 | 3 => {
|
||||
let defaults = match defaults {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(effective_error(profile, "Transport V2 requires ws_defaults")),
|
||||
std::option::Option::None => return std::result::Result::Err(effective_error(profile, "Transport V2/V3 requires ws_defaults")),
|
||||
};
|
||||
let sources = match sources {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(effective_error(profile, "Transport V2 profile requires ws_endpoints")),
|
||||
std::option::Option::None => return std::result::Result::Err(effective_error(profile, "Transport V2/V3 profile requires ws_endpoints")),
|
||||
};
|
||||
let endpoints = map_ws_endpoints(sources, &defaults, profile);
|
||||
let endpoints = match endpoints {
|
||||
@@ -360,6 +472,283 @@ fn map_optional_ws_settings(
|
||||
};
|
||||
}
|
||||
|
||||
fn map_optional_grpc_settings(
|
||||
format_version: u32,
|
||||
defaults: std::option::Option<EffectiveGrpcSessionSource>,
|
||||
sources: std::option::Option<std::vec::Vec<EffectiveGrpcEndpointSource>>,
|
||||
effective: &crate::ResolvedConfigJson,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
) -> ksp_core_lib::Result<std::option::Option<ksp_onchain_transport_lib::YellowstoneGrpcTransportSettings>> {
|
||||
return match format_version {
|
||||
1 | 2 => {
|
||||
if defaults.is_some() || sources.is_some() {
|
||||
std::result::Result::Err(effective_error(profile, "Transport V1/V2 must not define Yellowstone gRPC settings"))
|
||||
} else {
|
||||
std::result::Result::Ok(std::option::Option::None)
|
||||
}
|
||||
},
|
||||
3 => {
|
||||
let defaults = match defaults {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(effective_error(profile, "Transport V3 requires grpc_defaults")),
|
||||
};
|
||||
let sources = match sources {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Ok(std::option::Option::None),
|
||||
};
|
||||
let endpoints = map_grpc_endpoints(sources, &defaults, effective, profile);
|
||||
let endpoints = match endpoints {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let settings = ksp_onchain_transport_lib::YellowstoneGrpcTransportSettings::new(endpoints);
|
||||
if let std::result::Result::Err(error) = settings.validate() {
|
||||
return std::result::Result::Err(transport_contract_error(
|
||||
profile,
|
||||
"effective Yellowstone gRPC Transport settings fail the Transport runtime contract",
|
||||
&error,
|
||||
));
|
||||
}
|
||||
std::result::Result::Ok(std::option::Option::Some(settings))
|
||||
},
|
||||
_ => std::result::Result::Err(effective_error(profile, "effective Transport format_version is unsupported")),
|
||||
};
|
||||
}
|
||||
|
||||
fn map_grpc_endpoints(
|
||||
sources: std::vec::Vec<EffectiveGrpcEndpointSource>,
|
||||
defaults: &EffectiveGrpcSessionSource,
|
||||
effective: &crate::ResolvedConfigJson,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
) -> ksp_core_lib::Result<std::vec::Vec<ksp_onchain_transport_lib::YellowstoneGrpcEndpointSettings>> {
|
||||
let mut endpoints = std::vec::Vec::<ksp_onchain_transport_lib::YellowstoneGrpcEndpointSettings>::with_capacity(sources.len());
|
||||
for (endpoint_index, source) in sources.into_iter().enumerate() {
|
||||
let endpoint_name = source.name.clone();
|
||||
if source.protocol != "solana_yellowstone" {
|
||||
return std::result::Result::Err(
|
||||
effective_error(profile, "effective gRPC protocol is unsupported")
|
||||
.with_context("endpoint_name", endpoint_name)
|
||||
.with_context("grpc_protocol", source.protocol),
|
||||
);
|
||||
}
|
||||
let url = ksp_onchain_transport_lib::YellowstoneGrpcEndpointUrl::parse(source.url);
|
||||
let url = match url {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(
|
||||
transport_contract_error(profile, "effective Yellowstone gRPC endpoint URL is invalid", &error)
|
||||
.with_context("endpoint_name", endpoint_name),
|
||||
);
|
||||
},
|
||||
};
|
||||
let session = map_grpc_session_settings(defaults, source.session.as_ref(), profile, endpoint_name.as_str());
|
||||
let session = match session {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let metadata = map_grpc_metadata(source.metadata, source.secret_metadata, effective, profile, endpoint_name.as_str(), endpoint_index);
|
||||
let metadata = match metadata {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let endpoint = ksp_onchain_transport_lib::YellowstoneGrpcEndpointSettings::new(
|
||||
source.name,
|
||||
source.enabled,
|
||||
ksp_onchain_transport_lib::YellowstoneGrpcProviderName::new(source.provider),
|
||||
ksp_onchain_transport_lib::YellowstoneGrpcClusterName::new(source.cluster),
|
||||
url,
|
||||
session,
|
||||
);
|
||||
let endpoint = endpoint.with_metadata(metadata);
|
||||
let endpoint = match endpoint {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(
|
||||
transport_contract_error(profile, "effective Yellowstone gRPC metadata fail the Transport runtime contract", &error)
|
||||
.with_context("endpoint_name", endpoint_name),
|
||||
);
|
||||
},
|
||||
};
|
||||
endpoints.push(endpoint);
|
||||
}
|
||||
return std::result::Result::Ok(endpoints);
|
||||
}
|
||||
|
||||
fn map_grpc_session_settings(
|
||||
defaults: &EffectiveGrpcSessionSource,
|
||||
overrides: std::option::Option<&EffectiveGrpcSessionOverrideSource>,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
endpoint_name: &str,
|
||||
) -> ksp_core_lib::Result<ksp_onchain_transport_lib::YellowstoneGrpcSessionSettings> {
|
||||
let mut connect_timeout_ms = defaults.connect_timeout_ms;
|
||||
let mut unary_timeout_ms = defaults.unary_timeout_ms;
|
||||
let mut close_timeout_ms = defaults.close_timeout_ms;
|
||||
let mut reconnect_max_retries = defaults.reconnect.max_retries;
|
||||
let mut reconnect_initial_backoff_ms = defaults.reconnect.initial_backoff_ms;
|
||||
let mut reconnect_max_backoff_ms = defaults.reconnect.max_backoff_ms;
|
||||
let mut request_channel_capacity = defaults.request_channel_capacity;
|
||||
let mut update_channel_capacity = defaults.update_channel_capacity;
|
||||
let mut max_inbound_message_size_bytes = defaults.max_inbound_message_size_bytes;
|
||||
let mut max_outbound_message_size_bytes = defaults.max_outbound_message_size_bytes;
|
||||
if let std::option::Option::Some(overrides) = overrides {
|
||||
if let std::option::Option::Some(value) = overrides.connect_timeout_ms {
|
||||
connect_timeout_ms = value;
|
||||
}
|
||||
if let std::option::Option::Some(value) = overrides.unary_timeout_ms {
|
||||
unary_timeout_ms = value;
|
||||
}
|
||||
if let std::option::Option::Some(value) = overrides.close_timeout_ms {
|
||||
close_timeout_ms = value;
|
||||
}
|
||||
if let std::option::Option::Some(reconnect) = overrides.reconnect.as_ref() {
|
||||
if let std::option::Option::Some(value) = reconnect.max_retries {
|
||||
reconnect_max_retries = value;
|
||||
}
|
||||
if let std::option::Option::Some(value) = reconnect.initial_backoff_ms {
|
||||
reconnect_initial_backoff_ms = value;
|
||||
}
|
||||
if let std::option::Option::Some(value) = reconnect.max_backoff_ms {
|
||||
reconnect_max_backoff_ms = value;
|
||||
}
|
||||
}
|
||||
if let std::option::Option::Some(value) = overrides.request_channel_capacity {
|
||||
request_channel_capacity = value;
|
||||
}
|
||||
if let std::option::Option::Some(value) = overrides.update_channel_capacity {
|
||||
update_channel_capacity = value;
|
||||
}
|
||||
if let std::option::Option::Some(value) = overrides.max_inbound_message_size_bytes {
|
||||
max_inbound_message_size_bytes = value;
|
||||
}
|
||||
if let std::option::Option::Some(value) = overrides.max_outbound_message_size_bytes {
|
||||
max_outbound_message_size_bytes = value;
|
||||
}
|
||||
}
|
||||
let reconnect = ksp_onchain_transport_lib::YellowstoneGrpcReconnectSettings::new(
|
||||
reconnect_max_retries,
|
||||
std::time::Duration::from_millis(reconnect_initial_backoff_ms),
|
||||
std::time::Duration::from_millis(reconnect_max_backoff_ms),
|
||||
);
|
||||
let settings = ksp_onchain_transport_lib::YellowstoneGrpcSessionSettings::new(
|
||||
std::time::Duration::from_millis(connect_timeout_ms),
|
||||
std::time::Duration::from_millis(unary_timeout_ms),
|
||||
std::time::Duration::from_millis(close_timeout_ms),
|
||||
reconnect,
|
||||
request_channel_capacity,
|
||||
update_channel_capacity,
|
||||
max_inbound_message_size_bytes,
|
||||
max_outbound_message_size_bytes,
|
||||
);
|
||||
if let std::result::Result::Err(error) = settings.validate() {
|
||||
return std::result::Result::Err(
|
||||
transport_contract_error(profile, "effective Yellowstone gRPC session settings fail the Transport runtime contract", &error)
|
||||
.with_context("endpoint_name", endpoint_name),
|
||||
);
|
||||
}
|
||||
return std::result::Result::Ok(settings);
|
||||
}
|
||||
|
||||
fn map_grpc_metadata(
|
||||
public_sources: std::vec::Vec<EffectiveGrpcMetadataSource>,
|
||||
secret_sources: std::vec::Vec<EffectiveGrpcMetadataSource>,
|
||||
effective: &crate::ResolvedConfigJson,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
endpoint_name: &str,
|
||||
endpoint_index: usize,
|
||||
) -> ksp_core_lib::Result<std::vec::Vec<ksp_onchain_transport_lib::YellowstoneGrpcMetadataEntry>> {
|
||||
let mut metadata = std::vec::Vec::<ksp_onchain_transport_lib::YellowstoneGrpcMetadataEntry>::with_capacity(public_sources.len() + secret_sources.len());
|
||||
for (metadata_index, source) in public_sources.into_iter().enumerate() {
|
||||
let pointer = format!("/grpc_endpoints/{endpoint_index}/metadata/{metadata_index}/value");
|
||||
if let std::result::Result::Err(error) = validate_grpc_metadata_provenance(effective, pointer.as_str(), false, profile, endpoint_name) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let entry = ksp_onchain_transport_lib::YellowstoneGrpcMetadataEntry::public(source.key, source.value);
|
||||
let entry = match entry {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(
|
||||
transport_contract_error(profile, "effective public Yellowstone gRPC metadata are invalid", &error)
|
||||
.with_context("endpoint_name", endpoint_name),
|
||||
);
|
||||
},
|
||||
};
|
||||
metadata.push(entry);
|
||||
}
|
||||
for (metadata_index, source) in secret_sources.into_iter().enumerate() {
|
||||
let pointer = format!("/grpc_endpoints/{endpoint_index}/secret_metadata/{metadata_index}/value");
|
||||
if let std::result::Result::Err(error) = validate_grpc_metadata_provenance(effective, pointer.as_str(), true, profile, endpoint_name) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let entry = ksp_onchain_transport_lib::YellowstoneGrpcMetadataEntry::secret(source.key, source.value);
|
||||
let entry = match entry {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(
|
||||
transport_contract_error(profile, "effective secret Yellowstone gRPC metadata are invalid", &error)
|
||||
.with_context("endpoint_name", endpoint_name),
|
||||
);
|
||||
},
|
||||
};
|
||||
metadata.push(entry);
|
||||
}
|
||||
return std::result::Result::Ok(metadata);
|
||||
}
|
||||
|
||||
fn validate_grpc_metadata_provenance(
|
||||
effective: &crate::ResolvedConfigJson,
|
||||
pointer: &str,
|
||||
secret_class: bool,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
endpoint_name: &str,
|
||||
) -> ksp_core_lib::Result<()> {
|
||||
let provenance = match effective.provenance_at(pointer) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(
|
||||
effective_error(profile, "Yellowstone gRPC metadata provenance is unavailable")
|
||||
.with_context("endpoint_name", endpoint_name)
|
||||
.with_context("field", pointer),
|
||||
);
|
||||
},
|
||||
};
|
||||
let mut has_secret_environment = false;
|
||||
for item in provenance {
|
||||
let variable_name = match item.variable_name() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => continue,
|
||||
};
|
||||
let sensitivity = crate::ConfigSensitivity::from_variable_name(variable_name);
|
||||
let sensitivity = match sensitivity {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if sensitivity.is_secret() {
|
||||
has_secret_environment = true;
|
||||
if !secret_class {
|
||||
return std::result::Result::Err(
|
||||
effective_error(profile, "public Yellowstone gRPC metadata must not derive from a secret environment variable")
|
||||
.with_context("endpoint_name", endpoint_name)
|
||||
.with_context("field", pointer),
|
||||
);
|
||||
}
|
||||
} else if secret_class {
|
||||
return std::result::Result::Err(
|
||||
effective_error(profile, "secret Yellowstone gRPC metadata may reference only secret environment variables")
|
||||
.with_context("endpoint_name", endpoint_name)
|
||||
.with_context("field", pointer),
|
||||
);
|
||||
}
|
||||
}
|
||||
if secret_class && !has_secret_environment {
|
||||
return std::result::Result::Err(
|
||||
effective_error(profile, "secret Yellowstone gRPC metadata require secret environment provenance")
|
||||
.with_context("endpoint_name", endpoint_name)
|
||||
.with_context("field", pointer),
|
||||
);
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn map_endpoints(
|
||||
sources: std::vec::Vec<EffectiveEndpointSource>,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-config-lib/tests/ownership.rs
|
||||
// version: 6
|
||||
// version: 9
|
||||
|
||||
//! Workspace ownership audits for KSP application configuration boundaries.
|
||||
|
||||
@@ -177,7 +177,7 @@ fn has_preceding_explanatory_comment(source: &str, assignment_line_index: usize)
|
||||
#[test]
|
||||
fn foundational_dependency_direction_does_not_point_back_to_config() {
|
||||
let root = workspace_root();
|
||||
for crate_name in ["ksp-core-lib", "ksp-logging-lib"] {
|
||||
for crate_name in ["ksp-core-lib", "ksp-logging-lib", "ksp-offchain-transport-lib"] {
|
||||
let manifest_path = root.join("crates").join(crate_name).join("Cargo.toml");
|
||||
let manifest = std::fs::read_to_string(manifest_path.as_path());
|
||||
assert!(manifest.is_ok(), "unable to read {}", manifest_path.display());
|
||||
@@ -262,10 +262,13 @@ fn workspace_crates_do_not_hardcode_config_managed_physical_files() {
|
||||
};
|
||||
for token in [
|
||||
"\".env\"",
|
||||
"\"composite.ksp-app-solprices-desk.json\"",
|
||||
"\"composite.ksp-app-wallet-desk.json\"",
|
||||
"\"composite.schema.json\"",
|
||||
"\"std.logging.json\"",
|
||||
"\"std.logging.schema.json\"",
|
||||
"\"std.offchain_transport.json\"",
|
||||
"\"std.offchain_transport.schema.json\"",
|
||||
"\"std.transport.json\"",
|
||||
"\"std.transport.schema.json\"",
|
||||
"\"std.wallet.json\"",
|
||||
@@ -277,6 +280,49 @@ fn workspace_crates_do_not_hardcode_config_managed_physical_files() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_config_adapter_does_not_force_backend_feature_or_reverse_dependency() {
|
||||
let root = workspace_root();
|
||||
let config_manifest_path = root.join("crates/ksp-config-lib/Cargo.toml");
|
||||
let config_manifest = std::fs::read_to_string(config_manifest_path.as_path());
|
||||
assert!(config_manifest.is_ok(), "unable to read {}", config_manifest_path.display());
|
||||
if let std::result::Result::Ok(config_manifest) = config_manifest {
|
||||
assert!(
|
||||
config_manifest.contains("ksp-store-lib = { path = \"../ksp-store-lib\", default-features = false }"),
|
||||
"Config -> Store dependency must not force a physical backend feature"
|
||||
);
|
||||
}
|
||||
for crate_name in ["ksp-store-lib", "ksp-store-postgres-lib"] {
|
||||
let manifest_path = root.join("crates").join(crate_name).join("Cargo.toml");
|
||||
let manifest = std::fs::read_to_string(manifest_path.as_path());
|
||||
assert!(manifest.is_ok(), "unable to read {}", manifest_path.display());
|
||||
if let std::result::Result::Ok(manifest) = manifest {
|
||||
assert!(!manifest.contains("ksp-config-lib"), "{} must not depend back on Config", manifest_path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_runtime_sources_do_not_bypass_config_for_environment_or_libpq_files() {
|
||||
let root = workspace_root();
|
||||
for crate_name in ["ksp-store-lib", "ksp-store-postgres-lib"] {
|
||||
let source_root = root.join("crates").join(crate_name).join("src");
|
||||
let mut rust_files = std::vec::Vec::new();
|
||||
collect_rust_files(source_root.as_path(), &mut rust_files);
|
||||
for rust_file in rust_files {
|
||||
let source = std::fs::read_to_string(rust_file.as_path());
|
||||
assert!(source.is_ok(), "unable to read {}", rust_file.display());
|
||||
let source = match source {
|
||||
std::result::Result::Ok(value) => non_comment_source(value.as_str()),
|
||||
std::result::Result::Err(_) => continue,
|
||||
};
|
||||
for forbidden in ["std::env", "dotenv", "KSP_", "KSPB_", "\"PG", ".pgpass"] {
|
||||
assert!(!source.contains(forbidden), "{} bypasses Config through forbidden Store environment/libpq token {forbidden}", rust_file.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_name_scanner_ignores_namespace_labels_but_keeps_concrete_names() {
|
||||
let source = r#"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// file: crates/ksp-config-lib/tests/public_api.rs
|
||||
// version: 22
|
||||
// version: 26
|
||||
|
||||
//! Integration tests for the public `ksp-config-lib` bootstrap, registry, JSON/profile/composite, environment-resolution, sensitivity,
|
||||
//! Logging/Transport adapters and management contracts.
|
||||
//! Logging/Transport/Store adapters and management contracts.
|
||||
|
||||
#[test]
|
||||
fn bootstrap_contract_is_available_from_crate_root() {
|
||||
@@ -80,16 +80,21 @@ fn registry_descriptor_inventory_is_available_from_crate_root() {
|
||||
assert!(registry.is_ok(), "public registry should remain constructible: {registry:?}");
|
||||
if let std::result::Result::Ok(registry) = registry {
|
||||
let descriptors: std::vec::Vec<&ksp_config_lib::ConfigFileDescriptor> = registry.descriptors().collect();
|
||||
assert_eq!(descriptors.len(), 8);
|
||||
assert_eq!(descriptors[0].file_id().as_str(), ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK);
|
||||
assert_eq!(descriptors[1].file_id().as_str(), ksp_config_lib::FILE_ID_STD_LOGGING);
|
||||
assert_eq!(descriptors[2].file_id().as_str(), ksp_config_lib::FILE_ID_STD_TRANSPORT);
|
||||
assert_eq!(descriptors[3].file_id().as_str(), ksp_config_lib::FILE_ID_STD_WALLET);
|
||||
assert_eq!(descriptors[4].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_COMPOSITE);
|
||||
assert_eq!(descriptors[5].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_LOGGING);
|
||||
assert_eq!(descriptors[6].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_TRANSPORT);
|
||||
assert_eq!(descriptors[7].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_WALLET);
|
||||
let schema_file_id = descriptors[3].schema_file_id();
|
||||
assert_eq!(descriptors.len(), 13);
|
||||
assert_eq!(descriptors[0].file_id().as_str(), ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_SOLPRICES_DESK);
|
||||
assert_eq!(descriptors[1].file_id().as_str(), ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK);
|
||||
assert_eq!(descriptors[2].file_id().as_str(), ksp_config_lib::FILE_ID_STD_LOGGING);
|
||||
assert_eq!(descriptors[3].file_id().as_str(), ksp_config_lib::FILE_ID_STD_OFFCHAIN_TRANSPORT);
|
||||
assert_eq!(descriptors[4].file_id().as_str(), ksp_config_lib::FILE_ID_STD_STORE);
|
||||
assert_eq!(descriptors[5].file_id().as_str(), ksp_config_lib::FILE_ID_STD_TRANSPORT);
|
||||
assert_eq!(descriptors[6].file_id().as_str(), ksp_config_lib::FILE_ID_STD_WALLET);
|
||||
assert_eq!(descriptors[7].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_COMPOSITE);
|
||||
assert_eq!(descriptors[8].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_LOGGING);
|
||||
assert_eq!(descriptors[9].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT);
|
||||
assert_eq!(descriptors[10].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_STORE);
|
||||
assert_eq!(descriptors[11].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_TRANSPORT);
|
||||
assert_eq!(descriptors[12].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_WALLET);
|
||||
let schema_file_id = descriptors[6].schema_file_id();
|
||||
assert!(schema_file_id.is_some(), "public Wallet descriptor should preserve schema association");
|
||||
if let std::option::Option::Some(schema_file_id) = schema_file_id {
|
||||
assert_eq!(schema_file_id.as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_WALLET);
|
||||
@@ -158,6 +163,8 @@ fn composite_schema_and_provenance_contracts_are_available_from_crate_root() {
|
||||
assert_eq!(descriptor.kind(), ksp_config_lib::ConfigFileKind::Schema);
|
||||
}
|
||||
}
|
||||
assert_eq!(ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_SOLPRICES_DESK, "cfg.composite.ksp-app-solprices-desk");
|
||||
assert_eq!(ksp_config_lib::DEFAULT_COMPOSITE_KSP_APP_SOLPRICES_DESK_FILENAME, "composite.ksp-app-solprices-desk.json");
|
||||
assert_ne!(ksp_config_lib::ConfigProfileSelectionSource::Composite, ksp_config_lib::ConfigProfileSelectionSource::Explicit);
|
||||
}
|
||||
|
||||
@@ -206,6 +213,18 @@ fn logging_adapter_contract_is_available_from_crate_root() {
|
||||
assert!(std::mem::size_of::<ksp_config_lib::ResolvedLoggingConfig>() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_adapter_contract_is_available_from_crate_root() {
|
||||
let adapter = ksp_config_lib::ConfigDocumentEngine::load_resolved_store_config;
|
||||
let composite_adapter = ksp_config_lib::ConfigDocumentEngine::resolve_store_config_profile;
|
||||
let _ = (adapter, composite_adapter);
|
||||
assert_eq!(ksp_config_lib::FILE_ID_STD_STORE, "cfg.std.store");
|
||||
assert_eq!(ksp_config_lib::FILE_ID_SCHEMA_STD_STORE, "schema.std.store");
|
||||
assert_eq!(ksp_config_lib::DEFAULT_STD_STORE_FILENAME, "std.store.json");
|
||||
assert_eq!(ksp_config_lib::DEFAULT_STD_STORE_SCHEMA_FILENAME, "std.store.schema.json");
|
||||
assert!(std::mem::size_of::<ksp_config_lib::ResolvedStoreConfig>() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn management_contracts_are_available_from_crate_root() {
|
||||
let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
@@ -254,7 +273,9 @@ fn transport_adapter_contract_is_available_from_crate_root() {
|
||||
assert!(std::mem::size_of::<ksp_config_lib::ResolvedTransportConfig>() > 0);
|
||||
let _http_settings = ksp_config_lib::ResolvedTransportConfig::http_settings;
|
||||
let _ws_settings = ksp_config_lib::ResolvedTransportConfig::ws_settings;
|
||||
let _grpc_settings = ksp_config_lib::ResolvedTransportConfig::grpc_settings;
|
||||
let _into_transport_settings = ksp_config_lib::ResolvedTransportConfig::into_transport_settings;
|
||||
let _into_all_transport_settings = ksp_config_lib::ResolvedTransportConfig::into_all_transport_settings;
|
||||
assert_eq!(ksp_config_lib::FILE_ID_STD_TRANSPORT, "cfg.std.transport");
|
||||
assert_eq!(ksp_config_lib::FILE_ID_SCHEMA_STD_TRANSPORT, "schema.std.transport");
|
||||
assert_eq!(ksp_config_lib::DEFAULT_STD_TRANSPORT_FILENAME, "std.transport.json");
|
||||
@@ -274,6 +295,18 @@ fn wallet_adapter_contract_is_available_from_crate_root() {
|
||||
assert_eq!(ksp_config_lib::DEFAULT_STD_WALLET_SCHEMA_FILENAME, "std.wallet.schema.json");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offchain_transport_adapter_contract_is_available_from_crate_root() {
|
||||
let _loader = ksp_config_lib::ConfigDocumentEngine::load_resolved_offchain_transport_config;
|
||||
let _composite_loader = ksp_config_lib::ConfigDocumentEngine::resolve_offchain_transport_config_profile;
|
||||
assert!(std::mem::size_of::<ksp_config_lib::ResolvedOffchainTransportConfig>() > 0);
|
||||
let _service = ksp_config_lib::ResolvedOffchainTransportConfig::service;
|
||||
assert_eq!(ksp_config_lib::FILE_ID_STD_OFFCHAIN_TRANSPORT, "cfg.std.offchain_transport");
|
||||
assert_eq!(ksp_config_lib::FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT, "schema.std.offchain_transport");
|
||||
assert_eq!(ksp_config_lib::DEFAULT_STD_OFFCHAIN_TRANSPORT_FILENAME, "std.offchain_transport.json");
|
||||
assert_eq!(ksp_config_lib::DEFAULT_STD_OFFCHAIN_TRANSPORT_SCHEMA_FILENAME, "std.offchain_transport.schema.json");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packaged_runtime_layout_contract_is_available_from_crate_root() {
|
||||
let prepare: fn(&std::path::Path) -> ksp_core_lib::Result<ksp_config_lib::PackagedRuntimeLayout> = ksp_config_lib::prepare_packaged_runtime;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"default_profile": "public_keyless",
|
||||
"profiles": [
|
||||
{
|
||||
"profile_id": "public_keyless",
|
||||
"documents": [
|
||||
{
|
||||
"component_id": "logging",
|
||||
"file_id": "cfg.std.logging",
|
||||
"profile_id": "local_dev"
|
||||
},
|
||||
{
|
||||
"component_id": "offchain_transport",
|
||||
"file_id": "cfg.std.offchain_transport",
|
||||
"profile_id": "public_keyless"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"profile_id": "all_free",
|
||||
"documents": [
|
||||
{
|
||||
"component_id": "logging",
|
||||
"file_id": "cfg.std.logging",
|
||||
"profile_id": "local_dev"
|
||||
},
|
||||
{
|
||||
"component_id": "offchain_transport",
|
||||
"file_id": "cfg.std.offchain_transport",
|
||||
"profile_id": "all_free"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"profile_id": "tests",
|
||||
"documents": [
|
||||
{
|
||||
"component_id": "logging",
|
||||
"file_id": "cfg.std.logging",
|
||||
"profile_id": "local_dev"
|
||||
},
|
||||
{
|
||||
"component_id": "offchain_transport",
|
||||
"file_id": "cfg.std.offchain_transport",
|
||||
"profile_id": "public_keyless"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user