1#![doc = include_str!("../README.md")]
2
3mod builder;
4mod chain_handler;
5mod conversions;
6mod deposit;
7pub mod evm;
9pub 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
32pub type Domain = u32;
34
35#[derive(Debug, thiserror::Error)]
41pub enum TridentClientError {
42 #[error("No chain registered for domain {domain}")]
44 MissingChain { domain: Domain },
45
46 #[error(transparent)]
48 ChainHandler(#[from] ChainHandlerError),
49
50 #[error("gRPC call failed: {0}")]
53 Grpc(#[from] tonic::Status),
54
55 #[error("Server returned empty response")]
57 EmptyResponse,
58
59 #[error("Spend rejected: {reason}")]
62 SpendRejected { reason: String },
63
64 #[error("Invalid proto conversion: {0}")]
66 ProtoConversion(#[from] ProtoConversionError),
67
68 #[error("Deposit amount must be greater than zero")]
70 ZeroDepositAmount,
71
72 #[error("No deployment found for domain {domain}")]
75 MissingDeployment { domain: Domain },
76}
77
78pub struct TridentClient {
90 chains: HashMap<Domain, Arc<dyn ChainHandler>>,
91 pub delivery: DeliveryServiceClient<Channel>,
96 pub(crate) expiry_margins: HashMap<Domain, u64>,
97}
98
99#[derive(Default)]
100pub struct Balance {
101 pub deposited_balance: U256,
103 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 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 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 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 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 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 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
328fn 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}