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
- Validate 0.7.27
+ Validate
+
+
+
+
+
+
+
+
+
+
+
+ Groupe les instructions d’un programme par discriminator décodé, nombre de comptes, stack height et statut decoded/non-decoded.
+
+
+
+ Program id
+
+
+
+
+ Instruction row limit
+
+
+
+
+
+ Load discriminator summaries
@@ -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);
+ }
+}