use serde::{Deserialize, Serialize};
use serde_with::serde_as;
use solana_address::Address;
pub use solana_signature::Signature;
use crate::{EncodedBinary, SwapMode, route_plan::RoutePlan};
pub const BPS_SCALE: i128 = 10_000;
pub const CHANGE_CLAMP_BPS: i128 = 10 * BPS_SCALE;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ChangeBps {
pub bps: i64,
pub clamped: bool,
}
pub fn clamped_change_bps(new: u64, base: u64) -> ChangeBps {
let raw = (i128::from(new) - i128::from(base)) * BPS_SCALE / i128::from(base);
ChangeBps {
bps: raw.clamp(-CHANGE_CLAMP_BPS, CHANGE_CLAMP_BPS) as i64,
clamped: raw.abs() >= CHANGE_CLAMP_BPS,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReplacementContext {
pub slot: u64,
}
#[serde_as]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReplacementCore {
pub context: ReplacementContext,
pub slot: u64,
pub batch_index: u32,
#[serde_as(as = "serde_with::DisplayFromStr")]
pub original_signature: Signature,
#[serde(default)]
pub original_failed: bool,
pub outcome: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(rename = "err", default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct L1Score {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub l1_fill: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub change_vs_l1_bps: Option<i64>,
}
impl L1Score {
pub fn new(measured: Option<u64>, l1_fill: u64, mode: SwapMode) -> Self {
let l1_fill = (l1_fill > 0).then_some(l1_fill);
Self {
l1_fill,
change_vs_l1_bps: measured.filter(|&measured| measured > 0).zip(l1_fill).map(
|(measured, l1)| {
let change = clamped_change_bps(measured, l1).bps;
match mode {
SwapMode::ExactIn => change,
SwapMode::ExactOut => -change,
}
},
),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SimCost {
pub compute_units_consumed: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fee: Option<u64>,
}
#[serde_as]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RerouteLegNotification {
#[serde_as(as = "serde_with::DisplayFromStr")]
pub input_mint: Address,
#[serde_as(as = "serde_with::DisplayFromStr")]
pub output_mint: Address,
pub amount: u64,
pub swap_mode: String,
pub original_quoted_out: u64,
pub metis_quoted_out: u64,
pub route_summary: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub route_plan: Option<RoutePlan>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub original_route_plan: Option<RoutePlan>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub realized_route_plan: Option<RoutePlan>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RequoteNotification {
#[serde(flatten)]
pub core: ReplacementCore,
#[serde(flatten)]
pub comparison: L1Score,
#[serde(flatten)]
pub cost: SimCost,
#[serde(default)]
pub legs: Vec<RerouteLegNotification>,
pub routed_transaction: EncodedBinary,
#[serde(default)]
pub logs: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub realized_output_amount: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub original_realized_output_amount: Option<u64>,
}
#[serde_as]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProbedHop {
pub slot: u64,
pub block_time: i64,
pub tx_index: u32,
pub ix_index: u32,
pub leg_index: u32,
#[serde_as(as = "Option<serde_with::DisplayFromStr>")]
#[serde(default)]
pub program: Option<Address>,
pub input_amount: u64,
pub output_amount: u64,
#[serde(default)]
pub replay_input_amount: Option<u64>,
#[serde(default)]
pub replay_output_amount: Option<u64>,
}
#[serde_as]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DirectFillNotification {
#[serde(flatten)]
pub core: ReplacementCore,
#[serde(flatten)]
pub comparison: L1Score,
pub template: String,
#[serde_as(as = "serde_with::DisplayFromStr")]
pub program: Address,
pub hop: ProbedHop,
pub quote_tx_index: u32,
pub quote_lag_blocks: u32,
#[serde_as(as = "serde_with::DisplayFromStr")]
pub input_mint: Address,
#[serde_as(as = "serde_with::DisplayFromStr")]
pub output_mint: Address,
pub amount: u64,
pub swap_mode: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub template_fill: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OriginalNotification {
#[serde(flatten)]
pub core: ReplacementCore,
#[serde(flatten)]
pub comparison: L1Score,
#[serde(flatten)]
pub cost: SimCost,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub route_plan: Option<RoutePlan>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub l1_route_plan: Option<RoutePlan>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub simulated_output_amount: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum ReplacementNotification {
Requote(RequoteNotification),
DirectFill(DirectFillNotification),
Original(OriginalNotification),
}
impl ReplacementNotification {
pub const fn core(&self) -> &ReplacementCore {
match self {
Self::Requote(requote) => &requote.core,
Self::DirectFill(probe) => &probe.core,
Self::Original(original) => &original.core,
}
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
#[rstest]
#[case::total_loss(0, 1_000, -10_000, false)]
#[case::unchanged(1_000, 1_000, 0, false)]
#[case::a_real_improvement(1_100, 1_000, 1_000, false)]
#[case::ten_x_is_the_last_unclamped(101_000, 10_000, 91_000, false)]
#[case::eleven_x_is_the_bound_itself(110_000, 10_000, 100_000, true)]
#[case::dust_base(1_000_000, 1, 100_000, true)]
fn a_change_reports_whether_the_clamp_bound_it(
#[case] new: u64,
#[case] base: u64,
#[case] bps: i64,
#[case] clamped: bool,
) {
assert_eq!(clamped_change_bps(new, base), ChangeBps { bps, clamped });
}
#[rstest]
#[case::no_baseline(Some(1_000), 0, SwapMode::ExactIn, None, None)]
#[case::no_measurement(None, 1_000, SwapMode::ExactIn, Some(1_000), None)]
#[case::beat_it(Some(1_100), 1_000, SwapMode::ExactIn, Some(1_000), Some(1_000))]
#[case::lost_to_it(Some(900), 1_000, SwapMode::ExactIn, Some(1_000), Some(-1_000))]
#[case::tiny_baseline_clamps(
Some(u64::MAX),
1,
SwapMode::ExactIn,
Some(1),
Some(CHANGE_CLAMP_BPS as i64)
)]
#[case::exact_out_paid_less(Some(900), 1_000, SwapMode::ExactOut, Some(1_000), Some(1_000))]
#[case::exact_out_paid_more(Some(1_100), 1_000, SwapMode::ExactOut, Some(1_000), Some(-1_000))]
#[case::a_declined_quote_is_unscored(Some(0), 1_000, SwapMode::ExactIn, Some(1_000), None)]
fn scoring_a_fill_against_l1(
#[case] measured: Option<u64>,
#[case] l1_fill: u64,
#[case] mode: SwapMode,
#[case] expected_l1: Option<u64>,
#[case] expected_bps: Option<i64>,
) {
let scored = L1Score::new(measured, l1_fill, mode);
assert_eq!(scored.l1_fill, expected_l1);
assert_eq!(scored.change_vs_l1_bps, expected_bps);
}
#[test]
fn an_unscored_comparison_emits_no_keys() {
let empty = serde_json::to_value(L1Score::new(None, 0, SwapMode::ExactIn))
.expect("a score serializes");
assert_eq!(empty, serde_json::json!({}));
}
}