#![doc = include_str!("../README.md")]
mod builder;
mod chain_handler;
mod conversions;
mod deposit;
pub mod evm;
pub mod proto;
mod spend_attestation;
mod spend_request;
mod spend_spec_hash;
pub use builder::{TridentClientBuilder, TridentClientBuilderError};
pub use deposit::Deposit;
pub use spend_attestation::{EvmCallContext, SpendAttestation, SpendMulticall};
pub use spend_request::{ChainAddress, SpendRequestBuilder};
pub use trident_evm_aa::call_builder::MulticallBuilder;
use alloy_primitives::U256;
use proto::{delivery_v1, v1};
use std::collections::HashMap;
use std::sync::Arc;
use tonic::transport::Channel;
use crate::chain_handler::{ChainHandler, ChainHandlerError};
use crate::conversions::ProtoConversionError;
use crate::spend_spec_hash::compute_spend_spec_hash;
use delivery_v1::delivery_service_client::DeliveryServiceClient;
pub type Domain = u32;
#[derive(Debug, thiserror::Error)]
pub enum TridentClientError {
#[error("No chain registered for domain {domain}")]
MissingChain { domain: Domain },
#[error(transparent)]
ChainHandler(#[from] ChainHandlerError),
#[error("gRPC call failed: {0}")]
Grpc(#[from] tonic::Status),
#[error("Server returned empty response")]
EmptyResponse,
#[error("Spend rejected: {reason}")]
SpendRejected { reason: String },
#[error("Invalid proto conversion: {0}")]
ProtoConversion(#[from] ProtoConversionError),
#[error("Deposit amount must be greater than zero")]
ZeroDepositAmount,
#[error("No deployment found for domain {domain}")]
MissingDeployment { domain: Domain },
}
pub struct TridentClient {
chains: HashMap<Domain, Arc<dyn ChainHandler>>,
pub delivery: DeliveryServiceClient<Channel>,
pub(crate) expiry_margins: HashMap<Domain, u64>,
}
#[derive(Default)]
pub struct Balance {
pub deposited_balance: U256,
pub available_balance: U256,
}
impl TryFrom<delivery_v1::TokenBalance> for Balance {
type Error = TridentClientError;
fn try_from(value: delivery_v1::TokenBalance) -> Result<Self, Self::Error> {
let deposited_balance: U256 = value
.deposited_balance
.ok_or(TridentClientError::EmptyResponse)?
.try_into()?;
let available_balance: U256 = value
.available_balance
.ok_or(TridentClientError::EmptyResponse)?
.try_into()?;
Ok(Balance {
deposited_balance,
available_balance,
})
}
}
impl TridentClient {
pub async fn spend<SrcAddressType, DstAddressType>(
&mut self,
request: SpendRequestBuilder<SrcAddressType, DstAddressType>,
) -> Result<SpendAttestation<DstAddressType>, TridentClientError>
where
SrcAddressType: ChainAddress,
DstAddressType: ChainAddress,
{
let src_domain = request.src_domain();
let src_chain = self.get_handler(src_domain)?;
let src_spender = src_chain.signer();
let src_deposit_contract = src_chain.deposit_contract();
let dst_domain = request.dst_domain();
let dst_chain = self.get_handler(dst_domain)?;
let dst_delivery_contract = dst_chain.delivery_contract();
let salt: [u8; 32] = rand::random();
let max_fee = match request.max_fee() {
Some(fee) => fee,
None => self.estimate_fees(&request).await?,
};
let expiry_height = match request.expiry_height() {
Some(height) => height,
None => self.compute_expiry_block(src_domain).await?,
};
let dst_caller = request.caller.unwrap_or_default();
let intent = v1::SpendIntent {
spec: Some(v1::SpendSpec {
src_spender: Some(src_spender),
src_domain: request.src_domain,
src_token: Some(request.src_token.into()),
src_deposit_contract: Some(src_deposit_contract),
dst_domain: request.dst_domain,
dst_token: Some(request.dst_token.into()),
dst_delivery_contract: Some(dst_delivery_contract),
dst_recipient: Some(request.dst_recipient.into()),
dst_caller: Some(dst_caller.into()),
amount: Some(request.amount.into()),
salt: Some(salt.into()),
}),
max_fee: Some(max_fee.into()),
expiry_height,
};
let spend_spec_hash = compute_spend_spec_hash(intent.clone())?;
let signature = src_chain.sign_spend_intent(intent.clone()).await?;
let grpc_request = delivery_v1::SpendRequest {
signed_spend_intent: Some(v1::SignedSpendIntent {
intent: Some(intent),
signature,
}),
};
let response = self.delivery.spend(grpc_request).await?;
let result = response
.into_inner()
.result
.ok_or(TridentClientError::EmptyResponse)?;
match result {
delivery_v1::spend_response::Result::Accepted(raw) => {
let ctx = dst_chain.prepare_spend_attestation();
let attestation = SpendAttestation::new(ctx, raw, spend_spec_hash);
Ok(attestation)
}
delivery_v1::spend_response::Result::Rejected(reason) => {
Err(TridentClientError::SpendRejected { reason })
}
}
}
pub fn deposit<T: ChainAddress>(
&self,
domain: Domain,
token: T,
amount: U256,
) -> Result<Deposit<T>, TridentClientError> {
if amount.is_zero() {
return Err(TridentClientError::ZeroDepositAmount);
}
let chain = self.get_handler(domain)?;
let ctx = chain.prepare_deposit(token.into(), amount)?;
Ok(Deposit::new(ctx))
}
pub async fn get_balance(
&mut self,
domain: Domain,
address: impl ChainAddress,
token: impl ChainAddress,
) -> Result<Balance, TridentClientError> {
let response = self
.delivery
.get_domain_balance(delivery_v1::GetDomainBalanceRequest {
domain,
address: Some(address.into()),
token: Some(token.into()),
})
.await?
.into_inner();
let balance = response
.balance
.ok_or(TridentClientError::EmptyResponse)?
.try_into()?;
Ok(balance)
}
pub async fn estimate_fees<SrcAddressType, DstAddressType>(
&mut self,
request: &SpendRequestBuilder<SrcAddressType, DstAddressType>,
) -> Result<U256, TridentClientError>
where
SrcAddressType: ChainAddress,
DstAddressType: ChainAddress,
{
let grpc_request = delivery_v1::GetSpendFeesRequest {
spend_intent: Some(request.build_fee_intent()),
};
let response = self
.delivery
.get_spend_fees(grpc_request)
.await?
.into_inner();
let breakdown = response
.fees_breakdown
.ok_or(TridentClientError::EmptyResponse)?;
let total_fees: U256 = breakdown
.total_fees
.ok_or(TridentClientError::EmptyResponse)?
.try_into()?;
Ok(total_fees)
}
async fn compute_expiry_block(&mut self, domain: Domain) -> Result<u64, TridentClientError> {
let deployments = self
.delivery
.list_deployments(delivery_v1::ListDeploymentsRequest {})
.await?
.into_inner();
let deployment = deployments
.deployments
.into_iter()
.find(|d| d.domain == domain)
.ok_or(TridentClientError::MissingDeployment { domain })?;
Ok(resolve_expiry_block(
deployment.spend_intent_min_expiry_height,
self.expiry_margins.get(&domain).copied(),
))
}
fn get_handler(&self, domain: Domain) -> Result<Arc<dyn ChainHandler>, TridentClientError> {
self.chains
.get(&domain)
.cloned()
.ok_or(TridentClientError::MissingChain { domain })
}
}
fn resolve_expiry_block(min: u64, override_margin: Option<u64>) -> u64 {
let margin = override_margin.unwrap_or(min / 10);
min.saturating_add(margin)
}
#[cfg(test)]
mod tests {
use super::*;
use alloy_primitives::Address;
use tonic::transport::Endpoint;
fn empty_client() -> TridentClient {
let channel = Endpoint::from_static("http://[::1]:50051").connect_lazy();
TridentClient {
chains: HashMap::new(),
delivery: DeliveryServiceClient::new(channel),
expiry_margins: HashMap::new(),
}
}
#[test]
fn resolve_expiry_block_success_uses_override() {
assert_eq!(resolve_expiry_block(1_000, Some(250)), 1_250);
}
#[test]
fn resolve_expiry_block_success_defaults_to_ten_percent() {
assert_eq!(resolve_expiry_block(1_000, None), 1_100);
}
#[test]
fn resolve_expiry_block_success_saturates_on_overflow() {
assert_eq!(resolve_expiry_block(u64::MAX, Some(10)), u64::MAX);
}
#[tokio::test]
async fn deposit_error_zero_amount() {
let client = empty_client();
let result = client.deposit(1, Address::from([0x11; 20]), U256::ZERO);
assert!(matches!(result, Err(TridentClientError::ZeroDepositAmount)));
}
#[tokio::test]
async fn deposit_error_missing_chain() {
let client = empty_client();
let result = client.deposit(42, Address::from([0x11; 20]), U256::from(100u64));
assert!(matches!(
result,
Err(TridentClientError::MissingChain { domain: 42 })
));
}
}