Skip to main content

trident_sdk/
spend_request.rs

1use alloy_primitives::{Address, U256};
2
3use crate::Domain;
4use crate::conversions::ProtoConversionError;
5use crate::proto::v1;
6
7/// A chain-agnostic address trait used by the SDK's cross-chain request APIs.
8///
9/// This trait lets [`crate::TridentClient`] and [`SpendRequestBuilder`] work with
10/// a single generic address bound across supported chains. Implementors provide
11/// conversion to and from the wire-level [`v1::MultiChainAddress`] so the SDK
12/// can accept concrete address types while still speaking a common cross-chain
13/// format internally.
14///
15/// [`Default`] is required so the SDK can construct zero-address placeholders
16/// for fields that are filled in later, such as fee-estimation requests.
17///
18/// Implemented for [`alloy_primitives::Address`].
19pub trait ChainAddress:
20    Clone
21    + Default
22    + Into<v1::MultiChainAddress>
23    + TryFrom<v1::MultiChainAddress, Error = ProtoConversionError>
24{
25}
26
27impl ChainAddress for Address {}
28
29/// Configures a cross-chain spend request.
30///
31/// Pass the completed builder to [`TridentClient::spend`](crate::TridentClient::spend)
32/// to submit the intent. The client fills in the source spender address, salt, and fee estimation
33/// automatically.
34pub struct SpendRequestBuilder<SrcAddressType, DstAddressType> {
35    pub(crate) src_domain: Domain,
36    pub(crate) src_token: SrcAddressType,
37    pub(crate) dst_domain: Domain,
38    pub(crate) dst_token: DstAddressType,
39    pub(crate) dst_recipient: DstAddressType,
40    pub(crate) amount: U256,
41    pub(crate) expiry_height: Option<u64>,
42    pub(crate) max_fee: Option<U256>,
43    pub(crate) caller: Option<DstAddressType>,
44}
45
46impl<SrcAddressType, DstAddressType> SpendRequestBuilder<SrcAddressType, DstAddressType> {
47    /// Returns the source domain.
48    pub const fn src_domain(&self) -> Domain {
49        self.src_domain
50    }
51
52    /// Returns the destination domain.
53    pub const fn dst_domain(&self) -> Domain {
54        self.dst_domain
55    }
56
57    /// Returns the explicit max fee, or `None` if it will be auto-estimated.
58    pub const fn max_fee(&self) -> Option<U256> {
59        self.max_fee
60    }
61
62    /// Returns the explicit expiry height, or `None` if it will be auto-resolved.
63    pub const fn expiry_height(&self) -> Option<u64> {
64        self.expiry_height
65    }
66}
67
68impl<SrcAddressType, DstAddressType> SpendRequestBuilder<SrcAddressType, DstAddressType>
69where
70    SrcAddressType: ChainAddress,
71    DstAddressType: ChainAddress,
72{
73    /// Creates a new builder for a spend from `src_domain`/`src_token` to
74    /// `dst_domain`/`dst_token`, paying `dst_recipient` the given `amount`.
75    ///
76    /// `max_fee`, `expiry_height`, and `caller` default to unset and can be configured
77    /// via the corresponding `with_*` methods; if left unset, the client auto-resolves
78    /// them when the request is submitted.
79    pub fn new(
80        src_domain: Domain,
81        src_token: SrcAddressType,
82        dst_domain: Domain,
83        dst_token: DstAddressType,
84        dst_recipient: DstAddressType,
85        amount: U256,
86    ) -> Self {
87        Self {
88            src_domain,
89            src_token,
90            dst_domain,
91            dst_token,
92            dst_recipient,
93            amount,
94            expiry_height: None,
95            max_fee: None,
96            caller: None,
97        }
98    }
99
100    /// Sets an explicit max fee, skipping server-side fee estimation.
101    pub fn with_max_fee(mut self, max_fee: U256) -> Self {
102        self.max_fee = Some(max_fee);
103        self
104    }
105
106    /// Sets an explicit expiry block height for the spend intent.
107    ///
108    /// When omitted, the client auto-resolves the expiry by querying the
109    /// server's `spend_intent_min_expiry_height` and adding the source
110    /// domain's configured margin (set on the builder via
111    /// [`crate::TridentClientBuilder::with_expiry_margin`]). Domains without
112    /// an explicit margin fall back to 10% of the server's minimum.
113    pub fn with_expiry_height(mut self, expiry_height: u64) -> Self {
114        self.expiry_height = Some(expiry_height);
115        self
116    }
117
118    /// Sets the on-chain caller for the destination-side execution.
119    /// Defaults to the zero address when not set.
120    pub fn with_caller(mut self, caller: DstAddressType) -> Self {
121        self.caller = Some(caller);
122        self
123    }
124
125    /// Builds a [`v1::SpendIntent`] with placeholder values suitable for fee estimation.
126    pub(crate) fn build_fee_intent(&self) -> v1::SpendIntent
127    where
128        SrcAddressType: Clone,
129        DstAddressType: Clone,
130    {
131        let dst_caller = self
132            .caller
133            .clone()
134            .map_or_else(|| DstAddressType::default().into(), Into::into);
135
136        v1::SpendIntent {
137            spec: Some(v1::SpendSpec {
138                src_spender: Some(SrcAddressType::default().into()),
139                src_domain: self.src_domain,
140                src_token: Some(self.src_token.clone().into()),
141                src_deposit_contract: Some(SrcAddressType::default().into()),
142                dst_domain: self.dst_domain,
143                dst_token: Some(self.dst_token.clone().into()),
144                dst_delivery_contract: Some(DstAddressType::default().into()),
145                dst_recipient: Some(self.dst_recipient.clone().into()),
146                dst_caller: Some(dst_caller),
147                amount: Some(self.amount.into()),
148                salt: Some([0u8; 32].into()),
149            }),
150            max_fee: Some(U256::ZERO.into()),
151            expiry_height: self.expiry_height.unwrap_or(0),
152        }
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use alloy_primitives::Address;
160
161    #[derive(Clone, Debug, PartialEq, Eq)]
162    struct SrcAddress(Address);
163
164    impl Default for SrcAddress {
165        fn default() -> Self {
166            Self(Address::from([0x11; 20]))
167        }
168    }
169
170    impl From<SrcAddress> for v1::MultiChainAddress {
171        fn from(value: SrcAddress) -> Self {
172            value.0.into()
173        }
174    }
175
176    impl TryFrom<v1::MultiChainAddress> for SrcAddress {
177        type Error = ProtoConversionError;
178
179        fn try_from(value: v1::MultiChainAddress) -> Result<Self, Self::Error> {
180            Address::try_from(value).map(Self)
181        }
182    }
183
184    impl ChainAddress for SrcAddress {}
185
186    #[derive(Clone, Debug, PartialEq, Eq)]
187    struct DstAddress(Address);
188
189    impl Default for DstAddress {
190        fn default() -> Self {
191            Self(Address::from([0x22; 20]))
192        }
193    }
194
195    impl From<DstAddress> for v1::MultiChainAddress {
196        fn from(value: DstAddress) -> Self {
197            value.0.into()
198        }
199    }
200
201    impl TryFrom<v1::MultiChainAddress> for DstAddress {
202        type Error = ProtoConversionError;
203
204        fn try_from(value: v1::MultiChainAddress) -> Result<Self, Self::Error> {
205            Address::try_from(value).map(Self)
206        }
207    }
208
209    impl ChainAddress for DstAddress {}
210
211    #[test]
212    fn chain_address_roundtrip_success() {
213        let address = Address::from([0x44; 20]);
214        let proto: v1::MultiChainAddress = address.into();
215        let decoded = Address::try_from(proto).unwrap();
216
217        assert_eq!(decoded, address);
218    }
219
220    #[test]
221    fn build_fee_intent_success_uses_source_default_for_src_spender() {
222        let request = SpendRequestBuilder::new(
223            1,
224            SrcAddress(Address::from([0x33; 20])),
225            2,
226            DstAddress(Address::from([0x44; 20])),
227            DstAddress(Address::from([0x55; 20])),
228            U256::from(100u64),
229        );
230
231        let intent = request.build_fee_intent();
232        let spec = intent.spec.expect("fee intent spec");
233        let src_spender = spec.src_spender.expect("src_spender");
234
235        assert_eq!(src_spender, SrcAddress::default().into());
236    }
237}