1704 lines
62 KiB
Rust
1704 lines
62 KiB
Rust
// file: kb_lib/src/trade_amount_resolution.rs
|
|
|
|
//! Trade amount resolution orchestration.
|
|
//!
|
|
//! This module resolves base/quote raw amounts and quote/base price for one
|
|
//! decoded trade candidate by applying protocol-specific and generic fallback
|
|
//! strategies in deterministic order.
|
|
|
|
/// Input context required to resolve trade amounts.
|
|
pub(crate) struct TradeAmountResolutionInput<'a> {
|
|
/// Database connection.
|
|
pub(crate) database: &'a crate::Database,
|
|
/// Persisted transaction row.
|
|
pub(crate) transaction: &'a crate::ChainTransactionDto,
|
|
/// Decoded DEX event row.
|
|
pub(crate) decoded_event: &'a crate::DexDecodedEventDto,
|
|
/// Decoded event payload.
|
|
pub(crate) payload: &'a serde_json::Value,
|
|
/// Pool account address.
|
|
pub(crate) pool_address: &'a str,
|
|
/// Base token mint, when known.
|
|
pub(crate) base_token_mint: std::option::Option<&'a str>,
|
|
/// Quote token mint, when known.
|
|
pub(crate) quote_token_mint: std::option::Option<&'a str>,
|
|
/// Base token decimals, when known.
|
|
pub(crate) base_token_decimals: std::option::Option<u8>,
|
|
/// Quote token decimals, when known.
|
|
pub(crate) quote_token_decimals: std::option::Option<u8>,
|
|
/// Base token vault address, when known.
|
|
pub(crate) base_vault_address: std::option::Option<&'a str>,
|
|
/// Quote token vault address, when known.
|
|
pub(crate) quote_vault_address: std::option::Option<&'a str>,
|
|
}
|
|
|
|
/// Resolved raw trade amounts and quote/base price.
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct TradeAmountResolution {
|
|
/// Base amount in raw token units.
|
|
pub(crate) base_amount_raw: std::option::Option<std::string::String>,
|
|
/// Quote amount in raw token units.
|
|
pub(crate) quote_amount_raw: std::option::Option<std::string::String>,
|
|
/// Quote/base price.
|
|
pub(crate) price_quote_per_base: std::option::Option<f64>,
|
|
/// Trade side resolved from balance deltas, when available.
|
|
pub(crate) resolved_trade_side: std::option::Option<crate::SwapTradeSide>,
|
|
}
|
|
|
|
/// Resolves trade amounts from payload and protocol-specific fallbacks.
|
|
pub(crate) async fn resolve_trade_amounts(
|
|
input: &crate::trade_amount_resolution::TradeAmountResolutionInput<'_>,
|
|
) -> Result<crate::trade_amount_resolution::TradeAmountResolution, crate::Error> {
|
|
let mut base_amount_raw = crate::trade_amount_resolution::extract_amount_string(
|
|
input.payload,
|
|
&["baseAmountRaw", "base_amount_raw", "baseAmount", "amountBase", "amountInBase"],
|
|
);
|
|
let mut quote_amount_raw = crate::trade_amount_resolution::extract_amount_string(
|
|
input.payload,
|
|
&[
|
|
"quoteAmountRaw",
|
|
"quote_amount_raw",
|
|
"quoteAmount",
|
|
"amountQuote",
|
|
"amountOutQuote",
|
|
],
|
|
);
|
|
let mut price_quote_per_base = None;
|
|
let mut resolved_trade_side = None;
|
|
if input.decoded_event.event_kind.starts_with("pump_swap.")
|
|
&& (base_amount_raw.is_none()
|
|
|| quote_amount_raw.is_none()
|
|
|| price_quote_per_base.is_none())
|
|
{
|
|
let resolution_result = crate::trade_amount_resolution::apply_pump_swap_amount_fallbacks(
|
|
input,
|
|
&mut base_amount_raw,
|
|
&mut quote_amount_raw,
|
|
&mut price_quote_per_base,
|
|
)
|
|
.await;
|
|
if let Err(error) = resolution_result {
|
|
return Err(error);
|
|
}
|
|
}
|
|
if input.decoded_event.event_kind.starts_with("pump_fun.")
|
|
&& (base_amount_raw.is_none()
|
|
|| quote_amount_raw.is_none()
|
|
|| price_quote_per_base.is_none())
|
|
{
|
|
let resolution_result = crate::trade_amount_resolution::apply_pump_fun_amount_fallback(
|
|
input,
|
|
&mut base_amount_raw,
|
|
&mut quote_amount_raw,
|
|
&mut price_quote_per_base,
|
|
);
|
|
if let Err(error) = resolution_result {
|
|
return Err(error);
|
|
}
|
|
}
|
|
if (input.decoded_event.event_kind.starts_with("raydium_amm_v4.")
|
|
|| input.decoded_event.event_kind.starts_with("raydium_cpmm.")
|
|
|| input.decoded_event.event_kind.starts_with("raydium_clmm."))
|
|
&& (base_amount_raw.is_none()
|
|
|| quote_amount_raw.is_none()
|
|
|| price_quote_per_base.is_none())
|
|
{
|
|
let resolution_result =
|
|
crate::trade_amount_resolution::apply_raydium_instruction_amount_fallback(
|
|
input,
|
|
&mut base_amount_raw,
|
|
&mut quote_amount_raw,
|
|
&mut price_quote_per_base,
|
|
)
|
|
.await;
|
|
if let Err(error) = resolution_result {
|
|
return Err(error);
|
|
}
|
|
}
|
|
if input.decoded_event.event_kind.starts_with("raydium_amm_v4.")
|
|
&& (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_cpmm.")
|
|
&& (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())
|
|
{
|
|
let resolution_result = crate::trade_amount_resolution::apply_vault_balance_delta_fallback(
|
|
input,
|
|
input.base_vault_address,
|
|
input.quote_vault_address,
|
|
&mut base_amount_raw,
|
|
&mut quote_amount_raw,
|
|
&mut price_quote_per_base,
|
|
);
|
|
if let Err(error) = resolution_result {
|
|
return Err(error);
|
|
}
|
|
}
|
|
if input.decoded_event.event_kind.starts_with("meteora_dlmm.")
|
|
&& (base_amount_raw.is_none() || quote_amount_raw.is_none())
|
|
{
|
|
let resolution_result =
|
|
crate::trade_amount_resolution::apply_meteora_dlmm_flattened_cpi_amount_fallback(
|
|
input,
|
|
&mut base_amount_raw,
|
|
&mut quote_amount_raw,
|
|
&mut resolved_trade_side,
|
|
)
|
|
.await;
|
|
if let Err(error) = resolution_result {
|
|
return Err(error);
|
|
}
|
|
}
|
|
if input.decoded_event.event_kind.starts_with("meteora_damm_v1.")
|
|
&& (base_amount_raw.is_none() || quote_amount_raw.is_none())
|
|
{
|
|
let resolution_result =
|
|
crate::trade_amount_resolution::apply_meteora_damm_v1_flattened_cpi_amount_fallback(
|
|
input,
|
|
&mut base_amount_raw,
|
|
&mut quote_amount_raw,
|
|
&mut resolved_trade_side,
|
|
)
|
|
.await;
|
|
if let Err(error) = resolution_result {
|
|
return Err(error);
|
|
}
|
|
}
|
|
if input.decoded_event.event_kind.starts_with("meteora_dlmm.")
|
|
&& (base_amount_raw.is_none() || quote_amount_raw.is_none())
|
|
{
|
|
let resolution_result = crate::trade_amount_resolution::apply_vault_balance_delta_fallback(
|
|
input,
|
|
input.base_vault_address,
|
|
input.quote_vault_address,
|
|
&mut base_amount_raw,
|
|
&mut quote_amount_raw,
|
|
&mut price_quote_per_base,
|
|
);
|
|
if let Err(error) = resolution_result {
|
|
return Err(error);
|
|
}
|
|
}
|
|
if input.decoded_event.event_kind.starts_with("meteora_damm_v1.")
|
|
&& (base_amount_raw.is_none() || quote_amount_raw.is_none())
|
|
{
|
|
let resolution_result = crate::trade_amount_resolution::apply_vault_balance_delta_fallback(
|
|
input,
|
|
input.base_vault_address,
|
|
input.quote_vault_address,
|
|
&mut base_amount_raw,
|
|
&mut quote_amount_raw,
|
|
&mut price_quote_per_base,
|
|
);
|
|
if let Err(error) = resolution_result {
|
|
return Err(error);
|
|
}
|
|
}
|
|
if input.decoded_event.event_kind.starts_with("raydium_amm_v4.") {
|
|
let vault_side = crate::trade_amount_resolution::infer_trade_side_from_vault_balance_deltas(
|
|
input.transaction.meta_json.as_deref(),
|
|
input.transaction.transaction_json.as_str(),
|
|
input.base_vault_address,
|
|
input.quote_vault_address,
|
|
);
|
|
if vault_side.is_some() {
|
|
resolved_trade_side = vault_side;
|
|
}
|
|
}
|
|
if input.decoded_event.event_kind.starts_with("meteora_dlmm.") {
|
|
let vault_side = crate::trade_amount_resolution::infer_trade_side_from_vault_balance_deltas(
|
|
input.transaction.meta_json.as_deref(),
|
|
input.transaction.transaction_json.as_str(),
|
|
input.base_vault_address,
|
|
input.quote_vault_address,
|
|
);
|
|
if vault_side.is_some() {
|
|
resolved_trade_side = vault_side;
|
|
}
|
|
}
|
|
if input.decoded_event.event_kind.starts_with("meteora_damm_v1.") {
|
|
let vault_side = crate::trade_amount_resolution::infer_trade_side_from_vault_balance_deltas(
|
|
input.transaction.meta_json.as_deref(),
|
|
input.transaction.transaction_json.as_str(),
|
|
input.base_vault_address,
|
|
input.quote_vault_address,
|
|
);
|
|
if vault_side.is_some() {
|
|
resolved_trade_side = vault_side;
|
|
}
|
|
}
|
|
if price_quote_per_base.is_none() {
|
|
price_quote_per_base =
|
|
crate::trade_metric_update::compute_price_quote_per_base_from_raw_amounts_with_decimals(
|
|
base_amount_raw.as_deref(),
|
|
quote_amount_raw.as_deref(),
|
|
input.base_token_decimals,
|
|
input.quote_token_decimals,
|
|
);
|
|
}
|
|
if price_quote_per_base.is_none() {
|
|
price_quote_per_base =
|
|
crate::trade_solana_amounts::compute_price_quote_per_base_with_decimals(
|
|
input.transaction.meta_json.as_deref(),
|
|
input.transaction.transaction_json.as_str(),
|
|
input.base_vault_address,
|
|
input.quote_vault_address,
|
|
);
|
|
}
|
|
if price_quote_per_base.is_none() {
|
|
price_quote_per_base =
|
|
crate::trade_metric_update::compute_price_quote_per_base_from_raw_amounts(
|
|
base_amount_raw.as_deref(),
|
|
quote_amount_raw.as_deref(),
|
|
);
|
|
}
|
|
return Ok(crate::trade_amount_resolution::TradeAmountResolution {
|
|
base_amount_raw,
|
|
quote_amount_raw,
|
|
price_quote_per_base,
|
|
resolved_trade_side,
|
|
});
|
|
}
|
|
|
|
async fn apply_pump_swap_amount_fallbacks(
|
|
input: &crate::trade_amount_resolution::TradeAmountResolutionInput<'_>,
|
|
base_amount_raw: &mut std::option::Option<std::string::String>,
|
|
quote_amount_raw: &mut std::option::Option<std::string::String>,
|
|
price_quote_per_base: &mut std::option::Option<f64>,
|
|
) -> Result<(), crate::Error> {
|
|
let pool_owner_result = match (input.base_token_mint, input.quote_token_mint) {
|
|
(Some(base_mint), Some(quote_mint)) => {
|
|
crate::trade_pump_swap_amounts::resolve_pump_swap_trade_amounts_from_pool_balance_deltas(
|
|
input.transaction.meta_json.as_deref(),
|
|
input.pool_address,
|
|
base_mint,
|
|
quote_mint,
|
|
input.decoded_event.event_kind.as_str(),
|
|
input.base_token_decimals,
|
|
input.quote_token_decimals,
|
|
)
|
|
},
|
|
_ => Ok(crate::trade_pump_swap_amounts::PumpSwapPoolBalanceDeltaResolution::MissingData),
|
|
};
|
|
let pool_owner_resolution = match pool_owner_result {
|
|
Ok(pool_owner_resolution) => pool_owner_resolution,
|
|
Err(error) => return Err(error),
|
|
};
|
|
let pool_owner_resolution_label = pool_owner_resolution.as_label();
|
|
tracing::debug!(
|
|
event_kind = %input.decoded_event.event_kind,
|
|
pool_account = ?input.decoded_event.pool_account,
|
|
decoded_event_id = ?input.decoded_event.id,
|
|
transaction_signature = %input.transaction.signature,
|
|
base_mint = ?input.base_token_mint,
|
|
quote_mint = ?input.quote_token_mint,
|
|
pool_owner_resolution = %pool_owner_resolution_label,
|
|
"pump_swap pool-owner delta resolution result"
|
|
);
|
|
match pool_owner_resolution {
|
|
crate::trade_pump_swap_amounts::PumpSwapPoolBalanceDeltaResolution::Matched(amounts) => {
|
|
*base_amount_raw = Some(amounts.base_amount_raw);
|
|
*quote_amount_raw = Some(amounts.quote_amount_raw);
|
|
*price_quote_per_base = Some(amounts.price_quote_per_base);
|
|
tracing::debug!(
|
|
event_kind = %input.decoded_event.event_kind,
|
|
pool_account = ?input.decoded_event.pool_account,
|
|
decoded_event_id = ?input.decoded_event.id,
|
|
base_mint = ?input.base_token_mint,
|
|
quote_mint = ?input.quote_token_mint,
|
|
base_amount_raw = ?base_amount_raw,
|
|
quote_amount_raw = ?quote_amount_raw,
|
|
price_quote_per_base = ?price_quote_per_base,
|
|
"pump_swap trade amounts recovered from pool-owner token balance deltas"
|
|
);
|
|
},
|
|
crate::trade_pump_swap_amounts::PumpSwapPoolBalanceDeltaResolution::DirectionMismatch => {
|
|
tracing::debug!(
|
|
event_kind = %input.decoded_event.event_kind,
|
|
pool_account = ?input.decoded_event.pool_account,
|
|
decoded_event_id = ?input.decoded_event.id,
|
|
transaction_signature = %input.transaction.signature,
|
|
"pump_swap pool-owner full-transaction delta direction mismatch; continuing with instruction-scoped fallbacks"
|
|
);
|
|
},
|
|
crate::trade_pump_swap_amounts::PumpSwapPoolBalanceDeltaResolution::MissingData => {},
|
|
}
|
|
let decoded_instruction_index_result =
|
|
crate::trade_amount_resolution::load_decoded_instruction_index(
|
|
input.database,
|
|
input.decoded_event,
|
|
)
|
|
.await;
|
|
let decoded_instruction_index = match decoded_instruction_index_result {
|
|
Ok(decoded_instruction_index) => decoded_instruction_index,
|
|
Err(error) => return Err(error),
|
|
};
|
|
let payload_user_base_token_account =
|
|
crate::trade_amount_resolution::extract_string_by_candidate_keys(
|
|
input.payload,
|
|
&["userBaseTokenAccount", "user_base_token_account"],
|
|
);
|
|
let payload_user_quote_token_account =
|
|
crate::trade_amount_resolution::extract_string_by_candidate_keys(
|
|
input.payload,
|
|
&["userQuoteTokenAccount", "user_quote_token_account"],
|
|
);
|
|
let payload_pool_base_token_account =
|
|
crate::trade_amount_resolution::extract_string_by_candidate_keys(
|
|
input.payload,
|
|
&["poolBaseTokenAccount", "pool_base_token_account"],
|
|
);
|
|
let payload_pool_quote_token_account =
|
|
crate::trade_amount_resolution::extract_string_by_candidate_keys(
|
|
input.payload,
|
|
&["poolQuoteTokenAccount", "pool_quote_token_account"],
|
|
);
|
|
let effective_base_vault_address = match input.base_vault_address {
|
|
Some(base_vault_address) => Some(base_vault_address),
|
|
None => payload_pool_base_token_account.as_deref(),
|
|
};
|
|
let effective_quote_vault_address = match input.quote_vault_address {
|
|
Some(quote_vault_address) => Some(quote_vault_address),
|
|
None => payload_pool_quote_token_account.as_deref(),
|
|
};
|
|
let (input_vault_address, output_vault_address, input_token_account, output_token_account) =
|
|
if input.decoded_event.event_kind.ends_with(".buy") {
|
|
(
|
|
effective_quote_vault_address,
|
|
effective_base_vault_address,
|
|
payload_user_quote_token_account.as_deref(),
|
|
payload_user_base_token_account.as_deref(),
|
|
)
|
|
} else if input.decoded_event.event_kind.ends_with(".sell") {
|
|
(
|
|
effective_base_vault_address,
|
|
effective_quote_vault_address,
|
|
payload_user_base_token_account.as_deref(),
|
|
payload_user_quote_token_account.as_deref(),
|
|
)
|
|
} else {
|
|
(None, None, None, None)
|
|
};
|
|
let inferred_result =
|
|
crate::trade_solana_amounts::extract_trade_amounts_from_instruction_token_transfers(
|
|
input.transaction.meta_json.as_deref(),
|
|
decoded_instruction_index,
|
|
input_vault_address,
|
|
output_vault_address,
|
|
input_token_account,
|
|
output_token_account,
|
|
effective_base_vault_address,
|
|
effective_quote_vault_address,
|
|
);
|
|
let inferred = match inferred_result {
|
|
Ok(inferred) => inferred,
|
|
Err(error) => return Err(error),
|
|
};
|
|
if base_amount_raw.is_none() {
|
|
*base_amount_raw = inferred.0;
|
|
}
|
|
if quote_amount_raw.is_none() {
|
|
*quote_amount_raw = inferred.1;
|
|
}
|
|
if price_quote_per_base.is_none() {
|
|
*price_quote_per_base = inferred.2;
|
|
}
|
|
if base_amount_raw.is_none() || quote_amount_raw.is_none() {
|
|
let fallback_result =
|
|
crate::trade_solana_amounts::extract_trade_amounts_from_vault_balance_deltas(
|
|
input.transaction.transaction_json.as_str(),
|
|
input.transaction.meta_json.as_deref(),
|
|
effective_base_vault_address,
|
|
effective_quote_vault_address,
|
|
);
|
|
let fallback = match fallback_result {
|
|
Ok(fallback) => fallback,
|
|
Err(error) => return Err(error),
|
|
};
|
|
if base_amount_raw.is_none() {
|
|
*base_amount_raw = fallback.0;
|
|
}
|
|
if quote_amount_raw.is_none() {
|
|
*quote_amount_raw = fallback.1;
|
|
}
|
|
if price_quote_per_base.is_none() {
|
|
*price_quote_per_base = fallback.2;
|
|
}
|
|
}
|
|
|
|
if base_amount_raw.is_none() || quote_amount_raw.is_none() || price_quote_per_base.is_none() {
|
|
let transaction_value_result =
|
|
crate::trade_pump_swap_amounts::build_transaction_value_with_meta_json(
|
|
input.transaction.transaction_json.as_str(),
|
|
input.transaction.meta_json.as_deref(),
|
|
);
|
|
let transaction_value = match transaction_value_result {
|
|
Ok(transaction_value) => transaction_value,
|
|
Err(error) => return Err(error),
|
|
};
|
|
let fallback_amounts = match (input.base_token_mint, input.quote_token_mint) {
|
|
(Some(base_mint), Some(quote_mint)) => {
|
|
crate::trade_pump_swap_amounts::try_build_pump_swap_trade_amounts_from_token_balance_deltas(
|
|
&transaction_value,
|
|
base_mint,
|
|
quote_mint,
|
|
)
|
|
},
|
|
_ => None,
|
|
};
|
|
if let Some(fallback_amounts) = fallback_amounts {
|
|
if base_amount_raw.is_none() {
|
|
*base_amount_raw = crate::trade_pump_swap_amounts::convert_ui_amount_to_raw_string(
|
|
fallback_amounts.base_amount,
|
|
input.base_token_decimals,
|
|
);
|
|
}
|
|
if quote_amount_raw.is_none() {
|
|
*quote_amount_raw = crate::trade_pump_swap_amounts::convert_ui_amount_to_raw_string(
|
|
fallback_amounts.quote_amount,
|
|
input.quote_token_decimals,
|
|
);
|
|
}
|
|
if price_quote_per_base.is_none() {
|
|
*price_quote_per_base = Some(fallback_amounts.price_quote_per_base);
|
|
}
|
|
tracing::debug!(
|
|
event_kind = %input.decoded_event.event_kind,
|
|
pool_account = ?input.decoded_event.pool_account,
|
|
decoded_event_id = ?input.decoded_event.id,
|
|
base_mint = ?input.base_token_mint,
|
|
quote_mint = ?input.quote_token_mint,
|
|
base_amount_raw = ?base_amount_raw,
|
|
quote_amount_raw = ?quote_amount_raw,
|
|
price_quote_per_base = ?price_quote_per_base,
|
|
"pump_swap trade amounts recovered from token balance deltas"
|
|
);
|
|
}
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
fn apply_pump_fun_amount_fallback(
|
|
input: &crate::trade_amount_resolution::TradeAmountResolutionInput<'_>,
|
|
base_amount_raw: &mut std::option::Option<std::string::String>,
|
|
quote_amount_raw: &mut std::option::Option<std::string::String>,
|
|
price_quote_per_base: &mut std::option::Option<f64>,
|
|
) -> Result<(), crate::Error> {
|
|
let inferred_result = crate::trade_solana_amounts::extract_pump_fun_amounts_from_transaction(
|
|
input.transaction.transaction_json.as_str(),
|
|
input.transaction.meta_json.as_deref(),
|
|
input.base_vault_address,
|
|
input.quote_vault_address,
|
|
);
|
|
let inferred = match inferred_result {
|
|
Ok(inferred) => inferred,
|
|
Err(error) => return Err(error),
|
|
};
|
|
if base_amount_raw.is_none() {
|
|
*base_amount_raw = inferred.0;
|
|
}
|
|
if quote_amount_raw.is_none() {
|
|
*quote_amount_raw = inferred.1;
|
|
}
|
|
if price_quote_per_base.is_none() {
|
|
*price_quote_per_base = inferred.2;
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
async fn apply_raydium_instruction_amount_fallback(
|
|
input: &crate::trade_amount_resolution::TradeAmountResolutionInput<'_>,
|
|
base_amount_raw: &mut std::option::Option<std::string::String>,
|
|
quote_amount_raw: &mut std::option::Option<std::string::String>,
|
|
price_quote_per_base: &mut std::option::Option<f64>,
|
|
) -> Result<(), crate::Error> {
|
|
let decoded_instruction_index_result =
|
|
crate::trade_amount_resolution::load_decoded_instruction_index(
|
|
input.database,
|
|
input.decoded_event,
|
|
)
|
|
.await;
|
|
let decoded_instruction_index = match decoded_instruction_index_result {
|
|
Ok(decoded_instruction_index) => decoded_instruction_index,
|
|
Err(error) => return Err(error),
|
|
};
|
|
let payload_input_vault_address =
|
|
crate::trade_amount_resolution::extract_string_by_candidate_keys(
|
|
input.payload,
|
|
&["inputVault", "input_vault"],
|
|
);
|
|
let payload_output_vault_address =
|
|
crate::trade_amount_resolution::extract_string_by_candidate_keys(
|
|
input.payload,
|
|
&["outputVault", "output_vault"],
|
|
);
|
|
let payload_input_token_account =
|
|
crate::trade_amount_resolution::extract_string_by_candidate_keys(
|
|
input.payload,
|
|
&["inputTokenAccount", "input_token_account"],
|
|
);
|
|
let payload_output_token_account =
|
|
crate::trade_amount_resolution::extract_string_by_candidate_keys(
|
|
input.payload,
|
|
&["outputTokenAccount", "output_token_account"],
|
|
);
|
|
let payload_base_vault_address =
|
|
crate::trade_amount_resolution::extract_string_by_candidate_keys(
|
|
input.payload,
|
|
&["baseVault", "base_vault"],
|
|
);
|
|
let payload_quote_vault_address =
|
|
crate::trade_amount_resolution::extract_string_by_candidate_keys(
|
|
input.payload,
|
|
&["quoteVault", "quote_vault"],
|
|
);
|
|
let effective_base_vault_address = match input.base_vault_address {
|
|
Some(base_vault_address) => Some(base_vault_address),
|
|
None => payload_base_vault_address.as_deref(),
|
|
};
|
|
let effective_quote_vault_address = match input.quote_vault_address {
|
|
Some(quote_vault_address) => Some(quote_vault_address),
|
|
None => payload_quote_vault_address.as_deref(),
|
|
};
|
|
let inferred_result =
|
|
crate::trade_solana_amounts::extract_trade_amounts_from_instruction_token_transfers(
|
|
input.transaction.meta_json.as_deref(),
|
|
decoded_instruction_index,
|
|
payload_input_vault_address.as_deref(),
|
|
payload_output_vault_address.as_deref(),
|
|
payload_input_token_account.as_deref(),
|
|
payload_output_token_account.as_deref(),
|
|
effective_base_vault_address,
|
|
effective_quote_vault_address,
|
|
);
|
|
let inferred = match inferred_result {
|
|
Ok(inferred) => inferred,
|
|
Err(error) => return Err(error),
|
|
};
|
|
if base_amount_raw.is_none() {
|
|
*base_amount_raw = inferred.0;
|
|
}
|
|
if quote_amount_raw.is_none() {
|
|
*quote_amount_raw = inferred.1;
|
|
}
|
|
if price_quote_per_base.is_none() {
|
|
*price_quote_per_base = inferred.2;
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct FlattenedCpiTransferAmountResolution {
|
|
base_amount_raw: std::option::Option<std::string::String>,
|
|
quote_amount_raw: std::option::Option<std::string::String>,
|
|
resolved_trade_side: std::option::Option<crate::SwapTradeSide>,
|
|
}
|
|
|
|
async fn apply_meteora_dlmm_flattened_cpi_amount_fallback(
|
|
input: &crate::trade_amount_resolution::TradeAmountResolutionInput<'_>,
|
|
base_amount_raw: &mut std::option::Option<std::string::String>,
|
|
quote_amount_raw: &mut std::option::Option<std::string::String>,
|
|
resolved_trade_side: &mut std::option::Option<crate::SwapTradeSide>,
|
|
) -> Result<(), crate::Error> {
|
|
return crate::trade_amount_resolution::apply_flattened_cpi_amount_fallback(
|
|
input,
|
|
"meteora_dlmm",
|
|
base_amount_raw,
|
|
quote_amount_raw,
|
|
resolved_trade_side,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
async fn apply_meteora_damm_v1_flattened_cpi_amount_fallback(
|
|
input: &crate::trade_amount_resolution::TradeAmountResolutionInput<'_>,
|
|
base_amount_raw: &mut std::option::Option<std::string::String>,
|
|
quote_amount_raw: &mut std::option::Option<std::string::String>,
|
|
resolved_trade_side: &mut std::option::Option<crate::SwapTradeSide>,
|
|
) -> Result<(), crate::Error> {
|
|
return crate::trade_amount_resolution::apply_flattened_cpi_amount_fallback(
|
|
input,
|
|
"meteora_damm_v1",
|
|
base_amount_raw,
|
|
quote_amount_raw,
|
|
resolved_trade_side,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
async fn apply_flattened_cpi_amount_fallback(
|
|
input: &crate::trade_amount_resolution::TradeAmountResolutionInput<'_>,
|
|
protocol_label: &str,
|
|
base_amount_raw: &mut std::option::Option<std::string::String>,
|
|
quote_amount_raw: &mut std::option::Option<std::string::String>,
|
|
resolved_trade_side: &mut std::option::Option<crate::SwapTradeSide>,
|
|
) -> Result<(), crate::Error> {
|
|
let decoded_instruction_result = crate::trade_amount_resolution::load_decoded_instruction(
|
|
input.database,
|
|
input.decoded_event,
|
|
)
|
|
.await;
|
|
let decoded_instruction = match decoded_instruction_result {
|
|
Ok(Some(decoded_instruction)) => decoded_instruction,
|
|
Ok(None) => return Ok(()),
|
|
Err(error) => return Err(error),
|
|
};
|
|
let instructions_result = crate::query_chain_instructions_list_by_transaction_id(
|
|
input.database,
|
|
input.decoded_event.transaction_id,
|
|
)
|
|
.await;
|
|
let instructions = match instructions_result {
|
|
Ok(instructions) => instructions,
|
|
Err(error) => return Err(error),
|
|
};
|
|
let flattened_result = resolve_amounts_from_flattened_cpi_transfer_window(
|
|
&decoded_instruction,
|
|
&instructions,
|
|
input.base_token_mint,
|
|
input.quote_token_mint,
|
|
input.base_vault_address,
|
|
input.quote_vault_address,
|
|
);
|
|
let flattened = match flattened_result {
|
|
Ok(flattened) => flattened,
|
|
Err(error) => return Err(error),
|
|
};
|
|
if base_amount_raw.is_none() {
|
|
*base_amount_raw = flattened.base_amount_raw;
|
|
}
|
|
if quote_amount_raw.is_none() {
|
|
*quote_amount_raw = flattened.quote_amount_raw;
|
|
}
|
|
if resolved_trade_side.is_none() {
|
|
*resolved_trade_side = flattened.resolved_trade_side;
|
|
}
|
|
if base_amount_raw.is_some() || quote_amount_raw.is_some() {
|
|
tracing::debug!(
|
|
event_kind = %input.decoded_event.event_kind,
|
|
pool_account = ?input.decoded_event.pool_account,
|
|
protocol_label = %protocol_label,
|
|
decoded_event_id = ?input.decoded_event.id,
|
|
transaction_signature = %input.transaction.signature,
|
|
base_mint = ?input.base_token_mint,
|
|
quote_mint = ?input.quote_token_mint,
|
|
base_amount_raw = ?base_amount_raw,
|
|
quote_amount_raw = ?quote_amount_raw,
|
|
resolved_trade_side = ?resolved_trade_side,
|
|
"trade amounts recovered from flattened CPI transfer window"
|
|
);
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
fn resolve_amounts_from_flattened_cpi_transfer_window(
|
|
decoded_instruction: &crate::ChainInstructionDto,
|
|
transaction_instructions: &[crate::ChainInstructionDto],
|
|
base_token_mint: std::option::Option<&str>,
|
|
quote_token_mint: std::option::Option<&str>,
|
|
base_vault_address: std::option::Option<&str>,
|
|
quote_vault_address: std::option::Option<&str>,
|
|
) -> Result<crate::trade_amount_resolution::FlattenedCpiTransferAmountResolution, crate::Error> {
|
|
let decoded_stack_height = match decoded_instruction.stack_height {
|
|
Some(stack_height) => stack_height,
|
|
None => return Ok(crate::trade_amount_resolution::empty_flattened_cpi_amount_resolution()),
|
|
};
|
|
let stop_inner_instruction_index =
|
|
crate::trade_amount_resolution::find_flattened_cpi_window_stop_inner_instruction_index(
|
|
decoded_instruction,
|
|
transaction_instructions,
|
|
decoded_stack_height,
|
|
);
|
|
let mut base_transfer_direction = None;
|
|
let mut quote_transfer_direction = None;
|
|
let mut base_amount_raw = None;
|
|
let mut quote_amount_raw = None;
|
|
for instruction in transaction_instructions {
|
|
if !crate::trade_amount_resolution::instruction_is_inside_flattened_cpi_window(
|
|
decoded_instruction,
|
|
instruction,
|
|
decoded_stack_height,
|
|
stop_inner_instruction_index,
|
|
) {
|
|
continue;
|
|
}
|
|
let parsed_transfer_result =
|
|
crate::trade_amount_resolution::parse_transfer_checked_instruction(instruction);
|
|
let parsed_transfer = match parsed_transfer_result {
|
|
Ok(Some(parsed_transfer)) => parsed_transfer,
|
|
Ok(None) => continue,
|
|
Err(error) => return Err(error),
|
|
};
|
|
if base_amount_raw.is_none()
|
|
&& crate::trade_amount_resolution::string_option_equals(
|
|
base_token_mint,
|
|
parsed_transfer.mint.as_str(),
|
|
)
|
|
&& crate::trade_amount_resolution::transfer_touches_vault(
|
|
&parsed_transfer,
|
|
base_vault_address,
|
|
)
|
|
{
|
|
base_transfer_direction = crate::trade_amount_resolution::infer_transfer_direction(
|
|
&parsed_transfer,
|
|
base_vault_address,
|
|
);
|
|
base_amount_raw = Some(parsed_transfer.amount_raw.clone());
|
|
continue;
|
|
}
|
|
if quote_amount_raw.is_none()
|
|
&& crate::trade_amount_resolution::string_option_equals(
|
|
quote_token_mint,
|
|
parsed_transfer.mint.as_str(),
|
|
)
|
|
&& crate::trade_amount_resolution::transfer_touches_vault(
|
|
&parsed_transfer,
|
|
quote_vault_address,
|
|
)
|
|
{
|
|
quote_transfer_direction = crate::trade_amount_resolution::infer_transfer_direction(
|
|
&parsed_transfer,
|
|
quote_vault_address,
|
|
);
|
|
quote_amount_raw = Some(parsed_transfer.amount_raw.clone());
|
|
continue;
|
|
}
|
|
}
|
|
let resolved_trade_side =
|
|
crate::trade_amount_resolution::infer_trade_side_from_transfer_directions(
|
|
base_transfer_direction,
|
|
quote_transfer_direction,
|
|
);
|
|
return Ok(crate::trade_amount_resolution::FlattenedCpiTransferAmountResolution {
|
|
base_amount_raw,
|
|
quote_amount_raw,
|
|
resolved_trade_side,
|
|
});
|
|
}
|
|
|
|
fn instruction_is_inside_flattened_cpi_window(
|
|
decoded_instruction: &crate::ChainInstructionDto,
|
|
candidate_instruction: &crate::ChainInstructionDto,
|
|
decoded_stack_height: u32,
|
|
stop_inner_instruction_index: std::option::Option<u32>,
|
|
) -> bool {
|
|
if candidate_instruction.transaction_id != decoded_instruction.transaction_id {
|
|
return false;
|
|
}
|
|
if candidate_instruction.instruction_index != decoded_instruction.instruction_index {
|
|
return false;
|
|
}
|
|
let candidate_inner_instruction_index = match candidate_instruction.inner_instruction_index {
|
|
Some(inner_instruction_index) => inner_instruction_index,
|
|
None => return false,
|
|
};
|
|
if let Some(decoded_inner_instruction_index) = decoded_instruction.inner_instruction_index {
|
|
if candidate_inner_instruction_index <= decoded_inner_instruction_index {
|
|
return false;
|
|
}
|
|
}
|
|
let candidate_stack_height = match candidate_instruction.stack_height {
|
|
Some(stack_height) => stack_height,
|
|
None => return false,
|
|
};
|
|
if candidate_stack_height <= decoded_stack_height {
|
|
return false;
|
|
}
|
|
if let Some(stop_inner_instruction_index) = stop_inner_instruction_index {
|
|
if candidate_inner_instruction_index >= stop_inner_instruction_index {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
fn find_flattened_cpi_window_stop_inner_instruction_index(
|
|
decoded_instruction: &crate::ChainInstructionDto,
|
|
transaction_instructions: &[crate::ChainInstructionDto],
|
|
decoded_stack_height: u32,
|
|
) -> std::option::Option<u32> {
|
|
let decoded_inner_instruction_index = match decoded_instruction.inner_instruction_index {
|
|
Some(decoded_inner_instruction_index) => decoded_inner_instruction_index,
|
|
None => return None,
|
|
};
|
|
let mut stop_inner_instruction_index = None;
|
|
for instruction in transaction_instructions {
|
|
if instruction.transaction_id != decoded_instruction.transaction_id {
|
|
continue;
|
|
}
|
|
if instruction.instruction_index != decoded_instruction.instruction_index {
|
|
continue;
|
|
}
|
|
let candidate_inner_instruction_index = match instruction.inner_instruction_index {
|
|
Some(candidate_inner_instruction_index) => candidate_inner_instruction_index,
|
|
None => continue,
|
|
};
|
|
if candidate_inner_instruction_index <= decoded_inner_instruction_index {
|
|
continue;
|
|
}
|
|
let candidate_stack_height = match instruction.stack_height {
|
|
Some(candidate_stack_height) => candidate_stack_height,
|
|
None => continue,
|
|
};
|
|
if candidate_stack_height > decoded_stack_height {
|
|
continue;
|
|
}
|
|
match stop_inner_instruction_index {
|
|
Some(current_stop) => {
|
|
if candidate_inner_instruction_index < current_stop {
|
|
stop_inner_instruction_index = Some(candidate_inner_instruction_index);
|
|
}
|
|
},
|
|
None => stop_inner_instruction_index = Some(candidate_inner_instruction_index),
|
|
}
|
|
}
|
|
return stop_inner_instruction_index;
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct ParsedTransferCheckedInstruction {
|
|
mint: std::string::String,
|
|
amount_raw: std::string::String,
|
|
source: std::string::String,
|
|
destination: std::string::String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum VaultTransferDirection {
|
|
IntoVault,
|
|
OutOfVault,
|
|
}
|
|
|
|
fn parse_transfer_checked_instruction(
|
|
instruction: &crate::ChainInstructionDto,
|
|
) -> Result<
|
|
std::option::Option<crate::trade_amount_resolution::ParsedTransferCheckedInstruction>,
|
|
crate::Error,
|
|
> {
|
|
let parsed_type = match instruction.parsed_type.as_deref() {
|
|
Some(parsed_type) => parsed_type,
|
|
None => return Ok(None),
|
|
};
|
|
if parsed_type != "transferChecked" {
|
|
return Ok(None);
|
|
}
|
|
let parsed_json_text = match instruction.parsed_json.as_deref() {
|
|
Some(parsed_json_text) => parsed_json_text,
|
|
None => return Ok(None),
|
|
};
|
|
let parsed_json_result = serde_json::from_str::<serde_json::Value>(parsed_json_text);
|
|
let parsed_json = match parsed_json_result {
|
|
Ok(parsed_json) => parsed_json,
|
|
Err(error) => {
|
|
return Err(crate::Error::Json(format!(
|
|
"cannot parse parsed_json for transferChecked instruction '{}': {}",
|
|
crate::trade_amount_resolution::format_instruction_id_for_log(instruction.id),
|
|
error
|
|
)));
|
|
},
|
|
};
|
|
let info = match parsed_json.get("info") {
|
|
Some(info) => info,
|
|
None => return Ok(None),
|
|
};
|
|
let mint =
|
|
match crate::trade_amount_resolution::extract_string_by_candidate_keys(info, &["mint"]) {
|
|
Some(mint) => mint,
|
|
None => return Ok(None),
|
|
};
|
|
let amount_raw =
|
|
match crate::trade_amount_resolution::extract_scalar_as_string_by_candidate_keys(
|
|
info,
|
|
&["amount"],
|
|
) {
|
|
Some(amount_raw) => amount_raw,
|
|
None => {
|
|
let token_amount = match info.get("tokenAmount") {
|
|
Some(token_amount) => token_amount,
|
|
None => return Ok(None),
|
|
};
|
|
match crate::trade_amount_resolution::extract_scalar_as_string_by_candidate_keys(
|
|
token_amount,
|
|
&["amount"],
|
|
) {
|
|
Some(amount_raw) => amount_raw,
|
|
None => return Ok(None),
|
|
}
|
|
},
|
|
};
|
|
let source =
|
|
match crate::trade_amount_resolution::extract_string_by_candidate_keys(info, &["source"]) {
|
|
Some(source) => source,
|
|
None => return Ok(None),
|
|
};
|
|
let destination = match crate::trade_amount_resolution::extract_string_by_candidate_keys(
|
|
info,
|
|
&["destination"],
|
|
) {
|
|
Some(destination) => destination,
|
|
None => return Ok(None),
|
|
};
|
|
return Ok(Some(crate::trade_amount_resolution::ParsedTransferCheckedInstruction {
|
|
mint,
|
|
amount_raw,
|
|
source,
|
|
destination,
|
|
}));
|
|
}
|
|
|
|
fn transfer_touches_vault(
|
|
transfer: &crate::trade_amount_resolution::ParsedTransferCheckedInstruction,
|
|
vault_address: std::option::Option<&str>,
|
|
) -> bool {
|
|
let vault_address = match vault_address {
|
|
Some(vault_address) => vault_address,
|
|
None => return true,
|
|
};
|
|
if crate::trade_amount_resolution::account_equals(transfer.source.as_str(), vault_address) {
|
|
return true;
|
|
}
|
|
if crate::trade_amount_resolution::account_equals(transfer.destination.as_str(), vault_address)
|
|
{
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
fn infer_transfer_direction(
|
|
transfer: &crate::trade_amount_resolution::ParsedTransferCheckedInstruction,
|
|
vault_address: std::option::Option<&str>,
|
|
) -> std::option::Option<crate::trade_amount_resolution::VaultTransferDirection> {
|
|
let vault_address = match vault_address {
|
|
Some(vault_address) => vault_address,
|
|
None => return None,
|
|
};
|
|
if crate::trade_amount_resolution::account_equals(transfer.destination.as_str(), vault_address)
|
|
{
|
|
return Some(crate::trade_amount_resolution::VaultTransferDirection::IntoVault);
|
|
}
|
|
if crate::trade_amount_resolution::account_equals(transfer.source.as_str(), vault_address) {
|
|
return Some(crate::trade_amount_resolution::VaultTransferDirection::OutOfVault);
|
|
}
|
|
return None;
|
|
}
|
|
|
|
fn infer_trade_side_from_transfer_directions(
|
|
base_transfer_direction: std::option::Option<
|
|
crate::trade_amount_resolution::VaultTransferDirection,
|
|
>,
|
|
quote_transfer_direction: std::option::Option<
|
|
crate::trade_amount_resolution::VaultTransferDirection,
|
|
>,
|
|
) -> std::option::Option<crate::SwapTradeSide> {
|
|
match (base_transfer_direction, quote_transfer_direction) {
|
|
(
|
|
Some(crate::trade_amount_resolution::VaultTransferDirection::OutOfVault),
|
|
Some(crate::trade_amount_resolution::VaultTransferDirection::IntoVault),
|
|
) => return Some(crate::SwapTradeSide::BuyBase),
|
|
(
|
|
Some(crate::trade_amount_resolution::VaultTransferDirection::IntoVault),
|
|
Some(crate::trade_amount_resolution::VaultTransferDirection::OutOfVault),
|
|
) => return Some(crate::SwapTradeSide::SellBase),
|
|
_ => return None,
|
|
}
|
|
}
|
|
|
|
fn string_option_equals(left: std::option::Option<&str>, right: &str) -> bool {
|
|
let left = match left {
|
|
Some(left) => left.trim(),
|
|
None => return false,
|
|
};
|
|
let right = right.trim();
|
|
if left.is_empty() || right.is_empty() {
|
|
return false;
|
|
}
|
|
return left == right;
|
|
}
|
|
|
|
fn account_equals(left: &str, right: &str) -> bool {
|
|
let left = left.trim();
|
|
let right = right.trim();
|
|
if left.is_empty() || right.is_empty() {
|
|
return false;
|
|
}
|
|
return left == right;
|
|
}
|
|
|
|
fn format_instruction_id_for_log(instruction_id: std::option::Option<i64>) -> std::string::String {
|
|
match instruction_id {
|
|
Some(instruction_id) => return instruction_id.to_string(),
|
|
None => return "<none>".to_string(),
|
|
}
|
|
}
|
|
|
|
fn empty_flattened_cpi_amount_resolution()
|
|
-> crate::trade_amount_resolution::FlattenedCpiTransferAmountResolution {
|
|
return crate::trade_amount_resolution::FlattenedCpiTransferAmountResolution {
|
|
base_amount_raw: None,
|
|
quote_amount_raw: None,
|
|
resolved_trade_side: None,
|
|
};
|
|
}
|
|
|
|
fn apply_vault_balance_delta_fallback(
|
|
input: &crate::trade_amount_resolution::TradeAmountResolutionInput<'_>,
|
|
base_vault_address: std::option::Option<&str>,
|
|
quote_vault_address: std::option::Option<&str>,
|
|
base_amount_raw: &mut std::option::Option<std::string::String>,
|
|
quote_amount_raw: &mut std::option::Option<std::string::String>,
|
|
price_quote_per_base: &mut std::option::Option<f64>,
|
|
) -> Result<(), crate::Error> {
|
|
let inferred_result =
|
|
crate::trade_solana_amounts::extract_trade_amounts_from_vault_balance_deltas(
|
|
input.transaction.transaction_json.as_str(),
|
|
input.transaction.meta_json.as_deref(),
|
|
base_vault_address,
|
|
quote_vault_address,
|
|
);
|
|
let inferred = match inferred_result {
|
|
Ok(inferred) => inferred,
|
|
Err(error) => return Err(error),
|
|
};
|
|
if base_amount_raw.is_none() {
|
|
*base_amount_raw = inferred.0;
|
|
}
|
|
if quote_amount_raw.is_none() {
|
|
*quote_amount_raw = inferred.1;
|
|
}
|
|
if price_quote_per_base.is_none() {
|
|
*price_quote_per_base = inferred.2;
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
async fn load_decoded_instruction_index(
|
|
database: &crate::Database,
|
|
decoded_event: &crate::DexDecodedEventDto,
|
|
) -> Result<std::option::Option<u32>, crate::Error> {
|
|
let instruction_result =
|
|
crate::trade_amount_resolution::load_decoded_instruction(database, decoded_event).await;
|
|
let instruction_option = match instruction_result {
|
|
Ok(instruction_option) => instruction_option,
|
|
Err(error) => return Err(error),
|
|
};
|
|
match instruction_option {
|
|
Some(instruction) => return Ok(Some(instruction.instruction_index)),
|
|
None => return Ok(None),
|
|
}
|
|
}
|
|
|
|
async fn load_decoded_instruction(
|
|
database: &crate::Database,
|
|
decoded_event: &crate::DexDecodedEventDto,
|
|
) -> Result<std::option::Option<crate::ChainInstructionDto>, crate::Error> {
|
|
let instruction_id = match decoded_event.instruction_id {
|
|
Some(instruction_id) => instruction_id,
|
|
None => return Ok(None),
|
|
};
|
|
let instruction_result =
|
|
crate::query_chain_instructions_get_by_id(database, instruction_id).await;
|
|
let instruction_option = match instruction_result {
|
|
Ok(instruction_option) => instruction_option,
|
|
Err(error) => return Err(error),
|
|
};
|
|
return Ok(instruction_option);
|
|
}
|
|
|
|
fn extract_amount_string(
|
|
payload: &serde_json::Value,
|
|
candidate_keys: &[&str],
|
|
) -> std::option::Option<std::string::String> {
|
|
return crate::trade_amount_resolution::extract_scalar_as_string_by_candidate_keys(
|
|
payload,
|
|
candidate_keys,
|
|
);
|
|
}
|
|
|
|
fn extract_string_by_candidate_keys(
|
|
value: &serde_json::Value,
|
|
candidate_keys: &[&str],
|
|
) -> std::option::Option<std::string::String> {
|
|
if let Some(object) = value.as_object() {
|
|
for candidate_key in candidate_keys {
|
|
let direct_option = object.get(*candidate_key);
|
|
if let Some(direct) = direct_option {
|
|
let direct_text_option = direct.as_str();
|
|
if let Some(direct_text) = direct_text_option {
|
|
return Some(direct_text.to_string());
|
|
}
|
|
}
|
|
}
|
|
for nested_value in object.values() {
|
|
let nested_result = crate::trade_amount_resolution::extract_string_by_candidate_keys(
|
|
nested_value,
|
|
candidate_keys,
|
|
);
|
|
if nested_result.is_some() {
|
|
return nested_result;
|
|
}
|
|
}
|
|
return None;
|
|
}
|
|
if let Some(array) = value.as_array() {
|
|
for nested_value in array {
|
|
let nested_result = crate::trade_amount_resolution::extract_string_by_candidate_keys(
|
|
nested_value,
|
|
candidate_keys,
|
|
);
|
|
if nested_result.is_some() {
|
|
return nested_result;
|
|
}
|
|
}
|
|
}
|
|
return None;
|
|
}
|
|
|
|
fn extract_scalar_as_string_by_candidate_keys(
|
|
value: &serde_json::Value,
|
|
candidate_keys: &[&str],
|
|
) -> std::option::Option<std::string::String> {
|
|
if let Some(object) = value.as_object() {
|
|
for candidate_key in candidate_keys {
|
|
let direct_option = object.get(*candidate_key);
|
|
if let Some(direct) = direct_option {
|
|
if let Some(text) = direct.as_str() {
|
|
return Some(text.to_string());
|
|
}
|
|
if let Some(number) = direct.as_i64() {
|
|
return Some(number.to_string());
|
|
}
|
|
if let Some(number) = direct.as_u64() {
|
|
return Some(number.to_string());
|
|
}
|
|
if let Some(number) = direct.as_f64() {
|
|
return Some(number.to_string());
|
|
}
|
|
}
|
|
}
|
|
for nested_value in object.values() {
|
|
let nested_result =
|
|
crate::trade_amount_resolution::extract_scalar_as_string_by_candidate_keys(
|
|
nested_value,
|
|
candidate_keys,
|
|
);
|
|
if nested_result.is_some() {
|
|
return nested_result;
|
|
}
|
|
}
|
|
return None;
|
|
}
|
|
if let Some(array) = value.as_array() {
|
|
for nested_value in array {
|
|
let nested_result =
|
|
crate::trade_amount_resolution::extract_scalar_as_string_by_candidate_keys(
|
|
nested_value,
|
|
candidate_keys,
|
|
);
|
|
if nested_result.is_some() {
|
|
return nested_result;
|
|
}
|
|
}
|
|
}
|
|
return None;
|
|
}
|
|
|
|
fn infer_trade_side_from_vault_balance_deltas(
|
|
meta_json: std::option::Option<&str>,
|
|
transaction_json: &str,
|
|
base_vault_address: std::option::Option<&str>,
|
|
quote_vault_address: std::option::Option<&str>,
|
|
) -> std::option::Option<crate::SwapTradeSide> {
|
|
let base_vault_address = match base_vault_address {
|
|
Some(base_vault_address) => base_vault_address,
|
|
None => return None,
|
|
};
|
|
let quote_vault_address = match quote_vault_address {
|
|
Some(quote_vault_address) => quote_vault_address,
|
|
None => return None,
|
|
};
|
|
let meta_text = match meta_json {
|
|
Some(meta_text) => meta_text,
|
|
None => return None,
|
|
};
|
|
let meta_result = serde_json::from_str::<serde_json::Value>(meta_text);
|
|
let meta = match meta_result {
|
|
Ok(meta) => meta,
|
|
Err(_) => return None,
|
|
};
|
|
let transaction_result = serde_json::from_str::<serde_json::Value>(transaction_json);
|
|
let transaction = match transaction_result {
|
|
Ok(transaction) => transaction,
|
|
Err(_) => return None,
|
|
};
|
|
if transaction_failed(&meta) {
|
|
return None;
|
|
}
|
|
let account_keys = collect_transaction_account_keys(&transaction, &meta);
|
|
let base_account_index = find_account_index(&account_keys, base_vault_address);
|
|
let base_account_index = match base_account_index {
|
|
Some(base_account_index) => base_account_index,
|
|
None => return None,
|
|
};
|
|
let quote_account_index = find_account_index(&account_keys, quote_vault_address);
|
|
let quote_account_index = match quote_account_index {
|
|
Some(quote_account_index) => quote_account_index,
|
|
None => return None,
|
|
};
|
|
let base_delta = token_balance_delta_for_account_index(&meta, base_account_index);
|
|
let base_delta = match base_delta {
|
|
Some(base_delta) => base_delta,
|
|
None => return None,
|
|
};
|
|
let quote_delta = token_balance_delta_for_account_index(&meta, quote_account_index);
|
|
let quote_delta = match quote_delta {
|
|
Some(quote_delta) => quote_delta,
|
|
None => return None,
|
|
};
|
|
if base_delta < 0_i128 && quote_delta > 0_i128 {
|
|
return Some(crate::SwapTradeSide::BuyBase);
|
|
}
|
|
if base_delta > 0_i128 && quote_delta < 0_i128 {
|
|
return Some(crate::SwapTradeSide::SellBase);
|
|
}
|
|
return None;
|
|
}
|
|
|
|
fn transaction_failed(meta: &serde_json::Value) -> bool {
|
|
if let Some(err_value) = meta.get("err") {
|
|
if !err_value.is_null() {
|
|
return true;
|
|
}
|
|
}
|
|
if let Some(status) = meta.get("status") {
|
|
if let Some(object) = status.as_object() {
|
|
if object.get("Err").is_some() {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
fn collect_transaction_account_keys(
|
|
transaction: &serde_json::Value,
|
|
meta: &serde_json::Value,
|
|
) -> std::vec::Vec<std::string::String> {
|
|
let mut account_keys = std::vec::Vec::new();
|
|
collect_account_keys_from_candidate_path(
|
|
transaction,
|
|
&["transaction", "message", "accountKeys"],
|
|
&mut account_keys,
|
|
);
|
|
if account_keys.is_empty() {
|
|
collect_account_keys_from_candidate_path(
|
|
transaction,
|
|
&["message", "accountKeys"],
|
|
&mut account_keys,
|
|
);
|
|
}
|
|
collect_loaded_addresses(meta, &mut account_keys);
|
|
return account_keys;
|
|
}
|
|
|
|
fn collect_account_keys_from_candidate_path(
|
|
value: &serde_json::Value,
|
|
path: &[&str],
|
|
target: &mut std::vec::Vec<std::string::String>,
|
|
) {
|
|
let mut current = value;
|
|
for key in path {
|
|
let next = current.get(*key);
|
|
current = match next {
|
|
Some(next) => next,
|
|
None => return,
|
|
};
|
|
}
|
|
let array = match current.as_array() {
|
|
Some(array) => array,
|
|
None => return,
|
|
};
|
|
for item in array {
|
|
if let Some(text) = item.as_str() {
|
|
target.push(text.to_string());
|
|
continue;
|
|
}
|
|
if let Some(pubkey) = item.get("pubkey").and_then(|value| return value.as_str()) {
|
|
target.push(pubkey.to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
fn collect_loaded_addresses(
|
|
meta: &serde_json::Value,
|
|
target: &mut std::vec::Vec<std::string::String>,
|
|
) {
|
|
let loaded_addresses = match meta.get("loadedAddresses") {
|
|
Some(loaded_addresses) => loaded_addresses,
|
|
None => return,
|
|
};
|
|
collect_loaded_address_array(loaded_addresses, "writable", target);
|
|
collect_loaded_address_array(loaded_addresses, "readonly", target);
|
|
}
|
|
|
|
fn collect_loaded_address_array(
|
|
loaded_addresses: &serde_json::Value,
|
|
key: &str,
|
|
target: &mut std::vec::Vec<std::string::String>,
|
|
) {
|
|
let array = match loaded_addresses.get(key).and_then(|value| return value.as_array()) {
|
|
Some(array) => array,
|
|
None => return,
|
|
};
|
|
for item in array {
|
|
if let Some(text) = item.as_str() {
|
|
target.push(text.to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
fn find_account_index(
|
|
account_keys: &[std::string::String],
|
|
account_address: &str,
|
|
) -> std::option::Option<usize> {
|
|
for (index, account_key) in account_keys.iter().enumerate() {
|
|
if account_key == account_address {
|
|
return Some(index);
|
|
}
|
|
}
|
|
return None;
|
|
}
|
|
|
|
fn token_balance_delta_for_account_index(
|
|
meta: &serde_json::Value,
|
|
account_index: usize,
|
|
) -> std::option::Option<i128> {
|
|
let pre_amount =
|
|
token_balance_amount_for_account_index(meta, "preTokenBalances", account_index);
|
|
let pre_amount = match pre_amount {
|
|
Some(pre_amount) => pre_amount,
|
|
None => return None,
|
|
};
|
|
let post_amount =
|
|
token_balance_amount_for_account_index(meta, "postTokenBalances", account_index);
|
|
let post_amount = match post_amount {
|
|
Some(post_amount) => post_amount,
|
|
None => return None,
|
|
};
|
|
return Some(post_amount - pre_amount);
|
|
}
|
|
|
|
fn token_balance_amount_for_account_index(
|
|
meta: &serde_json::Value,
|
|
key: &str,
|
|
account_index: usize,
|
|
) -> std::option::Option<i128> {
|
|
let balances = match meta.get(key).and_then(|value| return value.as_array()) {
|
|
Some(balances) => balances,
|
|
None => return None,
|
|
};
|
|
for balance in balances {
|
|
let balance_index = balance.get("accountIndex").and_then(|value| return value.as_u64());
|
|
let balance_index = match balance_index {
|
|
Some(balance_index) => balance_index,
|
|
None => continue,
|
|
};
|
|
let requested_index = match u64::try_from(account_index) {
|
|
Ok(requested_index) => requested_index,
|
|
Err(_) => return None,
|
|
};
|
|
if balance_index != requested_index {
|
|
continue;
|
|
}
|
|
let amount_text = balance
|
|
.get("uiTokenAmount")
|
|
.and_then(|value| return value.get("amount"))
|
|
.and_then(|value| return value.as_str());
|
|
let amount_text = match amount_text {
|
|
Some(amount_text) => amount_text,
|
|
None => return None,
|
|
};
|
|
let amount_result = amount_text.parse::<i128>();
|
|
match amount_result {
|
|
Ok(amount) => return Some(amount),
|
|
Err(_) => return None,
|
|
}
|
|
}
|
|
return None;
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
fn transaction_json_with_vaults(base_vault: &str, quote_vault: &str) -> std::string::String {
|
|
return serde_json::json!({
|
|
"transaction": {
|
|
"message": {
|
|
"accountKeys": [
|
|
base_vault,
|
|
quote_vault
|
|
]
|
|
}
|
|
}
|
|
})
|
|
.to_string();
|
|
}
|
|
|
|
fn meta_json_with_vault_amounts(
|
|
base_pre: &str,
|
|
base_post: &str,
|
|
quote_pre: &str,
|
|
quote_post: &str,
|
|
) -> std::string::String {
|
|
return serde_json::json!({
|
|
"err": null,
|
|
"status": {
|
|
"Ok": null
|
|
},
|
|
"preTokenBalances": [
|
|
{
|
|
"accountIndex": 0,
|
|
"uiTokenAmount": {
|
|
"amount": base_pre
|
|
}
|
|
},
|
|
{
|
|
"accountIndex": 1,
|
|
"uiTokenAmount": {
|
|
"amount": quote_pre
|
|
}
|
|
}
|
|
],
|
|
"postTokenBalances": [
|
|
{
|
|
"accountIndex": 0,
|
|
"uiTokenAmount": {
|
|
"amount": base_post
|
|
}
|
|
},
|
|
{
|
|
"accountIndex": 1,
|
|
"uiTokenAmount": {
|
|
"amount": quote_post
|
|
}
|
|
}
|
|
]
|
|
})
|
|
.to_string();
|
|
}
|
|
|
|
fn make_test_instruction(
|
|
id: i64,
|
|
parent_instruction_id: std::option::Option<i64>,
|
|
instruction_index: u32,
|
|
inner_instruction_index: std::option::Option<u32>,
|
|
stack_height: std::option::Option<u32>,
|
|
parsed_type: std::option::Option<&str>,
|
|
parsed_json: std::option::Option<std::string::String>,
|
|
) -> crate::ChainInstructionDto {
|
|
return crate::ChainInstructionDto {
|
|
id: Some(id),
|
|
transaction_id: 10,
|
|
parent_instruction_id,
|
|
instruction_index,
|
|
inner_instruction_index,
|
|
program_id: None,
|
|
program_name: None,
|
|
stack_height,
|
|
accounts_json: "[]".to_string(),
|
|
data_json: None,
|
|
parsed_type: parsed_type.map(|value| return value.to_string()),
|
|
parsed_json,
|
|
created_at: chrono::Utc::now(),
|
|
};
|
|
}
|
|
|
|
fn transfer_checked_json(
|
|
mint: &str,
|
|
amount: &str,
|
|
source: &str,
|
|
destination: &str,
|
|
) -> std::string::String {
|
|
return serde_json::json!({
|
|
"info": {
|
|
"mint": mint,
|
|
"source": source,
|
|
"destination": destination,
|
|
"tokenAmount": {
|
|
"amount": amount
|
|
}
|
|
},
|
|
"type": "transferChecked"
|
|
})
|
|
.to_string();
|
|
}
|
|
|
|
#[test]
|
|
fn flattened_cpi_window_extracts_meteora_dlmm_transfer_checked_amounts() {
|
|
let decoded_instruction =
|
|
make_test_instruction(100, Some(90), 3, Some(1), Some(3), None, None);
|
|
let instructions = vec![
|
|
decoded_instruction.clone(),
|
|
make_test_instruction(101, Some(90), 3, Some(2), Some(4), None, None),
|
|
make_test_instruction(
|
|
102,
|
|
Some(90),
|
|
3,
|
|
Some(3),
|
|
Some(4),
|
|
Some("transferChecked"),
|
|
Some(transfer_checked_json("BASE", "5689283022", "UserBase", "BaseVault")),
|
|
),
|
|
make_test_instruction(
|
|
103,
|
|
Some(90),
|
|
3,
|
|
Some(4),
|
|
Some(4),
|
|
Some("transferChecked"),
|
|
Some(transfer_checked_json("QUOTE", "1322754129", "QuoteVault", "UserQuote")),
|
|
),
|
|
make_test_instruction(
|
|
104,
|
|
Some(90),
|
|
3,
|
|
Some(5),
|
|
Some(3),
|
|
Some("transferChecked"),
|
|
Some(transfer_checked_json("QUOTE", "999", "QuoteVault", "UserQuote")),
|
|
),
|
|
];
|
|
let resolution_result = super::resolve_amounts_from_flattened_cpi_transfer_window(
|
|
&decoded_instruction,
|
|
&instructions,
|
|
Some("BASE"),
|
|
Some("QUOTE"),
|
|
Some("BaseVault"),
|
|
Some("QuoteVault"),
|
|
);
|
|
let resolution = match resolution_result {
|
|
Ok(resolution) => resolution,
|
|
Err(error) => panic!("flattened CPI extraction should succeed: {}", error),
|
|
};
|
|
assert_eq!(resolution.base_amount_raw, Some("5689283022".to_string()));
|
|
assert_eq!(resolution.quote_amount_raw, Some("1322754129".to_string()));
|
|
assert_eq!(resolution.resolved_trade_side, Some(crate::SwapTradeSide::SellBase));
|
|
}
|
|
|
|
#[test]
|
|
fn flattened_cpi_window_extracts_top_level_meteora_damm_v1_transfer_checked_amounts() {
|
|
let decoded_instruction = make_test_instruction(200, None, 4, None, Some(1), None, None);
|
|
let instructions = vec![
|
|
decoded_instruction.clone(),
|
|
make_test_instruction(
|
|
201,
|
|
Some(200),
|
|
4,
|
|
Some(0),
|
|
Some(2),
|
|
Some("transferChecked"),
|
|
Some(transfer_checked_json("BASE", "250000000", "UserBase", "PoolBaseVault")),
|
|
),
|
|
make_test_instruction(
|
|
202,
|
|
Some(200),
|
|
4,
|
|
Some(1),
|
|
Some(2),
|
|
Some("transferChecked"),
|
|
Some(transfer_checked_json("QUOTE", "10000000", "PoolQuoteVault", "UserQuote")),
|
|
),
|
|
];
|
|
let resolution_result = super::resolve_amounts_from_flattened_cpi_transfer_window(
|
|
&decoded_instruction,
|
|
&instructions,
|
|
Some("BASE"),
|
|
Some("QUOTE"),
|
|
None,
|
|
None,
|
|
);
|
|
let resolution = match resolution_result {
|
|
Ok(resolution) => resolution,
|
|
Err(error) => panic!("top-level flattened CPI extraction should succeed: {}", error),
|
|
};
|
|
assert_eq!(resolution.base_amount_raw, Some("250000000".to_string()));
|
|
assert_eq!(resolution.quote_amount_raw, Some("10000000".to_string()));
|
|
assert_eq!(resolution.resolved_trade_side, None);
|
|
}
|
|
|
|
#[test]
|
|
fn buy_base_is_inferred_when_base_vault_decreases_and_quote_vault_increases() {
|
|
let transaction_json = transaction_json_with_vaults("base_vault", "quote_vault");
|
|
let meta_json = meta_json_with_vault_amounts("1000", "900", "5000", "5200");
|
|
let side = super::infer_trade_side_from_vault_balance_deltas(
|
|
Some(meta_json.as_str()),
|
|
transaction_json.as_str(),
|
|
Some("base_vault"),
|
|
Some("quote_vault"),
|
|
);
|
|
assert_eq!(side, Some(crate::SwapTradeSide::BuyBase));
|
|
}
|
|
|
|
#[test]
|
|
fn sell_base_is_inferred_when_base_vault_increases_and_quote_vault_decreases() {
|
|
let transaction_json = transaction_json_with_vaults("base_vault", "quote_vault");
|
|
let meta_json = meta_json_with_vault_amounts("1000", "1200", "5000", "4900");
|
|
let side = super::infer_trade_side_from_vault_balance_deltas(
|
|
Some(meta_json.as_str()),
|
|
transaction_json.as_str(),
|
|
Some("base_vault"),
|
|
Some("quote_vault"),
|
|
);
|
|
assert_eq!(side, Some(crate::SwapTradeSide::SellBase));
|
|
}
|
|
|
|
#[test]
|
|
fn failed_transaction_does_not_infer_trade_side() {
|
|
let transaction_json = transaction_json_with_vaults("base_vault", "quote_vault");
|
|
let meta_json = serde_json::json!({
|
|
"err": {
|
|
"InstructionError": [3, {"Custom": 104}]
|
|
},
|
|
"status": {
|
|
"Err": {
|
|
"InstructionError": [3, {"Custom": 104}]
|
|
}
|
|
},
|
|
"preTokenBalances": [],
|
|
"postTokenBalances": []
|
|
})
|
|
.to_string();
|
|
let side = super::infer_trade_side_from_vault_balance_deltas(
|
|
Some(meta_json.as_str()),
|
|
transaction_json.as_str(),
|
|
Some("base_vault"),
|
|
Some("quote_vault"),
|
|
);
|
|
assert_eq!(side, None);
|
|
}
|
|
}
|