From 38f42da970f8f29ff0129ae4ccbd18fc4bb173f8 Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Thu, 11 Jun 2026 17:22:55 +0200 Subject: [PATCH] 0.7.52 --- CHANGELOG.md | 1 + Cargo.toml | 2 +- README.md | 43 +- ROADMAP.md | 66 +- docs/DB_EVENT_MODEL_REVIEW.md | 13 + docs/DEX_DECODER_MATRIX.md | 13 +- docs/DEX_EVENT_COVERAGE_MATRIX.md | 25 + docs/SOLSCAN_ACCOUNT_SOURCE_MATRIX.md | 3 - docs/VALIDATION_STATUS_0_7_52_FINAL.md | 67 + ...YDIUM_STABLE_SWAP_EVENT_COVERAGE_REPORT.md | 162 ++ kb_demo_app/package.json | 2 +- kb_demo_app/tauri.conf.json | 2 +- kb_lib/src/dex.rs | 7 + kb_lib/src/dex/raydium_stable_swap.rs | 1524 +++++++++++++++++ kb_lib/src/dex_decode.rs | 104 +- kb_lib/src/dex_detect.rs | 78 + kb_lib/src/dex_detection_route.rs | 16 + kb_lib/src/dex_event_coverage.rs | 93 + kb_lib/src/instruction_observation_index.rs | 29 +- kb_lib/src/lib.rs | 28 +- kb_lib/src/trade_amount_resolution.rs | 16 + kb_lib/src/upstream_registry_generated.rs | 99 ++ ..._VALIDATION_RAYDIUM_STABLE_SWAP_0_7_52.sql | 292 ++++ 23 files changed, 2650 insertions(+), 35 deletions(-) create mode 100644 docs/VALIDATION_STATUS_0_7_52_FINAL.md create mode 100644 docs/reports/RAYDIUM_STABLE_SWAP_EVENT_COVERAGE_REPORT.md create mode 100644 kb_lib/src/dex/raydium_stable_swap.rs create mode 100644 validation_sql/SQL_VALIDATION_RAYDIUM_STABLE_SWAP_0_7_52.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 96863a5..4c1bb66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,3 +84,4 @@ 0.7.50 - Raydium Launchpad event coverage bootstrap : normalisation locale canonique vers `raydium_launchpad`, ajout de `RAYDIUM_LAUNCHPAD_PROGRAM_ID`, synchronisation des entrées Carbon Launchpad dans le registre upstream, fallback audit/mapped decoder pour discriminants Launchpad, enrichissement audit Anchor self-CPI, maintien conservatoire en `decoded_events_only`, rapport Launchpad et SQL de validation dédiés. 0.7.50-pre-r2 - Clôture CPMM/CLMM post-Launchpad : ajout des entrées Carbon `cpi_event` pour `raydium_cpmm` et `raydium_clmm`, ajout de `raydium_clmm.update_dynamic_fee_config`, normalisation des Program-data events CLMM, ajout de la table `k_sol_token_account_events` et de la matérialisation `create_support_mint_associated`, reclassement des familles ambiguës (`cpi_transport`, `liquidity_calculation`, `liquidity_change`, `position_open`, `pool_create`, `admin_config`, `account_create`, `idl_management`), codage du discriminant CPMM `40f4bc78a7e9690a` comme `raydium_cpmm.anchor_idl_instruction` decoded-only après inspection Solscan, et contexte de secours pour matérialisation liquidity CLMM via événements frères quand possible. 0.7.51 - Raydium AMM v4 event coverage clôturé : decoder maximal local pour tous les discriminants officiels AMM v4 `00..11`, spécialisation des swaps `swap_base_in/out` et `swap_base_in/out_v2`, suppression durable du `raydium_amm_v4.swap` legacy, index AMM v4 en discriminant 1 octet, matérialisation validée des swaps, liquidity, lifecycle, fees, admin/config et side effects orderbook, `pre_initialize` conservé comme lifecycle audit deprecated/partial, `simulate_info` decoded-only, reset replay renforcé par `protocol_name`, validation des invariants failed/non-swap/single-target/unexplained gaps et maintien de `raydium_pool_v4` en audit conditionnel sans decoder autonome. +0.7.52 - Raydium Stable Swap event coverage clôturé : decoder legacy 1 octet pour la surface locale `00..0d`, matérialisation lifecycle/liquidity/admin/fee/orderbook selon contexte, swaps `swap_base_in/out` matérialisés uniquement depuis deltas de vaults exacts (`stable_swap_vault_balance_delta`), conservation des bornes d’instruction comme audit-only, failed transactions decoded-only avec skip reasons, validation locale 407 tests et clippy `-D warnings` OK. diff --git a/Cargo.toml b/Cargo.toml index 42afbe8..f5f7921 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ ] [workspace.package] -version = "0.7.51" +version = "0.7.52" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-bobobot" diff --git a/README.md b/README.md index 094b3f1..497a96e 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ non-swap CLMM avec trade_count > 0 = 0 Les 11 Anchor / `Program data` events CLMM restent listés en `upstream_git_unverified` car aucun corpus local ne les observe encore. Le code est préparé pour les accueillir en audit-only lorsqu’ils apparaîtront dans un corpus local, sans créer de trade/candle par défaut. -La tranche `0.7.51 raydium_amm_v4` est maintenant validée côté `kb_lib`. La suite de roadmap reprend avec `0.7.52 raydium_stable`, tandis que `raydium_pool_v4` reste un audit conditionnel ultérieur et ne doit pas être promu sans confirmation de program id/rôle/corpus. +Les tranches `0.7.51 raydium_amm_v4` et `0.7.52 raydium_stable_swap` sont maintenant validées côté `kb_lib`. La suite de roadmap reprend avec les rechecks conditionnels et les surfaces restantes, tandis que `raydium_pool_v4` reste un audit conditionnel ultérieur et ne doit pas être promu sans confirmation de program id/rôle/corpus. ## Organisation documentaire @@ -320,7 +320,7 @@ Chaque DEX ou variante de DEX doit avoir sa propre étape de validation. Les fam À garder dans la matrice mais sans bloquer les versions immédiates : -- `raydium_stable_swap` tant que son usage réel n’est pas démontré par corpus local ; +- `raydium_stable_swap` est désormais démontré par corpus local en `0.7.52` ; le garder comme DEX effectif supporté, avec surveillance des nouveaux discriminants ; - vieux programmes legacy uniquement utiles pour compatibilité ou replay historique ; - agrégateurs/routeurs comme `okx_dex` tant qu’ils ne correspondent pas à un DEX direct matérialisable ; - entrées ambiguës comme `zora` tant qu’aucun programme Solana pertinent n’est prouvé. @@ -433,7 +433,7 @@ La priorité immédiate après le point de reprise `0.7.43-E5C` est : 2. `0.7.49` : `raydium_clmm` — clôturé côté instructions observées, matérialisation non-trade prouvée et nettoyage fallback ; 3. `0.7.50-pre-r2` : `raydium_launchpad` clos + re-vérification `raydium_cpmm` / `raydium_clmm` ; 4. `0.7.51` : `raydium_amm_v4` ; -5. `0.7.52` : `raydium_stable` ; +5. `0.7.52` : `raydium_stable_swap` — clôturé ; 6. `0.7.53` : `raydium_pool_v4` audit / program-id decision seulement si program id distinct et corpus exploitable ; 7. `0.7.54` : `pump_swap` ; 8. `0.7.55` : `pump_fun` ; @@ -546,7 +546,7 @@ La suite fonctionnelle reprend par Raydium avant Meteora : 2. `0.7.49` — `raydium_clmm` ; 3. `0.7.50-pre-r2` — `raydium_launchpad` + clôture CPMM/CLMM ; 4. `0.7.51` — `raydium_amm_v4` ; -5. `0.7.52` — `raydium_stable` ; +5. `0.7.52` — `raydium_stable_swap` — clôturé ; 6. `0.7.53` — `raydium_pool_v4` audit conditionnel, sans promotion automatique ; 7. `0.7.54+` — Pump, Meteora, Phoenix/OpenBook, Orca puis les autres DEX/surfaces. @@ -631,4 +631,37 @@ Cette tranche complète la clôture Raydium en ajoutant `cpi_event` pour CPMM/CL Le discriminant CPMM `40f4bc78a7e9690a` est désormais codé comme `raydium_cpmm.anchor_idl_instruction` : les signatures inspectées correspondent aux instructions Anchor `IdlCreateAccount` / `IdlCloseAccount`, donc il reste `decoded_events_only` et ne matérialise aucune table métier. -Rapport de clôture : `docs/reports/RAYDIUM_CPMM_CLMM_RECHECK_REPORT_0_7_50_PRE_R2.md`. \ No newline at end of file +Rapport de clôture : `docs/reports/RAYDIUM_CPMM_CLMM_RECHECK_REPORT_0_7_50_PRE_R2.md`. +## Tranche clôturée — 0.7.52 raydium_stable_swap + +`0.7.52` clôture Raydium Stable Swap avec le code local canonique `raydium_stable_swap` et le program id `5quBtoiQqxF9Jv6KYKctB59NT3gtJD2Y65kdnB1Uev3h`. + +Décisions finales : + +- Stable Swap est décodé en layout legacy **1 octet**. +- La surface locale observée `00..0d` est couverte : lifecycle, model setup, admin/config, liquidity, orderbook side effects, fees et swaps. +- `swap_base_in` / `swap_base_out` produisent trades/candles uniquement avec des montants exacts dérivés des deltas de vaults (`amountSource=stable_swap_vault_balance_delta`). +- Les arguments d’instruction `amountInRaw`, `minimumAmountOutRaw`, `maxAmountInRaw`, `amountOutRaw` sont conservés comme bornes d’instruction, mais ne sont pas utilisés comme prix/montants exacts. +- Les transactions failed restent decoded-only avec `skipTradeReason=failed_transaction` et `skipCandleReason=failed_transaction`. + +Livrables de clôture : + +- `kb_lib/src/dex/raydium_stable_swap.rs` +- `docs/reports/RAYDIUM_STABLE_SWAP_EVENT_COVERAGE_REPORT.md` +- `validation_sql/SQL_VALIDATION_RAYDIUM_STABLE_SWAP_0_7_52.sql` + +Validation locale finale : + +```text +cargo test -p kb_lib -> 407 passed, 0 failed +cargo clippy -p kb_lib --all-targets -- -D warnings -> ok +``` + +Replay final observé : + +```text +replayed=298, trades=290, liquidity=16, lifecycle=4, candle_upserts=1160, +instructionObservations=5317, catalog=40 tokens / 59 pools / 59 pairs +``` + +Statut : **clôturé côté code et validation locale**. diff --git a/ROADMAP.md b/ROADMAP.md index 6f130ae..5753250 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -36,7 +36,7 @@ Règles de planification : | `0.7.49` | `raydium_clmm` | Clôturé : 33 instructions observées/décodées, orderbook CLMM, liquidity/fee/reward/admin/lifecycle, fallbacks upstream nettoyés, 11 Program-data events préparés mais non observés. | | `0.7.50` | `raydium_launchpad` | Bootstrap ouvert : surface LaunchLab/Launchpad, discriminants Carbon/IDL, fallback audit, SQL de validation, aucune matérialisation métier sans corpus. | | `0.7.51` | `raydium_amm_v4` | Clôturé : decoder maximal `00..11`, swaps spécialisés, lifecycle/liquidity/fees/admin/orderbook, `pre_initialize` audit, `simulate_info` decoded-only, cleanup legacy/fallback. | -| `0.7.52` | `raydium_stable` | Reprendre Raydium Stable : program ids/IDL, swaps stables, pool lifecycle, liquidity, fees/admin, invariants pricing/candles. | +| `0.7.52` | `raydium_stable_swap` | Clôturé : surface legacy `00..0d`, swaps via deltas vault exacts, failed tx decoded-only, invariants trade/candle propres. | | `0.7.53` | `raydium_pool_v4` | Audit / program-id decision seulement : confirmer program id, rôle exact et corpus avant toute promotion métier. | | `0.7.54` | `pump_swap` | Couvrir `buy/sell` et tous les events auxiliaires disponibles : fees, cashback, volume accumulator, admin/config. | | `0.7.55` | `pump_fun` | Traiter launch/bonding/migration ; séparer création token, buy/sell bonding, migration vers DEX effectif. | @@ -855,7 +855,7 @@ Matrice cible initiale : | `raydium_launchpad` | launch surface | planifié, program id local connu | ajouter decoder/materialization dédiée | | `raydium_amm_v4` | AMM legacy | partiel | corpus dédié après autres Raydium | | `raydium_router` | router | partiel | ne pas matérialiser en trade direct avant preuve | -| `raydium_stable_swap` | AMM legacy | planifié | traiter seulement si corpus pertinent | +| `raydium_stable_swap` | AMM legacy | supporté / 0.7.52 clos | swaps depuis deltas vault exacts ; failed tx decoded-only | | `meteora_dlmm` | DLMM | supporté | verrouiller corpus et non-régression | | `meteora_damm_v1` | AMM legacy | partiel | garder skip explicite sans payload montant/prix | | `meteora_damm_v2` | AMM | partiel | corpus et séparation events | @@ -1006,7 +1006,7 @@ Réalisé : - maintien des launch surfaces comme surfaces reportées et non prioritaires ; - ajout du profil `0.7.39_dex_first_effective_swap_surfaces` ; - validation locale confirmée avec `validationPassed = true`, `blockingIssueCount = 0`, `actionableMissingTradeEventCount = 0` et `missingTradeEventCount = 0` ; -- confirmation par corpus local initial que Raydium CLMM est observé, tandis que Raydium AMM v4 et Stable Swap ne sont pas encore exploitables sans constitution de corpus dédiée. +- confirmation par corpus local initial que Raydium CLMM est observé ; les tranches ultérieures ont ensuite clôturé Raydium AMM v4 en `0.7.51` et Stable Swap en `0.7.52`. Décision : `0.7.39` est clos. La suite immédiate ne doit pas commencer par un décodeur Raydium AMM v4 sans corpus. Il faut d’abord ajouter les outils de découverte on-chain et de backfill ciblé afin d’obtenir des signatures, pools/state accounts, token mints et instructions exploitables. @@ -1310,10 +1310,10 @@ Objectif : hisser AMM v4 legacy au niveau de couverture CPMM/CLMM. Réalisé : decoder maximal AMM v4 pour tous les discriminants officiels `00..11`, spécialisation des swaps (`swap_base_in`, `swap_base_out`, `swap_base_in_v2`, `swap_base_out_v2`), suppression du legacy `raydium_amm_v4.swap`, observation locale de tous les discriminants, matérialisation validée des familles trade, liquidity, lifecycle, fee, admin/config et orderbook, `pre_initialize` conservé comme lifecycle audit deprecated/partial, `simulate_info` conservé en decoded-only, gaps successful non matérialisés expliqués, et validation des invariants failed/non-swap/single-target. -### 6.084. Version `0.7.52` — `raydium_stable` event coverage -Objectif : reprendre Raydium Stable comme troisième tranche Raydium post-CLMM. +### 6.084. Version `0.7.52` — `raydium_stable_swap` event coverage +Objectif : reprendre Raydium Stable comme tranche Raydium dédiée après AMM v4. -À faire : vérifier program ids/IDL, swaps stables, liquidity, pool lifecycle, fees/admin/config, cohérence des montants/prix et absence de faux trades/candles. +Réalisé : decoder legacy 1 octet, surface locale `00..0d`, matérialisation lifecycle/liquidity/admin/fee/orderbook selon contexte, swaps `swap_base_in/out` matérialisés uniquement depuis deltas vault exacts (`stable_swap_vault_balance_delta`), transactions failed decoded-only, invariants trade/candle propres. ### 6.085. Version `0.7.53` — `raydium_pool_v4` audit / program-id decision Objectif : auditer `raydium_pool_v4.json` comme source IDL annexe, sans promotion métier automatique. @@ -1566,7 +1566,7 @@ Ordre de travail recommandé pour la suite : 7. `0.7.49` : `raydium_clmm` — clos ; 8. `0.7.50-pre-r2` : `raydium_launchpad` clos + re-vérification CPMM/CLMM ; 9. `0.7.51` : `raydium_amm_v4` ; -10. `0.7.52` : `raydium_stable` ; +10. `0.7.52` : `raydium_stable_swap` — clôturé ; 11. `0.7.53` : `raydium_pool_v4` audit conditionnel ; 12. `0.7.54` : `pump_swap` ; 13. `0.7.55` : `pump_fun` ; @@ -1649,7 +1649,7 @@ La tranche CPMM reconnaît désormais tous les discriminants instruction-level l `0.7.48` est clôturable côté `raydium_cpmm`. Le decoder couvre les instructions/events CPMM listés par Carbon/fnzero/Raydium CP-Swap, avec matérialisation locale validée pour trades, liquidity, lifecycle, fees et admin/config. `swap_event` reste audit-only pour éviter les doublons avec `swap_base_input` / `swap_base_output`. Les side effects SPL Token / Token-2022 observés via Solscan (`burn`, `transfer`, `transferChecked`, `closeAccount`) restent hors decoder CPMM direct et alimenteront une réflexion transversale future. -La suite après `0.7.49 raydium_clmm` reprend en `0.7.50-pre-r2` par la clôture Launchpad et la re-vérification CPMM/CLMM, puis `0.7.51 raydium_amm_v4`, `0.7.52 raydium_stable` et `0.7.53 raydium_pool_v4` uniquement comme audit conditionnel, en gardant la même discipline : sources Git/IDL + Solscan pour accélérer la découverte, mais corpus local obligatoire avant toute promotion métier. +La suite après `0.7.49 raydium_clmm` reprend en `0.7.50-pre-r2` par la clôture Launchpad et la re-vérification CPMM/CLMM, puis `0.7.51 raydium_amm_v4`, `0.7.52 raydium_stable_swap` et `0.7.53 raydium_pool_v4` uniquement comme audit conditionnel, en gardant la même discipline : sources Git/IDL + Solscan pour accélérer la découverte, mais corpus local obligatoire avant toute promotion métier. ## Clôture `0.7.51` — Raydium AMM v4 @@ -1674,4 +1674,52 @@ Décision `raydium_pool_v4` : ne pas ouvrir de decoder autonome dans cette tranc Le rapport de décision est `docs/reports/RAYDIUM_POOL_V4_DECISION_NOTE.md`. -La suite reprend `0.7.52 raydium_stable`, après rechecks CPMM/CLMM/Launchpad si la base de validation les contient. +La tranche `0.7.52 raydium_stable_swap` est clôturée ; la suite reprend sur les surfaces restantes ou les audits conditionnels selon le corpus disponible. + +### Addendum final — `0.7.52 raydium_stable_swap` + +Statut : **clôturé**. + +Décision finale : `raydium_stable_swap` est le code local canonique pour le program id `5quBtoiQqxF9Jv6KYKctB59NT3gtJD2Y65kdnB1Uev3h`. Le decoder utilise un layout legacy 1 octet et couvre la surface localement observée `00..0d`. + +Résultat de tranche : + +- `initialize` matérialise le lifecycle quand le contexte est complet ; +- `init_model_data` reste decoded-only expliqué ; +- `update_model_data` matérialise `k_sol_pool_admin_events` ; +- `deposit` / `withdraw` matérialisent `k_sol_liquidity_events` ; +- `monitor_step` / `admin_cancel_orders` matérialisent `k_sol_orderbook_events` quand le contexte est complet ; +- `set_params` matérialise `k_sol_pool_admin_events` ; +- `withdraw_pnl` / `withdraw_srm` matérialisent `k_sol_fee_events` quand le contexte est complet ; +- `simulate_info` reste decoded-only ; +- `swap_base_in` / `swap_base_out` matérialisent trades/candles uniquement depuis `amountSource=stable_swap_vault_balance_delta` ; +- `stable_swap_instruction_bounds_only` reste decoded-only et ne matérialise pas de trade/candle ; +- les transactions failed restent decoded-only avec `failed_transaction`. + +Validation finale : + +```text +cargo test -p kb_lib -> 407 passed, 0 failed +cargo clippy -p kb_lib --all-targets -- -D warnings -> ok +``` + +Replay final observé : + +```text +replayed=298, trades=290, liquidity=16, lifecycle=4, candle_upserts=1160, +instructionObservations=5317, catalog=40 tokens / 59 pools / 59 pairs +``` + +Clôture swap spécifique : + +```text +swap_base_in stable_swap_vault_balance_delta success 171 decoded / 171 trades +swap_base_in stable_swap_instruction_bounds_only failed 27 decoded / 0 trades +swap_base_out stable_swap_vault_balance_delta success 4 decoded / 4 trades +swap_base_out stable_swap_instruction_bounds_only failed 2 decoded / 0 trades +``` + +SQL de validation : `validation_sql/SQL_VALIDATION_RAYDIUM_STABLE_SWAP_0_7_52.sql`. + +Rapport : `docs/reports/RAYDIUM_STABLE_SWAP_EVENT_COVERAGE_REPORT.md`. + diff --git a/docs/DB_EVENT_MODEL_REVIEW.md b/docs/DB_EVENT_MODEL_REVIEW.md index b4292c9..22b453b 100644 --- a/docs/DB_EVENT_MODEL_REVIEW.md +++ b/docs/DB_EVENT_MODEL_REVIEW.md @@ -369,3 +369,16 @@ Règles validées : - les side effects SPL Token / Token-2022 restent transversaux. Contrôle final AMM v4 : le SQL `materialized_target_count > 1` doit rester vide. + +## 0.7.52 — Raydium Stable Swap DB model decision + +No schema migration is introduced for `raydium_stable_swap` at tranche opening. + +Stable Swap maps to existing DB targets: + +- `initialize` / `pre_initialize` → `k_sol_pool_lifecycle_events` when pool context is sufficient; +- `deposit` / `withdraw` → `k_sol_liquidity_events` when pool/pair context is sufficient; +- `swap_base_in` / `swap_base_out` → `k_sol_trade_events` and candles only when mints and amounts are reliable; +- `swap_event` → `k_sol_dex_decoded_events` only until a corpus-backed materialization decision exists. + +Side effects from SPL Token, Token-2022, Serum/OpenBook-style accounts or router transport remain transverse evidence and are not promoted as direct `raydium_stable_swap.*` business events without an explicit later DB decision. diff --git a/docs/DEX_DECODER_MATRIX.md b/docs/DEX_DECODER_MATRIX.md index 7d9d6c9..83f3ea1 100644 --- a/docs/DEX_DECODER_MATRIX.md +++ b/docs/DEX_DECODER_MATRIX.md @@ -31,8 +31,8 @@ Cette matrice complète `kb_lib/src/dex_support_matrix.rs`. Elle documente **ce | 1 | `raydium_cpmm` | `supported / 0.7.50-pre-r2 closure recheck` | Couverture CPMM clôturée : swaps, lifecycle, fees, admin/config, deposit/withdraw, `lp_change_event`, `swap_event` decoded-only, `cpi_event` transport Carbon et `anchor_idl_instruction` Solscan/manual pour `40f4bc78a7e9690a`. | Ne pas promouvoir `anchor_idl_instruction` : c'est de la gestion Anchor IDL, pas un événement AMM métier. | | 2 | `raydium_clmm` | `supported / 0.7.50-pre-r2 closure recheck` | Couverture CLMM complétée : `cpi_event`, `update_dynamic_fee_config`, Program-data events locaux, `create_support_mint_associated` vers `k_sol_token_account_events`, familles sans `unknown`, router/swap Program-data en decoded-only. | Rejouer la base CLMM et confirmer que les seuls résidus sont `decoded_events_only`, transactions failed ou absence prouvée de contexte pool/pair. | | 3 | `raydium_launchpad` | `bootstrap / 0.7.50` | Surface canonique normalisée, 1 entrée programme + 26 discriminants Carbon/IDL listés, fallback audit/mapped decoder, SQL dédié. | Créer DB neuve, backfill par discriminant, replay forcé, promouvoir seulement après corpus local. | -| 4 | `raydium_amm_v4` | `supported / 0.7.51 closed` | Decoder maximal AMM v4 `00..11`, swaps spécialisés, lifecycle/liquidity/fees/admin/orderbook validés. | Rechecks CPMM/CLMM/Launchpad puis `raydium_stable`. | -| 5 | `raydium_stable_swap` | `planned / 0.7.52` | Entrée conservée. | Reprendre Stable séparément : swaps stables, pool lifecycle, liquidity, fees/admin, montants/prix exploitables. | +| 4 | `raydium_amm_v4` | `supported / 0.7.51 closed` | Decoder maximal AMM v4 `00..11`, swaps spécialisés, lifecycle/liquidity/fees/admin/orderbook validés. | Stable Swap clôturé ensuite en `0.7.52`; surveiller les surfaces restantes. | +| 5 | `raydium_stable_swap` | `supported / 0.7.52 closed` | Decoder legacy 1 octet, surface `00..0d`, swaps matérialisés depuis deltas vault exacts. | Surveiller seulement de nouveaux discriminants ou `swap_event` observé. | | 6 | `raydium_pool_v4` | `to_verify / 0.7.53 conditional audit` | IDL annexe mentionnée par fnzero, non présente dans l'archive locale, pas de program id/rôle confirmé ici. | Ne pas promouvoir tant que program id distinct, rôle exact et corpus exploitable ne sont pas confirmés. | | 7 | `pump_swap` | `supported / 0.7.54 planned` | `buy`/`sell` décodés et matérialisés ; trade/candle OK. | Ajouter tous les events Carbon/Solana Streamer : cashback, fee, volume accumulator, admin/config ; conserver les non-trades hors candles. | | 8 | `pump_fun` | `partial / 0.7.55 launch_surface` | Création/token launch partiellement décodée ; intégrée au pipeline de listings. | Traiter tous les events Pump.fun disponibles : buy/sell/migrate/create/update ; séparer bonding/launch de DEX effectif ; valider migration vers PumpSwap. | @@ -99,7 +99,7 @@ Un event peut devenir `materialized` uniquement si : | `raydium_launchpad` | `launch_surface` | `launch` | `known` | non | oui | non | `bootstrap` | decoded_events_only_until_local_corpus | | `raydium_liquidity_locking` | `to_verify` | `liquidity_locking` | `to_verify` | non | non | non | `to_verify` | upstream_git_program_id_requires_local_corpus_verification | | `raydium_router` | `aggregator_router` | `router` | `known` | non | non | non | `partial` | router_not_materialized_as_direct_trade_surface | -| `raydium_stable_swap` | `dex_effective` | `AMM` | `known` | non | non | non | `planned` | deprecated_program_not_prioritized | +| `raydium_stable_swap` | `dex_effective` | `AMM` | `known` | oui | oui | oui | `supported` | 0.7.52 closed; swaps via `stable_swap_vault_balance_delta` uniquement | | `meteora_dlmm` | `dex_effective` | `DLMM` | `known` | oui | oui | oui | `supported` | | | `meteora_dlc` | `to_verify` | `unknown` | `to_verify` | non | non | non | `to_verify` | surface_and_program_id_to_verify | | `meteora_damm_v1` | `dex_effective` | `AMM` | `known` | oui | oui | non | `partial` | meteora_damm_v1_swap_without_amount_payload | @@ -266,3 +266,10 @@ La clôture `0.7.50-pre-r2` complète les tranches `0.7.48` et `0.7.49` sans rou La tranche a été validée sur base SQLite dédiée : tous les discriminants `00..11` sont observés localement. Les gaps de matérialisation restants sont expliqués par decoded-only, transaction failed ou absence de catalogue/deltas exploitables. + +## 0.7.52 — Raydium Stable Swap + +| decoder_code | program id | status | layout | notes | +|---|---|---|---|---| +| `raydium_stable_swap` | `5quBtoiQqxF9Jv6KYKctB59NT3gtJD2Y65kdnB1Uev3h` | supported / closed | legacy 1 octet | Surface locale `00..0d` couverte ; swaps `swap_base_in/out` matérialisés uniquement depuis deltas vault exacts ; instruction bounds et failed tx restent decoded-only. | + diff --git a/docs/DEX_EVENT_COVERAGE_MATRIX.md b/docs/DEX_EVENT_COVERAGE_MATRIX.md index ebd10ca..454d751 100644 --- a/docs/DEX_EVENT_COVERAGE_MATRIX.md +++ b/docs/DEX_EVENT_COVERAGE_MATRIX.md @@ -189,3 +189,28 @@ Validation locale finale : tous les discriminants AMM v4 officiels `00..11` sont | `cpi/informational` | `simulate_info` | observed decoded-only | `k_sol_dex_decoded_events_only` | Audit technique uniquement. | | `token side effects` | SPL Token / Token-2022 inner instructions | transversal | decoded-only actuellement | Ne pas promouvoir comme AMM v4 direct. | | `unknown/unmapped audit` | residual `raydium_amm_v4.instruction_audit` | vide | decoded-only si futur inconnu | Tout residual doit être expliqué avant promotion. | + +## 0.7.52 — `raydium_stable_swap` + +Status: **closed on local corpus**. + +| entry | discriminator | family | expected target | local event kind | status | +|---|---:|---|---|---|---| +| `initialize` | `00` | `pool_create` | `k_sol_pool_lifecycle_events` | `raydium_stable_swap.initialize` | observed/materialized when context complete | +| `init_model_data` | `01` | `model_setup` | decoded-only | `raydium_stable_swap.init_model_data` | observed decoded-only / explained | +| `update_model_data` | `02` | `admin_config` | `k_sol_pool_admin_events` | `raydium_stable_swap.update_model_data` | observed/materialized | +| `deposit` | `03` | `liquidity_add` | `k_sol_liquidity_events` | `raydium_stable_swap.deposit` | observed/materialized | +| `withdraw` | `04` | `liquidity_remove` | `k_sol_liquidity_events` | `raydium_stable_swap.withdraw` | observed/materialized | +| `monitor_step` | `05` | `order_place` | `k_sol_orderbook_events` | `raydium_stable_swap.monitor_step` | observed/materialized | +| `set_params` | `06` | `admin_config` | `k_sol_pool_admin_events` | `raydium_stable_swap.set_params` | observed/materialized | +| `withdraw_pnl` | `07` | `fee` | `k_sol_fee_events` | `raydium_stable_swap.withdraw_pnl` | observed/materialized | +| `withdraw_srm` | `08` | `fee` | `k_sol_fee_events` | `raydium_stable_swap.withdraw_srm` | observed/materialized when context complete | +| `swap_base_in` | `09` | `swap` | `k_sol_trade_events` from vault deltas only | `raydium_stable_swap.swap_base_in` | success/vault-delta materialized; failed decoded-only | +| `pre_initialize` | `0a` | `pool_create` | lifecycle or decoded-only | `raydium_stable_swap.pre_initialize` | observed decoded-only / explained in current corpus | +| `swap_base_out` | `0b` | `swap` | `k_sol_trade_events` from vault deltas only | `raydium_stable_swap.swap_base_out` | success/vault-delta materialized; failed decoded-only | +| `simulate_info` | `0c` | `cpi_transport` | decoded-only | `raydium_stable_swap.simulate_info` | observed decoded-only / explained | +| `admin_cancel_orders` | `0d` | `orderbook_admin` | `k_sol_orderbook_events` | `raydium_stable_swap.admin_cancel_orders` | observed/materialized when context complete | +| `swap_event` | `40c6cde8260871e2` | `cpi_transport` | decoded-only | `raydium_stable_swap.swap_event` | upstream mapped; not observed locally | + +Stable Swap swaps are not materialized from instruction min/max bounds. `swap_base_in/out` require `amountSource=stable_swap_vault_balance_delta`; `stable_swap_instruction_bounds_only` remains decoded-only and, in the final corpus, appears only on failed transactions. + diff --git a/docs/SOLSCAN_ACCOUNT_SOURCE_MATRIX.md b/docs/SOLSCAN_ACCOUNT_SOURCE_MATRIX.md index 5b880fb..860abf2 100644 --- a/docs/SOLSCAN_ACCOUNT_SOURCE_MATRIX.md +++ b/docs/SOLSCAN_ACCOUNT_SOURCE_MATRIX.md @@ -10,9 +10,6 @@ This file records the manual Solscan account inventory added during the `0.7.50` | `Aldrin AMM V2` | `CURVGoZn8zycx6FXwwevgBTB2gVvdbGTEpvMJDbgs2t4` | `no_idl` | https://solscan.io/account/CURVGoZn8zycx6FXwwevgBTB2gVvdbGTEpvMJDbgs2t4 | | `ApePro Smart Wallet Program` | `JSW99DKmxNyREQM14SQLDykeBvEUG63TeohrvmofEiw` | `solscan_program_idl` | https://solscan.io/account/JSW99DKmxNyREQM14SQLDykeBvEUG63TeohrvmofEiw#programIdl | | `Aquifer` | `AQU1FRd7papthgdrwPTTq5JacJh8YtwEXaBfKU3bTz45` | `no_idl` | https://solscan.io/account/AQU1FRd7papthgdrwPTTq5JacJh8YtwEXaBfKU3bTz45 | -| `Arbitrage Bot (3s1rA)` | `3s1rAymURnacreXreMy718GfqW6kygQsLNka1xDyW8pC` | `no_idl` | https://solscan.io/account/3s1rAymURnacreXreMy718GfqW6kygQsLNka1xDyW8pC | -| `Arbitrage Bot (6MWVT)` | `6MWVTis8rmmk6Vt9zmAJJbmb3VuLpzoQ1aHH4N6wQEGh` | `no_idl` | https://solscan.io/account/6MWVTis8rmmk6Vt9zmAJJbmb3VuLpzoQ1aHH4N6wQEGh | -| `Arbitrage Bot (9Zzf9)` | `9Zzf9QqTy3TkyXysvJBsXyuRjda5aXCEJ9vXfL2HKSYv` | `no_idl` | https://solscan.io/account/9Zzf9QqTy3TkyXysvJBsXyuRjda5aXCEJ9vXfL2HKSYv | | `Axiom Trade` | `FLASHX8DrLbgeR8FcfNV1F5krxYcYMUdBkrP1EPBtxB9` | `no_idl` | https://solscan.io/account/FLASHX8DrLbgeR8FcfNV1F5krxYcYMUdBkrP1EPBtxB9 | | `Bags: Token Authority` | `BAGSB9TpGrZxQbEsrEznv5jXXdwyP6AXerN8aVRiAmcv` | `solscan_account` | https://solscan.io/account/BAGSB9TpGrZxQbEsrEznv5jXXdwyP6AXerN8aVRiAmcv | | `Believe : Token Authority` | `5qWya6UjwWnGVhdSBL3hyZ7B45jbk6Byt1hwd7ohEGXE` | `no_idl` | https://solscan.io/account/5qWya6UjwWnGVhdSBL3hyZ7B45jbk6Byt1hwd7ohEGXE | diff --git a/docs/VALIDATION_STATUS_0_7_52_FINAL.md b/docs/VALIDATION_STATUS_0_7_52_FINAL.md new file mode 100644 index 0000000..7ce4632 --- /dev/null +++ b/docs/VALIDATION_STATUS_0_7_52_FINAL.md @@ -0,0 +1,67 @@ +# Validation status — 0.7.52 Raydium Stable Swap final + +## Scope + +Decoder: `raydium_stable_swap` + +Program id: + +```text +5quBtoiQqxF9Jv6KYKctB59NT3gtJD2Y65kdnB1Uev3h +``` + +## Local commands + +```text +cargo test -p kb_lib +407 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out + +cargo clippy -p kb_lib --all-targets -- -D warnings +ok +``` + +## Final replay snapshot + +```text +replayed=298 +decode_skipped=0 +ledger_upserts=298 +unsafe_ledger_rows=258 +trades=290 +liquidity=16 +lifecycle=4 +tokenAccount=0 +candle_upserts=1160 +instructionObservations=5317 +resetDeleted=1059 +catalog=40 tokens / 59 pools / 59 pairs +``` + +## Stable Swap swap closure + +```text +raydium_stable_swap.swap_base_in stable_swap_instruction_bounds_only failed decoded=27 trades=0 +raydium_stable_swap.swap_base_in stable_swap_vault_balance_delta success decoded=171 trades=171 +raydium_stable_swap.swap_base_out stable_swap_instruction_bounds_only failed decoded=2 trades=0 +raydium_stable_swap.swap_base_out stable_swap_vault_balance_delta success decoded=4 trades=4 +``` + +No successful Stable Swap swap remains without trade or skip reason. + +## Invariants + +| invariant | status | +|---|---| +| residual local `instruction_audit` | empty | +| residual `upstream_git.instruction_match` for covered entries | empty | +| decoded without coverage | empty | +| non-swap materialized as trade | empty | +| failed tx materialized as trade | empty | +| multi-target materialization | empty | +| unexplained successful non-materialized event | empty | +| successful swap via vault deltas | `trade_count = decoded_count` | +| failed swap instruction bounds only | `trade_count = 0` | + +## Decision + +`0.7.52 raydium_stable_swap` is closed for the current local corpus. diff --git a/docs/reports/RAYDIUM_STABLE_SWAP_EVENT_COVERAGE_REPORT.md b/docs/reports/RAYDIUM_STABLE_SWAP_EVENT_COVERAGE_REPORT.md new file mode 100644 index 0000000..c499ee2 --- /dev/null +++ b/docs/reports/RAYDIUM_STABLE_SWAP_EVENT_COVERAGE_REPORT.md @@ -0,0 +1,162 @@ +# Raydium Stable Swap event coverage report — 0.7.52 final + +## Scope + +`0.7.52` closes the `raydium_stable_swap` tranche after `0.7.51 raydium_amm_v4`. + +Canonical local decoder code: + +```text +raydium_stable_swap +``` + +Canonical program id validated by local corpus: + +```text +5quBtoiQqxF9Jv6KYKctB59NT3gtJD2Y65kdnB1Uev3h +``` + +Stable Swap is handled as a Raydium legacy AMM-style program with a one-byte instruction discriminator layout. Anchor-like 8-byte discriminants remain upstream discovery evidence only and are not business proof. + +## Final implementation status + +Implemented and locally validated: + +- `kb_lib/src/dex/raydium_stable_swap.rs`. +- `RaydiumStableSwapDecoder` re-exported through `kb_lib/src/dex.rs` and `kb_lib/src/lib.rs`. +- Stable Swap route in `DexDecodeService` before generic Raydium instruction-audit preservation. +- One-byte Stable Swap instruction observation support. +- Coverage entries for all locally observed Stable Swap discriminants `00..0d`. +- Materialization into lifecycle/liquidity/fee/admin/orderbook/trade tables when the local corpus proves a safe target. +- Swap materialization from exact vault balance deltas only. +- Validation SQL in `validation_sql/SQL_VALIDATION_RAYDIUM_STABLE_SWAP_0_7_52.sql`. + +## Instruction surface + +| entry | discriminator | family | final target | local event kind | final status | +|---|---:|---|---|---|---| +| `initialize` | `00` | `pool_create` | `k_sol_pool_lifecycle_events` | `raydium_stable_swap.initialize` | observed/materialized when context complete | +| `init_model_data` | `01` | `model_setup` | decoded-only | `raydium_stable_swap.init_model_data` | observed decoded-only / explained | +| `update_model_data` | `02` | `admin_config` | `k_sol_pool_admin_events` | `raydium_stable_swap.update_model_data` | observed/materialized | +| `deposit` | `03` | `liquidity_add` | `k_sol_liquidity_events` | `raydium_stable_swap.deposit` | observed/materialized | +| `withdraw` | `04` | `liquidity_remove` | `k_sol_liquidity_events` | `raydium_stable_swap.withdraw` | observed/materialized | +| `monitor_step` | `05` | `order_place` | `k_sol_orderbook_events` | `raydium_stable_swap.monitor_step` | observed/materialized | +| `set_params` | `06` | `admin_config` | `k_sol_pool_admin_events` | `raydium_stable_swap.set_params` | observed/materialized | +| `withdraw_pnl` | `07` | `fee` | `k_sol_fee_events` | `raydium_stable_swap.withdraw_pnl` | observed/materialized | +| `withdraw_srm` | `08` | `fee` | `k_sol_fee_events` | `raydium_stable_swap.withdraw_srm` | observed/materialized when context complete | +| `swap_base_in` | `09` | `swap` | `k_sol_trade_events` only from vault deltas | `raydium_stable_swap.swap_base_in` | observed, materialized for successful swaps with exact deltas | +| `pre_initialize` | `0a` | `pool_create` | decoded-only or lifecycle when complete | `raydium_stable_swap.pre_initialize` | observed decoded-only / explained in current corpus | +| `swap_base_out` | `0b` | `swap` | `k_sol_trade_events` only from vault deltas | `raydium_stable_swap.swap_base_out` | observed, materialized for successful swaps with exact deltas | +| `simulate_info` | `0c` | `cpi_transport` | decoded-only | `raydium_stable_swap.simulate_info` | observed decoded-only / explained | +| `admin_cancel_orders` | `0d` | `orderbook_admin` | `k_sol_orderbook_events` | `raydium_stable_swap.admin_cancel_orders` | observed/materialized when context complete | +| `swap_event` | `40c6cde8260871e2` | `cpi_transport` | decoded-only | `raydium_stable_swap.swap_event` | upstream mapped, not observed in local corpus | + +## Swap amount policy + +Stable Swap instruction arguments are retained as instruction bounds, but they are not sufficient for trade/candle materialization: + +```text +swap_base_in: + amountInRaw = exact input argument + minimumAmountOutRaw = slippage lower bound, not exact output + +swap_base_out: + amountOutRaw = requested output argument + maxAmountInRaw = slippage upper bound, not exact input +``` + +Therefore, `swap_base_in` and `swap_base_out` materialize as trades/candles only when exact base/quote amounts are inferred from vault balance deltas: + +```text +amountSource = stable_swap_vault_balance_delta +``` + +Instruction-bound-only swaps remain decoded-only: + +```text +amountSource = stable_swap_instruction_bounds_only +tradeCandidate = false +candleCandidate = false +``` + +For failed transactions the skip reasons are: + +```text +skipTradeReason = failed_transaction +skipCandleReason = failed_transaction +``` + +For successful transactions where exact vault deltas cannot be proven, the expected skip reason is: + +```text +stable_swap_exact_amounts_unresolved +``` + +The final local corpus has no successful unresolved Stable Swap swap. + +## Final local validation snapshot + +Latest confirmed local commands: + +```text +cargo test -p kb_lib +407 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out + +cargo clippy -p kb_lib --all-targets -- -D warnings +ok +``` + +Latest replay snapshot: + +```text +replayed=298 +decode_skipped=0 +ledger_upserts=298 +unsafe_ledger_rows=258 +trades=290 +liquidity=16 +lifecycle=4 +token_account=0 +candle_upserts=1160 +instructionObservations=5317 +resetDeleted=1059 +catalog=40 tokens / 59 pools / 59 pairs +``` + +Stable Swap swap closure: + +| event kind | amount source | tx status | decoded | trades | +|---|---|---|---:|---:| +| `raydium_stable_swap.swap_base_in` | `stable_swap_instruction_bounds_only` | failed | 27 | 0 | +| `raydium_stable_swap.swap_base_in` | `stable_swap_vault_balance_delta` | success | 171 | 171 | +| `raydium_stable_swap.swap_base_out` | `stable_swap_instruction_bounds_only` | failed | 2 | 0 | +| `raydium_stable_swap.swap_base_out` | `stable_swap_vault_balance_delta` | success | 4 | 4 | + +UI smoke evidence after the vault-delta correction: + +```text +pair 27, timeframe 60s -> 70 candles +pair 30, timeframe 60s -> 44 candles +``` + +## Final invariant status + +Validated as clean on the local corpus: + +- residual `raydium_stable_swap.instruction_audit`: empty; +- residual `upstream_git.instruction_match` for covered local entries: empty; +- decoded-without-coverage: empty; +- non-swap materialized as trade: empty; +- failed transaction materialized as business trade: empty; +- multi-target materialization: empty; +- successful non-materialized swaps without skip reason: empty; +- Stable Swap successful swaps with `stable_swap_vault_balance_delta`: `trade_count = decoded_count`; +- Stable Swap instruction-bound-only swaps: failed only, `trade_count = 0`. + +## Closure decision + +`0.7.52 raydium_stable_swap` is closed for the currently observed local corpus. + +The decoder has detected all locally observed Stable Swap instruction discriminants, materialized every event that can safely be materialized, and preserved non-materializable/failed events as decoded-only with explicit reasons. + +Future work is not a blocker for `0.7.52` and should be handled as a later tranche if a new local corpus reveals additional discriminants or a direct, reliable `swap_event` path. diff --git a/kb_demo_app/package.json b/kb_demo_app/package.json index 35516d1..282fd18 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.51", + "version": "0.7.52", "type": "module", "scripts": { "dev": "vite", diff --git a/kb_demo_app/tauri.conf.json b/kb_demo_app/tauri.conf.json index 05d0d5a..aae859b 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.51", + "version": "0.7.52", "identifier": "com.sasedev.kb-demo-app", "build": { "beforeDevCommand": "npm run dev", diff --git a/kb_lib/src/dex.rs b/kb_lib/src/dex.rs index 5cc9f67..7ed5735 100644 --- a/kb_lib/src/dex.rs +++ b/kb_lib/src/dex.rs @@ -17,6 +17,7 @@ mod raydium_amm_v4; mod raydium_clmm; mod raydium_cpmm; pub(crate) mod raydium_launchpad; +mod raydium_stable_swap; pub use dexlab::DexlabCreatePoolDecoded; pub use dexlab::DexlabDecodedEvent; @@ -90,3 +91,9 @@ pub use raydium_cpmm::RaydiumCpmmSwapMode; pub use raydium_cpmm::classify_raydium_cpmm_instruction_data; pub use raydium_cpmm::decode_raydium_cpmm_instruction; pub use raydium_cpmm::decode_raydium_cpmm_program_data_event; +pub use raydium_stable_swap::RaydiumStableSwapDecodedEvent; +pub use raydium_stable_swap::RaydiumStableSwapDecoder; +pub use raydium_stable_swap::RaydiumStableSwapInstructionDecoded; +pub use raydium_stable_swap::RaydiumStableSwapSwapEventDecoded; +pub use raydium_stable_swap::classify_raydium_stable_swap_instruction_data; +pub use raydium_stable_swap::decode_raydium_stable_swap_program_data_event; diff --git a/kb_lib/src/dex/raydium_stable_swap.rs b/kb_lib/src/dex/raydium_stable_swap.rs new file mode 100644 index 0000000..9d2d04f --- /dev/null +++ b/kb_lib/src/dex/raydium_stable_swap.rs @@ -0,0 +1,1524 @@ +// file: kb_lib/src/dex/raydium_stable_swap.rs + +//! Raydium Stable Swap decoder. +//! +//! Raydium Stable Swap uses a legacy one-byte instruction discriminator layout. +//! The decoder below follows the Pinax/Substreams layout and keeps Anchor-like +//! eight-byte discriminants as upstream discovery evidence only. + +/// Raydium Stable Swap `initialize` one-byte discriminator. +const RAYDIUM_STABLE_SWAP_INITIALIZE_DISCRIMINATOR_HEX: &str = "00"; +/// Raydium Stable Swap `init_model_data` one-byte discriminator. +const RAYDIUM_STABLE_SWAP_INIT_MODEL_DATA_DISCRIMINATOR_HEX: &str = "01"; +/// Raydium Stable Swap `update_model_data` one-byte discriminator. +const RAYDIUM_STABLE_SWAP_UPDATE_MODEL_DATA_DISCRIMINATOR_HEX: &str = "02"; +/// Raydium Stable Swap `deposit` one-byte discriminator. +const RAYDIUM_STABLE_SWAP_DEPOSIT_DISCRIMINATOR_HEX: &str = "03"; +/// Raydium Stable Swap `withdraw` one-byte discriminator. +const RAYDIUM_STABLE_SWAP_WITHDRAW_DISCRIMINATOR_HEX: &str = "04"; +/// Raydium Stable Swap `monitor_step` one-byte discriminator. +const RAYDIUM_STABLE_SWAP_MONITOR_STEP_DISCRIMINATOR_HEX: &str = "05"; +/// Raydium Stable Swap `set_params` one-byte discriminator. +const RAYDIUM_STABLE_SWAP_SET_PARAMS_DISCRIMINATOR_HEX: &str = "06"; +/// Raydium Stable Swap `withdraw_pnl` one-byte discriminator. +const RAYDIUM_STABLE_SWAP_WITHDRAW_PNL_DISCRIMINATOR_HEX: &str = "07"; +/// Raydium Stable Swap `withdraw_srm` one-byte discriminator. +const RAYDIUM_STABLE_SWAP_WITHDRAW_SRM_DISCRIMINATOR_HEX: &str = "08"; +/// Raydium Stable Swap `swap_base_in` one-byte discriminator. +const RAYDIUM_STABLE_SWAP_SWAP_BASE_IN_DISCRIMINATOR_HEX: &str = "09"; +/// Raydium Stable Swap `pre_initialize` one-byte discriminator. +const RAYDIUM_STABLE_SWAP_PRE_INITIALIZE_DISCRIMINATOR_HEX: &str = "0a"; +/// Raydium Stable Swap `swap_base_out` one-byte discriminator. +const RAYDIUM_STABLE_SWAP_SWAP_BASE_OUT_DISCRIMINATOR_HEX: &str = "0b"; +/// Raydium Stable Swap `simulate_info` one-byte discriminator. +const RAYDIUM_STABLE_SWAP_SIMULATE_INFO_DISCRIMINATOR_HEX: &str = "0c"; +/// Raydium Stable Swap `admin_cancel_orders` one-byte discriminator observed locally. +const RAYDIUM_STABLE_SWAP_ADMIN_CANCEL_ORDERS_DISCRIMINATOR_HEX: &str = "0d"; +/// Raydium Stable Swap `SwapEvent` discriminator reused by Raydium CPMM logs. +const RAYDIUM_STABLE_SWAP_SWAP_EVENT_DISCRIMINATOR_HEX: &str = "40c6cde8260871e2"; + +/// Raydium Stable Swap decoded event. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub enum RaydiumStableSwapDecodedEvent { + /// Known Stable Swap instruction decoded from the local one-byte layout. + Instruction(std::boxed::Box), + /// Program-data swap event retained as decoded-only audit evidence. + SwapEvent(std::boxed::Box), +} + +/// Decoded Raydium Stable Swap instruction. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RaydiumStableSwapInstructionDecoded { + /// 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, + /// Local decoded event kind. + pub event_kind: std::string::String, + /// Upstream/local instruction name. + pub instruction_name: std::string::String, + /// One-byte instruction discriminator in hexadecimal form. + pub discriminator_hex: std::string::String, + /// Optional pool/AMM account. + pub pool_account: std::option::Option, + /// Optional market account. + pub market_account: std::option::Option, + /// Optional LP mint account. + pub lp_mint: std::option::Option, + /// Optional normalized base mint. + pub token_a_mint: std::option::Option, + /// Optional normalized quote mint. + pub token_b_mint: std::option::Option, + /// Optional normalized base vault. + pub base_vault: std::option::Option, + /// Optional normalized quote vault. + pub quote_vault: std::option::Option, + /// Decoded and enrichment-ready payload. + pub payload_json: serde_json::Value, +} + +/// Decoded Raydium Stable Swap program-data swap event. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RaydiumStableSwapSwapEventDecoded { + /// Event discriminator in hexadecimal form. + pub event_discriminator_hex: std::string::String, + /// Raydium stable-swap event-local dex flag. + pub dex: u8, + /// Raw input amount emitted by the event. + pub amount_in_raw: std::string::String, + /// Raw output amount emitted by the event. + pub amount_out_raw: std::string::String, + /// Whether the event can materialize as a trade. + pub trade_candidate: bool, + /// Whether the event can materialize as a candle. + pub candle_candidate: bool, + /// Reason why trade materialization is skipped. + pub skip_trade_reason: std::string::String, + /// Reason why candle materialization is skipped. + pub skip_candle_reason: std::string::String, +} + +/// Raydium Stable Swap decoder. +#[derive(Debug, Clone, Default)] +pub struct RaydiumStableSwapDecoder; + +impl RaydiumStableSwapDecoder { + /// Creates a new Raydium Stable Swap decoder. + pub fn new() -> Self { + return Self; + } + + /// Decodes all Raydium Stable Swap instructions in one projected transaction. + pub fn decode_transaction( + &self, + transaction: &crate::ChainTransactionDto, + instructions: &[crate::ChainInstructionDto], + ) -> Result, crate::Error> { + let transaction_id = match transaction.id { + Some(transaction_id) => transaction_id, + None => { + return Err(crate::Error::InvalidState(format!( + "chain transaction '{}' has no internal id", + transaction.signature + ))); + }, + }; + let transaction_json_value = parse_json_value(transaction.transaction_json.as_str()); + let meta_json_value = parse_optional_json_value(transaction.meta_json.as_deref()); + let account_keys = extract_transaction_account_keys( + transaction_json_value.as_ref(), + meta_json_value.as_ref(), + ); + let token_mints_by_account = extract_token_mints_by_account( + transaction_json_value.as_ref(), + meta_json_value.as_ref(), + account_keys.as_slice(), + ); + let token_balance_records = extract_token_balance_records( + transaction_json_value.as_ref(), + meta_json_value.as_ref(), + account_keys.as_slice(), + ); + let mut decoded_events = std::vec::Vec::new(); + for instruction in instructions { + let program_id = match instruction.program_id.as_ref() { + Some(program_id) => program_id, + None => continue, + }; + if program_id.as_str() != crate::RAYDIUM_STABLE_SWAP_AMM_PROGRAM_ID { + continue; + } + let instruction_id = match instruction.id { + Some(instruction_id) => instruction_id, + None => continue, + }; + let decoded = decode_raydium_stable_swap_instruction_with_context( + transaction_id, + instruction_id, + transaction.signature.as_str(), + program_id.as_str(), + instruction.accounts_json.as_str(), + instruction.data_json.as_deref(), + &token_mints_by_account, + token_balance_records.as_slice(), + ); + for event in decoded { + decoded_events.push(event); + } + } + return Ok(decoded_events); + } +} + +impl RaydiumStableSwapDecodedEvent { + /// Returns the local decoded event kind. + pub fn event_kind(&self) -> &str { + match self { + Self::Instruction(event) => return event.event_kind.as_str(), + Self::SwapEvent(_) => return "raydium_stable_swap.swap_event", + } + } + + /// Returns the optional pool account. + pub fn pool_account(&self) -> std::option::Option<&str> { + match self { + Self::Instruction(event) => return event.pool_account.as_deref(), + Self::SwapEvent(_) => return None, + } + } + + /// Returns the optional market account. + pub fn market_account(&self) -> std::option::Option<&str> { + match self { + Self::Instruction(event) => return event.market_account.as_deref(), + Self::SwapEvent(_) => return None, + } + } + + /// Returns the optional normalized base mint. + pub fn base_mint(&self) -> std::option::Option<&str> { + match self { + Self::Instruction(event) => return event.token_a_mint.as_deref(), + Self::SwapEvent(_) => return None, + } + } + + /// Returns the optional normalized quote mint. + pub fn quote_mint(&self) -> std::option::Option<&str> { + match self { + Self::Instruction(event) => return event.token_b_mint.as_deref(), + Self::SwapEvent(_) => return None, + } + } + + /// Returns the optional LP mint. + pub fn lp_mint(&self) -> std::option::Option<&str> { + match self { + Self::Instruction(event) => return event.lp_mint.as_deref(), + Self::SwapEvent(_) => return None, + } + } + + /// Serializes the decoded payload as JSON text. + pub fn to_payload_json(&self) -> std::option::Option { + match self { + Self::Instruction(event) => { + let result = serde_json::to_string(&event.payload_json); + match result { + Ok(payload) => return Some(payload), + Err(_) => return None, + } + }, + Self::SwapEvent(event) => { + let result = serde_json::to_string(event); + match result { + Ok(payload) => return Some(payload), + Err(_) => return None, + } + }, + } + } + + /// Returns the parent instruction id when available. + pub fn instruction_id(&self) -> std::option::Option { + match self { + Self::Instruction(event) => return Some(event.instruction_id), + Self::SwapEvent(_) => return None, + } + } +} + +/// Classifies one Raydium Stable Swap instruction data payload. +pub fn classify_raydium_stable_swap_instruction_data( + data_json: &str, +) -> std::option::Option<&'static str> { + let data = match decode_data_json_base58(data_json) { + Some(data) => data, + None => return None, + }; + let discriminator = match data.first() { + Some(discriminator) => *discriminator, + None => return None, + }; + return stable_instruction_name(discriminator); +} + +/// Decodes Raydium Stable Swap `Program data:` event payloads. +pub fn decode_raydium_stable_swap_program_data_event( + data_base64: &str, +) -> std::option::Option { + use base64::Engine as _; + let decoded_result = base64::engine::general_purpose::STANDARD.decode(data_base64.as_bytes()); + let data = match decoded_result { + Ok(data) => data, + Err(_) => return None, + }; + if data.len() < 25 { + return None; + } + let discriminator_hex = bytes_to_hex(&data[0..8]); + if discriminator_hex != RAYDIUM_STABLE_SWAP_SWAP_EVENT_DISCRIMINATOR_HEX { + return None; + } + let dex = data[8]; + let amount_in = match read_u64_le(data.as_slice(), 9) { + Some(amount_in) => amount_in, + None => return None, + }; + let amount_out = match read_u64_le(data.as_slice(), 17) { + Some(amount_out) => amount_out, + None => return None, + }; + return Some(crate::RaydiumStableSwapDecodedEvent::SwapEvent(std::boxed::Box::new( + RaydiumStableSwapSwapEventDecoded { + event_discriminator_hex: discriminator_hex, + dex, + amount_in_raw: amount_in.to_string(), + amount_out_raw: amount_out.to_string(), + trade_candidate: false, + candle_candidate: false, + skip_trade_reason: "raydium_stable_swap_swap_event_decoded_only".to_string(), + skip_candle_reason: "raydium_stable_swap_swap_event_decoded_only".to_string(), + }, + ))); +} + +#[allow(clippy::too_many_arguments)] +fn decode_raydium_stable_swap_instruction_with_context( + transaction_id: i64, + instruction_id: i64, + signature: &str, + program_id: &str, + accounts_json: &str, + data_json: std::option::Option<&str>, + token_mints_by_account: &std::collections::BTreeMap, + token_balance_records: &[TokenBalanceRecord], +) -> std::vec::Vec { + let data_json = match data_json { + Some(data_json) => data_json, + None => return std::vec::Vec::new(), + }; + let accounts = match parse_accounts_json(accounts_json) { + Some(accounts) => accounts, + None => return std::vec::Vec::new(), + }; + let data = match decode_data_json_base58(data_json) { + Some(data) => data, + None => return std::vec::Vec::new(), + }; + let discriminator = match data.first() { + Some(discriminator) => *discriminator, + None => return std::vec::Vec::new(), + }; + let instruction_name = match stable_instruction_name(discriminator) { + Some(instruction_name) => instruction_name, + None => return std::vec::Vec::new(), + }; + let decoded = build_stable_instruction_event( + transaction_id, + instruction_id, + signature, + program_id, + instruction_name, + discriminator, + accounts.as_slice(), + data.as_slice(), + token_mints_by_account, + token_balance_records, + ); + let decoded = match decoded { + Some(decoded) => decoded, + None => return std::vec::Vec::new(), + }; + return vec![crate::RaydiumStableSwapDecodedEvent::Instruction(std::boxed::Box::new(decoded))]; +} + +#[allow(clippy::too_many_arguments)] +fn build_stable_instruction_event( + transaction_id: i64, + instruction_id: i64, + signature: &str, + program_id: &str, + instruction_name: &str, + discriminator: u8, + accounts: &[std::string::String], + data: &[u8], + token_mints_by_account: &std::collections::BTreeMap, + token_balance_records: &[TokenBalanceRecord], +) -> std::option::Option { + let event_kind = format!("raydium_stable_swap.{}", instruction_name); + let discriminator_hex = match stable_instruction_discriminator_hex(discriminator) { + Some(discriminator_hex) => discriminator_hex.to_string(), + None => format!("{:02x}", discriminator), + }; + let mut payload = serde_json::Map::new(); + payload.insert( + "instructionName".to_string(), + serde_json::Value::String(instruction_name.to_string()), + ); + payload.insert("eventKind".to_string(), serde_json::Value::String(event_kind.clone())); + payload.insert( + "discriminatorHex".to_string(), + serde_json::Value::String(discriminator_hex.clone()), + ); + payload.insert("programId".to_string(), serde_json::Value::String(program_id.to_string())); + payload.insert( + "stableLayout".to_string(), + serde_json::Value::String("pinax_one_byte".to_string()), + ); + + let pool_account: std::option::Option; + let market_account: std::option::Option; + let lp_mint: std::option::Option; + let token_a_mint: std::option::Option; + let token_b_mint: std::option::Option; + let base_vault: std::option::Option; + let quote_vault: std::option::Option; + + match instruction_name { + "initialize" => { + pool_account = account_at(accounts, 3); + lp_mint = account_at(accounts, 6); + let coin_mint = account_at(accounts, 7); + let pc_mint = account_at(accounts, 8); + let coin_vault = account_at(accounts, 9); + let pc_vault = account_at(accounts, 10); + market_account = account_at(accounts, 14); + let normalized = normalize_pair_with_vaults( + coin_mint.clone(), + pc_mint.clone(), + coin_vault.clone(), + pc_vault.clone(), + ); + token_a_mint = normalized.base_mint; + token_b_mint = normalized.quote_mint; + base_vault = normalized.base_vault; + quote_vault = normalized.quote_vault; + insert_optional_string(&mut payload, "poolAccount", pool_account.clone()); + insert_optional_string(&mut payload, "lpMint", lp_mint.clone()); + insert_optional_string(&mut payload, "coinMint", coin_mint); + insert_optional_string(&mut payload, "pcMint", pc_mint); + insert_optional_string(&mut payload, "baseVault", base_vault.clone()); + insert_optional_string(&mut payload, "quoteVault", quote_vault.clone()); + insert_optional_string(&mut payload, "marketAccount", market_account.clone()); + insert_optional_u8(&mut payload, "nonce", data.get(1).copied()); + insert_optional_u64(&mut payload, "openTimeRaw", read_u64_le(data, 2)); + }, + "pre_initialize" => { + pool_account = None; + lp_mint = account_at(accounts, 5); + let coin_mint = account_at(accounts, 6); + let pc_mint = account_at(accounts, 7); + let coin_vault = account_at(accounts, 8); + let pc_vault = account_at(accounts, 9); + market_account = account_at(accounts, 10); + let normalized = normalize_pair_with_vaults( + coin_mint.clone(), + pc_mint.clone(), + coin_vault.clone(), + pc_vault.clone(), + ); + token_a_mint = normalized.base_mint; + token_b_mint = normalized.quote_mint; + base_vault = normalized.base_vault; + quote_vault = normalized.quote_vault; + insert_optional_string(&mut payload, "lpMint", lp_mint.clone()); + insert_optional_string(&mut payload, "coinMint", coin_mint); + insert_optional_string(&mut payload, "pcMint", pc_mint); + insert_optional_string(&mut payload, "baseVault", base_vault.clone()); + insert_optional_string(&mut payload, "quoteVault", quote_vault.clone()); + insert_optional_string(&mut payload, "marketAccount", market_account.clone()); + insert_optional_u8(&mut payload, "nonce", data.get(1).copied()); + payload.insert( + "skipLifecycleReason".to_string(), + serde_json::Value::String("stable_pre_initialize_partial_pool_context".to_string()), + ); + }, + "deposit" => { + pool_account = account_at(accounts, 1); + lp_mint = account_at(accounts, 5); + let coin_vault = account_at(accounts, 6); + let pc_vault = account_at(accounts, 7); + market_account = account_at(accounts, 9); + let coin_mint = mint_for_account(coin_vault.as_deref(), token_mints_by_account); + let pc_mint = mint_for_account(pc_vault.as_deref(), token_mints_by_account); + let normalized = normalize_pair_with_vaults( + coin_mint.clone(), + pc_mint.clone(), + coin_vault.clone(), + pc_vault.clone(), + ); + token_a_mint = normalized.base_mint; + token_b_mint = normalized.quote_mint; + base_vault = normalized.base_vault; + quote_vault = normalized.quote_vault; + insert_optional_string(&mut payload, "poolAccount", pool_account.clone()); + insert_optional_string(&mut payload, "lpMint", lp_mint.clone()); + insert_optional_string(&mut payload, "baseVault", base_vault.clone()); + insert_optional_string(&mut payload, "quoteVault", quote_vault.clone()); + insert_optional_string(&mut payload, "marketAccount", market_account.clone()); + insert_optional_u64_string(&mut payload, "maxCoinAmountRaw", read_u64_le(data, 1)); + insert_optional_u64_string(&mut payload, "maxPcAmountRaw", read_u64_le(data, 9)); + insert_optional_u64_string(&mut payload, "baseSideRaw", read_u64_le(data, 17)); + }, + "withdraw" => { + pool_account = account_at(accounts, 1); + lp_mint = account_at(accounts, 5); + let coin_vault = account_at(accounts, 6); + let pc_vault = account_at(accounts, 7); + market_account = account_at(accounts, 10); + let coin_mint = mint_for_account(coin_vault.as_deref(), token_mints_by_account); + let pc_mint = mint_for_account(pc_vault.as_deref(), token_mints_by_account); + let normalized = normalize_pair_with_vaults( + coin_mint.clone(), + pc_mint.clone(), + coin_vault.clone(), + pc_vault.clone(), + ); + token_a_mint = normalized.base_mint; + token_b_mint = normalized.quote_mint; + base_vault = normalized.base_vault; + quote_vault = normalized.quote_vault; + insert_optional_string(&mut payload, "poolAccount", pool_account.clone()); + insert_optional_string(&mut payload, "lpMint", lp_mint.clone()); + insert_optional_string(&mut payload, "baseVault", base_vault.clone()); + insert_optional_string(&mut payload, "quoteVault", quote_vault.clone()); + insert_optional_string(&mut payload, "marketAccount", market_account.clone()); + insert_optional_u64_string(&mut payload, "lpAmountRaw", read_u64_le(data, 1)); + }, + "init_model_data" | "update_model_data" => { + pool_account = account_at(accounts, 0); + market_account = None; + lp_mint = None; + token_a_mint = None; + token_b_mint = None; + base_vault = None; + quote_vault = None; + insert_optional_string(&mut payload, "modelDataAccount", pool_account.clone()); + payload.insert("tradeCandidate".to_string(), serde_json::Value::Bool(false)); + payload.insert("candleCandidate".to_string(), serde_json::Value::Bool(false)); + payload.insert( + "skipTradeReason".to_string(), + serde_json::Value::String("stable_model_data_instruction_decoded_only".to_string()), + ); + payload.insert( + "skipLiquidityReason".to_string(), + serde_json::Value::String("stable_model_data_instruction_decoded_only".to_string()), + ); + payload.insert( + "skipLifecycleReason".to_string(), + serde_json::Value::String("stable_model_data_instruction_decoded_only".to_string()), + ); + }, + "monitor_step" | "admin_cancel_orders" => { + pool_account = account_at(accounts, 1); + market_account = account_at(accounts, 5); + lp_mint = None; + token_a_mint = None; + token_b_mint = None; + base_vault = None; + quote_vault = None; + insert_optional_string(&mut payload, "poolAccount", pool_account.clone()); + insert_optional_string(&mut payload, "marketAccount", market_account.clone()); + payload.insert("tradeCandidate".to_string(), serde_json::Value::Bool(false)); + payload.insert("candleCandidate".to_string(), serde_json::Value::Bool(false)); + payload.insert( + "skipTradeReason".to_string(), + serde_json::Value::String("stable_orderbook_instruction_decoded_only".to_string()), + ); + payload.insert( + "skipLiquidityReason".to_string(), + serde_json::Value::String("stable_orderbook_instruction_decoded_only".to_string()), + ); + payload.insert( + "skipLifecycleReason".to_string(), + serde_json::Value::String("stable_orderbook_instruction_decoded_only".to_string()), + ); + }, + "set_params" => { + pool_account = account_at(accounts, 1); + market_account = None; + lp_mint = None; + token_a_mint = None; + token_b_mint = None; + base_vault = None; + quote_vault = None; + insert_optional_string(&mut payload, "poolAccount", pool_account.clone()); + payload.insert("tradeCandidate".to_string(), serde_json::Value::Bool(false)); + payload.insert("candleCandidate".to_string(), serde_json::Value::Bool(false)); + payload.insert( + "skipTradeReason".to_string(), + serde_json::Value::String("stable_admin_instruction_decoded_only".to_string()), + ); + payload.insert( + "skipLiquidityReason".to_string(), + serde_json::Value::String("stable_admin_instruction_decoded_only".to_string()), + ); + payload.insert( + "skipLifecycleReason".to_string(), + serde_json::Value::String("stable_admin_instruction_decoded_only".to_string()), + ); + }, + "withdraw_pnl" | "withdraw_srm" => { + pool_account = account_at(accounts, 1); + market_account = account_at(accounts, 4); + lp_mint = None; + token_a_mint = None; + token_b_mint = None; + base_vault = None; + quote_vault = None; + insert_optional_string(&mut payload, "poolAccount", pool_account.clone()); + insert_optional_string(&mut payload, "marketAccount", market_account.clone()); + payload.insert("tradeCandidate".to_string(), serde_json::Value::Bool(false)); + payload.insert("candleCandidate".to_string(), serde_json::Value::Bool(false)); + payload.insert( + "skipTradeReason".to_string(), + serde_json::Value::String("stable_fee_instruction_decoded_only".to_string()), + ); + payload.insert( + "skipLiquidityReason".to_string(), + serde_json::Value::String("stable_fee_instruction_decoded_only".to_string()), + ); + payload.insert( + "skipLifecycleReason".to_string(), + serde_json::Value::String("stable_fee_instruction_decoded_only".to_string()), + ); + }, + "simulate_info" => { + pool_account = account_at(accounts, 1); + market_account = None; + lp_mint = None; + token_a_mint = None; + token_b_mint = None; + base_vault = None; + quote_vault = None; + insert_optional_string(&mut payload, "poolAccount", pool_account.clone()); + payload.insert("tradeCandidate".to_string(), serde_json::Value::Bool(false)); + payload.insert("candleCandidate".to_string(), serde_json::Value::Bool(false)); + payload.insert( + "skipTradeReason".to_string(), + serde_json::Value::String("stable_simulate_info_decoded_only".to_string()), + ); + payload.insert( + "skipLiquidityReason".to_string(), + serde_json::Value::String("stable_simulate_info_decoded_only".to_string()), + ); + payload.insert( + "skipLifecycleReason".to_string(), + serde_json::Value::String("stable_simulate_info_decoded_only".to_string()), + ); + }, + "swap_base_in" | "swap_base_out" => { + pool_account = account_at(accounts, 1); + lp_mint = None; + let coin_vault = account_at(accounts, 4); + let pc_vault = account_at(accounts, 5); + market_account = account_at(accounts, 8); + let input_token_account = account_at(accounts, 15); + let output_token_account = account_at(accounts, 16); + let coin_mint = mint_for_account(coin_vault.as_deref(), token_mints_by_account); + let pc_mint = mint_for_account(pc_vault.as_deref(), token_mints_by_account); + let input_mint = + mint_for_account(input_token_account.as_deref(), token_mints_by_account); + let output_mint = + mint_for_account(output_token_account.as_deref(), token_mints_by_account); + let normalized = normalize_pair_with_vaults( + coin_mint.clone(), + pc_mint.clone(), + coin_vault.clone(), + pc_vault.clone(), + ); + token_a_mint = normalized.base_mint; + token_b_mint = normalized.quote_mint; + base_vault = normalized.base_vault; + quote_vault = normalized.quote_vault; + insert_optional_string(&mut payload, "poolAccount", pool_account.clone()); + insert_optional_string(&mut payload, "baseVault", base_vault.clone()); + insert_optional_string(&mut payload, "quoteVault", quote_vault.clone()); + insert_optional_string(&mut payload, "marketAccount", market_account.clone()); + insert_optional_string(&mut payload, "inputTokenAccount", input_token_account); + insert_optional_string(&mut payload, "outputTokenAccount", output_token_account); + insert_optional_string(&mut payload, "inputMint", input_mint.clone()); + insert_optional_string(&mut payload, "outputMint", output_mint.clone()); + let amount_in_or_max = read_u64_le(data, 1); + let amount_out_or_min = read_u64_le(data, 9); + if instruction_name == "swap_base_in" { + insert_optional_u64_string(&mut payload, "amountInRaw", amount_in_or_max); + insert_optional_u64_string(&mut payload, "minimumAmountOutRaw", amount_out_or_min); + } else { + insert_optional_u64_string(&mut payload, "maxAmountInRaw", amount_in_or_max); + insert_optional_u64_string(&mut payload, "amountOutRaw", amount_out_or_min); + } + let instruction_amounts = map_swap_amounts( + instruction_name, + amount_in_or_max, + amount_out_or_min, + input_mint.as_deref(), + output_mint.as_deref(), + token_a_mint.as_deref(), + token_b_mint.as_deref(), + ); + if let Some(instruction_amounts) = instruction_amounts { + insert_optional_string( + &mut payload, + "instructionTradeSide", + Some(instruction_amounts.trade_side.clone()), + ); + insert_optional_string( + &mut payload, + "instructionBoundBaseAmountRaw", + Some(instruction_amounts.base_amount_raw), + ); + insert_optional_string( + &mut payload, + "instructionBoundQuoteAmountRaw", + Some(instruction_amounts.quote_amount_raw), + ); + } + let exact_amounts = resolve_stable_swap_vault_delta_amounts( + base_vault.as_deref(), + quote_vault.as_deref(), + token_balance_records, + ); + match exact_amounts { + Some(exact_amounts) => { + insert_optional_string( + &mut payload, + "tradeSide", + Some(exact_amounts.trade_side), + ); + insert_optional_string( + &mut payload, + "baseAmountRaw", + Some(exact_amounts.base_amount_raw), + ); + insert_optional_string( + &mut payload, + "quoteAmountRaw", + Some(exact_amounts.quote_amount_raw), + ); + payload.insert("tradeCandidate".to_string(), serde_json::Value::Bool(true)); + payload.insert("candleCandidate".to_string(), serde_json::Value::Bool(true)); + payload.insert( + "amountSource".to_string(), + serde_json::Value::String("stable_swap_vault_balance_delta".to_string()), + ); + payload.insert("skipTradeReason".to_string(), serde_json::Value::Null); + payload.insert("skipCandleReason".to_string(), serde_json::Value::Null); + }, + None => { + payload.insert("tradeCandidate".to_string(), serde_json::Value::Bool(false)); + payload.insert("candleCandidate".to_string(), serde_json::Value::Bool(false)); + payload.insert( + "amountSource".to_string(), + serde_json::Value::String( + "stable_swap_instruction_bounds_only".to_string(), + ), + ); + payload.insert( + "skipTradeReason".to_string(), + serde_json::Value::String( + "stable_swap_exact_amounts_unresolved".to_string(), + ), + ); + payload.insert( + "skipCandleReason".to_string(), + serde_json::Value::String( + "stable_swap_exact_amounts_unresolved".to_string(), + ), + ); + }, + } + }, + _ => return None, + } + + insert_optional_string(&mut payload, "tokenAMint", token_a_mint.clone()); + insert_optional_string(&mut payload, "tokenBMint", token_b_mint.clone()); + return Some(RaydiumStableSwapInstructionDecoded { + transaction_id, + instruction_id, + signature: signature.to_string(), + program_id: program_id.to_string(), + event_kind, + instruction_name: instruction_name.to_string(), + discriminator_hex, + pool_account, + market_account, + lp_mint, + token_a_mint, + token_b_mint, + base_vault, + quote_vault, + payload_json: serde_json::Value::Object(payload), + }); +} + +fn stable_instruction_name(discriminator: u8) -> std::option::Option<&'static str> { + match discriminator { + 0 => return Some("initialize"), + 1 => return Some("init_model_data"), + 2 => return Some("update_model_data"), + 3 => return Some("deposit"), + 4 => return Some("withdraw"), + 5 => return Some("monitor_step"), + 6 => return Some("set_params"), + 7 => return Some("withdraw_pnl"), + 8 => return Some("withdraw_srm"), + 9 => return Some("swap_base_in"), + 10 => return Some("pre_initialize"), + 11 => return Some("swap_base_out"), + 12 => return Some("simulate_info"), + 13 => return Some("admin_cancel_orders"), + _ => return None, + } +} + +fn stable_instruction_discriminator_hex(discriminator: u8) -> std::option::Option<&'static str> { + match discriminator { + 0 => return Some(RAYDIUM_STABLE_SWAP_INITIALIZE_DISCRIMINATOR_HEX), + 1 => return Some(RAYDIUM_STABLE_SWAP_INIT_MODEL_DATA_DISCRIMINATOR_HEX), + 2 => return Some(RAYDIUM_STABLE_SWAP_UPDATE_MODEL_DATA_DISCRIMINATOR_HEX), + 3 => return Some(RAYDIUM_STABLE_SWAP_DEPOSIT_DISCRIMINATOR_HEX), + 4 => return Some(RAYDIUM_STABLE_SWAP_WITHDRAW_DISCRIMINATOR_HEX), + 5 => return Some(RAYDIUM_STABLE_SWAP_MONITOR_STEP_DISCRIMINATOR_HEX), + 6 => return Some(RAYDIUM_STABLE_SWAP_SET_PARAMS_DISCRIMINATOR_HEX), + 7 => return Some(RAYDIUM_STABLE_SWAP_WITHDRAW_PNL_DISCRIMINATOR_HEX), + 8 => return Some(RAYDIUM_STABLE_SWAP_WITHDRAW_SRM_DISCRIMINATOR_HEX), + 9 => return Some(RAYDIUM_STABLE_SWAP_SWAP_BASE_IN_DISCRIMINATOR_HEX), + 10 => return Some(RAYDIUM_STABLE_SWAP_PRE_INITIALIZE_DISCRIMINATOR_HEX), + 11 => return Some(RAYDIUM_STABLE_SWAP_SWAP_BASE_OUT_DISCRIMINATOR_HEX), + 12 => return Some(RAYDIUM_STABLE_SWAP_SIMULATE_INFO_DISCRIMINATOR_HEX), + 13 => return Some(RAYDIUM_STABLE_SWAP_ADMIN_CANCEL_ORDERS_DISCRIMINATOR_HEX), + _ => return None, + } +} + +#[derive(Debug, Clone)] +struct NormalizedVaultPair { + base_mint: std::option::Option, + quote_mint: std::option::Option, + base_vault: std::option::Option, + quote_vault: std::option::Option, +} + +fn normalize_pair_with_vaults( + coin_mint: std::option::Option, + pc_mint: std::option::Option, + coin_vault: std::option::Option, + pc_vault: std::option::Option, +) -> NormalizedVaultPair { + let coin_mint_ref = match coin_mint.as_ref() { + Some(coin_mint) => coin_mint.as_str(), + None => { + return NormalizedVaultPair { + base_mint: None, + quote_mint: None, + base_vault: None, + quote_vault: None, + }; + }, + }; + let pc_mint_ref = match pc_mint.as_ref() { + Some(pc_mint) => pc_mint.as_str(), + None => { + return NormalizedVaultPair { + base_mint: None, + quote_mint: None, + base_vault: None, + quote_vault: None, + }; + }, + }; + let coin_is_quote = is_quote_mint(coin_mint_ref); + let pc_is_quote = is_quote_mint(pc_mint_ref); + let pc_is_normalized_quote = if pc_is_quote && !coin_is_quote { + true + } else if coin_is_quote && !pc_is_quote { + false + } else { + pc_mint_ref <= coin_mint_ref + }; + if pc_is_normalized_quote { + return NormalizedVaultPair { + base_mint: coin_mint, + quote_mint: pc_mint, + base_vault: coin_vault, + quote_vault: pc_vault, + }; + } + return NormalizedVaultPair { + base_mint: pc_mint, + quote_mint: coin_mint, + base_vault: pc_vault, + quote_vault: coin_vault, + }; +} + +fn is_quote_mint(mint: &str) -> bool { + return mint == crate::WSOL_MINT_ID + || mint == crate::USDC_MINT_ID + || mint == crate::USDT_MINT_ID; +} + +#[derive(Debug, Clone)] +struct StableSwapAmounts { + trade_side: std::string::String, + base_amount_raw: std::string::String, + quote_amount_raw: std::string::String, +} + +fn map_swap_amounts( + instruction_name: &str, + first_amount: std::option::Option, + second_amount: std::option::Option, + input_mint: std::option::Option<&str>, + output_mint: std::option::Option<&str>, + base_mint: std::option::Option<&str>, + quote_mint: std::option::Option<&str>, +) -> std::option::Option { + let first_amount = match first_amount { + Some(first_amount) => first_amount, + None => return None, + }; + let second_amount = match second_amount { + Some(second_amount) => second_amount, + None => return None, + }; + let input_mint = match input_mint { + Some(input_mint) => input_mint, + None => return None, + }; + if output_mint.is_none() { + return None; + } + let base_mint = match base_mint { + Some(base_mint) => base_mint, + None => return None, + }; + let quote_mint = match quote_mint { + Some(quote_mint) => quote_mint, + None => return None, + }; + if instruction_name == "swap_base_in" { + if input_mint == base_mint { + return Some(StableSwapAmounts { + trade_side: "sell".to_string(), + base_amount_raw: first_amount.to_string(), + quote_amount_raw: second_amount.to_string(), + }); + } + if input_mint == quote_mint { + return Some(StableSwapAmounts { + trade_side: "buy".to_string(), + base_amount_raw: second_amount.to_string(), + quote_amount_raw: first_amount.to_string(), + }); + } + } + if instruction_name == "swap_base_out" { + if input_mint == base_mint { + return Some(StableSwapAmounts { + trade_side: "sell".to_string(), + base_amount_raw: first_amount.to_string(), + quote_amount_raw: second_amount.to_string(), + }); + } + if input_mint == quote_mint { + return Some(StableSwapAmounts { + trade_side: "buy".to_string(), + base_amount_raw: second_amount.to_string(), + quote_amount_raw: first_amount.to_string(), + }); + } + } + return None; +} + +#[derive(Debug, Clone)] +struct StableSwapExactAmounts { + trade_side: std::string::String, + base_amount_raw: std::string::String, + quote_amount_raw: std::string::String, +} + +#[derive(Debug, Clone)] +struct TokenBalanceRecord { + account_address: std::option::Option, + pre_amount_raw: std::option::Option, + post_amount_raw: std::option::Option, +} + +#[derive(Debug, Clone)] +struct TokenBalanceAccumulator { + account_index: std::option::Option, + account_address: std::option::Option, + mint: std::string::String, + pre_amount_raw: std::option::Option, + post_amount_raw: std::option::Option, +} + +impl TokenBalanceRecord { + fn delta_raw(&self) -> std::option::Option { + let pre = parse_i128_or_zero(self.pre_amount_raw.as_deref()); + let post = parse_i128_or_zero(self.post_amount_raw.as_deref()); + match (pre, post) { + (Some(pre), Some(post)) => return Some(post - pre), + _ => return None, + } + } +} + +fn resolve_stable_swap_vault_delta_amounts( + base_vault: std::option::Option<&str>, + quote_vault: std::option::Option<&str>, + token_balance_records: &[TokenBalanceRecord], +) -> std::option::Option { + let base_vault = match base_vault { + Some(base_vault) => base_vault, + None => return None, + }; + let quote_vault = match quote_vault { + Some(quote_vault) => quote_vault, + None => return None, + }; + let base_record = token_balance_record_for_account(token_balance_records, base_vault); + let base_record = match base_record { + Some(base_record) => base_record, + None => return None, + }; + let quote_record = token_balance_record_for_account(token_balance_records, quote_vault); + let quote_record = match quote_record { + Some(quote_record) => quote_record, + None => return None, + }; + let base_delta = match base_record.delta_raw() { + Some(base_delta) => base_delta, + None => return None, + }; + let quote_delta = match quote_record.delta_raw() { + Some(quote_delta) => quote_delta, + None => return None, + }; + if base_delta == 0 || quote_delta == 0 { + return None; + } + if base_delta < 0 && quote_delta > 0 { + return Some(StableSwapExactAmounts { + trade_side: "buy".to_string(), + base_amount_raw: (-base_delta).to_string(), + quote_amount_raw: quote_delta.to_string(), + }); + } + if base_delta > 0 && quote_delta < 0 { + return Some(StableSwapExactAmounts { + trade_side: "sell".to_string(), + base_amount_raw: base_delta.to_string(), + quote_amount_raw: (-quote_delta).to_string(), + }); + } + return None; +} + +fn token_balance_record_for_account<'a>( + token_balance_records: &'a [TokenBalanceRecord], + account: &str, +) -> std::option::Option<&'a TokenBalanceRecord> { + for record in token_balance_records { + if record.account_address.as_deref() == Some(account) { + return Some(record); + } + } + return None; +} + +fn parse_i128_or_zero(value: std::option::Option<&str>) -> std::option::Option { + let value = match value { + Some(value) => value.trim(), + None => return Some(0), + }; + if value.is_empty() { + return Some(0); + } + let parsed = value.parse::(); + match parsed { + Ok(parsed) => return Some(parsed), + Err(_) => return None, + } +} + +fn parse_accounts_json( + accounts_json: &str, +) -> std::option::Option> { + let value = match serde_json::from_str::(accounts_json) { + Ok(value) => value, + Err(_) => return None, + }; + let array = match value.as_array() { + Some(array) => array, + None => return None, + }; + let mut accounts = std::vec::Vec::new(); + for item in array { + if let Some(text) = item.as_str() { + accounts.push(text.to_string()); + } else if let Some(pubkey) = item.get("pubkey").and_then(|value| return value.as_str()) { + accounts.push(pubkey.to_string()); + } + } + return Some(accounts); +} + +fn decode_data_json_base58(data_json: &str) -> std::option::Option> { + let value = match serde_json::from_str::(data_json) { + Ok(value) => value, + Err(_) => return None, + }; + let data_base58 = if let Some(text) = value.as_str() { + text.to_string() + } else if let Some(text) = value.get("data").and_then(|value| return value.as_str()) { + text.to_string() + } else { + return None; + }; + let decoded = bs58::decode(data_base58.as_str()).into_vec(); + match decoded { + Ok(decoded) => return Some(decoded), + Err(_) => return None, + } +} + +fn account_at( + accounts: &[std::string::String], + index: usize, +) -> std::option::Option { + return accounts.get(index).cloned(); +} + +fn read_u64_le(data: &[u8], offset: usize) -> std::option::Option { + let end = offset.saturating_add(8); + if data.len() < end { + return None; + } + let bytes = [ + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3], + data[offset + 4], + data[offset + 5], + data[offset + 6], + data[offset + 7], + ]; + return Some(u64::from_le_bytes(bytes)); +} + +fn parse_json_value(input: &str) -> std::option::Option { + let parsed = serde_json::from_str::(input); + match parsed { + Ok(parsed) => return Some(parsed), + Err(_) => return None, + } +} + +fn parse_optional_json_value( + input: std::option::Option<&str>, +) -> std::option::Option { + let input = match input { + Some(input) => input, + None => return None, + }; + return parse_json_value(input); +} + +fn extract_transaction_account_keys( + transaction_json: std::option::Option<&serde_json::Value>, + meta_json: std::option::Option<&serde_json::Value>, +) -> std::vec::Vec { + let mut account_keys = std::vec::Vec::new(); + let message = transaction_json + .and_then(|value| return value.get("transaction")) + .and_then(|value| return value.get("message")); + if let Some(message) = message { + extract_account_keys_from_message(message, &mut account_keys); + } + if account_keys.is_empty() { + let message = transaction_json.and_then(|value| return value.get("message")); + if let Some(message) = message { + extract_account_keys_from_message(message, &mut account_keys); + } + } + if account_keys.is_empty() { + let loaded = meta_json.and_then(|value| return value.get("loadedAddresses")); + if let Some(loaded) = loaded { + append_loaded_addresses(loaded, &mut account_keys); + } + } + return account_keys; +} + +fn extract_account_keys_from_message( + message: &serde_json::Value, + account_keys: &mut std::vec::Vec, +) { + let keys = match message.get("accountKeys").and_then(|value| return value.as_array()) { + Some(keys) => keys, + None => return, + }; + for key in keys { + if let Some(text) = key.as_str() { + account_keys.push(text.to_string()); + } else if let Some(pubkey) = key.get("pubkey").and_then(|value| return value.as_str()) { + account_keys.push(pubkey.to_string()); + } + } + if let Some(loaded) = message.get("loadedAddresses") { + append_loaded_addresses(loaded, account_keys); + } +} + +fn append_loaded_addresses( + loaded: &serde_json::Value, + account_keys: &mut std::vec::Vec, +) { + for section in ["writable", "readonly"] { + let array = loaded.get(section).and_then(|value| return value.as_array()); + let array = match array { + Some(array) => array, + None => continue, + }; + for item in array { + if let Some(text) = item.as_str() { + account_keys.push(text.to_string()); + } + } + } +} + +fn extract_token_balance_records( + transaction_json: std::option::Option<&serde_json::Value>, + meta_json: std::option::Option<&serde_json::Value>, + account_keys: &[std::string::String], +) -> std::vec::Vec { + let mut accumulators = std::vec::Vec::new(); + let meta_candidates = [ + meta_json, + transaction_json.and_then(|value| return value.get("meta")), + transaction_json + .and_then(|value| return value.get("transaction")) + .and_then(|value| return value.get("meta")), + ]; + for meta in meta_candidates.iter().flatten() { + collect_token_balance_side( + meta.get("preTokenBalances"), + account_keys, + true, + &mut accumulators, + ); + collect_token_balance_side( + meta.get("postTokenBalances"), + account_keys, + false, + &mut accumulators, + ); + } + let mut records = std::vec::Vec::new(); + for accumulator in accumulators { + records.push(TokenBalanceRecord { + account_address: accumulator.account_address, + pre_amount_raw: accumulator.pre_amount_raw, + post_amount_raw: accumulator.post_amount_raw, + }); + } + return records; +} + +fn collect_token_balance_side( + balances: std::option::Option<&serde_json::Value>, + account_keys: &[std::string::String], + is_pre: bool, + accumulators: &mut std::vec::Vec, +) { + let array = balances.and_then(|value| return value.as_array()); + let array = match array { + Some(array) => array, + None => return, + }; + for balance in array { + let account_index = balance.get("accountIndex").and_then(|value| return value.as_u64()); + let mint = match balance.get("mint").and_then(|value| return value.as_str()) { + Some(mint) => mint.to_string(), + None => continue, + }; + let amount = balance + .get("uiTokenAmount") + .and_then(|value| return value.get("amount")) + .and_then(|value| return value.as_str()) + .map(|value| return value.to_string()); + let account_address = account_address_by_index(account_keys, account_index); + let accumulator_index = + find_token_balance_accumulator(accumulators.as_slice(), account_index, mint.as_str()); + let index = match accumulator_index { + Some(index) => index, + None => { + accumulators.push(TokenBalanceAccumulator { + account_index, + account_address, + mint, + pre_amount_raw: None, + post_amount_raw: None, + }); + accumulators.len() - 1 + }, + }; + if is_pre { + accumulators[index].pre_amount_raw = amount; + } else { + accumulators[index].post_amount_raw = amount; + } + } +} + +fn find_token_balance_accumulator( + accumulators: &[TokenBalanceAccumulator], + account_index: std::option::Option, + mint: &str, +) -> std::option::Option { + let mut index = 0usize; + while index < accumulators.len() { + let accumulator = &accumulators[index]; + if accumulator.account_index == account_index && accumulator.mint == mint { + return Some(index); + } + index += 1; + } + return None; +} + +fn account_address_by_index( + account_keys: &[std::string::String], + account_index: std::option::Option, +) -> std::option::Option { + let account_index = match account_index { + Some(account_index) => account_index, + None => return None, + }; + let index = match usize::try_from(account_index) { + Ok(index) => index, + Err(_) => return None, + }; + return account_keys.get(index).cloned(); +} + +fn extract_token_mints_by_account( + transaction_json: std::option::Option<&serde_json::Value>, + meta_json: std::option::Option<&serde_json::Value>, + account_keys: &[std::string::String], +) -> std::collections::BTreeMap { + let mut map = std::collections::BTreeMap::new(); + let meta_candidates = [ + meta_json, + transaction_json.and_then(|value| return value.get("meta")), + transaction_json + .and_then(|value| return value.get("transaction")) + .and_then(|value| return value.get("meta")), + ]; + for meta in meta_candidates.iter().flatten() { + append_token_balance_mints(meta.get("preTokenBalances"), account_keys, &mut map); + append_token_balance_mints(meta.get("postTokenBalances"), account_keys, &mut map); + } + return map; +} + +fn append_token_balance_mints( + balances: std::option::Option<&serde_json::Value>, + account_keys: &[std::string::String], + map: &mut std::collections::BTreeMap, +) { + let array = balances.and_then(|value| return value.as_array()); + let array = match array { + Some(array) => array, + None => return, + }; + for balance in array { + let account_index = balance.get("accountIndex").and_then(|value| return value.as_u64()); + let mint = balance.get("mint").and_then(|value| return value.as_str()); + let owner = balance.get("owner").and_then(|value| return value.as_str()); + let account_index = match account_index { + Some(account_index) => account_index, + None => continue, + }; + let mint = match mint { + Some(mint) => mint, + None => continue, + }; + let index_result = usize::try_from(account_index); + let index = match index_result { + Ok(index) => index, + Err(_) => continue, + }; + if let Some(account) = account_keys.get(index) { + map.insert(account.clone(), mint.to_string()); + } + if let Some(owner) = owner { + map.insert(owner.to_string(), mint.to_string()); + } + } +} + +fn mint_for_account( + account: std::option::Option<&str>, + token_mints_by_account: &std::collections::BTreeMap, +) -> std::option::Option { + let account = match account { + Some(account) => account, + None => return None, + }; + return token_mints_by_account.get(account).cloned(); +} + +fn insert_optional_string( + object: &mut serde_json::Map, + key: &str, + value: std::option::Option, +) { + if let Some(value) = value { + object.insert(key.to_string(), serde_json::Value::String(value)); + } +} + +fn insert_optional_u8( + object: &mut serde_json::Map, + key: &str, + value: std::option::Option, +) { + if let Some(value) = value { + object.insert(key.to_string(), serde_json::Value::Number(serde_json::Number::from(value))); + } +} + +fn insert_optional_u64( + object: &mut serde_json::Map, + key: &str, + value: std::option::Option, +) { + if let Some(value) = value { + object.insert(key.to_string(), serde_json::Value::Number(serde_json::Number::from(value))); + } +} + +fn insert_optional_u64_string( + object: &mut serde_json::Map, + key: &str, + value: std::option::Option, +) { + if let Some(value) = value { + object.insert(key.to_string(), serde_json::Value::String(value.to_string())); + } +} + +fn bytes_to_hex(data: &[u8]) -> std::string::String { + let mut output = std::string::String::new(); + for byte in data { + output.push_str(format!("{:02x}", byte).as_str()); + } + return output; +} + +#[cfg(test)] +mod tests { + fn encode_data(data: &[u8]) -> std::string::String { + return bs58::encode(data).into_string(); + } + + #[test] + fn classifies_one_byte_stable_swap_discriminators() { + let initialize = serde_json::json!(encode_data(&[0_u8, 1_u8, 0, 0, 0, 0, 0, 0, 0, 0])); + let deposit = serde_json::json!(encode_data(&[ + 3_u8, 1, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ])); + let initialize_text = initialize.to_string(); + let deposit_text = deposit.to_string(); + assert_eq!( + super::classify_raydium_stable_swap_instruction_data(initialize_text.as_str()), + Some("initialize") + ); + assert_eq!( + super::classify_raydium_stable_swap_instruction_data(deposit_text.as_str()), + Some("deposit") + ); + } + + #[test] + fn decodes_stable_swap_initialize_with_normalized_pair() { + let accounts = serde_json::json!([ + "TokenProgram1111111111111111111111111111111111", + "System111111111111111111111111111111111111", + "Rent111111111111111111111111111111111111111", + "StablePool11111111111111111111111111111111111", + "Authority111111111111111111111111111111111111", + "OpenOrders1111111111111111111111111111111111", + "LpMint11111111111111111111111111111111111111", + crate::USDT_MINT_ID, + crate::USDC_MINT_ID, + "CoinVault1111111111111111111111111111111111", + "PcVault11111111111111111111111111111111111", + "TargetOrders11111111111111111111111111111111", + "ModelData1111111111111111111111111111111111", + "SerumProgram111111111111111111111111111111", + "SerumMarket1111111111111111111111111111111", + "UserLp111111111111111111111111111111111111", + "Wallet111111111111111111111111111111111111" + ]); + let mut data = vec![0_u8, 1_u8]; + data.extend_from_slice(&123_u64.to_le_bytes()); + let events = super::decode_raydium_stable_swap_instruction_with_context( + 1, + 2, + "sig", + crate::RAYDIUM_STABLE_SWAP_AMM_PROGRAM_ID, + accounts.to_string().as_str(), + Some(serde_json::json!(encode_data(data.as_slice())).to_string().as_str()), + &std::collections::BTreeMap::new(), + &[], + ); + assert_eq!(events.len(), 1); + match &events[0] { + crate::RaydiumStableSwapDecodedEvent::Instruction(event) => { + assert_eq!(event.event_kind, "raydium_stable_swap.initialize"); + assert_eq!( + event.pool_account.as_deref(), + Some("StablePool11111111111111111111111111111111111") + ); + assert_eq!(event.token_b_mint.as_deref(), Some(crate::USDC_MINT_ID)); + }, + _ => panic!("expected instruction event"), + } + } +} diff --git a/kb_lib/src/dex_decode.rs b/kb_lib/src/dex_decode.rs index 2c41ea0..28f0588 100644 --- a/kb_lib/src/dex_decode.rs +++ b/kb_lib/src/dex_decode.rs @@ -11,6 +11,7 @@ pub struct DexDecodeService { persistence: crate::DetectionPersistenceService, raydium_amm_v4_decoder: crate::RaydiumAmmV4Decoder, raydium_clmm_decoder: crate::RaydiumClmmDecoder, + raydium_stable_swap_decoder: crate::RaydiumStableSwapDecoder, pump_fun_decoder: crate::PumpFunDecoder, pump_swap_decoder: crate::PumpSwapDecoder, orca_whirlpools_decoder: crate::OrcaWhirlpoolsDecoder, @@ -33,6 +34,7 @@ impl DexDecodeService { persistence, raydium_amm_v4_decoder: crate::RaydiumAmmV4Decoder::new(), raydium_clmm_decoder: crate::RaydiumClmmDecoder::new(), + raydium_stable_swap_decoder: crate::RaydiumStableSwapDecoder::new(), pump_fun_decoder: crate::PumpFunDecoder::new(), pump_swap_decoder: crate::PumpSwapDecoder::new(), orca_whirlpools_decoder: crate::OrcaWhirlpoolsDecoder::new(), @@ -78,6 +80,13 @@ impl DexDecodeService { if let Err(error) = append_result { return Err(error); } + let append_result = append_persisted_events_result( + &mut persisted, + self.decode_and_persist_raydium_stable_swap_events(&transaction, &instructions).await, + ); + if let Err(error) = append_result { + return Err(error); + } let append_result = append_persisted_events_result( &mut persisted, self.decode_and_persist_raydium_clmm_events(&transaction, &instructions).await, @@ -1512,6 +1521,65 @@ impl DexDecodeService { .await; } + async fn persist_raydium_stable_swap_event( + &self, + transaction: &crate::ChainTransactionDto, + decoded_event: &crate::RaydiumStableSwapDecodedEvent, + ) -> Result { + let transaction_id = match transaction.id { + Some(transaction_id) => transaction_id, + None => { + return Err(crate::Error::InvalidState(format!( + "transaction '{}' has no internal id", + transaction.signature + ))); + }, + }; + let instruction_id = match decoded_event.instruction_id() { + Some(instruction_id) => instruction_id, + None => { + return Err(crate::Error::InvalidState(format!( + "raydium stable swap decoded event for transaction '{}' has no instruction id", + transaction.signature + ))); + }, + }; + let event_kind = decoded_event.event_kind().to_string(); + let raw_payload_json = match decoded_event.to_payload_json() { + Some(payload_json) => payload_json, + None => { + return Err(crate::Error::Json( + "cannot serialize decoded raydium stable swap payload".to_string(), + )); + }, + }; + let payload_value_result = enriched_raydium_payload_value( + "raydium_stable_swap", + event_kind.as_str(), + raw_payload_json.as_str(), + ); + let payload_value = match payload_value_result { + Ok(payload_value) => payload_value, + Err(error) => return Err(error), + }; + return self + .materialize_named_dex_event( + transaction, + transaction_id, + instruction_id, + "raydium_stable_swap", + crate::RAYDIUM_STABLE_SWAP_AMM_PROGRAM_ID.to_string(), + event_kind.as_str(), + decoded_event.pool_account().map(|value| return value.to_string()), + decoded_event.market_account().map(|value| return value.to_string()), + decoded_event.base_mint().map(|value| return value.to_string()), + decoded_event.quote_mint().map(|value| return value.to_string()), + decoded_event.lp_mint().map(|value| return value.to_string()), + payload_value, + ) + .await; + } + async fn persist_pump_fun_event( &self, transaction: &crate::ChainTransactionDto, @@ -1708,6 +1776,32 @@ impl DexDecodeService { return Ok(persisted); } + async fn decode_and_persist_raydium_stable_swap_events( + &self, + transaction: &crate::ChainTransactionDto, + instructions: &[crate::ChainInstructionDto], + ) -> Result, crate::Error> { + let decoded_result = self + .raydium_stable_swap_decoder + .decode_transaction(transaction, instructions); + let decoded_events = match decoded_result { + Ok(decoded_events) => decoded_events, + Err(error) => return Err(error), + }; + let mut persisted = std::vec::Vec::new(); + for decoded_event in &decoded_events { + let persist_result = self + .persist_raydium_stable_swap_event(transaction, decoded_event) + .await; + let persisted_event = match persist_result { + Ok(persisted_event) => persisted_event, + Err(error) => return Err(error), + }; + persisted.push(persisted_event); + } + return Ok(persisted); + } + async fn decode_and_persist_raydium_clmm_events( &self, transaction: &crate::ChainTransactionDto, @@ -2509,6 +2603,13 @@ fn raydium_instruction_audit_spec( candidate_pool_account_index: 3, }); } + if program_id == crate::RAYDIUM_STABLE_SWAP_AMM_PROGRAM_ID { + return Some(RaydiumInstructionAuditSpec { + protocol_name: "raydium_stable_swap", + event_kind: "raydium_stable_swap.instruction_audit", + candidate_pool_account_index: 1, + }); + } if program_id == crate::RAYDIUM_LAUNCHPAD_PROGRAM_ID { return Some(RaydiumInstructionAuditSpec { protocol_name: "raydium_launchpad", @@ -4234,6 +4335,7 @@ fn instruction_audit_event_kind_by_protocol( "raydium_amm_v4" => return Some("raydium_amm_v4.instruction_audit"), "raydium_clmm" => return Some("raydium_clmm.instruction_audit"), "raydium_cpmm" => return Some("raydium_cpmm.instruction_audit"), + "raydium_stable_swap" => return Some("raydium_stable_swap.instruction_audit"), "raydium_launchpad" => return Some("raydium_launchpad.instruction_audit"), "meteora_dlmm" => return Some("meteora_dlmm.instruction_audit"), "meteora_damm_v1" => return Some("meteora_damm_v1.instruction_audit"), @@ -4719,7 +4821,7 @@ fn raydium_instruction_discriminator_hex( bytes: std::option::Option<&[u8]>, offset: usize, ) -> std::option::Option { - if protocol_name == "raydium_amm_v4" { + if protocol_name == "raydium_amm_v4" || protocol_name == "raydium_stable_swap" { return discriminator_hex_from_bytes_with_len(bytes, offset, 1); } return discriminator_hex_from_bytes(bytes, offset); diff --git a/kb_lib/src/dex_detect.rs b/kb_lib/src/dex_detect.rs index 2c98aab..66f8fba 100644 --- a/kb_lib/src/dex_detect.rs +++ b/kb_lib/src/dex_detect.rs @@ -108,6 +108,12 @@ impl DexDetectService { crate::dex_detection_route::DexDetectionRoute::RaydiumCpmmTrade => { self.detect_raydium_cpmm_trade(&transaction, decoded_event).await }, + crate::dex_detection_route::DexDetectionRoute::RaydiumStableSwapTrade => { + self.detect_raydium_stable_swap_trade(&transaction, decoded_event).await + }, + crate::dex_detection_route::DexDetectionRoute::RaydiumStableSwapPool => { + self.detect_raydium_stable_swap_pool(&transaction, decoded_event).await + }, crate::dex_detection_route::DexDetectionRoute::RaydiumClmmTrade => { self.detect_raydium_clmm_trade(&transaction, decoded_event).await }, @@ -668,6 +674,78 @@ impl DexDetectService { .await; } + + async fn detect_raydium_stable_swap_pool( + &self, + transaction: &crate::ChainTransactionDto, + decoded_event: &crate::DexDecodedEventDto, + ) -> Result { + return self + .detect_materialized_pool_from_decoded_event( + transaction, + decoded_event, + "raydium_stable_swap", + crate::PoolKind::Amm, + crate::PoolStatus::Active, + "signal.dex.raydium_stable_swap", + ) + .await; + } + + async fn detect_raydium_stable_swap_trade( + &self, + transaction: &crate::ChainTransactionDto, + decoded_event: &crate::DexDecodedEventDto, + ) -> Result { + let dex_id_result = + crate::dex_catalog::ensure_known_dex(self.database.as_ref(), "raydium_stable_swap").await; + let dex_id = match dex_id_result { + Ok(dex_id) => dex_id, + Err(error) => return Err(error), + }; + let payload_value_result = parse_payload_json(decoded_event.payload_json.as_str()); + let payload_value = match payload_value_result { + Ok(payload_value) => payload_value, + Err(error) => return Err(error), + }; + let base_vault_address = extract_payload_string_field(&payload_value, "baseVault"); + let quote_vault_address = extract_payload_string_field(&payload_value, "quoteVault"); + let input_result = + crate::dex_pool_materialization::DexPoolMaterializationInput::from_decoded_event( + decoded_event, + dex_id, + crate::PoolKind::Amm, + crate::PoolStatus::Active, + crate::dex_pool_materialization::DexPoolTokenOrder::AlreadyBaseQuote, + base_vault_address, + quote_vault_address, + transaction.source_endpoint_name.clone(), + ); + let input = match input_result { + Ok(input) => input, + Err(error) => return Err(error), + }; + let detection_result = + crate::dex_pool_materialization::materialize_dex_pool(self.database.as_ref(), &input) + .await; + let detection_result = match detection_result { + Ok(detection_result) => detection_result, + Err(error) => return Err(error), + }; + let signal_result = self + .record_pool_detection_signals( + transaction, + "signal.dex.raydium_stable_swap", + &detection_result, + payload_value, + ) + .await; + if let Err(error) = signal_result { + return Err(error); + } + return Ok(detection_result); + } + async fn detect_raydium_cpmm_trade( &self, transaction: &crate::ChainTransactionDto, diff --git a/kb_lib/src/dex_detection_route.rs b/kb_lib/src/dex_detection_route.rs index ca26640..13a1c42 100644 --- a/kb_lib/src/dex_detection_route.rs +++ b/kb_lib/src/dex_detection_route.rs @@ -11,6 +11,10 @@ pub(crate) enum DexDetectionRoute { RaydiumAmmV4Trade, /// Raydium CPMM trade route. RaydiumCpmmTrade, + /// Raydium Stable Swap trade route. + RaydiumStableSwapTrade, + /// Raydium Stable Swap pool lifecycle route. + RaydiumStableSwapPool, /// Raydium CLMM trade route. RaydiumClmmTrade, /// Raydium Launchpad pool or bonding-curve creation route. @@ -67,6 +71,18 @@ pub(crate) fn dex_detection_route( ("raydium_cpmm", "raydium_cpmm.swap_base_output") => { return Some(crate::dex_detection_route::DexDetectionRoute::RaydiumCpmmTrade); }, + ("raydium_stable_swap", "raydium_stable_swap.initialize") => { + return Some(crate::dex_detection_route::DexDetectionRoute::RaydiumStableSwapPool); + }, + ("raydium_stable_swap", "raydium_stable_swap.pre_initialize") => { + return Some(crate::dex_detection_route::DexDetectionRoute::RaydiumStableSwapPool); + }, + ("raydium_stable_swap", "raydium_stable_swap.swap_base_in") => { + return Some(crate::dex_detection_route::DexDetectionRoute::RaydiumStableSwapTrade); + }, + ("raydium_stable_swap", "raydium_stable_swap.swap_base_out") => { + return Some(crate::dex_detection_route::DexDetectionRoute::RaydiumStableSwapTrade); + }, ("raydium_clmm", "raydium_clmm.swap") => { return Some(crate::dex_detection_route::DexDetectionRoute::RaydiumClmmTrade); }, diff --git a/kb_lib/src/dex_event_coverage.rs b/kb_lib/src/dex_event_coverage.rs index 558eab8..f4f82ca 100644 --- a/kb_lib/src/dex_event_coverage.rs +++ b/kb_lib/src/dex_event_coverage.rs @@ -260,6 +260,39 @@ fn infer_expected_db_target_for_entry( ); } } + if decoder_code == "raydium_stable_swap" { + if entry_name == "initialize" || entry_name == "pre_initialize" { + return Some( + crate::DexEventCoverageEntryDto::DB_TARGET_POOL_LIFECYCLE_EVENTS.to_string(), + ); + } + if entry_name == "deposit" || entry_name == "withdraw" { + return Some(crate::DexEventCoverageEntryDto::DB_TARGET_LIQUIDITY_EVENTS.to_string()); + } + if entry_name == "swap_base_in" || entry_name == "swap_base_out" { + return Some(crate::DexEventCoverageEntryDto::DB_TARGET_TRADE_EVENTS.to_string()); + } + if entry_name == "withdraw_pnl" || entry_name == "withdraw_srm" { + return Some(crate::DexEventCoverageEntryDto::DB_TARGET_FEE_EVENTS.to_string()); + } + if entry_name == "set_params" { + return Some(crate::DexEventCoverageEntryDto::DB_TARGET_POOL_ADMIN_EVENTS.to_string()); + } + if entry_name == "monitor_step" || entry_name == "admin_cancel_orders" { + return Some(crate::DexEventCoverageEntryDto::DB_TARGET_ORDERBOOK_EVENTS.to_string()); + } + if entry_name == "update_model_data" { + return Some(crate::DexEventCoverageEntryDto::DB_TARGET_POOL_ADMIN_EVENTS.to_string()); + } + if entry_name == "init_model_data" + || entry_name == "simulate_info" + || entry_name == "swap_event" + { + return Some( + crate::DexEventCoverageEntryDto::DB_TARGET_DECODED_EVENTS_ONLY.to_string(), + ); + } + } if decoder_code == "raydium_clmm" { if entry_name == "initialize_reward" { return Some(crate::DexEventCoverageEntryDto::DB_TARGET_REWARD_EVENTS.to_string()); @@ -405,6 +438,9 @@ fn infer_event_family_for_entry( if decoder_code == "raydium_cpmm" { return infer_raydium_cpmm_event_family(entry_name, entry_kind); } + if decoder_code == "raydium_stable_swap" { + return infer_raydium_stable_swap_event_family(entry_name, entry_kind); + } return infer_event_family(entry_name, entry_kind); } @@ -452,6 +488,34 @@ fn infer_raydium_cpmm_event_family( } } + +fn infer_raydium_stable_swap_event_family( + entry_name: &str, + entry_kind: &str, +) -> std::option::Option { + if entry_kind == crate::ENTRY_KIND_PROGRAM { + return None; + } + match entry_name { + "initialize" => return Some("pool_create".to_string()), + "pre_initialize" => return Some("pool_create".to_string()), + "init_model_data" => return Some("model_setup".to_string()), + "update_model_data" => return Some("admin_config".to_string()), + "deposit" => return Some("liquidity_add".to_string()), + "withdraw" => return Some("liquidity_remove".to_string()), + "monitor_step" => return Some("order_place".to_string()), + "set_params" => return Some("admin_config".to_string()), + "withdraw_pnl" => return Some("fee".to_string()), + "withdraw_srm" => return Some("fee".to_string()), + "swap_base_in" => return Some("swap".to_string()), + "swap_base_out" => return Some("swap".to_string()), + "simulate_info" => return Some("cpi_transport".to_string()), + "admin_cancel_orders" => return Some("orderbook_admin".to_string()), + "swap_event" => return Some("cpi_transport".to_string()), + _ => return infer_event_family(entry_name, entry_kind), + } +} + fn infer_raydium_clmm_event_family( entry_name: &str, entry_kind: &str, @@ -725,6 +789,32 @@ fn raydium_amm_v4_local_event_kind(entry_name: &str) -> std::option::Option std::option::Option { + match entry_name { + "initialize" => return Some("raydium_stable_swap.initialize".to_string()), + "init_model_data" => return Some("raydium_stable_swap.init_model_data".to_string()), + "update_model_data" => return Some("raydium_stable_swap.update_model_data".to_string()), + "pre_initialize" => return Some("raydium_stable_swap.pre_initialize".to_string()), + "deposit" => return Some("raydium_stable_swap.deposit".to_string()), + "withdraw" => return Some("raydium_stable_swap.withdraw".to_string()), + "monitor_step" => return Some("raydium_stable_swap.monitor_step".to_string()), + "set_params" => return Some("raydium_stable_swap.set_params".to_string()), + "withdraw_pnl" => return Some("raydium_stable_swap.withdraw_pnl".to_string()), + "withdraw_srm" => return Some("raydium_stable_swap.withdraw_srm".to_string()), + "swap_base_in" => return Some("raydium_stable_swap.swap_base_in".to_string()), + "swap_base_out" => return Some("raydium_stable_swap.swap_base_out".to_string()), + "simulate_info" => return Some("raydium_stable_swap.simulate_info".to_string()), + "admin_cancel_orders" => { + return Some("raydium_stable_swap.admin_cancel_orders".to_string()); + }, + "swap_event" => return Some("raydium_stable_swap.swap_event".to_string()), + _ => return None, + } +} + pub(crate) fn known_local_event_kind( decoder_code: &str, entry_name: &str, @@ -732,6 +822,9 @@ pub(crate) fn known_local_event_kind( if decoder_code == "raydium_amm_v4" { return raydium_amm_v4_local_event_kind(entry_name); } + if decoder_code == "raydium_stable_swap" { + return raydium_stable_swap_local_event_kind(entry_name); + } if decoder_code == "raydium_launchpad" && raydium_launchpad_local_entry_is_known(entry_name) { return Some(format!("raydium_launchpad.{}", entry_name)); } diff --git a/kb_lib/src/instruction_observation_index.rs b/kb_lib/src/instruction_observation_index.rs index 3431904..4b2951b 100644 --- a/kb_lib/src/instruction_observation_index.rs +++ b/kb_lib/src/instruction_observation_index.rs @@ -224,7 +224,29 @@ fn resolve_instruction_name( }; return Some(name.to_string()); } - + if program_id == crate::RAYDIUM_STABLE_SWAP_AMM_PROGRAM_ID + || decoder_code == Some("raydium_stable_swap") + { + let name = match discriminator_hex { + "00" => "raydium_stable_swap.initialize", + "01" => "raydium_stable_swap.init_model_data", + "02" => "raydium_stable_swap.update_model_data", + "03" => "raydium_stable_swap.deposit", + "04" => "raydium_stable_swap.withdraw", + "05" => "raydium_stable_swap.monitor_step", + "06" => "raydium_stable_swap.set_params", + "07" => "raydium_stable_swap.withdraw_pnl", + "08" => "raydium_stable_swap.withdraw_srm", + "09" => "raydium_stable_swap.swap_base_in", + "0a" => "raydium_stable_swap.pre_initialize", + "0b" => "raydium_stable_swap.swap_base_out", + "0c" => "raydium_stable_swap.simulate_info", + "0d" => "raydium_stable_swap.admin_cancel_orders", + "40c6cde8260871e2" => "raydium_stable_swap.swap_event", + _ => return None, + }; + return Some(name.to_string()); + } if program_id == crate::RAYDIUM_CPMM_PROGRAM_ID || decoder_code == Some("raydium_cpmm") { let name = match discriminator_hex { "9c5420764587467b" => "raydium_cpmm.close_permission_pda", @@ -284,7 +306,6 @@ fn resolve_instruction_name( }; return Some(name.to_string()); } - if program_id == crate::RAYDIUM_LAUNCHPAD_PROGRAM_ID || decoder_code == Some("raydium_launchpad") { @@ -309,7 +330,9 @@ fn discriminator_hex_from_data_json( Some(decoded) => decoded, None => return None, }; - let discriminator_len = if program_id == crate::RAYDIUM_AMM_V4_PROGRAM_ID { + let discriminator_len = if program_id == crate::RAYDIUM_AMM_V4_PROGRAM_ID + || program_id == crate::RAYDIUM_STABLE_SWAP_AMM_PROGRAM_ID + { 1_usize } else { 8_usize diff --git a/kb_lib/src/lib.rs b/kb_lib/src/lib.rs index f215b9d..ce2ee77 100644 --- a/kb_lib/src/lib.rs +++ b/kb_lib/src/lib.rs @@ -505,6 +505,8 @@ pub use db::FeeEventEntity; pub use db::InstructionObservationDto; /// Persisted technical observation for one Solana instruction. pub use db::InstructionObservationEntity; +/// Raw source row used to rebuild the technical instruction-observation index. +pub use db::InstructionObservationSourceRow; /// Application-facing known HTTP endpoint DTO. pub use db::KnownHttpEndpointDto; /// Application-facing known WebSocket endpoint DTO. @@ -517,6 +519,8 @@ pub use db::KnownWsEndpointEntity; pub use db::LaunchAttributionDto; /// Persisted launch attribution row. pub use db::LaunchAttributionEntity; +/// Input used to upsert one launch event row. +pub use db::LaunchEventUpsertInput; /// Application-facing launch surface DTO. pub use db::LaunchSurfaceDto; /// Persisted launch surface row. @@ -762,12 +766,12 @@ pub use db::query_dex_decoded_events_delete_local_replay_scope_by_transaction_id pub use db::query_dex_decoded_events_delete_locally_covered_upstream_instruction_matches; /// Deletes Meteora DLMM Anchor self-CPI swap audit rows already covered by decoded swaps. pub use db::query_dex_decoded_events_delete_meteora_dlmm_anchor_swap_instruction_audits; -/// Deletes decoded DEX instruction audit rows related to one decoded instruction. -pub use db::query_dex_decoded_events_delete_related_instruction_audit; /// Deletes one Raydium CLMM instruction-audit row by discriminator. pub use db::query_dex_decoded_events_delete_raydium_clmm_instruction_audit_by_discriminator; /// Deletes one Raydium Launchpad self-CPI audit row by discriminator. pub use db::query_dex_decoded_events_delete_raydium_launchpad_anchor_self_cpi_audit; +/// Deletes decoded DEX instruction audit rows related to one decoded instruction. +pub use db::query_dex_decoded_events_delete_related_instruction_audit; /// Deletes Raydium CLMM instruction-audit rows for locally mapped CLMM instructions. pub use db::query_dex_decoded_events_delete_replaced_raydium_clmm_instruction_audits; /// Deletes Raydium CPMM instruction-audit rows already covered by local named rows. @@ -778,10 +782,10 @@ pub use db::query_dex_decoded_events_get_by_key; pub use db::query_dex_decoded_events_get_latest_pump_fun_create_payload_by_mint; /// Lists decoded DEX events for one transaction. pub use db::query_dex_decoded_events_list_by_transaction_id; -/// Inserts or updates one decoded DEX event row. -pub use db::query_dex_decoded_events_upsert; /// Updates the persisted payload of one decoded DEX event row. pub use db::query_dex_decoded_events_update_payload_json_by_id; +/// Inserts or updates one decoded DEX event row. +pub use db::query_dex_decoded_events_upsert; /// Deletes DEX event coverage entries for one decoder. pub use db::query_dex_event_coverage_entries_delete_by_decoder; /// Lists DEX event coverage entries for one decoder. @@ -819,8 +823,6 @@ pub use db::query_instruction_observations_delete_by_transaction_ids; pub use db::query_instruction_observations_list_by_filter; /// Upserts one instruction observation row. pub use db::query_instruction_observations_upsert; -/// Raw source row used to rebuild the technical instruction-observation index. -pub use db::InstructionObservationSourceRow; /// Reads one known HTTP endpoint by name. pub use db::query_known_http_endpoints_get; /// Lists all known HTTP endpoints. @@ -841,8 +843,6 @@ pub use db::query_launch_attributions_list_by_pool_id; pub use db::query_launch_attributions_upsert; /// Inserts or updates one launch event row. pub use db::query_launch_events_upsert; -/// Input used to upsert one launch event row. -pub use db::LaunchEventUpsertInput; /// Returns one launch-surface matching key identified by its kind and value, if it exists. pub use db::query_launch_surface_keys_get_by_match; /// Lists all launch-surface matching keys attached to one launch surface id. @@ -1221,8 +1221,18 @@ pub use dex::RaydiumCpmmSwapDecoded; pub use dex::RaydiumCpmmSwapEventDecoded; /// Raydium CPMM swap mode. pub use dex::RaydiumCpmmSwapMode; +/// Raydium Stable Swap decoded event. +pub use dex::RaydiumStableSwapDecodedEvent; +/// Raydium Stable Swap decoder. +pub use dex::RaydiumStableSwapDecoder; +/// Decoded Raydium Stable Swap instruction. +pub use dex::RaydiumStableSwapInstructionDecoded; +/// Decoded Raydium Stable Swap program-data swap event. +pub use dex::RaydiumStableSwapSwapEventDecoded; /// Decodes one Raydium CPMM instruction from projected instruction fields. pub use dex::classify_raydium_cpmm_instruction_data; +/// Classifies one Raydium Stable Swap instruction data payload. +pub use dex::classify_raydium_stable_swap_instruction_data; /// Decodes a Raydium CLMM instruction. pub use dex::decode_raydium_clmm_instruction; /// Decodes one Raydium CLMM Anchor Program data event. @@ -1231,6 +1241,8 @@ pub use dex::decode_raydium_clmm_program_data_event; pub use dex::decode_raydium_cpmm_instruction; /// Decodes Raydium CPMM Anchor events emitted in `Program data:` logs. pub use dex::decode_raydium_cpmm_program_data_event; +/// Decodes Raydium Stable Swap `Program data:` event payloads. +pub use dex::decode_raydium_stable_swap_program_data_event; /// DEX decode service. pub use dex_decode::DexDecodeService; /// Business-level DEX detection service. diff --git a/kb_lib/src/trade_amount_resolution.rs b/kb_lib/src/trade_amount_resolution.rs index f16e5e9..5481654 100644 --- a/kb_lib/src/trade_amount_resolution.rs +++ b/kb_lib/src/trade_amount_resolution.rs @@ -98,6 +98,7 @@ pub(crate) async fn resolve_trade_amounts( } if (input.decoded_event.event_kind.starts_with("raydium_amm_v4.") || input.decoded_event.event_kind.starts_with("raydium_cpmm.") + || input.decoded_event.event_kind.starts_with("raydium_stable_swap.") || input.decoded_event.event_kind.starts_with("raydium_clmm.")) && (base_amount_raw.is_none() || quote_amount_raw.is_none() @@ -183,6 +184,21 @@ pub(crate) async fn resolve_trade_amounts( return Err(error); } } + if input.decoded_event.event_kind.starts_with("raydium_stable_swap.") + && (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_clmm.") && (base_amount_raw.is_none() || quote_amount_raw.is_none()) { diff --git a/kb_lib/src/upstream_registry_generated.rs b/kb_lib/src/upstream_registry_generated.rs index 9237558..67c324d 100644 --- a/kb_lib/src/upstream_registry_generated.rs +++ b/kb_lib/src/upstream_registry_generated.rs @@ -12836,6 +12836,28 @@ pub(crate) const UPSTREAM_REGISTRY_ENTRIES: &[crate::UpstreamRegistryEntry] = &[ 8, "decoders/raydium-liquidity-locking-decoder/src/instructions/settle_cp_fee_event.rs", ), + manual_solscan_discriminator_entry( + "raydium_stable_swap", + Some(crate::RAYDIUM_STABLE_SWAP_AMM_PROGRAM_ID), + "raydium", + "stable_swap", + crate::ENTRY_KIND_INSTRUCTION, + "init_model_data", + "01", + 1, + "docs.raydium.io/products/stable/instructions#initmodeldata-local-corpus-observed", + ), + manual_solscan_discriminator_entry( + "raydium_stable_swap", + Some(crate::RAYDIUM_STABLE_SWAP_AMM_PROGRAM_ID), + "raydium", + "stable_swap", + crate::ENTRY_KIND_INSTRUCTION, + "update_model_data", + "02", + 1, + "docs.raydium.io/products/stable/instructions#updatemodeldata-local-corpus-observed", + ), upstream_git_discriminator_entry( "raydium_stable_swap", Some(crate::RAYDIUM_STABLE_SWAP_AMM_PROGRAM_ID), @@ -12869,6 +12891,50 @@ pub(crate) const UPSTREAM_REGISTRY_ENTRIES: &[crate::UpstreamRegistryEntry] = &[ 1, "decoders/raydium-stable-swap-decoder/src/instructions/pre_initialize.rs", ), + manual_solscan_discriminator_entry( + "raydium_stable_swap", + Some(crate::RAYDIUM_STABLE_SWAP_AMM_PROGRAM_ID), + "raydium", + "stable_swap", + crate::ENTRY_KIND_INSTRUCTION, + "monitor_step", + "05", + 1, + "docs.raydium.io/products/stable/instructions#monitorstep-local-corpus-observed", + ), + manual_solscan_discriminator_entry( + "raydium_stable_swap", + Some(crate::RAYDIUM_STABLE_SWAP_AMM_PROGRAM_ID), + "raydium", + "stable_swap", + crate::ENTRY_KIND_INSTRUCTION, + "set_params", + "06", + 1, + "docs.raydium.io/products/stable/instructions#setparams-local-corpus-observed", + ), + manual_solscan_discriminator_entry( + "raydium_stable_swap", + Some(crate::RAYDIUM_STABLE_SWAP_AMM_PROGRAM_ID), + "raydium", + "stable_swap", + crate::ENTRY_KIND_INSTRUCTION, + "withdraw_pnl", + "07", + 1, + "docs.raydium.io/products/stable/instructions#withdrawpnl-local-corpus-observed", + ), + manual_solscan_discriminator_entry( + "raydium_stable_swap", + Some(crate::RAYDIUM_STABLE_SWAP_AMM_PROGRAM_ID), + "raydium", + "stable_swap", + crate::ENTRY_KIND_INSTRUCTION, + "withdraw_srm", + "08", + 1, + "docs.raydium.io/products/stable/instructions#withdrawsrm-local-corpus-observed", + ), upstream_git_discriminator_entry( "raydium_stable_swap", Some(crate::RAYDIUM_STABLE_SWAP_AMM_PROGRAM_ID), @@ -12902,6 +12968,39 @@ pub(crate) const UPSTREAM_REGISTRY_ENTRIES: &[crate::UpstreamRegistryEntry] = &[ 1, "decoders/raydium-stable-swap-decoder/src/instructions/withdraw.rs", ), + manual_solscan_discriminator_entry( + "raydium_stable_swap", + Some(crate::RAYDIUM_STABLE_SWAP_AMM_PROGRAM_ID), + "raydium", + "stable_swap", + crate::ENTRY_KIND_INSTRUCTION, + "simulate_info", + "0c", + 1, + "docs.raydium.io/products/stable/instructions#simulateinfo-local-corpus-observed", + ), + manual_solscan_discriminator_entry( + "raydium_stable_swap", + Some(crate::RAYDIUM_STABLE_SWAP_AMM_PROGRAM_ID), + "raydium", + "stable_swap", + crate::ENTRY_KIND_INSTRUCTION, + "admin_cancel_orders", + "0d", + 1, + "raydium-amm/program/src/instruction.rs#admincancelorders-stable-local-corpus-observed", + ), + manual_solscan_discriminator_entry( + "raydium_stable_swap", + Some(crate::RAYDIUM_STABLE_SWAP_AMM_PROGRAM_ID), + "raydium", + "stable_swap", + crate::ENTRY_KIND_EVENT, + "swap_event", + "40c6cde8260871e2", + 8, + "docs.raydium.io/products/stable/instructions#program-data-swap-event-decoded-only", + ), upstream_git_discriminator_entry( "stabble_stable_swap", Some(crate::STABBLE_STABLE_SWAP_PROGRAM_ID), diff --git a/validation_sql/SQL_VALIDATION_RAYDIUM_STABLE_SWAP_0_7_52.sql b/validation_sql/SQL_VALIDATION_RAYDIUM_STABLE_SWAP_0_7_52.sql new file mode 100644 index 0000000..43bc75b --- /dev/null +++ b/validation_sql/SQL_VALIDATION_RAYDIUM_STABLE_SWAP_0_7_52.sql @@ -0,0 +1,292 @@ +-- file: validation_sql/SQL_VALIDATION_RAYDIUM_STABLE_SWAP_0_7_52.sql + +-- 0.7.52 raydium_stable_swap validation checklist. +-- Run on a dedicated fresh SQLite database after corpus construction and replay with: +-- skipDexDecode=no, forceDexDecode=yes, deferInstructionObservations=yes. + +-- 01. Coverage stable swap. +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 = 'raydium_stable_swap' +ORDER BY entry_kind, entry_name, discriminator_hex; + +-- 02. Instruction observations. +SELECT + instruction_name, + discriminator_hex, + COUNT(*) AS observed_count, + COUNT(DISTINCT signature) AS tx_count +FROM k_sol_instruction_observations +WHERE decoder_code = 'raydium_stable_swap' +GROUP BY instruction_name, discriminator_hex +ORDER BY observed_count DESC, instruction_name, discriminator_hex; + +-- 03. Residual local instruction audit. +SELECT + json_extract(payload_json, '$.discriminatorHex') AS discriminator_hex, + COUNT(*) AS audit_count, + COUNT(DISTINCT transaction_id) AS tx_count +FROM k_sol_dex_decoded_events +WHERE protocol_name = 'raydium_stable_swap' + AND event_kind = 'raydium_stable_swap.instruction_audit' +GROUP BY discriminator_hex +ORDER BY audit_count DESC, discriminator_hex; + +-- 04. Residual upstream fallback for covered local entries. +SELECT + json_extract(ug.payload_json, '$.upstreamDecoderCode') AS upstream_decoder_code, + json_extract(ug.payload_json, '$.upstreamEntryName') AS entry_name, + json_extract(ug.payload_json, '$.upstreamDiscriminatorHex') AS discriminator_hex, + json_extract(ug.payload_json, '$.upstreamSourceRepo') AS source_repo, + COUNT(*) AS fallback_count, + COUNT(DISTINCT ug.transaction_id) AS tx_count +FROM k_sol_dex_decoded_events ug +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 ce.discriminator_hex = 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') = 'raydium_stable_swap' +GROUP BY upstream_decoder_code, entry_name, discriminator_hex, source_repo +ORDER BY fallback_count DESC, entry_name; + +-- 05. Non-swap safety: non-swap event must not materialize as trade. +SELECT + de.event_kind, + ce.event_family, + COUNT(*) AS decoded_count, + COUNT(te.id) AS trade_count +FROM k_sol_dex_decoded_events de +LEFT JOIN k_sol_dex_event_coverage_entries ce + ON ce.decoder_code = 'raydium_stable_swap' + AND ce.local_event_kind = de.event_kind +LEFT JOIN k_sol_trade_events te + ON te.decoded_event_id = de.id +WHERE de.protocol_name = 'raydium_stable_swap' +GROUP BY de.event_kind, ce.event_family +HAVING ce.event_family <> 'swap' + AND COUNT(te.id) > 0 +ORDER BY trade_count DESC, de.event_kind; + +-- 06. Failed transaction safety: failed tx must not materialize as business trade. +SELECT + de.event_kind, + COUNT(*) AS decoded_failed_count, + COUNT(te.id) AS trade_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 +WHERE de.protocol_name = 'raydium_stable_swap' + AND tx.err_json IS NOT NULL + AND tx.err_json <> '' + AND tx.err_json <> 'null' +GROUP BY de.event_kind +HAVING COUNT(te.id) > 0 +ORDER BY trade_count DESC, de.event_kind; + +-- 07. Decoded without coverage entry. +SELECT + de.event_kind, + COUNT(*) AS decoded_count +FROM k_sol_dex_decoded_events de +LEFT JOIN k_sol_dex_event_coverage_entries ce + ON ce.decoder_code = 'raydium_stable_swap' + AND ce.local_event_kind = de.event_kind +WHERE de.protocol_name = 'raydium_stable_swap' + AND ce.id IS NULL +GROUP BY de.event_kind +ORDER BY decoded_count DESC, de.event_kind; + +-- 08. Multi-target materialization. +SELECT + de.event_kind, + COUNT(DISTINCT de.id) AS decoded_count, + COUNT(DISTINCT te.id) AS trade_count, + COUNT(DISTINCT le.id) AS liquidity_count, + COUNT(DISTINCT pe.id) AS lifecycle_count, + COUNT(DISTINCT fe.id) AS fee_count, + COUNT(DISTINCT ae.id) AS admin_count, + COUNT(DISTINCT oe.id) AS orderbook_count, + ( + CASE WHEN COUNT(DISTINCT te.id) > 0 THEN 1 ELSE 0 END + + CASE WHEN COUNT(DISTINCT le.id) > 0 THEN 1 ELSE 0 END + + CASE WHEN COUNT(DISTINCT pe.id) > 0 THEN 1 ELSE 0 END + + CASE WHEN COUNT(DISTINCT fe.id) > 0 THEN 1 ELSE 0 END + + CASE WHEN COUNT(DISTINCT ae.id) > 0 THEN 1 ELSE 0 END + + CASE WHEN COUNT(DISTINCT oe.id) > 0 THEN 1 ELSE 0 END + ) AS materialized_target_count +FROM k_sol_dex_decoded_events de +LEFT JOIN k_sol_trade_events te + ON te.decoded_event_id = de.id +LEFT JOIN k_sol_liquidity_events le + ON le.decoded_event_id = de.id +LEFT JOIN k_sol_pool_lifecycle_events pe + ON pe.decoded_event_id = de.id +LEFT JOIN k_sol_fee_events fe + ON fe.decoded_event_id = de.id +LEFT JOIN k_sol_pool_admin_events ae + ON ae.decoded_event_id = de.id +LEFT JOIN k_sol_orderbook_events oe + ON oe.decoded_event_id = de.id +WHERE de.protocol_name = 'raydium_stable_swap' +GROUP BY de.event_kind +HAVING materialized_target_count > 1 +ORDER BY materialized_target_count DESC, de.event_kind; + +-- 09. Unexplained successful non-materialized events. +SELECT + de.event_kind, + COUNT(*) AS unexplained_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 le + ON le.decoded_event_id = de.id +LEFT JOIN k_sol_pool_lifecycle_events pe + ON pe.decoded_event_id = de.id +LEFT JOIN k_sol_fee_events fe + ON fe.decoded_event_id = de.id +LEFT JOIN k_sol_pool_admin_events ae + ON ae.decoded_event_id = de.id +LEFT JOIN k_sol_orderbook_events oe + ON oe.decoded_event_id = de.id +LEFT JOIN k_sol_token_account_events tae + ON tae.decoded_event_id = de.id +WHERE de.protocol_name = 'raydium_stable_swap' + AND ( + tx.err_json IS NULL + OR tx.err_json = '' + OR tx.err_json = 'null' + ) + AND te.id IS NULL + AND le.id IS NULL + AND pe.id IS NULL + AND fe.id IS NULL + AND ae.id IS NULL + AND oe.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, '$.skipLiquidityReason')), '') = '' + AND COALESCE(TRIM(json_extract(de.payload_json, '$.skipLifecycleReason')), '') = '' + AND COALESCE(TRIM(json_extract(de.payload_json, '$.skipCatalogReason')), '') = '' +GROUP BY de.event_kind +ORDER BY unexplained_count DESC, de.event_kind; + +-- 10. Materialization summary. +SELECT + de.event_kind, + COUNT(DISTINCT de.id) AS decoded_count, + COUNT(DISTINCT te.id) AS trade_count, + COUNT(DISTINCT le.id) AS liquidity_count, + COUNT(DISTINCT pe.id) AS lifecycle_count, + COUNT(DISTINCT fe.id) AS fee_count, + COUNT(DISTINCT ae.id) AS admin_count, + COUNT(DISTINCT oe.id) AS orderbook_count +FROM k_sol_dex_decoded_events de +LEFT JOIN k_sol_trade_events te + ON te.decoded_event_id = de.id +LEFT JOIN k_sol_liquidity_events le + ON le.decoded_event_id = de.id +LEFT JOIN k_sol_pool_lifecycle_events pe + ON pe.decoded_event_id = de.id +LEFT JOIN k_sol_fee_events fe + ON fe.decoded_event_id = de.id +LEFT JOIN k_sol_pool_admin_events ae + ON ae.decoded_event_id = de.id +LEFT JOIN k_sol_orderbook_events oe + ON oe.decoded_event_id = de.id +WHERE de.protocol_name = 'raydium_stable_swap' +GROUP BY de.event_kind +ORDER BY de.event_kind; + + +-- 11. Stable Swap swap amount-source closure. +-- Successful swaps must materialize from exact vault deltas. +-- Failed swaps may remain instruction-bounds-only and must not materialize as trades. +SELECT + de.event_kind, + json_extract(de.payload_json, '$.amountSource') AS amount_source, + CASE + WHEN tx.err_json IS NOT NULL + AND tx.err_json <> '' + AND tx.err_json <> 'null' + THEN 'failed' + ELSE 'success' + END AS tx_status, + COUNT(*) AS decoded_count, + COUNT(te.id) AS trade_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 +WHERE de.protocol_name = 'raydium_stable_swap' + AND de.event_kind IN ( + 'raydium_stable_swap.swap_base_in', + 'raydium_stable_swap.swap_base_out' + ) +GROUP BY de.event_kind, amount_source, tx_status +ORDER BY de.event_kind, amount_source, tx_status; + +-- 12. Stable Swap successful swap without trade and without explanation. +-- Expected result: empty. +SELECT + de.event_kind, + COUNT(*) AS unexplained_success_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 +WHERE de.protocol_name = 'raydium_stable_swap' + AND de.event_kind IN ( + 'raydium_stable_swap.swap_base_in', + 'raydium_stable_swap.swap_base_out' + ) + AND ( + tx.err_json IS NULL + OR tx.err_json = '' + OR tx.err_json = 'null' + ) + AND te.id IS NULL + AND COALESCE(TRIM(json_extract(de.payload_json, '$.skipTradeReason')), '') = '' +GROUP BY de.event_kind; + +-- 13. Stable Swap failed swap safety. +-- Expected result: empty. +SELECT + de.event_kind, + json_extract(de.payload_json, '$.amountSource') AS amount_source, + COUNT(*) AS failed_trade_count +FROM k_sol_dex_decoded_events de +JOIN k_sol_chain_transactions tx + ON tx.id = de.transaction_id +JOIN k_sol_trade_events te + ON te.decoded_event_id = de.id +WHERE de.protocol_name = 'raydium_stable_swap' + AND de.event_kind IN ( + 'raydium_stable_swap.swap_base_in', + 'raydium_stable_swap.swap_base_out' + ) + AND tx.err_json IS NOT NULL + AND tx.err_json <> '' + AND tx.err_json <> 'null' +GROUP BY de.event_kind, amount_source +ORDER BY de.event_kind, amount_source;