This commit is contained in:
2026-04-18 23:40:49 +02:00
parent 54d4d8b18a
commit ed490a96f6
7 changed files with 295 additions and 2 deletions

View File

@@ -24,6 +24,7 @@ pub async fn run_listener_app(config_path: &str) -> core::result::Result<(), cra
solana_ws_rpc_url = %config.solana_ws_rpc_url,
yellowstone_grpc_url = ?config.yellowstone_grpc_url,
bootstrap_database = config.bootstrap_database,
listener_max_ticks = config.listener_max_ticks,
listener_poll_interval_ms = config.listener_poll_interval_ms,
enable_ws_slot_subscribe = config.enable_ws_slot_subscribe,
enable_ws_logs_subscribe = config.enable_ws_logs_subscribe,

164
khbb_lib/src/candidate.rs Normal file
View File

@@ -0,0 +1,164 @@
// file: khbb_lib/src/candidate.rs
//! Candidate domain objects derived from correlated signals.
/// Candidate object derived from a correlated signal.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum KhbbCandidate {
/// Candidate token account.
TokenAccount(KhbbTokenAccountCandidate),
/// Candidate mint account.
Mint(KhbbMintCandidate),
/// Candidate bootstrap flow.
BootstrapFlow(KhbbBootstrapFlowCandidate),
}
/// Candidate token account.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct KhbbTokenAccountCandidate {
/// Candidate pubkey.
pub pubkey: std::string::String,
/// Context slot.
pub context_slot: u64,
/// Owner program if available.
pub owner: std::option::Option<std::string::String>,
/// Lamports if available.
pub lamports: std::option::Option<u64>,
}
/// Candidate mint account.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct KhbbMintCandidate {
/// Candidate pubkey.
pub pubkey: std::string::String,
/// Context slot.
pub context_slot: u64,
/// Owner program if available.
pub owner: std::option::Option<std::string::String>,
/// Lamports if available.
pub lamports: std::option::Option<u64>,
}
/// Candidate bootstrap flow.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct KhbbBootstrapFlowCandidate {
/// Optional pubkey attached to the flow.
pub pubkey: std::option::Option<std::string::String>,
/// Context slot.
pub context_slot: u64,
/// Whether token program activity was seen.
pub saw_token_program: bool,
/// Whether associated token account activity was seen.
pub saw_associated_token_account: bool,
/// Whether bootstrap-style logs were seen.
pub saw_bootstrap_logs: bool,
}
/// Builds candidate objects from correlated signals.
pub(crate) fn build_candidates_from_correlated_signal(
signal: &crate::KhbbCorrelatedSignal,
) -> core::result::Result<std::vec::Vec<KhbbCandidate>, crate::KhbbError> {
match signal {
crate::KhbbCorrelatedSignal::ConfirmedTokenAccountUpdate(inner) => {
Ok(vec![KhbbCandidate::TokenAccount(KhbbTokenAccountCandidate {
pubkey: inner.pubkey.clone(),
context_slot: inner.context_slot,
owner: inner.owner.clone(),
lamports: inner.lamports,
})])
},
crate::KhbbCorrelatedSignal::PotentialNewTokenMint(inner) => {
Ok(vec![KhbbCandidate::Mint(KhbbMintCandidate {
pubkey: inner.pubkey.clone(),
context_slot: inner.context_slot,
owner: inner.owner.clone(),
lamports: inner.lamports,
})])
},
crate::KhbbCorrelatedSignal::PotentialTokenBootstrapFlow(inner) => {
Ok(vec![KhbbCandidate::BootstrapFlow(KhbbBootstrapFlowCandidate {
pubkey: inner.pubkey.clone(),
context_slot: inner.context_slot,
saw_token_program: inner.saw_token_program,
saw_associated_token_account: inner.saw_associated_token_account,
saw_bootstrap_logs: inner.saw_bootstrap_logs,
})])
},
}
}
#[cfg(test)]
mod tests {
#[test]
fn build_candidate_from_confirmed_token_account_update() {
let signal = crate::KhbbCorrelatedSignal::ConfirmedTokenAccountUpdate(
crate::KhbbConfirmedTokenAccountUpdateSignal {
pubkey: std::string::String::from("SomePubkey"),
context_slot: 100,
owner: Some(crate::ids::SPL_TOKEN_PROGRAM_ID.to_string()),
lamports: Some(123),
},
);
let result = super::build_candidates_from_correlated_signal(&signal);
assert!(result.is_ok());
let candidates = result.expect("build token account candidate");
assert_eq!(candidates.len(), 1);
match &candidates[0] {
super::KhbbCandidate::TokenAccount(inner) => {
assert_eq!(inner.pubkey, "SomePubkey");
},
_ => {
panic!("expected token account candidate");
},
}
}
#[test]
fn build_candidate_from_potential_new_token_mint() {
let signal = crate::KhbbCorrelatedSignal::PotentialNewTokenMint(
crate::KhbbPotentialNewTokenMintSignal {
pubkey: std::string::String::from("MintPubkey"),
context_slot: 200,
owner: Some(crate::ids::SPL_TOKEN_PROGRAM_ID.to_string()),
lamports: Some(456),
},
);
let result = super::build_candidates_from_correlated_signal(&signal);
assert!(result.is_ok());
let candidates = result.expect("build mint candidate");
assert_eq!(candidates.len(), 1);
match &candidates[0] {
super::KhbbCandidate::Mint(inner) => {
assert_eq!(inner.pubkey, "MintPubkey");
},
_ => {
panic!("expected mint candidate");
},
}
}
#[test]
fn build_candidate_from_bootstrap_flow() {
let signal = crate::KhbbCorrelatedSignal::PotentialTokenBootstrapFlow(
crate::KhbbPotentialTokenBootstrapFlowSignal {
pubkey: Some(std::string::String::from("FlowPubkey")),
context_slot: 300,
saw_token_program: true,
saw_associated_token_account: true,
saw_bootstrap_logs: true,
},
);
let result = super::build_candidates_from_correlated_signal(&signal);
assert!(result.is_ok());
let candidates = result.expect("build bootstrap candidate");
assert_eq!(candidates.len(), 1);
match &candidates[0] {
super::KhbbCandidate::BootstrapFlow(inner) => {
assert_eq!(inner.pubkey.as_deref(), Some("FlowPubkey"));
},
_ => {
panic!("expected bootstrap flow candidate");
},
}
}
}

