trident-sdk 0.2.0

End-user SDK for Trident deposits and cross-chain spends
Documentation
use alloy_primitives::{Address, U256};

use crate::Domain;
use crate::conversions::ProtoConversionError;
use crate::proto::v1;

/// A chain-agnostic address trait used by the SDK's cross-chain request APIs.
///
/// This trait lets [`crate::TridentClient`] and [`SpendRequestBuilder`] work with
/// a single generic address bound across supported chains. Implementors provide
/// conversion to and from the wire-level [`v1::MultiChainAddress`] so the SDK
/// can accept concrete address types while still speaking a common cross-chain
/// format internally.
///
/// [`Default`] is required so the SDK can construct zero-address placeholders
/// for fields that are filled in later, such as fee-estimation requests.
///
/// Implemented for [`alloy_primitives::Address`].
pub trait ChainAddress:
    Clone
    + Default
    + Into<v1::MultiChainAddress>
    + TryFrom<v1::MultiChainAddress, Error = ProtoConversionError>
{
}

impl ChainAddress for Address {}

/// Configures a cross-chain spend request.
///
/// Pass the completed builder to [`TridentClient::spend`](crate::TridentClient::spend)
/// to submit the intent. The client fills in the source spender address, salt, and fee estimation
/// automatically.
pub struct SpendRequestBuilder<SrcAddressType, DstAddressType> {
    pub(crate) src_domain: Domain,
    pub(crate) src_token: SrcAddressType,
    pub(crate) dst_domain: Domain,
    pub(crate) dst_token: DstAddressType,
    pub(crate) dst_recipient: DstAddressType,
    pub(crate) amount: U256,
    pub(crate) expiry_height: Option<u64>,
    pub(crate) max_fee: Option<U256>,
    pub(crate) caller: Option<DstAddressType>,
}

impl<SrcAddressType, DstAddressType> SpendRequestBuilder<SrcAddressType, DstAddressType> {
    /// Returns the source domain.
    pub const fn src_domain(&self) -> Domain {
        self.src_domain
    }

    /// Returns the destination domain.
    pub const fn dst_domain(&self) -> Domain {
        self.dst_domain
    }

    /// Returns the explicit max fee, or `None` if it will be auto-estimated.
    pub const fn max_fee(&self) -> Option<U256> {
        self.max_fee
    }

    /// Returns the explicit expiry height, or `None` if it will be auto-resolved.
    pub const fn expiry_height(&self) -> Option<u64> {
        self.expiry_height
    }
}

