0.6.1
This commit is contained in:
@@ -8,7 +8,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.5.9"
|
version = "0.6.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://git.sasedev.com/Sasedev/khadhroony-bobot"
|
repository = "https://git.sasedev.com/Sasedev/khadhroony-bobot"
|
||||||
@@ -19,6 +19,7 @@ publish = false
|
|||||||
async-trait = { version = "^0.1", features = [] }
|
async-trait = { version = "^0.1", features = [] }
|
||||||
base64 = { version = "^0.22", features = [] }
|
base64 = { version = "^0.22", features = [] }
|
||||||
chrono = { version = "^0.4", features = ["serde"] }
|
chrono = { version = "^0.4", features = ["serde"] }
|
||||||
|
fs2 = { version = "^0.4", features = [] }
|
||||||
futures-util = { version = "^0.3", features = [] }
|
futures-util = { version = "^0.3", features = [] }
|
||||||
reqwest = { version = "^0.13", default-features = false, features = ["charset", "cookies", "deflate", "form", "gzip", "http2", "json", "multipart", "query", "rustls", "socks", "stream", "zstd"] }
|
reqwest = { version = "^0.13", default-features = false, features = ["charset", "cookies", "deflate", "form", "gzip", "http2", "json", "multipart", "query", "rustls", "socks", "stream", "zstd"] }
|
||||||
rustls = { version = "^0.23", features = ["aws-lc-rs"] }
|
rustls = { version = "^0.23", features = ["aws-lc-rs"] }
|
||||||
@@ -38,11 +39,16 @@ spl-memo-interface = { version = "^2.0", features = [] }
|
|||||||
spl-token-interface = { version = "^2.0", features = [] }
|
spl-token-interface = { version = "^2.0", features = [] }
|
||||||
spl-token-2022-interface = { version = "^2.1", features = [] }
|
spl-token-2022-interface = { version = "^2.1", features = [] }
|
||||||
sqlx = { version = "^0.8", features = ["chrono", "uuid", "bigdecimal", "json", "sqlite", "runtime-tokio-rustls"] }
|
sqlx = { version = "^0.8", features = ["chrono", "uuid", "bigdecimal", "json", "sqlite", "runtime-tokio-rustls"] }
|
||||||
|
tauri = { version = "^2.10", features = ["default"] }
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
tauri-plugin-tracing = { version = "^0.3", features = [] }
|
||||||
tokio = { version = "^1.52", features = ["full"] }
|
tokio = { version = "^1.52", features = ["full"] }
|
||||||
tokio-stream = { version = "^0.1", features = ["full"] }
|
tokio-stream = { version = "^0.1", features = ["full"] }
|
||||||
tokio-tungstenite = { version = "^0.29", default-features = false, features = ["connect", "handshake", "rustls-tls-webpki-roots", "stream", "url"] }
|
tokio-tungstenite = { version = "^0.29", default-features = false, features = ["connect", "handshake", "rustls-tls-webpki-roots", "stream", "url"] }
|
||||||
tracing = { version = "^0.1", features = [] }
|
tracing = { version = "^0.1", features = [] }
|
||||||
|
tracing-appender = { version = "^0.2", features = [] }
|
||||||
tracing-subscriber = { version = "^0.3", features = ["ansi", "env-filter", "chrono", "serde", "json"] }
|
tracing-subscriber = { version = "^0.3", features = ["ansi", "env-filter", "chrono", "serde", "json"] }
|
||||||
|
ts-rs = { version = "^12.0", features = [] }
|
||||||
yellowstone-grpc-client = { version = "^13.0", features = [] }
|
yellowstone-grpc-client = { version = "^13.0", features = [] }
|
||||||
yellowstone-grpc-proto = { version = "^12.2", features = [] }
|
yellowstone-grpc-proto = { version = "^12.2", features = [] }
|
||||||
uuid = { version = "^1.23", features = ["v4", "serde"] }
|
uuid = { version = "^1.23", features = ["v4", "serde"] }
|
||||||
|
|||||||
396
TODO.md
Normal file
396
TODO.md
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
<!-- file: TODO.md -->
|
||||||
|
|
||||||
|
# Khadhroony Bobot — Global TODO / Roadmap
|
||||||
|
|
||||||
|
> This roadmap is a living document.
|
||||||
|
> Completed versions reflect the current implemented state.
|
||||||
|
> Planned versions are indicative and may evolve as architecture and product needs become clearer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project goals
|
||||||
|
|
||||||
|
The project is split into several major applications sharing `khbb_lib`:
|
||||||
|
|
||||||
|
- `khbb_listener_app`
|
||||||
|
- listen to Solana data sources,
|
||||||
|
- detect token / mint / token-account / bootstrap / pair / pool related activity,
|
||||||
|
- enrich and store observed data.
|
||||||
|
|
||||||
|
- `khbb_pattern_analyser_app`
|
||||||
|
- analyze recurring patterns on tokens, mints, pools, names, lifetimes, scam patterns, bootstrap flows, early activity.
|
||||||
|
|
||||||
|
- `khbb_trader_app`
|
||||||
|
- consume early detections and pattern outputs,
|
||||||
|
- take automated trading decisions,
|
||||||
|
- apply entry/exit logic based on price, duration, confidence, and risk.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current architectural direction
|
||||||
|
|
||||||
|
Current implementation started with a simple inline listener loop in order to validate:
|
||||||
|
|
||||||
|
- Solana HTTP RPC transport,
|
||||||
|
- Solana WebSocket transport,
|
||||||
|
- raw data storage,
|
||||||
|
- event normalization,
|
||||||
|
- early domain classification,
|
||||||
|
- heuristic signals,
|
||||||
|
- HTTP enrichment,
|
||||||
|
- candidate tracking.
|
||||||
|
|
||||||
|
This is considered a bootstrap architecture.
|
||||||
|
|
||||||
|
### Target architecture direction
|
||||||
|
|
||||||
|
The target architecture should progressively move toward:
|
||||||
|
|
||||||
|
- multiple concurrent data sources,
|
||||||
|
- autonomous source clients,
|
||||||
|
- async task-based source runtimes,
|
||||||
|
- command/event channels,
|
||||||
|
- source aggregation hub,
|
||||||
|
- separation between:
|
||||||
|
- transport,
|
||||||
|
- normalization,
|
||||||
|
- enrichment,
|
||||||
|
- correlation,
|
||||||
|
- persistence,
|
||||||
|
- strategy/trading.
|
||||||
|
|
||||||
|
This especially applies to:
|
||||||
|
|
||||||
|
- multiple WebSocket clients,
|
||||||
|
- Yellowstone gRPC client(s),
|
||||||
|
- HTTP enrichment workers,
|
||||||
|
- future external data sources.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Completed versions
|
||||||
|
|
||||||
|
### v0.1.x
|
||||||
|
- initial project skeleton
|
||||||
|
- workspace setup
|
||||||
|
- strict coding conventions
|
||||||
|
- config loading
|
||||||
|
- tracing bootstrap
|
||||||
|
- SQLite bootstrap
|
||||||
|
|
||||||
|
### v0.2.x
|
||||||
|
- basic listener runtime
|
||||||
|
- listener session creation
|
||||||
|
- SQLite connectivity validation
|
||||||
|
- initial runtime loop
|
||||||
|
- first persistence primitives
|
||||||
|
|
||||||
|
### v0.3.0
|
||||||
|
- HTTP JSON-RPC client foundation
|
||||||
|
- manual request ids
|
||||||
|
- use of official Solana RPC request/response types where applicable
|
||||||
|
- basic `getSlot` support
|
||||||
|
- raw HTTP RPC storage
|
||||||
|
|
||||||
|
### v0.3.1
|
||||||
|
- tests added around config and storage
|
||||||
|
- tests added around HTTP RPC request/response parsing
|
||||||
|
- stronger validation of config and SQLite helpers
|
||||||
|
|
||||||
|
### v0.3.2
|
||||||
|
- HTTP polling integrated into listener runtime
|
||||||
|
- `getSlot` polling stored as raw RPC traffic
|
||||||
|
|
||||||
|
### v0.4.1
|
||||||
|
- initial Solana WebSocket RPC support
|
||||||
|
- subscription primitives
|
||||||
|
- support for:
|
||||||
|
- `slotSubscribe`
|
||||||
|
- `logsSubscribe`
|
||||||
|
- `programSubscribe`
|
||||||
|
- raw WS message storage
|
||||||
|
|
||||||
|
### v0.4.3
|
||||||
|
- centralized WebSocket read loop improvements
|
||||||
|
- safe unsubscribe handling
|
||||||
|
- shutdown flow improvements
|
||||||
|
- Rustls provider initialization fixes
|
||||||
|
|
||||||
|
### v0.4.4
|
||||||
|
- centralized WS dispatch between:
|
||||||
|
- JSON-RPC responses
|
||||||
|
- notifications
|
||||||
|
- better separation of response wait logic and notification flow
|
||||||
|
|
||||||
|
### v0.4.5
|
||||||
|
- active WS subscription registry
|
||||||
|
- source metadata attached to subscriptions
|
||||||
|
- cleaner handling of multiple subscription kinds
|
||||||
|
|
||||||
|
### v0.4.6
|
||||||
|
- WS notification normalization layer
|
||||||
|
- normalized events introduced for:
|
||||||
|
- slot
|
||||||
|
- logs
|
||||||
|
- program notifications
|
||||||
|
|
||||||
|
### v0.5.0
|
||||||
|
- first domain event layer
|
||||||
|
- normalized WS events converted into domain events
|
||||||
|
|
||||||
|
### v0.5.1
|
||||||
|
- known program registry
|
||||||
|
- first known-program classification layer
|
||||||
|
- SPL Token / Token-2022 / System / ComputeBudget / ATA recognition
|
||||||
|
|
||||||
|
### v0.5.2
|
||||||
|
- official `ids.rs` module added
|
||||||
|
- heuristic signal layer introduced
|
||||||
|
- first weak signals around:
|
||||||
|
- token account activity
|
||||||
|
- mint activity
|
||||||
|
- initial token activity
|
||||||
|
- bootstrap-style activity
|
||||||
|
|
||||||
|
### v0.5.3
|
||||||
|
- heuristic refinement
|
||||||
|
- associated token account style signals
|
||||||
|
- stronger bootstrap-oriented heuristics from known logs
|
||||||
|
|
||||||
|
### v0.5.4
|
||||||
|
- targeted HTTP enrichment via `getAccountInfo`
|
||||||
|
- enriched account snapshot
|
||||||
|
- first distinction between:
|
||||||
|
- potential token account
|
||||||
|
- potential mint account
|
||||||
|
- unknown account
|
||||||
|
|
||||||
|
### v0.5.5
|
||||||
|
- enriched classification layer
|
||||||
|
- confirmed enriched events introduced:
|
||||||
|
- confirmed token account activity
|
||||||
|
- confirmed mint account activity
|
||||||
|
- unknown token-program-owned account activity
|
||||||
|
|
||||||
|
### v0.5.6
|
||||||
|
- local correlation layer between:
|
||||||
|
- enriched events
|
||||||
|
- heuristics
|
||||||
|
- correlated signals introduced:
|
||||||
|
- confirmed token account update
|
||||||
|
- potential new token mint
|
||||||
|
- potential token bootstrap flow
|
||||||
|
|
||||||
|
### v0.5.7
|
||||||
|
- candidate layer introduced
|
||||||
|
- first domain candidates:
|
||||||
|
- token account candidate
|
||||||
|
- mint candidate
|
||||||
|
- bootstrap flow candidate
|
||||||
|
- listener maximum tick count made configurable
|
||||||
|
|
||||||
|
### v0.5.8
|
||||||
|
- in-memory session candidate tracker
|
||||||
|
- session-local deduplication
|
||||||
|
- lightweight score and confidence
|
||||||
|
- per-session candidate upsert flow
|
||||||
|
|
||||||
|
### v0.5.9
|
||||||
|
- session candidate snapshots
|
||||||
|
- sorted candidate summaries
|
||||||
|
- confidence-based filtering
|
||||||
|
- end-of-session summary logs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current status summary
|
||||||
|
|
||||||
|
At the current stage, the project can already:
|
||||||
|
|
||||||
|
- connect to Solana HTTP RPC,
|
||||||
|
- connect to Solana WebSocket RPC,
|
||||||
|
- subscribe to slot/logs/program streams,
|
||||||
|
- store raw HTTP and WS payloads,
|
||||||
|
- normalize notifications,
|
||||||
|
- derive domain events,
|
||||||
|
- classify known programs,
|
||||||
|
- emit heuristic signals,
|
||||||
|
- enrich accounts with `getAccountInfo`,
|
||||||
|
- confirm token-account-like activity,
|
||||||
|
- correlate signals,
|
||||||
|
- track session candidates in memory,
|
||||||
|
- summarize candidates at the end of a run.
|
||||||
|
|
||||||
|
What is still missing is the transition from generic token activity detection to true:
|
||||||
|
|
||||||
|
- mint detection,
|
||||||
|
- token bootstrap detection,
|
||||||
|
- pool/pair detection,
|
||||||
|
- DEX-aware activity detection,
|
||||||
|
- persistent candidate storage,
|
||||||
|
- pattern analysis,
|
||||||
|
- trading integration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Planned versions
|
||||||
|
|
||||||
|
> Planned versions are provisional and may change.
|
||||||
|
|
||||||
|
### v0.6.0
|
||||||
|
- persist session candidates to SQLite
|
||||||
|
- add dedicated candidate table(s)
|
||||||
|
- persist summary-worthy candidates at end of listener session
|
||||||
|
- create base persistence model reusable by analyser and trader
|
||||||
|
|
||||||
|
### v0.6.1
|
||||||
|
- refactor WebSocket client into an autonomous async source runtime
|
||||||
|
- introduce command/event channel model
|
||||||
|
- separate WS transport orchestration from listener business logic
|
||||||
|
|
||||||
|
### v0.6.2
|
||||||
|
- support multiple concurrent WS source clients
|
||||||
|
- allow different endpoints/providers simultaneously
|
||||||
|
- introduce source identity and source metadata
|
||||||
|
- prepare failover / redundancy / specialization by source
|
||||||
|
|
||||||
|
### v0.6.3
|
||||||
|
- introduce Yellowstone gRPC source runtime
|
||||||
|
- normalize WS + gRPC source events into a shared event model
|
||||||
|
- compare and reconcile overlapping data streams
|
||||||
|
|
||||||
|
### v0.6.4
|
||||||
|
- introduce source aggregation hub
|
||||||
|
- merge multi-source events into a unified ingestion pipeline
|
||||||
|
- prepare source-level prioritization and deduplication
|
||||||
|
|
||||||
|
### v0.7.0
|
||||||
|
- improve token-account vs mint distinction
|
||||||
|
- decode more token-specific account information
|
||||||
|
- improve quality of confirmed mint detection
|
||||||
|
- reduce weak false candidate generation
|
||||||
|
|
||||||
|
### v0.7.1
|
||||||
|
- introduce first persistent mint candidates
|
||||||
|
- introduce first persistent token-account candidates
|
||||||
|
- session-to-database promotion logic
|
||||||
|
|
||||||
|
### v0.7.2
|
||||||
|
- begin real bootstrap flow persistence and tracking
|
||||||
|
- correlate:
|
||||||
|
- token accounts
|
||||||
|
- mint accounts
|
||||||
|
- bootstrap-like logs
|
||||||
|
- source timing
|
||||||
|
|
||||||
|
### v0.8.0
|
||||||
|
- begin DEX-aware detection layer
|
||||||
|
- identify relevant program ids for first supported DEXes
|
||||||
|
- start with a small prioritized list, likely including:
|
||||||
|
- Raydium
|
||||||
|
- Meteora
|
||||||
|
- Pump-like ecosystems
|
||||||
|
- detect pool/pair creation-related activity
|
||||||
|
|
||||||
|
### v0.8.1
|
||||||
|
- add first pair / pool candidate models
|
||||||
|
- begin liquidity/bootstrap/pair-side inference
|
||||||
|
- distinguish token-only activity from pair/pool activity
|
||||||
|
|
||||||
|
### v0.8.2
|
||||||
|
- add market-related enrichment:
|
||||||
|
- liquidity hints
|
||||||
|
- price hints
|
||||||
|
- pool metadata
|
||||||
|
- start preparing listener outputs for trading eligibility
|
||||||
|
|
||||||
|
### v0.9.0
|
||||||
|
- begin `khbb_pattern_analyser_app`
|
||||||
|
- consume persisted candidates/signals from listener
|
||||||
|
- define first recurring pattern models:
|
||||||
|
- mint lifetime
|
||||||
|
- suspicious bootstrap
|
||||||
|
- likely scam patterns
|
||||||
|
- repeated naming patterns
|
||||||
|
- repeated creator behavior
|
||||||
|
|
||||||
|
### v0.9.1
|
||||||
|
- pattern scoring
|
||||||
|
- candidate clustering
|
||||||
|
- repeated-behavior analysis across sessions
|
||||||
|
|
||||||
|
### v1.0.0
|
||||||
|
- first end-to-end listener milestone
|
||||||
|
- persistent candidate ingestion
|
||||||
|
- multi-source ingestion foundations
|
||||||
|
- DEX-aware first detection
|
||||||
|
- pattern analyser initial interoperability
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future trader-oriented roadmap
|
||||||
|
|
||||||
|
### Trader preparation
|
||||||
|
- define normalized outputs consumable by `khbb_trader_app`
|
||||||
|
- define candidate confidence / risk / eligibility model
|
||||||
|
- define basic token entry exclusion filters
|
||||||
|
- define liquidity / market-cap / bootstrap safety gates
|
||||||
|
|
||||||
|
### Early trading versions
|
||||||
|
- paper-trading mode first
|
||||||
|
- wallet integration
|
||||||
|
- transaction builder integration
|
||||||
|
- buy/sell strategy primitives
|
||||||
|
- time-based and price-based exits
|
||||||
|
- risk caps and kill switches
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cross-cutting technical TODO
|
||||||
|
|
||||||
|
### Transport / source runtime
|
||||||
|
- [ ] move away from single inline WS runtime model
|
||||||
|
- [ ] introduce autonomous async WS source tasks
|
||||||
|
- [ ] add multi-WS support
|
||||||
|
- [ ] add gRPC source runtime
|
||||||
|
- [ ] unify source event interface
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
- [ ] persist session candidates
|
||||||
|
- [ ] define candidate history tables
|
||||||
|
- [ ] define mint/pool/pair tables
|
||||||
|
- [ ] define source tables / provider metadata tables
|
||||||
|
|
||||||
|
### Classification / heuristics
|
||||||
|
- [ ] reduce weak token-account/mint ambiguity
|
||||||
|
- [ ] improve ATA detection
|
||||||
|
- [ ] refine bootstrap-flow detection
|
||||||
|
- [ ] correlate logs with program/account enrichment more strongly
|
||||||
|
|
||||||
|
### DEX support
|
||||||
|
- [ ] define first supported DEX list
|
||||||
|
- [ ] list official program ids and account models
|
||||||
|
- [ ] detect pair creation events
|
||||||
|
- [ ] detect liquidity initialization
|
||||||
|
- [ ] enrich pool metadata
|
||||||
|
|
||||||
|
### Pattern analysis
|
||||||
|
- [ ] define persistent pattern schema
|
||||||
|
- [ ] score repeated token behaviors
|
||||||
|
- [ ] score suspicious creators / repeated rugs / short lifetimes
|
||||||
|
|
||||||
|
### Trading
|
||||||
|
- [ ] define trader input schema
|
||||||
|
- [ ] define safe simulation mode
|
||||||
|
- [ ] define buy filters
|
||||||
|
- [ ] define sell rules
|
||||||
|
- [ ] define risk management rules
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Current versions intentionally favored iterative validation over final architecture purity.
|
||||||
|
- The current listener runtime is considered a stepping stone, not the final source-runtime model.
|
||||||
|
- Candidate/correlation layers may continue to evolve before stabilizing.
|
||||||
|
- Future versions may be renumbered, merged, split, or reprioritized depending on what real Solana traffic reveals.
|
||||||
@@ -30,6 +30,7 @@ mod signal_correlation;
|
|||||||
mod candidate;
|
mod candidate;
|
||||||
mod session_candidate;
|
mod session_candidate;
|
||||||
mod session_tracker;
|
mod session_tracker;
|
||||||
|
mod ws_source;
|
||||||
|
|
||||||
/// Runs the listener application bootstrap workflow.
|
/// Runs the listener application bootstrap workflow.
|
||||||
pub use crate::app::run_listener_app;
|
pub use crate::app::run_listener_app;
|
||||||
@@ -155,3 +156,7 @@ pub use crate::session_candidate::KhbbSessionCandidate;
|
|||||||
pub use crate::session_tracker::KhbbSessionCandidateTracker;
|
pub use crate::session_tracker::KhbbSessionCandidateTracker;
|
||||||
/// Result of inserting or updating a session candidate.
|
/// Result of inserting or updating a session candidate.
|
||||||
pub use crate::session_tracker::KhbbSessionCandidateUpdate;
|
pub use crate::session_tracker::KhbbSessionCandidateUpdate;
|
||||||
|
/// Event emitted by the autonomous WebSocket source.
|
||||||
|
pub use crate::ws_source::KhbbWsSourceEvent;
|
||||||
|
/// Handle used by the listener runtime to control a WebSocket source task.
|
||||||
|
pub use crate::ws_source::KhbbWsSourceHandle;
|
||||||
|
|||||||
@@ -52,14 +52,14 @@ pub async fn run_listener_runtime(
|
|||||||
};
|
};
|
||||||
let ws_client_config =
|
let ws_client_config =
|
||||||
crate::KhbbSolanaWsRpcClientConfig { url: config.solana_ws_rpc_url.clone() };
|
crate::KhbbSolanaWsRpcClientConfig { url: config.solana_ws_rpc_url.clone() };
|
||||||
let ws_client_result = crate::KhbbSolanaWsRpcClient::new(ws_client_config);
|
let ws_source_result = crate::KhbbWsSourceHandle::spawn(ws_client_config);
|
||||||
let mut ws_client = match ws_client_result {
|
let mut ws_source = match ws_source_result {
|
||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
return Err(error);
|
return Err(error);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let ws_connect_result = ws_client.connect().await;
|
let ws_connect_result = ws_source.connect().await;
|
||||||
match ws_connect_result {
|
match ws_connect_result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
@@ -75,7 +75,7 @@ pub async fn run_listener_runtime(
|
|||||||
let mut active_ws_subscriptions =
|
let mut active_ws_subscriptions =
|
||||||
std::vec::Vec::<crate::domain::KhbbActiveWsSubscription>::new();
|
std::vec::Vec::<crate::domain::KhbbActiveWsSubscription>::new();
|
||||||
if config.enable_ws_slot_subscribe {
|
if config.enable_ws_slot_subscribe {
|
||||||
let slot_subscribe_result = ws_client.slot_subscribe(1).await;
|
let slot_subscribe_result = ws_source.slot_subscribe(1).await;
|
||||||
let slot_subscribe_output = match slot_subscribe_result {
|
let slot_subscribe_output = match slot_subscribe_result {
|
||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -135,7 +135,7 @@ pub async fn run_listener_runtime(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if config.enable_ws_logs_subscribe {
|
if config.enable_ws_logs_subscribe {
|
||||||
let logs_subscribe_result = ws_client
|
let logs_subscribe_result = ws_source
|
||||||
.logs_subscribe(solana_rpc_client_api::config::RpcTransactionLogsFilter::All, None, 2)
|
.logs_subscribe(solana_rpc_client_api::config::RpcTransactionLogsFilter::All, None, 2)
|
||||||
.await;
|
.await;
|
||||||
let logs_subscribe_output = match logs_subscribe_result {
|
let logs_subscribe_output = match logs_subscribe_result {
|
||||||
@@ -200,7 +200,7 @@ pub async fn run_listener_runtime(
|
|||||||
let mut program_request_id: u64 = 10;
|
let mut program_request_id: u64 = 10;
|
||||||
for program_id in &config.ws_program_subscribe_program_ids {
|
for program_id in &config.ws_program_subscribe_program_ids {
|
||||||
let program_subscribe_result =
|
let program_subscribe_result =
|
||||||
ws_client.program_subscribe(program_id, None, program_request_id).await;
|
ws_source.program_subscribe(program_id, None, program_request_id).await;
|
||||||
let program_subscribe_output = match program_subscribe_result {
|
let program_subscribe_output = match program_subscribe_result {
|
||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -315,13 +315,15 @@ pub async fn run_listener_runtime(
|
|||||||
}
|
}
|
||||||
let ws_read_timeout_result = tokio::time::timeout(
|
let ws_read_timeout_result = tokio::time::timeout(
|
||||||
std::time::Duration::from_millis(50),
|
std::time::Duration::from_millis(50),
|
||||||
ws_client.read_next_incoming_message(),
|
ws_source.recv_event(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
match ws_read_timeout_result {
|
match ws_read_timeout_result {
|
||||||
Ok(read_result) => {
|
Ok(read_result) => {
|
||||||
match read_result {
|
match read_result {
|
||||||
Ok(crate::KhbbWsIncomingMessage::Response { raw, id, .. }) => {
|
Ok(crate::KhbbWsSourceEvent::IncomingMessage(
|
||||||
|
crate::KhbbWsIncomingMessage::Response { raw, id, .. },
|
||||||
|
)) => {
|
||||||
let insert_ws_message_result = crate::storage::insert_raw_ws_message(
|
let insert_ws_message_result = crate::storage::insert_raw_ws_message(
|
||||||
pool,
|
pool,
|
||||||
session.id,
|
session.id,
|
||||||
@@ -346,7 +348,9 @@ pub async fn run_listener_runtime(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(crate::KhbbWsIncomingMessage::Notification { raw, method, .. }) => {
|
Ok(crate::KhbbWsSourceEvent::IncomingMessage(
|
||||||
|
crate::KhbbWsIncomingMessage::Notification { raw, method, .. },
|
||||||
|
)) => {
|
||||||
let insert_ws_message_result = crate::storage::insert_raw_ws_message(
|
let insert_ws_message_result = crate::storage::insert_raw_ws_message(
|
||||||
pool,
|
pool,
|
||||||
session.id,
|
session.id,
|
||||||
@@ -1139,7 +1143,9 @@ pub async fn run_listener_runtime(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(crate::KhbbWsIncomingMessage::Unknown { raw, .. }) => {
|
Ok(crate::KhbbWsSourceEvent::IncomingMessage(
|
||||||
|
crate::KhbbWsIncomingMessage::Unknown { raw, .. },
|
||||||
|
)) => {
|
||||||
let insert_ws_message_result = crate::storage::insert_raw_ws_message(
|
let insert_ws_message_result = crate::storage::insert_raw_ws_message(
|
||||||
pool,
|
pool,
|
||||||
session.id,
|
session.id,
|
||||||
@@ -1163,7 +1169,9 @@ pub async fn run_listener_runtime(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(crate::KhbbWsIncomingMessage::StreamEnded) => {
|
Ok(crate::KhbbWsSourceEvent::IncomingMessage(
|
||||||
|
crate::KhbbWsIncomingMessage::StreamEnded,
|
||||||
|
)) => {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
listener_session_id = session.id,
|
listener_session_id = session.id,
|
||||||
"websocket stream ended"
|
"websocket stream ended"
|
||||||
@@ -1177,7 +1185,6 @@ pub async fn run_listener_runtime(
|
|||||||
error = %error,
|
error = %error,
|
||||||
"failed to read websocket message"
|
"failed to read websocket message"
|
||||||
);
|
);
|
||||||
|
|
||||||
final_status = std::string::String::from("ws_read_error");
|
final_status = std::string::String::from("ws_read_error");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1186,6 +1193,11 @@ pub async fn run_listener_runtime(
|
|||||||
Err(_) => {}
|
Err(_) => {}
|
||||||
}
|
}
|
||||||
if tick_count >= config.listener_max_ticks {
|
if tick_count >= config.listener_max_ticks {
|
||||||
|
tracing::info!(
|
||||||
|
listener_session_id = session.id,
|
||||||
|
tick_count = tick_count,
|
||||||
|
"listener reached max ticks, stopping loop"
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1227,67 +1239,10 @@ pub async fn run_listener_runtime(
|
|||||||
tracing::info!(
|
tracing::info!(
|
||||||
listener_session_id = session.id,
|
listener_session_id = session.id,
|
||||||
subscription_count = active_ws_subscriptions.len(),
|
subscription_count = active_ws_subscriptions.len(),
|
||||||
"starting websocket unsubscribe phase"
|
"starting websocket close phase"
|
||||||
);
|
);
|
||||||
for subscription in &active_ws_subscriptions {
|
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), ws_source.close()).await;
|
||||||
let unsubscribe_timeout_result = tokio::time::timeout(
|
let session_candidates = session_candidate_tracker.snapshot_sorted_by_score_desc();
|
||||||
std::time::Duration::from_millis(500),
|
|
||||||
ws_client.unsubscribe(
|
|
||||||
subscription.kind,
|
|
||||||
subscription.subscription_id,
|
|
||||||
1000u64.saturating_add(tick_count).saturating_add(subscription.request_id),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
match unsubscribe_timeout_result {
|
|
||||||
Ok(unsubscribe_result) => match unsubscribe_result {
|
|
||||||
Ok(value) => {
|
|
||||||
tracing::info!(
|
|
||||||
listener_session_id = session.id,
|
|
||||||
unsubscribed = value,
|
|
||||||
subscription_id = subscription.subscription_id,
|
|
||||||
kind = ?subscription.kind,
|
|
||||||
label = ?subscription.label,
|
|
||||||
"websocket subscription cancelled"
|
|
||||||
);
|
|
||||||
},
|
|
||||||
Err(error) => {
|
|
||||||
tracing::error!(
|
|
||||||
listener_session_id = session.id,
|
|
||||||
error = %error,
|
|
||||||
subscription_id = subscription.subscription_id,
|
|
||||||
kind = ?subscription.kind,
|
|
||||||
label = ?subscription.label,
|
|
||||||
"failed to cancel websocket subscription"
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Err(_) => {
|
|
||||||
tracing::error!(
|
|
||||||
listener_session_id = session.id,
|
|
||||||
subscription_id = subscription.subscription_id,
|
|
||||||
kind = ?subscription.kind,
|
|
||||||
label = ?subscription.label,
|
|
||||||
"websocket unsubscribe timed out"
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let ws_close_result = ws_client.close().await;
|
|
||||||
match ws_close_result {
|
|
||||||
Ok(()) => {
|
|
||||||
tracing::info!(listener_session_id = session.id, "websocket rpc client closed");
|
|
||||||
},
|
|
||||||
Err(error) => {
|
|
||||||
tracing::error!(
|
|
||||||
listener_session_id = session.id,
|
|
||||||
error = %error,
|
|
||||||
"failed to close websocket rpc client"
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
let session_candidates =
|
|
||||||
session_candidate_tracker.snapshot_sorted_by_score_desc();
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
listener_session_id = session.id,
|
listener_session_id = session.id,
|
||||||
candidate_count = session_candidates.len(),
|
candidate_count = session_candidates.len(),
|
||||||
@@ -1307,6 +1262,24 @@ pub async fn run_listener_runtime(
|
|||||||
"session candidate summary entry"
|
"session candidate summary entry"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
for candidate in &session_candidates {
|
||||||
|
let insert_result =
|
||||||
|
crate::storage::insert_session_candidate(pool, session.id, candidate).await;
|
||||||
|
if let Err(error) = insert_result {
|
||||||
|
tracing::error!(
|
||||||
|
listener_session_id = session.id,
|
||||||
|
key = %candidate.key,
|
||||||
|
error = %error,
|
||||||
|
"failed to persist session candidate"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tracing::trace!(
|
||||||
|
listener_session_id = session.id,
|
||||||
|
key = %candidate.key,
|
||||||
|
"session candidate persisted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
let high_confidence_candidates = session_candidate_tracker
|
let high_confidence_candidates = session_candidate_tracker
|
||||||
.snapshot_with_min_confidence(crate::KhbbCandidateConfidence::High);
|
.snapshot_with_min_confidence(crate::KhbbCandidateConfidence::High);
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
|
|||||||
@@ -196,7 +196,6 @@ CREATE TABLE IF NOT EXISTS tracked_pools (
|
|||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
let create_tracked_pools_result = sqlx::query(tracked_pools_sql).execute(pool).await;
|
let create_tracked_pools_result = sqlx::query(tracked_pools_sql).execute(pool).await;
|
||||||
match create_tracked_pools_result {
|
match create_tracked_pools_result {
|
||||||
Ok(_) => {},
|
Ok(_) => {},
|
||||||
@@ -207,6 +206,29 @@ CREATE TABLE IF NOT EXISTS tracked_pools (
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
let session_candidates_sql = r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS session_candidates (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
listener_session_id INTEGER NOT NULL,
|
||||||
|
candidate_key TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
pubkey TEXT NULL,
|
||||||
|
first_seen_slot INTEGER NOT NULL,
|
||||||
|
last_seen_slot INTEGER NOT NULL,
|
||||||
|
seen_count INTEGER NOT NULL,
|
||||||
|
score INTEGER NOT NULL,
|
||||||
|
confidence TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
FOREIGN KEY(listener_session_id) REFERENCES listener_sessions(id)
|
||||||
|
)
|
||||||
|
"#;
|
||||||
|
let session_candidates_query_result = sqlx::query(session_candidates_sql).execute(pool).await;
|
||||||
|
if let Err(error) = session_candidates_query_result {
|
||||||
|
return Err(crate::KhbbError::Database {
|
||||||
|
context: "create session_candidates table",
|
||||||
|
message: error.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,6 +377,91 @@ INSERT INTO raw_ws_messages (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn insert_session_candidate(
|
||||||
|
pool: &sqlx::SqlitePool,
|
||||||
|
listener_session_id: i64,
|
||||||
|
candidate: &crate::KhbbSessionCandidate,
|
||||||
|
) -> core::result::Result<(), crate::KhbbError> {
|
||||||
|
let first_seen_slot = i64::try_from(candidate.first_seen_slot);
|
||||||
|
let first_seen_slot_value = match first_seen_slot {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => {
|
||||||
|
return Err(crate::KhbbError::Runtime {
|
||||||
|
context: "convert first_seen_slot to sqlite integer",
|
||||||
|
message: error.to_string(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let last_seen_slot = i64::try_from(candidate.last_seen_slot);
|
||||||
|
let last_seen_slot_value = match last_seen_slot {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => {
|
||||||
|
return Err(crate::KhbbError::Runtime {
|
||||||
|
context: "convert last_seen_slot to sqlite integer",
|
||||||
|
message: error.to_string(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let seen_count = i64::try_from(candidate.seen_count);
|
||||||
|
let seen_count_value = match seen_count {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => {
|
||||||
|
return Err(crate::KhbbError::Runtime {
|
||||||
|
context: "convert seen_count to sqlite integer",
|
||||||
|
message: error.to_string(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let score = i64::try_from(candidate.score);
|
||||||
|
let score_value = match score {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => {
|
||||||
|
return Err(crate::KhbbError::Runtime {
|
||||||
|
context: "convert score to sqlite integer",
|
||||||
|
message: error.to_string(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let confidence_text = match candidate.confidence {
|
||||||
|
crate::KhbbCandidateConfidence::Low => "low",
|
||||||
|
crate::KhbbCandidateConfidence::Medium => "medium",
|
||||||
|
crate::KhbbCandidateConfidence::High => "high",
|
||||||
|
};
|
||||||
|
let insert_result = sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO session_candidates (
|
||||||
|
listener_session_id,
|
||||||
|
candidate_key,
|
||||||
|
category,
|
||||||
|
pubkey,
|
||||||
|
first_seen_slot,
|
||||||
|
last_seen_slot,
|
||||||
|
seen_count,
|
||||||
|
score,
|
||||||
|
confidence
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(listener_session_id)
|
||||||
|
.bind(&candidate.key)
|
||||||
|
.bind(&candidate.category)
|
||||||
|
.bind(candidate.pubkey.as_deref())
|
||||||
|
.bind(first_seen_slot_value)
|
||||||
|
.bind(last_seen_slot_value)
|
||||||
|
.bind(seen_count_value)
|
||||||
|
.bind(score_value)
|
||||||
|
.bind(confidence_text)
|
||||||
|
.execute(pool)
|
||||||
|
.await;
|
||||||
|
if let Err(error) = insert_result {
|
||||||
|
return Err(crate::KhbbError::Database {
|
||||||
|
context: "insert session candidate",
|
||||||
|
message: error.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -537,4 +644,76 @@ WHERE id = ?1;
|
|||||||
.await;
|
.await;
|
||||||
assert!(insert_ws_result.is_ok());
|
assert!(insert_ws_result.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ensure_sqlite_schema_creates_session_candidates_table() {
|
||||||
|
let pool_result = create_sqlite_pool("sqlite::memory:").await;
|
||||||
|
assert!(pool_result.is_ok());
|
||||||
|
let pool = pool_result.expect("sqlite memory pool");
|
||||||
|
let ensure_result = ensure_sqlite_schema(&pool).await;
|
||||||
|
assert!(ensure_result.is_ok());
|
||||||
|
let row_result = sqlx::query_scalar::<_, i64>(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='session_candidates'",
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await;
|
||||||
|
assert!(row_result.is_ok());
|
||||||
|
let count = row_result.expect("session_candidates table count");
|
||||||
|
assert_eq!(count, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn insert_session_candidate_inserts_row() {
|
||||||
|
let pool_result = create_sqlite_pool("sqlite::memory:").await;
|
||||||
|
assert!(pool_result.is_ok());
|
||||||
|
let pool = pool_result.expect("sqlite memory pool");
|
||||||
|
let ensure_result = ensure_sqlite_schema(&pool).await;
|
||||||
|
assert!(ensure_result.is_ok());
|
||||||
|
let database_url = build_temp_sqlite_url();
|
||||||
|
let config = build_test_config(database_url);
|
||||||
|
let session_result = insert_listener_session(&pool, &config).await;
|
||||||
|
assert!(session_result.is_ok());
|
||||||
|
let session = session_result.expect("listener session");
|
||||||
|
let candidate = crate::KhbbSessionCandidate {
|
||||||
|
key: std::string::String::from("token_account:SomePubkey"),
|
||||||
|
category: std::string::String::from("token_account"),
|
||||||
|
pubkey: Some(std::string::String::from("SomePubkey")),
|
||||||
|
first_seen_slot: 100,
|
||||||
|
last_seen_slot: 101,
|
||||||
|
seen_count: 2,
|
||||||
|
score: 50,
|
||||||
|
confidence: crate::KhbbCandidateConfidence::Medium,
|
||||||
|
};
|
||||||
|
let insert_result = insert_session_candidate(&pool, session.id, &candidate).await;
|
||||||
|
assert!(insert_result.is_ok());
|
||||||
|
let row_result =
|
||||||
|
sqlx::query_as::<_, (String, String, Option<String>, i64, i64, i64, i64, String)>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
candidate_key,
|
||||||
|
category,
|
||||||
|
pubkey,
|
||||||
|
first_seen_slot,
|
||||||
|
last_seen_slot,
|
||||||
|
seen_count,
|
||||||
|
score,
|
||||||
|
confidence
|
||||||
|
FROM session_candidates
|
||||||
|
WHERE listener_session_id = ?1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(session.id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await;
|
||||||
|
assert!(row_result.is_ok());
|
||||||
|
let row = row_result.expect("session candidate row");
|
||||||
|
assert_eq!(row.0, "token_account:SomePubkey");
|
||||||
|
assert_eq!(row.1, "token_account");
|
||||||
|
assert_eq!(row.2.as_deref(), Some("SomePubkey"));
|
||||||
|
assert_eq!(row.3, 100);
|
||||||
|
assert_eq!(row.4, 101);
|
||||||
|
assert_eq!(row.5, 2);
|
||||||
|
assert_eq!(row.6, 50);
|
||||||
|
assert_eq!(row.7, "medium");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
216
khbb_lib/src/ws_source.rs
Normal file
216
khbb_lib/src/ws_source.rs
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
// file: khbb_lib/src/ws_source.rs
|
||||||
|
|
||||||
|
//! Autonomous WebSocket source handle.
|
||||||
|
//!
|
||||||
|
//! This module currently provides a thin explicit wrapper around the existing
|
||||||
|
//! low-level Solana WebSocket JSON-RPC client. It preserves the listener-side
|
||||||
|
//! contract while keeping the implementation simple and deterministic:
|
||||||
|
//!
|
||||||
|
//! - no implicit reconnect
|
||||||
|
//! - no background retry logic
|
||||||
|
//! - explicit connect / read / close lifecycle
|
||||||
|
//! - reuse of the existing `KhbbSolanaWsRpcClient`
|
||||||
|
//!
|
||||||
|
//! The goal of this version is to restore a clean, coherent API boundary
|
||||||
|
//! between `listener.rs` and the WebSocket transport layer before introducing a
|
||||||
|
//! more advanced multi-source autonomous supervisor.
|
||||||
|
|
||||||
|
/// Event emitted by the WebSocket source handle.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum KhbbWsSourceEvent {
|
||||||
|
/// A classified incoming WebSocket message.
|
||||||
|
IncomingMessage(crate::KhbbWsIncomingMessage),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle used by the listener runtime to control a WebSocket source.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct KhbbWsSourceHandle {
|
||||||
|
/// Wrapped low-level Solana WebSocket client.
|
||||||
|
client: crate::KhbbSolanaWsRpcClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KhbbWsSourceHandle {
|
||||||
|
/// Creates a new source handle from an existing low-level client config.
|
||||||
|
pub fn spawn(
|
||||||
|
config: crate::KhbbSolanaWsRpcClientConfig,
|
||||||
|
) -> core::result::Result<Self, crate::KhbbError> {
|
||||||
|
let client_result = crate::KhbbSolanaWsRpcClient::new(config);
|
||||||
|
let client = match client_result {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => {
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(Self { client })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connects the underlying WebSocket client.
|
||||||
|
pub async fn connect(&mut self) -> core::result::Result<(), crate::KhbbError> {
|
||||||
|
let connect_result = self.client.connect().await;
|
||||||
|
match connect_result {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performs a `slotSubscribe` call.
|
||||||
|
pub async fn slot_subscribe(
|
||||||
|
&mut self,
|
||||||
|
id: u64,
|
||||||
|
) -> core::result::Result<crate::KhbbWsSubscribeCallOutput, crate::KhbbError> {
|
||||||
|
let subscribe_result = self.client.slot_subscribe(id).await;
|
||||||
|
match subscribe_result {
|
||||||
|
Ok(value) => Ok(value),
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performs a `logsSubscribe` call.
|
||||||
|
pub async fn logs_subscribe(
|
||||||
|
&mut self,
|
||||||
|
filter: solana_rpc_client_api::config::RpcTransactionLogsFilter,
|
||||||
|
config: core::option::Option<solana_rpc_client_api::config::RpcTransactionLogsConfig>,
|
||||||
|
id: u64,
|
||||||
|
) -> core::result::Result<crate::KhbbWsSubscribeCallOutput, crate::KhbbError> {
|
||||||
|
let subscribe_result = self.client.logs_subscribe(filter, config, id).await;
|
||||||
|
match subscribe_result {
|
||||||
|
Ok(value) => Ok(value),
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performs a `programSubscribe` call.
|
||||||
|
pub async fn program_subscribe(
|
||||||
|
&mut self,
|
||||||
|
program_id: &str,
|
||||||
|
config: core::option::Option<solana_rpc_client_api::config::RpcProgramAccountsConfig>,
|
||||||
|
id: u64,
|
||||||
|
) -> core::result::Result<crate::KhbbWsSubscribeCallOutput, crate::KhbbError> {
|
||||||
|
let subscribe_result = self.client.program_subscribe(program_id, config, id).await;
|
||||||
|
match subscribe_result {
|
||||||
|
Ok(value) => Ok(value),
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the next event emitted by the underlying client.
|
||||||
|
pub async fn recv_event(
|
||||||
|
&mut self,
|
||||||
|
) -> core::result::Result<KhbbWsSourceEvent, crate::KhbbError> {
|
||||||
|
let read_result = self.client.read_next_incoming_message().await;
|
||||||
|
match read_result {
|
||||||
|
Ok(message) => Ok(KhbbWsSourceEvent::IncomingMessage(message)),
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closes the underlying WebSocket client.
|
||||||
|
pub async fn close(&mut self) -> core::result::Result<(), crate::KhbbError> {
|
||||||
|
let close_result = self.client.close().await;
|
||||||
|
match close_result {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
/// Verifies that `spawn` rejects an empty URL via the wrapped client config
|
||||||
|
/// validation.
|
||||||
|
#[test]
|
||||||
|
fn spawn_rejects_empty_url() {
|
||||||
|
let result = super::KhbbWsSourceHandle::spawn(crate::KhbbSolanaWsRpcClientConfig {
|
||||||
|
url: std::string::String::new(),
|
||||||
|
});
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies that `recv_event` drains a preloaded pending response message.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn recv_event_returns_wrapped_response_message() {
|
||||||
|
let client_result = crate::KhbbSolanaWsRpcClient::new(crate::KhbbSolanaWsRpcClientConfig {
|
||||||
|
url: std::string::String::from("wss://example.invalid"),
|
||||||
|
});
|
||||||
|
let mut client = match client_result {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => {
|
||||||
|
panic!("unexpected client construction error: {error}");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
client
|
||||||
|
.pending_incoming_messages
|
||||||
|
.push_back(crate::KhbbWsIncomingMessage::Response {
|
||||||
|
raw: std::string::String::from(r#"{"jsonrpc":"2.0","id":1,"result":42}"#),
|
||||||
|
id: 1,
|
||||||
|
json: serde_json::json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"result": 42,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
let mut handle = super::KhbbWsSourceHandle { client };
|
||||||
|
let event_result = handle.recv_event().await;
|
||||||
|
match event_result {
|
||||||
|
Ok(super::KhbbWsSourceEvent::IncomingMessage(
|
||||||
|
crate::KhbbWsIncomingMessage::Response { id, .. },
|
||||||
|
)) => {
|
||||||
|
assert_eq!(id, 1);
|
||||||
|
}
|
||||||
|
Ok(_) => {
|
||||||
|
panic!("unexpected event variant");
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
panic!("unexpected recv_event error: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies that `recv_event` returns a wrapped stream-ended message when a
|
||||||
|
/// pending stream-ended message is queued.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn recv_event_returns_wrapped_stream_ended_message() {
|
||||||
|
let client_result = crate::KhbbSolanaWsRpcClient::new(crate::KhbbSolanaWsRpcClientConfig {
|
||||||
|
url: std::string::String::from("wss://example.invalid"),
|
||||||
|
});
|
||||||
|
let mut client = match client_result {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => {
|
||||||
|
panic!("unexpected client construction error: {error}");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
client
|
||||||
|
.pending_incoming_messages
|
||||||
|
.push_back(crate::KhbbWsIncomingMessage::StreamEnded);
|
||||||
|
let mut handle = super::KhbbWsSourceHandle { client };
|
||||||
|
let event_result = handle.recv_event().await;
|
||||||
|
match event_result {
|
||||||
|
Ok(super::KhbbWsSourceEvent::IncomingMessage(
|
||||||
|
crate::KhbbWsIncomingMessage::StreamEnded,
|
||||||
|
)) => {}
|
||||||
|
Ok(_) => {
|
||||||
|
panic!("unexpected event variant");
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
panic!("unexpected recv_event error: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies that `close` is a no-op when the wrapped client is not
|
||||||
|
/// connected.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn close_succeeds_when_client_is_not_connected() {
|
||||||
|
let handle_result = super::KhbbWsSourceHandle::spawn(crate::KhbbSolanaWsRpcClientConfig {
|
||||||
|
url: std::string::String::from("wss://example.invalid"),
|
||||||
|
});
|
||||||
|
let mut handle = match handle_result {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => {
|
||||||
|
panic!("unexpected handle construction error: {error}");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let close_result = handle.close().await;
|
||||||
|
assert!(close_result.is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user