View File

@@ -17,6 +17,8 @@ pub struct KhbbAppConfig {
pub log_filter: std::string::String,
/// Enables or disables database schema bootstrap at startup.
pub bootstrap_database: bool,
/// Maximum number of listener loop ticks before a controlled shutdown.
pub listener_max_ticks: u64,
/// Polling interval used by the current runtime skeleton.
pub listener_poll_interval_ms: u64,
/// Enables or disables `slotSubscribe` during listener startup.
@@ -81,6 +83,11 @@ impl KhbbAppConfig {
message: std::string::String::from("log_filter must not be empty"),
});
}
if self.listener_max_ticks == 0 {
return Err(crate::KhbbError::Config {
message: std::string::String::from("listener_max_ticks must be greater than 0"),
});
}
if self.listener_poll_interval_ms == 0 {
return Err(crate::KhbbError::Config {
message: std::string::String::from(
@@ -126,6 +133,7 @@ mod tests {
)),
log_filter: std::string::String::from("info"),
bootstrap_database: true,
listener_max_ticks: 3,
listener_poll_interval_ms: 1000,
enable_ws_slot_subscribe: true,
enable_ws_logs_subscribe: true,
@@ -173,6 +181,14 @@ mod tests {
assert!(result.is_err());
}
#[test]
fn validate_rejects_zero_listener_max_ticks() {
let mut config = build_valid_config();
config.listener_max_ticks = 0;
let result = config.validate();
assert!(result.is_err());
}
#[test]
fn validate_rejects_zero_poll_interval() {
let mut config = build_valid_config();
@@ -194,6 +210,7 @@ mod tests {
"yellowstone_grpc_url": "https://mainnet.helius-rpc.com:443",
"log_filter": "info",
"bootstrap_database": true,
"listener_max_ticks": 15,
"listener_poll_interval_ms": 1000,
"enable_ws_slot_subscribe": true,
"enable_ws_logs_subscribe": true,

View File

@@ -27,6 +27,7 @@ mod heuristics;
mod account_enrichment;
mod enriched_classifier;
mod signal_correlation;
mod candidate;
/// Runs the listener application bootstrap workflow.
pub use crate::app::run_listener_app;
@@ -136,3 +137,11 @@ pub use crate::signal_correlation::KhbbConfirmedTokenAccountUpdateSignal;
pub use crate::signal_correlation::KhbbPotentialNewTokenMintSignal;
/// Correlated signal indicating a potential token bootstrap flow.
pub use crate::signal_correlation::KhbbPotentialTokenBootstrapFlowSignal;
/// Candidate object derived from a correlated signal.
pub use crate::candidate::KhbbCandidate;
/// Candidate token account.
pub use crate::candidate::KhbbTokenAccountCandidate;
/// Candidate mint account.
pub use crate::candidate::KhbbMintCandidate;
/// Candidate bootstrap flow.
pub use crate::candidate::KhbbBootstrapFlowCandidate;

View File

@@ -693,6 +693,39 @@ pub async fn run_listener_runtime(
lamports = ?correlated.lamports,
"correlated confirmed token account update signal"
);
let candidate_result =
crate::candidate::build_candidates_from_correlated_signal(
&crate::KhbbCorrelatedSignal::ConfirmedTokenAccountUpdate(
correlated.clone(),
),
);
match candidate_result {
Ok(candidates) => {
for candidate in candidates {
match candidate {
crate::KhbbCandidate::TokenAccount(inner) => {
tracing::trace!(
listener_session_id = session.id,
pubkey = %inner.pubkey,
context_slot = inner.context_slot,
owner = ?inner.owner,
lamports = ?inner.lamports,
"token account candidate created"
);
}
crate::KhbbCandidate::Mint(_) => {}
crate::KhbbCandidate::BootstrapFlow(_) => {}
}
}
}
Err(error) => {
tracing::error!(
listener_session_id = session.id,
error = %error,
"failed to build token account candidates from correlated signal"
);
}
}
}
crate::KhbbCorrelatedSignal::PotentialTokenBootstrapFlow(correlated) => {
tracing::trace!(
@@ -748,6 +781,39 @@ pub async fn run_listener_runtime(
lamports = ?correlated.lamports,
"correlated potential new token mint signal"
);
let candidate_result =
crate::candidate::build_candidates_from_correlated_signal(
&crate::KhbbCorrelatedSignal::PotentialNewTokenMint(
correlated.clone(),
),
);
match candidate_result {
Ok(candidates) => {
for candidate in candidates {
match candidate {
crate::KhbbCandidate::Mint(inner) => {
tracing::trace!(
listener_session_id = session.id,
pubkey = %inner.pubkey,
context_slot = inner.context_slot,
owner = ?inner.owner,
lamports = ?inner.lamports,
"mint candidate created"
);
}
crate::KhbbCandidate::TokenAccount(_) => {}
crate::KhbbCandidate::BootstrapFlow(_) => {}
}
}
}
Err(error) => {
tracing::error!(
listener_session_id = session.id,
error = %error,
"failed to build mint candidates from correlated signal"
);
}
}
}
crate::KhbbCorrelatedSignal::ConfirmedTokenAccountUpdate(_) => {}
crate::KhbbCorrelatedSignal::PotentialTokenBootstrapFlow(_) => {}
@@ -794,6 +860,40 @@ pub async fn run_listener_runtime(
saw_bootstrap_logs = correlated.saw_bootstrap_logs,
"correlated potential token bootstrap flow signal"
);
let candidate_result =
crate::candidate::build_candidates_from_correlated_signal(
&crate::KhbbCorrelatedSignal::PotentialTokenBootstrapFlow(
correlated.clone(),
),
);
match candidate_result {
Ok(candidates) => {
for candidate in candidates {
match candidate {
crate::KhbbCandidate::BootstrapFlow(inner) => {
tracing::trace!(
listener_session_id = session.id,
pubkey = ?inner.pubkey,
context_slot = inner.context_slot,
saw_token_program = inner.saw_token_program,
saw_associated_token_account = inner.saw_associated_token_account,
saw_bootstrap_logs = inner.saw_bootstrap_logs,
"bootstrap flow candidate created"
);
}
crate::KhbbCandidate::TokenAccount(_) => {}
crate::KhbbCandidate::Mint(_) => {}
}
}
}
Err(error) => {
tracing::error!(
listener_session_id = session.id,
error = %error,
"failed to build bootstrap flow candidates from correlated signal"
);
}
}
}
crate::KhbbCorrelatedSignal::ConfirmedTokenAccountUpdate(_) => {}
crate::KhbbCorrelatedSignal::PotentialNewTokenMint(_) => {}
@@ -1127,7 +1227,7 @@ pub async fn run_listener_runtime(
}
Err(_) => {}
}
if tick_count >= 3 {
if tick_count >= config.listener_max_ticks {
break;
}
}

View File

@@ -373,6 +373,7 @@ mod tests {
)),
log_filter: std::string::String::from("info"),
bootstrap_database: true,
listener_max_ticks: 3,
listener_poll_interval_ms: 1000,
enable_ws_slot_subscribe: true,
enable_ws_logs_subscribe: true,
@@ -500,6 +501,7 @@ WHERE id = ?1;
yellowstone_grpc_url: None,
log_filter: "info".into(),
bootstrap_database: false,
listener_max_ticks: 3,
listener_poll_interval_ms: 1000,
enable_ws_slot_subscribe: true,
enable_ws_logs_subscribe: true,