diff --git a/CHANGELOG.md b/CHANGELOG.md index fd96891..4be20e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,4 +17,5 @@ 0.4.3 - Pool d’endpoints HTTP 0.4.4 - Ajout de la fenêtre Demo Http dans kb_app, exécution manuelle des méthodes HTTP via le pool, snapshot des endpoints et amélioration des presets UI 0.5.0 - Début du socle SQLite : configuration database, ouverture/validation de la base et premières briques de persistance -0.5.1 - Ajout des premières tables métier SQLite pour les endpoints connus et les événements runtime, avec séparation entities/dtos/queries/types +0.5.1 - Ajout des premières tables métier SQLite pour les endpoints connus HTTP/WS et les événements runtime, avec séparation entities/dtos/queries/types +0.5.2 - Ajout de la table des tokens observés, de leur statut local et des premières requêtes de persistance associées diff --git a/Cargo.toml b/Cargo.toml index 49a12a6..0eac541 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ ] [workspace.package] -version = "0.5.1" +version = "0.5.2" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-bobobot" diff --git a/ROADMAP.md b/ROADMAP.md index b85bba5..aebee78 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -141,7 +141,6 @@ Le tracing est centralisé dans `kb_lib`. ## 6. Phasage par versions ### 6.1. Version `0.0.2` — Socle conforme - Objectif : corriger le squelette et poser la base de travail. Réalisé : @@ -157,7 +156,6 @@ Réalisé : - UI Tauri minimale. ### 6.2. Version `0.1.x` — Transport WebSocket générique - Objectif : construire un vrai `WsClient` asynchrone clonable. Réalisé : @@ -172,7 +170,6 @@ Réalisé : - tests offline avec serveur mock. ### 6.3. Version `0.1.1` — Intégration Tauri minimale du `WsClient` - Objectif : valider le transport via l’application desktop. Réalisé : @@ -183,7 +180,6 @@ Réalisé : - validation du flux `frontend -> tauri -> kb_lib -> frontend`. ### 6.4. Version `0.2.0` — Couche JSON-RPC WS Solana - Objectif : séparer clairement transport, réponses RPC et notifications. Réalisé : @@ -195,7 +191,6 @@ Réalisé : - premiers helpers JSON-RPC sur `WsClient`. ### 6.5. Version `0.3.0` — Registre subscriptions / notifications - Objectif : fiabiliser la gestion des subscriptions. Réalisé : @@ -208,7 +203,6 @@ Réalisé : - routage séparé des notifications. ### 6.6. Version `0.3.1` — Helpers subscribe/unsubscribe WebSocket - Objectif : ajouter les helpers haut niveau correspondant aux principales méthodes PubSub Solana. Réalisé : @@ -218,7 +212,6 @@ Réalisé : - premiers tests de validation des noms de méthodes. ### 6.7. Version `0.3.2` — Helpers typed et notifications typed - Objectif : s’appuyer principalement sur `solana-rpc-client-api` pour typer les subscribe et les notifications. Réalisé : @@ -228,7 +221,6 @@ Réalisé : - base de travail pour réduire l’usage direct de `serde_json::Value`. ### 6.8. Version `0.3.3` — Distinction API typed / raw - Objectif : clarifier l’API publique de `WsClient`. Réalisé : @@ -238,7 +230,6 @@ Réalisé : - préparation d’une hiérarchie API plus explicite. ### 6.9. Version `0.3.4` — Fenêtre `Demo Ws` dans `kb_app` - Objectif : tester manuellement les souscriptions live dans une fenêtre dédiée. Réalisé : @@ -251,7 +242,6 @@ Réalisé : - premiers tests réels sur `wss://api.mainnet.solana.com`. ### 6.10. Version `0.3.5` — Stabilisation de `Demo Ws` - Objectif : rendre la fenêtre de démonstration robuste sous flux élevé et cohérente avec la configuration. Réalisé : @@ -269,8 +259,7 @@ Réalisé : Objectif : construire un `HttpClient` clonable, limité et extensible, puis ajouter les premiers helpers HTTP Solana. -### 0.4.0 — Socle `HttpClient` - +### 6.12. Version `0.4.0` — Socle `HttpClient` Réalisé : - client `reqwest` asynchrone clonable, @@ -291,8 +280,7 @@ Livrables : - `getVersion` - `getSlot` -### 0.4.1 — Helpers HTTP Solana - +### 6.13. Version `0.4.1` — Helpers HTTP Solana Réalisé : - ajouter des helpers HTTP haut niveau comme pour le client WS, @@ -300,8 +288,7 @@ Réalisé : - couvrir les premières méthodes utiles du RPC HTTP Solana, - conserver `HttpClient` comme couche générique réutilisable. -### 0.4.2 — Politique HTTP avancée - +### 6.14. Version `0.4.2` — Politique HTTP avancée Réalisé : - préparer un état de pause avant envoi pour un endpoint HTTP, @@ -309,8 +296,7 @@ Réalisé : - distinguer quota RPC général et quota `sendTransaction`, - préparer un futur pool d’endpoints HTTP et l’arbitrage entre eux. -### 0.4.3 — Pool d’endpoints HTTP - +### 6.15. Version `0.4.3` — Pool d’endpoints HTTP Réalisé : - ajouter un pool d’`HttpClient`, @@ -320,8 +306,7 @@ Réalisé : - prendre en compte la classe de méthode HTTP, - préparer le routage multi-RPC et la limitation de concurrence par endpoint. -### 0.4.4 — Démo HTTP dans `kb_app` - +### 6.16. Version `0.4.4` — Démo HTTP dans `kb_app` Réalisé : - ajout d’une fenêtre `Demo Http`, @@ -332,11 +317,10 @@ Réalisé : - alignement visuel de la fenêtre sur le gabarit `Demo Ws`, - amélioration des presets UI, copie de réponse et bascule pretty/raw. -## 6.12. Version `0.5.x` — Base de données SQLite - +### 6.17. Version `0.5.x` — Base de données SQLite Objectif : poser la persistance locale avec une organisation préparée dès le départ à une future évolution vers PostgreSQL ou un autre backend. -#### 0.5.0 — Socle SQLite +### 6.18. Version `0.5.0` — Socle SQLite Réalisé : - configuration DB dans `config.json`, @@ -346,29 +330,32 @@ Réalisé : - table `kb_db_metadata`, - séparation `db/entities`, `db/dtos`, `db/queries`, `db/types`. -#### 0.5.1 — Premières tables métier de stockage local -À faire : +### 6.19. Version `0.5.1` — Premières tables métier de stockage local +Réalisé : -- ajouter les tables de référence pour les endpoints connus, -- ajouter les tables techniques pour les événements runtime locaux, -- poser les `entities`, `dtos`, `queries` et `types` associés, -- préparer le stockage local des endpoints HTTP/WS connus et de leur état utile. +- ajout des tables de référence pour les endpoints connus HTTP/WS, +- ajout des tables techniques pour les événements runtime locaux, +- mise en place des `entities`, `dtos`, `queries` et `types` associés, +- préparation du stockage local des endpoints HTTP/WS connus et de leur état utile. -#### 0.5.2 — Stockage des tokens observés -À faire : +### 6.20. Version `0.5.2` — Stockage des tokens observés +Réalisé : -- ajouter les premières tables liées aux tokens observés, -- préparer le stockage minimal des mints, symboles, statuts et dates de découverte, -- préparer les relations futures avec pools, paires et événements on-chain. +- ajout de la table `kb_observed_tokens`, +- stockage minimal des mints, symboles, noms, statuts et dates d’observation, +- ajout du `token_program`, +- préparation des relations futures avec pools, paires et événements on-chain, +- conservation d’unicité locale par mint sans duplication par endpoint. -#### 0.5.3 — Événements et signaux locaux +### 6.21. Version `0.5.3` — Événements et signaux locaux À faire : - stocker les événements techniques importants remontés par les connecteurs, - préparer la conservation locale des signaux utiles à l’analyse, -- distinguer événements runtime, observations on-chain et événements métier. +- distinguer événements runtime, observations on-chain et événements métier, +- préparer la traçabilité de provenance si plusieurs sources détectent un même objet, sans remettre en cause l’unicité locale d’un token par mint. -#### 0.5.x — Consolidation de la couche stockage +### 6.22. Version `0.5.x` — Consolidation de la couche stockage À faire : - conserver l’abstraction du backend dès le départ, @@ -376,8 +363,7 @@ Réalisé : - garder les conversions explicites entre entités DB et DTOs applicatifs, - préparer une future compatibilité PostgreSQL sans casser l’organisation générale. -### 6.13. Version `0.6.x` — Détection technique on-chain / RPC - +### 6.23. Version `0.6.x` — Détection technique on-chain / RPC Objectif : commencer la détection utile pour l’application. À faire : @@ -387,8 +373,7 @@ Objectif : commencer la détection utile pour l’application. - débuts de normalisation d’événements, - premiers connecteurs DEX. -### 6.14. Version `0.7.x` — DEX connectors v1 - +### 6.24. Version `0.7.x` — DEX connectors v1 Objectif : structurer les connecteurs par protocole. Cibles initiales possibles : @@ -407,8 +392,7 @@ Cibles initiales possibles : - création de types métiers propres, - enrichissement des métadonnées token/pool/pair. -### 6.15. Version `0.8.x` — Analyse et filtrage - +### 6.25. Version `0.8.x` — Analyse et filtrage Objectif : transformer les événements bruts en signaux exploitables. À faire : @@ -419,8 +403,7 @@ Objectif : transformer les événements bruts en signaux exploitables. - statistiques de comportement, - premiers patterns. -### 6.16. Version `1.x.y` — Wallets et swap préparatoire - +### 6.26. Version `1.x.y` — Wallets et swap préparatoire Objectif : préparer la couche d’action. À faire : @@ -431,8 +414,7 @@ Objectif : préparer la couche d’action. - préparation d’ordres et de swaps, - simulation et garde-fous. -### 6.17. Version `2.x.y` — Trading semi-automatisé - +### 6.27. Version `2.x.y` — Trading semi-automatisé Objectif : brancher l’analyse à l’action tout en gardant des garde-fous explicites. À faire : @@ -443,8 +425,7 @@ Objectif : brancher l’analyse à l’action tout en gardant des garde-fous exp - confirmations explicites ou semi-automatiques, - journaux d’exécution. -### 6.18. Version `3.x.y` — Yellowstone gRPC - +### 6.28. Version `3.x.y` — Yellowstone gRPC Objectif : ajouter le connecteur gRPC dédié. À faire : @@ -457,7 +438,6 @@ Objectif : ajouter le connecteur gRPC dédié. ## 7. Organisation des modules ciblés ### 7.1. `kb_lib` - Modules cibles à court terme : - `error.rs` @@ -472,7 +452,6 @@ Modules cibles à court terme : - `rpc_ws_solana.rs` ### 7.2. `kb_app` - Responsabilités cibles : - lancement Tauri, @@ -483,7 +462,6 @@ Responsabilités cibles : - fenêtres de démonstration / diagnostic isolées. ## 8. Ligne de conduite sur le `WsClient` - Le `WsClient` doit être conçu en plusieurs couches : 1. transport brut WebSocket, @@ -500,7 +478,6 @@ Cette séparation évite de mélanger : - les notifications push. ## 9. Politique initiale de reconnexion - Au départ : - pas de reconnexion automatique, @@ -510,7 +487,6 @@ Au départ : Plus tard, ce comportement pourra devenir configurable dans `config.json` et pilotable depuis l’application. ## 10. Politique initiale de fermeture - À la fermeture d’un `WsClient` : 1. marquer le client en arrêt, @@ -522,7 +498,6 @@ Plus tard, ce comportement pourra devenir configurable dans `config.json` et pil 7. journaliser clairement les cas dégradés. ## 11. Documentation et livrables de référence - Le projet doit maintenir au minimum : - un `README.md` global, @@ -533,12 +508,11 @@ Le projet doit maintenir au minimum : - les bindings TS générés via `cargo test export_bindings` lorsque les types partagés évoluent. ## 12. Priorité immédiate - La priorité immédiate est désormais la suivante : -1. démarrer la version `0.5.1` avec les premières tables métier SQLite, -2. ajouter les tables locales pour les endpoints connus HTTP/WS, -3. ajouter les tables locales pour les événements runtime techniques, -4. structurer ces tables via `db/entities`, `db/dtos`, `db/queries` et `db/types`, +1. démarrer la version `0.5.3` avec les événements et signaux locaux, +2. stocker les événements techniques importants remontés par les connecteurs, +3. distinguer clairement événements runtime, observations on-chain et événements métier, +4. préparer la conservation locale des signaux utiles à l’analyse, 5. conserver l’abstraction du backend dès cette phase SQLite, -6. préparer ensuite le stockage des tokens observés et des premiers signaux persistés. +6. préparer ensuite l’exploitation de ces signaux pour la future détection technique on-chain / RPC. diff --git a/kb_lib/src/db.rs b/kb_lib/src/db.rs index 735bfde..fbaf3bb 100644 --- a/kb_lib/src/db.rs +++ b/kb_lib/src/db.rs @@ -19,20 +19,26 @@ pub use crate::db::dtos::KbDbMetadataDto; pub use crate::db::dtos::KbDbRuntimeEventDto; pub use crate::db::dtos::KbKnownHttpEndpointDto; pub use crate::db::dtos::KbKnownWsEndpointDto; +pub use crate::db::dtos::KbObservedTokenDto; pub use crate::db::entities::KbDbMetadataEntity; pub use crate::db::entities::KbDbRuntimeEventEntity; pub use crate::db::entities::KbKnownHttpEndpointEntity; pub use crate::db::entities::KbKnownWsEndpointEntity; +pub use crate::db::entities::KbObservedTokenEntity; pub use crate::db::queries::get_db_metadata; pub use crate::db::queries::get_known_http_endpoint; pub use crate::db::queries::get_known_ws_endpoint; +pub use crate::db::queries::get_observed_token_by_mint; pub use crate::db::queries::insert_db_runtime_event; pub use crate::db::queries::list_db_metadata; pub use crate::db::queries::list_known_http_endpoints; pub use crate::db::queries::list_known_ws_endpoints; +pub use crate::db::queries::list_observed_tokens; pub use crate::db::queries::list_recent_db_runtime_events; pub use crate::db::queries::upsert_db_metadata; pub use crate::db::queries::upsert_known_http_endpoint; pub use crate::db::queries::upsert_known_ws_endpoint; +pub use crate::db::queries::upsert_observed_token; pub use crate::db::types::KbDatabaseBackend; pub use crate::db::types::KbDbRuntimeEventLevel; +pub use crate::db::types::KbObservedTokenStatus; diff --git a/kb_lib/src/db/dtos.rs b/kb_lib/src/db/dtos.rs index 13bc1e5..6a2e2e9 100644 --- a/kb_lib/src/db/dtos.rs +++ b/kb_lib/src/db/dtos.rs @@ -6,8 +6,10 @@ mod db_metadata; mod db_runtime_event; mod known_http_endpoint; mod known_ws_endpoint; +mod observed_token; pub use crate::db::dtos::db_metadata::KbDbMetadataDto; pub use crate::db::dtos::db_runtime_event::KbDbRuntimeEventDto; pub use crate::db::dtos::known_http_endpoint::KbKnownHttpEndpointDto; pub use crate::db::dtos::known_ws_endpoint::KbKnownWsEndpointDto; +pub use crate::db::dtos::observed_token::KbObservedTokenDto; diff --git a/kb_lib/src/db/dtos/observed_token.rs b/kb_lib/src/db/dtos/observed_token.rs new file mode 100644 index 0000000..8e99b16 --- /dev/null +++ b/kb_lib/src/db/dtos/observed_token.rs @@ -0,0 +1,129 @@ +// file: kb_lib/src/db/dtos/observed_token.rs + +//! Observed token DTO. + +/// Application-facing observed token DTO. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct KbObservedTokenDto { + /// Optional numeric primary key. + pub id: std::option::Option, + /// Token mint address. + pub mint: std::string::String, + /// Optional token symbol. + pub symbol: std::option::Option, + /// Optional token display name. + pub name: std::option::Option, + /// Optional decimals value. + pub decimals: std::option::Option, + /// Token program id. + pub token_program: std::string::String, + /// Local status. + pub status: crate::KbObservedTokenStatus, + /// First seen timestamp. + pub first_seen_at: chrono::DateTime, + /// Last seen timestamp. + pub last_seen_at: chrono::DateTime, + /// Last update timestamp. + pub updated_at: chrono::DateTime, +} + +impl KbObservedTokenDto { + /// Creates a new observed token DTO with current timestamps. + pub fn new( + mint: std::string::String, + symbol: std::option::Option, + name: std::option::Option, + decimals: std::option::Option, + token_program: std::string::String, + status: crate::KbObservedTokenStatus, + ) -> Self { + let now = chrono::Utc::now(); + Self { + id: None, + mint, + symbol, + name, + decimals, + token_program, + status, + first_seen_at: now, + last_seen_at: now, + updated_at: now, + } + } +} + +impl TryFrom for KbObservedTokenDto { + type Error = crate::KbError; + + fn try_from( + entity: crate::KbObservedTokenEntity, + ) -> Result { + let status_result = crate::KbObservedTokenStatus::from_i16(entity.status); + let status = match status_result { + Ok(status) => status, + Err(error) => return Err(error), + }; + let first_seen_at_result = chrono::DateTime::parse_from_rfc3339(&entity.first_seen_at); + let first_seen_at = match first_seen_at_result { + Ok(first_seen_at) => first_seen_at.with_timezone(&chrono::Utc), + Err(error) => { + return Err(crate::KbError::Db(format!( + "cannot parse observed token first_seen_at '{}': {}", + entity.first_seen_at, + error + ))); + }, + }; + let last_seen_at_result = chrono::DateTime::parse_from_rfc3339(&entity.last_seen_at); + let last_seen_at = match last_seen_at_result { + Ok(last_seen_at) => last_seen_at.with_timezone(&chrono::Utc), + Err(error) => { + return Err(crate::KbError::Db(format!( + "cannot parse observed token last_seen_at '{}': {}", + entity.last_seen_at, + error + ))); + }, + }; + let updated_at_result = chrono::DateTime::parse_from_rfc3339(&entity.updated_at); + let updated_at = match updated_at_result { + Ok(updated_at) => updated_at.with_timezone(&chrono::Utc), + Err(error) => { + return Err(crate::KbError::Db(format!( + "cannot parse observed token updated_at '{}': {}", + entity.updated_at, + error + ))); + }, + }; + let decimals = match entity.decimals { + Some(decimals) => { + let decimals_u8_result = u8::try_from(decimals); + match decimals_u8_result { + Ok(decimals_u8) => Some(decimals_u8), + Err(error) => { + return Err(crate::KbError::Db(format!( + "cannot convert observed token decimals '{}' to u8: {}", + decimals, + error + ))); + }, + } + }, + None => None, + }; + Ok(Self { + id: Some(entity.id), + mint: entity.mint, + symbol: entity.symbol, + name: entity.name, + decimals, + token_program: entity.token_program, + status, + first_seen_at, + last_seen_at, + updated_at, + }) + } +} diff --git a/kb_lib/src/db/entities.rs b/kb_lib/src/db/entities.rs index 01d1041..fdaa036 100644 --- a/kb_lib/src/db/entities.rs +++ b/kb_lib/src/db/entities.rs @@ -8,8 +8,10 @@ mod db_metadata; mod db_runtime_event; mod known_http_endpoint; mod known_ws_endpoint; +mod observed_token; pub use crate::db::entities::db_metadata::KbDbMetadataEntity; pub use crate::db::entities::db_runtime_event::KbDbRuntimeEventEntity; pub use crate::db::entities::known_http_endpoint::KbKnownHttpEndpointEntity; pub use crate::db::entities::known_ws_endpoint::KbKnownWsEndpointEntity; +pub use crate::db::entities::observed_token::KbObservedTokenEntity; diff --git a/kb_lib/src/db/entities/observed_token.rs b/kb_lib/src/db/entities/observed_token.rs new file mode 100644 index 0000000..37fd5ae --- /dev/null +++ b/kb_lib/src/db/entities/observed_token.rs @@ -0,0 +1,28 @@ +// file: kb_lib/src/db/entities/observed_token.rs + +//! Observed token entity. + +/// Persisted observed token row. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow)] +pub struct KbObservedTokenEntity { + /// Numeric primary key. + pub id: i64, + /// Token mint address. + pub mint: std::string::String, + /// Optional token symbol. + pub symbol: std::option::Option, + /// Optional token display name. + pub name: std::option::Option, + /// Optional decimals value. + pub decimals: std::option::Option, + /// Token program id. + pub token_program: std::string::String, + /// Local status stored as stable integer. + pub status: i16, + /// First seen timestamp encoded as RFC3339 UTC text. + pub first_seen_at: std::string::String, + /// Last seen timestamp encoded as RFC3339 UTC text. + pub last_seen_at: std::string::String, + /// Last update timestamp encoded as RFC3339 UTC text. + pub updated_at: std::string::String, +} diff --git a/kb_lib/src/db/queries.rs b/kb_lib/src/db/queries.rs index 0e7a983..898189e 100644 --- a/kb_lib/src/db/queries.rs +++ b/kb_lib/src/db/queries.rs @@ -6,6 +6,7 @@ mod db_metadata; mod db_runtime_event; mod known_http_endpoint; mod known_ws_endpoint; +mod observed_token; pub use crate::db::queries::db_metadata::get_db_metadata; pub use crate::db::queries::db_metadata::list_db_metadata; @@ -18,3 +19,6 @@ pub use crate::db::queries::known_http_endpoint::upsert_known_http_endpoint; pub use crate::db::queries::known_ws_endpoint::get_known_ws_endpoint; pub use crate::db::queries::known_ws_endpoint::list_known_ws_endpoints; pub use crate::db::queries::known_ws_endpoint::upsert_known_ws_endpoint; +pub use crate::db::queries::observed_token::get_observed_token_by_mint; +pub use crate::db::queries::observed_token::list_observed_tokens; +pub use crate::db::queries::observed_token::upsert_observed_token; diff --git a/kb_lib/src/db/queries/observed_token.rs b/kb_lib/src/db/queries/observed_token.rs new file mode 100644 index 0000000..67b0c77 --- /dev/null +++ b/kb_lib/src/db/queries/observed_token.rs @@ -0,0 +1,230 @@ +// file: kb_lib/src/db/queries/observed_token.rs + +//! Queries for `kb_observed_tokens`. + +/// Inserts or updates one observed token by mint. +pub async fn upsert_observed_token( + database: &crate::KbDatabase, + dto: &crate::KbObservedTokenDto, +) -> Result { + match database.connection() { + crate::KbDatabaseConnection::Sqlite(pool) => { + let decimals_i64 = dto.decimals.map(i64::from); + let insert_result = sqlx::query( + r#" +INSERT INTO kb_observed_tokens ( + mint, + symbol, + name, + decimals, + token_program, + status, + first_seen_at, + last_seen_at, + updated_at +) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(mint) DO UPDATE SET + symbol = excluded.symbol, + name = excluded.name, + decimals = excluded.decimals, + token_program = excluded.token_program, + status = excluded.status, + last_seen_at = excluded.last_seen_at, + updated_at = excluded.updated_at + "#, + ) + .bind(dto.mint.clone()) + .bind(dto.symbol.clone()) + .bind(dto.name.clone()) + .bind(decimals_i64) + .bind(dto.token_program.clone()) + .bind(dto.status.to_i16()) + .bind(dto.first_seen_at.to_rfc3339()) + .bind(dto.last_seen_at.to_rfc3339()) + .bind(dto.updated_at.to_rfc3339()) + .execute(pool) + .await; + if let Err(error) = insert_result { + return Err(crate::KbError::Db(format!( + "cannot upsert kb_observed_tokens on sqlite: {}", + error + ))); + } + let select_result = sqlx::query_scalar::( + r#" +SELECT id +FROM kb_observed_tokens +WHERE mint = ? +LIMIT 1 + "#, + ) + .bind(dto.mint.clone()) + .fetch_one(pool) + .await; + match select_result { + Ok(id) => Ok(id), + Err(error) => Err(crate::KbError::Db(format!( + "cannot fetch kb_observed_tokens id for mint '{}' on sqlite: {}", + dto.mint, error + ))), + } + } + } +} + +/// Reads one observed token by mint. +pub async fn get_observed_token_by_mint( + database: &crate::KbDatabase, + mint: &str, +) -> Result, crate::KbError> { + match database.connection() { + crate::KbDatabaseConnection::Sqlite(pool) => { + let query_result = sqlx::query_as::( + r#" +SELECT + id, + mint, + symbol, + name, + decimals, + token_program, + status, + first_seen_at, + last_seen_at, + updated_at +FROM kb_observed_tokens +WHERE mint = ? +LIMIT 1 + "#, + ) + .bind(mint) + .fetch_optional(pool) + .await; + let entity_option = match query_result { + Ok(entity_option) => entity_option, + Err(error) => { + return Err(crate::KbError::Db(format!( + "cannot read observed token '{}' on sqlite: {}", + mint, error + ))); + } + }; + match entity_option { + Some(entity) => { + let dto_result = crate::KbObservedTokenDto::try_from(entity); + match dto_result { + Ok(dto) => Ok(Some(dto)), + Err(error) => Err(error), + } + } + None => Ok(None), + } + } + } +} + +/// Lists observed tokens ordered by newest first. +pub async fn list_observed_tokens( + database: &crate::KbDatabase, + limit: u32, +) -> Result, crate::KbError> { + if limit == 0 { + return Ok(std::vec::Vec::new()); + } + match database.connection() { + crate::KbDatabaseConnection::Sqlite(pool) => { + let query_result = sqlx::query_as::( + r#" +SELECT + id, + mint, + symbol, + name, + decimals, + token_program, + status, + first_seen_at, + last_seen_at, + updated_at +FROM kb_observed_tokens +ORDER BY id DESC +LIMIT ? + "#, + ) + .bind(i64::from(limit)) + .fetch_all(pool) + .await; + let entities = match query_result { + Ok(entities) => entities, + Err(error) => { + return Err(crate::KbError::Db(format!( + "cannot list observed tokens on sqlite: {}", + error + ))); + } + }; + let mut dtos = std::vec::Vec::new(); + for entity in entities { + let dto_result = crate::KbObservedTokenDto::try_from(entity); + let dto = match dto_result { + Ok(dto) => dto, + Err(error) => return Err(error), + }; + dtos.push(dto); + } + Ok(dtos) + } + } +} + +#[cfg(test)] +mod tests { + #[tokio::test] + async fn observed_token_roundtrip_works() { + let tempdir = tempfile::tempdir().expect("tempdir must succeed"); + let database_path = tempdir.path().join("observed_token.sqlite3"); + let config = crate::KbDatabaseConfig { + enabled: true, + backend: crate::KbDatabaseBackend::Sqlite, + sqlite: crate::KbSqliteDatabaseConfig { + path: database_path.to_string_lossy().to_string(), + create_if_missing: true, + busy_timeout_ms: 5000, + max_connections: 1, + auto_initialize_schema: true, + use_wal: true, + }, + }; + let database = crate::KbDatabase::connect_and_initialize(&config) + .await + .expect("database init must succeed"); + let dto = crate::KbObservedTokenDto::new( + "So11111111111111111111111111111111111111112".to_string(), + Some("WSOL".to_string()), + Some("Wrapped SOL".to_string()), + Some(9), + crate::SPL_TOKEN_PROGRAM_ID.to_string(), + crate::KbObservedTokenStatus::Active, + ); + let inserted_id = crate::upsert_observed_token(&database, &dto) + .await + .expect("upsert must succeed"); + assert!(inserted_id > 0); + let fetched = crate::get_observed_token_by_mint( + &database, + "So11111111111111111111111111111111111111112", + ) + .await + .expect("fetch must succeed"); + assert!(fetched.is_some()); + let fetched = fetched.expect("token must exist"); + assert_eq!(fetched.symbol.as_deref(), Some("WSOL")); + assert_eq!(fetched.decimals, Some(9)); + assert_eq!(fetched.status, crate::KbObservedTokenStatus::Active); + let listed = crate::list_observed_tokens(&database, 10) + .await + .expect("list must succeed"); + assert_eq!(listed.len(), 1); + } +} diff --git a/kb_lib/src/db/schema.rs b/kb_lib/src/db/schema.rs index ee27d3c..7e387f5 100644 --- a/kb_lib/src/db/schema.rs +++ b/kb_lib/src/db/schema.rs @@ -3,7 +3,9 @@ //! Database schema initialization. /// Ensures that the database schema exists. -pub(crate) async fn ensure_schema(database: &crate::KbDatabase) -> Result<(), crate::KbError> { +pub(crate) async fn ensure_schema( + database: &crate::KbDatabase, +) -> Result<(), crate::KbError> { match database.connection() { crate::KbDatabaseConnection::Sqlite(pool) => { let metadata_table_result = sqlx::query( @@ -99,6 +101,58 @@ ON kb_db_runtime_events (created_at) error ))); } + let observed_tokens_result = sqlx::query( + r#" +CREATE TABLE IF NOT EXISTS kb_observed_tokens ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + mint TEXT NOT NULL UNIQUE, + symbol TEXT NULL, + name TEXT NULL, + decimals INTEGER NULL, + token_program TEXT NOT NULL, + status INTEGER NOT NULL, + first_seen_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + updated_at TEXT NOT NULL +) + "#, + ) + .execute(pool) + .await; + if let Err(error) = observed_tokens_result { + return Err(crate::KbError::Db(format!( + "cannot create table kb_observed_tokens on sqlite: {}", + error + ))); + } + let observed_tokens_mint_index_result = sqlx::query( + r#" +CREATE UNIQUE INDEX IF NOT EXISTS kb_idx_observed_tokens_mint +ON kb_observed_tokens (mint) + "#, + ) + .execute(pool) + .await; + if let Err(error) = observed_tokens_mint_index_result { + return Err(crate::KbError::Db(format!( + "cannot create index kb_idx_observed_tokens_mint on sqlite: {}", + error + ))); + } + let observed_tokens_status_index_result = sqlx::query( + r#" +CREATE INDEX IF NOT EXISTS kb_idx_observed_tokens_status +ON kb_observed_tokens (status) + "#, + ) + .execute(pool) + .await; + if let Err(error) = observed_tokens_status_index_result { + return Err(crate::KbError::Db(format!( + "cannot create index kb_idx_observed_tokens_status on sqlite: {}", + error + ))); + } let schema_version = crate::KbDbMetadataDto::new( "schema_version".to_string(), env!("CARGO_PKG_VERSION").to_string(), @@ -108,6 +162,6 @@ ON kb_db_runtime_events (created_at) return Err(error); } Ok(()) - } + }, } } diff --git a/kb_lib/src/db/types.rs b/kb_lib/src/db/types.rs index ef2569b..c2cb5a5 100644 --- a/kb_lib/src/db/types.rs +++ b/kb_lib/src/db/types.rs @@ -3,7 +3,9 @@ //! Database shared types. mod database_backend; +mod observed_token_status; mod runtime_event_level; pub use crate::db::types::database_backend::KbDatabaseBackend; +pub use crate::db::types::observed_token_status::KbObservedTokenStatus; pub use crate::db::types::runtime_event_level::KbDbRuntimeEventLevel; diff --git a/kb_lib/src/db/types/observed_token_status.rs b/kb_lib/src/db/types/observed_token_status.rs new file mode 100644 index 0000000..faeac6d --- /dev/null +++ b/kb_lib/src/db/types/observed_token_status.rs @@ -0,0 +1,42 @@ +// file: kb_lib/src/db/types/observed_token_status.rs + +//! Observed token status. + +/// Local status of one observed token. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum KbObservedTokenStatus { + /// Newly discovered token. + New, + /// Token currently tracked. + Active, + /// Token ignored by local filtering rules. + Ignored, + /// Token marked as suspicious or blocked. + Blocked, +} + +impl KbObservedTokenStatus { + /// Converts the status to its stable integer representation. + pub fn to_i16(self) -> i16 { + match self { + Self::New => 0, + Self::Active => 1, + Self::Ignored => 2, + Self::Blocked => 3, + } + } + + /// Restores a status from its stable integer representation. + pub fn from_i16(value: i16) -> Result { + match value { + 0 => Ok(Self::New), + 1 => Ok(Self::Active), + 2 => Ok(Self::Ignored), + 3 => Ok(Self::Blocked), + _ => Err(crate::KbError::Db(format!( + "invalid KbObservedTokenStatus value: {}", + value + ))), + } + } +} diff --git a/kb_lib/src/lib.rs b/kb_lib/src/lib.rs index 0c4a740..c078202 100644 --- a/kb_lib/src/lib.rs +++ b/kb_lib/src/lib.rs @@ -78,6 +78,12 @@ pub use crate::db::KbKnownHttpEndpointDto; pub use crate::db::KbKnownHttpEndpointEntity; pub use crate::db::KbKnownWsEndpointDto; pub use crate::db::KbKnownWsEndpointEntity; +pub use crate::db::KbObservedTokenDto; +pub use crate::db::KbObservedTokenEntity; +pub use crate::db::KbObservedTokenStatus; +pub use crate::db::get_observed_token_by_mint; +pub use crate::db::list_observed_tokens; +pub use crate::db::upsert_observed_token; pub use crate::db::get_known_http_endpoint; pub use crate::db::get_known_ws_endpoint; pub use crate::db::insert_db_runtime_event;