diff --git a/CHANGELOG.md b/CHANGELOG.md index 77287c9..a053f80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,4 +58,4 @@ 0.7.25 - Enrichissement metadata des tokens, avec résolution locale limitée à SOL / WSOL, résolution des autres mints via comptes on-chain, Token-2022, Metaplex ou payloads DEX, et conservation explicite des cas non résolus 0.7.26 - Diagnostics locaux du pipeline persisté, correction de l’agrégation instruction-scoped des swaps Raydium, clarification des compteurs de replay/upsert, et validation qu’aucun trade candidate issu d’une transaction OK n’est perdu 0.7.27 - Validation multi-DEX et non-régression du pipeline sur Pump.fun, PumpSwap, Raydium CPMM et Raydium CLMM, avec corpus de tests, diagnostics de référence et garanties sur les événements non pricés -0.7.28 - nettoyer la couche DEX avant d’ajouter de nouveaux protocoles, sans modifier le transport HTTP/WS déjà stabilisé. +0.7.28 - Refactor DEX commun et verrouillage des invariants de normalisation : séparation des événements décodés, actionnables, trade candidates et candle candidates ; conservation des transactions failed comme traçables mais non actionnables ; ajout de la règle bloquante empêchant tout trade/candle candidate sans payload de montants exploitable, notamment pour le cas partiel `meteora_damm_v1.swap` sans base/quote amount. diff --git a/Cargo.toml b/Cargo.toml index a429edd..28ebf8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ publish = false argon2 = { version = "^0.5", features = ["std", "zeroize"] } async-trait = { version = "^0.1", features = [] } base64 = { version = "^0.22", features = [] } +borsh = { version = "^1.6", features = ["ascii", "bson", "bytes", "default", "derive", "de_strict_order", "borsh-derive", "indexmap", "std", "rc"] } bs58 = {version = "^0.5", features = ["default", "cb58", "check"] } chacha20poly1305 = { version = "^0.10", features = ["std", "stream"] } chrono = { version = "^0.4", features = ["serde"] } @@ -30,23 +31,23 @@ reqwest = { version = "^0.13", default-features = false, features = ["charset", rustls = { version = "^0.23", features = ["aws-lc-rs"] } serde = { version = "^1.0", features = ["derive"] } serde_json = { version = "^1.0", features = [] } -solana-account-decoder-client-types = { version = ">=4.0.0-rc.0", features = ["zstd"] } -solana-address-lookup-table-interface = { version = "^3.1", features = ["bincode", "serde"] } +solana-account-decoder-client-types = { version = ">=4.0.0-rc.1", features = ["zstd"] } +solana-address-lookup-table-interface = { version = "^3.1", features = ["serde"] } solana-client = { version = ">=4.0.0-rc.0", features = [] } solana-compute-budget-interface = { version = "^3.0", features = ["borsh", "serde"] } solana-rpc-client-api = { version = ">=4.0.0-rc.0", features = [] } solana-rpc-client-types = { version = ">=4.0.0-rc.0", features = [] } solana-sdk = { version = "^4.0", features = ["full"] } solana-sdk-ids = { version = "^3.1", features = [] } -solana-system-interface = { version = "^3.2", features = ["alloc", "bincode", "serde", "std"] } -solana-transaction-status-client-types = { version = ">=4.0.0-rc.0", features = [] } +solana-system-interface = { version = "^3.2", features = ["alloc", "serde", "std"] } +solana-transaction-status-client-types = { version = ">=4.0.0-rc.1", features = [] } spl-associated-token-account-interface = { version = "^2.0", features = ["borsh"] } spl-memo-interface = { version = "^2.0", features = [] } spl-token-interface = { version = "^2.0", features = [] } spl-token-2022-interface = { version = "^2.1", features = [] } sqlx = { version = "^0.8", features = ["chrono", "uuid", "bigdecimal", "json", "sqlite", "runtime-tokio-rustls"] } tauri = { version = "^2.11", features = ["default", "tray-icon"] } -tauri-build = { version = "2", features = [] } +tauri-build = { version = "^2.6", features = [] } tauri-plugin-tracing = { version = "^0.3", default-features = false, features = [] } tempfile = { version = "^3", features = [] } tokio = { version = "^1.52", features = ["full"] } diff --git a/README.md b/README.md index 36b0ca0..ed021f3 100644 --- a/README.md +++ b/README.md @@ -246,15 +246,3 @@ Pour reprendre rapidement le codage dans une nouvelle session, fournir au minimu - `kb_lib/src/db/queries.rs` et `kb_lib/src/db/queries/*`. Ajouter `kb_demo_app/src/demo_pipeline*.rs` seulement si la tâche concerne l’UI ou les diagnostics affichés. - -## 10. Prompt court de reprise - -```text -Je reprends le workspace Rust khadhroony-bobobot autour de la version 0.7.27. -Objectif actuel : finaliser le pipeline DEX Solana avant trading. -Ne pas toucher pour le moment à ws_client/ws_manager/http_client/http_pool : ils fonctionnent et sont non bloquants. -Priorité : refactor DEX commun à partir de 0.7.28, matrice DEX, transactions inconnues/protocol candidates, événements non-trade, puis ajout/consolidation des DEX et launch surfaces. -Respecter les contraintes : Rust 2024, pas de mod.rs, pas de anyhow/thiserror, pas de ?/unwrap/expect dans le code applicatif, rustdoc utile sur l’API publique. -Les connecteurs à verrouiller avant extension sont pump_fun, pump_swap, raydium_cpmm et raydium_clmm. -Les launch surfaces sont importantes comme première source de mint, même si le token migre ensuite vers Raydium/Meteora/autre. -``` diff --git a/ROADMAP.md b/ROADMAP.md index 008a748..18401db 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -749,7 +749,9 @@ Réalisé : - seuls les trade candidates issus de transactions échouées restent ignorés. ### 6.059. Version `0.7.27` — Validation multi-DEX des connecteurs déjà branchés -Réalisé : +Objectif : verrouiller la non-régression du pipeline actuel avant d’ajouter de nouveaux DEX ou d’ouvrir la phase d’analyse `0.8.x`. + +À faire : - rejouer des bases neuves de test pour `pump_fun`, `pump_swap`, `raydium_cpmm` et `raydium_clmm`, - ne pas ajouter de nouveau DEX dans cette version ; cette version sert uniquement à valider les connecteurs déjà branchés, @@ -764,7 +766,9 @@ Réalisé : - valider que les transactions échouées restent traçables dans les événements décodés sans produire de `k_sol_trade_events`. ### 6.060. Version `0.7.28` — Refactor DEX commun et préparation extension -Réalisé : +Objectif : nettoyer la couche DEX avant d’ajouter de nouveaux protocoles, sans modifier le transport HTTP/WS déjà stabilisé. + +À faire : - ne pas toucher à `ws_client.rs`, `ws_manager.rs`, `http_client.rs`, `http_pool.rs` ni aux couches JSON-RPC déjà stabilisées, - extraire depuis `dex_decode.rs` les catégories communes d’événements : trade, candle candidate, liquidity candidate, fee candidate, reward candidate, admin candidate, pool lifecycle candidate, @@ -1163,7 +1167,7 @@ Le projet doit maintenir au minimum : - un `README.md` global, - un `ROADMAP.md` global, - un `CHANGELOG.md` global, -- des `README.md` et `TODO.md` par crate à mesure de l’évolution (surtout en version 1.0), +- des `README.md` et `TODO.md` par crate à mesure de l’évolution, - des tests unitaires robustes, - les bindings TS générés via `cargo test export_bindings` lorsque les types partagés évoluent. diff --git a/kb_demo_app/frontend/demo_pipeline2.html b/kb_demo_app/frontend/demo_pipeline2.html index a8f3a01..adc1024 100644 --- a/kb_demo_app/frontend/demo_pipeline2.html +++ b/kb_demo_app/frontend/demo_pipeline2.html @@ -168,7 +168,50 @@ Diagnose local pipeline + + + + + +
+

+ +

+
+
+

+ Groupe les instructions d’un programme par discriminator décodé, nombre de comptes, stack height et statut decoded/non-decoded. +

+ +
+ + +
+ +
+ + +
+ +
+
@@ -290,6 +333,25 @@
+ +
+

+ +

+
+
+ +
+
+

diff --git a/kb_demo_app/frontend/ts/bindings/DemoPipeline2ProgramInstructionDiscriminatorSummaryPayload.ts b/kb_demo_app/frontend/ts/bindings/DemoPipeline2ProgramInstructionDiscriminatorSummaryPayload.ts new file mode 100644 index 0000000..846b410 --- /dev/null +++ b/kb_demo_app/frontend/ts/bindings/DemoPipeline2ProgramInstructionDiscriminatorSummaryPayload.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Response payload for program instruction discriminator summaries. + */ +export type DemoPipeline2ProgramInstructionDiscriminatorSummaryPayload = { +/** + * Pretty JSON summary rows. + */ +summariesJson: string, }; diff --git a/kb_demo_app/frontend/ts/bindings/DemoPipeline2ProgramInstructionDiscriminatorSummaryRequest.ts b/kb_demo_app/frontend/ts/bindings/DemoPipeline2ProgramInstructionDiscriminatorSummaryRequest.ts new file mode 100644 index 0000000..c762457 --- /dev/null +++ b/kb_demo_app/frontend/ts/bindings/DemoPipeline2ProgramInstructionDiscriminatorSummaryRequest.ts @@ -0,0 +1,14 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Request payload for program instruction discriminator summaries. + */ +export type DemoPipeline2ProgramInstructionDiscriminatorSummaryRequest = { +/** + * Program id to inspect. + */ +programId: string, +/** + * Maximum number of instruction rows to inspect before grouping. + */ +limit: number, }; diff --git a/kb_demo_app/frontend/ts/demo_pipeline2.ts b/kb_demo_app/frontend/ts/demo_pipeline2.ts index 9e5ab3c..70786b1 100644 --- a/kb_demo_app/frontend/ts/demo_pipeline2.ts +++ b/kb_demo_app/frontend/ts/demo_pipeline2.ts @@ -15,6 +15,8 @@ import type { DemoPipeline2PairCandlesRequest } from "./bindings/DemoPipeline2Pa import type { DemoPipeline2PairCandlesPayload } from "./bindings/DemoPipeline2PairCandlesPayload.ts"; import type { DemoPipeline2LocalDiagnosticsPayload } from "./bindings/DemoPipeline2LocalDiagnosticsPayload.ts"; import type { DemoPipeline2LocalValidationPayload } from "./bindings/DemoPipeline2LocalValidationPayload.ts"; +import type { DemoPipeline2ProgramInstructionDiscriminatorSummaryRequest } from "./bindings/DemoPipeline2ProgramInstructionDiscriminatorSummaryRequest.ts"; +import type { DemoPipeline2ProgramInstructionDiscriminatorSummaryPayload } from "./bindings/DemoPipeline2ProgramInstructionDiscriminatorSummaryPayload.ts"; import { DemoPipeline2ProtocolCandidateSummaryRequest } from './bindings/DemoPipeline2ProtocolCandidateSummaryRequest.ts'; import { DemoPipeline2ProtocolCandidateSummaryPayload } from './bindings/DemoPipeline2ProtocolCandidateSummaryPayload.ts'; @@ -356,6 +358,10 @@ document.addEventListener("DOMContentLoaded", async () => { const diagnoseLocalPipelineButton = document.querySelector("#demoPipeline2DiagnoseLocalPipelineButton"); const validateLocalPipelineButton = document.querySelector("#demoPipeline2ValidateLocalPipelineButton"); + const discriminatorProgramIdInput = document.querySelector("#demoPipeline2DiscriminatorProgramIdInput"); + const discriminatorLimitInput = document.querySelector("#demoPipeline2DiscriminatorLimitInput"); + const loadDiscriminatorSummariesButton = document.querySelector("#demoPipeline2LoadDiscriminatorSummariesButton"); + const protocolCandidateLimitInput = document.querySelector("#demoPipeline2ProtocolCandidateLimitInput"); const refreshProtocolCandidatesButton = document.querySelector("#demoPipeline2RefreshProtocolCandidatesButton"); @@ -371,6 +377,8 @@ document.addEventListener("DOMContentLoaded", async () => { const localDiagnosticsTextarea = document.querySelector("#demoPipeline2LocalDiagnosticsTextarea"); const localValidationTextarea = document.querySelector("#demoPipeline2LocalValidationTextarea"); + const discriminatorSummariesTextarea = document.querySelector("#demoPipeline2DiscriminatorSummariesTextarea"); + const protocolCandidateSummariesTextarea = document.querySelector("#demoPipeline2ProtocolCandidateSummariesTextarea"); const clearLogButton = document.querySelector("#demoPipeline2ClearLogButton"); @@ -395,6 +403,9 @@ document.addEventListener("DOMContentLoaded", async () => { !replayLocalPipelineButton || !diagnoseLocalPipelineButton || !validateLocalPipelineButton || + !discriminatorProgramIdInput || + !discriminatorLimitInput || + !loadDiscriminatorSummariesButton || !protocolCandidateLimitInput || !refreshProtocolCandidatesButton || !pairSelect || @@ -405,6 +416,7 @@ document.addEventListener("DOMContentLoaded", async () => { !backfillSummaryTextarea || !localDiagnosticsTextarea || !localValidationTextarea || + !discriminatorSummariesTextarea || !protocolCandidateSummariesTextarea || !chartElement || !chartMeta || @@ -635,7 +647,7 @@ document.addEventListener("DOMContentLoaded", async () => { }); validateLocalPipelineButton.addEventListener("click", async () => { - appendLogLine(logTextarea, "[ui] validating local pipeline with 0.7.27 profile"); + appendLogLine(logTextarea, "[ui] validating local pipeline with 0.7.28 profile"); try { const payload = await invoke( @@ -693,6 +705,52 @@ document.addEventListener("DOMContentLoaded", async () => { } }); + loadDiscriminatorSummariesButton.addEventListener("click", async () => { + const programId = discriminatorProgramIdInput.value.trim(); + if (programId === "") { + appendLogLine(logTextarea, "[ui] discriminator program id is required"); + return; + } + + const limit = readPositiveIntegerInput( + discriminatorLimitInput, + logTextarea, + "discriminatorSummaryLimit", + ); + if (limit === undefined) { + return; + } + + appendLogLine( + logTextarea, + `[ui] loading instruction discriminator summaries for program '${programId}' with limit '${limit.toString()}'`, + ); + + const request: DemoPipeline2ProgramInstructionDiscriminatorSummaryRequest = { + programId, + limit, + }; + + try { + const payload = await invoke( + "demo_pipeline2_get_program_instruction_discriminator_summaries", + { request }, + ); + + discriminatorSummariesTextarea.value = payload.summariesJson; + + appendLogLine( + logTextarea, + "[ui] instruction discriminator summaries loaded", + ); + } catch (error) { + appendLogLine( + logTextarea, + `[ui] instruction discriminator summary error: ${String(error)}`, + ); + } + }); + loadCandlesButton.addEventListener("click", async () => { const pairIdText = pairSelect.value.trim(); if (pairIdText === "") { diff --git a/kb_demo_app/src/demo_pipeline2.rs b/kb_demo_app/src/demo_pipeline2.rs index d3f2083..b4fbf7d 100644 --- a/kb_demo_app/src/demo_pipeline2.rs +++ b/kb_demo_app/src/demo_pipeline2.rs @@ -10,6 +10,31 @@ use tauri::Manager; use ts_rs::TS; +/// Request payload for program instruction discriminator summaries. +#[derive(Clone, Debug, serde::Deserialize, ts_rs::TS)] +#[ts( + export, + export_to = "../frontend/ts/bindings/DemoPipeline2ProgramInstructionDiscriminatorSummaryRequest.ts" +)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DemoPipeline2ProgramInstructionDiscriminatorSummaryRequest { + /// Program id to inspect. + pub program_id: std::string::String, + /// Maximum number of instruction rows to inspect before grouping. + pub limit: u32, +} + +/// Response payload for program instruction discriminator summaries. +#[derive(Clone, Debug, serde::Serialize, ts_rs::TS)] +#[ts( + export, + export_to = "../frontend/ts/bindings/DemoPipeline2ProgramInstructionDiscriminatorSummaryPayload.ts" +)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DemoPipeline2ProgramInstructionDiscriminatorSummaryPayload { + /// Pretty JSON summary rows. + pub summaries_json: std::string::String, +} /// Request payload for protocol candidate summary diagnostics. #[derive(Clone, Debug, serde::Deserialize, ts_rs::TS)] @@ -35,9 +60,6 @@ pub(crate) struct DemoPipeline2ProtocolCandidateSummaryPayload { pub summaries_json: std::string::String, } - - - /// Local diagnostics payload returned to the UI. #[derive(Clone, Debug, serde::Serialize, TS)] #[ts( @@ -718,6 +740,43 @@ pub(crate) struct DemoPipeline2PairCandlesPayload { pub candles_json: std::string::String, } +/// Lists program instruction discriminator summaries for one program id. +#[tauri::command] +pub(crate) async fn demo_pipeline2_get_program_instruction_discriminator_summaries( + state: tauri::State<'_, crate::AppState>, + request: DemoPipeline2ProgramInstructionDiscriminatorSummaryRequest, +) -> Result { + if request.program_id.trim().is_empty() { + return Err("program id must not be empty".to_string()); + } + if request.limit == 0 { + return Err("instruction discriminator summary limit must be > 0".to_string()); + } + let rows_result = kb_lib::query_program_instruction_discriminator_summaries_list_by_program_id( + state.database.as_ref(), + request.program_id.as_str(), + request.limit, + ) + .await; + let rows = match rows_result { + Ok(rows) => rows, + Err(error) => { + return Err(format!( + "cannot list instruction discriminator summaries for program_id '{}': {}", + request.program_id, error + )); + }, + }; + let summaries_json_result = serde_json::to_string_pretty(&rows); + let summaries_json = match summaries_json_result { + Ok(summaries_json) => summaries_json, + Err(error) => { + return Err(format!("cannot serialize instruction discriminator summaries: {}", error)); + }, + }; + return Ok(DemoPipeline2ProgramInstructionDiscriminatorSummaryPayload { summaries_json }); +} + /// Lists protocol candidate summaries ordered by investigation priority. #[tauri::command] pub(crate) async fn demo_pipeline2_get_protocol_candidate_summaries( @@ -745,10 +804,7 @@ pub(crate) async fn demo_pipeline2_get_protocol_candidate_summaries( let summaries_json = match summaries_json_result { Ok(summaries_json) => summaries_json, Err(error) => { - return Err(format!( - "cannot serialize protocol candidate summaries: {}", - error - )); + return Err(format!("cannot serialize protocol candidate summaries: {}", error)); }, }; return Ok(DemoPipeline2ProtocolCandidateSummaryPayload { summaries_json }); @@ -783,14 +839,14 @@ pub(crate) async fn demo_pipeline2_diagnose_local_pipeline( }) } -/// Validates the local pipeline with the strict `0.7.27` non-regression profile. +/// Validates the local pipeline with the `0.7.28` multi-DEX non-regression profile. #[tauri::command] pub(crate) async fn demo_pipeline2_validate_local_pipeline( state: tauri::State<'_, crate::AppState>, ) -> Result { let database = state.database.clone(); let service = kb_lib::LocalPipelineValidationService::new(database.clone()); - let run_result = service.validate_v0_7_27_current_database().await; + let run_result = service.validate_v0_7_28_current_database().await; let run = match run_result { Ok(run) => run, Err(error) => { diff --git a/kb_demo_app/src/lib.rs b/kb_demo_app/src/lib.rs index 45d151d..9bbb7dd 100644 --- a/kb_demo_app/src/lib.rs +++ b/kb_demo_app/src/lib.rs @@ -154,6 +154,7 @@ pub async fn run() -> Result<(), kb_lib::Error> { crate::demo_pipeline2::demo_pipeline2_diagnose_local_pipeline, crate::demo_pipeline2::demo_pipeline2_validate_local_pipeline, crate::demo_pipeline2::demo_pipeline2_get_protocol_candidate_summaries, + crate::demo_pipeline2::demo_pipeline2_get_program_instruction_discriminator_summaries, ]); tauri_builder = tauri_builder.plugin(tracing_builder.build::()); tauri_builder = tauri_builder.setup(|app| { diff --git a/kb_lib/Cargo.toml b/kb_lib/Cargo.toml index 62976f8..5c3c8dc 100644 --- a/kb_lib/Cargo.toml +++ b/kb_lib/Cargo.toml @@ -10,6 +10,8 @@ publish.workspace = true [dependencies] chrono.workspace = true +base64.workspace = true +borsh.workspace = true bs58.workspace = true futures-util.workspace = true reqwest.workspace = true diff --git a/kb_lib/src/db.rs b/kb_lib/src/db.rs index b68063a..7a43bce 100644 --- a/kb_lib/src/db.rs +++ b/kb_lib/src/db.rs @@ -50,6 +50,8 @@ pub use dtos::PoolDto; pub use dtos::PoolListingDto; pub use dtos::PoolOriginDto; pub use dtos::PoolTokenDto; +pub use dtos::ProgramInstructionDiagnosticDto; +pub use dtos::ProgramInstructionDiscriminatorSummaryDto; pub use dtos::ProtocolCandidateDto; pub use dtos::ProtocolCandidateSummaryDto; pub use dtos::SwapDto; @@ -85,6 +87,8 @@ pub use entities::PoolEntity; pub use entities::PoolListingEntity; pub use entities::PoolOriginEntity; pub use entities::PoolTokenEntity; +pub use entities::ProgramInstructionDiagnosticEntity; +pub use entities::ProgramInstructionDiscriminatorRowEntity; pub use entities::ProtocolCandidateEntity; pub use entities::ProtocolCandidateSummaryEntity; pub use entities::SwapEntity; @@ -178,6 +182,8 @@ pub use queries::query_pool_tokens_upsert; pub use queries::query_pools_get_by_address; pub use queries::query_pools_list; pub use queries::query_pools_upsert; +pub use queries::query_program_instruction_diagnostics_list_by_program_id; +pub use queries::query_program_instruction_discriminator_summaries_list_by_program_id; pub use queries::query_protocol_candidate_summaries_list_by_priority; pub use queries::query_protocol_candidates_delete_by_transaction_id; pub use queries::query_protocol_candidates_insert; diff --git a/kb_lib/src/db/dtos.rs b/kb_lib/src/db/dtos.rs index e2b5677..6b67fff 100644 --- a/kb_lib/src/db/dtos.rs +++ b/kb_lib/src/db/dtos.rs @@ -27,6 +27,7 @@ mod pool; mod pool_listing; mod pool_origin; mod pool_token; +mod program_instruction_diagnostic; mod protocol_candidate; mod protocol_candidate_summary; mod swap; @@ -37,6 +38,7 @@ mod trade_event; mod transaction_classification; mod wallet; mod wallet_holding; +mod program_instruction_discriminator_summary; mod wallet_participation; pub(crate) use local_pipeline_diagnostics::LocalDecodedEventDiagnosticSummaryRow; @@ -50,6 +52,7 @@ pub(crate) use local_pipeline_diagnostics::LocalPairDiagnosticSummaryRow; pub(crate) use local_pipeline_diagnostics::LocalPairGapDiagnosticSampleRow; pub(crate) use local_pipeline_diagnostics::LocalPipelineDiagnosticCountersRow; +pub use program_instruction_discriminator_summary::ProgramInstructionDiscriminatorSummaryDto; pub use analysis_signal::AnalysisSignalDto; pub use chain_instruction::ChainInstructionDto; pub use chain_slot::ChainSlotDto; @@ -85,6 +88,7 @@ pub use pool::PoolDto; pub use pool_listing::PoolListingDto; pub use pool_origin::PoolOriginDto; pub use pool_token::PoolTokenDto; +pub use program_instruction_diagnostic::ProgramInstructionDiagnosticDto; pub use protocol_candidate::ProtocolCandidateDto; pub use protocol_candidate_summary::ProtocolCandidateSummaryDto; pub use swap::SwapDto; diff --git a/kb_lib/src/db/dtos/program_instruction_diagnostic.rs b/kb_lib/src/db/dtos/program_instruction_diagnostic.rs new file mode 100644 index 0000000..959edce --- /dev/null +++ b/kb_lib/src/db/dtos/program_instruction_diagnostic.rs @@ -0,0 +1,298 @@ +// file: kb_lib/src/db/dtos/program_instruction_diagnostic.rs + +//! Program instruction diagnostic DTO. + +/// Diagnostic row for instructions of one Solana program. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ProgramInstructionDiagnosticDto { + /// Parent transaction id. + pub transaction_id: i64, + /// Transaction signature. + pub signature: std::string::String, + /// Optional Solana slot. + pub slot: std::option::Option, + /// Internal instruction id. + pub instruction_id: i64, + /// Optional parent instruction id. + pub parent_instruction_id: std::option::Option, + /// Outer instruction index. + pub instruction_index: u32, + /// Optional inner instruction index. + pub inner_instruction_index: std::option::Option, + /// Program id. + pub program_id: std::option::Option, + /// Optional program name. + pub program_name: std::option::Option, + /// Optional stack height. + pub stack_height: std::option::Option, + /// Number of accounts in `accounts_json`. + pub accounts_count: u64, + /// First account, when present. + pub account_0: std::option::Option, + /// Second account, when present. + pub account_1: std::option::Option, + /// Third account, when present. + pub account_2: std::option::Option, + /// Fourth account, when present. + pub account_3: std::option::Option, + /// Last account, when present. + pub last_account: std::option::Option, + /// Optional parsed instruction type. + pub parsed_type: std::option::Option, + /// True when `data_json` exists. + pub has_data_json: bool, + /// True when `parsed_json` exists. + pub has_parsed_json: bool, + /// Short data JSON preview. + pub data_json_preview: std::option::Option, + /// Short parsed JSON preview. + pub parsed_json_preview: std::option::Option, + /// JSON array of useful log hints. + pub log_hints_json: std::string::String, +} + +impl TryFrom for ProgramInstructionDiagnosticDto { + type Error = crate::Error; + + fn try_from(entity: crate::ProgramInstructionDiagnosticEntity) -> Result { + let slot = match entity.slot { + Some(slot) => match u64::try_from(slot) { + Ok(slot) => Some(slot), + Err(error) => { + return Err(crate::Error::Db(format!( + "cannot convert program instruction diagnostic slot '{}' to u64: {}", + slot, error + ))); + }, + }, + None => None, + }; + let instruction_index = match u32::try_from(entity.instruction_index) { + Ok(instruction_index) => instruction_index, + Err(error) => { + return Err(crate::Error::Db(format!( + "cannot convert program instruction diagnostic instruction_index '{}' to u32: {}", + entity.instruction_index, error + ))); + }, + }; + let inner_instruction_index = match entity.inner_instruction_index { + Some(inner_instruction_index) => match u32::try_from(inner_instruction_index) { + Ok(inner_instruction_index) => Some(inner_instruction_index), + Err(error) => { + return Err(crate::Error::Db(format!( + "cannot convert program instruction diagnostic inner_instruction_index '{}' to u32: {}", + inner_instruction_index, error + ))); + }, + }, + None => None, + }; + let stack_height = match entity.stack_height { + Some(stack_height) => match u32::try_from(stack_height) { + Ok(stack_height) => Some(stack_height), + Err(error) => { + return Err(crate::Error::Db(format!( + "cannot convert program instruction diagnostic stack_height '{}' to u32: {}", + stack_height, error + ))); + }, + }, + None => None, + }; + let accounts = parse_accounts_json(entity.accounts_json.as_str()); + let accounts_count = match u64::try_from(accounts.len()) { + Ok(accounts_count) => accounts_count, + Err(error) => { + return Err(crate::Error::Db(format!( + "cannot convert accounts count to u64: {}", + error + ))); + }, + }; + let account_0 = account_at(&accounts, 0); + let account_1 = account_at(&accounts, 1); + let account_2 = account_at(&accounts, 2); + let account_3 = account_at(&accounts, 3); + let last_account = match accounts.last() { + Some(last_account) => Some(last_account.clone()), + None => None, + }; + let log_hints = + collect_log_hints(entity.meta_json.as_deref(), entity.transaction_json.as_str()); + let log_hints_json_result = serde_json::to_string(&log_hints); + let log_hints_json = match log_hints_json_result { + Ok(log_hints_json) => log_hints_json, + Err(error) => { + return Err(crate::Error::Json(format!( + "cannot serialize program instruction log hints: {}", + error + ))); + }, + }; + return Ok(Self { + transaction_id: entity.transaction_id, + signature: entity.signature, + slot, + instruction_id: entity.instruction_id, + parent_instruction_id: entity.parent_instruction_id, + instruction_index, + inner_instruction_index, + program_id: entity.program_id, + program_name: entity.program_name, + stack_height, + accounts_count, + account_0, + account_1, + account_2, + account_3, + last_account, + parsed_type: entity.parsed_type, + has_data_json: entity.data_json.is_some(), + has_parsed_json: entity.parsed_json.is_some(), + data_json_preview: preview_text(entity.data_json.as_deref(), 600), + parsed_json_preview: preview_text(entity.parsed_json.as_deref(), 1200), + log_hints_json, + }); + } +} + +fn account_at( + accounts: &[std::string::String], + index: usize, +) -> std::option::Option { + match accounts.get(index) { + Some(account) => return Some(account.clone()), + None => return None, + } +} + +fn preview_text( + text: std::option::Option<&str>, + max_len: usize, +) -> std::option::Option { + let text = match text { + Some(text) => text, + None => return None, + }; + if text.len() <= max_len { + return Some(text.to_string()); + } + let mut preview = text.chars().take(max_len).collect::(); + preview.push_str("..."); + return Some(preview); +} + +fn parse_accounts_json(accounts_json: &str) -> std::vec::Vec { + let parsed_result = serde_json::from_str::(accounts_json); + let parsed = match parsed_result { + Ok(parsed) => parsed, + Err(_) => return std::vec::Vec::new(), + }; + let array = match parsed.as_array() { + Some(array) => array, + None => return std::vec::Vec::new(), + }; + let mut accounts = std::vec::Vec::new(); + for item in array { + if let Some(text) = item.as_str() { + accounts.push(text.to_string()); + continue; + } + if let Some(pubkey) = item.get("pubkey").and_then(|value| value.as_str()) { + accounts.push(pubkey.to_string()); + } + } + return accounts; +} + +fn collect_log_hints( + meta_json: std::option::Option<&str>, + transaction_json: &str, +) -> std::vec::Vec { + let mut hints = std::vec::Vec::new(); + if let Some(meta_json) = meta_json { + collect_log_hints_from_json_text(meta_json, &mut hints); + } + collect_log_hints_from_json_text(transaction_json, &mut hints); + hints.sort(); + hints.dedup(); + return hints; +} + +fn collect_log_hints_from_json_text( + json_text: &str, + hints: &mut std::vec::Vec, +) { + let value_result = serde_json::from_str::(json_text); + let value = match value_result { + Ok(value) => value, + Err(_) => return, + }; + collect_log_hints_from_value(&value, hints); +} + +fn collect_log_hints_from_value( + value: &serde_json::Value, + hints: &mut std::vec::Vec, +) { + match value { + serde_json::Value::String(text) => { + let normalized = text.to_ascii_lowercase(); + if normalized.contains("instruction:") + || normalized.contains("meteora") + || normalized.contains("dlmm") + || normalized.contains("lb") + || normalized.contains("swap") + || normalized.contains("bin") + { + hints.push(text.clone()); + } + }, + serde_json::Value::Array(values) => { + for nested in values { + collect_log_hints_from_value(nested, hints); + } + }, + serde_json::Value::Object(object) => { + for nested in object.values() { + collect_log_hints_from_value(nested, hints); + } + }, + _ => {}, + } +} + +#[cfg(test)] +mod tests { + #[test] + fn accounts_json_extracts_string_accounts() { + let accounts = super::parse_accounts_json( + serde_json::json!(["A111", "B222", "C333"]).to_string().as_str(), + ); + assert_eq!(accounts.len(), 3); + assert_eq!(accounts[0], "A111"); + assert_eq!(accounts[1], "B222"); + assert_eq!(accounts[2], "C333"); + } + + #[test] + fn log_hints_are_extracted_from_nested_json() { + let mut hints = std::vec::Vec::new(); + super::collect_log_hints_from_json_text( + serde_json::json!({ + "meta": { + "logMessages": [ + "Program log: Instruction: Swap", + "irrelevant" + ] + } + }) + .to_string() + .as_str(), + &mut hints, + ); + assert_eq!(hints.len(), 1); + assert_eq!(hints[0], "Program log: Instruction: Swap"); + } +} diff --git a/kb_lib/src/db/dtos/program_instruction_discriminator_summary.rs b/kb_lib/src/db/dtos/program_instruction_discriminator_summary.rs new file mode 100644 index 0000000..220f51b --- /dev/null +++ b/kb_lib/src/db/dtos/program_instruction_discriminator_summary.rs @@ -0,0 +1,46 @@ +// file: kb_lib/src/db/dtos/program_instruction_discriminator_summary.rs + +//! Program instruction discriminator summary DTO. + +/// Aggregated instruction discriminator diagnostic row. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ProgramInstructionDiscriminatorSummaryDto { + /// Program id. + pub program_id: std::string::String, + /// First eight decoded instruction-data bytes as hex. + pub discriminator_hex: std::option::Option, + /// Known instruction name, when recognized by local diagnostic mapping. + pub known_instruction_name: std::option::Option, + /// Number of accounts in the instruction. + pub accounts_count: u64, + /// Optional stack height. + pub stack_height: std::option::Option, + /// True when the grouped instructions are inner instructions. + pub is_inner_instruction: bool, + /// Number of instruction rows in this group. + pub occurrence_count: u64, + /// Number of distinct transactions in this group. + pub transaction_count: u64, + /// Number of instruction rows already linked to decoded events. + pub decoded_event_count: u64, + /// Number of instruction rows not linked to decoded events. + pub undecoded_occurrence_count: u64, + /// Latest observed slot. + pub latest_slot: std::option::Option, + /// Latest signature in this group. + pub latest_signature: std::string::String, + /// Latest instruction id in this group. + pub latest_instruction_id: i64, + /// Latest outer instruction index. + pub latest_instruction_index: u32, + /// Latest inner instruction index. + pub latest_inner_instruction_index: std::option::Option, + /// Latest parsed type. + pub latest_parsed_type: std::option::Option, + /// Latest decoded event kind. + pub latest_decoded_event_kind: std::option::Option, + /// Data JSON preview from the latest row. + pub latest_data_json_preview: std::option::Option, + /// Accounts JSON preview from the latest row. + pub latest_accounts_json_preview: std::option::Option, +} diff --git a/kb_lib/src/db/entities.rs b/kb_lib/src/db/entities.rs index bd39c94..8b33c19 100644 --- a/kb_lib/src/db/entities.rs +++ b/kb_lib/src/db/entities.rs @@ -28,6 +28,8 @@ mod pool; mod pool_listing; mod pool_origin; mod pool_token; +mod program_instruction_diagnostic; +mod program_instruction_discriminator_row; mod protocol_candidate; mod protocol_candidate_summary; mod swap; @@ -64,6 +66,8 @@ pub use pool::PoolEntity; pub use pool_listing::PoolListingEntity; pub use pool_origin::PoolOriginEntity; pub use pool_token::PoolTokenEntity; +pub use program_instruction_diagnostic::ProgramInstructionDiagnosticEntity; +pub use program_instruction_discriminator_row::ProgramInstructionDiscriminatorRowEntity; pub use protocol_candidate::ProtocolCandidateEntity; pub use protocol_candidate_summary::ProtocolCandidateSummaryEntity; pub use swap::SwapEntity; diff --git a/kb_lib/src/db/entities/program_instruction_diagnostic.rs b/kb_lib/src/db/entities/program_instruction_diagnostic.rs new file mode 100644 index 0000000..dd4c102 --- /dev/null +++ b/kb_lib/src/db/entities/program_instruction_diagnostic.rs @@ -0,0 +1,40 @@ +// file: kb_lib/src/db/entities/program_instruction_diagnostic.rs + +//! Program instruction diagnostic entity. + +/// Raw diagnostic row for instructions of one Solana program. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow)] +pub struct ProgramInstructionDiagnosticEntity { + /// Parent transaction id. + pub transaction_id: i64, + /// Transaction signature. + pub signature: std::string::String, + /// Optional Solana slot. + pub slot: std::option::Option, + /// Internal instruction id. + pub instruction_id: i64, + /// Optional parent instruction id. + pub parent_instruction_id: std::option::Option, + /// Outer instruction index. + pub instruction_index: i64, + /// Optional inner instruction index. + pub inner_instruction_index: std::option::Option, + /// Optional program id. + pub program_id: std::option::Option, + /// Optional program name. + pub program_name: std::option::Option, + /// Optional stack height. + pub stack_height: std::option::Option, + /// Serialized accounts JSON. + pub accounts_json: std::string::String, + /// Optional serialized data JSON. + pub data_json: std::option::Option, + /// Optional parsed instruction type. + pub parsed_type: std::option::Option, + /// Optional serialized parsed JSON. + pub parsed_json: std::option::Option, + /// Optional transaction meta JSON. + pub meta_json: std::option::Option, + /// Full transaction JSON. + pub transaction_json: std::string::String, +} diff --git a/kb_lib/src/db/entities/program_instruction_discriminator_row.rs b/kb_lib/src/db/entities/program_instruction_discriminator_row.rs new file mode 100644 index 0000000..7142bff --- /dev/null +++ b/kb_lib/src/db/entities/program_instruction_discriminator_row.rs @@ -0,0 +1,37 @@ + +// file: kb_lib/src/db/entities/program_instruction_discriminator_row.rs + +//! Program instruction discriminator diagnostic row entity. + +/// Raw row used to summarize instruction discriminators for one Solana program. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow)] +pub struct ProgramInstructionDiscriminatorRowEntity { + /// Parent transaction id. + pub transaction_id: i64, + /// Transaction signature. + pub signature: std::string::String, + /// Optional Solana slot. + pub slot: std::option::Option, + /// Internal instruction id. + pub instruction_id: i64, + /// Optional parent instruction id. + pub parent_instruction_id: std::option::Option, + /// Outer instruction index. + pub instruction_index: i64, + /// Optional inner instruction index. + pub inner_instruction_index: std::option::Option, + /// Optional program id. + pub program_id: std::option::Option, + /// Optional stack height. + pub stack_height: std::option::Option, + /// Serialized accounts JSON. + pub accounts_json: std::string::String, + /// Optional serialized data JSON. + pub data_json: std::option::Option, + /// Optional parsed instruction type. + pub parsed_type: std::option::Option, + /// Optional decoded event id for this instruction. + pub decoded_event_id: std::option::Option, + /// Optional decoded event kind for this instruction. + pub decoded_event_kind: std::option::Option, +} diff --git a/kb_lib/src/db/queries.rs b/kb_lib/src/db/queries.rs index 557d3df..6c6e1de 100644 --- a/kb_lib/src/db/queries.rs +++ b/kb_lib/src/db/queries.rs @@ -27,6 +27,7 @@ mod pool; mod pool_listing; mod pool_origin; mod pool_token; +mod program_instruction_diagnostic; mod protocol_candidate; mod swap; mod token; @@ -120,6 +121,8 @@ pub use pool_origin::query_pool_origins_list; pub use pool_origin::query_pool_origins_upsert; pub use pool_token::query_pool_tokens_list_by_pool_id; pub use pool_token::query_pool_tokens_upsert; +pub use program_instruction_diagnostic::query_program_instruction_diagnostics_list_by_program_id; +pub use program_instruction_diagnostic::query_program_instruction_discriminator_summaries_list_by_program_id; pub use protocol_candidate::query_protocol_candidate_summaries_list_by_priority; pub use protocol_candidate::query_protocol_candidates_delete_by_transaction_id; pub use protocol_candidate::query_protocol_candidates_insert; diff --git a/kb_lib/src/db/queries/program_instruction_diagnostic.rs b/kb_lib/src/db/queries/program_instruction_diagnostic.rs new file mode 100644 index 0000000..ad8ae4a --- /dev/null +++ b/kb_lib/src/db/queries/program_instruction_diagnostic.rs @@ -0,0 +1,451 @@ +// file: kb_lib/src/db/queries/program_instruction_diagnostic.rs + +//! Queries for program instruction diagnostics. + +/// Lists diagnostic instruction rows for one program id. +pub async fn query_program_instruction_diagnostics_list_by_program_id( + database: &crate::Database, + program_id: &str, + limit: u32, +) -> Result, crate::Error> { + if limit == 0 { + return Ok(std::vec::Vec::new()); + } + match database.connection() { + crate::DatabaseConnection::Sqlite(pool) => { + let query_result = + sqlx::query_as::( + r#" +SELECT + tx.id AS transaction_id, + tx.signature AS signature, + tx.slot AS slot, + ins.id AS instruction_id, + ins.parent_instruction_id AS parent_instruction_id, + ins.instruction_index AS instruction_index, + ins.inner_instruction_index AS inner_instruction_index, + ins.program_id AS program_id, + ins.program_name AS program_name, + ins.stack_height AS stack_height, + ins.accounts_json AS accounts_json, + ins.data_json AS data_json, + ins.parsed_type AS parsed_type, + ins.parsed_json AS parsed_json, + tx.meta_json AS meta_json, + tx.transaction_json AS transaction_json +FROM k_sol_chain_instructions ins +JOIN k_sol_chain_transactions tx + ON tx.id = ins.transaction_id +WHERE ins.program_id = ? +ORDER BY + tx.slot DESC, + tx.id DESC, + ins.instruction_index ASC, + ins.inner_instruction_index ASC, + ins.id ASC +LIMIT ? + "#, + ) + .bind(program_id.to_string()) + .bind(i64::from(limit)) + .fetch_all(pool) + .await; + let entities = match query_result { + Ok(entities) => entities, + Err(error) => { + return Err(crate::Error::Db(format!( + "cannot list program instruction diagnostics for program_id '{}' on sqlite: {}", + program_id, error + ))); + }, + }; + let mut dtos = std::vec::Vec::new(); + for entity in entities { + let dto_result = crate::ProgramInstructionDiagnosticDto::try_from(entity); + let dto = match dto_result { + Ok(dto) => dto, + Err(error) => return Err(error), + }; + dtos.push(dto); + } + return Ok(dtos); + }, + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct ProgramInstructionDiscriminatorSummaryKey { + program_id: std::string::String, + discriminator_hex: std::option::Option, + accounts_count: u64, + stack_height: std::option::Option, + is_inner_instruction: bool, +} + +#[derive(Debug, Clone)] +struct ProgramInstructionDiscriminatorSummaryAccumulator { + key: ProgramInstructionDiscriminatorSummaryKey, + known_instruction_name: std::option::Option, + occurrence_count: u64, + decoded_event_count: u64, + transaction_signatures: std::collections::BTreeSet, + latest_slot: std::option::Option, + latest_signature: std::string::String, + latest_instruction_id: i64, + latest_instruction_index: u32, + latest_inner_instruction_index: std::option::Option, + latest_parsed_type: std::option::Option, + latest_decoded_event_kind: std::option::Option, + latest_data_json_preview: std::option::Option, + latest_accounts_json_preview: std::option::Option, +} + +fn build_program_instruction_discriminator_summaries( + rows: std::vec::Vec, +) -> Result, crate::Error> { + let mut grouped = std::collections::BTreeMap::< + ProgramInstructionDiscriminatorSummaryKey, + ProgramInstructionDiscriminatorSummaryAccumulator, + >::new(); + for row in rows { + let summary_row_result = build_summary_row_from_discriminator_entity(row); + let summary_row = match summary_row_result { + Ok(summary_row) => summary_row, + Err(error) => return Err(error), + }; + let existing = grouped.get_mut(&summary_row.key); + match existing { + Some(existing) => { + existing.occurrence_count += 1; + if summary_row.decoded_event_count > 0 { + existing.decoded_event_count += 1; + } + existing.transaction_signatures.insert(summary_row.latest_signature.clone()); + if summary_row.latest_instruction_id > existing.latest_instruction_id { + existing.latest_slot = summary_row.latest_slot; + existing.latest_signature = summary_row.latest_signature; + existing.latest_instruction_id = summary_row.latest_instruction_id; + existing.latest_instruction_index = summary_row.latest_instruction_index; + existing.latest_inner_instruction_index = + summary_row.latest_inner_instruction_index; + existing.latest_parsed_type = summary_row.latest_parsed_type; + existing.latest_decoded_event_kind = summary_row.latest_decoded_event_kind; + existing.latest_data_json_preview = summary_row.latest_data_json_preview; + existing.latest_accounts_json_preview = + summary_row.latest_accounts_json_preview; + } + }, + None => { + grouped.insert(summary_row.key.clone(), summary_row); + }, + } + } + let mut summaries = std::vec::Vec::new(); + for (_, accumulator) in grouped { + let transaction_count_result = u64::try_from(accumulator.transaction_signatures.len()); + let transaction_count = match transaction_count_result { + Ok(transaction_count) => transaction_count, + Err(error) => { + return Err(crate::Error::Db(format!( + "cannot convert discriminator summary transaction_count to u64: {}", + error + ))); + }, + }; + let undecoded_occurrence_count = + accumulator.occurrence_count.saturating_sub(accumulator.decoded_event_count); + summaries.push(crate::ProgramInstructionDiscriminatorSummaryDto { + program_id: accumulator.key.program_id, + discriminator_hex: accumulator.key.discriminator_hex, + known_instruction_name: accumulator.known_instruction_name, + accounts_count: accumulator.key.accounts_count, + stack_height: accumulator.key.stack_height, + is_inner_instruction: accumulator.key.is_inner_instruction, + occurrence_count: accumulator.occurrence_count, + transaction_count, + decoded_event_count: accumulator.decoded_event_count, + undecoded_occurrence_count, + latest_slot: accumulator.latest_slot, + latest_signature: accumulator.latest_signature, + latest_instruction_id: accumulator.latest_instruction_id, + latest_instruction_index: accumulator.latest_instruction_index, + latest_inner_instruction_index: accumulator.latest_inner_instruction_index, + latest_parsed_type: accumulator.latest_parsed_type, + latest_decoded_event_kind: accumulator.latest_decoded_event_kind, + latest_data_json_preview: accumulator.latest_data_json_preview, + latest_accounts_json_preview: accumulator.latest_accounts_json_preview, + }); + } + summaries.sort_by(|left, right| { + right + .undecoded_occurrence_count + .cmp(&left.undecoded_occurrence_count) + .then(right.transaction_count.cmp(&left.transaction_count)) + .then(right.occurrence_count.cmp(&left.occurrence_count)) + .then(right.latest_instruction_id.cmp(&left.latest_instruction_id)) + }); + return Ok(summaries); +} + +fn build_summary_row_from_discriminator_entity( + row: crate::ProgramInstructionDiscriminatorRowEntity, +) -> Result { + let program_id = match row.program_id.clone() { + Some(program_id) => program_id, + None => "unknown".to_string(), + }; + let accounts_count = accounts_count_from_json(row.accounts_json.as_str()); + let stack_height = match row.stack_height { + Some(stack_height) => match u32::try_from(stack_height) { + Ok(stack_height) => Some(stack_height), + Err(error) => { + return Err(crate::Error::Db(format!( + "cannot convert discriminator summary stack_height '{}' to u32: {}", + stack_height, error + ))); + }, + }, + None => None, + }; + let instruction_index = match u32::try_from(row.instruction_index) { + Ok(instruction_index) => instruction_index, + Err(error) => { + return Err(crate::Error::Db(format!( + "cannot convert discriminator summary instruction_index '{}' to u32: {}", + row.instruction_index, error + ))); + }, + }; + let inner_instruction_index = match row.inner_instruction_index { + Some(inner_instruction_index) => match u32::try_from(inner_instruction_index) { + Ok(inner_instruction_index) => Some(inner_instruction_index), + Err(error) => { + return Err(crate::Error::Db(format!( + "cannot convert discriminator summary inner_instruction_index '{}' to u32: {}", + inner_instruction_index, error + ))); + }, + }, + None => None, + }; + let latest_slot = match row.slot { + Some(slot) => match u64::try_from(slot) { + Ok(slot) => Some(slot), + Err(error) => { + return Err(crate::Error::Db(format!( + "cannot convert discriminator summary slot '{}' to u64: {}", + slot, error + ))); + }, + }, + None => None, + }; + let decoded_data = decode_instruction_data_json(row.data_json.as_ref()); + let discriminator_hex = match decoded_data { + Some(decoded_data) => first_8_bytes_hex(decoded_data.as_slice()), + None => None, + }; + let known_instruction_name = known_instruction_name_for_program_discriminator( + program_id.as_str(), + discriminator_hex.as_deref(), + ); + let mut transaction_signatures = std::collections::BTreeSet::new(); + transaction_signatures.insert(row.signature.clone()); + let decoded_event_count = if row.decoded_event_id.is_some() { 1_u64 } else { 0_u64 }; + let key = ProgramInstructionDiscriminatorSummaryKey { + program_id, + discriminator_hex, + accounts_count, + stack_height, + is_inner_instruction: row.parent_instruction_id.is_some(), + }; + return Ok(ProgramInstructionDiscriminatorSummaryAccumulator { + key, + known_instruction_name, + occurrence_count: 1, + decoded_event_count, + transaction_signatures, + latest_slot, + latest_signature: row.signature, + latest_instruction_id: row.instruction_id, + latest_instruction_index: instruction_index, + latest_inner_instruction_index: inner_instruction_index, + latest_parsed_type: row.parsed_type, + latest_decoded_event_kind: row.decoded_event_kind, + latest_data_json_preview: preview_text(row.data_json.as_deref(), 300), + latest_accounts_json_preview: preview_text(Some(row.accounts_json.as_str()), 600), + }); +} + +fn accounts_count_from_json(accounts_json: &str) -> u64 { + let parsed_result = serde_json::from_str::(accounts_json); + let parsed = match parsed_result { + Ok(parsed) => parsed, + Err(_) => return 0, + }; + let array = match parsed.as_array() { + Some(array) => array, + None => return 0, + }; + let count_result = u64::try_from(array.len()); + match count_result { + Ok(count) => return count, + Err(_) => return 0, + } +} + +fn decode_instruction_data_json( + data_json: std::option::Option<&std::string::String>, +) -> std::option::Option> { + let data_json = match data_json { + Some(data_json) => data_json, + None => return None, + }; + let parsed_result = serde_json::from_str::(data_json.as_str()); + let parsed = match parsed_result { + Ok(parsed) => parsed, + Err(_) => return None, + }; + if let serde_json::Value::String(base58_text) = parsed { + let decoded_result = decode_base58(base58_text.as_str()); + match decoded_result { + Ok(decoded) => return Some(decoded), + Err(_) => return None, + } + } + return None; +} + +fn decode_base58(input: &str) -> Result, crate::Error> { + let decoded_result = bs58::decode(input).into_vec(); + match decoded_result { + Ok(decoded) => return Ok(decoded), + Err(error) => { + return Err(crate::Error::Json(format!( + "cannot decode instruction data from base58: {}", + error + ))); + }, + } +} + +fn first_8_bytes_hex(bytes: &[u8]) -> std::option::Option { + if bytes.len() < 8 { + return None; + } + return Some(format!( + "{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], + )); +} + +fn known_instruction_name_for_program_discriminator( + program_id: &str, + discriminator_hex: std::option::Option<&str>, +) -> std::option::Option { + if program_id != crate::METEORA_DLMM_PROGRAM_ID { + return None; + } + let discriminator_hex = match discriminator_hex { + Some(discriminator_hex) => discriminator_hex, + None => return None, + }; + let name = match discriminator_hex { + "2d9aedd2dd0fa65c" => "initialize_lb_pair", + "493b2478ed536cc6" => "initialize_lb_pair2", + "2e2729876fb7c840" => "initialize_customizable_permissionless_lb_pair", + "f349817e3313f16b" => "initialize_customizable_permissionless_lb_pair2", + "f8c69e91e17587c8" => "swap", + "414b3f4ceb5b5b88" => "swap2", + "fa49652126cf4bb8" => "swap_exact_out", + "2bd7f784893cf351" => "swap_exact_out2", + "38ade6d0ade49ccd" => "swap_with_price_impact", + "235613b94ed44bd3" => "initialize_bin_array", + "dbc0ea47bebf6650" => "initialize_position", + "b59d59438fb63448" => "add_liquidity", + "5055d14818ceb16c" => "remove_liquidity", + "e445a52e51cb9a1d" => "anchor_self_cpi_log_event", + "70bf65ab1c907fbb" => "claim_fee2", + _ => return None, + }; + return Some(name.to_string()); +} + +fn preview_text( + text: std::option::Option<&str>, + max_len: usize, +) -> std::option::Option { + let text = match text { + Some(text) => text, + None => return None, + }; + if text.len() <= max_len { + return Some(text.to_string()); + } + let mut preview = text.chars().take(max_len).collect::(); + preview.push_str("..."); + return Some(preview); +} + +/// Lists instruction discriminator summaries for one program id. +pub async fn query_program_instruction_discriminator_summaries_list_by_program_id( + database: &crate::Database, + program_id: &str, + limit: u32, +) -> Result, crate::Error> { + if limit == 0 { + return Ok(std::vec::Vec::new()); + } + match database.connection() { + crate::DatabaseConnection::Sqlite(pool) => { + let query_result = + sqlx::query_as::( + r#" +SELECT + tx.id AS transaction_id, + tx.signature AS signature, + tx.slot AS slot, + ins.id AS instruction_id, + ins.parent_instruction_id AS parent_instruction_id, + ins.instruction_index AS instruction_index, + ins.inner_instruction_index AS inner_instruction_index, + ins.program_id AS program_id, + ins.stack_height AS stack_height, + ins.accounts_json AS accounts_json, + ins.data_json AS data_json, + ins.parsed_type AS parsed_type, + de.id AS decoded_event_id, + de.event_kind AS decoded_event_kind +FROM k_sol_chain_instructions ins +JOIN k_sol_chain_transactions tx + ON tx.id = ins.transaction_id +LEFT JOIN k_sol_dex_decoded_events de + ON de.transaction_id = tx.id + AND de.instruction_id = ins.id +WHERE ins.program_id = ? +ORDER BY + tx.slot DESC, + tx.id DESC, + ins.instruction_index ASC, + ins.inner_instruction_index ASC, + ins.id ASC +LIMIT ? + "#, + ) + .bind(program_id.to_string()) + .bind(i64::from(limit)) + .fetch_all(pool) + .await; + let rows = match query_result { + Ok(rows) => rows, + Err(error) => { + return Err(crate::Error::Db(format!( + "cannot list program instruction discriminator diagnostics for program_id '{}' on sqlite: {}", + program_id, error + ))); + }, + }; + return build_program_instruction_discriminator_summaries(rows); + }, + } +} diff --git a/kb_lib/src/dex.rs b/kb_lib/src/dex.rs index b2bb8b1..b1026df 100644 --- a/kb_lib/src/dex.rs +++ b/kb_lib/src/dex.rs @@ -7,6 +7,7 @@ mod fluxbeam; mod meteora_damm_v1; mod meteora_damm_v2; mod meteora_dbc; +mod meteora_dlmm; mod orca_whirlpools; mod pump_fun; mod pump_swap; @@ -34,6 +35,10 @@ pub use meteora_dbc::MeteoraDbcCreatePoolDecoded; pub use meteora_dbc::MeteoraDbcDecodedEvent; pub use meteora_dbc::MeteoraDbcDecoder; pub use meteora_dbc::MeteoraDbcSwapDecoded; +pub use meteora_dlmm::MeteoraDlmmCreatePoolDecoded; +pub use meteora_dlmm::MeteoraDlmmDecodedEvent; +pub use meteora_dlmm::MeteoraDlmmDecoder; +pub use meteora_dlmm::MeteoraDlmmSwapDecoded; pub use orca_whirlpools::OrcaWhirlpoolsCreatePoolDecoded; pub use orca_whirlpools::OrcaWhirlpoolsDecodedEvent; pub use orca_whirlpools::OrcaWhirlpoolsDecoder; diff --git a/kb_lib/src/dex/meteora_dlmm.rs b/kb_lib/src/dex/meteora_dlmm.rs new file mode 100644 index 0000000..163d040 --- /dev/null +++ b/kb_lib/src/dex/meteora_dlmm.rs @@ -0,0 +1,1318 @@ +// file: kb_lib/src/dex/meteora_dlmm.rs + +//! Meteora DLMM transaction decoder. +//! +//! This first decoder version is intentionally conservative. It only emits +//! decoded events when the projected instruction or transaction logs expose +//! clear DLMM create/swap hints. + +const DLMM_DISCRIMINATOR_CLAIM_FEE2: [u8; 8] = [0x70, 0xbf, 0x65, 0xab, 0x1c, 0x90, 0x7f, 0xbb]; + +const DLMM_DISCRIMINATOR_INITIALIZE_POSITION: [u8; 8] = + [0xdb, 0xc0, 0xea, 0x47, 0xbe, 0xbf, 0x66, 0x50]; + +const DLMM_DISCRIMINATOR_INITIALIZE_LB_PAIR: [u8; 8] = + [0x2d, 0x9a, 0xed, 0xd2, 0xdd, 0x0f, 0xa6, 0x5c]; + +const DLMM_DISCRIMINATOR_INITIALIZE_LB_PAIR2: [u8; 8] = + [0x49, 0x3b, 0x24, 0x78, 0xed, 0x53, 0x6c, 0xc6]; + +const DLMM_DISCRIMINATOR_INITIALIZE_CUSTOMIZABLE_PERMISSIONLESS_LB_PAIR: [u8; 8] = + [0x2e, 0x27, 0x29, 0x87, 0x6f, 0xb7, 0xc8, 0x40]; + +const DLMM_DISCRIMINATOR_INITIALIZE_CUSTOMIZABLE_PERMISSIONLESS_LB_PAIR2: [u8; 8] = + [0xf3, 0x49, 0x81, 0x7e, 0x33, 0x13, 0xf1, 0x6b]; + +const DLMM_DISCRIMINATOR_SWAP: [u8; 8] = [0xf8, 0xc6, 0x9e, 0x91, 0xe1, 0x75, 0x87, 0xc8]; + +const DLMM_DISCRIMINATOR_SWAP2: [u8; 8] = [0x41, 0x4b, 0x3f, 0x4c, 0xeb, 0x5b, 0x5b, 0x88]; + +const DLMM_DISCRIMINATOR_SWAP_EXACT_OUT: [u8; 8] = [0xfa, 0x49, 0x65, 0x21, 0x26, 0xcf, 0x4b, 0xb8]; + +const DLMM_DISCRIMINATOR_SWAP_EXACT_OUT2: [u8; 8] = + [0x2b, 0xd7, 0xf7, 0x84, 0x89, 0x3c, 0xf3, 0x51]; + +const DLMM_DISCRIMINATOR_SWAP_WITH_PRICE_IMPACT: [u8; 8] = + [0x38, 0xad, 0xe6, 0xd0, 0xad, 0xe4, 0x9c, 0xcd]; + +/// Decoded Meteora DLMM create-pool event. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct MeteoraDlmmCreatePoolDecoded { + /// Parent transaction id. + pub transaction_id: i64, + /// Parent instruction id. + pub instruction_id: i64, + /// Transaction signature. + pub signature: std::string::String, + /// Program id. + pub program_id: std::string::String, + /// Optional DLMM pair/pool account. + pub pool_account: std::option::Option, + /// Optional token X/base mint. + pub token_a_mint: std::option::Option, + /// Optional token Y/quote mint. + pub token_b_mint: std::option::Option, + /// Optional preset/config account. + pub config_account: std::option::Option, + /// Decoded payload. + pub payload_json: serde_json::Value, +} + +/// Decoded Meteora DLMM swap event. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct MeteoraDlmmSwapDecoded { + /// Parent transaction id. + pub transaction_id: i64, + /// Parent instruction id. + pub instruction_id: i64, + /// Transaction signature. + pub signature: std::string::String, + /// Program id. + pub program_id: std::string::String, + /// Trade side relative to normalized base, when inferable. + pub trade_side: crate::SwapTradeSide, + /// Optional DLMM pair/pool account. + pub pool_account: std::option::Option, + /// Optional token X/base mint. + pub token_a_mint: std::option::Option, + /// Optional token Y/quote mint. + pub token_b_mint: std::option::Option, + /// Optional reserve X token account. + pub reserve_x_account: std::option::Option, + /// Optional reserve Y token account. + pub reserve_y_account: std::option::Option, + /// Optional user token-in account. + pub user_token_in_account: std::option::Option, + /// Optional user token-out account. + pub user_token_out_account: std::option::Option, + /// Decoded payload. + pub payload_json: serde_json::Value, +} + +/// Decoded Meteora DLMM event. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub enum MeteoraDlmmDecodedEvent { + /// DLMM pair/pool creation. + CreatePool(MeteoraDlmmCreatePoolDecoded), + /// DLMM swap. + Swap(MeteoraDlmmSwapDecoded), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MeteoraDlmmInstructionKind { + CreatePool, + Swap, + Ignore, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MeteoraDlmmInstructionName { + InitializeLbPair, + InitializeLbPair2, + InitializeCustomizablePermissionlessLbPair, + InitializeCustomizablePermissionlessLbPair2, + Swap, + Swap2, + SwapExactOut, + SwapExactOut2, + SwapWithPriceImpact, + ClaimFee2, + InitializePosition, + Unknown, +} + +impl MeteoraDlmmInstructionName { + fn as_str(&self) -> &'static str { + match self { + Self::InitializeLbPair => return "initialize_lb_pair", + Self::InitializeLbPair2 => return "initialize_lb_pair2", + Self::InitializeCustomizablePermissionlessLbPair => { + return "initialize_customizable_permissionless_lb_pair"; + }, + Self::InitializeCustomizablePermissionlessLbPair2 => { + return "initialize_customizable_permissionless_lb_pair2"; + }, + Self::Swap => return "swap", + Self::Swap2 => return "swap2", + Self::SwapExactOut => return "swap_exact_out", + Self::SwapExactOut2 => return "swap_exact_out2", + Self::SwapWithPriceImpact => return "swap_with_price_impact", + Self::ClaimFee2 => return "claim_fee2", + Self::InitializePosition => return "initialize_position", + Self::Unknown => return "unknown", + } + } + + fn kind(&self) -> MeteoraDlmmInstructionKind { + match self { + Self::InitializeLbPair + | Self::InitializeLbPair2 + | Self::InitializeCustomizablePermissionlessLbPair + | Self::InitializeCustomizablePermissionlessLbPair2 => { + return MeteoraDlmmInstructionKind::CreatePool; + }, + Self::Swap + | Self::Swap2 + | Self::SwapExactOut + | Self::SwapExactOut2 + | Self::SwapWithPriceImpact => return MeteoraDlmmInstructionKind::Swap, + Self::Unknown => return MeteoraDlmmInstructionKind::Unknown, + Self::ClaimFee2 | Self::InitializePosition => { + return MeteoraDlmmInstructionKind::Ignore; + }, + } + } +} + +/// Meteora DLMM decoder. +#[derive(Debug, Clone, Default)] +pub struct MeteoraDlmmDecoder; + +impl MeteoraDlmmDecoder { + /// Creates a new decoder. + pub fn new() -> Self { + return Self; + } + + /// Decodes one projected transaction into zero or more Meteora DLMM events. + pub fn decode_transaction( + &self, + transaction: &crate::ChainTransactionDto, + instructions: &[crate::ChainInstructionDto], + ) -> Result, crate::Error> { + let transaction_id = match transaction.id { + Some(transaction_id) => transaction_id, + None => { + return Err(crate::Error::InvalidState(format!( + "chain transaction '{}' has no internal id", + transaction.signature + ))); + }, + }; + let transaction_json_result = + serde_json::from_str::(transaction.transaction_json.as_str()); + let transaction_json = match transaction_json_result { + Ok(transaction_json) => transaction_json, + Err(error) => { + return Err(crate::Error::Json(format!( + "cannot parse transaction_json for signature '{}': {}", + transaction.signature, error + ))); + }, + }; + let log_messages = extract_log_messages(&transaction_json); + let mut decoded_events = std::vec::Vec::new(); + for instruction in instructions { + let program_id = match instruction.program_id.as_deref() { + Some(program_id) => program_id, + None => continue, + }; + if program_id != crate::METEORA_DLMM_PROGRAM_ID { + continue; + } + let instruction_id = match instruction.id { + Some(instruction_id) => instruction_id, + None => continue, + }; + let accounts_result = parse_accounts_json(instruction.accounts_json.as_str()); + let accounts = match accounts_result { + Ok(accounts) => accounts, + Err(error) => return Err(error), + }; + let parsed_json_result = parse_optional_parsed_json(instruction.parsed_json.as_ref()); + let parsed_json = match parsed_json_result { + Ok(parsed_json) => parsed_json, + Err(error) => return Err(error), + }; + let instruction_data_result = + decode_instruction_data_json(instruction.data_json.as_ref()); + let instruction_data = match instruction_data_result { + Ok(instruction_data) => instruction_data, + Err(error) => return Err(error), + }; + let instruction_name = classify_instruction_name( + parsed_json.as_ref(), + instruction.parsed_type.as_deref(), + instruction_data.as_deref(), + &log_messages, + ); + let instruction_kind = instruction_name.kind(); + if instruction_kind == MeteoraDlmmInstructionKind::Unknown + || instruction_kind == MeteoraDlmmInstructionKind::Ignore + { + continue; + } + let pool_account = + resolve_dlmm_pool_account(instruction_name, parsed_json.as_ref(), &accounts); + let token_a_mint = + resolve_dlmm_token_x_mint(instruction_name, parsed_json.as_ref(), &accounts); + let token_b_mint = + resolve_dlmm_token_y_mint(instruction_name, parsed_json.as_ref(), &accounts); + if pool_account.is_none() || token_a_mint.is_none() || token_b_mint.is_none() { + continue; + } + let config_account = + resolve_dlmm_config_account(instruction_name, parsed_json.as_ref(), &accounts); + if instruction_kind == MeteoraDlmmInstructionKind::CreatePool { + let payload_json = serde_json::json!({ + "decoder": "meteora_dlmm", + "eventKind": "create_pool", + "decodedInstructionName": instruction_name.as_str(), + "dataDiscriminatorHex": instruction_data + .as_ref() + .and_then(|data| return first_8_bytes_hex(data.as_slice())), + "classifiedInstructionKind": "create_pool", + "signature": transaction.signature, + "instructionId": instruction_id, + "parentInstructionId": instruction.parent_instruction_id, + "instructionIndex": instruction.instruction_index, + "innerInstructionIndex": instruction.inner_instruction_index, + "stackHeight": instruction.stack_height, + "accounts": accounts, + "parsed": parsed_json, + "logMessages": log_messages, + "poolAccount": pool_account, + "tokenAMint": token_a_mint, + "tokenBMint": token_b_mint, + "configAccount": config_account + }); + decoded_events.push(crate::MeteoraDlmmDecodedEvent::CreatePool( + crate::MeteoraDlmmCreatePoolDecoded { + transaction_id, + instruction_id, + signature: transaction.signature.clone(), + program_id: program_id.to_string(), + pool_account, + token_a_mint, + token_b_mint, + config_account, + payload_json, + }, + )); + continue; + } + if instruction_kind == MeteoraDlmmInstructionKind::Swap { + let reserve_x_account = resolve_dlmm_reserve_x_account( + instruction_name, + parsed_json.as_ref(), + &accounts, + ); + let reserve_y_account = resolve_dlmm_reserve_y_account( + instruction_name, + parsed_json.as_ref(), + &accounts, + ); + let user_token_in_account = resolve_dlmm_user_token_in_account( + instruction_name, + parsed_json.as_ref(), + &accounts, + ); + let user_token_out_account = resolve_dlmm_user_token_out_account( + instruction_name, + parsed_json.as_ref(), + &accounts, + ); + let trade_side = infer_trade_side(parsed_json.as_ref()); + let payload_json = serde_json::json!({ + "decoder": "meteora_dlmm", + "eventKind": "swap", + "decodedInstructionName": instruction_name.as_str(), + "dataDiscriminatorHex": instruction_data + .as_ref() + .and_then(|data| return first_8_bytes_hex(data.as_slice())), + "classifiedInstructionKind": "swap", + "signature": transaction.signature, + "instructionId": instruction_id, + "parentInstructionId": instruction.parent_instruction_id, + "instructionIndex": instruction.instruction_index, + "innerInstructionIndex": instruction.inner_instruction_index, + "stackHeight": instruction.stack_height, + "accounts": accounts, + "parsed": parsed_json, + "logMessages": log_messages, + "poolAccount": pool_account, + "tokenAMint": token_a_mint, + "tokenBMint": token_b_mint, + "reserveXAccount": reserve_x_account, + "reserveYAccount": reserve_y_account, + "userTokenInAccount": user_token_in_account, + "userTokenOutAccount": user_token_out_account, + "tradeSide": format!("{:?}", trade_side) + }); + decoded_events.push(crate::MeteoraDlmmDecodedEvent::Swap( + crate::MeteoraDlmmSwapDecoded { + transaction_id, + instruction_id, + signature: transaction.signature.clone(), + program_id: program_id.to_string(), + trade_side, + pool_account, + token_a_mint, + token_b_mint, + reserve_x_account, + reserve_y_account, + user_token_in_account, + user_token_out_account, + payload_json, + }, + )); + } + } + return Ok(decoded_events); + } +} + +fn classify_instruction_name( + parsed_json: std::option::Option<&serde_json::Value>, + parsed_type: std::option::Option<&str>, + instruction_data: std::option::Option<&[u8]>, + log_messages: &[std::string::String], +) -> MeteoraDlmmInstructionName { + let from_data = classify_instruction_name_from_data(instruction_data); + if from_data != MeteoraDlmmInstructionName::Unknown { + return from_data; + } + if instruction_data.is_some() { + return MeteoraDlmmInstructionName::Unknown; + } + if contains_create_pool_hint(parsed_type) { + return MeteoraDlmmInstructionName::InitializeLbPair; + } + if contains_swap_hint(parsed_type) { + return MeteoraDlmmInstructionName::Swap; + } + if parsed_type.is_some() { + return MeteoraDlmmInstructionName::Unknown; + } + if let Some(parsed_json) = parsed_json { + if contains_create_pool_hint_in_value(parsed_json) { + return MeteoraDlmmInstructionName::InitializeLbPair; + } + if contains_swap_hint_in_value(parsed_json) { + return MeteoraDlmmInstructionName::Swap; + } + return MeteoraDlmmInstructionName::Unknown; + } + for log_message in log_messages { + if contains_create_pool_hint(Some(log_message.as_str())) { + return MeteoraDlmmInstructionName::InitializeLbPair; + } + if contains_swap_hint(Some(log_message.as_str())) { + return MeteoraDlmmInstructionName::Swap; + } + } + return MeteoraDlmmInstructionName::Unknown; +} + +fn classify_instruction_name_from_data( + instruction_data: std::option::Option<&[u8]>, +) -> MeteoraDlmmInstructionName { + let instruction_data = match instruction_data { + Some(instruction_data) => instruction_data, + None => return MeteoraDlmmInstructionName::Unknown, + }; + if instruction_data.len() < 8 { + return MeteoraDlmmInstructionName::Unknown; + } + let discriminator = [ + instruction_data[0], + instruction_data[1], + instruction_data[2], + instruction_data[3], + instruction_data[4], + instruction_data[5], + instruction_data[6], + instruction_data[7], + ]; + if discriminator == DLMM_DISCRIMINATOR_INITIALIZE_LB_PAIR { + return MeteoraDlmmInstructionName::InitializeLbPair; + } + if discriminator == DLMM_DISCRIMINATOR_INITIALIZE_LB_PAIR2 { + return MeteoraDlmmInstructionName::InitializeLbPair2; + } + if discriminator == DLMM_DISCRIMINATOR_INITIALIZE_CUSTOMIZABLE_PERMISSIONLESS_LB_PAIR { + return MeteoraDlmmInstructionName::InitializeCustomizablePermissionlessLbPair; + } + if discriminator == DLMM_DISCRIMINATOR_INITIALIZE_CUSTOMIZABLE_PERMISSIONLESS_LB_PAIR2 { + return MeteoraDlmmInstructionName::InitializeCustomizablePermissionlessLbPair2; + } + if discriminator == DLMM_DISCRIMINATOR_SWAP { + return MeteoraDlmmInstructionName::Swap; + } + if discriminator == DLMM_DISCRIMINATOR_SWAP2 { + return MeteoraDlmmInstructionName::Swap2; + } + if discriminator == DLMM_DISCRIMINATOR_SWAP_EXACT_OUT { + return MeteoraDlmmInstructionName::SwapExactOut; + } + if discriminator == DLMM_DISCRIMINATOR_SWAP_EXACT_OUT2 { + return MeteoraDlmmInstructionName::SwapExactOut2; + } + if discriminator == DLMM_DISCRIMINATOR_SWAP_WITH_PRICE_IMPACT { + return MeteoraDlmmInstructionName::SwapWithPriceImpact; + } + if discriminator == DLMM_DISCRIMINATOR_CLAIM_FEE2 { + return MeteoraDlmmInstructionName::ClaimFee2; + } + if discriminator == DLMM_DISCRIMINATOR_INITIALIZE_POSITION { + return MeteoraDlmmInstructionName::InitializePosition; + } + return MeteoraDlmmInstructionName::Unknown; +} + +fn resolve_dlmm_pool_account( + instruction_name: MeteoraDlmmInstructionName, + parsed_json: std::option::Option<&serde_json::Value>, + accounts: &[std::string::String], +) -> std::option::Option { + let parsed_value = extract_string_by_candidate_keys( + parsed_json, + &[ + "lbPair", + "lb_pair", + "lbPairAccount", + "pair", + "pairAccount", + "pool", + "poolAccount", + ], + ); + if parsed_value.is_some() { + return parsed_value; + } + match instruction_name { + MeteoraDlmmInstructionName::InitializeLbPair + | MeteoraDlmmInstructionName::InitializeLbPair2 + | MeteoraDlmmInstructionName::InitializeCustomizablePermissionlessLbPair + | MeteoraDlmmInstructionName::InitializeCustomizablePermissionlessLbPair2 + | MeteoraDlmmInstructionName::Swap + | MeteoraDlmmInstructionName::Swap2 + | MeteoraDlmmInstructionName::SwapExactOut + | MeteoraDlmmInstructionName::SwapExactOut2 + | MeteoraDlmmInstructionName::SwapWithPriceImpact => { + return extract_account(accounts, 0); + }, + MeteoraDlmmInstructionName::ClaimFee2 + | MeteoraDlmmInstructionName::InitializePosition + | MeteoraDlmmInstructionName::Unknown => return None, + } +} + +fn resolve_dlmm_token_x_mint( + instruction_name: MeteoraDlmmInstructionName, + parsed_json: std::option::Option<&serde_json::Value>, + accounts: &[std::string::String], +) -> std::option::Option { + let parsed_value = extract_string_by_candidate_keys( + parsed_json, + &[ + "tokenXMint", + "token_x_mint", + "tokenMintX", + "token_mint_x", + "mintX", + "mint_x", + "tokenAMint", + "token_a_mint", + "baseMint", + ], + ); + if parsed_value.is_some() { + return parsed_value; + } + match instruction_name { + MeteoraDlmmInstructionName::InitializeLbPair + | MeteoraDlmmInstructionName::InitializeLbPair2 + | MeteoraDlmmInstructionName::InitializeCustomizablePermissionlessLbPair + | MeteoraDlmmInstructionName::InitializeCustomizablePermissionlessLbPair2 => { + return extract_account(accounts, 2); + }, + MeteoraDlmmInstructionName::Swap + | MeteoraDlmmInstructionName::Swap2 + | MeteoraDlmmInstructionName::SwapExactOut + | MeteoraDlmmInstructionName::SwapExactOut2 + | MeteoraDlmmInstructionName::SwapWithPriceImpact => { + return extract_account(accounts, 6); + }, + MeteoraDlmmInstructionName::ClaimFee2 + | MeteoraDlmmInstructionName::InitializePosition + | MeteoraDlmmInstructionName::Unknown => return None, + } +} + +fn resolve_dlmm_token_y_mint( + instruction_name: MeteoraDlmmInstructionName, + parsed_json: std::option::Option<&serde_json::Value>, + accounts: &[std::string::String], +) -> std::option::Option { + let parsed_value = extract_string_by_candidate_keys( + parsed_json, + &[ + "tokenYMint", + "token_y_mint", + "tokenMintY", + "token_mint_y", + "mintY", + "mint_y", + "tokenBMint", + "token_b_mint", + "quoteMint", + ], + ); + if parsed_value.is_some() { + return parsed_value; + } + match instruction_name { + MeteoraDlmmInstructionName::InitializeLbPair + | MeteoraDlmmInstructionName::InitializeLbPair2 + | MeteoraDlmmInstructionName::InitializeCustomizablePermissionlessLbPair + | MeteoraDlmmInstructionName::InitializeCustomizablePermissionlessLbPair2 => { + return extract_account(accounts, 3); + }, + MeteoraDlmmInstructionName::Swap + | MeteoraDlmmInstructionName::Swap2 + | MeteoraDlmmInstructionName::SwapExactOut + | MeteoraDlmmInstructionName::SwapExactOut2 + | MeteoraDlmmInstructionName::SwapWithPriceImpact => { + return extract_account(accounts, 7); + }, + MeteoraDlmmInstructionName::ClaimFee2 + | MeteoraDlmmInstructionName::InitializePosition + | MeteoraDlmmInstructionName::Unknown => return None, + } +} + +fn resolve_dlmm_reserve_x_account( + instruction_name: MeteoraDlmmInstructionName, + parsed_json: std::option::Option<&serde_json::Value>, + accounts: &[std::string::String], +) -> std::option::Option { + let parsed_value = extract_string_by_candidate_keys( + parsed_json, + &["reserveX", "reserve_x", "reserveXAccount", "reserve_x_account"], + ); + if parsed_value.is_some() { + return parsed_value; + } + match instruction_name { + MeteoraDlmmInstructionName::Swap + | MeteoraDlmmInstructionName::Swap2 + | MeteoraDlmmInstructionName::SwapExactOut + | MeteoraDlmmInstructionName::SwapExactOut2 + | MeteoraDlmmInstructionName::SwapWithPriceImpact => { + return extract_account(accounts, 2); + }, + _ => return None, + } +} + +fn resolve_dlmm_reserve_y_account( + instruction_name: MeteoraDlmmInstructionName, + parsed_json: std::option::Option<&serde_json::Value>, + accounts: &[std::string::String], +) -> std::option::Option { + let parsed_value = extract_string_by_candidate_keys( + parsed_json, + &["reserveY", "reserve_y", "reserveYAccount", "reserve_y_account"], + ); + if parsed_value.is_some() { + return parsed_value; + } + match instruction_name { + MeteoraDlmmInstructionName::Swap + | MeteoraDlmmInstructionName::Swap2 + | MeteoraDlmmInstructionName::SwapExactOut + | MeteoraDlmmInstructionName::SwapExactOut2 + | MeteoraDlmmInstructionName::SwapWithPriceImpact => { + return extract_account(accounts, 3); + }, + _ => return None, + } +} + +fn resolve_dlmm_user_token_in_account( + instruction_name: MeteoraDlmmInstructionName, + parsed_json: std::option::Option<&serde_json::Value>, + accounts: &[std::string::String], +) -> std::option::Option { + let parsed_value = extract_string_by_candidate_keys( + parsed_json, + &["userTokenIn", "user_token_in", "userTokenInAccount", "user_token_in_account"], + ); + if parsed_value.is_some() { + return parsed_value; + } + match instruction_name { + MeteoraDlmmInstructionName::Swap + | MeteoraDlmmInstructionName::Swap2 + | MeteoraDlmmInstructionName::SwapExactOut + | MeteoraDlmmInstructionName::SwapExactOut2 + | MeteoraDlmmInstructionName::SwapWithPriceImpact => { + return extract_account(accounts, 4); + }, + _ => return None, + } +} + +fn resolve_dlmm_user_token_out_account( + instruction_name: MeteoraDlmmInstructionName, + parsed_json: std::option::Option<&serde_json::Value>, + accounts: &[std::string::String], +) -> std::option::Option { + let parsed_value = extract_string_by_candidate_keys( + parsed_json, + &[ + "userTokenOut", + "user_token_out", + "userTokenOutAccount", + "user_token_out_account", + ], + ); + if parsed_value.is_some() { + return parsed_value; + } + match instruction_name { + MeteoraDlmmInstructionName::Swap + | MeteoraDlmmInstructionName::Swap2 + | MeteoraDlmmInstructionName::SwapExactOut + | MeteoraDlmmInstructionName::SwapWithPriceImpact + | MeteoraDlmmInstructionName::SwapExactOut2 => { + return extract_account(accounts, 5); + }, + _ => return None, + } +} + +fn resolve_dlmm_config_account( + instruction_name: MeteoraDlmmInstructionName, + parsed_json: std::option::Option<&serde_json::Value>, + accounts: &[std::string::String], +) -> std::option::Option { + let parsed_value = extract_string_by_candidate_keys( + parsed_json, + &[ + "presetParameter", + "preset_parameter", + "presetParameter2", + "config", + "poolConfig", + ], + ); + if parsed_value.is_some() { + return parsed_value; + } + match instruction_name { + MeteoraDlmmInstructionName::InitializeLbPair + | MeteoraDlmmInstructionName::InitializeLbPair2 + | MeteoraDlmmInstructionName::InitializeCustomizablePermissionlessLbPair => { + return extract_account(accounts, 7); + }, + MeteoraDlmmInstructionName::InitializeCustomizablePermissionlessLbPair2 => { + return None; + }, + _ => return None, + } +} + +fn contains_create_pool_hint(value: std::option::Option<&str>) -> bool { + let value = match value { + Some(value) => value.to_ascii_lowercase(), + None => return false, + }; + if value.contains("initializelbpair") { + return true; + } + if value.contains("initialize_lb_pair") { + return true; + } + if value.contains("initializecustomizablepermissionlesslbpair") { + return true; + } + if value.contains("initialize_customizable_permissionless_lb_pair") { + return true; + } + if value.contains("initializepermissionlbpair") { + return true; + } + if value.contains("initialize_permission_lb_pair") { + return true; + } + if value.contains("create_lb_pair") { + return true; + } + return false; +} + +fn contains_swap_hint(value: std::option::Option<&str>) -> bool { + let value = match value { + Some(value) => value.to_ascii_lowercase(), + None => return false, + }; + if value.contains("swap") { + return true; + } + return false; +} + +fn contains_create_pool_hint_in_value(value: &serde_json::Value) -> bool { + return contains_string_hint_in_value(value, contains_create_pool_hint); +} + +fn contains_swap_hint_in_value(value: &serde_json::Value) -> bool { + return contains_string_hint_in_value(value, contains_swap_hint); +} + +fn contains_string_hint_in_value( + value: &serde_json::Value, + predicate: fn(std::option::Option<&str>) -> bool, +) -> bool { + match value { + serde_json::Value::String(text) => return predicate(Some(text.as_str())), + serde_json::Value::Array(values) => { + for nested in values { + if contains_string_hint_in_value(nested, predicate) { + return true; + } + } + }, + serde_json::Value::Object(object) => { + for nested in object.values() { + if contains_string_hint_in_value(nested, predicate) { + return true; + } + } + }, + _ => {}, + } + return false; +} + +fn infer_trade_side(parsed_json: std::option::Option<&serde_json::Value>) -> crate::SwapTradeSide { + let side = extract_string_by_candidate_keys( + parsed_json, + &["tradeSide", "trade_side", "side", "swapSide", "swap_side"], + ); + match side.as_deref() { + Some("BuyBase") => return crate::SwapTradeSide::BuyBase, + Some("buy") => return crate::SwapTradeSide::BuyBase, + Some("BUY") => return crate::SwapTradeSide::BuyBase, + Some("SellBase") => return crate::SwapTradeSide::SellBase, + Some("sell") => return crate::SwapTradeSide::SellBase, + Some("SELL") => return crate::SwapTradeSide::SellBase, + _ => return crate::SwapTradeSide::Unknown, + } +} + +fn extract_log_messages( + transaction_json: &serde_json::Value, +) -> std::vec::Vec { + let mut messages = std::vec::Vec::new(); + let meta = match transaction_json.get("meta") { + Some(meta) => meta, + None => return messages, + }; + let logs = match meta.get("logMessages").and_then(|value| return value.as_array()) { + Some(logs) => logs, + None => return messages, + }; + for log in logs { + if let Some(text) = log.as_str() { + messages.push(text.to_string()); + } + } + return messages; +} + +fn parse_accounts_json( + accounts_json: &str, +) -> Result, crate::Error> { + let parsed_result = serde_json::from_str::(accounts_json); + let parsed = match parsed_result { + Ok(parsed) => parsed, + Err(error) => { + return Err(crate::Error::Json(format!( + "cannot parse Meteora DLMM accounts_json: {}", + error + ))); + }, + }; + let array = match parsed.as_array() { + Some(array) => array, + None => return Ok(std::vec::Vec::new()), + }; + let mut accounts = std::vec::Vec::new(); + for item in array { + if let Some(text) = item.as_str() { + accounts.push(text.to_string()); + continue; + } + if let Some(pubkey) = item.get("pubkey").and_then(|value| return value.as_str()) { + accounts.push(pubkey.to_string()); + } + } + return Ok(accounts); +} + +fn parse_optional_parsed_json( + parsed_json: std::option::Option<&std::string::String>, +) -> Result, crate::Error> { + let parsed_json = match parsed_json { + Some(parsed_json) => parsed_json, + None => return Ok(None), + }; + let parsed_result = serde_json::from_str::(parsed_json); + match parsed_result { + Ok(parsed) => return Ok(Some(parsed)), + Err(error) => { + return Err(crate::Error::Json(format!( + "cannot parse Meteora DLMM parsed_json: {}", + error + ))); + }, + } +} + +fn extract_account( + accounts: &[std::string::String], + index: usize, +) -> std::option::Option { + match accounts.get(index) { + Some(account) => return Some(account.clone()), + None => return None, + } +} + +fn extract_string_by_candidate_keys( + value: std::option::Option<&serde_json::Value>, + candidate_keys: &[&str], +) -> std::option::Option { + let value = match value { + Some(value) => value, + None => return None, + }; + if let Some(object) = value.as_object() { + for candidate_key in candidate_keys { + if let Some(found) = object.get(*candidate_key) { + if let Some(text) = found.as_str() { + return Some(text.to_string()); + } + if let Some(number) = found.as_u64() { + return Some(number.to_string()); + } + if let Some(number) = found.as_i64() { + return Some(number.to_string()); + } + } + } + for nested in object.values() { + let nested_result = extract_string_by_candidate_keys(Some(nested), candidate_keys); + if nested_result.is_some() { + return nested_result; + } + } + } + if let Some(array) = value.as_array() { + for nested in array { + let nested_result = extract_string_by_candidate_keys(Some(nested), candidate_keys); + if nested_result.is_some() { + return nested_result; + } + } + } + return None; +} + +fn decode_instruction_data_json( + data_json: std::option::Option<&std::string::String>, +) -> Result>, crate::Error> { + let data_json = match data_json { + Some(data_json) => data_json, + None => return Ok(None), + }; + let parsed_result = serde_json::from_str::(data_json.as_str()); + let parsed = match parsed_result { + Ok(parsed) => parsed, + Err(error) => { + return Err(crate::Error::Json(format!( + "cannot parse Meteora DLMM data_json: {}", + error + ))); + }, + }; + if let serde_json::Value::String(base58_text) = parsed { + let decoded_result = decode_base58(base58_text.as_str()); + let decoded = match decoded_result { + Ok(decoded) => decoded, + Err(error) => return Err(error), + }; + return Ok(Some(decoded)); + } + return Ok(None); +} + +fn decode_base58(input: &str) -> Result, crate::Error> { + let decoded_result = bs58::decode(input).into_vec(); + match decoded_result { + Ok(decoded) => return Ok(decoded), + Err(error) => { + return Err(crate::Error::Json(format!( + "cannot decode Meteora DLMM instruction data from base58: {}", + error + ))); + }, + } +} + +fn first_8_bytes_hex(bytes: &[u8]) -> std::option::Option { + if bytes.len() < 8 { + return None; + } + return Some(format!( + "{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], + )); +} + +#[cfg(test)] +mod tests { + fn make_create_transaction() -> crate::ChainTransactionDto { + let mut dto = crate::ChainTransactionDto::new( + "sig-meteora-dlmm-create-1".to_string(), + Some(888101), + Some(1779400001), + Some("helius_primary_http".to_string()), + Some("0".to_string()), + None, + None, + serde_json::json!({ + "slot": 888101, + "meta": { + "logMessages": [ + "Program log: Instruction: InitializeLbPair" + ] + }, + "transaction": { + "message": { + "instructions": [] + } + } + }) + .to_string(), + ); + dto.id = Some(401); + return dto; + } + + fn make_create_instruction() -> crate::ChainInstructionDto { + let mut dto = crate::ChainInstructionDto::new( + 401, + None, + 0, + None, + Some(crate::METEORA_DLMM_PROGRAM_ID.to_string()), + Some("meteora-dlmm".to_string()), + Some(1), + serde_json::json!([ + "DlmmPair111", + "DlmmTokenX111", + crate::WSOL_MINT_ID, + "DlmmPreset111" + ]) + .to_string(), + None, + Some("initialize_lb_pair".to_string()), + Some( + serde_json::json!({ + "info": { + "lbPair": "DlmmPair111", + "tokenXMint": "DlmmTokenX111", + "tokenYMint": crate::WSOL_MINT_ID, + "presetParameter": "DlmmPreset111" + } + }) + .to_string(), + ), + ); + dto.id = Some(402); + return dto; + } + + fn make_swap_transaction() -> crate::ChainTransactionDto { + let mut dto = crate::ChainTransactionDto::new( + "sig-meteora-dlmm-swap-1".to_string(), + Some(888102), + Some(1779400002), + Some("helius_primary_http".to_string()), + Some("0".to_string()), + None, + None, + serde_json::json!({ + "slot": 888102, + "meta": { + "logMessages": [ + "Program log: Instruction: Swap" + ] + }, + "transaction": { + "message": { + "instructions": [] + } + } + }) + .to_string(), + ); + dto.id = Some(403); + return dto; + } + + fn make_swap_instruction() -> crate::ChainInstructionDto { + let mut dto = crate::ChainInstructionDto::new( + 403, + None, + 0, + None, + Some(crate::METEORA_DLMM_PROGRAM_ID.to_string()), + Some("meteora-dlmm".to_string()), + Some(1), + serde_json::json!(["DlmmPairSwap111", "DlmmSwapTokenX111", crate::WSOL_MINT_ID]) + .to_string(), + None, + Some("swap".to_string()), + Some( + serde_json::json!({ + "info": { + "lbPair": "DlmmPairSwap111", + "tokenXMint": "DlmmSwapTokenX111", + "tokenYMint": crate::WSOL_MINT_ID, + "tradeSide": "buy" + } + }) + .to_string(), + ), + ); + dto.id = Some(404); + return dto; + } + + #[test] + fn meteora_dlmm_create_pool_is_detected() { + let decoder = crate::MeteoraDlmmDecoder::new(); + let transaction = make_create_transaction(); + let instructions = vec![make_create_instruction()]; + let decoded_result = decoder.decode_transaction(&transaction, &instructions); + let decoded = match decoded_result { + Ok(decoded) => decoded, + Err(error) => panic!("decode must succeed: {}", error), + }; + assert_eq!(decoded.len(), 1); + match &decoded[0] { + crate::MeteoraDlmmDecodedEvent::CreatePool(event) => { + assert_eq!(event.transaction_id, 401); + assert_eq!(event.instruction_id, 402); + assert_eq!(event.pool_account, Some("DlmmPair111".to_string())); + assert_eq!(event.token_a_mint, Some("DlmmTokenX111".to_string())); + assert_eq!(event.token_b_mint, Some(crate::WSOL_MINT_ID.to_string())); + assert_eq!(event.config_account, Some("DlmmPreset111".to_string())); + }, + crate::MeteoraDlmmDecodedEvent::Swap(_) => { + panic!("unexpected swap event"); + }, + } + } + + #[test] + fn meteora_dlmm_swap_is_detected() { + let decoder = crate::MeteoraDlmmDecoder::new(); + let transaction = make_swap_transaction(); + let instructions = vec![make_swap_instruction()]; + let decoded_result = decoder.decode_transaction(&transaction, &instructions); + let decoded = match decoded_result { + Ok(decoded) => decoded, + Err(error) => panic!("decode must succeed: {}", error), + }; + assert_eq!(decoded.len(), 1); + match &decoded[0] { + crate::MeteoraDlmmDecodedEvent::Swap(event) => { + assert_eq!(event.transaction_id, 403); + assert_eq!(event.instruction_id, 404); + assert_eq!(event.pool_account, Some("DlmmPairSwap111".to_string())); + assert_eq!(event.token_a_mint, Some("DlmmSwapTokenX111".to_string())); + assert_eq!(event.token_b_mint, Some(crate::WSOL_MINT_ID.to_string())); + assert_eq!(event.trade_side, crate::SwapTradeSide::BuyBase); + }, + crate::MeteoraDlmmDecodedEvent::CreatePool(_) => { + panic!("unexpected create event"); + }, + } + } + + #[test] + fn meteora_dlmm_ignores_unclear_instruction() { + let decoder = crate::MeteoraDlmmDecoder::new(); + let transaction = make_swap_transaction(); + let mut instruction = make_swap_instruction(); + instruction.parsed_type = Some("initialize_bin_array".to_string()); + instruction.parsed_json = None; + let decoded_result = decoder.decode_transaction(&transaction, &[instruction]); + let decoded = match decoded_result { + Ok(decoded) => decoded, + Err(error) => panic!("decode must succeed: {}", error), + }; + assert_eq!(decoded.len(), 0); + } + + #[test] + fn meteora_dlmm_swap2_discriminator_is_detected() { + let instruction_data = [0x41, 0x4b, 0x3f, 0x4c, 0xeb, 0x5b, 0x5b, 0x88, 0x01, 0x02, 0x03]; + let name = super::classify_instruction_name_from_data(Some(&instruction_data)); + assert_eq!(name, super::MeteoraDlmmInstructionName::Swap2); + assert_eq!(name.kind(), super::MeteoraDlmmInstructionKind::Swap); + } + + #[test] + fn meteora_dlmm_swap_accounts_are_mapped_from_carbon_layout() { + let accounts = vec![ + "LbPair111".to_string(), + "Bitmap111".to_string(), + "ReserveX111".to_string(), + "ReserveY111".to_string(), + "UserTokenIn111".to_string(), + "UserTokenOut111".to_string(), + "TokenXMint111".to_string(), + "TokenYMint111".to_string(), + ]; + let pool = super::resolve_dlmm_pool_account( + super::MeteoraDlmmInstructionName::Swap2, + None, + &accounts, + ); + let token_x = super::resolve_dlmm_token_x_mint( + super::MeteoraDlmmInstructionName::Swap2, + None, + &accounts, + ); + let token_y = super::resolve_dlmm_token_y_mint( + super::MeteoraDlmmInstructionName::Swap2, + None, + &accounts, + ); + assert_eq!(pool, Some("LbPair111".to_string())); + assert_eq!(token_x, Some("TokenXMint111".to_string())); + assert_eq!(token_y, Some("TokenYMint111".to_string())); + } + + #[test] + fn meteora_dlmm_real_swap2_base58_discriminator_is_decoded() { + let data_json = "\"fx9RHbGFfZ8bH9v4Jv5SQRfuGUq9kGrWfmiZ99\"".to_string(); + let decoded_result = super::decode_instruction_data_json(Some(&data_json)); + let decoded = match decoded_result { + Ok(Some(decoded)) => decoded, + Ok(None) => panic!("expected decoded data"), + Err(error) => panic!("decode must succeed: {}", error), + }; + let name = super::classify_instruction_name_from_data(Some(decoded.as_slice())); + assert_eq!(name, super::MeteoraDlmmInstructionName::Swap2); + } + + #[test] + fn meteora_dlmm_inner_swap2_instruction_is_not_skipped() { + let decoder = crate::MeteoraDlmmDecoder::new(); + let mut transaction = crate::ChainTransactionDto::new( + "sig-meteora-dlmm-inner-swap2".to_string(), + Some(888103), + Some(1779400003), + Some("helius_primary_http".to_string()), + Some("0".to_string()), + None, + None, + serde_json::json!({ + "slot": 888103, + "meta": { + "logMessages": [] + }, + "transaction": { + "message": { + "instructions": [] + } + } + }) + .to_string(), + ); + transaction.id = Some(405); + let mut instruction = crate::ChainInstructionDto::new( + 405, + Some(404), + 3, + Some(14), + Some(crate::METEORA_DLMM_PROGRAM_ID.to_string()), + Some("meteora-dlmm".to_string()), + Some(2), + serde_json::json!([ + "LbPair111", + "Bitmap111", + "ReserveX111", + "ReserveY111", + "UserTokenIn111", + "UserTokenOut111", + "TokenXMint111", + "TokenYMint111", + "Oracle111", + "HostFee111", + "User111", + crate::SPL_TOKEN_PROGRAM_ID, + crate::SPL_TOKEN_PROGRAM_ID, + "EventAuthority111".to_string(), + crate::METEORA_DLMM_PROGRAM_ID, + "BinArray111", + "BinArray222" + ]) + .to_string(), + Some("\"fx9RHbGFfZ8bH9v4Jv5SQRfuGUq9kGrWfmiZ99\"".to_string()), + None, + None, + ); + instruction.id = Some(406); + let decoded_result = decoder.decode_transaction(&transaction, &[instruction]); + let decoded = match decoded_result { + Ok(decoded) => decoded, + Err(error) => panic!("decode must succeed: {}", error), + }; + assert_eq!(decoded.len(), 1); + match &decoded[0] { + crate::MeteoraDlmmDecodedEvent::Swap(event) => { + assert_eq!(event.transaction_id, 405); + assert_eq!(event.instruction_id, 406); + assert_eq!(event.pool_account, Some("LbPair111".to_string())); + assert_eq!(event.token_a_mint, Some("TokenXMint111".to_string())); + assert_eq!(event.token_b_mint, Some("TokenYMint111".to_string())); + assert_eq!(event.reserve_x_account, Some("ReserveX111".to_string())); + assert_eq!(event.reserve_y_account, Some("ReserveY111".to_string())); + assert_eq!(event.user_token_in_account, Some("UserTokenIn111".to_string())); + assert_eq!(event.user_token_out_account, Some("UserTokenOut111".to_string())); + }, + crate::MeteoraDlmmDecodedEvent::CreatePool(_) => { + panic!("unexpected create event"); + }, + } + } + + #[test] + fn meteora_dlmm_initialize_position_discriminator_is_ignored() { + let instruction_data = [0xdb, 0xc0, 0xea, 0x47, 0xbe, 0xbf, 0x66, 0x50, 0x01, 0x02, 0x03]; + let log_messages = vec!["Program log: Instruction: Swap".to_string()]; + let name = + super::classify_instruction_name(None, None, Some(&instruction_data), &log_messages); + assert_eq!(name, super::MeteoraDlmmInstructionName::InitializePosition); + assert_eq!(name.kind(), super::MeteoraDlmmInstructionKind::Ignore); + } + + #[test] + fn meteora_dlmm_unknown_data_discriminator_does_not_fallback_to_global_swap_logs() { + let instruction_data = [0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, 0x11, 0x01, 0x02, 0x03]; + let log_messages = vec!["Program log: Instruction: Swap".to_string()]; + let name = + super::classify_instruction_name(None, None, Some(&instruction_data), &log_messages); + assert_eq!(name, super::MeteoraDlmmInstructionName::Unknown); + } +} diff --git a/kb_lib/src/dex/raydium_clmm.rs b/kb_lib/src/dex/raydium_clmm.rs index 1f55326..2e39f36 100644 --- a/kb_lib/src/dex/raydium_clmm.rs +++ b/kb_lib/src/dex/raydium_clmm.rs @@ -301,52 +301,10 @@ fn read_bool(data: &[u8], offset: usize) -> std::option::Option { } fn decode_base58(input: &str) -> std::option::Option> { - let alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".as_bytes(); - let mut bytes: std::vec::Vec = std::vec::Vec::new(); - for input_byte in input.bytes() { - let mut value_option = None; - let mut alphabet_index = 0_usize; - while alphabet_index < alphabet.len() { - if alphabet[alphabet_index] == input_byte { - value_option = Some(alphabet_index as u32); - break; - } - alphabet_index += 1; - } - let mut carry = match value_option { - Some(value) => value, - None => return None, - }; - let mut byte_index = bytes.len(); - while byte_index > 0 { - byte_index -= 1; - let value = (bytes[byte_index] as u32) * 58 + carry; - bytes[byte_index] = (value & 0xff) as u8; - carry = value >> 8; - } - while carry > 0 { - bytes.insert(0, (carry & 0xff) as u8); - carry >>= 8; - } + match bs58::decode(input).into_vec() { + Ok(decoded) => return Some(decoded), + Err(_) => return None, } - let mut leading_zero_count = 0_usize; - for input_byte in input.bytes() { - if input_byte == b'1' { - leading_zero_count += 1; - } else { - break; - } - } - let mut result = std::vec::Vec::new(); - let mut index = 0_usize; - while index < leading_zero_count { - result.push(0_u8); - index += 1; - } - for byte in bytes { - result.push(byte); - } - return Some(result); } #[cfg(test)] diff --git a/kb_lib/src/dex_catalog.rs b/kb_lib/src/dex_catalog.rs index ee204e7..b079ab5 100644 --- a/kb_lib/src/dex_catalog.rs +++ b/kb_lib/src/dex_catalog.rs @@ -81,6 +81,15 @@ pub(crate) fn dex_catalog_item_by_code( is_enabled: true, }); }, + "meteora_dlmm" => { + return Some(crate::dex_catalog::DexCatalogItem { + code: "meteora_dlmm", + name: "Meteora DLMM", + program_id: Some(crate::METEORA_DLMM_PROGRAM_ID), + router_program_id: None, + is_enabled: true, + }); + }, "meteora_damm_v1" => { return Some(crate::dex_catalog::DexCatalogItem { code: "meteora_damm_v1", @@ -250,6 +259,7 @@ mod tests { "pump_fun", "pump_swap", "meteora_dbc", + "meteora_dlmm", "meteora_damm_v1", "meteora_damm_v2", "orca_whirlpools", diff --git a/kb_lib/src/dex_decode.rs b/kb_lib/src/dex_decode.rs index 5bbd0f8..f70ff56 100644 --- a/kb_lib/src/dex_decode.rs +++ b/kb_lib/src/dex_decode.rs @@ -12,6 +12,7 @@ pub struct DexDecodeService { pump_swap_decoder: crate::PumpSwapDecoder, orca_whirlpools_decoder: crate::OrcaWhirlpoolsDecoder, meteora_dbc_decoder: crate::MeteoraDbcDecoder, + meteora_dlmm_decoder: crate::MeteoraDlmmDecoder, meteora_damm_v1_decoder: crate::MeteoraDammV1Decoder, meteora_damm_v2_decoder: crate::MeteoraDammV2Decoder, fluxbeam_decoder: crate::FluxbeamDecoder, @@ -30,6 +31,7 @@ impl DexDecodeService { pump_swap_decoder: crate::PumpSwapDecoder::new(), orca_whirlpools_decoder: crate::OrcaWhirlpoolsDecoder::new(), meteora_dbc_decoder: crate::MeteoraDbcDecoder::new(), + meteora_dlmm_decoder: crate::MeteoraDlmmDecoder::new(), meteora_damm_v1_decoder: crate::MeteoraDammV1Decoder::new(), meteora_damm_v2_decoder: crate::MeteoraDammV2Decoder::new(), fluxbeam_decoder: crate::FluxbeamDecoder::new(), @@ -98,8 +100,7 @@ impl DexDecodeService { } let append_result = append_persisted_events_result( &mut persisted, - self.decode_and_persist_meteora_damm_v2_events(&transaction, &instructions) - .await, + self.decode_and_persist_meteora_dlmm_events(&transaction, &instructions).await, ); if let Err(error) = append_result { return Err(error); @@ -112,6 +113,14 @@ impl DexDecodeService { if let Err(error) = append_result { return Err(error); } + let append_result = append_persisted_events_result( + &mut persisted, + self.decode_and_persist_meteora_damm_v2_events(&transaction, &instructions) + .await, + ); + if let Err(error) = append_result { + return Err(error); + } let append_result = append_persisted_events_result( &mut persisted, self.decode_and_persist_orca_whirlpools_events(&transaction, &instructions) @@ -311,6 +320,96 @@ impl DexDecodeService { } } + async fn persist_meteora_dbc_event( + &self, + transaction: &crate::ChainTransactionDto, + decoded_event: &crate::MeteoraDbcDecodedEvent, + ) -> Result { + match decoded_event { + crate::MeteoraDbcDecodedEvent::CreatePool(event) => { + return self + .materialize_named_dex_event( + transaction, + event.transaction_id, + event.instruction_id, + "meteora_dbc", + event.program_id.clone(), + "meteora_dbc.create_pool", + event.pool_account.clone(), + None, + event.token_a_mint.clone(), + event.token_b_mint.clone(), + event.config_account.clone(), + event.payload_json.clone(), + ) + .await; + }, + crate::MeteoraDbcDecodedEvent::Swap(event) => { + return self + .materialize_named_dex_event( + transaction, + event.transaction_id, + event.instruction_id, + "meteora_dbc", + event.program_id.clone(), + "meteora_dbc.swap", + event.pool_account.clone(), + None, + event.token_a_mint.clone(), + event.token_b_mint.clone(), + None, + event.payload_json.clone(), + ) + .await; + }, + } + } + + async fn persist_meteora_dlmm_event( + &self, + transaction: &crate::ChainTransactionDto, + decoded_event: &crate::MeteoraDlmmDecodedEvent, + ) -> Result { + match decoded_event { + crate::MeteoraDlmmDecodedEvent::CreatePool(event) => { + return self + .materialize_named_dex_event( + transaction, + event.transaction_id, + event.instruction_id, + "meteora_dlmm", + event.program_id.clone(), + "meteora_dlmm.create_pool", + event.pool_account.clone(), + None, + event.token_a_mint.clone(), + event.token_b_mint.clone(), + event.config_account.clone(), + event.payload_json.clone(), + ) + .await; + }, + crate::MeteoraDlmmDecodedEvent::Swap(event) => { + return self + .materialize_named_dex_event( + transaction, + event.transaction_id, + event.instruction_id, + "meteora_dlmm", + event.program_id.clone(), + "meteora_dlmm.swap", + event.pool_account.clone(), + None, + event.token_a_mint.clone(), + event.token_b_mint.clone(), + None, + event.payload_json.clone(), + ) + .await; + }, + } + } + async fn persist_meteora_damm_v1_event( &self, transaction: &crate::ChainTransactionDto, @@ -336,6 +435,8 @@ impl DexDecodeService { .await; }, crate::MeteoraDammV1DecodedEvent::Swap(event) => { + let enrichment_payload_json = + prepare_meteora_damm_v1_swap_payload_for_classification(event); return self .materialize_named_dex_event( transaction, @@ -349,7 +450,7 @@ impl DexDecodeService { event.token_a_mint.clone(), event.token_b_mint.clone(), None, - event.payload_json.clone(), + enrichment_payload_json, ) .await; }, @@ -401,51 +502,6 @@ impl DexDecodeService { } } - async fn persist_meteora_dbc_event( - &self, - transaction: &crate::ChainTransactionDto, - decoded_event: &crate::MeteoraDbcDecodedEvent, - ) -> Result { - match decoded_event { - crate::MeteoraDbcDecodedEvent::CreatePool(event) => { - return self - .materialize_named_dex_event( - transaction, - event.transaction_id, - event.instruction_id, - "meteora_dbc", - event.program_id.clone(), - "meteora_dbc.create_pool", - event.pool_account.clone(), - None, - event.token_a_mint.clone(), - event.token_b_mint.clone(), - event.config_account.clone(), - event.payload_json.clone(), - ) - .await; - }, - crate::MeteoraDbcDecodedEvent::Swap(event) => { - return self - .materialize_named_dex_event( - transaction, - event.transaction_id, - event.instruction_id, - "meteora_dbc", - event.program_id.clone(), - "meteora_dbc.swap", - event.pool_account.clone(), - None, - event.token_a_mint.clone(), - event.token_b_mint.clone(), - None, - event.payload_json.clone(), - ) - .await; - }, - } - } - async fn persist_raydium_amm_v4_event( &self, transaction: &crate::ChainTransactionDto, @@ -900,6 +956,29 @@ impl DexDecodeService { return Ok(persisted); } + async fn decode_and_persist_meteora_dlmm_events( + &self, + transaction: &crate::ChainTransactionDto, + instructions: &[crate::ChainInstructionDto], + ) -> Result, crate::Error> { + let decoded_result = + self.meteora_dlmm_decoder.decode_transaction(transaction, instructions); + let decoded_events = match decoded_result { + Ok(decoded_events) => decoded_events, + Err(error) => return Err(error), + }; + let mut persisted = std::vec::Vec::new(); + for decoded_event in &decoded_events { + let persist_result = self.persist_meteora_dlmm_event(transaction, decoded_event).await; + let persisted_event = match persist_result { + Ok(persisted_event) => persisted_event, + Err(error) => return Err(error), + }; + persisted.push(persisted_event); + } + return Ok(persisted); + } + async fn decode_and_persist_meteora_damm_v1_events( &self, transaction: &crate::ChainTransactionDto, @@ -1056,6 +1135,38 @@ fn enriched_raydium_payload_value( return Ok(crate::enrich_dex_decoded_payload(protocol_name, event_kind, payload_value)); } +// Marks Meteora DAMM v1 swaps without direct amounts as non-materializable candidates +// before generic classification metadata is inserted. +fn prepare_meteora_damm_v1_swap_payload_for_classification( + event: &crate::MeteoraDammV1SwapDecoded, +) -> serde_json::Value { + let mut object = match event.payload_json.clone() { + serde_json::Value::Object(object) => object, + other => { + let mut object = serde_json::Map::new(); + object.insert("rawPayload".to_string(), other); + object + }, + }; + let payload_json = serde_json::Value::Object(object.clone()); + if crate::dex_event_classification::decoded_payload_has_trade_amount_or_price_payload( + &payload_json, + ) { + return serde_json::Value::Object(object); + } + object.insert("tradeCandidate".to_string(), serde_json::Value::Bool(false)); + object.insert("candleCandidate".to_string(), serde_json::Value::Bool(false)); + object.insert( + "skipTradeReason".to_string(), + serde_json::Value::String("meteora_damm_v1_swap_without_amount_payload".to_string()), + ); + object.insert( + "skipCandleReason".to_string(), + serde_json::Value::String("meteora_damm_v1_swap_without_amount_payload".to_string()), + ); + return serde_json::Value::Object(object); +} + // Marks incomplete PumpSwap decoded trades as non-materializable candidates before generic // classification metadata is inserted. fn prepare_pump_swap_trade_payload_for_classification( @@ -1313,6 +1424,73 @@ mod tests { assert_eq!(decoded[0].token_a_mint, Some("MintPF111".to_string())); } + #[test] + fn prepare_meteora_damm_v1_swap_without_amounts_marks_event_non_actionable() { + let event = crate::MeteoraDammV1SwapDecoded { + transaction_id: 1, + instruction_id: 2, + signature: "sig-damm-v1-no-amounts".to_string(), + program_id: crate::METEORA_DAMM_V1_PROGRAM_ID.to_string(), + trade_side: crate::SwapTradeSide::Unknown, + pool_account: Some("PoolDammV1".to_string()), + token_a_mint: Some("TokenA".to_string()), + token_b_mint: Some("TokenB".to_string()), + payload_json: serde_json::json!({ + "decoder": "meteora_damm_v1", + "eventKind": "swap" + }), + }; + let prepared_payload = + super::prepare_meteora_damm_v1_swap_payload_for_classification(&event); + let object_option = prepared_payload.as_object(); + let object = match object_option { + Some(object) => object, + None => { + panic!("expected prepared payload object"); + }, + }; + assert_eq!(object.get("tradeCandidate"), Some(&serde_json::Value::Bool(false))); + assert_eq!(object.get("candleCandidate"), Some(&serde_json::Value::Bool(false))); + assert_eq!( + object.get("skipTradeReason"), + Some(&serde_json::Value::String( + "meteora_damm_v1_swap_without_amount_payload".to_string() + )) + ); + } + + #[test] + fn prepare_meteora_damm_v1_swap_with_amounts_keeps_event_actionable() { + let event = crate::MeteoraDammV1SwapDecoded { + transaction_id: 1, + instruction_id: 2, + signature: "sig-damm-v1-with-amounts".to_string(), + program_id: crate::METEORA_DAMM_V1_PROGRAM_ID.to_string(), + trade_side: crate::SwapTradeSide::Unknown, + pool_account: Some("PoolDammV1".to_string()), + token_a_mint: Some("TokenA".to_string()), + token_b_mint: Some("TokenB".to_string()), + payload_json: serde_json::json!({ + "decoder": "meteora_damm_v1", + "eventKind": "swap", + "baseAmountRaw": "100", + "quoteAmountRaw": "250" + }), + }; + let prepared_payload = + super::prepare_meteora_damm_v1_swap_payload_for_classification(&event); + let object_option = prepared_payload.as_object(); + let object = match object_option { + Some(object) => object, + None => { + panic!("expected prepared payload object"); + }, + }; + assert_eq!(object.get("tradeCandidate"), None); + assert_eq!(object.get("candleCandidate"), None); + assert_eq!(object.get("skipTradeReason"), None); + } + #[tokio::test] async fn decode_transaction_by_signature_persists_decoded_pump_swap_event() { let database = make_database().await; diff --git a/kb_lib/src/dex_decoded_event_materialization.rs b/kb_lib/src/dex_decoded_event_materialization.rs index 6401a69..726f419 100644 --- a/kb_lib/src/dex_decoded_event_materialization.rs +++ b/kb_lib/src/dex_decoded_event_materialization.rs @@ -50,10 +50,15 @@ pub(crate) struct DexDecodedEventMaterializationInput<'a> { pub(crate) async fn materialize_dex_decoded_event( input: crate::dex_decoded_event_materialization::DexDecodedEventMaterializationInput<'_>, ) -> Result { + let enrichment_payload_json = + crate::dex_decoded_event_materialization::prepare_payload_for_transaction_status( + input.transaction, + input.enrichment_payload_json, + ); let payload_json_result = crate::enrich_and_serialize_dex_decoded_payload( input.protocol_name.as_str(), input.event_kind.as_str(), - input.enrichment_payload_json, + enrichment_payload_json, ); let payload_json = match payload_json_result { Ok(payload_json) => payload_json, @@ -138,3 +143,32 @@ pub(crate) async fn materialize_dex_decoded_event( } return Ok(fetched); } + +fn prepare_payload_for_transaction_status( + transaction: &crate::ChainTransactionDto, + payload_json: serde_json::Value, +) -> serde_json::Value { + if transaction.err_json.is_none() { + return payload_json; + } + let mut object = match payload_json { + serde_json::Value::Object(object) => object, + other => { + let mut object = serde_json::Map::new(); + object.insert("rawPayload".to_string(), other); + object + }, + }; + object.insert("transactionFailed".to_string(), serde_json::Value::Bool(true)); + object.insert("tradeCandidate".to_string(), serde_json::Value::Bool(false)); + object.insert("candleCandidate".to_string(), serde_json::Value::Bool(false)); + object.insert( + "skipTradeReason".to_string(), + serde_json::Value::String("failed_transaction".to_string()), + ); + object.insert( + "skipCandleReason".to_string(), + serde_json::Value::String("failed_transaction".to_string()), + ); + return serde_json::Value::Object(object); +} diff --git a/kb_lib/src/dex_detect.rs b/kb_lib/src/dex_detect.rs index ceef9db..22c6944 100644 --- a/kb_lib/src/dex_detect.rs +++ b/kb_lib/src/dex_detect.rs @@ -115,12 +115,15 @@ impl DexDetectService { crate::dex_detection_route::DexDetectionRoute::MeteoraDbcPool => { self.detect_meteora_dbc_pool(&transaction, decoded_event).await }, - crate::dex_detection_route::DexDetectionRoute::MeteoraDammV2Pool => { - self.detect_meteora_damm_v2_pool(&transaction, decoded_event).await + crate::dex_detection_route::DexDetectionRoute::MeteoraDlmmPool => { + self.detect_meteora_dlmm_pool(&transaction, decoded_event).await }, crate::dex_detection_route::DexDetectionRoute::MeteoraDammV1Pool => { self.detect_meteora_damm_v1_pool(&transaction, decoded_event).await }, + crate::dex_detection_route::DexDetectionRoute::MeteoraDammV2Pool => { + self.detect_meteora_damm_v2_pool(&transaction, decoded_event).await + }, crate::dex_detection_route::DexDetectionRoute::OrcaWhirlpoolsPool => { self.detect_orca_whirlpools_pool(&transaction, decoded_event).await }, @@ -380,21 +383,58 @@ impl DexDetectService { .await; } - async fn detect_meteora_damm_v2_pool( + async fn detect_meteora_dlmm_pool( &self, transaction: &crate::ChainTransactionDto, decoded_event: &crate::DexDecodedEventDto, ) -> Result { - return self - .detect_materialized_pool_from_decoded_event( - transaction, + let dex_id_result = + crate::dex_catalog::ensure_known_dex(self.database.as_ref(), "meteora_dlmm").await; + let dex_id = match dex_id_result { + Ok(dex_id) => dex_id, + Err(error) => return Err(error), + }; + let payload_value_result = parse_payload_json(decoded_event.payload_json.as_str()); + let payload_value = match payload_value_result { + Ok(payload_value) => payload_value, + Err(error) => return Err(error), + }; + let reserve_x_account = extract_payload_string_field(&payload_value, "reserveXAccount"); + let reserve_y_account = extract_payload_string_field(&payload_value, "reserveYAccount"); + let input_result = + crate::dex_pool_materialization::DexPoolMaterializationInput::from_decoded_event( decoded_event, - "meteora_damm_v2", - crate::PoolKind::Amm, + dex_id, + crate::PoolKind::Clmm, crate::PoolStatus::Active, - "signal.dex.meteora_damm_v2", + crate::dex_pool_materialization::DexPoolTokenOrder::ChooseBaseQuoteFromTokenAB, + reserve_x_account, + reserve_y_account, + transaction.source_endpoint_name.clone(), + ); + let input = match input_result { + Ok(input) => input, + Err(error) => return Err(error), + }; + let detection_result = + crate::dex_pool_materialization::materialize_dex_pool(self.database.as_ref(), &input) + .await; + let detection_result = match detection_result { + Ok(detection_result) => detection_result, + Err(error) => return Err(error), + }; + let signal_result = self + .record_pool_detection_signals( + transaction, + "signal.dex.meteora_dlmm", + &detection_result, + payload_value, ) .await; + if let Err(error) = signal_result { + return Err(error); + } + return Ok(detection_result); } async fn detect_meteora_damm_v1_pool( @@ -414,6 +454,23 @@ impl DexDetectService { .await; } + async fn detect_meteora_damm_v2_pool( + &self, + transaction: &crate::ChainTransactionDto, + decoded_event: &crate::DexDecodedEventDto, + ) -> Result { + return self + .detect_materialized_pool_from_decoded_event( + transaction, + decoded_event, + "meteora_damm_v2", + crate::PoolKind::Amm, + crate::PoolStatus::Active, + "signal.dex.meteora_damm_v2", + ) + .await; + } + async fn detect_orca_whirlpools_pool( &self, transaction: &crate::ChainTransactionDto, diff --git a/kb_lib/src/dex_detection_route.rs b/kb_lib/src/dex_detection_route.rs index 42f7ec3..cc1c60f 100644 --- a/kb_lib/src/dex_detection_route.rs +++ b/kb_lib/src/dex_detection_route.rs @@ -21,10 +21,12 @@ pub(crate) enum DexDetectionRoute { SkipIncompletePumpSwapTrade, /// Meteora DBC pool route. MeteoraDbcPool, - /// Meteora DAMM v2 pool route. - MeteoraDammV2Pool, + /// Meteora DLMM pool route. + MeteoraDlmmPool, /// Meteora DAMM v1 pool route. MeteoraDammV1Pool, + /// Meteora DAMM v2 pool route. + MeteoraDammV2Pool, /// Orca Whirlpools pool route. OrcaWhirlpoolsPool, /// FluxBeam pool route. @@ -83,11 +85,11 @@ pub(crate) fn dex_detection_route( ("meteora_dbc", "meteora_dbc.swap") => { return Some(crate::dex_detection_route::DexDetectionRoute::MeteoraDbcPool); }, - ("meteora_damm_v2", "meteora_damm_v2.create_pool") => { - return Some(crate::dex_detection_route::DexDetectionRoute::MeteoraDammV2Pool); + ("meteora_dlmm", "meteora_dlmm.create_pool") => { + return Some(crate::dex_detection_route::DexDetectionRoute::MeteoraDlmmPool); }, - ("meteora_damm_v2", "meteora_damm_v2.swap") => { - return Some(crate::dex_detection_route::DexDetectionRoute::MeteoraDammV2Pool); + ("meteora_dlmm", "meteora_dlmm.swap") => { + return Some(crate::dex_detection_route::DexDetectionRoute::MeteoraDlmmPool); }, ("meteora_damm_v1", "meteora_damm_v1.create_pool") => { return Some(crate::dex_detection_route::DexDetectionRoute::MeteoraDammV1Pool); @@ -95,6 +97,12 @@ pub(crate) fn dex_detection_route( ("meteora_damm_v1", "meteora_damm_v1.swap") => { return Some(crate::dex_detection_route::DexDetectionRoute::MeteoraDammV1Pool); }, + ("meteora_damm_v2", "meteora_damm_v2.create_pool") => { + return Some(crate::dex_detection_route::DexDetectionRoute::MeteoraDammV2Pool); + }, + ("meteora_damm_v2", "meteora_damm_v2.swap") => { + return Some(crate::dex_detection_route::DexDetectionRoute::MeteoraDammV2Pool); + }, ("orca_whirlpools", "orca_whirlpools.create_pool") => { return Some(crate::dex_detection_route::DexDetectionRoute::OrcaWhirlpoolsPool); }, diff --git a/kb_lib/src/dex_event_classification.rs b/kb_lib/src/dex_event_classification.rs index 2da9dca..6614915 100644 --- a/kb_lib/src/dex_event_classification.rs +++ b/kb_lib/src/dex_event_classification.rs @@ -195,6 +195,37 @@ pub fn is_dex_admin_event_kind(event_kind: &str) -> bool { return false; } +/// Returns true when a decoded payload contains at least one direct amount or price field. +/// +/// This is a conservative payload-level check. It does not inspect transaction +/// token balance deltas and is intended for protocol decoders that cannot yet +/// produce a deterministic materializable swap payload. +pub(crate) fn decoded_payload_has_trade_amount_or_price_payload( + payload: &serde_json::Value, +) -> bool { + return value_contains_any_non_null_key( + payload, + &[ + "baseAmountRaw", + "base_amount_raw", + "baseAmount", + "amountBase", + "amountInBase", + "quoteAmountRaw", + "quote_amount_raw", + "quoteAmount", + "amountQuote", + "amountOutQuote", + "amountIn", + "amountOut", + "priceQuotePerBase", + "price_quote_per_base", + "quotePerBase", + "lastPriceQuotePerBase", + ], + ); +} + /// Returns true when a decoded payload is marked as a trade candidate. /// /// Explicit payload metadata wins over event-kind inference. This allows @@ -332,6 +363,38 @@ fn json_insert_i64_if_missing( object.insert(key.to_owned(), serde_json::Value::Number(serde_json::Number::from(value))); } +fn value_contains_any_non_null_key(value: &serde_json::Value, candidate_keys: &[&str]) -> bool { + if let Some(object) = value.as_object() { + for candidate_key in candidate_keys { + let candidate_value_option = object.get(*candidate_key); + if let Some(candidate_value) = candidate_value_option { + if !candidate_value.is_null() { + if let Some(text) = candidate_value.as_str() { + if text.trim().is_empty() { + continue; + } + } + return true; + } + } + } + for nested_value in object.values() { + if value_contains_any_non_null_key(nested_value, candidate_keys) { + return true; + } + } + return false; + } + if let Some(array) = value.as_array() { + for nested_value in array { + if value_contains_any_non_null_key(nested_value, candidate_keys) { + return true; + } + } + } + return false; +} + fn extract_top_level_bool_by_candidate_keys( payload: &serde_json::Value, candidate_keys: &[&str], @@ -506,4 +569,20 @@ mod tests { assert!(!super::is_decoded_event_trade_candidate("pump_swap.buy", &payload_json)); assert!(!super::is_decoded_event_candle_candidate("pump_swap.buy", &payload_json)); } + + #[test] + fn detects_direct_amount_or_price_payload_recursively() { + let payload_json = serde_json::json!({ + "nested": { + "quoteAmountRaw": "2500" + } + }); + assert!(super::decoded_payload_has_trade_amount_or_price_payload(&payload_json)); + let empty_payload_json = serde_json::json!({ + "nested": { + "quoteAmountRaw": "" + } + }); + assert!(!super::decoded_payload_has_trade_amount_or_price_payload(&empty_payload_json)); + } } diff --git a/kb_lib/src/lib.rs b/kb_lib/src/lib.rs index ebfb38a..188d08b 100644 --- a/kb_lib/src/lib.rs +++ b/kb_lib/src/lib.rs @@ -411,6 +411,14 @@ pub use db::PoolTokenDto; pub use db::PoolTokenEntity; /// Role of one token inside a normalized pool. pub use db::PoolTokenRole; +/// Diagnostic row for instructions of one Solana program. +pub use db::ProgramInstructionDiagnosticDto; +/// Raw diagnostic row for instructions of one Solana program. +pub use db::ProgramInstructionDiagnosticEntity; +/// Raw row used to summarize instruction discriminators for one Solana program. +pub use db::ProgramInstructionDiscriminatorRowEntity; +/// Aggregated instruction discriminator diagnostic row. +pub use db::ProgramInstructionDiscriminatorSummaryDto; /// Application-facing protocol candidate DTO. /// /// A protocol candidate records a program/instruction that should be inspected @@ -625,6 +633,10 @@ pub use db::query_pools_get_by_address; pub use db::query_pools_list; /// Inserts or updates one normalized pool row by address. pub use db::query_pools_upsert; +/// Lists diagnostic instruction rows for one program id. +pub use db::query_program_instruction_diagnostics_list_by_program_id; +/// Lists instruction discriminator summaries for one program id. +pub use db::query_program_instruction_discriminator_summaries_list_by_program_id; /// Lists protocol candidate summaries ordered by investigation priority. pub use db::query_protocol_candidate_summaries_list_by_priority; /// Deletes protocol candidates for one transaction. @@ -761,6 +773,14 @@ pub use dex::MeteoraDbcDecodedEvent; pub use dex::MeteoraDbcDecoder; /// Decoded Meteora DBC swap event. pub use dex::MeteoraDbcSwapDecoded; +/// Decoded Meteora DLMM create-pool event. +pub use dex::MeteoraDlmmCreatePoolDecoded; +/// Decoded Meteora DLMM event. +pub use dex::MeteoraDlmmDecodedEvent; +/// Meteora DLMM decoder. +pub use dex::MeteoraDlmmDecoder; +/// Decoded Meteora DLMM swap event. +pub use dex::MeteoraDlmmSwapDecoded; /// Decoded Orca Whirlpools create-pool event. pub use dex::OrcaWhirlpoolsCreatePoolDecoded; /// Decoded Orca Whirlpools event. diff --git a/kb_lib/src/local_pipeline_validation.rs b/kb_lib/src/local_pipeline_validation.rs index 227a8d7..cabb655 100644 --- a/kb_lib/src/local_pipeline_validation.rs +++ b/kb_lib/src/local_pipeline_validation.rs @@ -84,6 +84,37 @@ impl LocalPipelineValidationConfig { require_candles_per_dex: true, }; } + + /// Builds the `0.7.28` multi-DEX validation config. + /// + /// This profile treats currently materialized DEX decoders as required: + /// PumpSwap, Raydium CPMM, Raydium CLMM, and Meteora DLMM. + /// + /// It intentionally accepts additional observed DEX codes because some DEXes + /// can be detected before their trade/candle materialization is complete + /// (`meteora_damm_v1`, future launchpads, and other roadmap DEXes). + pub fn v0_7_28_multi_dex_non_regression() -> Self { + return Self { + profile_code: "0.7.28_multi_dex_non_regression".to_string(), + expected_dex_codes: vec![ + "pump_swap".to_string(), + "raydium_cpmm".to_string(), + "raydium_clmm".to_string(), + "meteora_dlmm".to_string(), + ], + require_all_expected_dexes: true, + allow_unexpected_dexes: true, + require_clean_diagnostics: false, + require_ok_trade_candidates_fully_materialized: true, + require_no_invalid_trade_events: true, + require_no_duplicate_decoded_event_trades: true, + require_no_duplicate_candle_buckets: true, + require_no_pair_gaps: false, + require_decoded_events_per_dex: true, + require_trade_events_per_dex: false, + require_candles_per_dex: false, + }; + } } /// A single local pipeline validation issue. @@ -185,6 +216,14 @@ impl LocalPipelineValidationService { let config = crate::LocalPipelineValidationConfig::v0_7_27_multi_dex_non_regression(); return self.validate_current_database(&config).await; } + + /// Diagnoses the current database with the `0.7.28` multi-DEX non-regression profile. + pub async fn validate_v0_7_28_current_database( + &self, + ) -> Result { + let config = crate::LocalPipelineValidationConfig::v0_7_28_multi_dex_non_regression(); + return self.validate_current_database(&config).await; + } } /// Validates a diagnostics summary without performing database access. @@ -472,6 +511,48 @@ mod tests { assert_eq!(report.warning_count, 0); } + fn make_0_7_28_summary_with_meteora() -> crate::LocalPipelineDiagnosticSummaryDto { + let mut summary = make_clean_summary(); + summary.dex_summaries.retain(|dex_summary| { + return dex_summary.dex_code != "pump_fun"; + }); + summary.dex_summaries.push(crate::LocalDexDiagnosticSummaryDto { + dex_code: "meteora_dlmm".to_string(), + pool_count: 67, + pair_count: 67, + decoded_event_count: 795, + decoded_trade_candidate_count: 795, + decoded_candle_candidate_count: 795, + trade_event_count: 404, + pair_candle_count: 448, + }); + summary.dex_summaries.push(crate::LocalDexDiagnosticSummaryDto { + dex_code: "meteora_damm_v1".to_string(), + pool_count: 1, + pair_count: 1, + decoded_event_count: 1, + decoded_trade_candidate_count: 1, + decoded_candle_candidate_count: 1, + trade_event_count: 0, + pair_candle_count: 0, + }); + summary.pool_count = 95; + summary.pair_count = 95; + return summary; + } + + #[test] + fn validation_accepts_meteora_dlmm_and_partial_future_dex_in_0_7_28_profile() { + let summary = make_0_7_28_summary_with_meteora(); + let config = crate::LocalPipelineValidationConfig::v0_7_28_multi_dex_non_regression(); + let report = crate::validate_local_pipeline_diagnostics_summary(&summary, &config); + assert!(report.validation_passed); + assert_eq!(report.validation_profile_code, "0.7.28_multi_dex_non_regression"); + assert_eq!(report.blocking_issue_count, 0); + assert!(report.observed_dex_codes.contains(&"meteora_dlmm".to_string())); + assert!(report.observed_dex_codes.contains(&"meteora_damm_v1".to_string())); + } + #[test] fn validation_rejects_missing_expected_dex() { let mut summary = make_clean_summary(); diff --git a/kb_lib/src/token_metadata.rs b/kb_lib/src/token_metadata.rs index 35cca7f..5f52b62 100644 --- a/kb_lib/src/token_metadata.rs +++ b/kb_lib/src/token_metadata.rs @@ -677,59 +677,16 @@ fn clean_metaplex_string(value: &str) -> std::option::Option Result, crate::Error> { - let mut output = std::vec::Vec::new(); - let mut group = [0u8; 4]; - let mut group_len = 0usize; - let mut padding_count = 0usize; - for byte in text.bytes() { - if byte == b'\r' || byte == b'\n' || byte == b'\t' || byte == b' ' { - continue; - } - let value_option = base64_value(byte); - let value = match value_option { - Some(value) => value, - None => { - return Err(crate::Error::Json(format!( - "invalid base64 character '{}'", - byte as char - ))); - }, - }; - if byte == b'=' { - padding_count += 1; - } - group[group_len] = value; - group_len += 1; - if group_len == 4 { - output.push((group[0] << 2) | (group[1] >> 4)); - if padding_count < 2 { - output.push((group[1] << 4) | (group[2] >> 2)); - } - if padding_count == 0 { - output.push((group[2] << 6) | group[3]); - } - group = [0u8; 4]; - group_len = 0; - padding_count = 0; - } - } - if group_len != 0 { - return Err(crate::Error::Json( - "invalid base64 length: trailing partial group".to_string(), - )); - } - return Ok(output); -} - -fn base64_value(byte: u8) -> std::option::Option { - match byte { - b'A'..=b'Z' => return Some(byte - b'A'), - b'a'..=b'z' => return Some(byte - b'a' + 26), - b'0'..=b'9' => return Some(byte - b'0' + 52), - b'+' => return Some(62), - b'/' => return Some(63), - b'=' => return Some(0), - _ => return None, + use base64::Engine; + let decoded_result = base64::engine::general_purpose::STANDARD.decode(text); + match decoded_result { + Ok(decoded) => return Ok(decoded), + Err(error) => { + return Err(crate::Error::Json(format!( + "cannot decode standard base64 payload: {}", + error + ))); + }, } } diff --git a/kb_lib/src/trade_aggregation.rs b/kb_lib/src/trade_aggregation.rs index c6c8518..a640bf8 100644 --- a/kb_lib/src/trade_aggregation.rs +++ b/kb_lib/src/trade_aggregation.rs @@ -118,7 +118,8 @@ impl TradeAggregationService { } let trade_side = crate::trade_side_resolution::extract_trade_side( decoded_event.event_kind.as_str(), - &payload, ); + &payload, + ); let amount_input = crate::trade_amount_resolution::TradeAmountResolutionInput { database: self.database.as_ref(), transaction: &transaction, @@ -138,6 +139,10 @@ impl TradeAggregationService { Ok(amount_resolution) => amount_resolution, Err(error) => return Err(error), }; + let trade_side = match amount_resolution.resolved_trade_side.clone() { + Some(resolved_trade_side) => resolved_trade_side, + None => trade_side, + }; let base_amount_raw = amount_resolution.base_amount_raw.clone(); let quote_amount_raw = amount_resolution.quote_amount_raw.clone(); let price_quote_per_base = amount_resolution.price_quote_per_base; diff --git a/kb_lib/src/trade_amount_resolution.rs b/kb_lib/src/trade_amount_resolution.rs index 070b952..491b3f5 100644 --- a/kb_lib/src/trade_amount_resolution.rs +++ b/kb_lib/src/trade_amount_resolution.rs @@ -41,6 +41,8 @@ pub(crate) struct TradeAmountResolution { pub(crate) quote_amount_raw: std::option::Option, /// Quote/base price. pub(crate) price_quote_per_base: std::option::Option, + /// Trade side resolved from balance deltas, when available. + pub(crate) resolved_trade_side: std::option::Option, } /// Resolves trade amounts from payload and protocol-specific fallbacks. @@ -62,6 +64,7 @@ pub(crate) async fn resolve_trade_amounts( ], ); let mut price_quote_per_base = None; + let mut resolved_trade_side = None; if input.decoded_event.event_kind.starts_with("pump_swap.") && (base_amount_raw.is_none() || quote_amount_raw.is_none() @@ -141,6 +144,88 @@ pub(crate) async fn resolve_trade_amounts( return Err(error); } } + if input.decoded_event.event_kind.starts_with("meteora_dlmm.") + && (base_amount_raw.is_none() || quote_amount_raw.is_none()) + { + let resolution_result = + crate::trade_amount_resolution::apply_meteora_dlmm_flattened_cpi_amount_fallback( + input, + &mut base_amount_raw, + &mut quote_amount_raw, + &mut resolved_trade_side, + ) + .await; + if let Err(error) = resolution_result { + return Err(error); + } + } + if input.decoded_event.event_kind.starts_with("meteora_damm_v1.") + && (base_amount_raw.is_none() || quote_amount_raw.is_none()) + { + let resolution_result = + crate::trade_amount_resolution::apply_meteora_damm_v1_flattened_cpi_amount_fallback( + input, + &mut base_amount_raw, + &mut quote_amount_raw, + &mut resolved_trade_side, + ) + .await; + if let Err(error) = resolution_result { + return Err(error); + } + } + if input.decoded_event.event_kind.starts_with("meteora_dlmm.") + && (base_amount_raw.is_none() || quote_amount_raw.is_none()) + { + let resolution_result = crate::trade_amount_resolution::apply_vault_balance_delta_fallback( + input, + input.base_vault_address, + input.quote_vault_address, + &mut base_amount_raw, + &mut quote_amount_raw, + &mut price_quote_per_base, + ); + if let Err(error) = resolution_result { + return Err(error); + } + } + if input.decoded_event.event_kind.starts_with("meteora_damm_v1.") + && (base_amount_raw.is_none() || quote_amount_raw.is_none()) + { + let resolution_result = crate::trade_amount_resolution::apply_vault_balance_delta_fallback( + input, + input.base_vault_address, + input.quote_vault_address, + &mut base_amount_raw, + &mut quote_amount_raw, + &mut price_quote_per_base, + ); + if let Err(error) = resolution_result { + return Err(error); + } + } + if input.decoded_event.event_kind.starts_with("meteora_dlmm.") { + let vault_side = crate::trade_amount_resolution::infer_trade_side_from_vault_balance_deltas( + input.transaction.meta_json.as_deref(), + input.transaction.transaction_json.as_str(), + input.base_vault_address, + input.quote_vault_address, + ); + if vault_side.is_some() { + resolved_trade_side = vault_side; + } + } + if input.decoded_event.event_kind.starts_with("meteora_damm_v1.") { + let vault_side = crate::trade_amount_resolution::infer_trade_side_from_vault_balance_deltas( + input.transaction.meta_json.as_deref(), + input.transaction.transaction_json.as_str(), + input.base_vault_address, + input.quote_vault_address, + ); + if vault_side.is_some() { + resolved_trade_side = vault_side; + } + } if price_quote_per_base.is_none() { price_quote_per_base = crate::trade_metric_update::compute_price_quote_per_base_from_raw_amounts_with_decimals( @@ -170,6 +255,7 @@ pub(crate) async fn resolve_trade_amounts( base_amount_raw, quote_amount_raw, price_quote_per_base, + resolved_trade_side, }); } @@ -500,6 +586,459 @@ async fn apply_raydium_instruction_amount_fallback( return Ok(()); } +#[derive(Debug, Clone)] +struct FlattenedCpiTransferAmountResolution { + base_amount_raw: std::option::Option, + quote_amount_raw: std::option::Option, + resolved_trade_side: std::option::Option, +} + +async fn apply_meteora_dlmm_flattened_cpi_amount_fallback( + input: &crate::trade_amount_resolution::TradeAmountResolutionInput<'_>, + base_amount_raw: &mut std::option::Option, + quote_amount_raw: &mut std::option::Option, + resolved_trade_side: &mut std::option::Option, +) -> Result<(), crate::Error> { + return crate::trade_amount_resolution::apply_flattened_cpi_amount_fallback( + input, + "meteora_dlmm", + base_amount_raw, + quote_amount_raw, + resolved_trade_side, + ) + .await; +} + +async fn apply_meteora_damm_v1_flattened_cpi_amount_fallback( + input: &crate::trade_amount_resolution::TradeAmountResolutionInput<'_>, + base_amount_raw: &mut std::option::Option, + quote_amount_raw: &mut std::option::Option, + resolved_trade_side: &mut std::option::Option, +) -> Result<(), crate::Error> { + return crate::trade_amount_resolution::apply_flattened_cpi_amount_fallback( + input, + "meteora_damm_v1", + base_amount_raw, + quote_amount_raw, + resolved_trade_side, + ) + .await; +} + +async fn apply_flattened_cpi_amount_fallback( + input: &crate::trade_amount_resolution::TradeAmountResolutionInput<'_>, + protocol_label: &str, + base_amount_raw: &mut std::option::Option, + quote_amount_raw: &mut std::option::Option, + resolved_trade_side: &mut std::option::Option, +) -> Result<(), crate::Error> { + let decoded_instruction_result = crate::trade_amount_resolution::load_decoded_instruction( + input.database, + input.decoded_event, + ) + .await; + let decoded_instruction = match decoded_instruction_result { + Ok(Some(decoded_instruction)) => decoded_instruction, + Ok(None) => return Ok(()), + Err(error) => return Err(error), + }; + let instructions_result = crate::query_chain_instructions_list_by_transaction_id( + input.database, + input.decoded_event.transaction_id, + ) + .await; + let instructions = match instructions_result { + Ok(instructions) => instructions, + Err(error) => return Err(error), + }; + let flattened_result = resolve_amounts_from_flattened_cpi_transfer_window( + &decoded_instruction, + &instructions, + input.base_token_mint, + input.quote_token_mint, + input.base_vault_address, + input.quote_vault_address, + ); + let flattened = match flattened_result { + Ok(flattened) => flattened, + Err(error) => return Err(error), + }; + if base_amount_raw.is_none() { + *base_amount_raw = flattened.base_amount_raw; + } + if quote_amount_raw.is_none() { + *quote_amount_raw = flattened.quote_amount_raw; + } + if resolved_trade_side.is_none() { + *resolved_trade_side = flattened.resolved_trade_side; + } + if base_amount_raw.is_some() || quote_amount_raw.is_some() { + tracing::debug!( + event_kind = %input.decoded_event.event_kind, + pool_account = ?input.decoded_event.pool_account, + protocol_label = %protocol_label, + decoded_event_id = ?input.decoded_event.id, + transaction_signature = %input.transaction.signature, + base_mint = ?input.base_token_mint, + quote_mint = ?input.quote_token_mint, + base_amount_raw = ?base_amount_raw, + quote_amount_raw = ?quote_amount_raw, + resolved_trade_side = ?resolved_trade_side, + "trade amounts recovered from flattened CPI transfer window" + ); + } + return Ok(()); +} + +fn resolve_amounts_from_flattened_cpi_transfer_window( + decoded_instruction: &crate::ChainInstructionDto, + transaction_instructions: &[crate::ChainInstructionDto], + base_token_mint: std::option::Option<&str>, + quote_token_mint: std::option::Option<&str>, + base_vault_address: std::option::Option<&str>, + quote_vault_address: std::option::Option<&str>, +) -> Result { + let decoded_stack_height = match decoded_instruction.stack_height { + Some(stack_height) => stack_height, + None => return Ok(crate::trade_amount_resolution::empty_flattened_cpi_amount_resolution()), + }; + let stop_inner_instruction_index = + crate::trade_amount_resolution::find_flattened_cpi_window_stop_inner_instruction_index( + decoded_instruction, + transaction_instructions, + decoded_stack_height, + ); + let mut base_transfer_direction = None; + let mut quote_transfer_direction = None; + let mut base_amount_raw = None; + let mut quote_amount_raw = None; + for instruction in transaction_instructions { + if !crate::trade_amount_resolution::instruction_is_inside_flattened_cpi_window( + decoded_instruction, + instruction, + decoded_stack_height, + stop_inner_instruction_index, + ) { + continue; + } + let parsed_transfer_result = + crate::trade_amount_resolution::parse_transfer_checked_instruction(instruction); + let parsed_transfer = match parsed_transfer_result { + Ok(Some(parsed_transfer)) => parsed_transfer, + Ok(None) => continue, + Err(error) => return Err(error), + }; + if base_amount_raw.is_none() + && crate::trade_amount_resolution::string_option_equals( + base_token_mint, + parsed_transfer.mint.as_str(), + ) + && crate::trade_amount_resolution::transfer_touches_vault( + &parsed_transfer, + base_vault_address, + ) + { + base_transfer_direction = crate::trade_amount_resolution::infer_transfer_direction( + &parsed_transfer, + base_vault_address, + ); + base_amount_raw = Some(parsed_transfer.amount_raw.clone()); + continue; + } + if quote_amount_raw.is_none() + && crate::trade_amount_resolution::string_option_equals( + quote_token_mint, + parsed_transfer.mint.as_str(), + ) + && crate::trade_amount_resolution::transfer_touches_vault( + &parsed_transfer, + quote_vault_address, + ) + { + quote_transfer_direction = crate::trade_amount_resolution::infer_transfer_direction( + &parsed_transfer, + quote_vault_address, + ); + quote_amount_raw = Some(parsed_transfer.amount_raw.clone()); + continue; + } + } + let resolved_trade_side = + crate::trade_amount_resolution::infer_trade_side_from_transfer_directions( + base_transfer_direction, + quote_transfer_direction, + ); + return Ok(crate::trade_amount_resolution::FlattenedCpiTransferAmountResolution { + base_amount_raw, + quote_amount_raw, + resolved_trade_side, + }); +} + +fn instruction_is_inside_flattened_cpi_window( + decoded_instruction: &crate::ChainInstructionDto, + candidate_instruction: &crate::ChainInstructionDto, + decoded_stack_height: u32, + stop_inner_instruction_index: std::option::Option, +) -> bool { + if candidate_instruction.transaction_id != decoded_instruction.transaction_id { + return false; + } + if candidate_instruction.instruction_index != decoded_instruction.instruction_index { + return false; + } + let candidate_inner_instruction_index = match candidate_instruction.inner_instruction_index { + Some(inner_instruction_index) => inner_instruction_index, + None => return false, + }; + if let Some(decoded_inner_instruction_index) = decoded_instruction.inner_instruction_index { + if candidate_inner_instruction_index <= decoded_inner_instruction_index { + return false; + } + } + let candidate_stack_height = match candidate_instruction.stack_height { + Some(stack_height) => stack_height, + None => return false, + }; + if candidate_stack_height <= decoded_stack_height { + return false; + } + if let Some(stop_inner_instruction_index) = stop_inner_instruction_index { + if candidate_inner_instruction_index >= stop_inner_instruction_index { + return false; + } + } + return true; +} + +fn find_flattened_cpi_window_stop_inner_instruction_index( + decoded_instruction: &crate::ChainInstructionDto, + transaction_instructions: &[crate::ChainInstructionDto], + decoded_stack_height: u32, +) -> std::option::Option { + let decoded_inner_instruction_index = match decoded_instruction.inner_instruction_index { + Some(decoded_inner_instruction_index) => decoded_inner_instruction_index, + None => return None, + }; + let mut stop_inner_instruction_index = None; + for instruction in transaction_instructions { + if instruction.transaction_id != decoded_instruction.transaction_id { + continue; + } + if instruction.instruction_index != decoded_instruction.instruction_index { + continue; + } + let candidate_inner_instruction_index = match instruction.inner_instruction_index { + Some(candidate_inner_instruction_index) => candidate_inner_instruction_index, + None => continue, + }; + if candidate_inner_instruction_index <= decoded_inner_instruction_index { + continue; + } + let candidate_stack_height = match instruction.stack_height { + Some(candidate_stack_height) => candidate_stack_height, + None => continue, + }; + if candidate_stack_height > decoded_stack_height { + continue; + } + match stop_inner_instruction_index { + Some(current_stop) => { + if candidate_inner_instruction_index < current_stop { + stop_inner_instruction_index = Some(candidate_inner_instruction_index); + } + }, + None => stop_inner_instruction_index = Some(candidate_inner_instruction_index), + } + } + return stop_inner_instruction_index; +} + +#[derive(Debug, Clone)] +struct ParsedTransferCheckedInstruction { + mint: std::string::String, + amount_raw: std::string::String, + source: std::string::String, + destination: std::string::String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum VaultTransferDirection { + IntoVault, + OutOfVault, +} + +fn parse_transfer_checked_instruction( + instruction: &crate::ChainInstructionDto, +) -> Result< + std::option::Option, + crate::Error, +> { + let parsed_type = match instruction.parsed_type.as_deref() { + Some(parsed_type) => parsed_type, + None => return Ok(None), + }; + if parsed_type != "transferChecked" { + return Ok(None); + } + let parsed_json_text = match instruction.parsed_json.as_deref() { + Some(parsed_json_text) => parsed_json_text, + None => return Ok(None), + }; + let parsed_json_result = serde_json::from_str::(parsed_json_text); + let parsed_json = match parsed_json_result { + Ok(parsed_json) => parsed_json, + Err(error) => { + return Err(crate::Error::Json(format!( + "cannot parse parsed_json for transferChecked instruction '{}': {}", + crate::trade_amount_resolution::format_instruction_id_for_log(instruction.id), + error + ))); + }, + }; + let info = match parsed_json.get("info") { + Some(info) => info, + None => return Ok(None), + }; + let mint = + match crate::trade_amount_resolution::extract_string_by_candidate_keys(info, &["mint"]) { + Some(mint) => mint, + None => return Ok(None), + }; + let amount_raw = + match crate::trade_amount_resolution::extract_scalar_as_string_by_candidate_keys( + info, + &["amount"], + ) { + Some(amount_raw) => amount_raw, + None => { + let token_amount = match info.get("tokenAmount") { + Some(token_amount) => token_amount, + None => return Ok(None), + }; + match crate::trade_amount_resolution::extract_scalar_as_string_by_candidate_keys( + token_amount, + &["amount"], + ) { + Some(amount_raw) => amount_raw, + None => return Ok(None), + } + }, + }; + let source = + match crate::trade_amount_resolution::extract_string_by_candidate_keys(info, &["source"]) { + Some(source) => source, + None => return Ok(None), + }; + let destination = match crate::trade_amount_resolution::extract_string_by_candidate_keys( + info, + &["destination"], + ) { + Some(destination) => destination, + None => return Ok(None), + }; + return Ok(Some(crate::trade_amount_resolution::ParsedTransferCheckedInstruction { + mint, + amount_raw, + source, + destination, + })); +} + +fn transfer_touches_vault( + transfer: &crate::trade_amount_resolution::ParsedTransferCheckedInstruction, + vault_address: std::option::Option<&str>, +) -> bool { + let vault_address = match vault_address { + Some(vault_address) => vault_address, + None => return true, + }; + if crate::trade_amount_resolution::account_equals(transfer.source.as_str(), vault_address) { + return true; + } + if crate::trade_amount_resolution::account_equals(transfer.destination.as_str(), vault_address) + { + return true; + } + return false; +} + +fn infer_transfer_direction( + transfer: &crate::trade_amount_resolution::ParsedTransferCheckedInstruction, + vault_address: std::option::Option<&str>, +) -> std::option::Option { + let vault_address = match vault_address { + Some(vault_address) => vault_address, + None => return None, + }; + if crate::trade_amount_resolution::account_equals(transfer.destination.as_str(), vault_address) + { + return Some(crate::trade_amount_resolution::VaultTransferDirection::IntoVault); + } + if crate::trade_amount_resolution::account_equals(transfer.source.as_str(), vault_address) { + return Some(crate::trade_amount_resolution::VaultTransferDirection::OutOfVault); + } + return None; +} + +fn infer_trade_side_from_transfer_directions( + base_transfer_direction: std::option::Option< + crate::trade_amount_resolution::VaultTransferDirection, + >, + quote_transfer_direction: std::option::Option< + crate::trade_amount_resolution::VaultTransferDirection, + >, +) -> std::option::Option { + match (base_transfer_direction, quote_transfer_direction) { + ( + Some(crate::trade_amount_resolution::VaultTransferDirection::OutOfVault), + Some(crate::trade_amount_resolution::VaultTransferDirection::IntoVault), + ) => return Some(crate::SwapTradeSide::BuyBase), + ( + Some(crate::trade_amount_resolution::VaultTransferDirection::IntoVault), + Some(crate::trade_amount_resolution::VaultTransferDirection::OutOfVault), + ) => return Some(crate::SwapTradeSide::SellBase), + _ => return None, + } +} + +fn string_option_equals(left: std::option::Option<&str>, right: &str) -> bool { + let left = match left { + Some(left) => left.trim(), + None => return false, + }; + let right = right.trim(); + if left.is_empty() || right.is_empty() { + return false; + } + return left == right; +} + +fn account_equals(left: &str, right: &str) -> bool { + let left = left.trim(); + let right = right.trim(); + if left.is_empty() || right.is_empty() { + return false; + } + return left == right; +} + +fn format_instruction_id_for_log(instruction_id: std::option::Option) -> std::string::String { + match instruction_id { + Some(instruction_id) => return instruction_id.to_string(), + None => return "".to_string(), + } +} + +fn empty_flattened_cpi_amount_resolution() +-> crate::trade_amount_resolution::FlattenedCpiTransferAmountResolution { + return crate::trade_amount_resolution::FlattenedCpiTransferAmountResolution { + base_amount_raw: None, + quote_amount_raw: None, + resolved_trade_side: None, + }; +} + fn apply_vault_balance_delta_fallback( input: &crate::trade_amount_resolution::TradeAmountResolutionInput<'_>, base_vault_address: std::option::Option<&str>, @@ -535,6 +1074,22 @@ async fn load_decoded_instruction_index( database: &crate::Database, decoded_event: &crate::DexDecodedEventDto, ) -> Result, crate::Error> { + let instruction_result = + crate::trade_amount_resolution::load_decoded_instruction(database, decoded_event).await; + let instruction_option = match instruction_result { + Ok(instruction_option) => instruction_option, + Err(error) => return Err(error), + }; + match instruction_option { + Some(instruction) => return Ok(Some(instruction.instruction_index)), + None => return Ok(None), + } +} + +async fn load_decoded_instruction( + database: &crate::Database, + decoded_event: &crate::DexDecodedEventDto, +) -> Result, crate::Error> { let instruction_id = match decoded_event.instruction_id { Some(instruction_id) => instruction_id, None => return Ok(None), @@ -545,10 +1100,7 @@ async fn load_decoded_instruction_index( Ok(instruction_option) => instruction_option, Err(error) => return Err(error), }; - match instruction_option { - Some(instruction) => return Ok(Some(instruction.instruction_index)), - None => return Ok(None), - } + return Ok(instruction_option); } fn extract_amount_string( @@ -648,3 +1200,477 @@ fn extract_scalar_as_string_by_candidate_keys( } return None; } + +fn infer_trade_side_from_vault_balance_deltas( + meta_json: std::option::Option<&str>, + transaction_json: &str, + base_vault_address: std::option::Option<&str>, + quote_vault_address: std::option::Option<&str>, +) -> std::option::Option { + let base_vault_address = match base_vault_address { + Some(base_vault_address) => base_vault_address, + None => return None, + }; + let quote_vault_address = match quote_vault_address { + Some(quote_vault_address) => quote_vault_address, + None => return None, + }; + let meta_text = match meta_json { + Some(meta_text) => meta_text, + None => return None, + }; + let meta_result = serde_json::from_str::(meta_text); + let meta = match meta_result { + Ok(meta) => meta, + Err(_) => return None, + }; + let transaction_result = serde_json::from_str::(transaction_json); + let transaction = match transaction_result { + Ok(transaction) => transaction, + Err(_) => return None, + }; + if transaction_failed(&meta) { + return None; + } + let account_keys = collect_transaction_account_keys(&transaction, &meta); + let base_account_index = find_account_index(&account_keys, base_vault_address); + let base_account_index = match base_account_index { + Some(base_account_index) => base_account_index, + None => return None, + }; + let quote_account_index = find_account_index(&account_keys, quote_vault_address); + let quote_account_index = match quote_account_index { + Some(quote_account_index) => quote_account_index, + None => return None, + }; + let base_delta = token_balance_delta_for_account_index(&meta, base_account_index); + let base_delta = match base_delta { + Some(base_delta) => base_delta, + None => return None, + }; + let quote_delta = token_balance_delta_for_account_index(&meta, quote_account_index); + let quote_delta = match quote_delta { + Some(quote_delta) => quote_delta, + None => return None, + }; + if base_delta < 0_i128 && quote_delta > 0_i128 { + return Some(crate::SwapTradeSide::BuyBase); + } + if base_delta > 0_i128 && quote_delta < 0_i128 { + return Some(crate::SwapTradeSide::SellBase); + } + return None; +} + +fn transaction_failed(meta: &serde_json::Value) -> bool { + if let Some(err_value) = meta.get("err") { + if !err_value.is_null() { + return true; + } + } + if let Some(status) = meta.get("status") { + if let Some(object) = status.as_object() { + if object.get("Err").is_some() { + return true; + } + } + } + return false; +} + +fn collect_transaction_account_keys( + transaction: &serde_json::Value, + meta: &serde_json::Value, +) -> std::vec::Vec { + let mut account_keys = std::vec::Vec::new(); + collect_account_keys_from_candidate_path( + transaction, + &["transaction", "message", "accountKeys"], + &mut account_keys, + ); + if account_keys.is_empty() { + collect_account_keys_from_candidate_path( + transaction, + &["message", "accountKeys"], + &mut account_keys, + ); + } + collect_loaded_addresses(meta, &mut account_keys); + return account_keys; +} + +fn collect_account_keys_from_candidate_path( + value: &serde_json::Value, + path: &[&str], + target: &mut std::vec::Vec, +) { + let mut current = value; + for key in path { + let next = current.get(*key); + current = match next { + Some(next) => next, + None => return, + }; + } + let array = match current.as_array() { + Some(array) => array, + None => return, + }; + for item in array { + if let Some(text) = item.as_str() { + target.push(text.to_string()); + continue; + } + if let Some(pubkey) = item.get("pubkey").and_then(|value| value.as_str()) { + target.push(pubkey.to_string()); + } + } +} + +fn collect_loaded_addresses( + meta: &serde_json::Value, + target: &mut std::vec::Vec, +) { + let loaded_addresses = match meta.get("loadedAddresses") { + Some(loaded_addresses) => loaded_addresses, + None => return, + }; + collect_loaded_address_array(loaded_addresses, "writable", target); + collect_loaded_address_array(loaded_addresses, "readonly", target); +} + +fn collect_loaded_address_array( + loaded_addresses: &serde_json::Value, + key: &str, + target: &mut std::vec::Vec, +) { + let array = match loaded_addresses.get(key).and_then(|value| value.as_array()) { + Some(array) => array, + None => return, + }; + for item in array { + if let Some(text) = item.as_str() { + target.push(text.to_string()); + } + } +} + +fn find_account_index( + account_keys: &[std::string::String], + account_address: &str, +) -> std::option::Option { + for (index, account_key) in account_keys.iter().enumerate() { + if account_key == account_address { + return Some(index); + } + } + return None; +} + +fn token_balance_delta_for_account_index( + meta: &serde_json::Value, + account_index: usize, +) -> std::option::Option { + let pre_amount = + token_balance_amount_for_account_index(meta, "preTokenBalances", account_index); + let pre_amount = match pre_amount { + Some(pre_amount) => pre_amount, + None => return None, + }; + let post_amount = + token_balance_amount_for_account_index(meta, "postTokenBalances", account_index); + let post_amount = match post_amount { + Some(post_amount) => post_amount, + None => return None, + }; + return Some(post_amount - pre_amount); +} + +fn token_balance_amount_for_account_index( + meta: &serde_json::Value, + key: &str, + account_index: usize, +) -> std::option::Option { + let balances = match meta.get(key).and_then(|value| value.as_array()) { + Some(balances) => balances, + None => return None, + }; + for balance in balances { + let balance_index = balance.get("accountIndex").and_then(|value| value.as_u64()); + let balance_index = match balance_index { + Some(balance_index) => balance_index, + None => continue, + }; + let requested_index = match u64::try_from(account_index) { + Ok(requested_index) => requested_index, + Err(_) => return None, + }; + if balance_index != requested_index { + continue; + } + let amount_text = balance + .get("uiTokenAmount") + .and_then(|value| value.get("amount")) + .and_then(|value| value.as_str()); + let amount_text = match amount_text { + Some(amount_text) => amount_text, + None => return None, + }; + let amount_result = amount_text.parse::(); + match amount_result { + Ok(amount) => return Some(amount), + Err(_) => return None, + } + } + return None; +} + +#[cfg(test)] +mod tests { + fn transaction_json_with_vaults(base_vault: &str, quote_vault: &str) -> std::string::String { + return serde_json::json!({ + "transaction": { + "message": { + "accountKeys": [ + base_vault, + quote_vault + ] + } + } + }) + .to_string(); + } + + fn meta_json_with_vault_amounts( + base_pre: &str, + base_post: &str, + quote_pre: &str, + quote_post: &str, + ) -> std::string::String { + return serde_json::json!({ + "err": null, + "status": { + "Ok": null + }, + "preTokenBalances": [ + { + "accountIndex": 0, + "uiTokenAmount": { + "amount": base_pre + } + }, + { + "accountIndex": 1, + "uiTokenAmount": { + "amount": quote_pre + } + } + ], + "postTokenBalances": [ + { + "accountIndex": 0, + "uiTokenAmount": { + "amount": base_post + } + }, + { + "accountIndex": 1, + "uiTokenAmount": { + "amount": quote_post + } + } + ] + }) + .to_string(); + } + + fn make_test_instruction( + id: i64, + parent_instruction_id: std::option::Option, + instruction_index: u32, + inner_instruction_index: std::option::Option, + stack_height: std::option::Option, + parsed_type: std::option::Option<&str>, + parsed_json: std::option::Option, + ) -> crate::ChainInstructionDto { + return crate::ChainInstructionDto { + id: Some(id), + transaction_id: 10, + parent_instruction_id, + instruction_index, + inner_instruction_index, + program_id: None, + program_name: None, + stack_height, + accounts_json: "[]".to_string(), + data_json: None, + parsed_type: parsed_type.map(|value| value.to_string()), + parsed_json, + created_at: chrono::Utc::now(), + }; + } + + fn transfer_checked_json( + mint: &str, + amount: &str, + source: &str, + destination: &str, + ) -> std::string::String { + return serde_json::json!({ + "info": { + "mint": mint, + "source": source, + "destination": destination, + "tokenAmount": { + "amount": amount + } + }, + "type": "transferChecked" + }) + .to_string(); + } + + #[test] + fn flattened_cpi_window_extracts_meteora_dlmm_transfer_checked_amounts() { + let decoded_instruction = + make_test_instruction(100, Some(90), 3, Some(1), Some(3), None, None); + let instructions = vec![ + decoded_instruction.clone(), + make_test_instruction(101, Some(90), 3, Some(2), Some(4), None, None), + make_test_instruction( + 102, + Some(90), + 3, + Some(3), + Some(4), + Some("transferChecked"), + Some(transfer_checked_json("BASE", "5689283022", "UserBase", "BaseVault")), + ), + make_test_instruction( + 103, + Some(90), + 3, + Some(4), + Some(4), + Some("transferChecked"), + Some(transfer_checked_json("QUOTE", "1322754129", "QuoteVault", "UserQuote")), + ), + make_test_instruction( + 104, + Some(90), + 3, + Some(5), + Some(3), + Some("transferChecked"), + Some(transfer_checked_json("QUOTE", "999", "QuoteVault", "UserQuote")), + ), + ]; + let resolution_result = super::resolve_amounts_from_flattened_cpi_transfer_window( + &decoded_instruction, + &instructions, + Some("BASE"), + Some("QUOTE"), + Some("BaseVault"), + Some("QuoteVault"), + ); + let resolution = match resolution_result { + Ok(resolution) => resolution, + Err(error) => panic!("flattened CPI extraction should succeed: {}", error), + }; + assert_eq!(resolution.base_amount_raw, Some("5689283022".to_string())); + assert_eq!(resolution.quote_amount_raw, Some("1322754129".to_string())); + assert_eq!(resolution.resolved_trade_side, Some(crate::SwapTradeSide::SellBase)); + } + + #[test] + fn flattened_cpi_window_extracts_top_level_meteora_damm_v1_transfer_checked_amounts() { + let decoded_instruction = make_test_instruction(200, None, 4, None, Some(1), None, None); + let instructions = vec![ + decoded_instruction.clone(), + make_test_instruction( + 201, + Some(200), + 4, + Some(0), + Some(2), + Some("transferChecked"), + Some(transfer_checked_json("BASE", "250000000", "UserBase", "PoolBaseVault")), + ), + make_test_instruction( + 202, + Some(200), + 4, + Some(1), + Some(2), + Some("transferChecked"), + Some(transfer_checked_json("QUOTE", "10000000", "PoolQuoteVault", "UserQuote")), + ), + ]; + let resolution_result = super::resolve_amounts_from_flattened_cpi_transfer_window( + &decoded_instruction, + &instructions, + Some("BASE"), + Some("QUOTE"), + None, + None, + ); + let resolution = match resolution_result { + Ok(resolution) => resolution, + Err(error) => panic!("top-level flattened CPI extraction should succeed: {}", error), + }; + assert_eq!(resolution.base_amount_raw, Some("250000000".to_string())); + assert_eq!(resolution.quote_amount_raw, Some("10000000".to_string())); + assert_eq!(resolution.resolved_trade_side, None); + } + + #[test] + fn buy_base_is_inferred_when_base_vault_decreases_and_quote_vault_increases() { + let transaction_json = transaction_json_with_vaults("base_vault", "quote_vault"); + let meta_json = meta_json_with_vault_amounts("1000", "900", "5000", "5200"); + let side = super::infer_trade_side_from_vault_balance_deltas( + Some(meta_json.as_str()), + transaction_json.as_str(), + Some("base_vault"), + Some("quote_vault"), + ); + assert_eq!(side, Some(crate::SwapTradeSide::BuyBase)); + } + + #[test] + fn sell_base_is_inferred_when_base_vault_increases_and_quote_vault_decreases() { + let transaction_json = transaction_json_with_vaults("base_vault", "quote_vault"); + let meta_json = meta_json_with_vault_amounts("1000", "1200", "5000", "4900"); + let side = super::infer_trade_side_from_vault_balance_deltas( + Some(meta_json.as_str()), + transaction_json.as_str(), + Some("base_vault"), + Some("quote_vault"), + ); + assert_eq!(side, Some(crate::SwapTradeSide::SellBase)); + } + + #[test] + fn failed_transaction_does_not_infer_trade_side() { + let transaction_json = transaction_json_with_vaults("base_vault", "quote_vault"); + let meta_json = serde_json::json!({ + "err": { + "InstructionError": [3, {"Custom": 104}] + }, + "status": { + "Err": { + "InstructionError": [3, {"Custom": 104}] + } + }, + "preTokenBalances": [], + "postTokenBalances": [] + }) + .to_string(); + let side = super::infer_trade_side_from_vault_balance_deltas( + Some(meta_json.as_str()), + transaction_json.as_str(), + Some("base_vault"), + Some("quote_vault"), + ); + assert_eq!(side, None); + } +}