From 319be14aa64db0ead00de161a2f8ce00565743a1 Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Wed, 17 Jun 2026 16:06:09 +0200 Subject: [PATCH] 0.7.56 --- CHANGELOG.md | 1 + Cargo.toml | 2 +- README.md | 88 +- ROADMAP.md | 57 +- docs/DB_EVENT_MODEL_REVIEW.md | 15 + docs/DEX_DECODER_MATRIX.md | 34 +- docs/DEX_EVENT_COVERAGE_MATRIX.md | 31 + docs/VALIDATION_STATUS_0_7_56_FINAL.md | 51 + docs/prompts/PROMPT_0_7_56_METEORA_DBC.md | 4 +- ...ETEORA_DLMM_FULL_DECODE_MATERIALIZATION.md | 457 ++++ .../FEE_EVENT_AMOUNTS_MODEL_NOTE_0_7_56.md | 151 ++ .../METEORA_DBC_EVENT_COVERAGE_REPORT.md | 161 ++ kb_demo_app/package.json | 2 +- kb_demo_app/tauri.conf.json | 2 +- kb_lib/src/db.rs | 17 +- kb_lib/src/db/dtos.rs | 2 + kb_lib/src/db/dtos/fee_event_amount.rs | 110 + kb_lib/src/db/entities.rs | 2 + kb_lib/src/db/entities/fee_event_amount.rs | 34 + kb_lib/src/db/queries.rs | 6 + kb_lib/src/db/queries/dex_decoded_event.rs | 4 + kb_lib/src/db/queries/fee_event.rs | 94 +- kb_lib/src/db/queries/fee_event_amount.rs | 677 +++++ kb_lib/src/db/schema.rs | 125 + kb_lib/src/dex.rs | 1 + kb_lib/src/dex/meteora_dbc.rs | 2351 +++++++++++++++-- kb_lib/src/dex_decode.rs | 22 +- kb_lib/src/dex_detection_route.rs | 23 + kb_lib/src/dex_event_classification.rs | 49 +- kb_lib/src/dex_event_coverage.rs | 653 +++++ kb_lib/src/instruction_observation_index.rs | 67 + kb_lib/src/lib.rs | 58 +- kb_lib/src/non_trade_event_materialization.rs | 1105 +++++++- kb_lib/src/trade_aggregation.rs | 45 +- kb_lib/src/trade_amount_resolution.rs | 266 +- .../SQL_VALIDATION_METEORA_DBC_0_7_56.sql | 440 +++ .../SQL_VALIDATION_METEORA_DLMM_0_7_57.sql | 285 ++ 37 files changed, 7129 insertions(+), 363 deletions(-) create mode 100644 docs/VALIDATION_STATUS_0_7_56_FINAL.md create mode 100644 docs/prompts/PROMPT_0_7_57_METEORA_DLMM_FULL_DECODE_MATERIALIZATION.md create mode 100644 docs/reports/FEE_EVENT_AMOUNTS_MODEL_NOTE_0_7_56.md create mode 100644 docs/reports/METEORA_DBC_EVENT_COVERAGE_REPORT.md create mode 100644 kb_lib/src/db/dtos/fee_event_amount.rs create mode 100644 kb_lib/src/db/entities/fee_event_amount.rs create mode 100644 kb_lib/src/db/queries/fee_event_amount.rs create mode 100644 validation_sql/SQL_VALIDATION_METEORA_DBC_0_7_56.sql create mode 100644 validation_sql/SQL_VALIDATION_METEORA_DLMM_0_7_57.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index ee5c129..a0f54dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,3 +88,4 @@ 0.7.53 - Clôture PumpSwap : décodage transaction/log complet, matérialisation `buy/sell/buy_exact_quote_in` depuis sources exactes, events Anchor audit-only, tests synthétiques IDL, validation globale coverage SQL et non-régression Raydium. 0.7.54 - Clôture Pump.fun : decoder maximal local depuis IDL Solscan/upstream, décodage des 40 instructions et 23 events Anchor connus, matérialisation validée des trades `buy/sell/buy_exact_sol_in` et `trade_event` v2/exact sans double-count, non-trades launch/fee/reward/admin selon contexte, validation SQL Pump.fun propre et ouverture de `0.7.55 pump_fees`. 0.7.55 - Clôture Pump Fees : decoder local maximal `pump_fees` depuis l'IDL locale, `29` instructions et `20` events Anchor couverts, tests synthétiques des Anchor IDL non observés, matérialisation prudente fee/reward/admin/lifecycle, `get_fees` decoded-only, transactions failed audit-only, aucun trade/candle direct et SQL de validation Pump Fees propre. +0.7.56 - Clôture Meteora DBC : decoder local maximal depuis l'IDL `dbcij3LWUppWqq96dh6gJWwBifmcGfLSB5D4DuSMaqN`, couverture des 28 instructions et 23 events Anchor, matérialisation validée des swaps `swap/swap2`, lifecycle, admin/config, lockers/migrations et fees/surplus/leftover, ajout du modèle `k_sol_fee_event_amounts` pour les legs de montants fee, helper générique parent fee -> legs scalaires, recovery fee `allowlisted_inner_spl_transfer` strictement allowlistée pour anciens DEX, validation croisée Pump/Raydium, 446 tests passés, clippy OK et SQL de fermeture DBC propre. diff --git a/Cargo.toml b/Cargo.toml index d3800ad..82767d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ ] [workspace.package] -version = "0.7.55" +version = "0.7.56" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-bobobot" diff --git a/README.md b/README.md index 2aefbe2..9def016 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,92 @@ +## État final validé `0.7.56` — `meteora_dbc` + socle `fee_event_amounts` + +La tranche `0.7.56 meteora_dbc` est clôturée côté decoder local maximal, coverage, tests synthétiques, matérialisation prudente et validation SQL sur base dédiée. Le programme traité est : + +```text +dbcij3LWUppWqq96dh6gJWwBifmcGfLSB5D4DuSMaqN +``` + +Source IDL locale prioritaire : + +```text +idls/meteora_dbc.dbcij3LWUppWqq96dh6gJWwBifmcGfLSB5D4DuSMaqN.json +``` + +Surface couverte : `28` instructions, `23` events Anchor, `9` accounts et `59` types. Les instructions `swap` et `swap2` sont les seules candidates trade/candle directes, uniquement lorsque les montants et les mints base/quote sont fiables. Les initialisations de virtual pool, migrations, lockers, metadata/config/operator et transferts de créateur alimentent les tables non-trade adaptées ou restent decoded-only avec raison explicite. Les transactions failed restent audit-only. + +Validation locale finale rapportée : + +```text +cargo test -p kb_lib -> 446 passed / 0 failed +cargo clippy -p kb_lib --all-targets -- -D warnings -> OK +480 replayed +0 decode skipped +480 ledger upserts +454 unsafe ledger rows +264 trades +1 liquidity +122 lifecycle +0 tokenAccount +1056 candle upserts +instructionObservations = 7167 +resetDeleted = 3583 +catalog = 86 tokens / 60 pools / 60 pairs +``` + +Matérialisation DBC finale observée : + +```text +claim_creator_trading_fee 8 parents / 8 scalar / 8 legs +claim_partner_pool_creation_fee 10 parents / 10 scalar / 10 legs +claim_protocol_fee 10 parents / 10 scalar / 10 legs +claim_protocol_pool_creation_fee 10 parents / 10 scalar / 10 legs +claim_trading_fee 11 parents / 6 scalar / 18 legs +creator_withdraw_surplus 2 parents / 2 scalar / 2 legs +partner_withdraw_surplus 9 parents / 9 scalar / 9 legs +withdraw_leftover 10 parents / 10 scalar / 10 legs +withdraw_migration_fee 9 parents / 9 scalar / 9 legs +zap_protocol_fee 10 parents / 10 scalar / 10 legs +Total meteora_dbc 89 fee parents / 96 amount legs +``` + +La tranche introduit le socle générique `k_sol_fee_event_amounts` : + +- `k_sol_fee_events` reste l'événement fee parent ; +- `k_sol_fee_event_amounts` stocke les legs de montants, avec `fee_event_id`, `leg_index`, `token_mint`, `amount_raw`, comptes source/destination et `amount_source` ; +- tout parent fee avec `fee_token_mint + fee_amount_raw` crée automatiquement un leg scalaire ; +- les fees multi-mint/multi-leg laissent le parent sans montant scalaire et stockent les montants fiables dans les legs ; +- une recovery `allowlisted_inner_spl_transfer` existe pour les anciens DEX validés, mais aucun futur décodeur ne l'hérite par défaut ; +- tout futur décodeur doit déclarer explicitement sa policy de récupération des montants fee. + +Checks de fermeture `0.7.56` : + +- fallback `upstream_git` `meteora_dbc` pour entrées couvertes localement : vide ; +- decoded `meteora_dbc` sans coverage : vide ; +- successful non-materialized sans `skip*Reason` ou policy explicite : vide ; +- failed transaction avec business materialization : vide ; +- multi-target materialization : vide ; +- non-swap vers trade/candle : vide ; +- parent fee avec `fee_token_mint + fee_amount_raw` mais sans `k_sol_fee_event_amounts` : vide ; +- legs fee orphelins : vide ; +- recovery générique `allowlisted_inner_spl_transfer` non appliquée à `meteora_dbc`, qui reste couvert par ses chemins spécifiques. + +Contrôles croisés réalisés sur anciennes bases : `raydium_launchpad`, `raydium_cpmm`, `pump_swap`, `pump_fees`, `pump_fun`, `raydium_amm_v4`, `raydium_clmm`, `raydium_stable_swap`. Les parents fees scalaires ont des legs ; les events sans transfert exploitable restent documentés par `fee_instruction_has_no_actual_transfer` ou `fee_instruction_has_only_zero_amount_transfers`. + +Documents de référence : + +```text +docs/reports/METEORA_DBC_EVENT_COVERAGE_REPORT.md +docs/reports/FEE_EVENT_AMOUNTS_MODEL_NOTE_0_7_56.md +docs/VALIDATION_STATUS_0_7_56_FINAL.md +validation_sql/SQL_VALIDATION_METEORA_DBC_0_7_56.sql +docs/prompts/PROMPT_0_7_57_METEORA_DLMM_FULL_DECODE_MATERIALIZATION.md +``` + +Prochaine tranche recommandée : `0.7.57 meteora_dlmm` en mode full decode / full materialization, sur le programme `LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo`, avec vérification complète de l'IDL locale `idls/meteora_dlmm.LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo.json`. + ## État final validé `0.7.55` — `pump_fees` La tranche `0.7.55 pump_fees` est clôturée côté decoder local maximal, coverage, tests synthétiques, matérialisation prudente et validation SQL. Le programme `pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ` est traité comme surface fee/config/accounting : `get_fees` reste decoded-only, les claims social fee alimentent `k_sol_reward_events`, les donation/buyback alimentent `k_sol_fee_events`, les authority/config/tier/update alimentent `k_sol_pool_admin_events`, les créations/init/extend alimentent `k_sol_pool_lifecycle_events`, et aucun trade/candle direct n'est produit. @@ -603,7 +689,7 @@ Si une requête DB est ajoutée ou modifiée, mettre à jour les re-exports dans La priorité immédiate après la clôture `0.7.55 pump_fees` est la suivante : -1. ouvrir `0.7.56 meteora_dbc` sur une base SQLite neuve ; +1. reprendre `0.7.57 meteora_dlmm` sur une base SQLite neuve ; 2. décoder toutes les instructions/events DBC connus par l'IDL locale et les sources Git ; 3. matérialiser uniquement ce qui est prouvable : launch/bonding, swaps fiables, migration, liquidity, fees/admin/config ; 4. ne créer aucun trade/candle DBC sans montants, sens économique, pool/pair et mints fiables ; diff --git a/ROADMAP.md b/ROADMAP.md index 646f147..de7c1c2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,38 +1,39 @@ -# khadhroony-bobobot — Roadmap +# Roadmap — khadhroony-bobobot -### `0.7.55 pump_fees` — clôturé +## État courant — clôture `0.7.56 meteora_dbc` et ouverture `0.7.57 meteora_dlmm full decode/materialization` -- Decoder local `pump_fees` clôturé pour les `29` instructions et `20` events Anchor de l'IDL locale. -- Registre enrichi avec les entrées locales absentes du decoder Carbon partiel et conservation de deux discriminators Solscan hors IDL comme surfaces futures. -- Classification validée : `get_fees` decoded-only, social fee claim vers reward, donation/buyback vers fee, config/authority/tier/admin vers admin/lifecycle. -- Tests synthétiques ajoutés pour les Anchor events IDL non observés dans le corpus. -- Invariants propres : aucun fallback `pump_fees`, aucun decoded sans coverage, aucune failed tx matérialisée, aucun multi-target, aucun trade/candle direct. -- Dernier replay : `127 replayed`, `150 ledger upserts`, `125 unsafe`, `115 lifecycle`, `2234 instructionObservations`, catalogue `11 tokens / 10 pools / 10 pairs`. +### `0.7.56 meteora_dbc` — clos -## État courant — clôture `0.7.55 pump_fees` et ouverture `0.7.56 meteora_dbc` +- Program id : `dbcij3LWUppWqq96dh6gJWwBifmcGfLSB5D4DuSMaqN`. +- Source prioritaire : `idls/meteora_dbc.dbcij3LWUppWqq96dh6gJWwBifmcGfLSB5D4DuSMaqN.json`. +- Surface IDL : `28` instructions, `23` events Anchor, `9` accounts, `59` types. +- Build final : `cargo test -p kb_lib` -> `446 passed`; clippy `-D warnings` OK. +- Replay final DBC : `480 replayed`, `264 trades`, `1 liquidity`, `122 lifecycle`, `1056 candle upserts`, `instructionObservations=7167`, catalogue `86/60/60`. +- Fees DBC : `89` parents `k_sol_fee_events`, `96` legs `k_sol_fee_event_amounts`, aucun parent fee scalaire sans leg, aucun leg orphelin. +- Socle transversal ajouté : table `k_sol_fee_event_amounts`, helper générique parent fee + legs, backfill/normalisation safe, recovery `allowlisted_inner_spl_transfer` non globale. -`0.7.55 pump_fees` est clos pour la surface Pump Fees. La tranche couvre les `29` instructions et `20` events Anchor de l'IDL locale, avec tests synthétiques pour les Anchor events non observés. `get_fees` reste decoded-only, les rewards/fees/admin/lifecycle sont matérialisés uniquement sur transactions réussies et données fiables, les failed tx restent audit-only, et aucun trade/candle direct `pump_fees` n'est créé. +### Politique fee obligatoire à partir de `0.7.56` -Décisions de clôture Pump Fees : +- Un parent `k_sol_fee_events` avec `fee_token_mint + fee_amount_raw` doit créer automatiquement un leg `k_sol_fee_event_amounts` d'index `0`. +- Un fee multi-mint/multi-leg ne doit pas agréger artificiellement le parent ; le parent reste sans montant scalaire, les legs portent les montants fiables. +- Les montants issus d'arguments `max`, de bornes ou de `u64::MAX` ne sont pas des fees exécutés. +- La recovery par CPI SPL inner transfer n'est pas globale : chaque futur décodeur doit déclarer explicitement sa policy et ses event kinds autorisés. +- Les `amount_source` connus à préserver : `parent_fee_event_amount`, `fee_event_amounts`, `inner_spl_transfer`, `lamport_balance_delta`, `allowlisted_inner_spl_transfer`. +- Les cas sans transfert exploitable doivent écrire une raison explicite : `fee_instruction_has_no_actual_transfer` ou `fee_instruction_has_only_zero_amount_transfers`. -- `pump_fees.get_fees` est decoded-only et ne représente pas une fee payée ; -- `claim_social_fee_pda*` et `social_fee_pda_claimed` alimentent `k_sol_reward_events` quand la transaction réussit ; -- `crank_donation_fee_pda`, `donation_fee_pda_cranked`, `sweep_buyback` et `sweep_buyback_event` alimentent `k_sol_fee_events` quand les montants sont fiables ; -- fee sharing/config/authority/tier/update alimentent `k_sol_pool_admin_events` ou `k_sol_pool_lifecycle_events` selon le contexte ; -- les discriminators Solscan `revoke_fee_sharing_authority_event` et `transfer_fee_sharing_authority_event` restent conservés en coverage comme surfaces futures non observées ; -- les validations fallback, decoded sans coverage, failed materialization, multi-target, successful non-materialized, anti-trade/candle et watchlist `pump_fees` sont propres. +### Prochaine tranche immédiate -Replay final rapporté : `127 replayed`, `0 decode skipped`, `150 ledger upserts`, `125 unsafe`, `115 lifecycle`, `2234 instructionObservations`, catalogue `11 tokens / 10 pools / 10 pairs`. Tests : `431 passed`, clippy OK. - -### Phasage immédiat après `0.7.55` - -| Priorité | Tranche | Surface | Raison | +| Priorité | Tranche | Surface | Objectif | |---:|---|---|---| -| 1 | `0.7.56` | `meteora_dbc` | Prochaine tranche programmée : launch/bonding, swaps exploitables, migration, fees/admin/config depuis corpus neuf. | -| 2 | `0.7.57+` | `meteora_*` | Corriger les gaps locaux Meteora reportés volontairement, surface par surface. | -| 3 | ultérieur | `jupiter_swap` / agrégateurs | `jupiter_swap.route_v2` reste en watchlist résiduelle ; traiter sans double-count des DEX effectifs. | +| 1 | `0.7.57` | `meteora_dlmm` | Full decode + full materialization : `76` instructions IDL, `30` events Anchor, swaps, exact-out, liquidity/bin/position, fees, rewards, admin/config, limit-order events et side effects sans double-count. | +| 2 | `0.7.58` | `meteora_damm_v1` | Parité upstream finale : pools, swaps, liquidity, lock, fees/admin. | +| 3 | `0.7.59` | `meteora_damm_v2` | Couverture complète : create/custom pools, swaps, liquidity, dynamic config, fees/admin. | +| 4 | `0.7.60` | `meteora_vault` | Vault deposit/withdraw/fee/accounting ; pas de candle directe. | +| 5 | `0.7.61+` | programmes transversaux | System/SPL/ATA/Compute/Memo/ALT : side effects et contexte, pas de trade direct. | + +La tranche `0.7.57 meteora_dlmm` doit partir d'une base neuve, réutiliser `fee_event_amounts`, ajouter une validation dédiée `validation_sql/SQL_VALIDATION_METEORA_DLMM_0_7_57.sql`, et clôturer uniquement si toutes les entrées IDL locales sont couvertes, décodées ou explicitement non observées avec tests synthétiques. ## 0.7.47-1FE5 — Décision de planification : ne plus viser “tous les events en une session” @@ -72,8 +73,8 @@ Exceptions : les comptes non-programmes (`platform_config`, token authority, com | `0.7.53` | `pump_swap` | `pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA` | Pump / AMM | **Clos** : `buy/sell/buy_exact_quote_in` matérialisés seulement depuis sources exactes ; events Anchor audit-only ; tests synthétiques IDL ; SQL global. | | `0.7.54` | `pump_fun` | `6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P` | Pump / launch-bonding | **Clos** : decoder maximal IDL/local, trades directs `buy/sell/buy_exact_sol_in`, v2/exact via `trade_event`, non-trades matérialisés selon contexte, validations Pump.fun propres. | | `0.7.55` | `pump_fees` | `pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ` | Pump / fee | **Clos** : `29` instructions, `20` events Anchor, fee/reward/admin/lifecycle, `get_fees` decoded-only, failed tx audit-only, aucun trade/candle direct. | -| `0.7.56` | `meteora_dbc` | `dbcij3LWUppWqq96dh6gJWwBifmcGfLSB5D4DuSMaqN` | Meteora / DBC | Compléter launch/bonding, swaps exploitables, migration, fees/admin/config. | -| `0.7.57` | `meteora_dlmm` | `LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo` | Meteora / DLMM | Parité upstream finale : swaps, bins, positions, liquidity, fees/rewards/admin. | +| `0.7.56` | `meteora_dbc` | `dbcij3LWUppWqq96dh6gJWwBifmcGfLSB5D4DuSMaqN` | Meteora / DBC | Clos : `28` instructions et `23` events Anchor couverts, swaps `swap/swap2`, lifecycle/admin/fees, `k_sol_fee_event_amounts`, validations SQL propres. | +| `0.7.57` | `meteora_dlmm` | `LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo` | Meteora / DLMM | Full decode + full materialization : `76` instructions IDL, `30` events Anchor, swaps, bins, positions, liquidity, fees/rewards/admin/limit-order, sans double-count. | | `0.7.58` | `meteora_damm_v1` | `Eo7WjKq67rjJQSZxS6z3YkapzY3eMj6Xy8X5EQVn5UaB` | Meteora / DAMM v1 | Parité upstream finale : pools, swaps, liquidity, lock, fees/admin. | | `0.7.59` | `meteora_damm_v2` | `cpamdpZCGKUy5JxQXB4dcpGPiikHawvSWAd6mEn1sGG` | Meteora / DAMM v2 | Couverture complète : create/custom pools, swaps, liquidity, dynamic config, fees/admin. | | `0.7.60` | `meteora_vault` | `24Uqj9JCLxUeoC3hGfh5W3s9FM9uCHDS2SG3LYwBpyTi` | Meteora / vault | Vault deposit/withdraw/fee/accounting ; pas de candle directe. | @@ -1439,7 +1440,7 @@ Les comptes non-programmes ne créent pas de tranche decoder autonome. `SOLSCAN_ | Version | Decoder / surface | Program id | Objectif | |---:|---|---|---| | `0.7.56` | `meteora_dbc` | `dbcij3LWUppWqq96dh6gJWwBifmcGfLSB5D4DuSMaqN` | Compléter toutes les instructions/events DBC : launch/bonding, swap exploitable, migration, fees/admin/config. | -| `0.7.57` | `meteora_dlmm` | `LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo` | Parité upstream finale : swaps, bins, positions, liquidity, fees/rewards/admin. | +| `0.7.57` | `meteora_dlmm` | `LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo` | Full decode + full materialization : `76` instructions IDL, `30` events Anchor, swaps, bins, positions, liquidity, fees/rewards/admin/limit-order, sans double-count. | | `0.7.58` | `meteora_damm_v1` | `Eo7WjKq67rjJQSZxS6z3YkapzY3eMj6Xy8X5EQVn5UaB` | Parité upstream finale : pools, swaps, liquidity, lock, fees/admin. | | `0.7.59` | `meteora_damm_v2` | `cpamdpZCGKUy5JxQXB4dcpGPiikHawvSWAd6mEn1sGG` | Couverture complète : create/custom pools, swaps, liquidity, dynamic config, fees/admin. | | `0.7.60` | `meteora_vault` | `24Uqj9JCLxUeoC3hGfh5W3s9FM9uCHDS2SG3LYwBpyTi` | Vault deposit/withdraw/fee/accounting ; pas de candle directe. | diff --git a/docs/DB_EVENT_MODEL_REVIEW.md b/docs/DB_EVENT_MODEL_REVIEW.md index fec53c0..fac59a9 100644 --- a/docs/DB_EVENT_MODEL_REVIEW.md +++ b/docs/DB_EVENT_MODEL_REVIEW.md @@ -2,6 +2,21 @@ # Database Event Model Review — `khadhroony-bobobot` `0.7.47-1FE5` + +## Note `0.7.56` — modèle fee parent + amount legs + +`0.7.56` ajoute `k_sol_fee_event_amounts` comme table enfant de `k_sol_fee_events`. Cette table est obligatoire dès qu'un event fee porte plusieurs montants, plusieurs mints ou plusieurs destinations. Le parent `k_sol_fee_events` reste l'ancre logique liée au `decoded_event_id`; les legs portent `leg_index`, `token_mint`, `amount_raw`, comptes source/destination et `amount_source`. + +Invariants : + +- tout parent fee avec `fee_token_mint + fee_amount_raw` doit avoir un leg scalaire automatique ; +- un fee multi-leg/multi-mint ne doit pas agréger artificiellement le parent ; +- les replays doivent supprimer/remplacer les legs avec le parent ; +- les transactions failed restent audit-only ; +- la recovery `allowlisted_inner_spl_transfer` est strictement allowlistée et ne s'applique pas par défaut aux futurs decoders. + +Le contrôle standard `parent scalar without leg` doit être vide sur toute base de validation. Voir `docs/reports/FEE_EVENT_AMOUNTS_MODEL_NOTE_0_7_56.md`. + ## Conclusion courte La base actuelle est **suffisante pour continuer le décodage exhaustif en audit-only**, parce que `k_sol_dex_decoded_events` garde les events décodés avec `payload_json`. diff --git a/docs/DEX_DECODER_MATRIX.md b/docs/DEX_DECODER_MATRIX.md index 3c0598d..26e79f7 100644 --- a/docs/DEX_DECODER_MATRIX.md +++ b/docs/DEX_DECODER_MATRIX.md @@ -1,15 +1,26 @@ -# DEX Decoder Matrix — `khadhroony-bobobot` `0.7.55 pump_fees closed` +# DEX Decoder Matrix — `khadhroony-bobobot` `0.7.56 meteora_dbc closed` + +## Note `0.7.56 closed` — Meteora DBC clôturé, DLMM ensuite + +La tranche `0.7.56` ferme `meteora_dbc` / `dbcij3LWUppWqq96dh6gJWwBifmcGfLSB5D4DuSMaqN` depuis l'IDL locale `idls/meteora_dbc.dbcij3LWUppWqq96dh6gJWwBifmcGfLSB5D4DuSMaqN.json`. + +Le decoder local couvre les `28` instructions et les `23` events Anchor IDL. Les trades/candles ne sont produits que depuis `swap` / `swap2` avec montants et mints fiables. Les migrations, lockers, lifecycle, admin/config et fees sont matérialisés quand le contexte est fiable ; sinon ils restent decoded-only/audit-only avec raison explicite. Les transactions failed restent audit-only. + +Le modèle fee est maintenant transversal : `k_sol_fee_events` reste le parent, `k_sol_fee_event_amounts` porte les legs. Les parents fees scalaires créent automatiquement un leg ; les fees multi-leg/multi-mint n'agrègent pas artificiellement le parent. La recovery `allowlisted_inner_spl_transfer` est allowlistée et ne s'applique jamais par défaut à un futur decoder. + +La prochaine tranche programmée est `0.7.57 meteora_dlmm` avec objectif full decode + full materialization sur `LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo`. + ## Note `0.7.55 closed` — Pump Fees clôturé, Meteora DBC ensuite La tranche `0.7.55` ferme `pump_fees` comme surface fee/config/accounting. Le decoder local couvre les `29` instructions et `20` events Anchor de l'IDL locale, avec tests synthétiques pour les Anchor events IDL non observés. Les transactions failed restent audit-only, `get_fees` reste decoded-only, et aucun trade/candle direct n'est créé. Deux discriminators Solscan non présents dans l'IDL locale restent conservés en coverage comme surfaces futures : `revoke_fee_sharing_authority_event` (`7217653c0ebe993e`) et `transfer_fee_sharing_authority_event` (`7c8fc6f54db808ec`). -La prochaine tranche programmée est `0.7.56 meteora_dbc`. +La tranche suivante après `0.7.55` a été `0.7.56 meteora_dbc`, désormais clôturée ; la prochaine tranche courante est `0.7.57 meteora_dlmm`. ## Note `0.7.54 closed` — Pump.fun clôturé @@ -69,8 +80,8 @@ Cette matrice complète `kb_lib/src/dex_support_matrix.rs`. Elle documente **ce | 7 | `pump_swap` | `supported / 0.7.53 closed` | `buy`, `sell` + `buy_exact_quote_in` matérialisable via `BuyEvent` exact ; instructions non-trade spécialisées : liquidity, fee/creator fee, admin/config, cashback/token incentives, volume accumulator ; events Anchor autonomes audit-only. | Trades/candles uniquement depuis montants exacts ; failed tx decoded-only ; `instruction_bounds_only` reste decoded-only ; tests synthétiques IDL et SQL global ajoutés. | | 8 | `pump_fun` | `supported / 0.7.54 closed` | Surface launch/bonding/migration Pump.fun couverte localement ; trades directs et `trade_event` canonique validés. | Ne rouvrir que pour bug prouvé ou changement externe. | | 9 | `pump_fees` | `supported / 0.7.55 closed` | Surface fee/config/accounting couverte localement : `29` instructions, `20` events Anchor, fee/reward/admin/lifecycle, tests synthétiques Anchor IDL non observés, failed tx audit-only. | Aucun trade/candle direct ; conserver les deux discriminators Solscan hors IDL comme futures surfaces non observées. | -| 10 | `meteora_dbc` | `partial / 0.7.56 planned` | Swaps/instruction audits observés ; Demo3 donne du corpus. | Couverture complète DBC : launch/bonding curve, swap, migration, config/admin, fees ; matérialiser seulement ce qui est prouvé. | -| 11 | `meteora_dlmm` | `supported / 0.7.57 parity` | Couverture avancée validée en `0.7.45` : swaps, liquidity, positions, lifecycle, fees ; non-trade matérialisé. | Résoudre les audits résiduels non mappés ; comparer Carbon/IDL pour events rewards/admin restants ; revalidation base neuve. | +| 10 | `meteora_dbc` | `supported / closed 0.7.56` | Decoder local maximal : `28` instructions, `23` events Anchor, swaps `swap/swap2`, lifecycle/admin/fees, `k_sol_fee_event_amounts`, validation SQL propre. | Ne pas rouvrir sauf bug prouvé ; préserver la policy fee parent+legs et la recovery allowlistée. | +| 11 | `meteora_dlmm` | `next / 0.7.57 full decode` | Couverture partielle historique validée en `0.7.45`; IDL locale `76` instructions / `30` events Anchor. | Reprendre depuis base neuve : full decode, full materialization fiable, fees/rewards via `fee_event_amounts`, no double-count avec events Anchor. | | 12 | `meteora_damm_v1` | `supported / 0.7.58 parity` | Couverture `0.7.46` : swap, create_pool, add/remove liquidity, claim_fee, create_lock_escrow, lock_liquidity. | Vérifier les surfaces upstream non observées ; améliorer rattachement pool/pair pour remove_liquidity non matérialisés ; revalidation stricte. | | 13 | `meteora_damm_v2` | `partial / 0.7.59 planned` | `swap`, `instruction_audit`, registry/discriminants et corpus Demo3 existent. | Couvrir tous les events Carbon/source : create pool, liquidity, fees, dynamic config, admin ; déterminer actionability des swaps ; matérialiser si montants fiables. | | 14 | `phoenix_v1` | `audit-only / 0.7.60 planned` | Decoder local audit-only ; `log_audit`, order place/cancel, withdraw ; parsing strict `0x0f`; events `Reduce`, `Place`, `TimeInForce` observés ; `trade_count=0`. | Terminer tous les events Git : `Fill`, `FillSummary`, `Fee`, `Evict`, `ExpiredOrder`, etc. ; ajouter counts/flags audit ; seulement ensuite étudier trade materialization. | @@ -325,3 +336,18 @@ La tranche a été validée sur base SQLite dédiée : tous les discriminants `0 | Decoder | Program id | Statut | Source discriminants | Couverture locale | Règles métier | |---|---|---:|---|---|---| | `pump_fees` | `pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ` | supported / `0.7.55 closed` | `idls/pump_fees.pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ.json` + Carbon partiel + Solscan discriminators | `29` instructions et `20` events Anchor couverts ; tests synthétiques pour les Anchor events IDL non observés ; replay final propre ; watchlist `pump_fees` vide | Aucun trade/candle direct ; `get_fees` decoded-only ; social claim vers reward ; donation/buyback vers fee ; config/authority/tier/admin vers admin/lifecycle ; failed tx audit-only ; deux events Solscan hors IDL conservés non observés. | + + +## 0.7.56 — Meteora DBC clôturé + +| Decoder | Program id | Statut | Source locale | Couverture | Décision métier | +|---|---|---|---|---|---| +| `meteora_dbc` | `dbcij3LWUppWqq96dh6gJWwBifmcGfLSB5D4DuSMaqN` | `supported / closed 0.7.56` | `idls/meteora_dbc.dbcij3LWUppWqq96dh6gJWwBifmcGfLSB5D4DuSMaqN.json` | `28` instructions IDL + `23` events Anchor IDL, tests synthétiques et validation SQL dédiée | `swap`/`swap2` seuls vers trade/candle direct ; lifecycle/admin/fee selon contexte ; fees parent+legs ; failed tx audit-only. | + +Validation finale : `446` tests, clippy OK, `480 replayed`, `264 trades`, `1 liquidity`, `122 lifecycle`, `1056 candles`, `89` parents fee DBC, `96` fee amount legs, invariants SQL propres. + +## 0.7.57 — Meteora DLMM prochain + +| Decoder | Program id | Statut | Source locale | Couverture attendue | Décision métier | +|---|---|---|---|---|---| +| `meteora_dlmm` | `LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo` | `next / full decode + full materialization` | `idls/meteora_dlmm.LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo.json` | `76` instructions IDL + `30` events Anchor IDL | Swaps/exact-out, liquidity/bin/position, fees/rewards/admin/config, limit order events ; no duplicate trade/candle ; `fee_event_amounts` obligatoire pour fees. | diff --git a/docs/DEX_EVENT_COVERAGE_MATRIX.md b/docs/DEX_EVENT_COVERAGE_MATRIX.md index bc6a531..c976644 100644 --- a/docs/DEX_EVENT_COVERAGE_MATRIX.md +++ b/docs/DEX_EVENT_COVERAGE_MATRIX.md @@ -309,3 +309,34 @@ Programme : `pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ`. Source locale priorit Invariants propres : fallback `pump_fees` vide, decoded sans coverage vide, successful non-materialized sans skip/policy vide, failed tx matérialisée vide, multi-target vide, anti-trade/candle direct vide, watchlist globale sans `pump_fees`. + + +## 0.7.56 — Meteora DBC clôturé + +Programme : `dbcij3LWUppWqq96dh6gJWwBifmcGfLSB5D4DuSMaqN`. Source locale prioritaire : `idls/meteora_dbc.dbcij3LWUppWqq96dh6gJWwBifmcGfLSB5D4DuSMaqN.json`. + +| Groupe | Entrées | Famille | Cible DB | Local event kind | Décision finale | +|---|---|---|---|---|---| +| swaps | `swap`, `swap2` | swap | `k_sol_trade_events` + candles | `meteora_dbc.swap`, `meteora_dbc.swap2` | Matérialisés seulement avec montants et mints base/quote fiables ; `swap2` validé via transferts CPI/amount inference. | +| virtual pool init | `initialize_virtual_pool_with_spl_token`, `initialize_virtual_pool_with_token2022`, `EvtInitializePool` | pool_create | `k_sol_pool_lifecycle_events` + catalog si contexte complet | `meteora_dbc.initialize_virtual_pool_with_*`, `meteora_dbc.evt_initialize_pool_event` | Lifecycle/catalog prudent ; failed tx audit-only. | +| migration / lockers | `migrate_meteora_damm*`, `migration_damm_v2*`, `create_locker`, `EvtCurveComplete` | migration / lifecycle | `k_sol_pool_lifecycle_events` | `meteora_dbc.migrate_*`, `meteora_dbc.migration_*`, `meteora_dbc.create_locker` | Lifecycle, pas liquidity artificielle pour LP claim/lock ; metadata-only decoded-only si contexte insuffisant. | +| fees/surplus/leftover | `claim_*fee*`, `withdraw_migration_fee`, `zap_protocol_fee`, `*_withdraw_surplus`, `withdraw_leftover`, events Anchor fee | fee | `k_sol_fee_events` + `k_sol_fee_event_amounts` | `meteora_dbc.*fee*`, `meteora_dbc.*surplus*`, `meteora_dbc.*leftover*` | Montants réels depuis CPI SPL ou lamport delta ; maxima d'instruction refusés ; multi-leg/multi-mint dans `k_sol_fee_event_amounts`. | +| admin/config/metadata/operator | `create_config`, `create_operator_account`, `close_*operator*`, metadata, `transfer_pool_creator`, config events | admin_config | `k_sol_pool_admin_events` ou decoded-only | `meteora_dbc.*config*`, `meteora_dbc.*operator*`, `meteora_dbc.*metadata*`, `meteora_dbc.transfer_pool_creator` | Admin si compte/acteur fiables ; payload metadata générique decoded-only avec raison. | +| Anchor swap events | `EvtSwap`, `EvtSwap2` | swap/audit | decoded-only sauf corrélation sûre | `meteora_dbc.evt_swap_event`, `meteora_dbc.evt_swap2_event` | Les events portent des montants mais pas toujours le contexte mint/pair ; pas de double-count avec l'instruction swap matérialisée. | + +Validation finale DBC : `89` parents fee, `96` legs fee amount, aucun parent scalaire sans leg, aucun leg orphelin, aucun decoded event local sans coverage, aucun failed tx matérialisé, aucun multi-target, aucun non-swap vers trade/candle. + +## 0.7.57 — Meteora DLMM à reprendre + +Programme : `LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo`. Source locale : `idls/meteora_dlmm.LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo.json`. + +| Groupe | Entrées IDL | Famille | Cible DB attendue | Décision à valider | +|---|---|---|---|---| +| swaps | `swap`, `swap2`, `swap_exact_out*`, `swap_with_price_impact*`, `Swap`, `Swap2Evt` | swap | `k_sol_trade_events` + candles | Montants exécutés uniquement ; éviter double-count instruction/event. | +| pools / bins | `initialize_*lb_pair*`, `initialize_bin_array*`, `close_bin_array`, `LbPairCreate` | lifecycle | `k_sol_pool_lifecycle_events`, catalog/pair | Catalog si comptes pool/mints/config fiables. | +| liquidity / positions | `add_liquidity*`, `remove_liquidity*`, `remove_all_liquidity`, `rebalance_liquidity`, position create/close/update events | liquidity/lifecycle | `k_sol_liquidity_events`, `k_sol_pool_lifecycle_events` | Montants x/y/bin/liquidity fiables ; sinon skip reason. | +| fees | `claim_fee*`, `withdraw_protocol_fee`, `zap_protocol_fee`, `ClaimFee*`, `CompositionFee` | fee | `k_sol_fee_events` + `k_sol_fee_event_amounts` | Utiliser le socle fee parent+legs ; policy recovery explicite, non globale. | +| rewards | `initialize_reward`, `fund_reward`, `claim_reward*`, `withdraw_ineligible_reward`, reward events | reward | `k_sol_reward_events` | Montant/mint/récompense fiable uniquement. | +| admin/config | fee parameters, pair status, activation, operator, token badge, preset parameter | admin_config | `k_sol_pool_admin_events` ou decoded-only | Matérialiser si acteur/compte cible fiable. | +| limit/order events | place/cancel/close limit order events | orderbook/audit | `k_sol_orderbook_events` si modèle fiable | Pas de candle directe sans fill exact. | + diff --git a/docs/VALIDATION_STATUS_0_7_56_FINAL.md b/docs/VALIDATION_STATUS_0_7_56_FINAL.md new file mode 100644 index 0000000..1e4a1a6 --- /dev/null +++ b/docs/VALIDATION_STATUS_0_7_56_FINAL.md @@ -0,0 +1,51 @@ + + +# Validation Status — `0.7.56 meteora_dbc final` + +## Build + +```text +cargo test -p kb_lib -> 446 passed / 0 failed +cargo clippy -p kb_lib --all-targets -- -D warnings -> OK +``` + +## Replay final DBC + +```text +480 replayed +0 decode skipped +480 ledger upserts +454 unsafe ledger rows +264 trades +1 liquidity +122 lifecycle +0 tokenAccount +1056 candle upserts +instructionObservations = 7167 +resetDeleted = 3583 +catalog = 86 tokens / 60 pools / 60 pairs +``` + +## Fee model final DBC + +```text +k_sol_fee_events meteora_dbc = 89 parents +k_sol_fee_event_amounts meteora_dbc = 96 legs +parent scalar without leg = empty +orphan fee amount legs = empty +allowlisted recovery on DBC = empty by design +``` + +## Cross-base fee recovery checks + +| Base | Result | +|---|---| +| `meteora_dbc` | stable, no regression, 89/96 fee parent/legs. | +| `raydium_launchpad` | allowlisted CPI recovery enriched all observed claim/collect fee parents in tested corpus. | +| `raydium_cpmm` | creator fee recovered; fund/protocol fee explicit no-transfer in tested corpus. | +| `pump_swap` | coin creator fee mostly recovered; zero/no-transfer cases explicit. | +| `pump_fees` | donation and sweep buyback recovered when CPI SPL transfers are present. | + +## Closure decision + +`0.7.56 meteora_dbc` is closed. Next tranche: `0.7.57 meteora_dlmm` full decode + full materialization. diff --git a/docs/prompts/PROMPT_0_7_56_METEORA_DBC.md b/docs/prompts/PROMPT_0_7_56_METEORA_DBC.md index a35895f..b3dde8c 100644 --- a/docs/prompts/PROMPT_0_7_56_METEORA_DBC.md +++ b/docs/prompts/PROMPT_0_7_56_METEORA_DBC.md @@ -1,3 +1,5 @@ + + # Prompt de reprise — khadhroony-bobobot 0.7.56 — meteora_dbc Tu reprends le workspace Rust/Tauri `khadhroony-bobobot` après clôture technique de `0.7.55 pump_fees`. @@ -210,7 +212,7 @@ Fournir un delta zip contenant uniquement les fichiers modifiés/ajoutés. Nom recommandé : ```text -khadhroony-bobobot-v0.7.56-meteora_dbc-delta-N-files.zip +khadhroony-bobobot-v0.7.56-meteora_dbc-delta-pre.xxx.zip ``` Inclure dans chaque livraison : résumé des changements, liste exacte des fichiers modifiés, commandes `cargo fmt`, `cargo test -p kb_lib`, `cargo clippy -p kb_lib --all-targets -- -D warnings`, replay recommandé, SQL à exécuter et résultats attendus. diff --git a/docs/prompts/PROMPT_0_7_57_METEORA_DLMM_FULL_DECODE_MATERIALIZATION.md b/docs/prompts/PROMPT_0_7_57_METEORA_DLMM_FULL_DECODE_MATERIALIZATION.md new file mode 100644 index 0000000..624b2e0 --- /dev/null +++ b/docs/prompts/PROMPT_0_7_57_METEORA_DLMM_FULL_DECODE_MATERIALIZATION.md @@ -0,0 +1,457 @@ + + +# Prompt de reprise — khadhroony-bobobot `0.7.57` — `meteora_dlmm` full decode / full materialization + +Tu reprends le workspace Rust/Tauri `khadhroony-bobobot` après clôture de `0.7.56 meteora_dbc`. + +## 1. Archive et fichiers à fournir + +Utiliser l'archive la plus récente après application des docs `0.7.56 final`. + +Fichiers à lire en priorité : + +- `README.md` ; +- `ROADMAP.md` ; +- `CHANGELOG.md` ; +- `docs/DEX_DECODER_MATRIX.md` ; +- `docs/DEX_EVENT_COVERAGE_MATRIX.md` ; +- `docs/reports/METEORA_DBC_EVENT_COVERAGE_REPORT.md` ; +- `docs/reports/FEE_EVENT_AMOUNTS_MODEL_NOTE_0_7_56.md` ; +- `docs/VALIDATION_STATUS_0_7_56_FINAL.md` ; +- `validation_sql/SQL_VALIDATION_METEORA_DBC_0_7_56.sql` ; +- `validation_sql/SQL_VALIDATION_METEORA_DLMM_0_7_57.sql` ; +- `idls/**`, en particulier `idls/meteora_dlmm.LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo.json` ; +- `kb_lib/src/dex/meteora_dlmm.rs` ; +- `kb_lib/src/non_trade_event_materialization.rs` ; +- `kb_lib/src/db/queries/fee_event_amount.rs`. + +Ne pas supposer que l'ancien support `0.7.45 meteora_dlmm` est suffisant : il était partiel. `0.7.57` doit viser la parité IDL/corpus complète. + +## 2. État validé avant cette version + +`0.7.56 meteora_dbc` est clos. + +Build final : + +```text +cargo test -p kb_lib -> 446 passed / 0 failed +cargo clippy -p kb_lib --all-targets -- -D warnings -> OK +``` + +Replay final DBC : + +```text +480 replayed +0 decode skipped +480 ledger upserts +454 unsafe ledger rows +264 trades +1 liquidity +122 lifecycle +1056 candle upserts +instructionObservations = 7167 +catalog = 86 tokens / 60 pools / 60 pairs +``` + +Socle fee final : + +```text +k_sol_fee_events meteora_dbc = 89 parents +k_sol_fee_event_amounts meteora_dbc = 96 legs +parent scalar without leg = empty +orphan fee amount legs = empty +``` + +Règles fee à préserver : + +- parent fee scalaire -> leg `k_sol_fee_event_amounts` automatique ; +- fee multi-leg/multi-mint -> parent sans agrégation artificielle, legs explicites ; +- pas de montant depuis `maxAmount`, `u64::MAX`, bornes ou limites de claim ; +- recovery CPI SPL générique uniquement si policy/allowlist explicite ; +- aucun futur decoder ne doit hériter de `allowlisted_inner_spl_transfer` par défaut. + +## 3. Objectif de `0.7.57 meteora_dlmm` + +Program id cible : + +```text +LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo +``` + +IDL locale prioritaire : + +```text +idls/meteora_dlmm.LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo.json +``` + +Nom IDL : `lb_clmm`. + +Surface IDL : + +```text +76 instructions +30 events Anchor +12 accounts +``` + +Objectif : **full decode + full materialization**. + +Règle forte : + +> Tout ce qui peut être décodé doit être décodé. Tout ce qui peut être matérialisé de façon fiable doit être matérialisé. Ce qui ne peut pas être matérialisé doit rester decoded-only/audit-only avec `skip*Reason` explicite. les instructions/events/anchors/discriminator non observés aprés backfill sur une base de donnée vierge devront avoir des tests syntetiques. + +## 4. Méthode obligatoire + +Créer une nouvelle DB dédiée à `0.7.57 meteora_dlmm`. + +Après chaque backfill ou patch decoder : + +```text +skipDexDecode=no +forceDexDecode=yes +deferInstructionObservations=yes +``` + +Puis : refresh catalog, replay local, SQL validation, note des compteurs. + +Ne pas rouvrir `meteora_dbc`, Pump ou Raydium sauf bug prouvé par SQL. + +## 5. Sources à comparer + +Comparer au minimum : + +- IDL locale `idls/meteora_dlmm.LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo.json` ; +- Carbon Meteora DLMM decoder ; +- Pinax/Substreams Solana IDLs ; +- Solana Streamer / sol-parser-sdk si DLMM y apparaît ; +- code local historique `kb_lib/src/dex/meteora_dlmm.rs` ; +- coverage existante `k_sol_dex_event_coverage_entries` ; +- corpus SQLite neuf ; +- anciens rapports `0.7.45` et matrices pour comprendre ce qui était déjà validé. + +## 6. Checklist d'instructions IDL à inventorier + +| Instruction | Discriminator hex | +|---|---| +| `add_liquidity` | `b59d59438fb63448` | +| `add_liquidity2` | `e4a24e1c46db7473` | +| `add_liquidity_by_strategy` | `0703967f94283dc8` | +| `add_liquidity_by_strategy2` | `03dd95da6f8d76d5` | +| `add_liquidity_by_strategy_one_side` | `2905eeaf64e106cd` | +| `add_liquidity_by_weight` | `1c8cee63e7a21595` | +| `add_liquidity_by_weight2` | `d13b3f5b6fc899e4` | +| `add_liquidity_one_side` | `5e9b6797465fdca5` | +| `add_liquidity_one_side_precise` | `a1c26754ab47fa9a` | +| `add_liquidity_one_side_precise2` | `2133a3c975627de7` | +| `cancel_limit_order` | `849c841f4328e861` | +| `claim_fee` | `a9204f8988e84689` | +| `claim_fee2` | `70bf65ab1c907fbb` | +| `claim_reward` | `955fb5f25e5a9ea2` | +| `claim_reward2` | `be037f77b2579db7` | +| `close_bin_array` | `44ae5850b5cc13e0` | +| `close_claim_fee_operator_account` | `b8d5581fb3658224` | +| `close_limit_order_if_empty` | `397c249b7ef95dab` | +| `close_operator_account` | `ab09d54a7817031d` | +| `close_position` | `7b86510031446262` | +| `close_position2` | `ae5a2373ba2893e2` | +| `close_position_if_empty` | `3b7cd4765b986e9d` | +| `close_preset_parameter` | `04949164861ab53d` | +| `close_preset_parameter2` | `27195f6b7411731c` | +| `close_token_badge` | `6c92566eb3fe0a68` | +| `create_operator_account` | `dd40f695f099e5a3` | +| `decrease_position_length` | `c2db882019606925` | +| `for_idl_type_generation_do_not_call` | `b46945505f32496c` | +| `fund_reward` | `bc32f9a55d97263f` | +| `go_to_a_bin` | `9248aee028fd54ae` | +| `increase_oracle_length` | `be3d7d57674f9ead` | +| `increase_position_length` | `505375d3420d2195` | +| `increase_position_length2` | `ffd2cc477389e171` | +| `initialize_bin_array` | `235613b94ed44bd3` | +| `initialize_bin_array_bitmap_extension` | `2f9de2b40cf02147` | +| `initialize_customizable_permissionless_lb_pair` | `2e2729876fb7c840` | +| `initialize_customizable_permissionless_lb_pair2` | `f349817e3313f16b` | +| `initialize_lb_pair` | `2d9aedd2dd0fa65c` | +| `initialize_lb_pair2` | `493b2478ed536cc6` | +| `initialize_permission_lb_pair` | `6c66d555fb033515` | +| `initialize_position` | `dbc0ea47bebf6650` | +| `initialize_position2` | `8f13f291d50f6873` | +| `initialize_position_by_operator` | `fbbdbef475fe2394` | +| `initialize_position_pda` | `2e527d92558de499` | +| `initialize_preset_parameter` | `42bc47d3626d0eba` | +| `initialize_reward` | `5f87c0c4f281e644` | +| `initialize_token_badge` | `fd4dcd5f1be059df` | +| `place_limit_order` | `6cb021ba92e501c5` | +| `rebalance_liquidity` | `5c04b0c177b95309` | +| `remove_all_liquidity` | `0a333d2370691855` | +| `remove_liquidity` | `5055d14818ceb16c` | +| `remove_liquidity2` | `e6d7527ff165e392` | +| `remove_liquidity_by_range` | `1a526698f04a691a` | +| `remove_liquidity_by_range2` | `cc02c391359191cd` | +| `set_activation_point` | `5bf90fa51a81fe7d` | +| `set_pair_status` | `43f8e7899a95d9ae` | +| `set_pair_status_permissionless` | `4e3b98d346b72ed0` | +| `set_permissionless_operation_bits` | `543acb8ba351beba` | +| `set_pre_activation_duration` | `a53dc9f4829f1664` | +| `set_pre_activation_swap_address` | `398b2f7bd850df0a` | +| `swap` | `f8c69e91e17587c8` | +| `swap2` | `414b3f4ceb5b5b88` | +| `swap_exact_out` | `fa49652126cf4bb8` | +| `swap_exact_out2` | `2bd7f784893cf351` | +| `swap_with_price_impact` | `38ade6d0ade49ccd` | +| `swap_with_price_impact2` | `4a62c0d6b1334b33` | +| `update_base_fee_parameters` | `4ba8dfa110c3032f` | +| `update_dynamic_fee_parameters` | `5ca12ef6ffbd1616` | +| `update_fees_and_reward2` | `208eb89a6741b858` | +| `update_fees_and_rewards` | `9ae6fa0decd14bdf` | +| `update_position_operator` | `cab8678fb4bf74d9` | +| `update_reward_duration` | `8aaec4a9d5ebfe6b` | +| `update_reward_funder` | `d31c3020d7a02317` | +| `withdraw_ineligible_reward` | `94ce2ac3f7316708` | +| `withdraw_protocol_fee` | `9ec99ebd215da267` | +| `zap_protocol_fee` | `d59bbb2238b65bf0` | + +## 7. Checklist d'events Anchor IDL à inventorier + +| Event | Discriminator hex | +|---|---| +| `AddLiquidity` | `1f5e7d5ae3343dba` | +| `CancelLimitOrderEvt` | `83eac285090ebdd1` | +| `ClaimFee` | `4b7a9a308c4a7ba3` | +| `ClaimFee2` | `e8abf2613a4d232d` | +| `ClaimReward` | `947486cc16ab555f` | +| `ClaimReward2` | `1b8ff421502b6e92` | +| `CloseLimitOrderEvt` | `8e87084c5c3f7653` | +| `CompositionFee` | `80977b6a1166718e` | +| `DecreasePositionLength` | `3476eb55aca90f80` | +| `DynamicFeeParameterUpdate` | `5858b287c2925bf3` | +| `FeeParameterUpdate` | `304cf17590d7f22c` | +| `FundReward` | `f6e43a8291aa4fcc` | +| `GoToABin` | `3b8a4c448a83b043` | +| `IncreaseObservation` | `63f91179a69ccfd7` | +| `IncreasePositionLength` | `9def2acc1e38df2e` | +| `InitializeReward` | `d399583e953cb146` | +| `LbPairCreate` | `b94afc7d1bd7bc6f` | +| `PlaceLimitOrderEvt` | `2b4f1ba9f41ce13f` | +| `PositionClose` | `ffc4106b1cca3580` | +| `PositionCreate` | `908efc549d352579` | +| `Rebalancing` | `006d75b33d5bc7c8` | +| `RemoveLiquidity` | `74f461e8671f983a` | +| `SetPositionPermissionlessOperationBitsEvt` | `c3e593f51d7d30a8` | +| `Swap` | `516ce3becdd00ac4` | +| `Swap2Evt` | `2e7452d7941b544d` | +| `UpdatePositionLockReleasePoint` | `85d642e0400c07bf` | +| `UpdatePositionOperator` | `277330ccf62f4239` | +| `UpdateRewardDuration` | `dff5e099311da3ac` | +| `UpdateRewardFunder` | `e0b2ae4afca555b4` | +| `WithdrawIneligibleReward` | `e7bd419566d79af4` | + +## 8. Matérialisation attendue + +### Swaps + +Cibles : + +```text +swap +swap2 +swap_exact_out +swap_exact_out2 +swap_with_price_impact +swap_with_price_impact2 +Swap +Swap2Evt +``` + +Décision : + +- `k_sol_trade_events` + candles uniquement si montants exécutés, sens, pool, token X/Y et mints sont fiables ; +- les events Anchor swap ne doivent pas double-compter une instruction swap déjà matérialisée ; +- exact-out et price-impact ne doivent pas utiliser des bornes comme montants exécutés ; +- si le contexte n'est pas fiable : `skipTradeReason` + `skipCandleReason`. + +### Liquidity / bins / positions + +Cibles : + +```text +add_liquidity* +remove_liquidity* +remove_all_liquidity +rebalance_liquidity +initialize_position* +close_position* +position create/close/update events +initialize_bin_array* +close_bin_array +``` + +Décision : + +- `k_sol_liquidity_events` quand les montants token X/Y, pool, position et acteur sont fiables ; +- lifecycle pour création/fermeture position/bin/pair ; +- skip reason explicite pour position/bin sans montants exploitables. + +### Pools / catalog + +Cibles : + +```text +initialize_lb_pair +initialize_lb_pair2 +initialize_permission_lb_pair +initialize_customizable_permissionless_lb_pair +initialize_customizable_permissionless_lb_pair2 +LbPairCreate +``` + +Décision : lifecycle/catalog/pool/pair si mints X/Y, pool, config/preset et comptes vault sont fiables. + +### Fees + +Cibles : + +```text +claim_fee +claim_fee2 +withdraw_protocol_fee +zap_protocol_fee +ClaimFee +ClaimFee2 +CompositionFee +``` + +Décision : + +- `k_sol_fee_events` + `k_sol_fee_event_amounts` obligatoires si montant/mint fiable ; +- utiliser parent scalaire + leg automatique pour mono-fee ; +- utiliser multi-leg sans agrégation parent si plusieurs mints/composants ; +- ne pas confondre composition fee inclus dans swap/liquidity avec claim fee matérialisable ; +- déclarer explicitement toute policy de recovery ; ne pas ajouter DLMM à l'allowlist générique sans preuve et tests. + +### Rewards + +Cibles : + +```text +initialize_reward +fund_reward +claim_reward +claim_reward2 +withdraw_ineligible_reward +ClaimReward +ClaimReward2 +FundReward +InitializeReward +WithdrawIneligibleReward +``` + +Décision : `k_sol_reward_events` si montant/mint/reward index fiables ; decoded-only sinon. + +### Admin/config/status/operator/token badge + +Cibles : + +```text +update_base_fee_parameters +update_dynamic_fee_parameters +update_fees_and_rewards +update_fees_and_reward2 +set_pair_status +set_pair_status_permissionless +set_activation_point +set_pre_activation_duration +set_pre_activation_swap_address +set_permissionless_operation_bits +create_operator_account +close_operator_account +close_claim_fee_operator_account +initialize_token_badge +close_token_badge +initialize_preset_parameter +close_preset_parameter +close_preset_parameter2 +``` + +Décision : `k_sol_pool_admin_events` si acteur/cible fiables ; decoded-only avec raison sinon. + +### Limit orders / orderbook-like events + +Cibles : + +```text +place_limit_order +cancel_limit_order +close_limit_order_if_empty +PlaceLimitOrderEvt +CancelLimitOrderEvt +CloseLimitOrderEvt +``` + +Décision : `k_sol_orderbook_events` si sémantique fiable ; pas de trade/candle sans fill exact. + +## 9. SQL de validation attendu + +Créer/mettre à jour : + +```text +validation_sql/SQL_VALIDATION_METEORA_DLMM_0_7_57.sql +``` + +Le fichier doit vérifier au minimum : + +1. fallback upstream DLMM ; +2. instruction observations DLMM ; +3. coverage DLMM ; +4. decoded DLMM sans coverage ; +5. successful non-materialized sans skip reason ; +6. failed tx materialization ; +7. multi-target materialization ; +8. trade/candle sur non-swap ; +9. fee parent/legs ; +10. orphan fee legs ; +11. reward/fee separation ; +12. orderbook/limit-order sans double-count ; +13. watchlist globale. + +## 10. Invariants de fermeture + +`0.7.57 meteora_dlmm` ne peut être clôturé que si : + +- les `76` instructions et `30` events Anchor IDL sont dans la coverage ou explicitement non observés avec tests synthétiques ; +- aucun fallback upstream DLMM ne reste pour les entrées couvertes localement ; +- aucun decoded DLMM local sans coverage ; +- aucune tx failed n'alimente une table métier ; +- aucun event multi-target incohérent ; +- aucune ligne successful non-materialized sans `skip*Reason` ou policy explicite ; +- aucun non-swap ne produit trade/candle ; +- aucun fee parent scalaire sans leg ; +- aucun leg fee orphelin ; +- aucun reward n'est classé fee par défaut ; +- aucun limit/order event ne produit une candle sans fill exact ; +- la watchlist globale ne contient plus de backlog dominant `meteora_dlmm`. + +## 11. Contraintes de code à respecter + +- Rust 2024 ; +- async-first ; +- tracing obligatoire ; +- pas de `?`, pas de `unwrap/expect` en production ; +- pas de `anyhow` / `thiserror` ; +- pas de `mod.rs` ; +- pas de `pub mod` : utiliser `mod` + `pub use` ; +- imports seulement pour les traits ; +- `#![deny(unreachable_pub)]`, `#![warn(missing_docs)]` ; +- tests offline ; +- pas de macro DB/coverage ; +- après modification DB : re-exports `kb_lib/src/db.rs` et `kb_lib/src/lib.rs` ; +- après modification decoder : vérifier `kb_lib/src/dex.rs`, `kb_lib/src/lib.rs`, coverage et tests synthétiques. + +## 12. Format de livraison attendu + +Livrer des deltas successifs : + +```text +khadhroony-bobobot-v0.7.57-meteora_dlmm-delta-pre.xxx.zip +``` + +Chaque réponse doit indiquer : fichiers modifiés, raisons, tests à lancer, SQL à exécuter, résultat attendu, et risques éventuels. diff --git a/docs/reports/FEE_EVENT_AMOUNTS_MODEL_NOTE_0_7_56.md b/docs/reports/FEE_EVENT_AMOUNTS_MODEL_NOTE_0_7_56.md new file mode 100644 index 0000000..ad2363c --- /dev/null +++ b/docs/reports/FEE_EVENT_AMOUNTS_MODEL_NOTE_0_7_56.md @@ -0,0 +1,151 @@ + + +# Note technique — `k_sol_fee_event_amounts` et policy fees — `0.7.56` + +## Objectif + +La table `k_sol_fee_events` était suffisante pour un fee mono-montant, mais insuffisante pour les cas multi-mint, multi-destination ou multi-composant. `0.7.56` ajoute donc `k_sol_fee_event_amounts` comme table de legs rattachée au parent fee. + +## Modèle + +```text +k_sol_fee_events + id + transaction_id + decoded_event_id + fee_token_mint nullable / vide si multi-leg + fee_amount_raw nullable / vide si multi-leg + payload_json + +k_sol_fee_event_amounts + id + fee_event_id + transaction_id + decoded_event_id + leg_index + fee_component_kind + token_mint + amount_raw + source_account + destination_account + amount_source + payload_json +``` + +## Règles invariantes + +1. Un parent fee scalaire avec `fee_token_mint + fee_amount_raw` doit toujours avoir un leg `0`. +2. Un parent multi-leg ou multi-mint ne doit pas agréger artificiellement ses montants dans le parent. +3. Les legs doivent pointer vers le même `transaction_id` et `decoded_event_id` que le parent. +4. Les legs doivent être supprimés/remplacés lors du replay/cleanup parent. +5. Les transactions failed ne doivent pas créer de parent ou leg fee métier. +6. Les bornes d'instruction (`maxAmount`, `minAmount`, `u64::MAX`) ne sont pas des montants exécutés. + +## Sources de montant acceptées + +| `amount_source` | Usage | +|---|---| +| `parent_fee_event_amount` | Leg généré automatiquement depuis un parent scalaire déjà fiable. | +| `fee_event_amounts` | Source explicite reconstruite par un matérialisateur spécialisé. | +| `inner_spl_transfer` | CPI SPL vérifié dans un matérialisateur spécialisé. | +| `lamport_balance_delta` | Delta lamports prouvé et contextualisé. | +| `allowlisted_inner_spl_transfer` | Recovery générique mais strictement allowlistée pour event kinds déjà validés. | + +## Recovery allowlistée + +La recovery `allowlisted_inner_spl_transfer` n'est pas une règle globale. Elle s'applique seulement aux event kinds explicitement autorisés dans le code. Cette restriction protège les futurs décodeurs : une nouvelle surface ne doit jamais créer des legs fee à partir de transferts internes tant que la sémantique n'a pas été inspectée. + +Résultats de validation croisée en `0.7.56` : + +| Base / surface | Effet observé | +|---|---| +| `meteora_dbc` | Aucun usage de l'allowlist générique ; chemins spécialisés DBC conservés. | +| `raydium_launchpad` | `212` parents enrichis en legs : `claim_creator_fee`, `claim_platform_fee`, `claim_platform_fee_from_vault`, `collect_fee`. | +| `raydium_cpmm` | `collect_creator_fee` enrichi ; `collect_fund_fee` / `collect_protocol_fee` explicités sans transfert exploitable. | +| `pump_swap` | `collect_coin_creator_fee` et un `transfer_creator_fees_to_pump_v2` enrichis ; autres cas zero/no-transfer explicités. | +| `pump_fees` | `crank_donation_fee_pda` et `sweep_buyback` enrichis ; events déjà scalaires conservés. | + +## Contrôles SQL obligatoires + +### Parent scalaire sans leg + +```sql +SELECT + de.protocol_name, + de.event_kind, + tx.signature, + fee.id AS fee_event_id, + fee.fee_token_mint, + fee.fee_amount_raw, + fee.payload_json +FROM k_sol_fee_events fee +JOIN k_sol_dex_decoded_events de + ON de.id = fee.decoded_event_id +JOIN k_sol_chain_transactions tx + ON tx.id = fee.transaction_id +LEFT JOIN k_sol_fee_event_amounts fea + ON fea.fee_event_id = fee.id +WHERE COALESCE(TRIM(fee.fee_token_mint), '') <> '' + AND COALESCE(TRIM(fee.fee_amount_raw), '') <> '' + AND fea.id IS NULL +ORDER BY + de.protocol_name, + de.event_kind, + tx.signature +LIMIT 100; +``` + +Attendu : vide. + +### Legs orphelins + +```sql +SELECT + fea.id, + fea.fee_event_id, + fea.transaction_id, + fea.decoded_event_id +FROM k_sol_fee_event_amounts fea +LEFT JOIN k_sol_fee_events fee + ON fee.id = fea.fee_event_id +WHERE fee.id IS NULL; +``` + +Attendu : vide. + +### Résumé parent/legs par event + +```sql +SELECT + de.protocol_name, + de.event_kind, + COUNT(DISTINCT fee.id) AS fee_parent_count, + COUNT(DISTINCT CASE + WHEN COALESCE(TRIM(fee.fee_token_mint), '') <> '' + AND COALESCE(TRIM(fee.fee_amount_raw), '') <> '' + THEN fee.id + ELSE NULL + END) AS parent_with_scalar_amount_count, + COUNT(DISTINCT fea.id) AS fee_amount_leg_count, + MIN(tx.signature) AS sample_signature +FROM k_sol_fee_events fee +JOIN k_sol_dex_decoded_events de + ON de.id = fee.decoded_event_id +JOIN k_sol_chain_transactions tx + ON tx.id = fee.transaction_id +LEFT JOIN k_sol_fee_event_amounts fea + ON fea.fee_event_id = fee.id +GROUP BY + de.protocol_name, + de.event_kind +ORDER BY + de.protocol_name, + de.event_kind; +``` + +## Règles pour `0.7.57 meteora_dlmm` + +- Les instructions `claim_fee`, `claim_fee2`, `withdraw_protocol_fee`, `zap_protocol_fee`, `CompositionFee`, `ClaimFee`, `ClaimFee2` doivent utiliser `k_sol_fee_event_amounts` dès qu'un montant/mint fiable est disponible. +- Les rewards (`claim_reward*`, `fund_reward`, `withdraw_ineligible_reward`) vont vers `k_sol_reward_events`, pas vers fees, sauf sémantique contraire prouvée. +- Les `composition_fee` de swap/liquidity ne doivent pas être double-comptés si déjà inclus dans le trade/liquidity effectif. +- Toute recovery générique doit être déclarée par policy explicite et tests synthétiques ; ne pas ajouter DLMM à l'allowlist sans audit par event kind. diff --git a/docs/reports/METEORA_DBC_EVENT_COVERAGE_REPORT.md b/docs/reports/METEORA_DBC_EVENT_COVERAGE_REPORT.md new file mode 100644 index 0000000..23bbba0 --- /dev/null +++ b/docs/reports/METEORA_DBC_EVENT_COVERAGE_REPORT.md @@ -0,0 +1,161 @@ + + +# Meteora DBC Event Coverage Report — `0.7.56 final` + +## Statut final + +La tranche `0.7.56 meteora_dbc` est clôturée. + +Program id : + +```text +dbcij3LWUppWqq96dh6gJWwBifmcGfLSB5D4DuSMaqN +``` + +Source locale prioritaire : + +```text +idls/meteora_dbc.dbcij3LWUppWqq96dh6gJWwBifmcGfLSB5D4DuSMaqN.json +``` + +Surface IDL : `28` instructions, `23` events Anchor, `9` accounts, `59` types. + +## Résultat build/replay final + +```text +cargo test -p kb_lib -> 446 passed / 0 failed +cargo clippy -p kb_lib --all-targets -- -D warnings -> OK +480 replayed +0 decode skipped +480 ledger upserts +454 unsafe ledger rows +264 trades +1 liquidity +122 lifecycle +0 tokenAccount +1056 candle upserts +instructionObservations = 7167 +resetDeleted = 3583 +catalog = 86 tokens / 60 pools / 60 pairs +``` + +Replay final recommandé pour reproduire : + +```text +skipDexDecode=no +forceDexDecode=yes +deferInstructionObservations=yes +``` + +## Décisions métier verrouillées + +### Swaps + +- `meteora_dbc.swap` et `meteora_dbc.swap2` sont les seules entrées candidates trade/candle directes. +- Les trades/candles ne sont produits que si les montants exécutés et les mints base/quote sont fiables. +- Les montants de `swap2` doivent être dérivés du layout et/ou des CPI SPL effectifs ; ne pas utiliser naïvement les bornes d'instruction. +- Les events Anchor `EvtSwap` / `EvtSwap2` restent decoded-only s'ils ne portent pas un contexte mint/pair suffisant ou s'ils doublonnent l'instruction matérialisée. + +### Lifecycle / migration / lockers + +- `initialize_virtual_pool_with_spl_token` et `initialize_virtual_pool_with_token2022` alimentent lifecycle/catalog lorsque les comptes pool/base/quote/config sont fiables. +- `create_locker`, `migrate_meteora_damm_claim_lp_token`, `migrate_meteora_damm_lock_lp_token`, `migration_damm_v2*` et `migrate_meteora_damm*` sont lifecycle, pas liquidity artificielle. +- Les metadata-only restent decoded-only avec raison explicite si elles n'apportent pas de cible métier fiable. + +### Admin/config + +- `create_config`, `create_operator_account`, `close_*operator*`, metadata, `transfer_pool_creator` et events config/admin alimentent `k_sol_pool_admin_events` uniquement si l'acteur et la cible sont fiables. +- Les payloads génériques ou incomplets restent audit/decoded-only. + +### Fees + +- Les fees DBC utilisent le modèle parent+legs : `k_sol_fee_events` + `k_sol_fee_event_amounts`. +- Les maxima d'instruction (`maxAmount*`, `u64::MAX`, bornes de claim) ne sont pas des montants exécutés. +- Les montants fiables proviennent des CPI SPL, des legs explicitement reconstruits ou des lamport balance deltas prouvés. +- Les events Anchor fee sans mint restent decoded-only avec `skipFeeReason`. +- Les cas sans transfert réel portent `fee_instruction_has_no_actual_transfer` ou `fee_instruction_has_only_zero_amount_transfers`. + +## Matérialisation fee finale + +| Event kind | Fee parents | Parents scalaires | Amount legs | Décision | +|---|---:|---:|---:|---| +| `meteora_dbc.claim_creator_trading_fee` | 8 | 8 | 8 | Mono-leg fiable. | +| `meteora_dbc.claim_partner_pool_creation_fee` | 10 | 10 | 10 | Mono-leg fiable. | +| `meteora_dbc.claim_protocol_fee` | 10 | 10 | 10 | Mono-leg fiable. | +| `meteora_dbc.claim_protocol_pool_creation_fee` | 10 | 10 | 10 | Mono-leg/lamport delta fiable. | +| `meteora_dbc.claim_trading_fee` | 11 | 6 | 18 | Mix mono-leg et multi-leg ; parent non agrégé pour multi-leg. | +| `meteora_dbc.creator_withdraw_surplus` | 2 | 2 | 2 | Mono-leg fiable ; résiduels sans transfert réel explicités. | +| `meteora_dbc.partner_withdraw_surplus` | 9 | 9 | 9 | Mono-leg fiable. | +| `meteora_dbc.withdraw_leftover` | 10 | 10 | 10 | Mono-leg fiable. | +| `meteora_dbc.withdraw_migration_fee` | 9 | 9 | 9 | Fee, pas migration target ; mono-leg fiable. | +| `meteora_dbc.zap_protocol_fee` | 10 | 10 | 10 | Mono-leg fiable. | +| **Total `meteora_dbc`** | **89** | n/a | **96** | Parent+legs validé. | + +## Socle `k_sol_fee_event_amounts` + +La version `0.7.56` ajoute un modèle durable pour les fees composés : + +- `k_sol_fee_events` est le parent logique unique lié au `decoded_event_id` ; +- `k_sol_fee_event_amounts` contient les legs de montants, avec `leg_index`, `fee_component_kind`, `token_mint`, `amount_raw`, comptes source/destination et `amount_source` ; +- un parent avec `fee_token_mint + fee_amount_raw` crée automatiquement un leg scalaire ; +- un parent multi-leg/multi-mint laisse les champs scalaires du parent vides et stocke tout dans les legs ; +- les deletes/replays nettoient les legs avant ou avec le parent ; +- la requête de contrôle `parent scalar without leg` doit rester vide. + +Sources `amount_source` connues en fin de tranche : + +```text +parent_fee_event_amount +fee_event_amounts +inner_spl_transfer +lamport_balance_delta +allowlisted_inner_spl_transfer +``` + +## Recovery fee allowlistée + +La recovery `allowlisted_inner_spl_transfer` est volontairement non globale. + +Elle a été testée sur anciennes bases pour enrichir les surfaces déjà connues : + +| Surface testée | Résultat | +|---|---| +| `raydium_launchpad` | `claim_creator_fee`, `claim_platform_fee`, `claim_platform_fee_from_vault`, `collect_fee` enrichis en legs depuis CPI SPL. | +| `raydium_cpmm` | `collect_creator_fee` enrichi ; `collect_fund_fee` et `collect_protocol_fee` restent sans transfert réel exploitable dans le corpus testé. | +| `pump_swap` | `collect_coin_creator_fee` et certains `transfer_creator_fees_to_pump_v2` enrichis ; cas zero/no-transfer explicités. | +| `pump_fees` | `crank_donation_fee_pda` et `sweep_buyback` enrichis ; events déjà scalaires conservés. | +| `meteora_dbc` | Non concerné par l'allowlist générique ; DBC conserve ses chemins spécifiques. | + +Règle pour les prochaines versions : tout nouveau decoder doit déclarer explicitement sa policy de récupération des montants fee. Aucun futur decoder ne doit hériter automatiquement de la recovery CPI SPL. + +## Checks de fermeture + +Les contrôles de fermeture exigés sont propres : + +- fallback `upstream_git` `meteora_dbc` pour entrées couvertes localement : vide ; +- decoded `meteora_dbc` sans coverage : vide ; +- successful non-materialized sans `skip*Reason` ou policy explicite : vide ; +- failed tx avec materialization métier : vide ; +- multi-target materialization : vide ; +- non-swap DBC vers trade/candle : vide ; +- parent fee scalaire sans leg : vide ; +- legs fee orphelins : vide ; +- watchlist globale sans backlog dominant `meteora_dbc`. + +## Fichiers de référence + +```text +kb_lib/src/dex/meteora_dbc.rs +kb_lib/src/non_trade_event_materialization.rs +kb_lib/src/db/queries/fee_event_amount.rs +kb_lib/src/db/entities/fee_event_amount.rs +kb_lib/src/db/dtos/fee_event_amount.rs +validation_sql/SQL_VALIDATION_METEORA_DBC_0_7_56.sql +docs/reports/FEE_EVENT_AMOUNTS_MODEL_NOTE_0_7_56.md +docs/VALIDATION_STATUS_0_7_56_FINAL.md +docs/prompts/PROMPT_0_7_57_METEORA_DLMM_FULL_DECODE_MATERIALIZATION.md +``` + +## Décision + +`0.7.56 meteora_dbc` est clôturé. La prochaine tranche est `0.7.57 meteora_dlmm` en full decode + full materialization. diff --git a/kb_demo_app/package.json b/kb_demo_app/package.json index 65952ae..09aa2d8 100644 --- a/kb_demo_app/package.json +++ b/kb_demo_app/package.json @@ -1,7 +1,7 @@ { "name": "kb-demo-app", "private": true, - "version": "0.7.55", + "version": "0.7.56", "type": "module", "scripts": { "dev": "vite", diff --git a/kb_demo_app/tauri.conf.json b/kb_demo_app/tauri.conf.json index 6ed9c5d..1564bcc 100644 --- a/kb_demo_app/tauri.conf.json +++ b/kb_demo_app/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "kb-demo-app", - "version": "0.7.55", + "version": "0.7.56", "identifier": "com.sasedev.kb-demo-app", "build": { "beforeDevCommand": "npm run dev", diff --git a/kb_lib/src/db.rs b/kb_lib/src/db.rs index 2e84bab..2b401b3 100644 --- a/kb_lib/src/db.rs +++ b/kb_lib/src/db.rs @@ -26,11 +26,14 @@ pub use dtos::DexDecodedEventDto; pub use dtos::DexDto; pub use dtos::DexEventCoverageEntryDto; pub use dtos::DexEventCoverageSummaryDto; +pub use dtos::FeeEventAmountDto; pub use dtos::FeeEventDto; pub use dtos::InstructionObservationDto; +pub use dtos::InstructionObservationSourceRow; pub use dtos::KnownHttpEndpointDto; pub use dtos::KnownWsEndpointDto; pub use dtos::LaunchAttributionDto; +pub use dtos::LaunchEventUpsertInput; pub use dtos::LaunchSurfaceDto; pub use dtos::LaunchSurfaceKeyDto; pub use dtos::LiquidityEventDto; @@ -98,6 +101,7 @@ pub use entities::DexDecodedEventEntity; pub use entities::DexEntity; pub use entities::DexEventCoverageEntryEntity; pub use entities::DexEventCoverageSummaryEntity; +pub use entities::FeeEventAmountEntity; pub use entities::FeeEventEntity; pub use entities::InstructionObservationEntity; pub use entities::KnownHttpEndpointEntity; @@ -160,17 +164,17 @@ pub use queries::query_dex_decoded_events_delete_by_key; pub use queries::query_dex_decoded_events_delete_instruction_audit_by_discriminator; pub use queries::query_dex_decoded_events_delete_local_replay_scope_by_transaction_id; pub use queries::query_dex_decoded_events_delete_locally_covered_upstream_instruction_matches; +pub use queries::query_dex_decoded_events_delete_meteora_dlmm_anchor_swap_instruction_audits; pub use queries::query_dex_decoded_events_delete_raydium_clmm_instruction_audit_by_discriminator; pub use queries::query_dex_decoded_events_delete_raydium_launchpad_anchor_self_cpi_audit; -pub use queries::query_dex_decoded_events_delete_replaced_raydium_cpmm_instruction_audits; -pub use queries::query_dex_decoded_events_delete_meteora_dlmm_anchor_swap_instruction_audits; pub use queries::query_dex_decoded_events_delete_related_instruction_audit; pub use queries::query_dex_decoded_events_delete_replaced_raydium_clmm_instruction_audits; +pub use queries::query_dex_decoded_events_delete_replaced_raydium_cpmm_instruction_audits; pub use queries::query_dex_decoded_events_get_by_key; pub use queries::query_dex_decoded_events_get_latest_pump_fun_create_payload_by_mint; pub use queries::query_dex_decoded_events_list_by_transaction_id; -pub use queries::query_dex_decoded_events_upsert; pub use queries::query_dex_decoded_events_update_payload_json_by_id; +pub use queries::query_dex_decoded_events_upsert; pub use queries::query_dex_event_coverage_entries_delete_by_decoder; pub use queries::query_dex_event_coverage_entries_list_by_decoder; pub use queries::query_dex_event_coverage_entries_list_summary_by_decoder; @@ -180,6 +184,11 @@ pub use queries::query_dex_event_coverage_entries_upsert; pub use queries::query_dexs_get_by_code; pub use queries::query_dexs_list; pub use queries::query_dexs_upsert; +pub use queries::query_fee_event_amounts_backfill_single_leg_from_fee_events; +pub use queries::query_fee_event_amounts_delete_by_fee_event_id; +pub use queries::query_fee_event_amounts_list_by_fee_event_id; +pub use queries::query_fee_event_amounts_replace_for_fee_event; +pub use queries::query_fee_events_upsert_with_amount_legs; pub use queries::query_fee_events_get_by_decoded_event_id; pub use queries::query_fee_events_list_recent; pub use queries::query_fee_events_upsert; @@ -187,7 +196,6 @@ pub use queries::query_instruction_observation_source_rows_list_by_signature; pub use queries::query_instruction_observation_source_rows_list_recent; pub use queries::query_instruction_observation_source_rows_list_replay_window; pub use queries::query_instruction_observations_delete_by_transaction_ids; -pub use dtos::InstructionObservationSourceRow; pub use queries::query_instruction_observations_list_by_filter; pub use queries::query_instruction_observations_upsert; pub use queries::query_known_http_endpoints_get; @@ -200,7 +208,6 @@ pub use queries::query_launch_attributions_get_by_decoded_event_id; pub use queries::query_launch_attributions_list_by_pool_id; pub use queries::query_launch_attributions_upsert; pub use queries::query_launch_events_upsert; -pub use dtos::LaunchEventUpsertInput; pub use queries::query_launch_surface_keys_get_by_match; pub use queries::query_launch_surface_keys_list_by_surface_id; pub use queries::query_launch_surface_keys_upsert; diff --git a/kb_lib/src/db/dtos.rs b/kb_lib/src/db/dtos.rs index c7b776b..c8b6309 100644 --- a/kb_lib/src/db/dtos.rs +++ b/kb_lib/src/db/dtos.rs @@ -13,6 +13,7 @@ mod dex_decode_replay_ledger; mod dex_decoded_event; mod dex_event_coverage_entry; mod fee_event; +mod fee_event_amount; mod instruction_observation; mod known_http_endpoint; mod known_ws_endpoint; @@ -88,6 +89,7 @@ pub use dex_decoded_event::DexDecodedEventDto; pub use dex_event_coverage_entry::DexEventCoverageEntryDto; pub use dex_event_coverage_entry::DexEventCoverageSummaryDto; pub use fee_event::FeeEventDto; +pub use fee_event_amount::FeeEventAmountDto; pub use instruction_observation::InstructionObservationDto; pub use instruction_observation::InstructionObservationSourceRow; pub use known_http_endpoint::KnownHttpEndpointDto; diff --git a/kb_lib/src/db/dtos/fee_event_amount.rs b/kb_lib/src/db/dtos/fee_event_amount.rs new file mode 100644 index 0000000..0b6c19c --- /dev/null +++ b/kb_lib/src/db/dtos/fee_event_amount.rs @@ -0,0 +1,110 @@ +// file: kb_lib/src/db/dtos/fee_event_amount.rs + +//! Fee event amount DTO. + +/// Application-facing normalized fee event amount leg DTO. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct FeeEventAmountDto { + /// Optional numeric primary key. + pub id: std::option::Option, + /// Related parent fee event id. + pub fee_event_id: i64, + /// Related transaction id. + pub transaction_id: i64, + /// Related decoded DEX event id, when available. + pub decoded_event_id: std::option::Option, + /// Stable leg index within one parent fee event. + pub leg_index: u32, + /// Fee component kind or semantic role. + pub fee_component_kind: std::string::String, + /// Token mint used by this fee amount leg. + pub token_mint: std::string::String, + /// Raw amount for this leg as decimal text. + pub amount_raw: std::string::String, + /// Source token account or lamport account, when decoded. + pub source_account: std::option::Option, + /// Destination token account or lamport account, when decoded. + pub destination_account: std::option::Option, + /// Extraction source used to prove this amount. + pub amount_source: std::string::String, + /// Source/proof payload JSON. + pub payload_json: std::string::String, + /// Creation timestamp. + pub created_at: chrono::DateTime, +} + +impl FeeEventAmountDto { + /// Creates a new fee event amount DTO. + #[allow(clippy::too_many_arguments)] + pub fn new( + fee_event_id: i64, + transaction_id: i64, + decoded_event_id: std::option::Option, + leg_index: u32, + fee_component_kind: std::string::String, + token_mint: std::string::String, + amount_raw: std::string::String, + source_account: std::option::Option, + destination_account: std::option::Option, + amount_source: std::string::String, + payload_json: std::string::String, + ) -> Self { + return Self { + id: None, + fee_event_id, + transaction_id, + decoded_event_id, + leg_index, + fee_component_kind, + token_mint, + amount_raw, + source_account, + destination_account, + amount_source, + payload_json, + created_at: chrono::Utc::now(), + }; + } +} + +impl TryFrom for FeeEventAmountDto { + type Error = crate::Error; + + fn try_from(entity: crate::FeeEventAmountEntity) -> Result { + let leg_index_result = u32::try_from(entity.leg_index); + let leg_index = match leg_index_result { + Ok(leg_index) => leg_index, + Err(error) => { + return Err(crate::Error::Db(format!( + "cannot convert fee event amount leg_index '{}' to u32: {}", + entity.leg_index, error + ))); + }, + }; + let created_at_result = chrono::DateTime::parse_from_rfc3339(&entity.created_at); + let created_at = match created_at_result { + Ok(created_at) => created_at.with_timezone(&chrono::Utc), + Err(error) => { + return Err(crate::Error::Db(format!( + "cannot parse fee event amount created_at '{}': {}", + entity.created_at, error + ))); + }, + }; + return Ok(Self { + id: Some(entity.id), + fee_event_id: entity.fee_event_id, + transaction_id: entity.transaction_id, + decoded_event_id: entity.decoded_event_id, + leg_index, + fee_component_kind: entity.fee_component_kind, + token_mint: entity.token_mint, + amount_raw: entity.amount_raw, + source_account: entity.source_account, + destination_account: entity.destination_account, + amount_source: entity.amount_source, + payload_json: entity.payload_json, + created_at, + }); + } +} diff --git a/kb_lib/src/db/entities.rs b/kb_lib/src/db/entities.rs index 286835c..992c654 100644 --- a/kb_lib/src/db/entities.rs +++ b/kb_lib/src/db/entities.rs @@ -15,6 +15,7 @@ mod dex_decode_replay_ledger; mod dex_decoded_event; mod dex_event_coverage_entry; mod fee_event; +mod fee_event_amount; mod known_http_endpoint; mod known_ws_endpoint; mod instruction_observation; @@ -63,6 +64,7 @@ pub use dex_decoded_event::DexDecodedEventEntity; pub use dex_event_coverage_entry::DexEventCoverageEntryEntity; pub use dex_event_coverage_entry::DexEventCoverageSummaryEntity; pub use fee_event::FeeEventEntity; +pub use fee_event_amount::FeeEventAmountEntity; pub use known_http_endpoint::KnownHttpEndpointEntity; pub use known_ws_endpoint::KnownWsEndpointEntity; pub use instruction_observation::InstructionObservationEntity; diff --git a/kb_lib/src/db/entities/fee_event_amount.rs b/kb_lib/src/db/entities/fee_event_amount.rs new file mode 100644 index 0000000..524569b --- /dev/null +++ b/kb_lib/src/db/entities/fee_event_amount.rs @@ -0,0 +1,34 @@ +// file: kb_lib/src/db/entities/fee_event_amount.rs + +//! Fee event amount entity. + +/// Persisted normalized fee event amount leg row. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow)] +pub struct FeeEventAmountEntity { + /// Numeric primary key. + pub id: i64, + /// Related parent fee event id. + pub fee_event_id: i64, + /// Related transaction id. + pub transaction_id: i64, + /// Related decoded DEX event id, when available. + pub decoded_event_id: std::option::Option, + /// Stable leg index within one parent fee event. + pub leg_index: i64, + /// Fee component kind or semantic role. + pub fee_component_kind: std::string::String, + /// Token mint used by this fee amount leg. + pub token_mint: std::string::String, + /// Raw amount for this leg as decimal text. + pub amount_raw: std::string::String, + /// Source token account or lamport account, when decoded. + pub source_account: std::option::Option, + /// Destination token account or lamport account, when decoded. + pub destination_account: std::option::Option, + /// Extraction source used to prove this amount. + pub amount_source: std::string::String, + /// Source/proof payload JSON. + pub payload_json: std::string::String, + /// Creation timestamp encoded as RFC3339 UTC text. + pub created_at: std::string::String, +} diff --git a/kb_lib/src/db/queries.rs b/kb_lib/src/db/queries.rs index fb2e245..8c32a08 100644 --- a/kb_lib/src/db/queries.rs +++ b/kb_lib/src/db/queries.rs @@ -13,6 +13,7 @@ mod dex_decode_replay_ledger; mod dex_decoded_event; mod dex_event_coverage_entry; mod fee_event; +mod fee_event_amount; mod instruction_observation; mod known_http_endpoint; mod known_ws_endpoint; @@ -99,6 +100,11 @@ pub use dex_event_coverage_entry::query_dex_event_coverage_entries_upsert; pub use fee_event::query_fee_events_get_by_decoded_event_id; pub use fee_event::query_fee_events_list_recent; pub use fee_event::query_fee_events_upsert; +pub use fee_event_amount::query_fee_event_amounts_backfill_single_leg_from_fee_events; +pub use fee_event_amount::query_fee_event_amounts_delete_by_fee_event_id; +pub use fee_event_amount::query_fee_event_amounts_list_by_fee_event_id; +pub use fee_event_amount::query_fee_event_amounts_replace_for_fee_event; +pub use fee_event_amount::query_fee_events_upsert_with_amount_legs; pub use instruction_observation::query_instruction_observation_source_rows_list_by_signature; pub use instruction_observation::query_instruction_observation_source_rows_list_recent; pub use instruction_observation::query_instruction_observation_source_rows_list_replay_window; diff --git a/kb_lib/src/db/queries/dex_decoded_event.rs b/kb_lib/src/db/queries/dex_decoded_event.rs index 65c0981..5f8c184 100644 --- a/kb_lib/src/db/queries/dex_decoded_event.rs +++ b/kb_lib/src/db/queries/dex_decoded_event.rs @@ -136,6 +136,10 @@ WHERE decoded_event_id IN ( "k_sol_pool_lifecycle_events", "DELETE FROM k_sol_pool_lifecycle_events WHERE transaction_id = ?", ), + ( + "k_sol_fee_event_amounts", + "DELETE FROM k_sol_fee_event_amounts WHERE transaction_id = ?", + ), ("k_sol_fee_events", "DELETE FROM k_sol_fee_events WHERE transaction_id = ?"), ( "k_sol_reward_events", diff --git a/kb_lib/src/db/queries/fee_event.rs b/kb_lib/src/db/queries/fee_event.rs index b6038c7..54fea83 100644 --- a/kb_lib/src/db/queries/fee_event.rs +++ b/kb_lib/src/db/queries/fee_event.rs @@ -156,6 +156,10 @@ WHERE id = ? id, error ))); } + let leg_result = query_fee_events_replace_scalar_amount_leg(database, id, dto).await; + if let Err(error) = leg_result { + return Err(error); + } return Ok(id); } let insert_result = sqlx::query( @@ -227,7 +231,13 @@ LIMIT 1 .fetch_one(pool) .await; match id_result { - Ok(id) => return Ok(id), + Ok(id) => { + let leg_result = query_fee_events_replace_scalar_amount_leg(database, id, dto).await; + if let Err(error) = leg_result { + return Err(error); + } + return Ok(id); + }, Err(error) => { return Err(crate::Error::Db(format!( "cannot fetch inserted k_sol_fee_events id for signature '{}' on sqlite: {}", @@ -239,6 +249,88 @@ LIMIT 1 } } +async fn query_fee_events_replace_scalar_amount_leg( + database: &crate::Database, + fee_event_id: i64, + dto: &crate::FeeEventDto, +) -> Result<(), crate::Error> { + let token_mint = match dto.fee_token_mint.as_ref() { + Some(token_mint) => { + if token_mint.trim().is_empty() { + return Ok(()); + } + token_mint.clone() + }, + None => return Ok(()), + }; + let amount_raw = match dto.fee_amount_raw.as_ref() { + Some(amount_raw) => { + if amount_raw.trim().is_empty() { + return Ok(()); + } + amount_raw.clone() + }, + None => return Ok(()), + }; + let payload_json = scalar_fee_event_amount_payload_json(dto, token_mint.as_str(), amount_raw.as_str()); + let amount_leg = crate::FeeEventAmountDto::new( + fee_event_id, + dto.transaction_id, + dto.decoded_event_id, + 0, + dto.event_kind.clone(), + token_mint, + amount_raw, + None, + None, + "parent_fee_event_amount".to_string(), + payload_json, + ); + let replace_result = crate::query_fee_event_amounts_replace_for_fee_event( + database, + fee_event_id, + &[amount_leg], + ) + .await; + match replace_result { + Ok(_) => return Ok(()), + Err(error) => return Err(error), + } +} + +fn scalar_fee_event_amount_payload_json( + dto: &crate::FeeEventDto, + token_mint: &str, + amount_raw: &str, +) -> std::string::String { + let mut object = serde_json::Map::new(); + object.insert( + "decodedEventKind".to_string(), + serde_json::Value::String(dto.event_kind.clone()), + ); + object.insert( + "protocolName".to_string(), + serde_json::Value::String(dto.protocol_name.clone()), + ); + object.insert( + "legIndex".to_string(), + serde_json::Value::Number(serde_json::Number::from(0_u64)), + ); + object.insert( + "tokenMint".to_string(), + serde_json::Value::String(token_mint.to_string()), + ); + object.insert( + "amountRaw".to_string(), + serde_json::Value::String(amount_raw.to_string()), + ); + object.insert( + "amountSource".to_string(), + serde_json::Value::String("parent_fee_event_amount".to_string()), + ); + return serde_json::Value::Object(object).to_string(); +} + /// Lists recent fee events ordered from newest to oldest. pub async fn query_fee_events_list_recent( database: &crate::Database, diff --git a/kb_lib/src/db/queries/fee_event_amount.rs b/kb_lib/src/db/queries/fee_event_amount.rs new file mode 100644 index 0000000..c7402e5 --- /dev/null +++ b/kb_lib/src/db/queries/fee_event_amount.rs @@ -0,0 +1,677 @@ +// file: kb_lib/src/db/queries/fee_event_amount.rs + +//! Queries for `k_sol_fee_event_amounts`. + +/// Deletes fee amount legs for one parent fee event. +pub async fn query_fee_event_amounts_delete_by_fee_event_id( + database: &crate::Database, + fee_event_id: i64, +) -> Result { + match database.connection() { + crate::DatabaseConnection::Sqlite(pool) => { + let delete_result = sqlx::query( + r#" +DELETE FROM k_sol_fee_event_amounts +WHERE fee_event_id = ? + "#, + ) + .bind(fee_event_id) + .execute(pool) + .await; + match delete_result { + Ok(result) => return Ok(result.rows_affected()), + Err(error) => { + return Err(crate::Error::Db(format!( + "cannot delete k_sol_fee_event_amounts for fee_event_id '{}' on sqlite: {}", + fee_event_id, error + ))); + }, + } + }, + } +} + +/// Replaces all fee amount legs for one parent fee event. +pub async fn query_fee_event_amounts_replace_for_fee_event( + database: &crate::Database, + fee_event_id: i64, + dtos: &[crate::FeeEventAmountDto], +) -> Result { + let delete_result = + query_fee_event_amounts_delete_by_fee_event_id(database, fee_event_id).await; + if let Err(error) = delete_result { + return Err(error); + } + let mut inserted: u64 = 0; + match database.connection() { + crate::DatabaseConnection::Sqlite(pool) => { + for dto in dtos { + let leg_index_i64 = i64::from(dto.leg_index); + let insert_result = sqlx::query( + r#" +INSERT INTO k_sol_fee_event_amounts ( + fee_event_id, + transaction_id, + decoded_event_id, + leg_index, + fee_component_kind, + token_mint, + amount_raw, + source_account, + destination_account, + amount_source, + payload_json, + created_at +) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "#, + ) + .bind(dto.fee_event_id) + .bind(dto.transaction_id) + .bind(dto.decoded_event_id) + .bind(leg_index_i64) + .bind(dto.fee_component_kind.clone()) + .bind(dto.token_mint.clone()) + .bind(dto.amount_raw.clone()) + .bind(dto.source_account.clone()) + .bind(dto.destination_account.clone()) + .bind(dto.amount_source.clone()) + .bind(dto.payload_json.clone()) + .bind(dto.created_at.to_rfc3339()) + .execute(pool) + .await; + match insert_result { + Ok(_) => inserted += 1, + Err(error) => { + return Err(crate::Error::Db(format!( + "cannot insert k_sol_fee_event_amounts leg '{}' for fee_event_id '{}' on sqlite: {}", + dto.leg_index, fee_event_id, error + ))); + }, + } + } + }, + } + return Ok(inserted); +} + +/// Lists fee amount legs for one parent fee event. +pub async fn query_fee_event_amounts_list_by_fee_event_id( + database: &crate::Database, + fee_event_id: i64, +) -> Result, crate::Error> { + match database.connection() { + crate::DatabaseConnection::Sqlite(pool) => { + let query_result = sqlx::query_as::( + r#" +SELECT + id, + fee_event_id, + transaction_id, + decoded_event_id, + leg_index, + fee_component_kind, + token_mint, + amount_raw, + source_account, + destination_account, + amount_source, + payload_json, + created_at +FROM k_sol_fee_event_amounts +WHERE fee_event_id = ? +ORDER BY leg_index ASC + "#, + ) + .bind(fee_event_id) + .fetch_all(pool) + .await; + let entities = match query_result { + Ok(entities) => entities, + Err(error) => { + return Err(crate::Error::Db(format!( + "cannot list k_sol_fee_event_amounts for fee_event_id '{}' on sqlite: {}", + fee_event_id, error + ))); + }, + }; + let mut dtos = std::vec::Vec::new(); + for entity in entities { + let dto_result = crate::FeeEventAmountDto::try_from(entity); + match dto_result { + Ok(dto) => dtos.push(dto), + Err(error) => return Err(error), + } + } + return Ok(dtos); + }, + } +} + +/// Inserts or updates a fee parent and atomically replaces its amount legs. +pub async fn query_fee_events_upsert_with_amount_legs( + database: &crate::Database, + fee_event: &crate::FeeEventDto, + amount_legs: &[crate::FeeEventAmountDto], +) -> Result { + let fee_event_id_result = crate::query_fee_events_upsert(database, fee_event).await; + let fee_event_id = match fee_event_id_result { + Ok(fee_event_id) => fee_event_id, + Err(error) => return Err(error), + }; + let mut normalized_legs = std::vec::Vec::new(); + for amount_leg in amount_legs { + let mut normalized_leg = amount_leg.clone(); + normalized_leg.fee_event_id = fee_event_id; + normalized_leg.transaction_id = fee_event.transaction_id; + normalized_leg.decoded_event_id = fee_event.decoded_event_id; + normalized_legs.push(normalized_leg); + } + let replace_result = query_fee_event_amounts_replace_for_fee_event( + database, + fee_event_id, + normalized_legs.as_slice(), + ) + .await; + if let Err(error) = replace_result { + return Err(error); + } + return Ok(fee_event_id); +} + +/// Backfills one amount leg for existing fee parents that expose one scalar amount. +pub async fn query_fee_event_amounts_backfill_single_leg_from_fee_events( + database: &crate::Database, +) -> Result { + let entities = match database.connection() { + crate::DatabaseConnection::Sqlite(pool) => { + let query_result = sqlx::query_as::( + r#" +SELECT + f.id, + f.transaction_id, + f.decoded_event_id, + f.dex_id, + f.pool_id, + f.pair_id, + f.signature, + f.slot, + f.protocol_name, + f.program_id, + f.event_kind, + f.pool_account, + f.actor_wallet, + f.fee_token_mint, + f.fee_amount_raw, + f.payload_json, + f.executed_at, + f.created_at +FROM k_sol_fee_events f +WHERE f.fee_token_mint IS NOT NULL + AND TRIM(f.fee_token_mint) <> '' + AND f.fee_amount_raw IS NOT NULL + AND TRIM(f.fee_amount_raw) <> '' + AND NOT EXISTS ( + SELECT 1 + FROM k_sol_fee_event_amounts a + WHERE a.fee_event_id = f.id + LIMIT 1 + ) +ORDER BY f.id ASC + "#, + ) + .fetch_all(pool) + .await; + match query_result { + Ok(entities) => entities, + Err(error) => { + return Err(crate::Error::Db(format!( + "cannot list fee events for single-leg amount backfill on sqlite: {}", + error + ))); + }, + } + }, + }; + let mut backfilled: u64 = 0; + for entity in entities { + let dto_result = crate::FeeEventDto::try_from(entity); + let dto = match dto_result { + Ok(dto) => dto, + Err(error) => return Err(error), + }; + let fee_event_id = match dto.id { + Some(id) => id, + None => { + return Err(crate::Error::InvalidState( + "fee event backfill row does not contain an id".to_string(), + )); + }, + }; + let token_mint = match dto.fee_token_mint.as_ref() { + Some(token_mint) => token_mint.clone(), + None => continue, + }; + let amount_raw = match dto.fee_amount_raw.as_ref() { + Some(amount_raw) => amount_raw.clone(), + None => continue, + }; + let payload_json = single_fee_event_amount_backfill_payload_json( + &dto, + token_mint.as_str(), + amount_raw.as_str(), + ); + let amount_leg = crate::FeeEventAmountDto::new( + fee_event_id, + dto.transaction_id, + dto.decoded_event_id, + 0, + dto.event_kind.clone(), + token_mint, + amount_raw, + None, + None, + "parent_fee_event_backfill".to_string(), + payload_json, + ); + let replace_result = + query_fee_event_amounts_replace_for_fee_event(database, fee_event_id, &[amount_leg]) + .await; + match replace_result { + Ok(inserted) => backfilled += inserted, + Err(error) => return Err(error), + } + } + return Ok(backfilled); +} + +fn single_fee_event_amount_backfill_payload_json( + dto: &crate::FeeEventDto, + token_mint: &str, + amount_raw: &str, +) -> std::string::String { + let mut object = serde_json::Map::new(); + object.insert( + "decodedEventKind".to_string(), + serde_json::Value::String(dto.event_kind.clone()), + ); + object.insert("protocolName".to_string(), serde_json::Value::String(dto.protocol_name.clone())); + object.insert( + "legIndex".to_string(), + serde_json::Value::Number(serde_json::Number::from(0_u64)), + ); + object.insert("tokenMint".to_string(), serde_json::Value::String(token_mint.to_string())); + object.insert("amountRaw".to_string(), serde_json::Value::String(amount_raw.to_string())); + object.insert( + "amountSource".to_string(), + serde_json::Value::String("parent_fee_event_backfill".to_string()), + ); + return serde_json::Value::Object(object).to_string(); +} + +#[cfg(test)] +mod tests { + async fn make_database() -> crate::Database { + let tempdir_result = tempfile::tempdir(); + let tempdir = match tempdir_result { + Ok(tempdir) => tempdir, + Err(error) => panic!("tempdir must succeed: {}", error), + }; + let database_path = tempdir.path().join("fee_event_amount.sqlite3"); + let config = crate::DatabaseConfig { + enabled: true, + backend: crate::DatabaseBackend::Sqlite, + sqlite: crate::SqliteDatabaseConfig { + path: database_path.to_string_lossy().to_string(), + create_if_missing: true, + busy_timeout_ms: 5000, + max_connections: 1, + auto_initialize_schema: true, + use_wal: true, + }, + }; + let database_result = crate::Database::connect_and_initialize(&config).await; + match database_result { + Ok(database) => return database, + Err(error) => panic!("database init must succeed: {}", error), + } + } + + fn make_leg( + fee_event_id: i64, + leg_index: u32, + token_mint: &str, + amount_raw: &str, + ) -> crate::FeeEventAmountDto { + return crate::FeeEventAmountDto::new( + fee_event_id, + 7001, + Some(8001), + leg_index, + "trading_fee".to_string(), + token_mint.to_string(), + amount_raw.to_string(), + Some(format!("Source{}", leg_index)), + Some(format!("Destination{}", leg_index)), + "inner_spl_transfer".to_string(), + serde_json::json!({ + "test": true, + "legIndex": leg_index + }) + .to_string(), + ); + } + + #[tokio::test] + async fn fee_event_amounts_replace_roundtrip_supports_multiple_legs() { + let database = make_database().await; + let fee_event_id = 501; + let legs = vec![ + make_leg(fee_event_id, 0, crate::WSOL_MINT_ID, "100"), + make_leg(fee_event_id, 1, "TokenMint111", "200"), + ]; + let replace_result = + crate::query_fee_event_amounts_replace_for_fee_event(&database, fee_event_id, &legs) + .await; + let inserted = match replace_result { + Ok(inserted) => inserted, + Err(error) => panic!("replace must succeed: {}", error), + }; + assert_eq!(inserted, 2); + + let list_result = + crate::query_fee_event_amounts_list_by_fee_event_id(&database, fee_event_id).await; + let listed = match list_result { + Ok(listed) => listed, + Err(error) => panic!("list must succeed: {}", error), + }; + assert_eq!(listed.len(), 2); + assert_eq!(listed[0].leg_index, 0); + assert_eq!(listed[0].token_mint, crate::WSOL_MINT_ID); + assert_eq!(listed[0].amount_raw, "100"); + assert_eq!(listed[1].leg_index, 1); + assert_eq!(listed[1].token_mint, "TokenMint111"); + assert_eq!(listed[1].amount_raw, "200"); + } + + #[tokio::test] + async fn fee_event_amounts_replace_deletes_stale_legs() { + let database = make_database().await; + let fee_event_id = 502; + let initial_legs = vec![ + make_leg(fee_event_id, 0, crate::WSOL_MINT_ID, "100"), + make_leg(fee_event_id, 1, "TokenMint222", "200"), + ]; + let initial_result = crate::query_fee_event_amounts_replace_for_fee_event( + &database, + fee_event_id, + &initial_legs, + ) + .await; + if let Err(error) = initial_result { + panic!("initial replace must succeed: {}", error); + } + + let replacement_legs = vec![make_leg(fee_event_id, 0, "TokenMint333", "300")]; + let replacement_result = crate::query_fee_event_amounts_replace_for_fee_event( + &database, + fee_event_id, + &replacement_legs, + ) + .await; + let inserted = match replacement_result { + Ok(inserted) => inserted, + Err(error) => panic!("replacement must succeed: {}", error), + }; + assert_eq!(inserted, 1); + + let list_result = + crate::query_fee_event_amounts_list_by_fee_event_id(&database, fee_event_id).await; + let listed = match list_result { + Ok(listed) => listed, + Err(error) => panic!("list must succeed: {}", error), + }; + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].leg_index, 0); + assert_eq!(listed[0].token_mint, "TokenMint333"); + assert_eq!(listed[0].amount_raw, "300"); + } + + fn make_fee_event( + transaction_id: i64, + decoded_event_id: i64, + signature: &str, + fee_token_mint: std::option::Option<&str>, + fee_amount_raw: std::option::Option<&str>, + ) -> crate::FeeEventDto { + return crate::FeeEventDto::new( + transaction_id, + Some(decoded_event_id), + None, + None, + None, + signature.to_string(), + Some(1234), + "test_protocol".to_string(), + "TestProgram111".to_string(), + "test_protocol.claim_fee".to_string(), + None, + None, + fee_token_mint.map(|value| return value.to_string()), + fee_amount_raw.map(|value| return value.to_string()), + serde_json::json!({"test": true}).to_string(), + ); + } + + async fn insert_legacy_fee_event_parent( + database: &crate::Database, + fee_event: &crate::FeeEventDto, + ) -> i64 { + let slot_i64 = match fee_event.slot { + Some(slot) => { + let slot_result = i64::try_from(slot); + match slot_result { + Ok(slot_i64) => Some(slot_i64), + Err(error) => panic!("slot conversion must succeed: {}", error), + } + }, + None => None, + }; + match database.connection() { + crate::DatabaseConnection::Sqlite(pool) => { + let insert_result = sqlx::query( + r#" +INSERT INTO k_sol_fee_events ( + transaction_id, + decoded_event_id, + dex_id, + pool_id, + pair_id, + signature, + slot, + protocol_name, + program_id, + event_kind, + pool_account, + actor_wallet, + fee_token_mint, + fee_amount_raw, + payload_json, + executed_at, + created_at +) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "#, + ) + .bind(fee_event.transaction_id) + .bind(fee_event.decoded_event_id) + .bind(fee_event.dex_id) + .bind(fee_event.pool_id) + .bind(fee_event.pair_id) + .bind(fee_event.signature.clone()) + .bind(slot_i64) + .bind(fee_event.protocol_name.clone()) + .bind(fee_event.program_id.clone()) + .bind(fee_event.event_kind.clone()) + .bind(fee_event.pool_account.clone()) + .bind(fee_event.actor_wallet.clone()) + .bind(fee_event.fee_token_mint.clone()) + .bind(fee_event.fee_amount_raw.clone()) + .bind(fee_event.payload_json.clone()) + .bind(fee_event.executed_at.to_rfc3339()) + .bind(fee_event.created_at.to_rfc3339()) + .execute(pool) + .await; + if let Err(error) = insert_result { + panic!("legacy parent insert must succeed: {}", error); + } + let id_result = sqlx::query_scalar::( + r#" +SELECT id +FROM k_sol_fee_events +WHERE signature = ? +ORDER BY id DESC +LIMIT 1 + "#, + ) + .bind(fee_event.signature.clone()) + .fetch_one(pool) + .await; + match id_result { + Ok(id) => return id, + Err(error) => panic!("legacy parent id fetch must succeed: {}", error), + } + }, + } + } + + #[tokio::test] + async fn fee_events_upsert_with_amount_legs_normalizes_parent_id() { + let database = make_database().await; + let fee_event = make_fee_event(7101, 8101, "signature-helper", None, None); + let legs = vec![ + make_leg(0, 0, crate::WSOL_MINT_ID, "100"), + make_leg(0, 1, "TokenMint555", "200"), + ]; + let upsert_result = + crate::query_fee_events_upsert_with_amount_legs(&database, &fee_event, legs.as_slice()) + .await; + let fee_event_id = match upsert_result { + Ok(fee_event_id) => fee_event_id, + Err(error) => panic!("upsert with legs must succeed: {}", error), + }; + let list_result = + crate::query_fee_event_amounts_list_by_fee_event_id(&database, fee_event_id).await; + let listed = match list_result { + Ok(listed) => listed, + Err(error) => panic!("list must succeed: {}", error), + }; + assert_eq!(listed.len(), 2); + assert_eq!(listed[0].fee_event_id, fee_event_id); + assert_eq!(listed[0].transaction_id, fee_event.transaction_id); + assert_eq!(listed[0].decoded_event_id, fee_event.decoded_event_id); + assert_eq!(listed[1].fee_event_id, fee_event_id); + assert_eq!(listed[1].transaction_id, fee_event.transaction_id); + assert_eq!(listed[1].decoded_event_id, fee_event.decoded_event_id); + } + + #[tokio::test] + async fn fee_events_upsert_automatically_creates_scalar_amount_leg() { + let database = make_database().await; + let fee_event = make_fee_event( + 7151, + 8151, + "signature-auto-leg", + Some(crate::WSOL_MINT_ID), + Some("777"), + ); + let upsert_result = crate::query_fee_events_upsert(&database, &fee_event).await; + let fee_event_id = match upsert_result { + Ok(fee_event_id) => fee_event_id, + Err(error) => panic!("fee parent upsert must succeed: {}", error), + }; + let list_result = + crate::query_fee_event_amounts_list_by_fee_event_id(&database, fee_event_id).await; + let listed = match list_result { + Ok(listed) => listed, + Err(error) => panic!("list must succeed: {}", error), + }; + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].fee_event_id, fee_event_id); + assert_eq!(listed[0].transaction_id, fee_event.transaction_id); + assert_eq!(listed[0].decoded_event_id, fee_event.decoded_event_id); + assert_eq!(listed[0].token_mint, crate::WSOL_MINT_ID); + assert_eq!(listed[0].amount_raw, "777"); + assert_eq!(listed[0].amount_source, "parent_fee_event_amount"); + } + + #[tokio::test] + async fn fee_event_amounts_backfill_single_leg_from_existing_parent() { + let database = make_database().await; + let fee_event = make_fee_event( + 7201, + 8201, + "signature-backfill", + Some(crate::WSOL_MINT_ID), + Some("999"), + ); + let fee_event_id = insert_legacy_fee_event_parent(&database, &fee_event).await; + let backfill_result = + crate::query_fee_event_amounts_backfill_single_leg_from_fee_events(&database).await; + let backfilled = match backfill_result { + Ok(backfilled) => backfilled, + Err(error) => panic!("backfill must succeed: {}", error), + }; + assert_eq!(backfilled, 1); + let list_result = + crate::query_fee_event_amounts_list_by_fee_event_id(&database, fee_event_id).await; + let listed = match list_result { + Ok(listed) => listed, + Err(error) => panic!("list must succeed: {}", error), + }; + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].token_mint, crate::WSOL_MINT_ID); + assert_eq!(listed[0].amount_raw, "999"); + assert_eq!(listed[0].amount_source, "parent_fee_event_backfill"); + + let second_backfill_result = + crate::query_fee_event_amounts_backfill_single_leg_from_fee_events(&database).await; + let second_backfilled = match second_backfill_result { + Ok(second_backfilled) => second_backfilled, + Err(error) => panic!("second backfill must succeed: {}", error), + }; + assert_eq!(second_backfilled, 0); + } + + #[tokio::test] + async fn fee_event_amounts_delete_by_parent_removes_all_legs() { + let database = make_database().await; + let fee_event_id = 503; + let legs = vec![ + make_leg(fee_event_id, 0, crate::WSOL_MINT_ID, "100"), + make_leg(fee_event_id, 1, "TokenMint444", "200"), + ]; + let replace_result = + crate::query_fee_event_amounts_replace_for_fee_event(&database, fee_event_id, &legs) + .await; + if let Err(error) = replace_result { + panic!("replace must succeed: {}", error); + } + + let delete_result = + crate::query_fee_event_amounts_delete_by_fee_event_id(&database, fee_event_id).await; + let deleted = match delete_result { + Ok(deleted) => deleted, + Err(error) => panic!("delete must succeed: {}", error), + }; + assert_eq!(deleted, 2); + + let list_result = + crate::query_fee_event_amounts_list_by_fee_event_id(&database, fee_event_id).await; + let listed = match list_result { + Ok(listed) => listed, + Err(error) => panic!("list must succeed: {}", error), + }; + assert!(listed.is_empty()); + } +} diff --git a/kb_lib/src/db/schema.rs b/kb_lib/src/db/schema.rs index 952fb14..ed05a10 100644 --- a/kb_lib/src/db/schema.rs +++ b/kb_lib/src/db/schema.rs @@ -342,6 +342,30 @@ pub(crate) async fn ensure_schema(database: &crate::Database) -> Result<(), crat if let Err(error) = result { return Err(error); } + let result = create_tbl_fee_event_amounts(pool).await; + if let Err(error) = result { + return Err(error); + } + let result = create_idx_fee_event_amounts_fee_event_id(pool).await; + if let Err(error) = result { + return Err(error); + } + let result = create_idx_fee_event_amounts_transaction_id(pool).await; + if let Err(error) = result { + return Err(error); + } + let result = create_idx_fee_event_amounts_decoded_event_id(pool).await; + if let Err(error) = result { + return Err(error); + } + let result = create_idx_fee_event_amounts_token_mint(pool).await; + if let Err(error) = result { + return Err(error); + } + let result = create_uix_fee_event_amounts_fee_event_leg(pool).await; + if let Err(error) = result { + return Err(error); + } let result = create_tbl_reward_events(pool).await; if let Err(error) = result { return Err(error); @@ -2702,6 +2726,107 @@ WHERE decoded_event_id IS NOT NULL .await; } +/// Creates `k_sol_fee_event_amounts`. +async fn create_tbl_fee_event_amounts(pool: &sqlx::SqlitePool) -> Result<(), crate::Error> { + return execute_sqlite_schema_statement( + pool, + "create_tbl_fee_event_amounts", + r#" +CREATE TABLE IF NOT EXISTS k_sol_fee_event_amounts ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + fee_event_id INTEGER NOT NULL, + transaction_id INTEGER NOT NULL, + decoded_event_id INTEGER NULL, + leg_index INTEGER NOT NULL, + fee_component_kind TEXT NOT NULL, + token_mint TEXT NOT NULL, + amount_raw TEXT NOT NULL, + source_account TEXT NULL, + destination_account TEXT NULL, + amount_source TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL +) + "#, + ) + .await; +} + +/// Creates index on `k_sol_fee_event_amounts(fee_event_id)`. +async fn create_idx_fee_event_amounts_fee_event_id( + pool: &sqlx::SqlitePool, +) -> Result<(), crate::Error> { + return execute_sqlite_schema_statement( + pool, + "create_idx_fee_event_amounts_fee_event_id", + r#" +CREATE INDEX IF NOT EXISTS idx_fee_event_amounts_fee_event_id +ON k_sol_fee_event_amounts (fee_event_id) + "#, + ) + .await; +} + +/// Creates index on `k_sol_fee_event_amounts(transaction_id)`. +async fn create_idx_fee_event_amounts_transaction_id( + pool: &sqlx::SqlitePool, +) -> Result<(), crate::Error> { + return execute_sqlite_schema_statement( + pool, + "create_idx_fee_event_amounts_transaction_id", + r#" +CREATE INDEX IF NOT EXISTS idx_fee_event_amounts_transaction_id +ON k_sol_fee_event_amounts (transaction_id) + "#, + ) + .await; +} + +/// Creates index on `k_sol_fee_event_amounts(decoded_event_id)`. +async fn create_idx_fee_event_amounts_decoded_event_id( + pool: &sqlx::SqlitePool, +) -> Result<(), crate::Error> { + return execute_sqlite_schema_statement( + pool, + "create_idx_fee_event_amounts_decoded_event_id", + r#" +CREATE INDEX IF NOT EXISTS idx_fee_event_amounts_decoded_event_id +ON k_sol_fee_event_amounts (decoded_event_id) + "#, + ) + .await; +} + +/// Creates index on `k_sol_fee_event_amounts(token_mint)`. +async fn create_idx_fee_event_amounts_token_mint( + pool: &sqlx::SqlitePool, +) -> Result<(), crate::Error> { + return execute_sqlite_schema_statement( + pool, + "create_idx_fee_event_amounts_token_mint", + r#" +CREATE INDEX IF NOT EXISTS idx_fee_event_amounts_token_mint +ON k_sol_fee_event_amounts (token_mint) + "#, + ) + .await; +} + +/// Creates unique index on one fee amount leg per parent fee event and leg index. +async fn create_uix_fee_event_amounts_fee_event_leg( + pool: &sqlx::SqlitePool, +) -> Result<(), crate::Error> { + return execute_sqlite_schema_statement( + pool, + "create_uix_fee_event_amounts_fee_event_leg", + r#" +CREATE UNIQUE INDEX IF NOT EXISTS uix_fee_event_amounts_fee_event_leg +ON k_sol_fee_event_amounts (fee_event_id, leg_index) + "#, + ) + .await; +} + /// Creates `k_sol_reward_events`. async fn create_tbl_reward_events(pool: &sqlx::SqlitePool) -> Result<(), crate::Error> { return execute_sqlite_schema_statement( diff --git a/kb_lib/src/dex.rs b/kb_lib/src/dex.rs index 3f2c5da..7d455b1 100644 --- a/kb_lib/src/dex.rs +++ b/kb_lib/src/dex.rs @@ -43,6 +43,7 @@ pub use meteora_damm_v2::MeteoraDammV2SwapDecoded; pub use meteora_dbc::MeteoraDbcCreatePoolDecoded; pub use meteora_dbc::MeteoraDbcDecodedEvent; pub use meteora_dbc::MeteoraDbcDecoder; +pub use meteora_dbc::MeteoraDbcInstructionDecoded; pub use meteora_dbc::MeteoraDbcSwapDecoded; pub use meteora_dlmm::MeteoraDlmmCreatePoolDecoded; pub use meteora_dlmm::MeteoraDlmmDecodedEvent; diff --git a/kb_lib/src/dex/meteora_dbc.rs b/kb_lib/src/dex/meteora_dbc.rs index 0057e8e..cb210ea 100644 --- a/kb_lib/src/dex/meteora_dbc.rs +++ b/kb_lib/src/dex/meteora_dbc.rs @@ -1,16 +1,66 @@ // file: kb_lib/src/dex/meteora_dbc.rs //! Meteora Dynamic Bonding Curve (DBC) transaction decoder. +//! +//! The local authority for this decoder is +//! `idls/meteora_dbc.dbcij3LWUppWqq96dh6gJWwBifmcGfLSB5D4DuSMaqN.json`. +//! Instruction layouts are decoded conservatively from the IDL account order. +//! Business materialization is only enabled when the account and amount context +//! is strong enough for the existing local event model. -const DBC_DISCRIMINATOR_CREATE_POOL: [u8; 8] = [0xe9, 0x92, 0xd1, 0x8e, 0xcf, 0x68, 0x40, 0xbc]; +const DBC_ANCHOR_SELF_CPI_LOG_SELECTOR: [u8; 8] = [0xe4, 0x45, 0xa5, 0x2e, 0x51, 0xcb, 0x9a, 0x1d]; -const DBC_DISCRIMINATOR_INITIALIZE_POOL: [u8; 8] = [0x5f, 0xb4, 0x0a, 0xac, 0x54, 0xae, 0xe8, 0x28]; +const DBC_DISCRIMINATOR_LEGACY_CREATE_POOL: [u8; 8] = + [0xe9, 0x92, 0xd1, 0x8e, 0xcf, 0x68, 0x40, 0xbc]; +const DBC_DISCRIMINATOR_LEGACY_INITIALIZE_POOL: [u8; 8] = + [0x5f, 0xb4, 0x0a, 0xac, 0x54, 0xae, 0xe8, 0x28]; +const DBC_DISCRIMINATOR_LEGACY_LAUNCH_POOL: [u8; 8] = + [0xa6, 0x77, 0xd1, 0xb6, 0xd6, 0x6d, 0x3a, 0xb5]; -const DBC_DISCRIMINATOR_LAUNCH_POOL: [u8; 8] = [0xa6, 0x77, 0xd1, 0xb6, 0xd6, 0x6d, 0x3a, 0xb5]; - -const DBC_DISCRIMINATOR_SWAP: [u8; 8] = [0xf8, 0xc6, 0x9e, 0x91, 0xe1, 0x75, 0x87, 0xc8]; - -const DBC_DISCRIMINATOR_SWAP2: [u8; 8] = [0x41, 0x4b, 0x3f, 0x4c, 0xeb, 0x5b, 0x5b, 0x88]; +const DBC_EVENT_DISCRIMINATOR_EVT_CLAIM_CREATOR_TRADING_FEE: [u8; 8] = + [0x9a, 0xe4, 0xd7, 0xca, 0x85, 0x9b, 0xd6, 0x8a]; +const DBC_EVENT_DISCRIMINATOR_EVT_CLAIM_POOL_CREATION_FEE: [u8; 8] = + [0x95, 0x6f, 0x95, 0x2c, 0x88, 0x40, 0xaf, 0x3e]; +const DBC_EVENT_DISCRIMINATOR_EVT_CLAIM_PROTOCOL_FEE: [u8; 8] = + [0xba, 0xf4, 0x4b, 0xfb, 0xbc, 0x0d, 0x19, 0x21]; +const DBC_EVENT_DISCRIMINATOR_EVT_CLAIM_PROTOCOL_LIQUIDITY_MIGRATION_FEE: [u8; 8] = + [0x51, 0xa8, 0x74, 0x1f, 0xa1, 0x56, 0x1b, 0x23]; +const DBC_EVENT_DISCRIMINATOR_EVT_CLAIM_TRADING_FEE: [u8; 8] = + [0x1a, 0x53, 0x75, 0xf0, 0x5c, 0xca, 0x70, 0xfe]; +const DBC_EVENT_DISCRIMINATOR_EVT_CLOSE_CLAIM_FEE_OPERATOR: [u8; 8] = + [0x6f, 0x27, 0x25, 0x37, 0x6e, 0xd8, 0xc2, 0x17]; +const DBC_EVENT_DISCRIMINATOR_EVT_CREATE_CLAIM_FEE_OPERATOR: [u8; 8] = + [0x15, 0x06, 0x99, 0x78, 0x44, 0x74, 0x1c, 0xb1]; +const DBC_EVENT_DISCRIMINATOR_EVT_CREATE_CONFIG: [u8; 8] = + [0x83, 0xcf, 0xb4, 0xae, 0xb4, 0x49, 0xa5, 0x36]; +const DBC_EVENT_DISCRIMINATOR_EVT_CREATE_CONFIG_V2: [u8; 8] = + [0xa3, 0x4a, 0x42, 0xbb, 0x77, 0xc3, 0x1a, 0x90]; +const DBC_EVENT_DISCRIMINATOR_EVT_CREATE_METEORA_MIGRATION_METADATA: [u8; 8] = + [0x63, 0xa7, 0x85, 0x3f, 0xd6, 0x8f, 0xaf, 0x8b]; +const DBC_EVENT_DISCRIMINATOR_EVT_CREATOR_WITHDRAW_SURPLUS: [u8; 8] = + [0x98, 0x49, 0x15, 0x0f, 0x42, 0x57, 0x35, 0x9d]; +const DBC_EVENT_DISCRIMINATOR_EVT_CURVE_COMPLETE: [u8; 8] = + [0xe5, 0xe7, 0x56, 0x54, 0x9c, 0x86, 0x4b, 0x18]; +const DBC_EVENT_DISCRIMINATOR_EVT_INITIALIZE_POOL: [u8; 8] = + [0xe4, 0x32, 0xf6, 0x55, 0xcb, 0x42, 0x86, 0x25]; +const DBC_EVENT_DISCRIMINATOR_EVT_PARTNER_CLAIM_POOL_CREATION_FEE: [u8; 8] = + [0xae, 0xdf, 0x2c, 0x96, 0x91, 0x62, 0x59, 0xc3]; +const DBC_EVENT_DISCRIMINATOR_EVT_PARTNER_METADATA: [u8; 8] = + [0xc8, 0x7f, 0x06, 0x37, 0x0d, 0x20, 0x08, 0x96]; +const DBC_EVENT_DISCRIMINATOR_EVT_PARTNER_WITHDRAW_MIGRATION_FEE: [u8; 8] = + [0xb5, 0x69, 0x7f, 0x43, 0x08, 0xbb, 0x78, 0x39]; +const DBC_EVENT_DISCRIMINATOR_EVT_PARTNER_WITHDRAW_SURPLUS: [u8; 8] = + [0xc3, 0x38, 0x98, 0x09, 0xe8, 0x48, 0x23, 0x16]; +const DBC_EVENT_DISCRIMINATOR_EVT_SWAP: [u8; 8] = [0x1b, 0x3c, 0x15, 0xd5, 0x8a, 0xaa, 0xbb, 0x93]; +const DBC_EVENT_DISCRIMINATOR_EVT_SWAP2: [u8; 8] = [0xbd, 0x42, 0x33, 0xa8, 0x26, 0x50, 0x75, 0x99]; +const DBC_EVENT_DISCRIMINATOR_EVT_UPDATE_POOL_CREATOR: [u8; 8] = + [0x6b, 0xe1, 0xa5, 0xed, 0x5b, 0x9e, 0xd5, 0xdc]; +const DBC_EVENT_DISCRIMINATOR_EVT_VIRTUAL_POOL_METADATA: [u8; 8] = + [0xbc, 0x12, 0x48, 0x4c, 0xc3, 0x5b, 0x26, 0x4a]; +const DBC_EVENT_DISCRIMINATOR_EVT_WITHDRAW_LEFTOVER: [u8; 8] = + [0xbf, 0xbd, 0x68, 0x8f, 0x6f, 0x9c, 0x5e, 0xe5]; +const DBC_EVENT_DISCRIMINATOR_EVT_WITHDRAW_MIGRATION_FEE: [u8; 8] = + [0x1a, 0xcb, 0x54, 0x55, 0xa1, 0x17, 0x64, 0xd6]; /// Decoded Meteora DBC create-pool event. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -23,6 +73,8 @@ pub struct MeteoraDbcCreatePoolDecoded { pub signature: std::string::String, /// Program id. pub program_id: std::string::String, + /// Fully qualified local event kind. + pub event_kind: std::string::String, /// Optional pool account. pub pool_account: std::option::Option, /// Optional base mint. @@ -48,6 +100,8 @@ pub struct MeteoraDbcSwapDecoded { pub signature: std::string::String, /// Program id. pub program_id: std::string::String, + /// Fully qualified local event kind. + pub event_kind: std::string::String, /// Trade side relative to normalized base. pub trade_side: crate::SwapTradeSide, /// Optional pool account. @@ -60,6 +114,31 @@ pub struct MeteoraDbcSwapDecoded { pub payload_json: serde_json::Value, } +/// Decoded Meteora DBC instruction or Anchor event. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct MeteoraDbcInstructionDecoded { + /// 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, + /// Fully qualified local event kind. + pub event_kind: std::string::String, + /// Optional pool account. + pub pool_account: std::option::Option, + /// Optional base mint. + pub token_a_mint: std::option::Option, + /// Optional quote mint. + pub token_b_mint: std::option::Option, + /// Optional related account. + pub related_account: std::option::Option, + /// Decoded payload. + pub payload_json: serde_json::Value, +} + /// Decoded Meteora DBC event. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub enum MeteoraDbcDecodedEvent { @@ -67,15 +146,384 @@ pub enum MeteoraDbcDecodedEvent { CreatePool(MeteoraDbcCreatePoolDecoded), /// Swap / swap2. Swap(MeteoraDbcSwapDecoded), + /// Generic instruction or Anchor event covered by the DBC IDL. + Instruction(MeteoraDbcInstructionDecoded), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum MeteoraDbcInstructionKind { CreatePool, Swap, + PoolLifecycle, + Fee, + Admin, Unknown, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MeteoraDbcArgLayout { + None, + FlagU8(&'static str), + OneU64(&'static str), + TwoU64(&'static str, &'static str), + PermissionU128(&'static str), + Swap, + Swap2, +} + +#[derive(Debug, Clone, Copy)] +struct MeteoraDbcInstructionSpec { + name: &'static str, + discriminator: [u8; 8], + kind: MeteoraDbcInstructionKind, + pool_index: std::option::Option, + token_a_mint_index: std::option::Option, + token_b_mint_index: std::option::Option, + related_account_index: std::option::Option, + actor_index: std::option::Option, + arg_layout: MeteoraDbcArgLayout, + skip_reason: std::option::Option<&'static str>, +} + +const DBC_INSTRUCTION_SPECS: &[MeteoraDbcInstructionSpec] = &[ + MeteoraDbcInstructionSpec { + name: "claim_creator_trading_fee", + discriminator: [0x52, 0xdc, 0xfa, 0xbd, 0x03, 0x55, 0x6b, 0x2d], + kind: MeteoraDbcInstructionKind::Fee, + pool_index: Some(1), + token_a_mint_index: Some(6), + token_b_mint_index: Some(7), + related_account_index: Some(2), + actor_index: Some(8), + arg_layout: MeteoraDbcArgLayout::TwoU64("maxBaseAmountRaw", "maxQuoteAmountRaw"), + skip_reason: Some("instruction_args_are_claim_maximums_not_actual_fee_amounts"), + }, + MeteoraDbcInstructionSpec { + name: "claim_partner_pool_creation_fee", + discriminator: [0xfa, 0xee, 0x1a, 0x04, 0x8b, 0x0a, 0x65, 0xf8], + kind: MeteoraDbcInstructionKind::Fee, + pool_index: Some(1), + token_a_mint_index: None, + token_b_mint_index: None, + related_account_index: Some(2), + actor_index: Some(3), + arg_layout: MeteoraDbcArgLayout::None, + skip_reason: Some("pool_creation_fee_instruction_without_amount_payload"), + }, + MeteoraDbcInstructionSpec { + name: "claim_protocol_fee", + discriminator: [0xa5, 0xe4, 0x85, 0x30, 0x63, 0xf9, 0xff, 0x21], + kind: MeteoraDbcInstructionKind::Fee, + pool_index: Some(1), + token_a_mint_index: Some(6), + token_b_mint_index: Some(7), + related_account_index: Some(2), + actor_index: Some(8), + arg_layout: MeteoraDbcArgLayout::TwoU64("maxBaseAmountRaw", "maxQuoteAmountRaw"), + skip_reason: Some("instruction_args_are_claim_maximums_not_actual_fee_amounts"), + }, + MeteoraDbcInstructionSpec { + name: "claim_protocol_pool_creation_fee", + discriminator: [0x72, 0xcd, 0x53, 0xbc, 0xf0, 0x99, 0x19, 0x36], + kind: MeteoraDbcInstructionKind::Fee, + pool_index: Some(1), + token_a_mint_index: None, + token_b_mint_index: None, + related_account_index: Some(2), + actor_index: Some(3), + arg_layout: MeteoraDbcArgLayout::None, + skip_reason: Some("pool_creation_fee_instruction_without_amount_payload"), + }, + MeteoraDbcInstructionSpec { + name: "claim_trading_fee", + discriminator: [0x08, 0xec, 0x59, 0x31, 0x98, 0x7d, 0xb1, 0x51], + kind: MeteoraDbcInstructionKind::Fee, + pool_index: Some(1), + token_a_mint_index: Some(6), + token_b_mint_index: Some(7), + related_account_index: Some(2), + actor_index: Some(8), + arg_layout: MeteoraDbcArgLayout::TwoU64("maxAmountARaw", "maxAmountBRaw"), + skip_reason: Some("instruction_args_are_claim_maximums_not_actual_fee_amounts"), + }, + MeteoraDbcInstructionSpec { + name: "close_claim_protocol_fee_operator", + discriminator: [0x08, 0x29, 0x57, 0x23, 0x50, 0x30, 0x79, 0x1a], + kind: MeteoraDbcInstructionKind::Admin, + pool_index: None, + token_a_mint_index: None, + token_b_mint_index: None, + related_account_index: Some(0), + actor_index: Some(1), + arg_layout: MeteoraDbcArgLayout::None, + skip_reason: None, + }, + MeteoraDbcInstructionSpec { + name: "close_operator_account", + discriminator: [0xab, 0x09, 0xd5, 0x4a, 0x78, 0x17, 0x03, 0x1d], + kind: MeteoraDbcInstructionKind::Admin, + pool_index: None, + token_a_mint_index: None, + token_b_mint_index: None, + related_account_index: Some(0), + actor_index: Some(1), + arg_layout: MeteoraDbcArgLayout::None, + skip_reason: None, + }, + MeteoraDbcInstructionSpec { + name: "create_config", + discriminator: [0xc9, 0xcf, 0xf3, 0x72, 0x4b, 0x6f, 0x2f, 0xbd], + kind: MeteoraDbcInstructionKind::Admin, + pool_index: None, + token_a_mint_index: None, + token_b_mint_index: Some(3), + related_account_index: Some(0), + actor_index: Some(4), + arg_layout: MeteoraDbcArgLayout::None, + skip_reason: None, + }, + MeteoraDbcInstructionSpec { + name: "create_locker", + discriminator: [0xa7, 0x5a, 0x89, 0x9a, 0x4b, 0x2f, 0x11, 0x54], + kind: MeteoraDbcInstructionKind::PoolLifecycle, + pool_index: Some(1), + token_a_mint_index: None, + token_b_mint_index: None, + related_account_index: Some(2), + actor_index: Some(5), + arg_layout: MeteoraDbcArgLayout::None, + skip_reason: None, + }, + MeteoraDbcInstructionSpec { + name: "create_operator_account", + discriminator: [0xdd, 0x40, 0xf6, 0x95, 0xf0, 0x99, 0xe5, 0xa3], + kind: MeteoraDbcInstructionKind::Admin, + pool_index: None, + token_a_mint_index: None, + token_b_mint_index: None, + related_account_index: Some(0), + actor_index: Some(1), + arg_layout: MeteoraDbcArgLayout::PermissionU128("permissionRaw"), + skip_reason: None, + }, + MeteoraDbcInstructionSpec { + name: "create_partner_metadata", + discriminator: [0xc0, 0xa8, 0xea, 0xbf, 0xbc, 0xe2, 0xe3, 0xff], + kind: MeteoraDbcInstructionKind::Admin, + pool_index: None, + token_a_mint_index: None, + token_b_mint_index: None, + related_account_index: Some(0), + actor_index: Some(1), + arg_layout: MeteoraDbcArgLayout::None, + skip_reason: None, + }, + MeteoraDbcInstructionSpec { + name: "create_virtual_pool_metadata", + discriminator: [0x2d, 0x61, 0xbb, 0x67, 0xfe, 0x6d, 0x7c, 0x86], + kind: MeteoraDbcInstructionKind::Admin, + pool_index: Some(0), + token_a_mint_index: None, + token_b_mint_index: None, + related_account_index: Some(1), + actor_index: Some(2), + arg_layout: MeteoraDbcArgLayout::None, + skip_reason: None, + }, + MeteoraDbcInstructionSpec { + name: "creator_withdraw_surplus", + discriminator: [0xa5, 0x03, 0x89, 0x07, 0x1c, 0x86, 0x4c, 0x50], + kind: MeteoraDbcInstructionKind::Fee, + pool_index: Some(1), + token_a_mint_index: Some(4), + token_b_mint_index: None, + related_account_index: Some(2), + actor_index: Some(5), + arg_layout: MeteoraDbcArgLayout::None, + skip_reason: Some("surplus_instruction_without_actual_amount_payload"), + }, + MeteoraDbcInstructionSpec { + name: "initialize_virtual_pool_with_spl_token", + discriminator: [0x8c, 0x55, 0xd7, 0xb0, 0x66, 0x36, 0x68, 0x4f], + kind: MeteoraDbcInstructionKind::CreatePool, + pool_index: Some(5), + token_a_mint_index: Some(3), + token_b_mint_index: Some(4), + related_account_index: Some(0), + actor_index: Some(2), + arg_layout: MeteoraDbcArgLayout::None, + skip_reason: None, + }, + MeteoraDbcInstructionSpec { + name: "initialize_virtual_pool_with_token2022", + discriminator: [0xa9, 0x76, 0x33, 0x4e, 0x91, 0x6e, 0xdc, 0x9b], + kind: MeteoraDbcInstructionKind::CreatePool, + pool_index: Some(5), + token_a_mint_index: Some(3), + token_b_mint_index: Some(4), + related_account_index: Some(0), + actor_index: Some(2), + arg_layout: MeteoraDbcArgLayout::None, + skip_reason: None, + }, + MeteoraDbcInstructionSpec { + name: "migrate_meteora_damm", + discriminator: [0x1b, 0x01, 0x30, 0x16, 0xb4, 0x3f, 0x76, 0xd9], + kind: MeteoraDbcInstructionKind::PoolLifecycle, + pool_index: Some(0), + token_a_mint_index: Some(7), + token_b_mint_index: Some(8), + related_account_index: Some(4), + actor_index: Some(3), + arg_layout: MeteoraDbcArgLayout::None, + skip_reason: None, + }, + MeteoraDbcInstructionSpec { + name: "migrate_meteora_damm_claim_lp_token", + discriminator: [0x8b, 0x85, 0x02, 0x1e, 0x5b, 0x91, 0x7f, 0x9a], + kind: MeteoraDbcInstructionKind::PoolLifecycle, + pool_index: Some(0), + token_a_mint_index: Some(4), + token_b_mint_index: Some(5), + related_account_index: Some(3), + actor_index: Some(2), + arg_layout: MeteoraDbcArgLayout::None, + skip_reason: None, + }, + MeteoraDbcInstructionSpec { + name: "migrate_meteora_damm_lock_lp_token", + discriminator: [0xb1, 0x37, 0xee, 0x9d, 0xfb, 0x58, 0xa5, 0x2a], + kind: MeteoraDbcInstructionKind::PoolLifecycle, + pool_index: Some(0), + token_a_mint_index: Some(4), + token_b_mint_index: Some(5), + related_account_index: Some(3), + actor_index: Some(2), + arg_layout: MeteoraDbcArgLayout::None, + skip_reason: None, + }, + MeteoraDbcInstructionSpec { + name: "migration_damm_v2", + discriminator: [0x9c, 0xa9, 0xe6, 0x67, 0x35, 0xe4, 0x50, 0x40], + kind: MeteoraDbcInstructionKind::PoolLifecycle, + pool_index: Some(0), + token_a_mint_index: Some(13), + token_b_mint_index: Some(14), + related_account_index: Some(4), + actor_index: Some(3), + arg_layout: MeteoraDbcArgLayout::None, + skip_reason: None, + }, + MeteoraDbcInstructionSpec { + name: "migration_damm_v2_create_metadata", + discriminator: [0x6d, 0xbd, 0x13, 0x24, 0xc3, 0xb7, 0xde, 0x52], + kind: MeteoraDbcInstructionKind::Admin, + pool_index: Some(0), + token_a_mint_index: None, + token_b_mint_index: None, + related_account_index: Some(1), + actor_index: Some(2), + arg_layout: MeteoraDbcArgLayout::None, + skip_reason: None, + }, + MeteoraDbcInstructionSpec { + name: "migration_meteora_damm_create_metadata", + discriminator: [0x2f, 0x5e, 0x7e, 0x73, 0xdd, 0xe2, 0xc2, 0x85], + kind: MeteoraDbcInstructionKind::Admin, + pool_index: Some(0), + token_a_mint_index: None, + token_b_mint_index: None, + related_account_index: Some(1), + actor_index: Some(2), + arg_layout: MeteoraDbcArgLayout::None, + skip_reason: None, + }, + MeteoraDbcInstructionSpec { + name: "partner_withdraw_surplus", + discriminator: [0xa8, 0xad, 0x48, 0x64, 0xc9, 0x62, 0x26, 0x5c], + kind: MeteoraDbcInstructionKind::Fee, + pool_index: Some(1), + token_a_mint_index: Some(4), + token_b_mint_index: None, + related_account_index: Some(2), + actor_index: Some(5), + arg_layout: MeteoraDbcArgLayout::None, + skip_reason: Some("surplus_instruction_without_actual_amount_payload"), + }, + MeteoraDbcInstructionSpec { + name: "swap", + discriminator: [0xf8, 0xc6, 0x9e, 0x91, 0xe1, 0x75, 0x87, 0xc8], + kind: MeteoraDbcInstructionKind::Swap, + pool_index: Some(2), + token_a_mint_index: Some(7), + token_b_mint_index: Some(8), + related_account_index: Some(1), + actor_index: Some(9), + arg_layout: MeteoraDbcArgLayout::Swap, + skip_reason: None, + }, + MeteoraDbcInstructionSpec { + name: "swap2", + discriminator: [0x41, 0x4b, 0x3f, 0x4c, 0xeb, 0x5b, 0x5b, 0x88], + kind: MeteoraDbcInstructionKind::Swap, + pool_index: Some(2), + token_a_mint_index: Some(7), + token_b_mint_index: Some(8), + related_account_index: Some(1), + actor_index: Some(9), + arg_layout: MeteoraDbcArgLayout::Swap2, + skip_reason: None, + }, + MeteoraDbcInstructionSpec { + name: "transfer_pool_creator", + discriminator: [0x14, 0x07, 0xa9, 0x21, 0x3a, 0x93, 0xa6, 0x21], + kind: MeteoraDbcInstructionKind::Admin, + pool_index: Some(0), + token_a_mint_index: None, + token_b_mint_index: None, + related_account_index: Some(3), + actor_index: Some(2), + arg_layout: MeteoraDbcArgLayout::None, + skip_reason: None, + }, + MeteoraDbcInstructionSpec { + name: "withdraw_leftover", + discriminator: [0x14, 0xc6, 0xca, 0xed, 0xeb, 0xf3, 0xb7, 0x42], + kind: MeteoraDbcInstructionKind::Fee, + pool_index: Some(2), + token_a_mint_index: Some(5), + token_b_mint_index: None, + related_account_index: Some(6), + actor_index: Some(7), + arg_layout: MeteoraDbcArgLayout::None, + skip_reason: Some("leftover_instruction_without_actual_amount_payload"), + }, + MeteoraDbcInstructionSpec { + name: "withdraw_migration_fee", + discriminator: [0xed, 0x8e, 0x2d, 0x17, 0x81, 0x06, 0xde, 0xa2], + kind: MeteoraDbcInstructionKind::Fee, + pool_index: Some(2), + token_a_mint_index: None, + token_b_mint_index: Some(5), + related_account_index: Some(6), + actor_index: Some(7), + arg_layout: MeteoraDbcArgLayout::FlagU8("flag"), + skip_reason: Some("migration_fee_instruction_without_actual_amount_payload"), + }, + MeteoraDbcInstructionSpec { + name: "zap_protocol_fee", + discriminator: [0xd5, 0x9b, 0xbb, 0x22, 0x38, 0xb6, 0x5b, 0xf0], + kind: MeteoraDbcInstructionKind::Fee, + pool_index: Some(2), + token_a_mint_index: Some(4), + token_b_mint_index: None, + related_account_index: Some(5), + actor_index: Some(6), + arg_layout: MeteoraDbcArgLayout::OneU64("maxAmountRaw"), + skip_reason: Some("instruction_arg_is_maximum_not_actual_fee_amount"), + }, +]; + /// Meteora DBC decoder. #[derive(Debug, Clone, Default)] pub struct MeteoraDbcDecoder; @@ -148,188 +596,1228 @@ impl MeteoraDbcDecoder { if instruction.parent_instruction_id.is_some() && instruction_data.is_none() { continue; } + if let Some(anchor_event) = decode_anchor_self_cpi_event( + transaction_id, + instruction_id, + transaction.signature.as_str(), + program_id.as_str(), + instruction, + instruction_data.as_deref(), + ) { + decoded_events.push(anchor_event); + continue; + } let instruction_kind = classify_instruction_kind( parsed_json.as_ref(), instruction_data.as_deref(), &log_messages, ); - let pool_account = extract_string_by_candidate_keys( + let spec = resolve_instruction_spec( parsed_json.as_ref(), - &["pool", "poolAccount", "poolState", "virtualPool", "poolKey"], - ) - .or_else(|| return extract_account(&accounts, 0)); - let token_a_mint = extract_string_by_candidate_keys( - parsed_json.as_ref(), - &["baseMint", "tokenAMint", "mintA", "token0Mint", "mint0"], - ) - .or_else(|| return extract_account(&accounts, 1)); - let token_b_mint = extract_string_by_candidate_keys( - parsed_json.as_ref(), - &["quoteMint", "tokenBMint", "mintB", "token1Mint", "mint1"], - ) - .or_else(|| return extract_account(&accounts, 2)); - let config_account = extract_string_by_candidate_keys( - parsed_json.as_ref(), - &["poolConfig", "config", "dbcConfig", "curveConfig"], - ) - .or_else(|| return extract_account(&accounts, 3)); - let creator = extract_string_by_candidate_keys( - parsed_json.as_ref(), - &["creator", "poolCreator", "owner", "user"], - ) - .or_else(|| return extract_account(&accounts, 4)); + instruction_data.as_deref(), + &log_messages, + ); + let spec = match spec { + Some(spec) => spec, + None => continue, + }; if instruction_kind == MeteoraDbcInstructionKind::CreatePool { - let payload_json = serde_json::json!({ - "decoder": "meteora_dbc", - "eventKind": "create_pool", - "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, - "instructionIndex": instruction.instruction_index, - "accounts": accounts, - "parsed": parsed_json, - "logMessages": log_messages, - "poolAccount": pool_account, - "tokenAMint": token_a_mint, - "tokenBMint": token_b_mint, - "configAccount": config_account, - "creator": creator - }); - decoded_events.push(crate::MeteoraDbcDecodedEvent::CreatePool( - crate::MeteoraDbcCreatePoolDecoded { - transaction_id, - instruction_id, - signature: transaction.signature.clone(), - program_id: program_id.clone(), - pool_account, - token_a_mint, - token_b_mint, - config_account, - creator, - payload_json, - }, - )); + let event = build_create_pool_event( + transaction_id, + instruction_id, + transaction, + instruction, + program_id.as_str(), + spec, + &accounts, + parsed_json.as_ref(), + instruction_data.as_deref(), + &log_messages, + ); + decoded_events.push(event); continue; } if instruction_kind == MeteoraDbcInstructionKind::Swap { - let decoded_amounts_result = - crate::meteora_swap_amount_inference::infer_meteora_swap_amounts_from_inner_transfers( - transaction, - instructions, - instruction, - pool_account.as_deref(), - ); + let decoded_amounts_result = crate::meteora_swap_amount_inference::infer_meteora_swap_amounts_from_inner_transfers( + transaction, + instructions, + instruction, + resolve_pool_account(parsed_json.as_ref(), &accounts, spec).as_deref(), + ); let decoded_amounts = match decoded_amounts_result { Ok(decoded_amounts) => decoded_amounts, Err(error) => return Err(error), }; - let fallback_trade_side = infer_trade_side(&log_messages); - let trade_side = match decoded_amounts.as_ref() { - Some(decoded_amounts) => decoded_amounts.trade_side, - None => fallback_trade_side, - }; - let has_direct_amount_payload = - parsed_json_has_trade_amount_or_price_payload(parsed_json.as_ref()); - let has_inferred_amount_payload = decoded_amounts.is_some(); - let has_trade_amount_payload = - has_direct_amount_payload || has_inferred_amount_payload; - let event_actionability = if has_trade_amount_payload { - "trade_candidate" - } else { - "non_actionable_trade" - }; - let materialization_skip_reason = if has_trade_amount_payload { - serde_json::Value::Null - } else { - serde_json::Value::String("swap_without_amount_payload".to_string()) - }; - let effective_token_a_mint = match decoded_amounts.as_ref() { - Some(decoded_amounts) => Some(decoded_amounts.base_token_mint.clone()), - None => token_a_mint.clone(), - }; - let effective_token_b_mint = match decoded_amounts.as_ref() { - Some(decoded_amounts) => Some(decoded_amounts.quote_token_mint.clone()), - None => token_b_mint.clone(), - }; - let base_vault = match decoded_amounts.as_ref() { - Some(decoded_amounts) => decoded_amounts.base_vault_address.clone(), - None => None, - }; - let quote_vault = match decoded_amounts.as_ref() { - Some(decoded_amounts) => decoded_amounts.quote_vault_address.clone(), - None => None, - }; - let base_amount_raw = match decoded_amounts.as_ref() { - Some(decoded_amounts) => { - serde_json::Value::String(decoded_amounts.base_amount_raw.clone()) - }, - None => serde_json::Value::Null, - }; - let quote_amount_raw = match decoded_amounts.as_ref() { - Some(decoded_amounts) => { - serde_json::Value::String(decoded_amounts.quote_amount_raw.clone()) - }, - None => serde_json::Value::Null, - }; - let amount_resolution_source = if has_inferred_amount_payload { - "flattened_cpi_pool_transfer_window" - } else if has_direct_amount_payload { - "parsed_payload" - } else { - "none" - }; - let payload_json = serde_json::json!({ - "decoder": "meteora_dbc", - "eventKind": "swap", - "dataDiscriminatorHex": instruction_data - .as_ref() - .and_then(|data| return first_8_bytes_hex(data.as_slice())), - "classifiedInstructionKind": "swap", - "eventCategory": "trade", - "eventLifecycleKind": "trade_swap", - "eventActionability": event_actionability, - "tradeCandidate": has_trade_amount_payload, - "candleCandidate": has_trade_amount_payload, - "nonTradeUseful": false, - "materializationSkipReason": materialization_skip_reason, - "amountResolutionSource": amount_resolution_source, - "signature": transaction.signature, - "instructionId": instruction_id, - "instructionIndex": instruction.instruction_index, - "accounts": accounts, - "parsed": parsed_json, - "logMessages": log_messages, - "poolAccount": pool_account, - "tokenAMint": effective_token_a_mint.clone(), - "tokenBMint": effective_token_b_mint.clone(), - "baseVault": base_vault, - "quoteVault": quote_vault, - "baseAmountRaw": base_amount_raw, - "quoteAmountRaw": quote_amount_raw, - "tradeSide": format!("{:?}", trade_side) - }); - decoded_events.push(crate::MeteoraDbcDecodedEvent::Swap( - crate::MeteoraDbcSwapDecoded { - transaction_id, - instruction_id, - signature: transaction.signature.clone(), - program_id: program_id.clone(), - trade_side, - pool_account, - token_a_mint: effective_token_a_mint, - token_b_mint: effective_token_b_mint, - payload_json, - }, - )); + let event = build_swap_event( + transaction_id, + instruction_id, + transaction, + instruction, + program_id.as_str(), + spec, + &accounts, + parsed_json.as_ref(), + instruction_data.as_deref(), + &log_messages, + decoded_amounts, + ); + decoded_events.push(event); + continue; } + let event = build_generic_instruction_event( + transaction_id, + instruction_id, + transaction, + instruction, + program_id.as_str(), + spec, + &accounts, + parsed_json.as_ref(), + instruction_data.as_deref(), + &log_messages, + ); + decoded_events.push(event); } return Ok(decoded_events); } } +fn build_create_pool_event( + transaction_id: i64, + instruction_id: i64, + transaction: &crate::ChainTransactionDto, + instruction: &crate::ChainInstructionDto, + program_id: &str, + spec: MeteoraDbcInstructionSpec, + accounts: &[std::string::String], + parsed_json: std::option::Option<&serde_json::Value>, + instruction_data: std::option::Option<&[u8]>, + log_messages: &[std::string::String], +) -> crate::MeteoraDbcDecodedEvent { + let pool_account = resolve_pool_account(parsed_json, accounts, spec); + let token_a_mint = resolve_token_a_mint(parsed_json, accounts, spec); + let token_b_mint = resolve_token_b_mint(parsed_json, accounts, spec); + let config_account = resolve_related_account(parsed_json, accounts, spec); + let creator = resolve_actor(parsed_json, accounts, spec); + let payload_json = serde_json::json!({ + "decoder": "meteora_dbc", + "eventKind": "create_pool", + "dataDiscriminatorHex": instruction_data.and_then(first_8_bytes_hex), + "decodedInstructionName": spec.name, + "sourceInstructionName": spec.name, + "classifiedInstructionKind": "pool_create", + "eventCategory": "pool_lifecycle", + "eventLifecycleKind": "pool_create", + "eventActionability": "catalog_candidate", + "tradeCandidate": false, + "candleCandidate": false, + "nonTradeUseful": true, + "skipTradeReason": "non_swap_instruction", + "skipCandleReason": "non_swap_instruction", + "signature": transaction.signature, + "instructionId": instruction_id, + "instructionIndex": instruction.instruction_index, + "innerInstructionIndex": instruction.inner_instruction_index, + "parentInstructionId": instruction.parent_instruction_id, + "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, + "creator": creator, + "actorWallet": creator + }); + return crate::MeteoraDbcDecodedEvent::CreatePool(crate::MeteoraDbcCreatePoolDecoded { + transaction_id, + instruction_id, + signature: transaction.signature.clone(), + program_id: program_id.to_string(), + event_kind: "meteora_dbc.create_pool".to_string(), + pool_account, + token_a_mint, + token_b_mint, + config_account, + creator, + payload_json, + }); +} + +fn build_swap_event( + transaction_id: i64, + instruction_id: i64, + transaction: &crate::ChainTransactionDto, + instruction: &crate::ChainInstructionDto, + program_id: &str, + spec: MeteoraDbcInstructionSpec, + accounts: &[std::string::String], + parsed_json: std::option::Option<&serde_json::Value>, + instruction_data: std::option::Option<&[u8]>, + log_messages: &[std::string::String], + decoded_amounts: std::option::Option< + crate::meteora_swap_amount_inference::MeteoraSwapAmountInference, + >, +) -> crate::MeteoraDbcDecodedEvent { + let pool_account = resolve_pool_account(parsed_json, accounts, spec); + let token_a_mint = resolve_token_a_mint(parsed_json, accounts, spec); + let token_b_mint = resolve_token_b_mint(parsed_json, accounts, spec); + let fallback_trade_side = infer_trade_side(log_messages); + let trade_side = match decoded_amounts.as_ref() { + Some(decoded_amounts) => decoded_amounts.trade_side, + None => fallback_trade_side, + }; + let has_direct_amount_payload = parsed_json_has_trade_amount_or_price_payload(parsed_json); + let has_inferred_amount_payload = decoded_amounts.is_some(); + let fallback_base_vault = extract_account(accounts, 5); + let fallback_quote_vault = extract_account(accounts, 6); + let transfer_fallback_candidate = pool_account.is_some() + && token_a_mint.is_some() + && token_b_mint.is_some() + && fallback_base_vault.is_some() + && fallback_quote_vault.is_some(); + let has_trade_amount_payload = + has_direct_amount_payload || has_inferred_amount_payload || transfer_fallback_candidate; + let event_actionability = if has_trade_amount_payload { + "trade_candidate" + } else { + "non_actionable_trade" + }; + let effective_token_a_mint = match decoded_amounts.as_ref() { + Some(decoded_amounts) => Some(decoded_amounts.base_token_mint.clone()), + None => token_a_mint.clone(), + }; + let effective_token_b_mint = match decoded_amounts.as_ref() { + Some(decoded_amounts) => Some(decoded_amounts.quote_token_mint.clone()), + None => token_b_mint.clone(), + }; + let base_vault = match decoded_amounts.as_ref() { + Some(decoded_amounts) => decoded_amounts.base_vault_address.clone(), + None => fallback_base_vault.clone(), + }; + let quote_vault = match decoded_amounts.as_ref() { + Some(decoded_amounts) => decoded_amounts.quote_vault_address.clone(), + None => fallback_quote_vault.clone(), + }; + let base_amount_raw = match decoded_amounts.as_ref() { + Some(decoded_amounts) => serde_json::Value::String(decoded_amounts.base_amount_raw.clone()), + None => serde_json::Value::Null, + }; + let quote_amount_raw = match decoded_amounts.as_ref() { + Some(decoded_amounts) => { + serde_json::Value::String(decoded_amounts.quote_amount_raw.clone()) + }, + None => serde_json::Value::Null, + }; + let amount_resolution_source = if has_inferred_amount_payload { + "flattened_cpi_pool_transfer_window" + } else if has_direct_amount_payload { + "parsed_payload" + } else if transfer_fallback_candidate { + "trade_aggregation_transfer_or_vault_delta_fallback" + } else { + "none" + }; + let skip_reason = if has_trade_amount_payload { + serde_json::Value::Null + } else { + serde_json::Value::String("swap_without_amount_or_transfer_context".to_string()) + }; + let mut instruction_args = decode_instruction_args(spec, instruction_data); + if let Some(object) = instruction_args.as_object_mut() { + object.insert( + "amountResolutionSource".to_string(), + serde_json::Value::String(amount_resolution_source.to_string()), + ); + } + let payload_json = serde_json::json!({ + "decoder": "meteora_dbc", + "eventKind": spec.name, + "dataDiscriminatorHex": instruction_data.and_then(first_8_bytes_hex), + "decodedInstructionName": spec.name, + "classifiedInstructionKind": "swap", + "eventCategory": "trade", + "eventLifecycleKind": "trade_swap", + "eventActionability": event_actionability, + "tradeCandidate": has_trade_amount_payload, + "candleCandidate": has_trade_amount_payload, + "nonTradeUseful": false, + "materializationSkipReason": skip_reason, + "skipTradeReason": skip_reason, + "skipCandleReason": skip_reason, + "signature": transaction.signature, + "instructionId": instruction_id, + "instructionIndex": instruction.instruction_index, + "innerInstructionIndex": instruction.inner_instruction_index, + "parentInstructionId": instruction.parent_instruction_id, + "stackHeight": instruction.stack_height, + "accounts": accounts, + "parsed": parsed_json, + "logMessages": log_messages, + "poolAccount": pool_account, + "tokenAMint": effective_token_a_mint.clone(), + "tokenBMint": effective_token_b_mint.clone(), + "baseVault": base_vault, + "quoteVault": quote_vault, + "baseAmountRaw": base_amount_raw, + "quoteAmountRaw": quote_amount_raw, + "tradeSide": format!("{:?}", trade_side), + "instructionArgs": instruction_args + }); + return crate::MeteoraDbcDecodedEvent::Swap(crate::MeteoraDbcSwapDecoded { + transaction_id, + instruction_id, + signature: transaction.signature.clone(), + program_id: program_id.to_string(), + event_kind: format!("meteora_dbc.{}", spec.name), + trade_side, + pool_account, + token_a_mint: effective_token_a_mint, + token_b_mint: effective_token_b_mint, + payload_json, + }); +} + +fn build_generic_instruction_event( + transaction_id: i64, + instruction_id: i64, + transaction: &crate::ChainTransactionDto, + instruction: &crate::ChainInstructionDto, + program_id: &str, + spec: MeteoraDbcInstructionSpec, + accounts: &[std::string::String], + parsed_json: std::option::Option<&serde_json::Value>, + instruction_data: std::option::Option<&[u8]>, + log_messages: &[std::string::String], +) -> crate::MeteoraDbcDecodedEvent { + let pool_account = resolve_pool_account(parsed_json, accounts, spec); + let token_a_mint = resolve_token_a_mint(parsed_json, accounts, spec); + let token_b_mint = resolve_token_b_mint(parsed_json, accounts, spec); + let related_account = resolve_related_account(parsed_json, accounts, spec); + let actor = resolve_actor(parsed_json, accounts, spec); + let event_kind = format!("meteora_dbc.{}", spec.name); + let skip_key = skip_reason_key_for_kind(spec.kind); + let skip_reason_value = match spec.skip_reason { + Some(skip_reason) => serde_json::Value::String(skip_reason.to_string()), + None => serde_json::Value::Null, + }; + let instruction_args = decode_instruction_args(spec, instruction_data); + let mut payload_json = serde_json::json!({ + "decoder": "meteora_dbc", + "eventKind": spec.name, + "dataDiscriminatorHex": instruction_data.and_then(first_8_bytes_hex), + "decodedInstructionName": spec.name, + "classifiedInstructionKind": crate::classify_dex_event_lifecycle_kind_code(event_kind.as_str()), + "eventCategory": event_category_for_kind(spec.kind), + "eventActionability": event_actionability_for_kind(spec.kind, spec.skip_reason), + "tradeCandidate": false, + "candleCandidate": false, + "nonTradeUseful": spec.skip_reason.is_none(), + "skipTradeReason": "non_swap_instruction", + "skipCandleReason": "non_swap_instruction", + "signature": transaction.signature, + "instructionId": instruction_id, + "instructionIndex": instruction.instruction_index, + "innerInstructionIndex": instruction.inner_instruction_index, + "parentInstructionId": instruction.parent_instruction_id, + "stackHeight": instruction.stack_height, + "accounts": accounts, + "parsed": parsed_json, + "logMessages": log_messages, + "poolAccount": pool_account, + "tokenAMint": token_a_mint, + "tokenBMint": token_b_mint, + "relatedAccount": related_account, + "configAccount": related_account, + "actorWallet": actor, + "adminAction": spec.name, + "instructionArgs": instruction_args + }); + if let Some(object) = payload_json.as_object_mut() { + object.insert(skip_key.to_string(), skip_reason_value); + } + return crate::MeteoraDbcDecodedEvent::Instruction(crate::MeteoraDbcInstructionDecoded { + transaction_id, + instruction_id, + signature: transaction.signature.clone(), + program_id: program_id.to_string(), + event_kind, + pool_account, + token_a_mint, + token_b_mint, + related_account, + payload_json, + }); +} + +fn resolve_instruction_spec( + parsed_json: std::option::Option<&serde_json::Value>, + instruction_data: std::option::Option<&[u8]>, + log_messages: &[std::string::String], +) -> std::option::Option { + let data_spec = resolve_instruction_spec_from_data(instruction_data); + if data_spec.is_some() { + return data_spec; + } + if instruction_data_has_full_discriminator(instruction_data) { + return None; + } + let parsed_instruction_name = extract_string_by_candidate_keys( + parsed_json, + &["instruction", "instructionName", "type", "name"], + ); + if let Some(parsed_instruction_name) = parsed_instruction_name { + let normalized = normalize_log_text(parsed_instruction_name.as_str()); + if let Some(spec) = resolve_instruction_spec_by_normalized_name(normalized.as_str()) { + return Some(spec); + } + } + let has_create_config = value_contains_any_key( + parsed_json, + &["poolConfig", "migrationQuoteThreshold", "curveConfig", "dbcConfig"], + ); + if has_create_config { + return resolve_instruction_spec_by_name("initialize_virtual_pool_with_spl_token"); + } + if log_messages_contain_any_keyword( + log_messages, + &["create_pool", "createpool", "initialize_pool", "initializepool", "launch_pool"], + ) { + return resolve_instruction_spec_by_name("initialize_virtual_pool_with_spl_token"); + } + if log_messages_contain_any_keyword(log_messages, &["swap2"]) { + return resolve_instruction_spec_by_name("swap2"); + } + if log_messages_contain_any_keyword(log_messages, &["swap"]) { + return resolve_instruction_spec_by_name("swap"); + } + return None; +} + +fn resolve_instruction_spec_from_data( + instruction_data: std::option::Option<&[u8]>, +) -> std::option::Option { + let instruction_data = match instruction_data { + Some(instruction_data) => instruction_data, + None => return None, + }; + if instruction_data.len() < 8 { + return None; + } + let discriminator = read_8_bytes(instruction_data, 0); + if discriminator == DBC_DISCRIMINATOR_LEGACY_CREATE_POOL + || discriminator == DBC_DISCRIMINATOR_LEGACY_INITIALIZE_POOL + || discriminator == DBC_DISCRIMINATOR_LEGACY_LAUNCH_POOL + { + return resolve_instruction_spec_by_name("initialize_virtual_pool_with_spl_token"); + } + for spec in DBC_INSTRUCTION_SPECS { + if spec.discriminator == discriminator { + return Some(*spec); + } + } + return None; +} + +fn resolve_instruction_spec_by_name(name: &str) -> std::option::Option { + for spec in DBC_INSTRUCTION_SPECS { + if spec.name == name { + return Some(*spec); + } + } + return None; +} + +fn resolve_instruction_spec_by_normalized_name( + normalized_name: &str, +) -> std::option::Option { + for spec in DBC_INSTRUCTION_SPECS { + let normalized_spec_name = normalize_log_text(spec.name); + if normalized_spec_name == normalized_name { + return Some(*spec); + } + } + if normalized_name.contains("createpool") + || normalized_name.contains("initializepool") + || normalized_name.contains("launchpool") + { + return resolve_instruction_spec_by_name("initialize_virtual_pool_with_spl_token"); + } + return None; +} + +fn classify_instruction_kind( + parsed_json: std::option::Option<&serde_json::Value>, + instruction_data: std::option::Option<&[u8]>, + log_messages: &[std::string::String], +) -> MeteoraDbcInstructionKind { + let data_kind = classify_instruction_kind_from_data(instruction_data); + if data_kind != MeteoraDbcInstructionKind::Unknown { + return data_kind; + } + let spec = resolve_instruction_spec(parsed_json, instruction_data, log_messages); + match spec { + Some(spec) => return spec.kind, + None => return MeteoraDbcInstructionKind::Unknown, + } +} + +fn classify_instruction_kind_from_data( + instruction_data: std::option::Option<&[u8]>, +) -> MeteoraDbcInstructionKind { + let spec = resolve_instruction_spec_from_data(instruction_data); + match spec { + Some(spec) => return spec.kind, + None => return MeteoraDbcInstructionKind::Unknown, + } +} + +fn decode_anchor_self_cpi_event( + transaction_id: i64, + instruction_id: i64, + signature: &str, + program_id: &str, + instruction: &crate::ChainInstructionDto, + instruction_data: std::option::Option<&[u8]>, +) -> std::option::Option { + let data = match instruction_data { + Some(data) => data, + None => return None, + }; + if data.len() < 16 { + return None; + } + let selector = read_8_bytes(data, 0); + if selector != DBC_ANCHOR_SELF_CPI_LOG_SELECTOR { + return None; + } + let discriminator = read_8_bytes(data, 8); + if discriminator == DBC_EVENT_DISCRIMINATOR_EVT_SWAP { + return decode_anchor_swap_event( + transaction_id, + instruction_id, + signature, + program_id, + instruction, + data, + false, + ); + } + if discriminator == DBC_EVENT_DISCRIMINATOR_EVT_SWAP2 { + return decode_anchor_swap_event( + transaction_id, + instruction_id, + signature, + program_id, + instruction, + data, + true, + ); + } + if discriminator == DBC_EVENT_DISCRIMINATOR_EVT_INITIALIZE_POOL { + return decode_anchor_initialize_pool_event( + transaction_id, + instruction_id, + signature, + program_id, + instruction, + data, + ); + } + if discriminator == DBC_EVENT_DISCRIMINATOR_EVT_CURVE_COMPLETE { + return decode_anchor_curve_complete_event( + transaction_id, + instruction_id, + signature, + program_id, + instruction, + data, + ); + } + return decode_anchor_generic_event( + transaction_id, + instruction_id, + signature, + program_id, + instruction, + data, + discriminator, + ); +} + +fn decode_anchor_swap_event( + transaction_id: i64, + instruction_id: i64, + signature: &str, + program_id: &str, + instruction: &crate::ChainInstructionDto, + data: &[u8], + is_swap2: bool, +) -> std::option::Option { + if !is_swap2 && data.len() < 170 { + return None; + } + if is_swap2 && data.len() < 195 { + return None; + } + let pool_account = read_pubkey_string(data, 16); + let config_account = read_pubkey_string(data, 48); + let trade_direction = read_u8_string(data, 80); + let has_referral = read_bool(data, 81); + let event_kind = if is_swap2 { + "meteora_dbc.evt_swap2_event" + } else { + "meteora_dbc.evt_swap_event" + }; + let anchor_event_name = if is_swap2 { "EvtSwap2" } else { "EvtSwap" }; + let event_discriminator_hex = if is_swap2 { "bd4233a826507599" } else { "1b3c15d58aaabb93" }; + let input_amount = if is_swap2 { read_u64_string(data, 99) } else { read_u64_string(data, 98) }; + let output_amount = + if is_swap2 { read_u64_string(data, 123) } else { read_u64_string(data, 106) }; + let trading_fee = + if is_swap2 { read_u64_string(data, 147) } else { read_u64_string(data, 130) }; + let protocol_fee = + if is_swap2 { read_u64_string(data, 155) } else { read_u64_string(data, 138) }; + let referral_fee = + if is_swap2 { read_u64_string(data, 163) } else { read_u64_string(data, 146) }; + let payload_json = serde_json::json!({ + "decoder": "meteora_dbc", + "eventKind": if is_swap2 { "evt_swap2_event" } else { "evt_swap_event" }, + "decodedInstructionName": "anchor_self_cpi_log", + "classifiedInstructionKind": "swap", + "eventCategory": "trade", + "eventActionability": "decoded_only_missing_mint_context", + "tradeCandidate": false, + "candleCandidate": false, + "nonTradeUseful": false, + "skipTradeReason": "anchor_swap_event_without_mint_context", + "skipCandleReason": "anchor_swap_event_without_mint_context", + "materializationSkipReason": "anchor_swap_event_without_mint_context", + "signature": signature, + "instructionId": instruction_id, + "instructionIndex": instruction.instruction_index, + "innerInstructionIndex": instruction.inner_instruction_index, + "parentInstructionId": instruction.parent_instruction_id, + "stackHeight": instruction.stack_height, + "anchorSelfCpiLog": true, + "anchorSelfCpiLogSelectorHex": "e445a52e51cb9a1d", + "anchorEventName": anchor_event_name, + "anchorEventDiscriminatorHex": event_discriminator_hex, + "anchorEventPayloadSize": data.len().saturating_sub(16), + "proofStatus": "local_idl_layout", + "poolAccount": pool_account, + "configAccount": config_account, + "tradeDirectionRaw": trade_direction, + "hasReferral": has_referral, + "amountInRaw": input_amount, + "amountOutRaw": output_amount, + "tradingFeeRaw": trading_fee, + "protocolFeeRaw": protocol_fee, + "referralFeeRaw": referral_fee + }); + return Some(crate::MeteoraDbcDecodedEvent::Instruction( + crate::MeteoraDbcInstructionDecoded { + transaction_id, + instruction_id, + signature: signature.to_string(), + program_id: program_id.to_string(), + event_kind: event_kind.to_string(), + pool_account, + token_a_mint: None, + token_b_mint: None, + related_account: config_account, + payload_json, + }, + )); +} + +fn decode_anchor_initialize_pool_event( + transaction_id: i64, + instruction_id: i64, + signature: &str, + program_id: &str, + instruction: &crate::ChainInstructionDto, + data: &[u8], +) -> std::option::Option { + if data.len() < 153 { + return None; + } + let pool_account = read_pubkey_string(data, 16); + let config_account = read_pubkey_string(data, 48); + let creator = read_pubkey_string(data, 80); + let base_mint = read_pubkey_string(data, 112); + let pool_type = read_u8_string(data, 144); + let activation_point = read_u64_string(data, 145); + let payload_json = serde_json::json!({ + "decoder": "meteora_dbc", + "eventKind": "evt_initialize_pool_event", + "decodedInstructionName": "anchor_self_cpi_log", + "classifiedInstructionKind": "pool_create", + "eventCategory": "pool_lifecycle", + "eventActionability": "catalog_candidate", + "tradeCandidate": false, + "candleCandidate": false, + "nonTradeUseful": true, + "skipTradeReason": "non_swap_anchor_event", + "skipCandleReason": "non_swap_anchor_event", + "signature": signature, + "instructionId": instruction_id, + "instructionIndex": instruction.instruction_index, + "innerInstructionIndex": instruction.inner_instruction_index, + "parentInstructionId": instruction.parent_instruction_id, + "stackHeight": instruction.stack_height, + "anchorSelfCpiLog": true, + "anchorSelfCpiLogSelectorHex": "e445a52e51cb9a1d", + "anchorEventName": "EvtInitializePool", + "anchorEventDiscriminatorHex": "e432f655cb428625", + "anchorEventPayloadSize": data.len().saturating_sub(16), + "proofStatus": "local_idl_layout", + "poolAccount": pool_account, + "configAccount": config_account, + "creator": creator, + "actorWallet": creator, + "baseMint": base_mint, + "tokenAMint": base_mint, + "poolType": pool_type, + "activationPointRaw": activation_point, + "skipCatalogReason": "anchor_initialize_pool_event_without_quote_mint_context" + }); + return Some(crate::MeteoraDbcDecodedEvent::Instruction( + crate::MeteoraDbcInstructionDecoded { + transaction_id, + instruction_id, + signature: signature.to_string(), + program_id: program_id.to_string(), + event_kind: "meteora_dbc.evt_initialize_pool_event".to_string(), + pool_account, + token_a_mint: base_mint, + token_b_mint: None, + related_account: config_account, + payload_json, + }, + )); +} + +fn decode_anchor_curve_complete_event( + transaction_id: i64, + instruction_id: i64, + signature: &str, + program_id: &str, + instruction: &crate::ChainInstructionDto, + data: &[u8], +) -> std::option::Option { + if data.len() < 96 { + return None; + } + let pool_account = read_pubkey_string(data, 16); + let config_account = read_pubkey_string(data, 48); + let base_reserve = read_u64_string(data, 80); + let quote_reserve = read_u64_string(data, 88); + let payload_json = serde_json::json!({ + "decoder": "meteora_dbc", + "eventKind": "evt_curve_complete_event", + "decodedInstructionName": "anchor_self_cpi_log", + "classifiedInstructionKind": "migration", + "eventCategory": "pool_lifecycle", + "eventActionability": "lifecycle_candidate", + "tradeCandidate": false, + "candleCandidate": false, + "nonTradeUseful": true, + "skipTradeReason": "non_swap_anchor_event", + "skipCandleReason": "non_swap_anchor_event", + "signature": signature, + "instructionId": instruction_id, + "instructionIndex": instruction.instruction_index, + "innerInstructionIndex": instruction.inner_instruction_index, + "parentInstructionId": instruction.parent_instruction_id, + "stackHeight": instruction.stack_height, + "anchorSelfCpiLog": true, + "anchorSelfCpiLogSelectorHex": "e445a52e51cb9a1d", + "anchorEventName": "EvtCurveComplete", + "anchorEventDiscriminatorHex": "e5e756549c864b18", + "anchorEventPayloadSize": data.len().saturating_sub(16), + "proofStatus": "local_idl_layout", + "poolAccount": pool_account, + "configAccount": config_account, + "baseReserveRaw": base_reserve, + "quoteReserveRaw": quote_reserve + }); + return Some(crate::MeteoraDbcDecodedEvent::Instruction( + crate::MeteoraDbcInstructionDecoded { + transaction_id, + instruction_id, + signature: signature.to_string(), + program_id: program_id.to_string(), + event_kind: "meteora_dbc.evt_curve_complete_event".to_string(), + pool_account, + token_a_mint: None, + token_b_mint: None, + related_account: config_account, + payload_json, + }, + )); +} + +fn decode_anchor_generic_event( + transaction_id: i64, + instruction_id: i64, + signature: &str, + program_id: &str, + instruction: &crate::ChainInstructionDto, + data: &[u8], + discriminator: [u8; 8], +) -> std::option::Option { + let metadata = resolve_anchor_event_metadata(discriminator); + let metadata = match metadata { + Some(metadata) => metadata, + None => return None, + }; + let pool_account = read_pubkey_string(data, 16); + let related_account = read_pubkey_string(data, 48); + let mut payload_json = serde_json::json!({ + "decoder": "meteora_dbc", + "eventKind": metadata.0, + "decodedInstructionName": "anchor_self_cpi_log", + "classifiedInstructionKind": metadata.3, + "eventCategory": metadata.4, + "eventActionability": "decoded_only_anchor_event", + "tradeCandidate": false, + "candleCandidate": false, + "nonTradeUseful": false, + "skipTradeReason": "non_swap_anchor_event", + "skipCandleReason": "non_swap_anchor_event", + "signature": signature, + "instructionId": instruction_id, + "instructionIndex": instruction.instruction_index, + "innerInstructionIndex": instruction.inner_instruction_index, + "parentInstructionId": instruction.parent_instruction_id, + "stackHeight": instruction.stack_height, + "anchorSelfCpiLog": true, + "anchorSelfCpiLogSelectorHex": "e445a52e51cb9a1d", + "anchorEventName": metadata.1, + "anchorEventDiscriminatorHex": metadata.2, + "anchorEventPayloadSize": data.len().saturating_sub(16), + "proofStatus": "local_idl_discriminator_generic_payload", + "poolAccount": pool_account, + "relatedAccount": related_account + }); + if let Some(object) = payload_json.as_object_mut() { + object.insert(metadata.5.to_string(), serde_json::Value::String(metadata.6.to_string())); + } + return Some(crate::MeteoraDbcDecodedEvent::Instruction( + crate::MeteoraDbcInstructionDecoded { + transaction_id, + instruction_id, + signature: signature.to_string(), + program_id: program_id.to_string(), + event_kind: format!("meteora_dbc.{}", metadata.0), + pool_account, + token_a_mint: None, + token_b_mint: None, + related_account, + payload_json, + }, + )); +} + +fn resolve_anchor_event_metadata( + discriminator: [u8; 8], +) -> std::option::Option<( + &'static str, + &'static str, + &'static str, + &'static str, + &'static str, + &'static str, + &'static str, +)> { + if discriminator == DBC_EVENT_DISCRIMINATOR_EVT_CLAIM_CREATOR_TRADING_FEE { + return Some(( + "evt_claim_creator_trading_fee_event", + "EvtClaimCreatorTradingFee", + "9ae4d7ca859bd68a", + "fee", + "fee", + "skipFeeReason", + "anchor_fee_event_decoded_only_sibling_instruction_materializes_or_explains_amount", + )); + } + if discriminator == DBC_EVENT_DISCRIMINATOR_EVT_CLAIM_POOL_CREATION_FEE { + return Some(( + "evt_claim_pool_creation_fee_event", + "EvtClaimPoolCreationFee", + "956f952c8840af3e", + "fee", + "fee", + "skipFeeReason", + "anchor_fee_event_decoded_only_sibling_instruction_materializes_or_explains_amount", + )); + } + if discriminator == DBC_EVENT_DISCRIMINATOR_EVT_CLAIM_PROTOCOL_FEE { + return Some(( + "evt_claim_protocol_fee_event", + "EvtClaimProtocolFee", + "baf44bfbbc0d1921", + "fee", + "fee", + "skipFeeReason", + "anchor_fee_event_decoded_only_sibling_instruction_materializes_or_explains_amount", + )); + } + if discriminator == DBC_EVENT_DISCRIMINATOR_EVT_CLAIM_PROTOCOL_LIQUIDITY_MIGRATION_FEE { + return Some(( + "evt_claim_protocol_liquidity_migration_fee_event", + "EvtClaimProtocolLiquidityMigrationFee", + "51a8741fa1561b23", + "fee", + "fee", + "skipFeeReason", + "anchor_fee_event_decoded_only_sibling_instruction_materializes_or_explains_amount", + )); + } + if discriminator == DBC_EVENT_DISCRIMINATOR_EVT_CLAIM_TRADING_FEE { + return Some(( + "evt_claim_trading_fee_event", + "EvtClaimTradingFee", + "1a5375f05cca70fe", + "fee", + "fee", + "skipFeeReason", + "anchor_fee_event_decoded_only_sibling_instruction_materializes_or_explains_amount", + )); + } + if discriminator == DBC_EVENT_DISCRIMINATOR_EVT_CLOSE_CLAIM_FEE_OPERATOR { + return Some(( + "evt_close_claim_fee_operator_event", + "EvtCloseClaimFeeOperator", + "6f2725376ed8c217", + "admin", + "admin", + "skipAdminReason", + "anchor_operator_event_decoded_only", + )); + } + if discriminator == DBC_EVENT_DISCRIMINATOR_EVT_CREATE_CLAIM_FEE_OPERATOR { + return Some(( + "evt_create_claim_fee_operator_event", + "EvtCreateClaimFeeOperator", + "1506997844741cb1", + "admin", + "admin", + "skipAdminReason", + "anchor_operator_event_decoded_only", + )); + } + if discriminator == DBC_EVENT_DISCRIMINATOR_EVT_CREATE_CONFIG { + return Some(( + "evt_create_config_event", + "EvtCreateConfig", + "83cfb4aeb449a536", + "admin", + "admin", + "skipAdminReason", + "anchor_config_event_generic_payload", + )); + } + if discriminator == DBC_EVENT_DISCRIMINATOR_EVT_CREATE_CONFIG_V2 { + return Some(( + "evt_create_config_v2_event", + "EvtCreateConfigV2", + "a34a42bb77c31a90", + "admin", + "admin", + "skipAdminReason", + "anchor_config_v2_event_generic_payload", + )); + } + if discriminator == DBC_EVENT_DISCRIMINATOR_EVT_CREATE_METEORA_MIGRATION_METADATA { + return Some(( + "evt_create_meteora_migration_metadata_event", + "EvtCreateMeteoraMigrationMetadata", + "63a7853fd68faf8b", + "migration", + "pool_lifecycle", + "skipLifecycleReason", + "anchor_migration_metadata_event_decoded_only", + )); + } + if discriminator == DBC_EVENT_DISCRIMINATOR_EVT_CREATOR_WITHDRAW_SURPLUS { + return Some(( + "evt_creator_withdraw_surplus_event", + "EvtCreatorWithdrawSurplus", + "9849150f4257359d", + "fee", + "fee", + "skipFeeReason", + "anchor_surplus_event_decoded_only_sibling_instruction_materializes_or_explains_amount", + )); + } + if discriminator == DBC_EVENT_DISCRIMINATOR_EVT_PARTNER_CLAIM_POOL_CREATION_FEE { + return Some(( + "evt_partner_claim_pool_creation_fee_event", + "EvtPartnerClaimPoolCreationFee", + "aedf2c96916259c3", + "fee", + "fee", + "skipFeeReason", + "anchor_fee_event_decoded_only_sibling_instruction_materializes_or_explains_amount", + )); + } + if discriminator == DBC_EVENT_DISCRIMINATOR_EVT_PARTNER_METADATA { + return Some(( + "evt_partner_metadata_event", + "EvtPartnerMetadata", + "c87f06370d200896", + "admin", + "admin", + "skipAdminReason", + "anchor_metadata_event_decoded_only", + )); + } + if discriminator == DBC_EVENT_DISCRIMINATOR_EVT_PARTNER_WITHDRAW_MIGRATION_FEE { + return Some(( + "evt_partner_withdraw_migration_fee_event", + "EvtPartnerWithdrawMigrationFee", + "b5697f4308bb7839", + "fee", + "fee", + "skipFeeReason", + "anchor_fee_event_decoded_only_sibling_instruction_materializes_or_explains_amount", + )); + } + if discriminator == DBC_EVENT_DISCRIMINATOR_EVT_PARTNER_WITHDRAW_SURPLUS { + return Some(( + "evt_partner_withdraw_surplus_event", + "EvtPartnerWithdrawSurplus", + "c3389809e8482316", + "fee", + "fee", + "skipFeeReason", + "anchor_surplus_event_decoded_only_sibling_instruction_materializes_or_explains_amount", + )); + } + if discriminator == DBC_EVENT_DISCRIMINATOR_EVT_UPDATE_POOL_CREATOR { + return Some(( + "evt_update_pool_creator_event", + "EvtUpdatePoolCreator", + "6be1a5ed5b9ed5dc", + "admin", + "admin", + "skipAdminReason", + "anchor_update_pool_creator_event_decoded_only", + )); + } + if discriminator == DBC_EVENT_DISCRIMINATOR_EVT_VIRTUAL_POOL_METADATA { + return Some(( + "evt_virtual_pool_metadata_event", + "EvtVirtualPoolMetadata", + "bc12484cc35b264a", + "admin", + "admin", + "skipAdminReason", + "anchor_metadata_event_decoded_only", + )); + } + if discriminator == DBC_EVENT_DISCRIMINATOR_EVT_WITHDRAW_LEFTOVER { + return Some(( + "evt_withdraw_leftover_event", + "EvtWithdrawLeftover", + "bfbd688f6f9c5ee5", + "fee", + "fee", + "skipFeeReason", + "anchor_leftover_event_decoded_only_sibling_instruction_materializes_or_explains_amount", + )); + } + if discriminator == DBC_EVENT_DISCRIMINATOR_EVT_WITHDRAW_MIGRATION_FEE { + return Some(( + "evt_withdraw_migration_fee_event", + "EvtWithdrawMigrationFee", + "1acb5455a11764d6", + "fee", + "fee", + "skipFeeReason", + "anchor_fee_event_decoded_only_sibling_instruction_materializes_or_explains_amount", + )); + } + return None; +} + +fn resolve_pool_account( + parsed_json: std::option::Option<&serde_json::Value>, + accounts: &[std::string::String], + spec: MeteoraDbcInstructionSpec, +) -> std::option::Option { + return extract_string_by_candidate_keys( + parsed_json, + &["pool", "poolAccount", "poolState", "virtualPool", "poolKey"], + ) + .or_else(|| return extract_account_option(accounts, spec.pool_index)); +} + +fn resolve_token_a_mint( + parsed_json: std::option::Option<&serde_json::Value>, + accounts: &[std::string::String], + spec: MeteoraDbcInstructionSpec, +) -> std::option::Option { + return extract_string_by_candidate_keys( + parsed_json, + &["baseMint", "tokenAMint", "mintA", "token0Mint", "mint0"], + ) + .or_else(|| return extract_account_option(accounts, spec.token_a_mint_index)); +} + +fn resolve_token_b_mint( + parsed_json: std::option::Option<&serde_json::Value>, + accounts: &[std::string::String], + spec: MeteoraDbcInstructionSpec, +) -> std::option::Option { + return extract_string_by_candidate_keys( + parsed_json, + &["quoteMint", "tokenBMint", "mintB", "token1Mint", "mint1"], + ) + .or_else(|| return extract_account_option(accounts, spec.token_b_mint_index)); +} + +fn resolve_related_account( + parsed_json: std::option::Option<&serde_json::Value>, + accounts: &[std::string::String], + spec: MeteoraDbcInstructionSpec, +) -> std::option::Option { + return extract_string_by_candidate_keys( + parsed_json, + &["poolConfig", "config", "dbcConfig", "curveConfig", "metadata", "operator"], + ) + .or_else(|| return extract_account_option(accounts, spec.related_account_index)); +} + +fn resolve_actor( + parsed_json: std::option::Option<&serde_json::Value>, + accounts: &[std::string::String], + spec: MeteoraDbcInstructionSpec, +) -> std::option::Option { + return extract_string_by_candidate_keys( + parsed_json, + &["creator", "poolCreator", "owner", "user", "payer", "operator"], + ) + .or_else(|| return extract_account_option(accounts, spec.actor_index)); +} + +fn extract_account_option( + accounts: &[std::string::String], + index: std::option::Option, +) -> std::option::Option { + let index = match index { + Some(index) => index, + None => return None, + }; + return extract_account(accounts, index); +} + +fn decode_instruction_args( + spec: MeteoraDbcInstructionSpec, + instruction_data: std::option::Option<&[u8]>, +) -> serde_json::Value { + let data = match instruction_data { + Some(data) => data, + None => return serde_json::json!({}), + }; + let payload = if data.len() >= 8 { &data[8..] } else { &[] }; + match spec.arg_layout { + MeteoraDbcArgLayout::None => return serde_json::json!({}), + MeteoraDbcArgLayout::FlagU8(name) => { + return json_object_with_one_optional_string(name, read_u8_string(payload, 0)); + }, + MeteoraDbcArgLayout::OneU64(name) => { + return json_object_with_one_optional_string(name, read_u64_string(payload, 0)); + }, + MeteoraDbcArgLayout::TwoU64(name_a, name_b) => { + let mut value = serde_json::json!({}); + if let Some(object) = value.as_object_mut() { + object.insert( + name_a.to_string(), + optional_string_to_json(read_u64_string(payload, 0)), + ); + object.insert( + name_b.to_string(), + optional_string_to_json(read_u64_string(payload, 8)), + ); + } + return value; + }, + MeteoraDbcArgLayout::PermissionU128(name) => { + return json_object_with_one_optional_string(name, read_u128_string(payload, 0)); + }, + MeteoraDbcArgLayout::Swap => { + return serde_json::json!({ + "amountInRaw": read_u64_string(payload, 0), + "minimumAmountOutRaw": read_u64_string(payload, 8) + }); + }, + MeteoraDbcArgLayout::Swap2 => { + return serde_json::json!({ + "amount0Raw": read_u64_string(payload, 0), + "amount1Raw": read_u64_string(payload, 8), + "swapMode": read_u8_string(payload, 16) + }); + }, + } +} + +fn json_object_with_one_optional_string( + key: &str, + value: std::option::Option, +) -> serde_json::Value { + let mut json_value = serde_json::json!({}); + if let Some(object) = json_value.as_object_mut() { + object.insert(key.to_string(), optional_string_to_json(value)); + } + return json_value; +} + +fn optional_string_to_json(value: std::option::Option) -> serde_json::Value { + match value { + Some(value) => return serde_json::Value::String(value), + None => return serde_json::Value::Null, + } +} + +fn event_category_for_kind(kind: MeteoraDbcInstructionKind) -> &'static str { + match kind { + MeteoraDbcInstructionKind::CreatePool => return "pool_lifecycle", + MeteoraDbcInstructionKind::Swap => return "trade", + MeteoraDbcInstructionKind::PoolLifecycle => return "pool_lifecycle", + MeteoraDbcInstructionKind::Fee => return "fee", + MeteoraDbcInstructionKind::Admin => return "admin", + MeteoraDbcInstructionKind::Unknown => return "unknown", + } +} + +fn event_actionability_for_kind( + kind: MeteoraDbcInstructionKind, + skip_reason: std::option::Option<&str>, +) -> &'static str { + if skip_reason.is_some() { + return "decoded_only_with_explicit_skip_reason"; + } + match kind { + MeteoraDbcInstructionKind::PoolLifecycle => return "lifecycle_candidate", + MeteoraDbcInstructionKind::Fee => return "fee_candidate", + MeteoraDbcInstructionKind::Admin => return "admin_candidate", + _ => return "decoded_only", + } +} + +fn skip_reason_key_for_kind(kind: MeteoraDbcInstructionKind) -> &'static str { + match kind { + MeteoraDbcInstructionKind::Fee => return "skipFeeReason", + MeteoraDbcInstructionKind::Admin => return "skipAdminReason", + MeteoraDbcInstructionKind::PoolLifecycle => return "skipLifecycleReason", + _ => return "materializationSkipReason", + } +} + fn extract_log_messages( transaction_json: &serde_json::Value, ) -> std::vec::Vec { @@ -580,53 +2068,6 @@ fn infer_trade_side(log_messages: &[std::string::String]) -> crate::SwapTradeSid return crate::SwapTradeSide::Unknown; } -fn classify_instruction_kind( - parsed_json: std::option::Option<&serde_json::Value>, - instruction_data: std::option::Option<&[u8]>, - log_messages: &[std::string::String], -) -> MeteoraDbcInstructionKind { - let data_kind = classify_instruction_kind_from_data(instruction_data); - if data_kind != MeteoraDbcInstructionKind::Unknown { - return data_kind; - } - if instruction_data_has_full_discriminator(instruction_data) { - return MeteoraDbcInstructionKind::Unknown; - } - let parsed_instruction_name = extract_string_by_candidate_keys( - parsed_json, - &["instruction", "instructionName", "type", "name"], - ); - if let Some(parsed_instruction_name) = parsed_instruction_name { - let normalized = normalize_log_text(parsed_instruction_name.as_str()); - if normalized.contains("createpool") - || normalized.contains("initializepool") - || normalized.contains("launchpool") - { - return MeteoraDbcInstructionKind::CreatePool; - } - if normalized == "swap" || normalized == "swap2" { - return MeteoraDbcInstructionKind::Swap; - } - } - let has_create_config = value_contains_any_key( - parsed_json, - &["poolConfig", "migrationQuoteThreshold", "curveConfig", "dbcConfig"], - ); - if has_create_config { - return MeteoraDbcInstructionKind::CreatePool; - } - if log_messages_contain_any_keyword( - log_messages, - &["create_pool", "createpool", "initialize_pool", "initializepool", "launch_pool"], - ) { - return MeteoraDbcInstructionKind::CreatePool; - } - if log_messages_contain_any_keyword(log_messages, &["swap2", "swap"]) { - return MeteoraDbcInstructionKind::Swap; - } - return MeteoraDbcInstructionKind::Unknown; -} - fn instruction_data_has_full_discriminator(instruction_data: std::option::Option<&[u8]>) -> bool { let instruction_data = match instruction_data { Some(instruction_data) => instruction_data, @@ -635,38 +2076,6 @@ fn instruction_data_has_full_discriminator(instruction_data: std::option::Option return instruction_data.len() >= 8; } -fn classify_instruction_kind_from_data( - instruction_data: std::option::Option<&[u8]>, -) -> MeteoraDbcInstructionKind { - let instruction_data = match instruction_data { - Some(instruction_data) => instruction_data, - None => return MeteoraDbcInstructionKind::Unknown, - }; - if instruction_data.len() < 8 { - return MeteoraDbcInstructionKind::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 == DBC_DISCRIMINATOR_CREATE_POOL - || discriminator == DBC_DISCRIMINATOR_INITIALIZE_POOL - || discriminator == DBC_DISCRIMINATOR_LAUNCH_POOL - { - return MeteoraDbcInstructionKind::CreatePool; - } - if discriminator == DBC_DISCRIMINATOR_SWAP || discriminator == DBC_DISCRIMINATOR_SWAP2 { - return MeteoraDbcInstructionKind::Swap; - } - return MeteoraDbcInstructionKind::Unknown; -} - fn parsed_json_has_trade_amount_or_price_payload( parsed_json: std::option::Option<&serde_json::Value>, ) -> bool { @@ -723,6 +2132,66 @@ fn is_trade_amount_or_price_key(normalized_key: &str) -> bool { || normalized_key == "pricequoteperbase"; } +fn read_8_bytes(data: &[u8], offset: usize) -> [u8; 8] { + let mut bytes = [0_u8; 8]; + if data.len() < offset + 8 { + return bytes; + } + let mut index = 0_usize; + while index < 8 { + bytes[index] = data[offset + index]; + index += 1; + } + return bytes; +} + +fn read_pubkey_string(data: &[u8], offset: usize) -> std::option::Option { + if data.len() < offset + 32 { + return None; + } + return Some(bs58::encode(&data[offset..offset + 32]).into_string()); +} + +fn read_u8_string(data: &[u8], offset: usize) -> std::option::Option { + if data.len() < offset + 1 { + return None; + } + return Some(data[offset].to_string()); +} + +fn read_bool(data: &[u8], offset: usize) -> std::option::Option { + if data.len() < offset + 1 { + return None; + } + return Some(data[offset] != 0); +} + +fn read_u64_string(data: &[u8], offset: usize) -> std::option::Option { + if data.len() < offset + 8 { + return None; + } + let mut bytes = [0_u8; 8]; + let mut index = 0_usize; + while index < 8 { + bytes[index] = data[offset + index]; + index += 1; + } + return Some(u64::from_le_bytes(bytes).to_string()); +} + +fn read_u128_string(data: &[u8], offset: usize) -> std::option::Option { + if data.len() < offset + 16 { + return None; + } + let mut bytes = [0_u8; 16]; + let mut index = 0_usize; + while index < 16 { + bytes[index] = data[offset + index]; + index += 1; + } + return Some(u128::from_le_bytes(bytes).to_string()); +} + #[cfg(test)] mod tests { fn make_create_transaction() -> crate::ChainTransactionDto { @@ -738,7 +2207,7 @@ mod tests { "slot": 888001, "meta": { "logMessages": [ - "Program log: Instruction: CreatePool" + "Program log: Instruction: InitializeVirtualPoolWithSplToken" ] }, "transaction": { @@ -763,11 +2232,12 @@ mod tests { Some("meteora_dbc".to_string()), Some(1), serde_json::json!([ - "DbcPool111", + "DbcConfig111", + "DbcAuthority111", + "DbcCreator111", "DbcTokenA111", crate::WSOL_MINT_ID, - "DbcConfig111", - "DbcCreator111" + "DbcPool111" ]) .to_string(), None, @@ -826,8 +2296,19 @@ mod tests { Some(crate::METEORA_DBC_PROGRAM_ID.to_string()), Some("meteora_dbc".to_string()), Some(1), - serde_json::json!(["DbcPoolSwap111", "DbcSwapTokenA111", crate::WSOL_MINT_ID]) - .to_string(), + serde_json::json!([ + "DbcAuthority111", + "DbcConfigSwap111", + "DbcPoolSwap111", + "InputTokenAccount111", + "OutputTokenAccount111", + "DbcBaseVault111", + "DbcQuoteVault111", + "DbcSwapTokenA111", + crate::WSOL_MINT_ID, + "DbcPayer111" + ]) + .to_string(), None, None, Some( @@ -868,6 +2349,9 @@ mod tests { crate::MeteoraDbcDecodedEvent::Swap(_) => { panic!("unexpected swap event") }, + crate::MeteoraDbcDecodedEvent::Instruction(_) => { + panic!("unexpected generic event") + }, } } @@ -893,6 +2377,9 @@ mod tests { crate::MeteoraDbcDecodedEvent::CreatePool(_) => { panic!("unexpected create event") }, + crate::MeteoraDbcDecodedEvent::Instruction(_) => { + panic!("unexpected generic event") + }, } } @@ -920,6 +2407,9 @@ mod tests { crate::MeteoraDbcDecodedEvent::CreatePool(_) => { panic!("unexpected create event") }, + crate::MeteoraDbcDecodedEvent::Instruction(_) => { + panic!("unexpected generic event") + }, } } @@ -939,6 +2429,9 @@ mod tests { crate::MeteoraDbcDecodedEvent::Swap(_) => { panic!("unexpected swap event") }, + crate::MeteoraDbcDecodedEvent::Instruction(_) => { + panic!("unexpected generic event") + }, } } @@ -949,6 +2442,13 @@ mod tests { assert_eq!(kind, super::MeteoraDbcInstructionKind::Swap); } + #[test] + fn meteora_dbc_create_config_discriminator_is_decoded() { + let data = [0xc9, 0xcf, 0xf3, 0x72, 0x4b, 0x6f, 0x2f, 0xbd, 0x01]; + let kind = super::classify_instruction_kind_from_data(Some(&data)); + assert_eq!(kind, super::MeteoraDbcInstructionKind::Admin); + } + #[test] fn meteora_dbc_unknown_data_discriminator_does_not_fallback_to_global_swap_logs() { let data = [0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, 0x11, 0x01]; @@ -978,6 +2478,349 @@ mod tests { assert_eq!(event.pool_account, Some("DbcPoolSwap111".to_string())); }, crate::MeteoraDbcDecodedEvent::CreatePool(_) => panic!("unexpected create event"), + crate::MeteoraDbcDecodedEvent::Instruction(_) => panic!("unexpected generic event"), } } + + #[test] + fn meteora_dbc_all_instruction_specs_resolve_from_discriminators() { + for spec in super::DBC_INSTRUCTION_SPECS { + let resolved = super::resolve_instruction_spec_from_data(Some(&spec.discriminator[..])); + let resolved = match resolved { + Some(resolved) => resolved, + None => panic!("instruction spec discriminator must resolve: {}", spec.name), + }; + assert_eq!(resolved.name, spec.name); + assert_eq!(resolved.kind, spec.kind); + } + } + + #[test] + fn meteora_dbc_all_generic_anchor_event_discriminators_are_mapped() { + let discriminators = [ + super::DBC_EVENT_DISCRIMINATOR_EVT_CLAIM_CREATOR_TRADING_FEE, + super::DBC_EVENT_DISCRIMINATOR_EVT_CLAIM_POOL_CREATION_FEE, + super::DBC_EVENT_DISCRIMINATOR_EVT_CLAIM_PROTOCOL_FEE, + super::DBC_EVENT_DISCRIMINATOR_EVT_CLAIM_PROTOCOL_LIQUIDITY_MIGRATION_FEE, + super::DBC_EVENT_DISCRIMINATOR_EVT_CLAIM_TRADING_FEE, + super::DBC_EVENT_DISCRIMINATOR_EVT_CLOSE_CLAIM_FEE_OPERATOR, + super::DBC_EVENT_DISCRIMINATOR_EVT_CREATE_CLAIM_FEE_OPERATOR, + super::DBC_EVENT_DISCRIMINATOR_EVT_CREATE_CONFIG, + super::DBC_EVENT_DISCRIMINATOR_EVT_CREATE_CONFIG_V2, + super::DBC_EVENT_DISCRIMINATOR_EVT_CREATE_METEORA_MIGRATION_METADATA, + super::DBC_EVENT_DISCRIMINATOR_EVT_CREATOR_WITHDRAW_SURPLUS, + super::DBC_EVENT_DISCRIMINATOR_EVT_PARTNER_CLAIM_POOL_CREATION_FEE, + super::DBC_EVENT_DISCRIMINATOR_EVT_PARTNER_METADATA, + super::DBC_EVENT_DISCRIMINATOR_EVT_PARTNER_WITHDRAW_MIGRATION_FEE, + super::DBC_EVENT_DISCRIMINATOR_EVT_PARTNER_WITHDRAW_SURPLUS, + super::DBC_EVENT_DISCRIMINATOR_EVT_UPDATE_POOL_CREATOR, + super::DBC_EVENT_DISCRIMINATOR_EVT_VIRTUAL_POOL_METADATA, + super::DBC_EVENT_DISCRIMINATOR_EVT_WITHDRAW_LEFTOVER, + super::DBC_EVENT_DISCRIMINATOR_EVT_WITHDRAW_MIGRATION_FEE, + ]; + for discriminator in discriminators { + let metadata = super::resolve_anchor_event_metadata(discriminator); + assert!(metadata.is_some()); + } + } + + #[test] + fn meteora_dbc_swap2_with_complete_context_is_trade_fallback_candidate() { + let decoder = crate::MeteoraDbcDecoder::new(); + let transaction = make_swap_transaction(); + let mut instruction = make_swap_instruction(); + instruction.data_json = Some(format!( + "\"{}\"", + bs58::encode(&[0x41_u8, 0x4b, 0x3f, 0x4c, 0xeb, 0x5b, 0x5b, 0x88, 0x01]).into_string() + )); + 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::MeteoraDbcDecodedEvent::Swap(event) => { + let payload = event.payload_json.as_object(); + let payload = match payload { + Some(payload) => payload, + None => panic!("payload must be an object"), + }; + assert_eq!( + payload.get("tradeCandidate").and_then(|value| return value.as_bool()), + Some(true) + ); + assert_eq!( + payload.get("candleCandidate").and_then(|value| return value.as_bool()), + Some(true) + ); + assert_eq!( + payload + .get("instructionArgs") + .and_then(|value| return value.as_object()) + .and_then(|object| return object.get("amountResolutionSource")) + .and_then(|value| return value.as_str()), + Some("trade_aggregation_transfer_or_vault_delta_fallback") + ); + }, + crate::MeteoraDbcDecodedEvent::CreatePool(_) => panic!("unexpected create event"), + crate::MeteoraDbcDecodedEvent::Instruction(_) => panic!("unexpected generic event"), + } + } + + fn make_synthetic_transaction(signature: &str, id: i64, instruction_name: &str) -> crate::ChainTransactionDto { + let mut dto = crate::ChainTransactionDto::new( + signature.to_string(), + Some(888100), + Some(1779300100), + Some("helius_primary_http".to_string()), + Some("0".to_string()), + None, + None, + serde_json::json!({ + "slot": 888100, + "meta": { + "logMessages": [ + format!("Program log: Instruction: {}", instruction_name) + ] + }, + "transaction": { + "message": { + "instructions": [] + } + } + }) + .to_string(), + ); + dto.id = Some(id); + return dto; + } + + fn synthetic_accounts() -> std::vec::Vec { + let mut accounts = std::vec::Vec::new(); + for index in 0..24 { + accounts.push(format!("SyntheticDbcAccount{:02}", index)); + } + return accounts; + } + + fn synthetic_payload_for_arg_layout(layout: super::MeteoraDbcArgLayout) -> std::vec::Vec { + let mut payload = std::vec::Vec::new(); + match layout { + super::MeteoraDbcArgLayout::None => {}, + super::MeteoraDbcArgLayout::FlagU8(_) => payload.push(1), + super::MeteoraDbcArgLayout::OneU64(_) => { + payload.extend_from_slice(&123_u64.to_le_bytes()); + }, + super::MeteoraDbcArgLayout::TwoU64(_, _) => { + payload.extend_from_slice(&123_u64.to_le_bytes()); + payload.extend_from_slice(&456_u64.to_le_bytes()); + }, + super::MeteoraDbcArgLayout::PermissionU128(_) => { + payload.extend_from_slice(&789_u128.to_le_bytes()); + }, + super::MeteoraDbcArgLayout::Swap => { + payload.extend_from_slice(&1000_u64.to_le_bytes()); + payload.extend_from_slice(&900_u64.to_le_bytes()); + }, + super::MeteoraDbcArgLayout::Swap2 => { + payload.extend_from_slice(&1000_u64.to_le_bytes()); + payload.extend_from_slice(&900_u64.to_le_bytes()); + payload.push(1); + }, + } + return payload; + } + + fn make_synthetic_instruction_for_spec( + transaction_id: i64, + instruction_id: i64, + spec: super::MeteoraDbcInstructionSpec, + ) -> crate::ChainInstructionDto { + let mut data = std::vec::Vec::new(); + data.extend_from_slice(&spec.discriminator); + data.extend_from_slice(&synthetic_payload_for_arg_layout(spec.arg_layout)); + let mut dto = crate::ChainInstructionDto::new( + transaction_id, + None, + 0, + None, + Some(crate::METEORA_DBC_PROGRAM_ID.to_string()), + Some("meteora_dbc".to_string()), + Some(1), + serde_json::json!(synthetic_accounts()).to_string(), + Some(format!("\"{}\"", bs58::encode(data).into_string())), + None, + None, + ); + dto.id = Some(instruction_id); + return dto; + } + + fn synthetic_anchor_event_data(discriminator: [u8; 8]) -> std::vec::Vec { + let mut data = std::vec::Vec::new(); + data.extend_from_slice(&super::DBC_ANCHOR_SELF_CPI_LOG_SELECTOR); + data.extend_from_slice(&discriminator); + data.extend_from_slice(&[1_u8; 32]); + data.extend_from_slice(&[2_u8; 32]); + data.extend_from_slice(&[0_u8; 32]); + return data; + } + + fn make_synthetic_anchor_instruction( + transaction_id: i64, + instruction_id: i64, + discriminator: [u8; 8], + ) -> crate::ChainInstructionDto { + let data = synthetic_anchor_event_data(discriminator); + let mut dto = crate::ChainInstructionDto::new( + transaction_id, + None, + 1, + Some(0), + Some(crate::METEORA_DBC_PROGRAM_ID.to_string()), + Some("meteora_dbc".to_string()), + Some(2), + serde_json::json!(synthetic_accounts()).to_string(), + Some(format!("\"{}\"", bs58::encode(data).into_string())), + None, + None, + ); + dto.id = Some(instruction_id); + return dto; + } + + #[test] + fn meteora_dbc_synthetic_non_trade_instruction_specs_decode_to_expected_event_kinds() { + let decoder = crate::MeteoraDbcDecoder::new(); + for spec in super::DBC_INSTRUCTION_SPECS { + if spec.kind == super::MeteoraDbcInstructionKind::Swap + || spec.kind == super::MeteoraDbcInstructionKind::CreatePool + { + continue; + } + let transaction = make_synthetic_transaction( + format!("sig-meteora-dbc-synthetic-{}", spec.name).as_str(), + 400, + spec.name, + ); + let instruction = make_synthetic_instruction_for_spec(400, 401, *spec); + let decoded_result = decoder.decode_transaction(&transaction, &[instruction]); + let decoded = match decoded_result { + Ok(decoded) => decoded, + Err(error) => panic!("decode must succeed for {}: {}", spec.name, error), + }; + assert_eq!(decoded.len(), 1, "{} must decode once", spec.name); + match &decoded[0] { + crate::MeteoraDbcDecodedEvent::Instruction(event) => { + assert_eq!(event.event_kind, format!("meteora_dbc.{}", spec.name)); + let payload = match event.payload_json.as_object() { + Some(payload) => payload, + None => panic!("{} payload must be object", spec.name), + }; + assert_eq!( + payload.get("decodedInstructionName").and_then(|value| return value.as_str()), + Some(spec.name) + ); + assert_eq!( + payload.get("eventCategory").and_then(|value| return value.as_str()), + Some(super::event_category_for_kind(spec.kind)) + ); + assert_eq!( + payload.get("tradeCandidate").and_then(|value| return value.as_bool()), + Some(false) + ); + assert_eq!( + payload.get("candleCandidate").and_then(|value| return value.as_bool()), + Some(false) + ); + if let Some(skip_reason) = spec.skip_reason { + let skip_key = super::skip_reason_key_for_kind(spec.kind); + assert_eq!( + payload.get(skip_key).and_then(|value| return value.as_str()), + Some(skip_reason) + ); + } + }, + crate::MeteoraDbcDecodedEvent::CreatePool(_) => { + panic!("{} unexpectedly decoded as create pool", spec.name) + }, + crate::MeteoraDbcDecodedEvent::Swap(_) => { + panic!("{} unexpectedly decoded as swap", spec.name) + }, + } + } + } + + #[test] + fn meteora_dbc_synthetic_generic_anchor_events_decode_with_explicit_skip_reasons() { + let decoder = crate::MeteoraDbcDecoder::new(); + let discriminators = [ + super::DBC_EVENT_DISCRIMINATOR_EVT_CLAIM_CREATOR_TRADING_FEE, + super::DBC_EVENT_DISCRIMINATOR_EVT_CLAIM_POOL_CREATION_FEE, + super::DBC_EVENT_DISCRIMINATOR_EVT_CLAIM_PROTOCOL_FEE, + super::DBC_EVENT_DISCRIMINATOR_EVT_CLAIM_PROTOCOL_LIQUIDITY_MIGRATION_FEE, + super::DBC_EVENT_DISCRIMINATOR_EVT_CLAIM_TRADING_FEE, + super::DBC_EVENT_DISCRIMINATOR_EVT_CLOSE_CLAIM_FEE_OPERATOR, + super::DBC_EVENT_DISCRIMINATOR_EVT_CREATE_CLAIM_FEE_OPERATOR, + super::DBC_EVENT_DISCRIMINATOR_EVT_CREATE_CONFIG, + super::DBC_EVENT_DISCRIMINATOR_EVT_CREATE_CONFIG_V2, + super::DBC_EVENT_DISCRIMINATOR_EVT_CREATE_METEORA_MIGRATION_METADATA, + super::DBC_EVENT_DISCRIMINATOR_EVT_CREATOR_WITHDRAW_SURPLUS, + super::DBC_EVENT_DISCRIMINATOR_EVT_PARTNER_CLAIM_POOL_CREATION_FEE, + super::DBC_EVENT_DISCRIMINATOR_EVT_PARTNER_METADATA, + super::DBC_EVENT_DISCRIMINATOR_EVT_PARTNER_WITHDRAW_MIGRATION_FEE, + super::DBC_EVENT_DISCRIMINATOR_EVT_PARTNER_WITHDRAW_SURPLUS, + super::DBC_EVENT_DISCRIMINATOR_EVT_UPDATE_POOL_CREATOR, + super::DBC_EVENT_DISCRIMINATOR_EVT_VIRTUAL_POOL_METADATA, + super::DBC_EVENT_DISCRIMINATOR_EVT_WITHDRAW_LEFTOVER, + super::DBC_EVENT_DISCRIMINATOR_EVT_WITHDRAW_MIGRATION_FEE, + ]; + for discriminator in discriminators { + let metadata = match super::resolve_anchor_event_metadata(discriminator) { + Some(metadata) => metadata, + None => panic!("anchor metadata must resolve"), + }; + let transaction = make_synthetic_transaction( + format!("sig-meteora-dbc-anchor-{}", metadata.0).as_str(), + 410, + "anchor_self_cpi_log", + ); + let instruction = make_synthetic_anchor_instruction(410, 411, discriminator); + let decoded_result = decoder.decode_transaction(&transaction, &[instruction]); + let decoded = match decoded_result { + Ok(decoded) => decoded, + Err(error) => panic!("anchor decode must succeed for {}: {}", metadata.0, error), + }; + assert_eq!(decoded.len(), 1, "{} must decode once", metadata.0); + match &decoded[0] { + crate::MeteoraDbcDecodedEvent::Instruction(event) => { + assert_eq!(event.event_kind, format!("meteora_dbc.{}", metadata.0)); + let payload = match event.payload_json.as_object() { + Some(payload) => payload, + None => panic!("{} payload must be object", metadata.0), + }; + assert_eq!( + payload.get("anchorEventName").and_then(|value| return value.as_str()), + Some(metadata.1) + ); + assert_eq!( + payload.get("eventActionability").and_then(|value| return value.as_str()), + Some("decoded_only_anchor_event") + ); + assert_eq!( + payload.get(metadata.5).and_then(|value| return value.as_str()), + Some(metadata.6) + ); + }, + crate::MeteoraDbcDecodedEvent::CreatePool(_) => { + panic!("{} unexpectedly decoded as create pool", metadata.0) + }, + crate::MeteoraDbcDecodedEvent::Swap(_) => { + panic!("{} unexpectedly decoded as swap", metadata.0) + }, + } + } + } + } diff --git a/kb_lib/src/dex_decode.rs b/kb_lib/src/dex_decode.rs index 2e0bbab..62966f3 100644 --- a/kb_lib/src/dex_decode.rs +++ b/kb_lib/src/dex_decode.rs @@ -1045,7 +1045,7 @@ impl DexDecodeService { event.instruction_id, "meteora_dbc", event.program_id.clone(), - "meteora_dbc.create_pool", + event.event_kind.as_str(), event.pool_account.clone(), None, event.token_a_mint.clone(), @@ -1063,7 +1063,7 @@ impl DexDecodeService { event.instruction_id, "meteora_dbc", event.program_id.clone(), - "meteora_dbc.swap", + event.event_kind.as_str(), event.pool_account.clone(), None, event.token_a_mint.clone(), @@ -1073,6 +1073,24 @@ impl DexDecodeService { ) .await; }, + crate::MeteoraDbcDecodedEvent::Instruction(event) => { + return self + .materialize_named_dex_event( + transaction, + event.transaction_id, + event.instruction_id, + "meteora_dbc", + event.program_id.clone(), + event.event_kind.as_str(), + event.pool_account.clone(), + None, + event.token_a_mint.clone(), + event.token_b_mint.clone(), + event.related_account.clone(), + event.payload_json.clone(), + ) + .await; + }, } } diff --git a/kb_lib/src/dex_detection_route.rs b/kb_lib/src/dex_detection_route.rs index b2de670..6a780f5 100644 --- a/kb_lib/src/dex_detection_route.rs +++ b/kb_lib/src/dex_detection_route.rs @@ -164,6 +164,9 @@ pub(crate) fn dex_detection_route( ("meteora_dbc", "meteora_dbc.swap") => { return Some(crate::dex_detection_route::DexDetectionRoute::MeteoraDbcPool); }, + ("meteora_dbc", "meteora_dbc.swap2") => { + return Some(crate::dex_detection_route::DexDetectionRoute::MeteoraDbcPool); + }, ("meteora_dlmm", "meteora_dlmm.create_pool") => { return Some(crate::dex_detection_route::DexDetectionRoute::MeteoraDlmmPool); }, @@ -304,6 +307,26 @@ mod tests { assert!(!crate::dex_detection_route::decoded_event_has_full_pool_context(&event)); } + #[test] + fn meteora_dbc_swap2_routes_to_pool_detection() { + let event = make_decoded_event( + "meteora_dbc", + "meteora_dbc.swap2", + Some("Pool111"), + Some("TokenA111"), + Some("TokenB111"), + ); + let route_option = crate::dex_detection_route::dex_detection_route(&event); + let route = match route_option { + Some(route) => route, + None => panic!("route must be selected"), + }; + assert_eq!(route, crate::dex_detection_route::DexDetectionRoute::MeteoraDbcPool); + assert!(crate::dex_detection_route::dex_detection_route_requires_full_pool_context( + route + )); + } + #[test] fn raydium_launchpad_initialize_route_requires_full_pool_context() { let event = make_decoded_event( diff --git a/kb_lib/src/dex_event_classification.rs b/kb_lib/src/dex_event_classification.rs index 004f5d6..d36786d 100644 --- a/kb_lib/src/dex_event_classification.rs +++ b/kb_lib/src/dex_event_classification.rs @@ -350,6 +350,9 @@ pub fn is_dex_trade_event_kind(event_kind: &str) -> bool { if event_kind.ends_with(".swap") { return true; } + if event_kind.ends_with(".swap2") { + return true; + } if event_kind.contains(".swap_") { return true; } @@ -375,6 +378,22 @@ pub fn is_dex_candle_candidate_event_kind(event_kind: &str) -> bool { /// Returns true for liquidity lifecycle changes that must not become candles. pub fn is_dex_liquidity_event_kind(event_kind: &str) -> bool { + if event_kind.starts_with("meteora_dbc.") + && (event_kind.contains("fee") + || event_kind.contains("surplus") + || event_kind.contains("leftover") + || event_kind.contains("zap_protocol_fee")) + { + return false; + } + if event_kind == "meteora_dbc.migrate_meteora_damm_claim_lp_token" + || event_kind == "meteora_dbc.migrate_meteora_damm_lock_lp_token" + { + return false; + } + if event_kind.starts_with("meteora_dbc.") && event_kind.contains("lp_token") { + return true; + } if event_kind.contains(".withdraw_pnl") { return false; } @@ -490,6 +509,14 @@ pub fn is_dex_position_close_event_kind(event_kind: &str) -> bool { /// Returns true for fee collection events. pub fn is_dex_fee_event_kind(event_kind: &str) -> bool { + if event_kind.starts_with("meteora_dbc.") + && (event_kind.contains("fee") + || event_kind.contains("surplus") + || event_kind.contains("leftover") + || event_kind.contains("zap_protocol_fee")) + { + return true; + } if event_kind.starts_with("pump_fees.") && (event_kind.contains("donation_fee_pda_cranked") || event_kind.contains("sweep_buyback") @@ -639,7 +666,7 @@ pub fn is_dex_pool_lifecycle_event_kind(event_kind: &str) -> bool { if event_kind == "raydium_amm_v4.pre_initialize" { return true; } - if event_kind.contains(".create_lock_escrow") { + if event_kind.contains(".create_lock_escrow") || event_kind.contains(".create_locker") { return true; } if event_kind.contains(".initialize_bin_array") { @@ -731,6 +758,9 @@ pub fn is_dex_migration_event_kind(event_kind: &str) -> bool { if event_kind.contains(".migrate_pool_coin_creator") { return false; } + if event_kind.starts_with("meteora_dbc.") && event_kind.contains("metadata") { + return false; + } if event_kind.contains(".migrate") { return true; } @@ -742,6 +772,11 @@ pub fn is_dex_migration_event_kind(event_kind: &str) -> bool { /// Returns true for pool creation or initialization events. pub fn is_dex_pool_creation_event_kind(event_kind: &str) -> bool { + if event_kind == "meteora_dbc.evt_initialize_pool_event" + || event_kind.contains(".initialize_virtual_pool") + { + return true; + } if event_kind == "raydium_amm_v4.pre_initialize" { return true; } @@ -824,6 +859,15 @@ pub fn is_dex_token_account_close_event_kind(event_kind: &str) -> bool { /// Returns true for admin, configuration or permission changes. pub fn is_dex_admin_event_kind(event_kind: &str) -> bool { + if event_kind.starts_with("meteora_dbc.") + && (event_kind.contains("config") + || event_kind.contains("operator") + || event_kind.contains("metadata") + || event_kind.contains("pool_creator") + || event_kind == "meteora_dbc.transfer_pool_creator") + { + return true; + } if event_kind.starts_with("pump_fees.") && (event_kind.contains("authority") || event_kind.contains("admin") @@ -1232,13 +1276,16 @@ mod tests { ); assert_eq!(super::classify_dex_event_category_code("raydium_clmm.swap"), "trade"); assert_eq!(super::classify_dex_event_category_code("raydium_clmm.swap_v2"), "trade"); + assert_eq!(super::classify_dex_event_category_code("meteora_dbc.swap2"), "trade"); assert_eq!(super::classify_dex_event_category_code("raydium_clmm.exact_output"), "trade"); assert_eq!(super::classify_dex_event_category_code("pump_fun.buy"), "trade"); assert_eq!(super::classify_dex_event_category_code("pump_fun.buy_v2"), "trade"); assert_eq!(super::classify_dex_event_category_code("pump_fun.sell_v2"), "trade"); assert_eq!(super::classify_dex_event_category_code("pump_fun.trade_event"), "trade"); assert!(super::is_dex_trade_event_kind("raydium_cpmm.swap_base_input")); + assert!(super::is_dex_trade_event_kind("meteora_dbc.swap2")); assert!(super::is_dex_candle_candidate_event_kind("raydium_cpmm.swap_base_input")); + assert!(super::is_dex_candle_candidate_event_kind("meteora_dbc.swap2")); } #[test] diff --git a/kb_lib/src/dex_event_coverage.rs b/kb_lib/src/dex_event_coverage.rs index 64f2517..e17b471 100644 --- a/kb_lib/src/dex_event_coverage.rs +++ b/kb_lib/src/dex_event_coverage.rs @@ -65,6 +65,25 @@ impl DexEventCoverageService { Err(error) => return Err(error), } } + if decoder_code.as_deref().is_none() || decoder_code.as_deref() == Some("meteora_dbc") { + let supplemental_entries = local_meteora_dbc_registry_entries(); + for entry in &supplemental_entries { + let coverage_entry = build_coverage_entry_from_upstream(entry); + let upsert_result = crate::query_dex_event_coverage_entries_upsert( + self.database.as_ref(), + &coverage_entry, + ) + .await; + match upsert_result { + Ok(_) => upserted_entry_count += 1, + Err(error) => return Err(error), + } + } + return Ok(( + search_result.entries.len() + supplemental_entries.len(), + upserted_entry_count, + )); + } return Ok((search_result.entries.len(), upserted_entry_count)); } @@ -99,6 +118,11 @@ impl DexEventCoverageService { if let Err(error) = duplicate_cleanup_result { return Err(error); } + let meteora_dbc_duplicate_cleanup_result = + self.cleanup_duplicate_meteora_dbc_logical_coverage_rows(&decoder_code).await; + if let Err(error) = meteora_dbc_duplicate_cleanup_result { + return Err(error); + } let refreshed_entry_count = match &decoder_code { Some(decoder_code) => { let refresh_result = @@ -159,6 +183,11 @@ impl DexEventCoverageService { if let Err(error) = duplicate_cleanup_result { return Err(error); } + let meteora_dbc_duplicate_cleanup_result = + self.cleanup_duplicate_meteora_dbc_logical_coverage_rows(&decoder_code).await; + if let Err(error) = meteora_dbc_duplicate_cleanup_result { + return Err(error); + } let refreshed_entry_count = match &decoder_code { Some(decoder_code) => { let refresh_result = @@ -278,6 +307,50 @@ WHERE decoder_code = 'pump_swap' }, } } + + async fn cleanup_duplicate_meteora_dbc_logical_coverage_rows( + &self, + decoder_code: &std::option::Option, + ) -> Result { + if let Some(decoder_code) = decoder_code { + if decoder_code != "meteora_dbc" { + return Ok(0); + } + } + match self.database.connection() { + crate::DatabaseConnection::Sqlite(pool) => { + let query_result = sqlx::query( + r#" +DELETE FROM k_sol_dex_event_coverage_entries +WHERE decoder_code = 'meteora_dbc' + AND id NOT IN ( + SELECT MIN(id) + FROM k_sol_dex_event_coverage_entries + WHERE decoder_code = 'meteora_dbc' + GROUP BY + decoder_code, + COALESCE(program_id, ''), + entry_kind, + entry_name, + COALESCE(discriminator_hex, ''), + COALESCE(local_event_kind, '') + ) + "#, + ) + .execute(pool) + .await; + match query_result { + Ok(query_result) => return Ok(query_result.rows_affected()), + Err(error) => { + return Err(crate::Error::Db(format!( + "cannot delete duplicate Meteora DBC logical coverage rows on sqlite: {}", + error + ))); + }, + } + }, + } + } } fn build_coverage_entry_from_upstream( @@ -309,6 +382,353 @@ fn build_coverage_entry_from_upstream( return coverage_entry; } +fn local_meteora_dbc_registry_entries() -> std::vec::Vec { + let mut entries = std::vec::Vec::new(); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_INSTRUCTION, + "anchor_self_cpi_log", + Some("e445a52e51cb9a1d"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_INSTRUCTION, + "instruction_audit", + None, + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_INSTRUCTION, + "claim_creator_trading_fee", + Some("52dcfabd03556b2d"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_INSTRUCTION, + "claim_partner_pool_creation_fee", + Some("faee1a048b0a65f8"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_INSTRUCTION, + "claim_protocol_fee", + Some("a5e4853063f9ff21"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_INSTRUCTION, + "claim_protocol_pool_creation_fee", + Some("72cd53bcf0991936"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_INSTRUCTION, + "claim_trading_fee", + Some("08ec5931987db151"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_INSTRUCTION, + "close_claim_protocol_fee_operator", + Some("082957235030791a"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_INSTRUCTION, + "close_operator_account", + Some("ab09d54a7817031d"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_INSTRUCTION, + "create_config", + Some("c9cff3724b6f2fbd"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_INSTRUCTION, + "create_locker", + Some("a75a899a4b2f1154"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_INSTRUCTION, + "create_operator_account", + Some("dd40f695f099e5a3"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_INSTRUCTION, + "create_partner_metadata", + Some("c0a8eabfbce2e3ff"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_INSTRUCTION, + "create_virtual_pool_metadata", + Some("2d61bb67fe6d7c86"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_INSTRUCTION, + "creator_withdraw_surplus", + Some("a50389071c864c50"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_INSTRUCTION, + "initialize_virtual_pool_with_spl_token", + Some("8c55d7b06636684f"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_INSTRUCTION, + "initialize_virtual_pool_with_token2022", + Some("a976334e916edc9b"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_INSTRUCTION, + "migrate_meteora_damm", + Some("1b013016b43f76d9"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_INSTRUCTION, + "migrate_meteora_damm_claim_lp_token", + Some("8b85021e5b917f9a"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_INSTRUCTION, + "migrate_meteora_damm_lock_lp_token", + Some("b137ee9dfb58a52a"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_INSTRUCTION, + "migration_damm_v2", + Some("9ca9e66735e45040"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_INSTRUCTION, + "migration_damm_v2_create_metadata", + Some("6dbd1324c3b7de52"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_INSTRUCTION, + "migration_meteora_damm_create_metadata", + Some("2f5e7e73dde2c285"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_INSTRUCTION, + "partner_withdraw_surplus", + Some("a8ad4864c962265c"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_INSTRUCTION, + "swap", + Some("f8c69e91e17587c8"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_INSTRUCTION, + "swap2", + Some("414b3f4ceb5b5b88"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_INSTRUCTION, + "transfer_pool_creator", + Some("1407a9213a93a621"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_INSTRUCTION, + "withdraw_leftover", + Some("14c6caedebf3b742"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_INSTRUCTION, + "withdraw_migration_fee", + Some("ed8e2d178106dea2"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_INSTRUCTION, + "zap_protocol_fee", + Some("d59bbb2238b65bf0"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_EVENT, + "evt_claim_creator_trading_fee_event_local_idl", + Some("9ae4d7ca859bd68a"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_EVENT, + "evt_claim_pool_creation_fee_event_local_idl", + Some("956f952c8840af3e"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_EVENT, + "evt_claim_protocol_fee_event_local_idl", + Some("baf44bfbbc0d1921"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_EVENT, + "evt_claim_protocol_liquidity_migration_fee_event_local_idl", + Some("51a8741fa1561b23"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_EVENT, + "evt_claim_trading_fee_event_local_idl", + Some("1a5375f05cca70fe"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_EVENT, + "evt_close_claim_fee_operator_event_local_idl", + Some("6f2725376ed8c217"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_EVENT, + "evt_create_claim_fee_operator_event_local_idl", + Some("1506997844741cb1"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_EVENT, + "evt_create_config_event_local_idl", + Some("83cfb4aeb449a536"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_EVENT, + "evt_create_config_v2_event_local_idl", + Some("a34a42bb77c31a90"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_EVENT, + "evt_create_meteora_migration_metadata_event_local_idl", + Some("63a7853fd68faf8b"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_EVENT, + "evt_creator_withdraw_surplus_event_local_idl", + Some("9849150f4257359d"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_EVENT, + "evt_curve_complete_event_local_idl", + Some("e5e756549c864b18"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_EVENT, + "evt_initialize_pool_event_local_idl", + Some("e432f655cb428625"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_EVENT, + "evt_partner_claim_pool_creation_fee_event_local_idl", + Some("aedf2c96916259c3"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_EVENT, + "evt_partner_metadata_event_local_idl", + Some("c87f06370d200896"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_EVENT, + "evt_partner_withdraw_migration_fee_event_local_idl", + Some("b5697f4308bb7839"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_EVENT, + "evt_partner_withdraw_surplus_event_local_idl", + Some("c3389809e8482316"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_EVENT, + "evt_swap_event_local_idl", + Some("1b3c15d58aaabb93"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_EVENT, + "evt_swap2_event_local_idl", + Some("bd4233a826507599"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_EVENT, + "evt_update_pool_creator_event_local_idl", + Some("6be1a5ed5b9ed5dc"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_EVENT, + "evt_virtual_pool_metadata_event_local_idl", + Some("bc12484cc35b264a"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_EVENT, + "evt_withdraw_leftover_event_local_idl", + Some("bfbd688f6f9c5ee5"), + ); + add_local_meteora_dbc_entry( + &mut entries, + crate::ENTRY_KIND_EVENT, + "evt_withdraw_migration_fee_event_local_idl", + Some("1acb5455a11764d6"), + ); + return entries; +} + +fn add_local_meteora_dbc_entry( + entries: &mut std::vec::Vec, + entry_kind: &str, + entry_name: &str, + discriminator_hex: std::option::Option<&str>, +) { + entries.push(crate::UpstreamRegistryEntryDto { + source_repo: Some("local_idl".to_string()), + source_path: Some( + "idls/meteora_dbc.dbcij3LWUppWqq96dh6gJWwBifmcGfLSB5D4DuSMaqN.json".to_string(), + ), + decoder_code: "meteora_dbc".to_string(), + program_id: Some(crate::METEORA_DBC_PROGRAM_ID.to_string()), + program_family: "meteora".to_string(), + surface_kind: "launch_bonding_curve".to_string(), + entry_kind: entry_kind.to_string(), + entry_name: entry_name.to_string(), + discriminator_hex: discriminator_hex.map(|value| return value.to_string()), + discriminator_len: discriminator_hex.map(|_| return 8_u16), + proof_status: crate::PROOF_STATUS_UPSTREAM_GIT_MAPPED_UNVERIFIED.to_string(), + notes: "supplemental local Meteora DBC IDL coverage row".to_string(), + }); +} + fn infer_expected_db_target_for_entry( decoder_code: &str, entry_name: &str, @@ -324,6 +744,9 @@ fn infer_expected_db_target_for_entry( if decoder_code == "pump_fees" { return infer_pump_fees_expected_db_target(entry_name, entry_kind); } + if decoder_code == "meteora_dbc" { + return infer_meteora_dbc_expected_db_target(entry_name, entry_kind); + } if decoder_code == "raydium_cpmm" && (entry_name == "swap_event" || entry_name == "anchor_idl_instruction") { @@ -1094,9 +1517,236 @@ fn infer_event_family_for_entry( if decoder_code == "raydium_stable_swap" { return infer_raydium_stable_swap_event_family(entry_name, entry_kind); } + if decoder_code == "meteora_dbc" { + return infer_meteora_dbc_event_family(entry_name, entry_kind); + } return infer_event_family(entry_name, entry_kind); } +fn infer_meteora_dbc_expected_db_target( + entry_name: &str, + entry_kind: &str, +) -> std::option::Option { + if entry_kind == crate::ENTRY_KIND_PROGRAM || entry_kind == crate::ENTRY_KIND_ACCOUNT { + return Some(crate::DexEventCoverageEntryDto::DB_TARGET_DECODED_EVENTS_ONLY.to_string()); + } + let family = infer_meteora_dbc_event_family(entry_name, entry_kind); + let family = match family { + Some(family) => family, + None => { + return Some( + crate::DexEventCoverageEntryDto::DB_TARGET_DECODED_EVENTS_ONLY.to_string(), + ); + }, + }; + let normalized_entry_name = entry_name.strip_suffix("_local_idl").unwrap_or(entry_name); + if family == "swap" && (normalized_entry_name == "swap" || normalized_entry_name == "swap2") { + return Some(crate::DexEventCoverageEntryDto::DB_TARGET_TRADE_EVENTS.to_string()); + } + if family == "swap" { + return Some(crate::DexEventCoverageEntryDto::DB_TARGET_DECODED_EVENTS_ONLY.to_string()); + } + if entry_kind == crate::ENTRY_KIND_EVENT + && meteora_dbc_anchor_event_is_decoded_only_source(normalized_entry_name) + { + return Some(crate::DexEventCoverageEntryDto::DB_TARGET_DECODED_EVENTS_ONLY.to_string()); + } + if family == "pool_create" || family == "migration" || family == "pool_lifecycle" { + return Some(crate::DexEventCoverageEntryDto::DB_TARGET_POOL_LIFECYCLE_EVENTS.to_string()); + } + if family == "liquidity" { + return Some(crate::DexEventCoverageEntryDto::DB_TARGET_LIQUIDITY_EVENTS.to_string()); + } + if family == "fee" { + return Some(crate::DexEventCoverageEntryDto::DB_TARGET_FEE_EVENTS.to_string()); + } + if family == "admin_config" { + return Some(crate::DexEventCoverageEntryDto::DB_TARGET_POOL_ADMIN_EVENTS.to_string()); + } + if family == "audit" { + return Some(crate::DexEventCoverageEntryDto::DB_TARGET_DECODED_EVENTS_ONLY.to_string()); + } + return Some(crate::DexEventCoverageEntryDto::DB_TARGET_DECODED_EVENTS_ONLY.to_string()); +} + +fn meteora_dbc_anchor_event_is_decoded_only_source(entry_name: &str) -> bool { + return matches!( + entry_name, + "evt_claim_creator_trading_fee_event" + | "evt_claim_pool_creation_fee_event" + | "evt_claim_protocol_fee_event" + | "evt_claim_protocol_liquidity_migration_fee_event" + | "evt_claim_trading_fee_event" + | "evt_close_claim_fee_operator_event" + | "evt_create_claim_fee_operator_event" + | "evt_create_config_event" + | "evt_create_config_v2_event" + | "evt_create_meteora_migration_metadata_event" + | "evt_creator_withdraw_surplus_event" + | "evt_initialize_pool_event" + | "evt_partner_claim_pool_creation_fee_event" + | "evt_partner_metadata_event" + | "evt_partner_withdraw_migration_fee_event" + | "evt_partner_withdraw_surplus_event" + | "evt_update_pool_creator_event" + | "evt_virtual_pool_metadata_event" + | "evt_withdraw_leftover_event" + | "evt_withdraw_migration_fee_event" + ); +} + +fn infer_meteora_dbc_event_family( + entry_name: &str, + entry_kind: &str, +) -> std::option::Option { + if entry_kind == crate::ENTRY_KIND_PROGRAM { + return None; + } + if entry_name == "anchor_self_cpi_log" || entry_name == "instruction_audit" { + return Some("audit".to_string()); + } + if entry_name == "swap" + || entry_name == "swap2" + || entry_name == "evt_swap_event" + || entry_name == "evt_swap2_event" + { + return Some("swap".to_string()); + } + if entry_name == "initialize_virtual_pool_with_spl_token" + || entry_name == "initialize_virtual_pool_with_token2022" + || entry_name == "evt_initialize_pool_event" + { + return Some("pool_create".to_string()); + } + if entry_name == "create_locker" { + return Some("pool_lifecycle".to_string()); + } + if entry_name.contains("metadata") { + return Some("admin_config".to_string()); + } + if entry_name.contains("fee") + || entry_name.contains("surplus") + || entry_name.contains("leftover") + || entry_name == "zap_protocol_fee" + { + return Some("fee".to_string()); + } + if entry_name.contains("migrate") + || entry_name.contains("migration") + || entry_name == "evt_curve_complete_event" + { + return Some("migration".to_string()); + } + if entry_name.contains("lp_token") { + return Some("liquidity".to_string()); + } + if entry_name.contains("config") + || entry_name.contains("operator") + || entry_name.contains("metadata") + || entry_name == "transfer_pool_creator" + || entry_name == "evt_update_pool_creator_event" + || entry_name == "evt_partner_metadata_event" + || entry_name == "evt_virtual_pool_metadata_event" + { + return Some("admin_config".to_string()); + } + return infer_event_family(entry_name, entry_kind); +} + +fn meteora_dbc_local_event_kind(entry_name: &str) -> std::option::Option { + if entry_name == "program" || entry_name == "state" { + return None; + } + let normalized_event_name = match entry_name.strip_suffix("_local_idl") { + Some(value) => value, + None => entry_name, + }; + if normalized_event_name == "anchor_self_cpi_log" + || normalized_event_name == "instruction_audit" + { + return Some("meteora_dbc.instruction_audit".to_string()); + } + if normalized_event_name == "initialize_virtual_pool_with_spl_token" + || normalized_event_name == "initialize_virtual_pool_with_token2022" + { + return Some("meteora_dbc.create_pool".to_string()); + } + if entry_name.starts_with("evt_") && !entry_name.ends_with("_local_idl") { + return None; + } + if meteora_dbc_local_instruction_entry_is_known(entry_name) + || meteora_dbc_local_event_entry_is_known(normalized_event_name) + { + return Some(format!("meteora_dbc.{}", normalized_event_name)); + } + return None; +} + +fn meteora_dbc_local_instruction_entry_is_known(entry_name: &str) -> bool { + return matches!( + entry_name, + "anchor_self_cpi_log" + | "instruction_audit" + | "claim_creator_trading_fee" + | "claim_partner_pool_creation_fee" + | "claim_protocol_fee" + | "claim_protocol_pool_creation_fee" + | "claim_trading_fee" + | "close_claim_protocol_fee_operator" + | "close_operator_account" + | "create_config" + | "create_locker" + | "create_operator_account" + | "create_partner_metadata" + | "create_virtual_pool_metadata" + | "creator_withdraw_surplus" + | "initialize_virtual_pool_with_spl_token" + | "initialize_virtual_pool_with_token2022" + | "migrate_meteora_damm" + | "migrate_meteora_damm_claim_lp_token" + | "migrate_meteora_damm_lock_lp_token" + | "migration_damm_v2" + | "migration_damm_v2_create_metadata" + | "migration_meteora_damm_create_metadata" + | "partner_withdraw_surplus" + | "swap" + | "swap2" + | "transfer_pool_creator" + | "withdraw_leftover" + | "withdraw_migration_fee" + | "zap_protocol_fee" + ); +} + +fn meteora_dbc_local_event_entry_is_known(entry_name: &str) -> bool { + return matches!( + entry_name, + "evt_claim_creator_trading_fee_event" + | "evt_claim_pool_creation_fee_event" + | "evt_claim_protocol_fee_event" + | "evt_claim_protocol_liquidity_migration_fee_event" + | "evt_claim_trading_fee_event" + | "evt_close_claim_fee_operator_event" + | "evt_create_claim_fee_operator_event" + | "evt_create_config_event" + | "evt_create_config_v2_event" + | "evt_create_meteora_migration_metadata_event" + | "evt_creator_withdraw_surplus_event" + | "evt_curve_complete_event" + | "evt_initialize_pool_event" + | "evt_partner_claim_pool_creation_fee_event" + | "evt_partner_metadata_event" + | "evt_partner_withdraw_migration_fee_event" + | "evt_partner_withdraw_surplus_event" + | "evt_swap_event" + | "evt_swap2_event" + | "evt_update_pool_creator_event" + | "evt_virtual_pool_metadata_event" + | "evt_withdraw_leftover_event" + | "evt_withdraw_migration_fee_event" + ); +} + fn infer_raydium_amm_v4_event_family( entry_name: &str, entry_kind: &str, @@ -1479,6 +2129,9 @@ pub(crate) fn known_local_event_kind( if decoder_code == "pump_swap" { return pump_swap_local_event_kind(entry_name); } + if decoder_code == "meteora_dbc" { + return meteora_dbc_local_event_kind(entry_name); + } if decoder_code == "raydium_amm_v4" { return raydium_amm_v4_local_event_kind(entry_name); } diff --git a/kb_lib/src/instruction_observation_index.rs b/kb_lib/src/instruction_observation_index.rs index 81603e1..d829f03 100644 --- a/kb_lib/src/instruction_observation_index.rs +++ b/kb_lib/src/instruction_observation_index.rs @@ -200,6 +200,45 @@ fn resolve_instruction_name( Some(discriminator_hex) => discriminator_hex, None => return None, }; + if program_id == crate::METEORA_DBC_PROGRAM_ID || decoder_code == Some("meteora_dbc") { + let name = match discriminator_hex { + "e445a52e51cb9a1d" => "meteora_dbc.anchor_self_cpi_log", + "e992d18ecf6840bc" => "meteora_dbc.create_pool_legacy", + "5fb40aac54aee828" => "meteora_dbc.initialize_pool_legacy", + "a677d1b6d66d3ab5" => "meteora_dbc.launch_pool_legacy", + "52dcfabd03556b2d" => "meteora_dbc.claim_creator_trading_fee", + "faee1a048b0a65f8" => "meteora_dbc.claim_partner_pool_creation_fee", + "a5e4853063f9ff21" => "meteora_dbc.claim_protocol_fee", + "72cd53bcf0991936" => "meteora_dbc.claim_protocol_pool_creation_fee", + "08ec5931987db151" => "meteora_dbc.claim_trading_fee", + "082957235030791a" => "meteora_dbc.close_claim_protocol_fee_operator", + "ab09d54a7817031d" => "meteora_dbc.close_operator_account", + "c9cff3724b6f2fbd" => "meteora_dbc.create_config", + "a75a899a4b2f1154" => "meteora_dbc.create_locker", + "dd40f695f099e5a3" => "meteora_dbc.create_operator_account", + "c0a8eabfbce2e3ff" => "meteora_dbc.create_partner_metadata", + "2d61bb67fe6d7c86" => "meteora_dbc.create_virtual_pool_metadata", + "a50389071c864c50" => "meteora_dbc.creator_withdraw_surplus", + "8c55d7b06636684f" => "meteora_dbc.initialize_virtual_pool_with_spl_token", + "a976334e916edc9b" => "meteora_dbc.initialize_virtual_pool_with_token2022", + "1b013016b43f76d9" => "meteora_dbc.migrate_meteora_damm", + "8b85021e5b917f9a" => "meteora_dbc.migrate_meteora_damm_claim_lp_token", + "b137ee9dfb58a52a" => "meteora_dbc.migrate_meteora_damm_lock_lp_token", + "9ca9e66735e45040" => "meteora_dbc.migration_damm_v2", + "6dbd1324c3b7de52" => "meteora_dbc.migration_damm_v2_create_metadata", + "2f5e7e73dde2c285" => "meteora_dbc.migration_meteora_damm_create_metadata", + "a8ad4864c962265c" => "meteora_dbc.partner_withdraw_surplus", + "3688e18aacb6d6a7" => "meteora_dbc.protocol_withdraw_surplus", + "f8c69e91e17587c8" => "meteora_dbc.swap", + "414b3f4ceb5b5b88" => "meteora_dbc.swap2", + "1407a9213a93a621" => "meteora_dbc.transfer_pool_creator", + "14c6caedebf3b742" => "meteora_dbc.withdraw_leftover", + "ed8e2d178106dea2" => "meteora_dbc.withdraw_migration_fee", + "d59bbb2238b65bf0" => "meteora_dbc.zap_protocol_fee", + _ => return None, + }; + return Some(name.to_string()); + } if program_id == crate::RAYDIUM_AMM_V4_PROGRAM_ID || decoder_code == Some("raydium_amm_v4") { let name = match discriminator_hex { "00" => "raydium_amm_v4.initialize", @@ -536,3 +575,31 @@ fn option_i64_key(value: std::option::Option) -> std::string::String { None => return "-".to_string(), } } + +#[cfg(test)] +mod tests { + #[test] + fn resolves_meteora_dbc_instruction_observation_names() { + let anchor_name = super::resolve_instruction_name( + crate::METEORA_DBC_PROGRAM_ID, + Some("meteora_dbc"), + Some("e445a52e51cb9a1d"), + ); + assert_eq!(anchor_name.as_deref(), Some("meteora_dbc.anchor_self_cpi_log")); + let swap2_name = super::resolve_instruction_name( + crate::METEORA_DBC_PROGRAM_ID, + Some("meteora_dbc"), + Some("414b3f4ceb5b5b88"), + ); + assert_eq!(swap2_name.as_deref(), Some("meteora_dbc.swap2")); + let create_name = super::resolve_instruction_name( + crate::METEORA_DBC_PROGRAM_ID, + Some("meteora_dbc"), + Some("8c55d7b06636684f"), + ); + assert_eq!( + create_name.as_deref(), + Some("meteora_dbc.initialize_virtual_pool_with_spl_token") + ); + } +} diff --git a/kb_lib/src/lib.rs b/kb_lib/src/lib.rs index 64d7f32..16bc59c 100644 --- a/kb_lib/src/lib.rs +++ b/kb_lib/src/lib.rs @@ -497,6 +497,10 @@ pub use db::DexEventCoverageEntryEntity; pub use db::DexEventCoverageSummaryDto; /// Aggregated DEX event coverage summary row. pub use db::DexEventCoverageSummaryEntity; +/// Application-facing normalized fee event amount leg DTO. +pub use db::FeeEventAmountDto; +/// Persisted normalized fee event amount leg row. +pub use db::FeeEventAmountEntity; /// Normalized fee event persisted from useful non-trade DEX events. pub use db::FeeEventDto; /// Persisted fee event row. @@ -655,10 +659,6 @@ 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 -/// later because it may correspond to an unsupported DEX, launch surface, -/// migration path or protocol-specific non-trade event. pub use db::ProtocolCandidateDto; /// Persisted protocol candidate row. pub use db::ProtocolCandidateEntity; @@ -804,13 +804,22 @@ pub use db::query_dexs_get_by_code; pub use db::query_dexs_list; /// Inserts or updates one normalized DEX row by code. pub use db::query_dexs_upsert; +/// Deletes amount legs for one normalized fee event. +pub use db::query_fee_event_amounts_backfill_single_leg_from_fee_events; +/// Deletes fee amount legs for one parent fee event. +pub use db::query_fee_event_amounts_delete_by_fee_event_id; +/// Lists amount legs for one normalized fee event. +pub use db::query_fee_event_amounts_list_by_fee_event_id; +/// Replaces amount legs for one normalized fee event. +pub use db::query_fee_event_amounts_replace_for_fee_event; /// Returns one fee event by decoded-event id. pub use db::query_fee_events_get_by_decoded_event_id; /// Lists recent fee events ordered from newest to oldest. pub use db::query_fee_events_list_recent; /// Inserts or updates one normalized fee event row. pub use db::query_fee_events_upsert; -/// Inserts one on-chain observation row and returns its numeric id. +/// Inserts or updates a fee parent and atomically replaces its amount legs. +pub use db::query_fee_events_upsert_with_amount_legs; /// Lists instruction-observation source rows for one transaction signature. pub use db::query_instruction_observation_source_rows_list_by_signature; /// Lists recent instruction-observation source rows. @@ -984,8 +993,6 @@ 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. -/// -/// This is useful before recomputing candidates for a replayed transaction. pub use db::query_protocol_candidates_delete_by_transaction_id; /// Inserts one protocol candidate row. pub use db::query_protocol_candidates_insert; @@ -1133,6 +1140,8 @@ pub use dex::MeteoraDbcCreatePoolDecoded; pub use dex::MeteoraDbcDecodedEvent; /// Meteora DBC decoder. pub use dex::MeteoraDbcDecoder; +/// Decoded Meteora DBC instruction or Anchor event. +pub use dex::MeteoraDbcInstructionDecoded; /// Decoded Meteora DBC swap event. pub use dex::MeteoraDbcSwapDecoded; /// Decoded Meteora DLMM create-pool event. @@ -1293,6 +1302,7 @@ pub use dex_event_classification::is_dex_admin_event_kind; pub use dex_event_classification::is_dex_candle_candidate_event_kind; /// Returns true for fee collection DEX events. pub use dex_event_classification::is_dex_fee_event_kind; +/// Returns true for decoded audit-only events retained for corpus analysis. pub use dex_event_classification::is_dex_informational_event_kind; /// Returns true for launch or bonding-curve creation DEX events. pub use dex_event_classification::is_dex_launch_event_kind; @@ -1347,9 +1357,6 @@ pub use dex_support_matrix::dex_support_matrix_entry_by_program_id; /// Returns all DEX support matrix entries as owned DTOs. pub use dex_support_matrix::dex_support_matrix_entry_dtos; /// Global error type used by the `kb_lib` crate. -/// -/// The project intentionally avoids `anyhow` and `thiserror`, so this -/// enum centralizes the main error families with explicit textual messages. pub use error::Error; /// Generic asynchronous HTTP client. pub use http_client::HttpClient; @@ -1394,19 +1401,10 @@ pub use json_rpc_ws::JsonRpcWsRequest; /// JSON-RPC 2.0 success response. pub use json_rpc_ws::JsonRpcWsSuccessResponse; /// Parses a JSON value into a JSON-RPC incoming message. -/// -/// This parser accepts only server-originating incoming message shapes: -/// success responses, error responses, and notifications. pub use json_rpc_ws::is_probable_json_rpc_object_text; /// Parses a raw text message into a JSON-RPC incoming message. -/// -/// This parser accepts only server-originating incoming message shapes: -/// success responses, error responses, and notifications. pub use json_rpc_ws::parse_json_rpc_ws_incoming_text; /// Parses a JSON value into a JSON-RPC incoming message. -/// -/// This parser accepts only server-originating incoming message shapes: -/// success responses, error responses, and notifications. pub use json_rpc_ws::parse_json_rpc_ws_incoming_value; /// Result of one launch surface attribution. pub use launch_origin::LaunchAttributionResult; @@ -1467,14 +1465,8 @@ pub use pair_analytic_signal::PairAnalyticSignalService; /// One pair-candle aggregation result. pub use pair_candle_aggregation::PairCandleAggregationResult; /// Pair-candle aggregation service. -/// -/// This service materializes a small set of standard timeframes in base storage. -/// Arbitrary timeframes are rebuilt on demand through `PairCandleQueryService`. pub use pair_candle_aggregation::PairCandleAggregationService; /// Pair-candle query service. -/// -/// Standard materialized timeframes are served from base storage. -/// Arbitrary timeframes are rebuilt on demand from `trade_events`. pub use pair_candle_query::PairCandleQueryService; /// Summary produced by a pair-symbol refresh pass. pub use pair_symbol::PairSymbolRefreshResult; @@ -1493,11 +1485,6 @@ pub use solana_pubsub_ws::SolanaWsTypedNotification; /// Parses a Solana JSON-RPC notification into an official typed payload. pub use solana_pubsub_ws::parse_solana_ws_typed_notification; /// Parses a typed Solana PubSub notification from a generic websocket event. -/// -/// This returns: -/// - `Ok(Some(...))` for JSON-RPC notification-bearing events -/// - `Ok(None)` for events that do not carry a notification -/// - `Err(...)` when a notification is present but cannot be decoded pub use solana_pubsub_ws::parse_solana_ws_typed_notification_from_event; /// One pool-backfill result summary. pub use token_backfill::PoolBackfillResult; @@ -1510,22 +1497,14 @@ pub use token_backfill::SignatureBatchBackfillResult; /// One token-backfill result summary. pub use token_backfill::TokenBackfillResult; /// Historical token backfill service. -/// -/// This service reuses the existing transaction projection and downstream -/// DEX pipeline instead of introducing a separate historical code path. pub use token_backfill::TokenBackfillService; /// Summary produced by a token metadata backfill pass. pub use token_metadata::TokenMetadataBackfillResult; /// Service that enriches persisted token rows with mint and display metadata. pub use token_metadata::TokenMetadataBackfillService; /// Guard keeping non-blocking tracing workers alive. -/// -/// The guard must remain alive during the whole application lifetime so that -/// buffered log records are flushed correctly. pub use tracing::TracingGuard; /// Initializes the global tracing subscriber. -/// -/// This function is expected to be called once at application startup. pub use tracing::init_tracing; /// One trade-aggregation result. pub use trade_aggregation::TradeAggregationResult; @@ -1543,8 +1522,7 @@ pub use tx_resolution::TransactionResolutionRequest; pub use tx_resolution::TransactionResolutionService; /// One forwarded WebSocket notification envelope for transaction resolution. pub use tx_resolution::WsTransactionResolutionEnvelope; -/// Relay that consumes forwarded WS notifications and resolves matching -/// signatures through HTTP `getTransaction`. +/// Relay that consumes forwarded WS notifications and resolves matching signatures through HTTP `getTransaction`. pub use tx_resolution::WsTransactionResolutionRelay; /// Runtime statistics for one transaction resolution relay worker. pub use tx_resolution::WsTransactionResolutionRelayStats; diff --git a/kb_lib/src/non_trade_event_materialization.rs b/kb_lib/src/non_trade_event_materialization.rs index 5f5c846..652bd84 100644 --- a/kb_lib/src/non_trade_event_materialization.rs +++ b/kb_lib/src/non_trade_event_materialization.rs @@ -41,6 +41,133 @@ struct NonTradeDecodedEventContext { pair: std::option::Option, } +fn should_skip_non_trade_event_due_to_explicit_reason( + decoded_event: &crate::DexDecodedEventDto, + payload: &serde_json::Value, +) -> bool { + if crate::is_dex_liquidity_event_kind(decoded_event.event_kind.as_str()) + && payload_has_non_empty_text(payload, "skipLiquidityReason") + { + return true; + } + if crate::is_dex_pool_lifecycle_event_kind(decoded_event.event_kind.as_str()) + && (payload_has_non_empty_text(payload, "skipLifecycleReason") + || payload_has_non_empty_text(payload, "skipCatalogReason")) + { + return true; + } + if crate::is_dex_fee_event_kind(decoded_event.event_kind.as_str()) + && payload_has_non_empty_text(payload, "skipFeeReason") + { + return true; + } + if crate::is_dex_reward_event_kind(decoded_event.event_kind.as_str()) + && payload_has_non_empty_text(payload, "skipRewardReason") + { + return true; + } + if crate::is_dex_admin_event_kind(decoded_event.event_kind.as_str()) + && payload_has_non_empty_text(payload, "skipAdminReason") + { + return true; + } + return false; +} + + +fn should_attempt_meteora_dbc_explicit_skip_materialization( + decoded_event: &crate::DexDecodedEventDto, + payload: &serde_json::Value, +) -> bool { + if decoded_event.protocol_name != "meteora_dbc" { + return false; + } + if payload.get("anchorSelfCpiLog").and_then(serde_json::Value::as_bool) == Some(true) { + return false; + } + let event_kind = decoded_event.event_kind.as_str(); + if is_meteora_dbc_instruction_fee_materialization_candidate(event_kind) { + return true; + } + if is_meteora_dbc_instruction_admin_materialization_candidate(event_kind) { + return true; + } + return false; +} + +fn is_meteora_dbc_instruction_fee_materialization_candidate(event_kind: &str) -> bool { + return matches!( + event_kind, + "meteora_dbc.claim_creator_trading_fee" + | "meteora_dbc.claim_partner_pool_creation_fee" + | "meteora_dbc.claim_protocol_fee" + | "meteora_dbc.claim_protocol_pool_creation_fee" + | "meteora_dbc.claim_trading_fee" + | "meteora_dbc.creator_withdraw_surplus" + | "meteora_dbc.partner_withdraw_surplus" + | "meteora_dbc.withdraw_leftover" + | "meteora_dbc.withdraw_migration_fee" + | "meteora_dbc.zap_protocol_fee" + ); +} + +fn is_meteora_dbc_instruction_admin_materialization_candidate(event_kind: &str) -> bool { + return matches!( + event_kind, + "meteora_dbc.create_partner_metadata" + | "meteora_dbc.create_virtual_pool_metadata" + | "meteora_dbc.migration_damm_v2_create_metadata" + | "meteora_dbc.migration_meteora_damm_create_metadata" + ); +} + + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FeeAmountRecoveryPolicy { + Disabled, + InnerSplTransfer, +} + +fn fee_amount_recovery_policy_for_event_kind(event_kind: &str) -> FeeAmountRecoveryPolicy { + return match event_kind { + "pump_fees.crank_donation_fee_pda" + | "pump_fees.sweep_buyback" + | "pump_fun.collect_creator_fee" + | "pump_fun.collect_creator_fee_v2" + | "pump_fun.distribute_creator_fees" + | "pump_fun.distribute_creator_fees_v2" + | "pump_swap.collect_coin_creator_fee" + | "pump_swap.transfer_creator_fees_to_pump" + | "pump_swap.transfer_creator_fees_to_pump_v2" + | "raydium_amm_v4.withdraw_pnl" + | "raydium_amm_v4.withdraw_srm" + | "raydium_clmm.collect_fund_fee" + | "raydium_clmm.collect_protocol_fee_event" + | "raydium_cpmm.collect_creator_fee" + | "raydium_cpmm.collect_fund_fee" + | "raydium_cpmm.collect_protocol_fee" + | "raydium_launchpad.claim_creator_fee" + | "raydium_launchpad.claim_platform_fee" + | "raydium_launchpad.claim_platform_fee_from_vault" + | "raydium_launchpad.collect_fee" + | "raydium_stable_swap.withdraw_pnl" + | "raydium_stable_swap.withdraw_srm" => FeeAmountRecoveryPolicy::InnerSplTransfer, + _ => FeeAmountRecoveryPolicy::Disabled, + }; +} + +fn payload_has_non_empty_text(payload: &serde_json::Value, key: &str) -> bool { + let value = match payload.get(key) { + Some(value) => value, + None => return false, + }; + let text = match value.as_str() { + Some(text) => text, + None => return false, + }; + return !text.is_empty(); +} + impl NonTradeEventMaterializationService { /// Creates a new non-trade event materialization service. pub fn new(database: std::sync::Arc) -> Self { @@ -112,6 +239,17 @@ impl NonTradeEventMaterializationService { if is_anchor_event_audit_only(&payload) { continue; } + if should_skip_non_trade_event_due_to_explicit_reason(decoded_event, &payload) + && !should_attempt_meteora_dbc_explicit_skip_materialization(decoded_event, &payload) + { + tracing::debug!( + event_kind = %decoded_event.event_kind, + decoded_event_id = ?decoded_event.id, + signature = %transaction.signature, + "skipping non-trade materialization due to explicit decoded payload skip reason" + ); + continue; + } if should_skip_pump_fun_duplicate_non_trade_event(decoded_event, &decoded_events) { tracing::debug!( event_kind = %decoded_event.event_kind, @@ -361,7 +499,7 @@ impl NonTradeEventMaterializationService { "user", ], ); - let fee_token_mint = extract_first_string( + let mut fee_token_mint = extract_first_string( payload, &[ "feeTokenMint", @@ -373,7 +511,7 @@ impl NonTradeEventMaterializationService { "quote_mint", ], ); - let fee_amount_raw = extract_first_amount_string( + let mut fee_amount_raw = extract_first_amount_string( payload, &[ "feeAmountRaw", @@ -395,6 +533,140 @@ impl NonTradeEventMaterializationService { "amount", ], ); + let mut payload_json = decoded_event.payload_json.clone(); + let mut fee_amount_legs: std::vec::Vec = std::vec::Vec::new(); + if is_meteora_dbc_instruction_fee_materialization_candidate(decoded_event.event_kind.as_str()) { + let inferred_result = self + .resolve_meteora_dbc_actual_fee_amounts(transaction, transaction_id, decoded_event, payload) + .await; + let inferred = match inferred_result { + Ok(inferred) => inferred, + Err(error) => return Err(error), + }; + if !inferred.is_empty() { + fee_amount_legs = inferred; + match fee_amount_legs.as_slice() { + [first_leg] => { + fee_token_mint = Some(first_leg.token_mint.clone()); + fee_amount_raw = Some(first_leg.amount_raw.clone()); + }, + _ => { + fee_token_mint = None; + fee_amount_raw = None; + }, + } + let updated_payload = decoded_payload_with_materialized_fee_legs(payload, &fee_amount_legs); + payload_json = updated_payload.to_string(); + let update_result = crate::query_dex_decoded_events_update_payload_json_by_id( + self.database.as_ref(), + decoded_event_id, + payload_json.as_str(), + ) + .await; + if let Err(error) = update_result { + return Err(error); + } + } else if fee_amount_raw.is_none() { + let explanation_result = self + .resolve_meteora_dbc_unmaterialized_fee_skip_reason( + transaction_id, + decoded_event, + payload, + ) + .await; + let explanation = match explanation_result { + Ok(explanation) => explanation, + Err(error) => return Err(error), + }; + if let Some(explanation) = explanation { + let updated_payload = decoded_payload_with_unmaterialized_fee_reason( + payload, + explanation.as_str(), + ); + let updated_payload_json = updated_payload.to_string(); + let update_result = crate::query_dex_decoded_events_update_payload_json_by_id( + self.database.as_ref(), + decoded_event_id, + updated_payload_json.as_str(), + ) + .await; + if let Err(error) = update_result { + return Err(error); + } + } + return Ok(false); + } + } + let recovery_policy = fee_amount_recovery_policy_for_event_kind(decoded_event.event_kind.as_str()); + if fee_amount_legs.is_empty() + && fee_amount_raw.is_none() + && recovery_policy != FeeAmountRecoveryPolicy::Disabled + && !is_meteora_dbc_instruction_fee_materialization_candidate(decoded_event.event_kind.as_str()) + { + let inferred_result = self + .resolve_allowlisted_actual_fee_amounts_from_spl_transfers( + transaction_id, + decoded_event, + payload, + ) + .await; + let inferred = match inferred_result { + Ok(inferred) => inferred, + Err(error) => return Err(error), + }; + if !inferred.is_empty() { + fee_amount_legs = inferred; + match fee_amount_legs.as_slice() { + [first_leg] => { + fee_token_mint = Some(first_leg.token_mint.clone()); + fee_amount_raw = Some(first_leg.amount_raw.clone()); + }, + _ => { + fee_token_mint = None; + fee_amount_raw = None; + }, + } + let updated_payload = decoded_payload_with_materialized_fee_legs(payload, &fee_amount_legs); + payload_json = updated_payload.to_string(); + let update_result = crate::query_dex_decoded_events_update_payload_json_by_id( + self.database.as_ref(), + decoded_event_id, + payload_json.as_str(), + ) + .await; + if let Err(error) = update_result { + return Err(error); + } + } else { + let explanation_result = self + .resolve_allowlisted_unmaterialized_fee_amount_reason( + transaction_id, + decoded_event, + payload, + ) + .await; + let explanation = match explanation_result { + Ok(explanation) => explanation, + Err(error) => return Err(error), + }; + if let Some(explanation) = explanation { + let updated_payload = decoded_payload_with_unmaterialized_fee_amount_recovery_reason( + payload, + explanation.as_str(), + ); + payload_json = updated_payload.to_string(); + let update_result = crate::query_dex_decoded_events_update_payload_json_by_id( + self.database.as_ref(), + decoded_event_id, + payload_json.as_str(), + ) + .await; + if let Err(error) = update_result { + return Err(error); + } + } + } + } let dto = crate::FeeEventDto::new( transaction_id, Some(decoded_event_id), @@ -408,15 +680,407 @@ impl NonTradeEventMaterializationService { decoded_event.event_kind.clone(), decoded_event.pool_account.clone(), actor_wallet, - fee_token_mint, - fee_amount_raw, - decoded_event.payload_json.clone(), + fee_token_mint.clone(), + fee_amount_raw.clone(), + payload_json, ); - let upsert_result = crate::query_fee_events_upsert(self.database.as_ref(), &dto).await; - match upsert_result { - Ok(_) => return Ok(true), - Err(error) => return Err(error), + let mut amount_dtos = std::vec::Vec::new(); + if !fee_amount_legs.is_empty() { + for (index, leg) in fee_amount_legs.iter().enumerate() { + let leg_index = match u32::try_from(index) { + Ok(leg_index) => leg_index, + Err(error) => { + return Err(crate::Error::InvalidState(format!( + "cannot convert fee amount leg index '{}' for decoded_event_id '{}' to u32: {}", + index, decoded_event_id, error + ))); + }, + }; + let leg_payload = fee_amount_leg_payload_json(decoded_event, leg, leg_index); + amount_dtos.push(crate::FeeEventAmountDto::new( + 0, + transaction_id, + Some(decoded_event_id), + leg_index, + decoded_event.event_kind.clone(), + leg.token_mint.clone(), + leg.amount_raw.clone(), + leg.source_account.clone(), + leg.destination_account.clone(), + leg.source.clone(), + leg_payload, + )); + } + } else { + let parent_amount_leg = fee_event_amount_leg_from_parent_amount( + decoded_event, + transaction_id, + decoded_event_id, + fee_token_mint.as_ref(), + fee_amount_raw.as_ref(), + ); + if let Some(parent_amount_leg) = parent_amount_leg { + amount_dtos.push(parent_amount_leg); + } } + let upsert_result = if amount_dtos.is_empty() { + crate::query_fee_events_upsert(self.database.as_ref(), &dto).await + } else { + crate::query_fee_events_upsert_with_amount_legs( + self.database.as_ref(), + &dto, + amount_dtos.as_slice(), + ) + .await + }; + if let Err(error) = upsert_result { + return Err(error); + } + return Ok(true); + } + + async fn resolve_meteora_dbc_actual_fee_amounts( + &self, + transaction: &crate::ChainTransactionDto, + transaction_id: i64, + decoded_event: &crate::DexDecodedEventDto, + payload: &serde_json::Value, + ) -> Result, crate::Error> { + let spl_amount_result = self + .resolve_meteora_dbc_actual_fee_amounts_from_spl_transfers( + transaction_id, + decoded_event, + payload, + ) + .await; + let spl_amounts = match spl_amount_result { + Ok(spl_amounts) => spl_amounts, + Err(error) => return Err(error), + }; + if !spl_amounts.is_empty() { + return Ok(spl_amounts); + } + let lamport_amount = resolve_meteora_dbc_actual_fee_amount_from_lamport_delta( + transaction, + decoded_event, + payload, + ); + match lamport_amount { + Some(lamport_amount) => return Ok(vec![lamport_amount]), + None => return Ok(std::vec::Vec::new()), + } + } + + async fn resolve_meteora_dbc_actual_fee_amounts_from_spl_transfers( + &self, + transaction_id: i64, + decoded_event: &crate::DexDecodedEventDto, + payload: &serde_json::Value, + ) -> Result, crate::Error> { + let parent_instruction_id = match decoded_event.id { + Some(_) => extract_first_i64(payload, &["instructionId", "instruction_id"]), + None => None, + }; + let instruction_index = extract_first_i64(payload, &["instructionIndex", "instruction_index"]); + let instructions_result = + crate::query_chain_instructions_list_by_transaction_id(self.database.as_ref(), transaction_id) + .await; + let instructions = match instructions_result { + Ok(instructions) => instructions, + Err(error) => return Err(error), + }; + let mut amount_legs = std::vec::Vec::new(); + for instruction in &instructions { + if !instruction_is_child_of_decoded_event( + instruction, + parent_instruction_id, + instruction_index, + ) { + continue; + } + let parsed_type = match instruction.parsed_type.as_deref() { + Some(parsed_type) => parsed_type, + None => continue, + }; + if parsed_type != "transferChecked" && parsed_type != "transfer" { + continue; + } + let parsed_json = match instruction.parsed_json.as_deref() { + Some(parsed_json) => parsed_json, + None => continue, + }; + let parsed_value = serde_json::from_str::(parsed_json); + let parsed_value = match parsed_value { + Ok(parsed_value) => parsed_value, + Err(_) => continue, + }; + let amount_raw = extract_spl_transfer_amount_raw(&parsed_value); + let amount_raw = match amount_raw { + Some(amount_raw) => amount_raw, + None => continue, + }; + let amount = amount_raw.parse::(); + let amount = match amount { + Ok(amount) => amount, + Err(_) => continue, + }; + if amount == 0 { + continue; + } + let mint = extract_first_string(&parsed_value, &["mint"]); + let mint = match mint { + Some(mint) => mint, + None => continue, + }; + let source_account = extract_first_string(&parsed_value, &["source"]); + let destination_account = extract_first_string(&parsed_value, &["destination"]); + amount_legs.push(MeteoraDbcActualAmount { + token_mint: mint, + amount_raw, + source: "inner_spl_transfer".to_string(), + source_account, + destination_account, + }); + } + return Ok(amount_legs); + } + + async fn resolve_allowlisted_actual_fee_amounts_from_spl_transfers( + &self, + transaction_id: i64, + decoded_event: &crate::DexDecodedEventDto, + payload: &serde_json::Value, + ) -> Result, crate::Error> { + let parent_instruction_id = match decoded_event.id { + Some(_) => extract_first_i64(payload, &["instructionId", "instruction_id"]), + None => None, + }; + let instruction_index = extract_first_i64(payload, &["instructionIndex", "instruction_index"]); + let instructions_result = + crate::query_chain_instructions_list_by_transaction_id(self.database.as_ref(), transaction_id) + .await; + let instructions = match instructions_result { + Ok(instructions) => instructions, + Err(error) => return Err(error), + }; + let mut amount_legs = std::vec::Vec::new(); + for instruction in &instructions { + if !instruction_is_child_of_decoded_event( + instruction, + parent_instruction_id, + instruction_index, + ) { + continue; + } + let parsed_type = match instruction.parsed_type.as_deref() { + Some(parsed_type) => parsed_type, + None => continue, + }; + if parsed_type != "transferChecked" && parsed_type != "transfer" { + continue; + } + let parsed_json = match instruction.parsed_json.as_deref() { + Some(parsed_json) => parsed_json, + None => continue, + }; + let parsed_value = serde_json::from_str::(parsed_json); + let parsed_value = match parsed_value { + Ok(parsed_value) => parsed_value, + Err(_) => continue, + }; + let amount_raw = extract_spl_transfer_amount_raw(&parsed_value); + let amount_raw = match amount_raw { + Some(amount_raw) => amount_raw, + None => continue, + }; + let amount = amount_raw.parse::(); + let amount = match amount { + Ok(amount) => amount, + Err(_) => continue, + }; + if amount == 0 { + continue; + } + let mint = extract_first_string(&parsed_value, &["mint"]); + let mint = match mint { + Some(mint) => mint, + None => continue, + }; + let source_account = extract_first_string(&parsed_value, &["source"]); + let destination_account = extract_first_string(&parsed_value, &["destination"]); + amount_legs.push(MeteoraDbcActualAmount { + token_mint: mint, + amount_raw, + source: "allowlisted_inner_spl_transfer".to_string(), + source_account, + destination_account, + }); + } + return Ok(amount_legs); + } + + async fn resolve_allowlisted_unmaterialized_fee_amount_reason( + &self, + transaction_id: i64, + decoded_event: &crate::DexDecodedEventDto, + payload: &serde_json::Value, + ) -> Result, crate::Error> { + let parent_instruction_id = match decoded_event.id { + Some(_) => extract_first_i64(payload, &["instructionId", "instruction_id"]), + None => None, + }; + let instruction_index = extract_first_i64(payload, &["instructionIndex", "instruction_index"]); + let instructions_result = + crate::query_chain_instructions_list_by_transaction_id(self.database.as_ref(), transaction_id) + .await; + let instructions = match instructions_result { + Ok(instructions) => instructions, + Err(error) => return Err(error), + }; + let mut transfer_count: usize = 0; + let mut non_zero_transfer_count: usize = 0; + let mut non_zero_mints: std::collections::BTreeSet = + std::collections::BTreeSet::new(); + for instruction in &instructions { + if !instruction_is_child_of_decoded_event( + instruction, + parent_instruction_id, + instruction_index, + ) { + continue; + } + let parsed_type = match instruction.parsed_type.as_deref() { + Some(parsed_type) => parsed_type, + None => continue, + }; + if parsed_type != "transferChecked" && parsed_type != "transfer" { + continue; + } + transfer_count += 1; + let parsed_json = match instruction.parsed_json.as_deref() { + Some(parsed_json) => parsed_json, + None => continue, + }; + let parsed_value = serde_json::from_str::(parsed_json); + let parsed_value = match parsed_value { + Ok(parsed_value) => parsed_value, + Err(_) => continue, + }; + let amount_raw = extract_spl_transfer_amount_raw(&parsed_value); + let amount_raw = match amount_raw { + Some(amount_raw) => amount_raw, + None => continue, + }; + let amount = amount_raw.parse::(); + let amount = match amount { + Ok(amount) => amount, + Err(_) => continue, + }; + if amount == 0 { + continue; + } + non_zero_transfer_count += 1; + let mint = extract_first_string(&parsed_value, &["mint"]); + let mint = match mint { + Some(mint) => mint, + None => continue, + }; + non_zero_mints.insert(mint); + } + if non_zero_mints.len() > 1 { + return Ok(Some( + "multi_token_fee_amounts_materialized_as_fee_amount_legs".to_string(), + )); + } + if transfer_count > 0 && non_zero_transfer_count == 0 { + return Ok(Some("fee_instruction_has_only_zero_amount_transfers".to_string())); + } + if transfer_count == 0 { + return Ok(Some("fee_instruction_has_no_actual_transfer".to_string())); + } + return Ok(None); + } + + async fn resolve_meteora_dbc_unmaterialized_fee_skip_reason( + &self, + transaction_id: i64, + decoded_event: &crate::DexDecodedEventDto, + payload: &serde_json::Value, + ) -> Result, crate::Error> { + let parent_instruction_id = match decoded_event.id { + Some(_) => extract_first_i64(payload, &["instructionId", "instruction_id"]), + None => None, + }; + let instruction_index = extract_first_i64(payload, &["instructionIndex", "instruction_index"]); + let instructions_result = + crate::query_chain_instructions_list_by_transaction_id(self.database.as_ref(), transaction_id) + .await; + let instructions = match instructions_result { + Ok(instructions) => instructions, + Err(error) => return Err(error), + }; + let mut transfer_count: usize = 0; + let mut non_zero_transfer_count: usize = 0; + let mut non_zero_mints: std::collections::BTreeSet = + std::collections::BTreeSet::new(); + for instruction in &instructions { + if !instruction_is_child_of_decoded_event( + instruction, + parent_instruction_id, + instruction_index, + ) { + continue; + } + let parsed_type = match instruction.parsed_type.as_deref() { + Some(parsed_type) => parsed_type, + None => continue, + }; + if parsed_type != "transferChecked" && parsed_type != "transfer" { + continue; + } + transfer_count += 1; + let parsed_json = match instruction.parsed_json.as_deref() { + Some(parsed_json) => parsed_json, + None => continue, + }; + let parsed_value = serde_json::from_str::(parsed_json); + let parsed_value = match parsed_value { + Ok(parsed_value) => parsed_value, + Err(_) => continue, + }; + let amount_raw = extract_spl_transfer_amount_raw(&parsed_value); + let amount_raw = match amount_raw { + Some(amount_raw) => amount_raw, + None => continue, + }; + let amount = amount_raw.parse::(); + let amount = match amount { + Ok(amount) => amount, + Err(_) => continue, + }; + if amount == 0 { + continue; + } + non_zero_transfer_count += 1; + let mint = extract_first_string(&parsed_value, &["mint"]); + let mint = match mint { + Some(mint) => mint, + None => continue, + }; + non_zero_mints.insert(mint); + } + if non_zero_mints.len() > 1 { + return Ok(Some( + "multi_token_fee_amounts_require_multiple_fee_rows".to_string(), + )); + } + if transfer_count > 0 && non_zero_transfer_count == 0 { + return Ok(Some("fee_instruction_has_only_zero_amount_transfers".to_string())); + } + if transfer_count == 0 { + return Ok(Some("fee_instruction_has_no_actual_transfer".to_string())); + } + return Ok(None); } async fn materialize_reward_event( @@ -1427,6 +2091,413 @@ impl NonTradeEventMaterializationService { } } +#[derive(Debug, Clone)] +struct MeteoraDbcActualAmount { + token_mint: std::string::String, + amount_raw: std::string::String, + source: std::string::String, + source_account: std::option::Option, + destination_account: std::option::Option, +} + +fn instruction_is_child_of_decoded_event( + instruction: &crate::ChainInstructionDto, + parent_instruction_id: std::option::Option, + instruction_index: std::option::Option, +) -> bool { + if instruction.inner_instruction_index.is_none() { + return false; + } + if let Some(parent_instruction_id) = parent_instruction_id { + if instruction.parent_instruction_id == Some(parent_instruction_id) { + return true; + } + } + if let Some(instruction_index) = instruction_index { + let converted = i64::from(instruction.instruction_index); + if converted == instruction_index { + return true; + } + } + return false; +} + +fn extract_spl_transfer_amount_raw( + parsed_value: &serde_json::Value, +) -> std::option::Option { + let token_amount = parsed_value + .get("info") + .and_then(|value| return value.get("tokenAmount")) + .and_then(|value| return value.get("amount")) + .and_then(serde_json::Value::as_str); + if let Some(token_amount) = token_amount { + if !token_amount.trim().is_empty() { + return Some(token_amount.trim().to_string()); + } + } + let amount = parsed_value + .get("info") + .and_then(|value| return value.get("amount")) + .and_then(serde_json::Value::as_str); + if let Some(amount) = amount { + if !amount.trim().is_empty() { + return Some(amount.trim().to_string()); + } + } + return None; +} + +fn decoded_payload_with_materialized_fee_legs( + payload: &serde_json::Value, + legs: &[MeteoraDbcActualAmount], +) -> serde_json::Value { + let mut object = match payload.clone() { + serde_json::Value::Object(object) => object, + other => { + let mut object = serde_json::Map::new(); + object.insert("rawPayload".to_string(), other); + object + }, + }; + object.insert("skipFeeReason".to_string(), serde_json::Value::String(std::string::String::new())); + object.insert( + "feeMaterialized".to_string(), + serde_json::Value::Bool(true), + ); + object.insert( + "feeMaterializationSource".to_string(), + serde_json::Value::String("fee_event_amounts".to_string()), + ); + let leg_count = legs.len() as u64; + object.insert( + "feeLegCount".to_string(), + serde_json::Value::Number(serde_json::Number::from(leg_count)), + ); + if legs.len() == 1 { + if let Some(leg) = legs.first() { + object.insert( + "feeTokenMint".to_string(), + serde_json::Value::String(leg.token_mint.clone()), + ); + object.insert( + "feeAmountRaw".to_string(), + serde_json::Value::String(leg.amount_raw.clone()), + ); + object.insert( + "feePrimaryAmountSource".to_string(), + serde_json::Value::String(leg.source.clone()), + ); + } + } else { + object.insert("feeTokenMint".to_string(), serde_json::Value::Null); + object.insert("feeAmountRaw".to_string(), serde_json::Value::Null); + object.insert( + "feeMultiLegReason".to_string(), + serde_json::Value::String("multiple_fee_amount_legs".to_string()), + ); + } + object.insert( + "eventActionability".to_string(), + serde_json::Value::String("fee_materialized".to_string()), + ); + return serde_json::Value::Object(object); +} + +fn fee_amount_leg_payload_json( + decoded_event: &crate::DexDecodedEventDto, + leg: &MeteoraDbcActualAmount, + leg_index: u32, +) -> std::string::String { + let mut object = serde_json::Map::new(); + object.insert( + "decodedEventKind".to_string(), + serde_json::Value::String(decoded_event.event_kind.clone()), + ); + object.insert( + "legIndex".to_string(), + serde_json::Value::Number(serde_json::Number::from(u64::from(leg_index))), + ); + object.insert( + "tokenMint".to_string(), + serde_json::Value::String(leg.token_mint.clone()), + ); + object.insert( + "amountRaw".to_string(), + serde_json::Value::String(leg.amount_raw.clone()), + ); + object.insert( + "amountSource".to_string(), + serde_json::Value::String(leg.source.clone()), + ); + match leg.source_account.as_ref() { + Some(source_account) => { + object.insert( + "sourceAccount".to_string(), + serde_json::Value::String(source_account.clone()), + ); + }, + None => {}, + } + match leg.destination_account.as_ref() { + Some(destination_account) => { + object.insert( + "destinationAccount".to_string(), + serde_json::Value::String(destination_account.clone()), + ); + }, + None => {}, + } + return serde_json::Value::Object(object).to_string(); +} + + +fn fee_event_amount_leg_from_parent_amount( + decoded_event: &crate::DexDecodedEventDto, + transaction_id: i64, + decoded_event_id: i64, + fee_token_mint: std::option::Option<&std::string::String>, + fee_amount_raw: std::option::Option<&std::string::String>, +) -> std::option::Option { + let fee_token_mint = match fee_token_mint { + Some(fee_token_mint) => fee_token_mint, + None => return None, + }; + if fee_token_mint.trim().is_empty() { + return None; + } + let fee_amount_raw = match fee_amount_raw { + Some(fee_amount_raw) => fee_amount_raw, + None => return None, + }; + if fee_amount_raw.trim().is_empty() { + return None; + } + let payload_json = fee_amount_parent_payload_json(decoded_event, fee_token_mint.as_str(), fee_amount_raw.as_str()); + return Some(crate::FeeEventAmountDto::new( + 0, + transaction_id, + Some(decoded_event_id), + 0, + decoded_event.event_kind.clone(), + fee_token_mint.clone(), + fee_amount_raw.clone(), + None, + None, + "parent_fee_event_amount".to_string(), + payload_json, + )); +} + +fn fee_amount_parent_payload_json( + decoded_event: &crate::DexDecodedEventDto, + token_mint: &str, + amount_raw: &str, +) -> std::string::String { + let mut object = serde_json::Map::new(); + object.insert( + "decodedEventKind".to_string(), + serde_json::Value::String(decoded_event.event_kind.clone()), + ); + object.insert( + "legIndex".to_string(), + serde_json::Value::Number(serde_json::Number::from(0_u64)), + ); + object.insert( + "tokenMint".to_string(), + serde_json::Value::String(token_mint.to_string()), + ); + object.insert( + "amountRaw".to_string(), + serde_json::Value::String(amount_raw.to_string()), + ); + object.insert( + "amountSource".to_string(), + serde_json::Value::String("parent_fee_event_amount".to_string()), + ); + return serde_json::Value::Object(object).to_string(); +} + +fn decoded_payload_with_unmaterialized_fee_reason( + payload: &serde_json::Value, + reason: &str, +) -> serde_json::Value { + let mut object = match payload.clone() { + serde_json::Value::Object(object) => object, + other => { + let mut object = serde_json::Map::new(); + object.insert("rawPayload".to_string(), other); + object + }, + }; + object.insert( + "skipFeeReason".to_string(), + serde_json::Value::String(reason.to_string()), + ); + object.insert( + "feeMaterialized".to_string(), + serde_json::Value::Bool(false), + ); + object.insert( + "feeMaterializationSource".to_string(), + serde_json::Value::String("none".to_string()), + ); + object.insert( + "eventActionability".to_string(), + serde_json::Value::String("decoded_only_with_explicit_skip_reason".to_string()), + ); + return serde_json::Value::Object(object); +} + +fn decoded_payload_with_unmaterialized_fee_amount_recovery_reason( + payload: &serde_json::Value, + reason: &str, +) -> serde_json::Value { + let mut object = match payload.clone() { + serde_json::Value::Object(object) => object, + other => { + let mut object = serde_json::Map::new(); + object.insert("rawPayload".to_string(), other); + object + }, + }; + object.insert( + "feeAmountRecoveryPolicy".to_string(), + serde_json::Value::String("allowlisted_inner_spl_transfer".to_string()), + ); + object.insert( + "feeAmountRecoveryReason".to_string(), + serde_json::Value::String(reason.to_string()), + ); + object.insert( + "feeAmountLegsMaterialized".to_string(), + serde_json::Value::Bool(false), + ); + object.insert( + "feeAmountLegCount".to_string(), + serde_json::Value::Number(serde_json::Number::from(0_u64)), + ); + return serde_json::Value::Object(object); +} + +fn resolve_meteora_dbc_actual_fee_amount_from_lamport_delta( + transaction: &crate::ChainTransactionDto, + decoded_event: &crate::DexDecodedEventDto, + payload: &serde_json::Value, +) -> std::option::Option { + if !decoded_event.event_kind.contains("pool_creation_fee") { + return None; + } + let transaction_value = serde_json::from_str::(transaction.transaction_json.as_str()); + let transaction_value = match transaction_value { + Ok(transaction_value) => transaction_value, + Err(_) => return None, + }; + let meta_value = match transaction.meta_json.as_deref() { + Some(meta_json) => match serde_json::from_str::(meta_json) { + Ok(meta_value) => Some(meta_value), + Err(_) => None, + }, + None => transaction_value.get("meta").cloned(), + }; + let meta_value_ref = match meta_value.as_ref() { + Some(meta_value_ref) => meta_value_ref, + None => return None, + }; + let account_keys = materialization_account_keys(&transaction_value, Some(meta_value_ref)); + let mut candidate_accounts = std::vec::Vec::new(); + append_unique_candidate_account(&mut candidate_accounts, decoded_event.pool_account.as_deref()); + let payload_pool_account = extract_first_string(payload, &["poolAccount", "pool", "poolState"]); + append_unique_candidate_account(&mut candidate_accounts, payload_pool_account.as_deref()); + let payload_related_account = extract_first_string(payload, &["relatedAccount", "receiver", "feeReceiver"]); + append_unique_candidate_account(&mut candidate_accounts, payload_related_account.as_deref()); + if let Some(accounts) = payload.get("accounts").and_then(serde_json::Value::as_array) { + for account in accounts { + let account = match account.as_str() { + Some(account) => account, + None => continue, + }; + append_unique_candidate_account(&mut candidate_accounts, Some(account)); + } + } + let pre_balances = meta_value_ref.get("preBalances").and_then(serde_json::Value::as_array); + let post_balances = meta_value_ref.get("postBalances").and_then(serde_json::Value::as_array); + let pre_balances = match pre_balances { + Some(pre_balances) => pre_balances, + None => return None, + }; + let post_balances = match post_balances { + Some(post_balances) => post_balances, + None => return None, + }; + let mut best_delta: std::option::Option = None; + for candidate in &candidate_accounts { + for account_key in &account_keys { + if account_key.address != *candidate { + continue; + } + let index = match usize::try_from(account_key.index) { + Ok(index) => index, + Err(_) => continue, + }; + let pre = pre_balances.get(index).and_then(serde_json::Value::as_u64); + let post = post_balances.get(index).and_then(serde_json::Value::as_u64); + let pre = match pre { + Some(pre) => pre, + None => continue, + }; + let post = match post { + Some(post) => post, + None => continue, + }; + if post <= pre { + continue; + } + let delta = post - pre; + if delta == 0 { + continue; + } + match best_delta { + Some(current) => { + if delta > current { + best_delta = Some(delta); + } + }, + None => best_delta = Some(delta), + } + } + } + let best_delta = match best_delta { + Some(best_delta) => best_delta, + None => return None, + }; + return Some(MeteoraDbcActualAmount { + token_mint: "So11111111111111111111111111111111111111112".to_string(), + amount_raw: best_delta.to_string(), + source: "lamport_balance_delta".to_string(), + source_account: None, + destination_account: None, + }); +} + +fn append_unique_candidate_account( + accounts: &mut std::vec::Vec, + candidate: std::option::Option<&str>, +) { + let candidate = match candidate { + Some(candidate) => candidate, + None => return, + }; + if candidate.trim().is_empty() { + return; + } + for account in accounts.iter() { + if account == candidate { + return; + } + } + accounts.push(candidate.to_string()); +} + #[derive(Debug, Clone)] struct MaterializationAccountKeyInfo { index: i64, @@ -2240,6 +3311,22 @@ mod tests { assert!(super::transaction_has_effective_error(&transaction)); } + #[test] + fn fee_amount_recovery_policy_is_allowlisted_only() { + assert_eq!( + super::fee_amount_recovery_policy_for_event_kind("raydium_launchpad.collect_fee"), + super::FeeAmountRecoveryPolicy::InnerSplTransfer + ); + assert_eq!( + super::fee_amount_recovery_policy_for_event_kind("future_dex.collect_fee"), + super::FeeAmountRecoveryPolicy::Disabled + ); + assert_eq!( + super::fee_amount_recovery_policy_for_event_kind("meteora_dbc.claim_trading_fee"), + super::FeeAmountRecoveryPolicy::Disabled + ); + } + #[test] fn extracts_nested_liquidity_amounts() { let payload = serde_json::json!({ diff --git a/kb_lib/src/trade_aggregation.rs b/kb_lib/src/trade_aggregation.rs index 06818c5..0035f77 100644 --- a/kb_lib/src/trade_aggregation.rs +++ b/kb_lib/src/trade_aggregation.rs @@ -114,6 +114,24 @@ impl TradeAggregationService { continue; }, }; + let payload_base_vault_address = + crate::trade_aggregation::extract_payload_string_by_candidate_keys( + &payload, + &["baseVault", "base_vault", "baseVaultAddress", "base_vault_address"], + ); + let payload_quote_vault_address = + crate::trade_aggregation::extract_payload_string_by_candidate_keys( + &payload, + &["quoteVault", "quote_vault", "quoteVaultAddress", "quote_vault_address"], + ); + let effective_base_vault_address = match base_vault_address.as_deref() { + Some(base_vault_address) => Some(base_vault_address), + None => payload_base_vault_address.as_deref(), + }; + let effective_quote_vault_address = match quote_vault_address.as_deref() { + Some(quote_vault_address) => Some(quote_vault_address), + None => payload_quote_vault_address.as_deref(), + }; if !crate::is_decoded_event_trade_candidate(decoded_event.event_kind.as_str(), &payload) { tracing::debug!( @@ -150,8 +168,8 @@ impl TradeAggregationService { quote_token_mint: quote_token_mint.as_deref(), base_token_decimals, quote_token_decimals, - base_vault_address: base_vault_address.as_deref(), - quote_vault_address: quote_vault_address.as_deref(), + base_vault_address: effective_base_vault_address, + quote_vault_address: effective_quote_vault_address, }; let amount_resolution = crate::trade_amount_resolution::resolve_trade_amounts(&amount_input).await; @@ -212,6 +230,29 @@ impl TradeAggregationService { } } +fn extract_payload_string_by_candidate_keys( + payload: &serde_json::Value, + keys: &[&str], +) -> std::option::Option { + let object = match payload.as_object() { + Some(object) => object, + None => return None, + }; + for key in keys { + let value = object.get(*key); + let value = match value { + Some(value) => value, + None => continue, + }; + if let Some(text) = value.as_str() { + if !text.trim().is_empty() { + return Some(text.to_string()); + } + } + } + return None; +} + fn should_skip_pump_fun_duplicate_trade_event( decoded_event: &crate::DexDecodedEventDto, decoded_events: &[crate::DexDecodedEventDto], diff --git a/kb_lib/src/trade_amount_resolution.rs b/kb_lib/src/trade_amount_resolution.rs index 5094e94..a7a7428 100644 --- a/kb_lib/src/trade_amount_resolution.rs +++ b/kb_lib/src/trade_amount_resolution.rs @@ -253,6 +253,36 @@ pub(crate) async fn resolve_trade_amounts( return Err(error); } } + if input.decoded_event.event_kind.starts_with("meteora_dbc.") + && (base_amount_raw.is_none() || quote_amount_raw.is_none()) + { + let resolution_result = + crate::trade_amount_resolution::apply_meteora_dbc_payload_transfer_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_dbc.") + && (base_amount_raw.is_none() || quote_amount_raw.is_none()) + { + let resolution_result = + crate::trade_amount_resolution::apply_meteora_dbc_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()) { @@ -283,6 +313,21 @@ pub(crate) async fn resolve_trade_amounts( return Err(error); } } + if input.decoded_event.event_kind.starts_with("meteora_dbc.") + && (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("raydium_amm_v4.") { let vault_side = crate::trade_amount_resolution::infer_trade_side_from_vault_balance_deltas( input.transaction.meta_json.as_deref(), @@ -316,6 +361,17 @@ pub(crate) async fn resolve_trade_amounts( resolved_trade_side = vault_side; } } + if input.decoded_event.event_kind.starts_with("meteora_dbc.") { + 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( @@ -452,10 +508,7 @@ async fn apply_pump_swap_amount_fallbacks( }; let (input_vault_address, output_vault_address, input_token_account, output_token_account) = if input.decoded_event.event_kind.ends_with(".buy") - || input - .decoded_event - .event_kind - .ends_with(".buy_exact_quote_in") + || input.decoded_event.event_kind.ends_with(".buy_exact_quote_in") { ( effective_quote_vault_address, @@ -815,13 +868,14 @@ async fn apply_pump_fun_amount_fallback( *price_quote_per_base = inferred.2; } if base_amount_raw.is_none() || quote_amount_raw.is_none() || price_quote_per_base.is_none() { - let sibling_result = crate::trade_amount_resolution::apply_pump_fun_trade_event_sibling_amount_fallback( - input, - base_amount_raw, - quote_amount_raw, - price_quote_per_base, - ) - .await; + let sibling_result = + crate::trade_amount_resolution::apply_pump_fun_trade_event_sibling_amount_fallback( + input, + base_amount_raw, + quote_amount_raw, + price_quote_per_base, + ) + .await; if let Err(error) = sibling_result { return Err(error); } @@ -1111,6 +1165,160 @@ async fn apply_meteora_damm_v1_flattened_cpi_amount_fallback( .await; } +async fn apply_meteora_dbc_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_dbc", + base_amount_raw, + quote_amount_raw, + resolved_trade_side, + ) + .await; +} + +async fn apply_meteora_dbc_payload_transfer_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> { + let payload_token_a_mint = crate::trade_amount_resolution::extract_string_by_candidate_keys( + input.payload, + &["tokenAMint", "token_a_mint", "baseMint", "base_mint"], + ); + let payload_token_b_mint = crate::trade_amount_resolution::extract_string_by_candidate_keys( + input.payload, + &["tokenBMint", "token_b_mint", "quoteMint", "quote_mint"], + ); + let payload_base_vault = crate::trade_amount_resolution::extract_string_by_candidate_keys( + input.payload, + &["baseVault", "base_vault", "baseVaultAddress", "base_vault_address"], + ); + let payload_quote_vault = crate::trade_amount_resolution::extract_string_by_candidate_keys( + input.payload, + &["quoteVault", "quote_vault", "quoteVaultAddress", "quote_vault_address"], + ); + let payload_token_a_mint = match payload_token_a_mint { + Some(payload_token_a_mint) => payload_token_a_mint, + None => return Ok(()), + }; + let payload_token_b_mint = match payload_token_b_mint { + Some(payload_token_b_mint) => payload_token_b_mint, + None => return Ok(()), + }; + let payload_base_vault = match payload_base_vault { + Some(payload_base_vault) => payload_base_vault, + None => return Ok(()), + }; + let payload_quote_vault = match payload_quote_vault { + Some(payload_quote_vault) => payload_quote_vault, + None => return Ok(()), + }; + 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 payload_order_result = resolve_amounts_from_flattened_cpi_transfer_window( + &decoded_instruction, + &instructions, + Some(payload_token_a_mint.as_str()), + Some(payload_token_b_mint.as_str()), + Some(payload_base_vault.as_str()), + Some(payload_quote_vault.as_str()), + ); + let payload_order = match payload_order_result { + Ok(payload_order) => payload_order, + Err(error) => return Err(error), + }; + if payload_order.base_amount_raw.is_none() || payload_order.quote_amount_raw.is_none() { + return Ok(()); + } + if crate::trade_amount_resolution::optional_mint_pair_matches_payload_order( + input.base_token_mint, + input.quote_token_mint, + payload_token_a_mint.as_str(), + payload_token_b_mint.as_str(), + ) { + if base_amount_raw.is_none() { + *base_amount_raw = payload_order.base_amount_raw; + } + if quote_amount_raw.is_none() { + *quote_amount_raw = payload_order.quote_amount_raw; + } + if resolved_trade_side.is_none() { + *resolved_trade_side = payload_order.resolved_trade_side; + } + tracing::debug!( + event_kind = %input.decoded_event.event_kind, + decoded_event_id = ?input.decoded_event.id, + transaction_signature = %input.transaction.signature, + pool_account = ?input.decoded_event.pool_account, + payload_token_a_mint = %payload_token_a_mint, + payload_token_b_mint = %payload_token_b_mint, + payload_base_vault = %payload_base_vault, + payload_quote_vault = %payload_quote_vault, + base_amount_raw = ?base_amount_raw, + quote_amount_raw = ?quote_amount_raw, + resolved_trade_side = ?resolved_trade_side, + "meteora_dbc trade amounts recovered from payload-token CPI transfer window" + ); + return Ok(()); + } + if crate::trade_amount_resolution::optional_mint_pair_matches_payload_reverse_order( + input.base_token_mint, + input.quote_token_mint, + payload_token_a_mint.as_str(), + payload_token_b_mint.as_str(), + ) { + if base_amount_raw.is_none() { + *base_amount_raw = payload_order.quote_amount_raw; + } + if quote_amount_raw.is_none() { + *quote_amount_raw = payload_order.base_amount_raw; + } + if resolved_trade_side.is_none() { + *resolved_trade_side = crate::trade_amount_resolution::reverse_swap_trade_side( + payload_order.resolved_trade_side, + ); + } + tracing::debug!( + event_kind = %input.decoded_event.event_kind, + decoded_event_id = ?input.decoded_event.id, + transaction_signature = %input.transaction.signature, + pool_account = ?input.decoded_event.pool_account, + payload_token_a_mint = %payload_token_a_mint, + payload_token_b_mint = %payload_token_b_mint, + payload_base_vault = %payload_base_vault, + payload_quote_vault = %payload_quote_vault, + base_amount_raw = ?base_amount_raw, + quote_amount_raw = ?quote_amount_raw, + resolved_trade_side = ?resolved_trade_side, + "meteora_dbc trade amounts recovered from reversed payload-token CPI transfer window" + ); + } + return Ok(()); +} + async fn apply_flattened_cpi_amount_fallback( input: &crate::trade_amount_resolution::TradeAmountResolutionInput<'_>, protocol_label: &str, @@ -1488,6 +1696,41 @@ fn infer_trade_side_from_transfer_directions( } } +fn optional_mint_pair_matches_payload_order( + base_token_mint: std::option::Option<&str>, + quote_token_mint: std::option::Option<&str>, + payload_token_a_mint: &str, + payload_token_b_mint: &str, +) -> bool { + return crate::trade_amount_resolution::string_option_equals( + base_token_mint, + payload_token_a_mint, + ) && crate::trade_amount_resolution::string_option_equals(quote_token_mint, payload_token_b_mint); +} + +fn optional_mint_pair_matches_payload_reverse_order( + base_token_mint: std::option::Option<&str>, + quote_token_mint: std::option::Option<&str>, + payload_token_a_mint: &str, + payload_token_b_mint: &str, +) -> bool { + return crate::trade_amount_resolution::string_option_equals( + base_token_mint, + payload_token_b_mint, + ) && crate::trade_amount_resolution::string_option_equals(quote_token_mint, payload_token_a_mint); +} + +fn reverse_swap_trade_side( + trade_side: std::option::Option, +) -> std::option::Option { + match trade_side { + Some(crate::SwapTradeSide::BuyBase) => return Some(crate::SwapTradeSide::SellBase), + Some(crate::SwapTradeSide::SellBase) => return Some(crate::SwapTradeSide::BuyBase), + Some(crate::SwapTradeSide::Unknown) => return Some(crate::SwapTradeSide::Unknown), + None => return None, + } +} + fn string_option_equals(left: std::option::Option<&str>, right: &str) -> bool { let left = match left { Some(left) => left.trim(), @@ -1589,7 +1832,6 @@ async fn load_decoded_instruction( return Ok(instruction_option); } - fn normalize_pump_swap_anchor_buy_exact_quote_in_amounts( input: &crate::trade_amount_resolution::TradeAmountResolutionInput<'_>, base_amount_raw: &mut std::option::Option, diff --git a/validation_sql/SQL_VALIDATION_METEORA_DBC_0_7_56.sql b/validation_sql/SQL_VALIDATION_METEORA_DBC_0_7_56.sql new file mode 100644 index 0000000..80fbcb2 --- /dev/null +++ b/validation_sql/SQL_VALIDATION_METEORA_DBC_0_7_56.sql @@ -0,0 +1,440 @@ +-- file: validation_sql/SQL_VALIDATION_METEORA_DBC_0_7_56.sql + +-- 0.7.56 meteora_dbc validation and corpus-seed checklist. +-- Run on a dedicated fresh SQLite database for the Meteora DBC tranche. +-- Recommended replay settings after each backfill group: +-- skipDexDecode=no, forceDexDecode=yes, deferInstructionObservations=yes. +-- This file is intentionally read-only: it never mutates the database. + +-- 00. Corpus seed: upstream fallback samples to backfill first. +SELECT + json_extract(de.payload_json, '$.upstreamEntryName') AS upstream_entry_name, + json_extract(de.payload_json, '$.upstreamDiscriminatorHex') AS upstream_discriminator_hex, + json_extract(de.payload_json, '$.upstreamSourceRepo') AS source_repo, + COUNT(*) AS fallback_count, + COUNT(DISTINCT de.transaction_id) AS tx_count, + MIN(tx.signature) AS sample_signature +FROM k_sol_dex_decoded_events de +LEFT JOIN k_sol_chain_transactions tx + ON tx.id = de.transaction_id +WHERE de.protocol_name = 'upstream_git' + AND de.event_kind = 'upstream_git.instruction_match' + AND json_extract(de.payload_json, '$.upstreamDecoderCode') = 'meteora_dbc' +GROUP BY upstream_entry_name, upstream_discriminator_hex, source_repo +ORDER BY fallback_count DESC, upstream_entry_name, upstream_discriminator_hex; + +-- 01. Corpus seed: local instruction observations. +SELECT + instruction_name, + discriminator_hex, + COUNT(*) AS observed_count, + COUNT(DISTINCT signature) AS tx_count, + MIN(signature) AS sample_signature +FROM k_sol_instruction_observations +WHERE decoder_code = 'meteora_dbc' +GROUP BY instruction_name, discriminator_hex +ORDER BY observed_count DESC, instruction_name, discriminator_hex; + +-- 02. Coverage meteora_dbc. +SELECT + entry_name, + entry_kind, + event_family, + expected_db_target, + proof_status, + local_event_kind, + discriminator_hex, + observed_count, + materialized_count, + trade_count +FROM k_sol_dex_event_coverage_entries +WHERE decoder_code = 'meteora_dbc' +ORDER BY entry_kind, entry_name, discriminator_hex; + +-- 03. Decoded events summary. +SELECT + de.event_kind, + COUNT(*) AS decoded_count, + COUNT(DISTINCT de.transaction_id) AS tx_count, + MIN(tx.signature) AS sample_signature +FROM k_sol_dex_decoded_events de +LEFT JOIN k_sol_chain_transactions tx + ON tx.id = de.transaction_id +WHERE de.protocol_name = 'meteora_dbc' +GROUP BY de.event_kind +ORDER BY decoded_count DESC, de.event_kind; + +-- 04. Decoded meteora_dbc events without coverage. +-- Target after closure: empty for all locally decoded meteora_dbc rows. +SELECT + de.event_kind, + COUNT(*) AS decoded_count, + COUNT(DISTINCT de.transaction_id) AS tx_count, + MIN(tx.signature) AS sample_signature +FROM k_sol_dex_decoded_events de +LEFT JOIN k_sol_chain_transactions tx + ON tx.id = de.transaction_id +LEFT JOIN k_sol_dex_event_coverage_entries ce + ON ce.decoder_code = 'meteora_dbc' + AND ce.local_event_kind = de.event_kind +WHERE de.protocol_name = 'meteora_dbc' + AND ce.id IS NULL +GROUP BY de.event_kind +ORDER BY decoded_count DESC, de.event_kind; + +-- 05. Residual upstream fallback for entries covered locally. +-- Target after local promotion: empty for every entry that has a local_event_kind. +SELECT + json_extract(ug.payload_json, '$.upstreamEntryName') AS upstream_entry_name, + json_extract(ug.payload_json, '$.upstreamDiscriminatorHex') AS upstream_discriminator_hex, + json_extract(ug.payload_json, '$.upstreamSourceRepo') AS source_repo, + ce.local_event_kind, + ce.expected_db_target, + ce.proof_status, + COUNT(*) AS fallback_count, + COUNT(DISTINCT ug.transaction_id) AS tx_count, + MIN(tx.signature) AS sample_signature +FROM k_sol_dex_decoded_events ug +LEFT JOIN k_sol_chain_transactions tx + ON tx.id = ug.transaction_id +JOIN k_sol_dex_event_coverage_entries ce + ON ce.decoder_code = json_extract(ug.payload_json, '$.upstreamDecoderCode') + AND ce.entry_name = json_extract(ug.payload_json, '$.upstreamEntryName') + AND COALESCE(ce.discriminator_hex, '') = COALESCE(json_extract(ug.payload_json, '$.upstreamDiscriminatorHex'), '') + AND ce.local_event_kind IS NOT NULL + AND ce.local_event_kind <> '' +WHERE ug.protocol_name = 'upstream_git' + AND ug.event_kind = 'upstream_git.instruction_match' + AND json_extract(ug.payload_json, '$.upstreamDecoderCode') = 'meteora_dbc' +GROUP BY upstream_entry_name, upstream_discriminator_hex, source_repo, ce.local_event_kind, ce.expected_db_target, ce.proof_status +ORDER BY fallback_count DESC, upstream_entry_name; + +-- 06. Successful non-materialized events without explicit skip reason. +-- Target after closure: empty. Decoded-only rows must carry skip*Reason or informational/audit policy. +SELECT + de.event_kind, + COUNT(*) AS unexplained_count, + MIN(tx.signature) AS sample_signature +FROM k_sol_dex_decoded_events de +JOIN k_sol_chain_transactions tx + ON tx.id = de.transaction_id +LEFT JOIN k_sol_trade_events te + ON te.decoded_event_id = de.id +LEFT JOIN k_sol_launch_events lae + ON lae.decoded_event_id = de.id +LEFT JOIN k_sol_liquidity_events lie + ON lie.decoded_event_id = de.id +LEFT JOIN k_sol_pool_lifecycle_events ple + ON ple.decoded_event_id = de.id +LEFT JOIN k_sol_fee_events fee + ON fee.decoded_event_id = de.id +LEFT JOIN k_sol_reward_events rew + ON rew.decoded_event_id = de.id +LEFT JOIN k_sol_pool_admin_events adm + ON adm.decoded_event_id = de.id +LEFT JOIN k_sol_orderbook_events obe + ON obe.decoded_event_id = de.id +LEFT JOIN k_sol_token_account_events tae + ON tae.decoded_event_id = de.id +WHERE de.protocol_name = 'meteora_dbc' + AND (tx.err_json IS NULL OR tx.err_json = '' OR tx.err_json = 'null') + AND COALESCE(json_extract(de.payload_json, '$.eventActionability'), '') NOT IN ( + 'informational', + 'decoded_only_anchor_event', + 'decoded_only_missing_mint_context', + 'decoded_only_with_explicit_skip_reason' + ) + AND te.id IS NULL + AND lae.id IS NULL + AND lie.id IS NULL + AND ple.id IS NULL + AND fee.id IS NULL + AND rew.id IS NULL + AND adm.id IS NULL + AND obe.id IS NULL + AND tae.id IS NULL + AND COALESCE(TRIM(json_extract(de.payload_json, '$.skipTradeReason')), '') = '' + AND COALESCE(TRIM(json_extract(de.payload_json, '$.skipCandleReason')), '') = '' + AND COALESCE(TRIM(json_extract(de.payload_json, '$.skipLiquidityReason')), '') = '' + AND COALESCE(TRIM(json_extract(de.payload_json, '$.skipLifecycleReason')), '') = '' + AND COALESCE(TRIM(json_extract(de.payload_json, '$.skipCatalogReason')), '') = '' + AND COALESCE(TRIM(json_extract(de.payload_json, '$.skipFeeReason')), '') = '' + AND COALESCE(TRIM(json_extract(de.payload_json, '$.skipAdminReason')), '') = '' +GROUP BY de.event_kind +ORDER BY unexplained_count DESC, de.event_kind; + +-- 07. Failed transaction materialization safety. +-- Target after closure: empty. Failed transactions may be decoded for audit, but must not be business-materialized. +SELECT + de.event_kind, + COUNT(DISTINCT de.id) AS decoded_failed_count, + COUNT(DISTINCT te.id) AS trade_count, + COUNT(DISTINCT lae.id) AS launch_count, + COUNT(DISTINCT lie.id) AS liquidity_count, + COUNT(DISTINCT ple.id) AS lifecycle_count, + COUNT(DISTINCT fee.id) AS fee_count, + COUNT(DISTINCT rew.id) AS reward_count, + COUNT(DISTINCT adm.id) AS admin_count, + COUNT(DISTINCT obe.id) AS orderbook_count, + COUNT(DISTINCT tae.id) AS token_account_count, + MIN(tx.signature) AS sample_signature +FROM k_sol_dex_decoded_events de +JOIN k_sol_chain_transactions tx + ON tx.id = de.transaction_id +LEFT JOIN k_sol_trade_events te + ON te.decoded_event_id = de.id +LEFT JOIN k_sol_launch_events lae + ON lae.decoded_event_id = de.id +LEFT JOIN k_sol_liquidity_events lie + ON lie.decoded_event_id = de.id +LEFT JOIN k_sol_pool_lifecycle_events ple + ON ple.decoded_event_id = de.id +LEFT JOIN k_sol_fee_events fee + ON fee.decoded_event_id = de.id +LEFT JOIN k_sol_reward_events rew + ON rew.decoded_event_id = de.id +LEFT JOIN k_sol_pool_admin_events adm + ON adm.decoded_event_id = de.id +LEFT JOIN k_sol_orderbook_events obe + ON obe.decoded_event_id = de.id +LEFT JOIN k_sol_token_account_events tae + ON tae.decoded_event_id = de.id +WHERE de.protocol_name = 'meteora_dbc' + AND tx.err_json IS NOT NULL + AND tx.err_json <> '' + AND tx.err_json <> 'null' +GROUP BY de.event_kind +HAVING trade_count > 0 + OR launch_count > 0 + OR liquidity_count > 0 + OR lifecycle_count > 0 + OR fee_count > 0 + OR reward_count > 0 + OR admin_count > 0 + OR orderbook_count > 0 + OR token_account_count > 0 +ORDER BY decoded_failed_count DESC, de.event_kind; + +-- 08. Multi-target materialization safety. +-- Target after closure: empty. One decoded event must not feed several business targets. +SELECT * +FROM ( + SELECT + de.id AS decoded_event_id, + de.event_kind, + tx.signature, + (CASE WHEN te.id IS NULL THEN 0 ELSE 1 END) + + (CASE WHEN lae.id IS NULL THEN 0 ELSE 1 END) + + (CASE WHEN lie.id IS NULL THEN 0 ELSE 1 END) + + (CASE WHEN ple.id IS NULL THEN 0 ELSE 1 END) + + (CASE WHEN fee.id IS NULL THEN 0 ELSE 1 END) + + (CASE WHEN rew.id IS NULL THEN 0 ELSE 1 END) + + (CASE WHEN adm.id IS NULL THEN 0 ELSE 1 END) + + (CASE WHEN obe.id IS NULL THEN 0 ELSE 1 END) + + (CASE WHEN tae.id IS NULL THEN 0 ELSE 1 END) AS target_count + FROM k_sol_dex_decoded_events de + JOIN k_sol_chain_transactions tx + ON tx.id = de.transaction_id + LEFT JOIN k_sol_trade_events te + ON te.decoded_event_id = de.id + LEFT JOIN k_sol_launch_events lae + ON lae.decoded_event_id = de.id + LEFT JOIN k_sol_liquidity_events lie + ON lie.decoded_event_id = de.id + LEFT JOIN k_sol_pool_lifecycle_events ple + ON ple.decoded_event_id = de.id + LEFT JOIN k_sol_fee_events fee + ON fee.decoded_event_id = de.id + LEFT JOIN k_sol_reward_events rew + ON rew.decoded_event_id = de.id + LEFT JOIN k_sol_pool_admin_events adm + ON adm.decoded_event_id = de.id + LEFT JOIN k_sol_orderbook_events obe + ON obe.decoded_event_id = de.id + LEFT JOIN k_sol_token_account_events tae + ON tae.decoded_event_id = de.id + WHERE de.protocol_name = 'meteora_dbc' +) +WHERE target_count > 1 +ORDER BY target_count DESC, event_kind, signature; + +-- 09. Materialization summary by table with successful/failed split. +SELECT + de.event_kind, + COUNT(DISTINCT de.id) AS decoded_count, + COUNT(DISTINCT CASE WHEN tx.err_json IS NULL OR tx.err_json = '' OR tx.err_json = 'null' THEN de.id END) AS successful_decoded_count, + COUNT(DISTINCT CASE WHEN tx.err_json IS NOT NULL AND tx.err_json <> '' AND tx.err_json <> 'null' THEN de.id END) AS failed_decoded_count, + COUNT(DISTINCT te.id) AS trade_count, + COUNT(DISTINCT lie.id) AS liquidity_count, + COUNT(DISTINCT ple.id) AS lifecycle_count, + COUNT(DISTINCT fee.id) AS fee_count, + COUNT(DISTINCT adm.id) AS admin_count, + MIN(tx.signature) AS sample_signature +FROM k_sol_dex_decoded_events de +JOIN k_sol_chain_transactions tx + ON tx.id = de.transaction_id +LEFT JOIN k_sol_trade_events te + ON te.decoded_event_id = de.id +LEFT JOIN k_sol_liquidity_events lie + ON lie.decoded_event_id = de.id +LEFT JOIN k_sol_pool_lifecycle_events ple + ON ple.decoded_event_id = de.id +LEFT JOIN k_sol_fee_events fee + ON fee.decoded_event_id = de.id +LEFT JOIN k_sol_pool_admin_events adm + ON adm.decoded_event_id = de.id +WHERE de.protocol_name = 'meteora_dbc' +GROUP BY de.event_kind +ORDER BY decoded_count DESC, de.event_kind; + +-- 10. Instruction observation versus coverage. +-- Target after closure: every observed discriminator must map to a known coverage row. +SELECT + io.instruction_name, + io.discriminator_hex, + COUNT(*) AS observed_count, + MIN(io.signature) AS sample_signature, + ce.entry_name, + ce.local_event_kind, + ce.expected_db_target, + ce.proof_status +FROM k_sol_instruction_observations io +LEFT JOIN k_sol_dex_event_coverage_entries ce + ON ce.decoder_code = 'meteora_dbc' + AND ( + ce.discriminator_hex = io.discriminator_hex + OR ce.entry_name = io.instruction_name + ) +WHERE io.decoder_code = 'meteora_dbc' +GROUP BY io.instruction_name, io.discriminator_hex, ce.entry_name, ce.local_event_kind, ce.expected_db_target, ce.proof_status +ORDER BY observed_count DESC, io.instruction_name, io.discriminator_hex; + +-- 11. Anti-faux trade/candle for non-swap events. +-- Target after closure: empty. +SELECT + de.event_kind, + COUNT(DISTINCT te.id) AS trade_count, + COUNT(DISTINCT pc.id) AS candle_count, + MIN(tx.signature) AS sample_signature +FROM k_sol_dex_decoded_events de +JOIN k_sol_chain_transactions tx + ON tx.id = de.transaction_id +LEFT JOIN k_sol_trade_events te + ON te.decoded_event_id = de.id +LEFT JOIN k_sol_pair_candles pc + ON pc.pair_id = te.pair_id +WHERE de.protocol_name = 'meteora_dbc' + AND de.event_kind NOT IN ('meteora_dbc.swap', 'meteora_dbc.swap2') +GROUP BY de.event_kind +HAVING trade_count > 0 OR candle_count > 0 +ORDER BY trade_count DESC, candle_count DESC, de.event_kind; + +-- 12. Global watchlist after replay. +-- Target after closure: no meteora_dbc as dominant backlog; unrelated residuals may remain. +SELECT + COALESCE(json_extract(de.payload_json, '$.upstreamDecoderCode'), de.protocol_name) AS backlog_decoder, + de.event_kind, + json_extract(de.payload_json, '$.upstreamEntryName') AS upstream_entry_name, + json_extract(de.payload_json, '$.upstreamDiscriminatorHex') AS upstream_discriminator_hex, + COUNT(*) AS decoded_count, + COUNT(DISTINCT de.transaction_id) AS tx_count, + MIN(tx.signature) AS sample_signature +FROM k_sol_dex_decoded_events de +LEFT JOIN k_sol_chain_transactions tx + ON tx.id = de.transaction_id +WHERE de.protocol_name IN ('upstream_git', 'meteora_dbc') +GROUP BY backlog_decoder, de.event_kind, upstream_entry_name, upstream_discriminator_hex +ORDER BY decoded_count DESC, backlog_decoder, de.event_kind +LIMIT 100; + +-- 13. Fee parent/legs summary after 0.7.56 fee model. +SELECT + de.protocol_name, + de.event_kind, + COUNT(DISTINCT fee.id) AS fee_parent_count, + COUNT(DISTINCT CASE + WHEN COALESCE(TRIM(fee.fee_token_mint), '') <> '' + AND COALESCE(TRIM(fee.fee_amount_raw), '') <> '' + THEN fee.id + ELSE NULL + END) AS parent_with_scalar_amount_count, + COUNT(DISTINCT fea.id) AS fee_amount_leg_count, + MIN(tx.signature) AS sample_signature +FROM k_sol_fee_events fee +JOIN k_sol_dex_decoded_events de + ON de.id = fee.decoded_event_id +JOIN k_sol_chain_transactions tx + ON tx.id = fee.transaction_id +LEFT JOIN k_sol_fee_event_amounts fea + ON fea.fee_event_id = fee.id +WHERE de.protocol_name = 'meteora_dbc' +GROUP BY + de.protocol_name, + de.event_kind +ORDER BY + de.protocol_name, + de.event_kind; + +-- 14. Fee parent scalar without amount leg. +-- Target after closure: empty. +SELECT + de.protocol_name, + de.event_kind, + tx.signature, + fee.id AS fee_event_id, + fee.fee_token_mint, + fee.fee_amount_raw, + fee.payload_json +FROM k_sol_fee_events fee +JOIN k_sol_dex_decoded_events de + ON de.id = fee.decoded_event_id +JOIN k_sol_chain_transactions tx + ON tx.id = fee.transaction_id +LEFT JOIN k_sol_fee_event_amounts fea + ON fea.fee_event_id = fee.id +WHERE de.protocol_name = 'meteora_dbc' + AND COALESCE(TRIM(fee.fee_token_mint), '') <> '' + AND COALESCE(TRIM(fee.fee_amount_raw), '') <> '' + AND fea.id IS NULL +ORDER BY + de.protocol_name, + de.event_kind, + tx.signature +LIMIT 100; + +-- 15. Orphan fee amount legs. +-- Target after closure: empty. +SELECT + fea.id, + fea.fee_event_id, + fea.transaction_id, + fea.decoded_event_id +FROM k_sol_fee_event_amounts fea +LEFT JOIN k_sol_fee_events fee + ON fee.id = fea.fee_event_id +WHERE fee.id IS NULL; + +-- 16. Generic allowlisted recovery must not be used by DBC. +-- Target after closure: empty; DBC uses protocol-specific fee recovery paths. +SELECT + de.protocol_name, + de.event_kind, + tx.signature, + fee.id AS fee_event_id, + fea.leg_index, + fea.token_mint, + fea.amount_raw, + fea.amount_source +FROM k_sol_fee_event_amounts fea +JOIN k_sol_fee_events fee + ON fee.id = fea.fee_event_id +JOIN k_sol_dex_decoded_events de + ON de.id = fee.decoded_event_id +JOIN k_sol_chain_transactions tx + ON tx.id = fee.transaction_id +WHERE de.protocol_name = 'meteora_dbc' + AND fea.amount_source = 'allowlisted_inner_spl_transfer' +ORDER BY + de.event_kind, + tx.signature, + fea.leg_index; diff --git a/validation_sql/SQL_VALIDATION_METEORA_DLMM_0_7_57.sql b/validation_sql/SQL_VALIDATION_METEORA_DLMM_0_7_57.sql new file mode 100644 index 0000000..f159b8e --- /dev/null +++ b/validation_sql/SQL_VALIDATION_METEORA_DLMM_0_7_57.sql @@ -0,0 +1,285 @@ +-- file: validation_sql/SQL_VALIDATION_METEORA_DLMM_0_7_57.sql + +-- 0.7.57 meteora_dlmm validation checklist. +-- Run on a dedicated fresh SQLite database for the Meteora DLMM tranche. +-- Recommended replay settings: +-- skipDexDecode=no, forceDexDecode=yes, deferInstructionObservations=yes. +-- This file is read-only. + +-- 00. Upstream fallback samples to backfill/promote. +SELECT + json_extract(de.payload_json, '$.upstreamEntryName') AS upstream_entry_name, + json_extract(de.payload_json, '$.upstreamDiscriminatorHex') AS upstream_discriminator_hex, + json_extract(de.payload_json, '$.upstreamSourceRepo') AS source_repo, + COUNT(*) AS fallback_count, + COUNT(DISTINCT de.transaction_id) AS tx_count, + MIN(tx.signature) AS sample_signature +FROM k_sol_dex_decoded_events de +LEFT JOIN k_sol_chain_transactions tx + ON tx.id = de.transaction_id +WHERE de.protocol_name = 'upstream_git' + AND de.event_kind = 'upstream_git.instruction_match' + AND json_extract(de.payload_json, '$.upstreamDecoderCode') = 'meteora_dlmm' +GROUP BY upstream_entry_name, upstream_discriminator_hex, source_repo +ORDER BY fallback_count DESC, upstream_entry_name, upstream_discriminator_hex; + +-- 01. Local instruction observations. +SELECT + instruction_name, + discriminator_hex, + COUNT(*) AS observed_count, + COUNT(DISTINCT signature) AS tx_count, + MIN(signature) AS sample_signature +FROM k_sol_instruction_observations +WHERE decoder_code = 'meteora_dlmm' +GROUP BY instruction_name, discriminator_hex +ORDER BY observed_count DESC, instruction_name, discriminator_hex; + +-- 02. Coverage entries. +SELECT + entry_name, + entry_kind, + event_family, + expected_db_target, + proof_status, + local_event_kind, + discriminator_hex, + observed_count, + materialized_count, + trade_count +FROM k_sol_dex_event_coverage_entries +WHERE decoder_code = 'meteora_dlmm' +ORDER BY entry_kind, entry_name, discriminator_hex; + +-- 03. Decoded DLMM summary. +SELECT + de.event_kind, + COUNT(*) AS decoded_count, + COUNT(DISTINCT de.transaction_id) AS tx_count, + MIN(tx.signature) AS sample_signature +FROM k_sol_dex_decoded_events de +LEFT JOIN k_sol_chain_transactions tx + ON tx.id = de.transaction_id +WHERE de.protocol_name = 'meteora_dlmm' +GROUP BY de.event_kind +ORDER BY decoded_count DESC, de.event_kind; + +-- 04. Decoded DLMM without coverage. Target: empty. +SELECT + de.event_kind, + COUNT(*) AS decoded_count, + COUNT(DISTINCT de.transaction_id) AS tx_count, + MIN(tx.signature) AS sample_signature +FROM k_sol_dex_decoded_events de +LEFT JOIN k_sol_chain_transactions tx + ON tx.id = de.transaction_id +LEFT JOIN k_sol_dex_event_coverage_entries ce + ON ce.decoder_code = 'meteora_dlmm' + AND ce.local_event_kind = de.event_kind +WHERE de.protocol_name = 'meteora_dlmm' + AND ce.id IS NULL +GROUP BY de.event_kind +ORDER BY decoded_count DESC, de.event_kind; + +-- 05. Successful non-materialized without explicit skip/policy. Target: empty. +SELECT + de.event_kind, + COUNT(*) AS unexplained_count, + MIN(tx.signature) AS sample_signature +FROM k_sol_dex_decoded_events de +JOIN k_sol_chain_transactions tx + ON tx.id = de.transaction_id +LEFT JOIN k_sol_trade_events te ON te.decoded_event_id = de.id +LEFT JOIN k_sol_launch_events lae ON lae.decoded_event_id = de.id +LEFT JOIN k_sol_liquidity_events lie ON lie.decoded_event_id = de.id +LEFT JOIN k_sol_pool_lifecycle_events ple ON ple.decoded_event_id = de.id +LEFT JOIN k_sol_fee_events fee ON fee.decoded_event_id = de.id +LEFT JOIN k_sol_reward_events rew ON rew.decoded_event_id = de.id +LEFT JOIN k_sol_pool_admin_events adm ON adm.decoded_event_id = de.id +LEFT JOIN k_sol_orderbook_events obe ON obe.decoded_event_id = de.id +LEFT JOIN k_sol_token_account_events tae ON tae.decoded_event_id = de.id +WHERE de.protocol_name = 'meteora_dlmm' + AND (tx.err_json IS NULL OR tx.err_json = '' OR tx.err_json = 'null') + AND COALESCE(json_extract(de.payload_json, '$.eventActionability'), '') NOT IN ( + 'informational', + 'decoded_only_anchor_event', + 'decoded_only_missing_mint_context', + 'decoded_only_with_explicit_skip_reason' + ) + AND te.id IS NULL AND lae.id IS NULL AND lie.id IS NULL AND ple.id IS NULL + AND fee.id IS NULL AND rew.id IS NULL AND adm.id IS NULL AND obe.id IS NULL AND tae.id IS NULL + AND COALESCE(TRIM(json_extract(de.payload_json, '$.skipTradeReason')), '') = '' + AND COALESCE(TRIM(json_extract(de.payload_json, '$.skipCandleReason')), '') = '' + AND COALESCE(TRIM(json_extract(de.payload_json, '$.skipLiquidityReason')), '') = '' + AND COALESCE(TRIM(json_extract(de.payload_json, '$.skipLifecycleReason')), '') = '' + AND COALESCE(TRIM(json_extract(de.payload_json, '$.skipCatalogReason')), '') = '' + AND COALESCE(TRIM(json_extract(de.payload_json, '$.skipFeeReason')), '') = '' + AND COALESCE(TRIM(json_extract(de.payload_json, '$.skipRewardReason')), '') = '' + AND COALESCE(TRIM(json_extract(de.payload_json, '$.skipAdminReason')), '') = '' + AND COALESCE(TRIM(json_extract(de.payload_json, '$.skipOrderbookReason')), '') = '' +GROUP BY de.event_kind +ORDER BY unexplained_count DESC, de.event_kind; + +-- 06. Failed transaction materialization. Target: empty. +SELECT + de.event_kind, + COUNT(DISTINCT de.id) AS decoded_failed_count, + COUNT(DISTINCT te.id) AS trade_count, + COUNT(DISTINCT lie.id) AS liquidity_count, + COUNT(DISTINCT ple.id) AS lifecycle_count, + COUNT(DISTINCT fee.id) AS fee_count, + COUNT(DISTINCT rew.id) AS reward_count, + COUNT(DISTINCT adm.id) AS admin_count, + COUNT(DISTINCT obe.id) AS orderbook_count, + MIN(tx.signature) AS sample_signature +FROM k_sol_dex_decoded_events de +JOIN k_sol_chain_transactions tx + ON tx.id = de.transaction_id +LEFT JOIN k_sol_trade_events te ON te.decoded_event_id = de.id +LEFT JOIN k_sol_liquidity_events lie ON lie.decoded_event_id = de.id +LEFT JOIN k_sol_pool_lifecycle_events ple ON ple.decoded_event_id = de.id +LEFT JOIN k_sol_fee_events fee ON fee.decoded_event_id = de.id +LEFT JOIN k_sol_reward_events rew ON rew.decoded_event_id = de.id +LEFT JOIN k_sol_pool_admin_events adm ON adm.decoded_event_id = de.id +LEFT JOIN k_sol_orderbook_events obe ON obe.decoded_event_id = de.id +WHERE de.protocol_name = 'meteora_dlmm' + AND tx.err_json IS NOT NULL + AND tx.err_json <> '' + AND tx.err_json <> 'null' +GROUP BY de.event_kind +HAVING trade_count > 0 OR liquidity_count > 0 OR lifecycle_count > 0 + OR fee_count > 0 OR reward_count > 0 OR admin_count > 0 OR orderbook_count > 0 +ORDER BY decoded_failed_count DESC, de.event_kind; + +-- 07. Multi-target materialization. Target: empty. +SELECT * +FROM ( + SELECT + de.id AS decoded_event_id, + de.event_kind, + tx.signature, + (CASE WHEN te.id IS NULL THEN 0 ELSE 1 END) + + (CASE WHEN lie.id IS NULL THEN 0 ELSE 1 END) + + (CASE WHEN ple.id IS NULL THEN 0 ELSE 1 END) + + (CASE WHEN fee.id IS NULL THEN 0 ELSE 1 END) + + (CASE WHEN rew.id IS NULL THEN 0 ELSE 1 END) + + (CASE WHEN adm.id IS NULL THEN 0 ELSE 1 END) + + (CASE WHEN obe.id IS NULL THEN 0 ELSE 1 END) AS target_count + FROM k_sol_dex_decoded_events de + JOIN k_sol_chain_transactions tx ON tx.id = de.transaction_id + LEFT JOIN k_sol_trade_events te ON te.decoded_event_id = de.id + LEFT JOIN k_sol_liquidity_events lie ON lie.decoded_event_id = de.id + LEFT JOIN k_sol_pool_lifecycle_events ple ON ple.decoded_event_id = de.id + LEFT JOIN k_sol_fee_events fee ON fee.decoded_event_id = de.id + LEFT JOIN k_sol_reward_events rew ON rew.decoded_event_id = de.id + LEFT JOIN k_sol_pool_admin_events adm ON adm.decoded_event_id = de.id + LEFT JOIN k_sol_orderbook_events obe ON obe.decoded_event_id = de.id + WHERE de.protocol_name = 'meteora_dlmm' +) +WHERE target_count > 1 +ORDER BY target_count DESC, event_kind, signature; + +-- 08. Non-swap trade/candle safety. Target: empty. +SELECT + de.event_kind, + COUNT(DISTINCT te.id) AS trade_count, + COUNT(DISTINCT pc.id) AS candle_count, + MIN(tx.signature) AS sample_signature +FROM k_sol_dex_decoded_events de +JOIN k_sol_chain_transactions tx ON tx.id = de.transaction_id +LEFT JOIN k_sol_trade_events te ON te.decoded_event_id = de.id +LEFT JOIN k_sol_pair_candles pc ON pc.pair_id = te.pair_id +WHERE de.protocol_name = 'meteora_dlmm' + AND de.event_kind NOT IN ( + 'meteora_dlmm.swap', + 'meteora_dlmm.swap2', + 'meteora_dlmm.swap_exact_out', + 'meteora_dlmm.swap_exact_out2', + 'meteora_dlmm.swap_with_price_impact', + 'meteora_dlmm.swap_with_price_impact2' + ) +GROUP BY de.event_kind +HAVING trade_count > 0 OR candle_count > 0 +ORDER BY trade_count DESC, candle_count DESC, de.event_kind; + +-- 09. Fee parent/legs summary. +SELECT + de.protocol_name, + de.event_kind, + COUNT(DISTINCT fee.id) AS fee_parent_count, + COUNT(DISTINCT CASE + WHEN COALESCE(TRIM(fee.fee_token_mint), '') <> '' + AND COALESCE(TRIM(fee.fee_amount_raw), '') <> '' + THEN fee.id + ELSE NULL + END) AS parent_with_scalar_amount_count, + COUNT(DISTINCT fea.id) AS fee_amount_leg_count, + MIN(tx.signature) AS sample_signature +FROM k_sol_fee_events fee +JOIN k_sol_dex_decoded_events de ON de.id = fee.decoded_event_id +JOIN k_sol_chain_transactions tx ON tx.id = fee.transaction_id +LEFT JOIN k_sol_fee_event_amounts fea ON fea.fee_event_id = fee.id +WHERE de.protocol_name = 'meteora_dlmm' +GROUP BY de.protocol_name, de.event_kind +ORDER BY de.protocol_name, de.event_kind; + +-- 10. Fee parent scalar without leg. Target: empty. +SELECT + de.protocol_name, + de.event_kind, + tx.signature, + fee.id AS fee_event_id, + fee.fee_token_mint, + fee.fee_amount_raw, + fee.payload_json +FROM k_sol_fee_events fee +JOIN k_sol_dex_decoded_events de ON de.id = fee.decoded_event_id +JOIN k_sol_chain_transactions tx ON tx.id = fee.transaction_id +LEFT JOIN k_sol_fee_event_amounts fea ON fea.fee_event_id = fee.id +WHERE de.protocol_name = 'meteora_dlmm' + AND COALESCE(TRIM(fee.fee_token_mint), '') <> '' + AND COALESCE(TRIM(fee.fee_amount_raw), '') <> '' + AND fea.id IS NULL +ORDER BY de.protocol_name, de.event_kind, tx.signature +LIMIT 100; + +-- 11. Orphan fee amount legs. Target: empty. +SELECT + fea.id, + fea.fee_event_id, + fea.transaction_id, + fea.decoded_event_id +FROM k_sol_fee_event_amounts fea +LEFT JOIN k_sol_fee_events fee ON fee.id = fea.fee_event_id +WHERE fee.id IS NULL; + +-- 12. Reward/fee separation overview. +SELECT + de.event_kind, + COUNT(DISTINCT fee.id) AS fee_count, + COUNT(DISTINCT rew.id) AS reward_count, + MIN(tx.signature) AS sample_signature +FROM k_sol_dex_decoded_events de +JOIN k_sol_chain_transactions tx ON tx.id = de.transaction_id +LEFT JOIN k_sol_fee_events fee ON fee.decoded_event_id = de.id +LEFT JOIN k_sol_reward_events rew ON rew.decoded_event_id = de.id +WHERE de.protocol_name = 'meteora_dlmm' +GROUP BY de.event_kind +HAVING fee_count > 0 OR reward_count > 0 +ORDER BY de.event_kind; + +-- 13. Global watchlist after replay. +SELECT + COALESCE(json_extract(de.payload_json, '$.upstreamDecoderCode'), de.protocol_name) AS backlog_decoder, + de.event_kind, + json_extract(de.payload_json, '$.upstreamEntryName') AS upstream_entry_name, + json_extract(de.payload_json, '$.upstreamDiscriminatorHex') AS upstream_discriminator_hex, + COUNT(*) AS decoded_count, + COUNT(DISTINCT de.transaction_id) AS tx_count, + MIN(tx.signature) AS sample_signature +FROM k_sol_dex_decoded_events de +LEFT JOIN k_sol_chain_transactions tx ON tx.id = de.transaction_id +WHERE de.protocol_name IN ('upstream_git', 'meteora_dlmm') +GROUP BY backlog_decoder, de.event_kind, upstream_entry_name, upstream_discriminator_hex +ORDER BY decoded_count DESC, backlog_decoder, de.event_kind +LIMIT 100;