lighter-sdk 0.1.1

Rust SDK for interacting with the Lighter exchange over REST, WebSocket, and signer-backed transaction flows.
Documentation
use serde::de::{self, MapAccess, Visitor};
use serde::ser::SerializeMap;
use serde::{Deserialize, Deserializer, Serialize, Serializer};

use crate::constants::{FEE_TICK, MAX_ACCOUNT_INDEX};
use crate::error::{Result, SdkError};

const ATTRIBUTE_TYPE_INTEGRATOR_ACCOUNT_INDEX: &str = "1";
const ATTRIBUTE_TYPE_INTEGRATOR_TAKER_FEE: &str = "2";
const ATTRIBUTE_TYPE_INTEGRATOR_MAKER_FEE: &str = "3";
const ATTRIBUTE_TYPE_SKIP_TX_NONCE: &str = "4";

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct L2TxAttributes {
    pub integrator_account_index: Option<i64>,
    pub integrator_taker_fee: Option<u32>,
    pub integrator_maker_fee: Option<u32>,
    pub skip_nonce: Option<u8>,
}

impl L2TxAttributes {
    pub fn skip_nonce_enabled() -> Self {
        Self {
            skip_nonce: Some(1),
            ..Self::default()
        }
    }

    pub fn is_empty(&self) -> bool {
        self.integrator_account_index.unwrap_or(0) == 0
            && self.integrator_taker_fee.unwrap_or(0) == 0
            && self.integrator_maker_fee.unwrap_or(0) == 0
            && self.skip_nonce.unwrap_or(0) == 0
    }

    pub fn validate(&self) -> Result<()> {
        if let Some(index) = self.integrator_account_index
            && !(0..=MAX_ACCOUNT_INDEX).contains(&index)
        {
            return Err(SdkError::IntegratorAccountIndexInvalidRange);
        }

        if let Some(fee) = self.integrator_taker_fee
            && fee as i64 > FEE_TICK
        {
            return Err(SdkError::IntegratorFeeInvalidRange);
        }

        if let Some(fee) = self.integrator_maker_fee
            && fee as i64 > FEE_TICK
        {
            return Err(SdkError::IntegratorFeeInvalidRange);
        }

        if let Some(skip_nonce) = self.skip_nonce
            && skip_nonce != 1
        {
            return Err(SdkError::NonceSkipAttributeInvalid);
        }

        let has_fees = self.integrator_taker_fee.unwrap_or(0) != 0
            || self.integrator_maker_fee.unwrap_or(0) != 0;
        if has_fees && self.integrator_account_index.unwrap_or(0) == 0 {
            return Err(SdkError::IntegratorAccountIndexRequiredForNonZeroFees);
        }

        Ok(())
    }
}

impl Serialize for L2TxAttributes {
    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        let mut map = serializer.serialize_map(None)?;
        if let Some(index) = self.integrator_account_index.filter(|index| *index != 0) {
            map.serialize_entry(ATTRIBUTE_TYPE_INTEGRATOR_ACCOUNT_INDEX, &index)?;
        }
        if let Some(fee) = self.integrator_taker_fee.filter(|fee| *fee != 0) {
            map.serialize_entry(ATTRIBUTE_TYPE_INTEGRATOR_TAKER_FEE, &fee)?;
        }
        if let Some(fee) = self.integrator_maker_fee.filter(|fee| *fee != 0) {
            map.serialize_entry(ATTRIBUTE_TYPE_INTEGRATOR_MAKER_FEE, &fee)?;
        }
        if let Some(skip_nonce) = self.skip_nonce.filter(|skip_nonce| *skip_nonce != 0) {
            map.serialize_entry(ATTRIBUTE_TYPE_SKIP_TX_NONCE, &skip_nonce)?;
        }
        map.end()
    }
}

impl<'de> Deserialize<'de> for L2TxAttributes {
    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        struct L2TxAttributesVisitor;

        impl<'de> Visitor<'de> for L2TxAttributesVisitor {
            type Value = L2TxAttributes;

            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
                formatter.write_str("a L2TxAttributes map")
            }

