Skip to main content

trident_sdk/
lib.rs

1#![doc = include_str!("../README.md")]
2
3mod builder;
4mod chain_handler;
5mod conversions;
6mod deposit;
7/// Lower-level EVM bindings and signature utilities for Trident integrations.
8pub mod evm;
9/// Raw generated protobuf messages and gRPC client types.
10pub mod proto;
11mod spend_attestation;
12mod spend_request;
13mod spend_spec_hash;
14
15pub use builder::{TridentClientBuilder, TridentClientBuilderError};
16pub use deposit::Deposit;
17pub use spend_attestation::{EvmCallContext, SpendAttestation, SpendMulticall};
18pub use spend_request::{ChainAddress, SpendRequestBuilder};
19pub use trident_evm_aa::call_builder::MulticallBuilder;
20
21use alloy_primitives::U256;
22use proto::{delivery_v1, v1};
23use std::collections::HashMap;
24use std::sync::Arc;
25use tonic::transport::Channel;
26
27use crate::chain_handler::{ChainHandler, ChainHandlerError};
28use crate::conversions::ProtoConversionError;
29use crate::spend_spec_hash::compute_spend_spec_hash;
30use delivery_v1::delivery_service_client::DeliveryServiceClient;
31
32/// Trident domain identifier for a supported source or destination chain.
33pub type Domain = u32;
34
35/// Errors returned by [`TridentClient`] operations.
36///
37/// Covers local chain configuration and input validation, proto decoding
38/// failures, delivery-service gRPC failures, deployment-lookup misses, and
39/// server-side spend rejections.
40#[derive(Debug, thiserror::Error)]
41pub enum TridentClientError {
42    /// No chain handler was registered for `domain` when the client was built.
43    #[error("No chain registered for domain {domain}")]
44    MissingChain { domain: Domain },
45
46    /// A chain-family-specific operation (signing, deposit preparation) failed.
47    #[error(transparent)]
48    ChainHandler(#[from] ChainHandlerError),
49
50    /// The delivery service rejected the gRPC call at the transport or status
51    /// layer.
52    #[error("gRPC call failed: {0}")]
53    Grpc(#[from] tonic::Status),
54
55    /// The delivery service returned a response with a required field unset.
56    #[error("Server returned empty response")]
57    EmptyResponse,
58
59    /// The delivery service accepted the call but rejected the spend intent
60    /// with a human-readable reason.
61    #[error("Spend rejected: {reason}")]
62    SpendRejected { reason: String },
63
64    /// Encoding to or decoding from the wire proto representation failed.
65    #[error("Invalid proto conversion: {0}")]
66    ProtoConversion(#[from] ProtoConversionError),
67
68    /// [`TridentClient::deposit`] was called with `amount == 0`.
69    #[error("Deposit amount must be greater than zero")]
70    ZeroDepositAmount,
71
72    /// The delivery service did not report a deployment for `domain`, so
73    /// per-deployment data (e.g. expiry minimums) could not be resolved.
74    #[error("No deployment found for domain {domain}")]
75    MissingDeployment { domain: Domain },
76}
77
78/// Client for the Trident cross-chain delivery service.
79///
80/// Holds the gRPC connection plus a per-domain chain handler materialized
81/// during [`TridentClientBuilder::connect`]. Construct via
82/// [`TridentClientBuilder`].
83///
84/// The crate provides [`From`] / [`TryFrom`] impls between
85/// [`alloy_primitives::Address`], [`v1::MultiChainAddress`], [`v1::U256`],
86/// [`v1::Bytes32`], and friends, so building gRPC requests and decoding
87/// responses off the public `delivery` field is usually a one-liner via
88/// `.into()` / `.try_into()` without any hand-rolled conversion helpers.
89pub struct TridentClient {
90    chains: HashMap<Domain, Arc<dyn ChainHandler>>,
91    /// Live gRPC connection to the Trident attester. Use this field to talk to
92    /// the attester directly for RPCs the SDK does not wrap — such as
93    /// `get_token_routes`, `get_domain_routes`, or `get_sync_status` — on the
94    /// same connection the rest of the SDK uses.
95    pub delivery: DeliveryServiceClient<Channel>,
96    pub(crate) expiry_margins: HashMap<Domain, u64>,
97}
98
99#[derive(Default)]
100pub struct Balance {
101    /// Total deposited amount.
102    pub deposited_balance: U256,
103    /// Amount available for new spends.
104    pub available_balance: U256,
105}
106
107impl TryFrom<delivery_v1::TokenBalance> for Balance {
108    type Error = TridentClientError;
109
110    fn try_from(value: delivery_v1::TokenBalance) -> Result<Self, Self::Error> {
111        let deposited_balance: U256 = value
112            .deposited_balance
113            .ok_or(TridentClientError::EmptyResponse)?
114            .try_into()?;
115        let available_balance: U256 = value
116            .available_balance
117            .ok_or(TridentClientError::EmptyResponse)?
118            .try_into()?;
119        Ok(Balance {
120            deposited_balance,
121            available_balance,
122        })
123    }
124}
125
126impl TridentClient {
127    /// Submits a cross-chain spend intent to the delivery service.
128    ///
129    /// Fills in the source spender (from the configured signer), source deposit contract,
130    /// destination delivery contract, a random salt, and — unless the builder sets them
131    /// explicitly — the max fee (via [`Self::estimate_fees`]) and the expiry height (via
132    /// the server's minimum plus the source domain's configured margin, defaulting to
133    /// 10% of the minimum). Signs the intent with the source-domain signer and sends it
134    /// over gRPC.
135    ///
136    /// Returns a [`SpendAttestation`] that the caller must submit on-chain via
137    /// [`SpendAttestation::send`] to actually execute the spend.
138    pub async fn spend<SrcAddressType, DstAddressType>(
139        &mut self,
140        request: SpendRequestBuilder<SrcAddressType, DstAddressType>,
141    ) -> Result<SpendAttestation<DstAddressType>, TridentClientError>
142    where
143        SrcAddressType: ChainAddress,
144        DstAddressType: ChainAddress,
145    {
146        let src_domain = request.src_domain();
147        let src_chain = self.get_handler(src_domain)?;
148        let src_spender = src_chain.signer();
149        let src_deposit_contract = src_chain.deposit_contract();
150
151        let dst_domain = request.dst_domain();
152        let dst_chain = self.get_handler(dst_domain)?;
153        let dst_delivery_contract = dst_chain.delivery_contract();
154
155        let salt: [u8; 32] = rand::random();
156        let max_fee = match request.max_fee() {
157            Some(fee) => fee,
158            None => self.estimate_fees(&request).await?,
159        };
160        let expiry_height = match request.expiry_height() {
161            Some(height) => height,
162            None => self.compute_expiry_block(src_domain).await?,
163        };
164        let dst_caller = request.caller.unwrap_or_default();
165
166        let intent = v1::SpendIntent {
167            spec: Some(v1::SpendSpec {
168                src_spender: Some(src_spender),
169                src_domain: request.src_domain,
170                src_token: Some(request.src_token.into()),
171                src_deposit_contract: Some(src_deposit_contract),
172                dst_domain: request.dst_domain,
173                dst_token: Some(request.dst_token.into()),
174                dst_delivery_contract: Some(dst_delivery_contract),
175                dst_recipient: Some(request.dst_recipient.into()),
176                dst_caller: Some(dst_caller.into()),
177                amount: Some(request.amount.into()),
178                salt: Some(salt.into()),
179            }),
180            max_fee: Some(max_fee.into()),
181            expiry_height,
182        };
183
184        let spend_spec_hash = compute_spend_spec_hash(intent.clone())?;
185        let signature = src_chain.sign_spend_intent(intent.clone()).await?;
186
187        let grpc_request = delivery_v1::SpendRequest {
188            signed_spend_intent: Some(v1::SignedSpendIntent {
189                intent: Some(intent),
190                signature,
191            }),
192        };
193
194        let response = self.delivery.spend(grpc_request).await?;
195
196        let result = response
197            .into_inner()
198            .result
199            .ok_or(TridentClientError::EmptyResponse)?;
200
201        match result {
202            delivery_v1::spend_response::Result::Accepted(raw) => {
203                let ctx = dst_chain.prepare_spend_attestation();
204                let attestation = SpendAttestation::new(ctx, raw, spend_spec_hash);
205                Ok(attestation)
206            }
207            delivery_v1::spend_response::Result::Rejected(reason) => {
208                Err(TridentClientError::SpendRejected { reason })
209            }
210        }
211    }
212
213    /// Prepares a deposit of `amount` of `token` into the domain's deposit contract.
214    ///
215    /// Returns a [`Deposit`] the caller must submit on-chain via [`Deposit::send`].
216    /// Errors if `amount` is zero, no chain is registered for `domain`, or the handler
217    /// cannot decode `token` for this chain family.
218    pub fn deposit<T: ChainAddress>(
219        &self,
220        domain: Domain,
221        token: T,
222        amount: U256,
223    ) -> Result<Deposit<T>, TridentClientError> {
224        if amount.is_zero() {
225            return Err(TridentClientError::ZeroDepositAmount);
226        }
227        let chain = self.get_handler(domain)?;
228        let ctx = chain.prepare_deposit(token.into(), amount)?;
229        Ok(Deposit::new(ctx))
230    }
231
232    /// Queries the Trident balance held by `address` for `token` on `domain`.
233    ///
234    /// Returns [`U256::ZERO`] when no balance is recorded.
235    pub async fn get_balance(
236        &mut self,
237        domain: Domain,
238        address: impl ChainAddress,
239        token: impl ChainAddress,
240    ) -> Result<Balance, TridentClientError> {
241        let response = self
242            .delivery
243            .get_domain_balance(delivery_v1::GetDomainBalanceRequest {
244                domain,
245                address: Some(address.into()),
246                token: Some(token.into()),
247            })
248            .await?
249            .into_inner();
250
251        let balance = response
252            .balance
253            .ok_or(TridentClientError::EmptyResponse)?
254            .try_into()?;
255
256        Ok(balance)
257    }
258
259    /// Estimates the total fees for the given spend request.
260    ///
261    /// Asks the delivery service for a fee breakdown using a placeholder intent built
262    /// from the request (zero addresses for fields filled in by [`Self::spend`]) and
263    /// returns the `total_fees` field.
264    pub async fn estimate_fees<SrcAddressType, DstAddressType>(
265        &mut self,
266        request: &SpendRequestBuilder<SrcAddressType, DstAddressType>,
267    ) -> Result<U256, TridentClientError>
268    where
269        SrcAddressType: ChainAddress,
270        DstAddressType: ChainAddress,
271    {
272        let grpc_request = delivery_v1::GetSpendFeesRequest {
273            spend_intent: Some(request.build_fee_intent()),
274        };
275
276        let response = self
277            .delivery
278            .get_spend_fees(grpc_request)
279            .await?
280            .into_inner();
281
282        let breakdown = response
283            .fees_breakdown
284            .ok_or(TridentClientError::EmptyResponse)?;
285
286        let total_fees: U256 = breakdown
287            .total_fees
288            .ok_or(TridentClientError::EmptyResponse)?
289            .try_into()?;
290
291        Ok(total_fees)
292    }
293
294    /// Queries the server for the minimum spend intent expiry height on the
295    /// given domain and adds the domain's configured expiry margin. Falls
296    /// back to 10% of the server's minimum when no per-domain margin has been
297    /// registered on the builder via
298    /// [`TridentClientBuilder::with_expiry_margin`].
299    async fn compute_expiry_block(&mut self, domain: Domain) -> Result<u64, TridentClientError> {
300        let deployments = self
301            .delivery
302            .list_deployments(delivery_v1::ListDeploymentsRequest {})
303            .await?
304            .into_inner();
305
306        let deployment = deployments
307            .deployments
308            .into_iter()
309            .find(|d| d.domain == domain)
310            .ok_or(TridentClientError::MissingDeployment { domain })?;
311
312        Ok(resolve_expiry_block(
313            deployment.spend_intent_min_expiry_height,
314            self.expiry_margins.get(&domain).copied(),
315        ))
316    }
317
318    /// Returns an owned handle to the chain handler registered for `domain`,
319    /// or [`TridentClientError::MissingChain`] if none is registered.
320    fn get_handler(&self, domain: Domain) -> Result<Arc<dyn ChainHandler>, TridentClientError> {
321        self.chains
322            .get(&domain)
323            .cloned()
324            .ok_or(TridentClientError::MissingChain { domain })
325    }
326}
327
328/// Resolves the spend-intent expiry block from the server's minimum and an
329/// optional per-domain margin override. When no override is provided, falls
330/// back to 10% of `min` (integer division). Uses saturating addition so a
331/// pathological `min` close to `u64::MAX` cannot overflow.
332fn resolve_expiry_block(min: u64, override_margin: Option<u64>) -> u64 {
333    let margin = override_margin.unwrap_or(min / 10);
334    min.saturating_add(margin)
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use alloy_primitives::Address;
341    use tonic::transport::Endpoint;
342
343    fn empty_client() -> TridentClient {
344        let channel = Endpoint::from_static("http://[::1]:50051").connect_lazy();
345        TridentClient {
346            chains: HashMap::new(),
347            delivery: DeliveryServiceClient::new(channel),
348            expiry_margins: HashMap::new(),
349        }
350    }
351
352    #[test]
353    fn resolve_expiry_block_success_uses_override() {
354        assert_eq!(resolve_expiry_block(1_000, Some(250)), 1_250);
355    }
356
357    #[test]
358    fn resolve_expiry_block_success_defaults_to_ten_percent() {
359        assert_eq!(resolve_expiry_block(1_000, None), 1_100);
360    }
361
362    #[test]
363    fn resolve_expiry_block_success_saturates_on_overflow() {
364        assert_eq!(resolve_expiry_block(u64::MAX, Some(10)), u64::MAX);
365    }
366
367    #[tokio::test]
368    async fn deposit_error_zero_amount() {
369        let client = empty_client();
370        let result = client.deposit(1, Address::from([0x11; 20]), U256::ZERO);
371        assert!(matches!(result, Err(TridentClientError::ZeroDepositAmount)));
372    }
373
374    #[tokio::test]
375    async fn deposit_error_missing_chain() {
376        let client = empty_client();
377        let result = client.deposit(42, Address::from([0x11; 20]), U256::from(100u64));
378        assert!(matches!(
379            result,
380            Err(TridentClientError::MissingChain { domain: 42 })
381        ));
382    }
383}