impl<SrcAddressType, DstAddressType> SpendRequestBuilder<SrcAddressType, DstAddressType>
where
    SrcAddressType: ChainAddress,
    DstAddressType: ChainAddress,
{
    /// Creates a new builder for a spend from `src_domain`/`src_token` to
    /// `dst_domain`/`dst_token`, paying `dst_recipient` the given `amount`.
    ///
    /// `max_fee`, `expiry_height`, and `caller` default to unset and can be configured
    /// via the corresponding `with_*` methods; if left unset, the client auto-resolves
    /// them when the request is submitted.
    pub fn new(
        src_domain: Domain,
        src_token: SrcAddressType,
        dst_domain: Domain,
        dst_token: DstAddressType,
        dst_recipient: DstAddressType,
        amount: U256,
    ) -> Self {
        Self {
            src_domain,
            src_token,
            dst_domain,
            dst_token,
            dst_recipient,
            amount,
            expiry_height: None,
            max_fee: None,
            caller: None,
        }
    }

    /// Sets an explicit max fee, skipping server-side fee estimation.
    pub fn with_max_fee(mut self, max_fee: U256) -> Self {
        self.max_fee = Some(max_fee);
        self
    }

    /// Sets an explicit expiry block height for the spend intent.
    ///
    /// When omitted, the client auto-resolves the expiry by querying the
    /// server's `spend_intent_min_expiry_height` and adding the source
    /// domain's configured margin (set on the builder via
    /// [`crate::TridentClientBuilder::with_expiry_margin`]). Domains without
    /// an explicit margin fall back to 10% of the server's minimum.
    pub fn with_expiry_height(mut self, expiry_height: u64) -> Self {
        self.expiry_height = Some(expiry_height);
        self
    }

    /// Sets the on-chain caller for the destination-side execution.
    /// Defaults to the zero address when not set.
    pub fn with_caller(mut self, caller: DstAddressType) -> Self {
        self.caller = Some(caller);
        self
    }

    /// Builds a [`v1::SpendIntent`] with placeholder values suitable for fee estimation.
    pub(crate) fn build_fee_intent(&self) -> v1::SpendIntent
    where
        SrcAddressType: Clone,
        DstAddressType: Clone,
    {
        let dst_caller = self
            .caller
            .clone()
            .map_or_else(|| DstAddressType::default().into(), Into::into);

        v1::SpendIntent {
            spec: Some(v1::SpendSpec {
                src_spender: Some(SrcAddressType::default().into()),
                src_domain: self.src_domain,
                src_token: Some(self.src_token.clone().into()),
                src_deposit_contract: Some(SrcAddressType::default().into()),
                dst_domain: self.dst_domain,
                dst_token: Some(self.dst_token.clone().into()),
                dst_delivery_contract: Some(DstAddressType::default().into()),
                dst_recipient: Some(self.dst_recipient.clone().into()),
                dst_caller: Some(dst_caller),
                amount: Some(self.amount.into()),
                salt: Some([0u8; 32].into()),
            }),
            max_fee: Some(U256::ZERO.into()),
            expiry_height: self.expiry_height.unwrap_or(0),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use alloy_primitives::Address;

    #[derive(Clone, Debug, PartialEq, Eq)]
    struct SrcAddress(Address);

    impl Default for SrcAddress {
        fn default() -> Self {
            Self(Address::from([0x11; 20]))
        }
    }

    impl From<SrcAddress> for v1::MultiChainAddress {
        fn from(value: SrcAddress) -> Self {
            value.0.into()
        }
    }

    impl TryFrom<v1::MultiChainAddress> for SrcAddress {
        type Error = ProtoConversionError;

        fn try_from(value: v1::MultiChainAddress) -> Result<Self, Self::Error> {
            Address::try_from(value).map(Self)
        }
    }

    impl ChainAddress for SrcAddress {}

    #[derive(Clone, Debug, PartialEq, Eq)]
    struct DstAddress(Address);

    impl Default for DstAddress {
        fn default() -> Self {
            Self(Address::from([0x22; 20]))
        }
    }

    impl From<DstAddress> for v1::MultiChainAddress {
        fn from(value: DstAddress) -> Self {
            value.0.into()
        }
    }

    impl TryFrom<v1::MultiChainAddress> for DstAddress {
        type Error = ProtoConversionError;

        fn try_from(value: v1::MultiChainAddress) -> Result<Self, Self::Error> {
            Address::try_from(value).map(Self)
        }
    }

    impl ChainAddress for DstAddress {}

    #[test]
    fn chain_address_roundtrip_success() {
        let address = Address::from([0x44; 20]);
        let proto: v1::MultiChainAddress = address.into();
        let decoded = Address::try_from(proto).unwrap();

        assert_eq!(decoded, address);
    }

    #[test]
    fn build_fee_intent_success_uses_source_default_for_src_spender() {
        let request = SpendRequestBuilder::new(
            1,
            SrcAddress(Address::from([0x33; 20])),
            2,
            DstAddress(Address::from([0x44; 20])),
            DstAddress(Address::from([0x55; 20])),
            U256::from(100u64),
        );

        let intent = request.build_fee_intent();
        let spec = intent.spec.expect("fee intent spec");
        let src_spender = spec.src_spender.expect("src_spender");

        assert_eq!(src_spender, SrcAddress::default().into());
    }
}