            fn visit_map<A>(self, mut map: A) -> std::result::Result<Self::Value, A::Error>
            where
                A: MapAccess<'de>,
            {
                let mut attributes = L2TxAttributes::default();

                while let Some((key, value)) = map.next_entry::<String, serde_json::Value>()? {
                    match key.as_str() {
                        ATTRIBUTE_TYPE_INTEGRATOR_ACCOUNT_INDEX | "IntegratorAccountIndex" => {
                            let value: i64 =
                                serde_json::from_value(value).map_err(de::Error::custom)?;
                            if value != 0 {
                                attributes.integrator_account_index = Some(value);
                            }
                        }
                        ATTRIBUTE_TYPE_INTEGRATOR_TAKER_FEE | "IntegratorTakerFee" => {
                            let value: u32 =
                                serde_json::from_value(value).map_err(de::Error::custom)?;
                            if value != 0 {
                                attributes.integrator_taker_fee = Some(value);
                            }
                        }
                        ATTRIBUTE_TYPE_INTEGRATOR_MAKER_FEE | "IntegratorMakerFee" => {
                            let value: u32 =
                                serde_json::from_value(value).map_err(de::Error::custom)?;
                            if value != 0 {
                                attributes.integrator_maker_fee = Some(value);
                            }
                        }
                        ATTRIBUTE_TYPE_SKIP_TX_NONCE | "SkipNonce" => {
                            let value: u8 =
                                serde_json::from_value(value).map_err(de::Error::custom)?;
                            if value != 0 {
                                attributes.skip_nonce = Some(value);
                            }
                        }
                        _ => {}
                    }
                }

                Ok(attributes)
            }
        }

        deserializer.deserialize_map(L2TxAttributesVisitor)
    }
}

#[derive(Debug, Clone, Default)]
pub struct TransactOpts {
    pub from_account_index: Option<i64>,
    pub api_key_index: Option<u8>,
    pub expired_at: i64,
    pub nonce: Option<i64>,
    pub tx_attributes: Option<L2TxAttributes>,
    pub dry_run: bool,
}

#[cfg(test)]
mod tests {
    use serde_json::json;

    use super::L2TxAttributes;
    use crate::error::SdkError;

    #[test]
    fn serializes_skip_nonce_as_numeric_attribute_key() {
        let attributes = L2TxAttributes {
            integrator_account_index: Some(7),
            skip_nonce: Some(1),
            ..L2TxAttributes::default()
        };

        let value = serde_json::to_value(&attributes).unwrap();

        assert_eq!(value, json!({ "1": 7, "4": 1 }));
    }

    #[test]
    fn deserializes_numeric_and_named_attribute_keys() {
        let numeric: L2TxAttributes = serde_json::from_value(json!({ "2": 10, "4": 1 })).unwrap();
        assert_eq!(
            numeric,
            L2TxAttributes {
                integrator_taker_fee: Some(10),
                skip_nonce: Some(1),
                ..L2TxAttributes::default()
            }
        );

        let named: L2TxAttributes = serde_json::from_value(json!({
            "IntegratorAccountIndex": 11,
            "IntegratorMakerFee": 9,
            "SkipNonce": 1
        }))
        .unwrap();
        assert_eq!(
            named,
            L2TxAttributes {
                integrator_account_index: Some(11),
                integrator_maker_fee: Some(9),
                skip_nonce: Some(1),
                ..L2TxAttributes::default()
            }
        );

        let zero_normalized: L2TxAttributes =
            serde_json::from_value(json!({ "1": 0, "2": 0, "3": 0, "4": 1 })).unwrap();
        assert_eq!(
            zero_normalized,
            L2TxAttributes {
                skip_nonce: Some(1),
                ..L2TxAttributes::default()
            }
        );
    }

    #[test]
    fn validates_skip_nonce_and_integrator_fee_rules() {
        let invalid_skip_nonce = L2TxAttributes {
            skip_nonce: Some(2),
            ..L2TxAttributes::default()
        };
        assert!(matches!(
            invalid_skip_nonce.validate(),
            Err(SdkError::NonceSkipAttributeInvalid)
        ));

        let fee_without_integrator = L2TxAttributes {
            integrator_taker_fee: Some(1),
            ..L2TxAttributes::default()
        };
        assert!(matches!(
            fee_without_integrator.validate(),
            Err(SdkError::IntegratorAccountIndexRequiredForNonZeroFees)
        ));

        let valid = L2TxAttributes {
            integrator_account_index: Some(12),
            integrator_taker_fee: Some(1),
            integrator_maker_fee: Some(2),
            skip_nonce: Some(1),
        };
        assert!(valid.validate().is_ok());
    }

    #[test]
    fn zero_valued_attributes_are_treated_as_empty() {
        let attributes = L2TxAttributes {
            integrator_account_index: Some(0),
            integrator_taker_fee: Some(0),
            integrator_maker_fee: Some(0),
            skip_nonce: Some(0),
        };

        assert!(attributes.is_empty());
        assert_eq!(serde_json::to_value(&attributes).unwrap(), json!({}));
    }
}