trident-sdk 0.2.0

End-user SDK for Trident deposits and cross-chain spends
Documentation
#![doc = include_str!("../README.md")]

mod builder;
mod chain_handler;
mod conversions;
mod deposit;
/// Lower-level EVM bindings and signature utilities for Trident integrations.
pub mod evm;
/// Raw generated protobuf messages and gRPC client types.
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;

/// Trident domain identifier for a supported source or destination chain.
pub type Domain = u32;

/// Errors returned by [`TridentClient`] operations.
///
/// Covers local chain configuration and input validation, proto decoding
/// failures, delivery-service gRPC failures, deployment-lookup misses, and
/// server-side spend rejections.
#[derive(Debug, thiserror::Error)]
pub enum TridentClientError {
    /// No chain handler was registered for `domain` when the client was built.
    #[error("No chain registered for domain {domain}")]
    MissingChain { domain: Domain },

    /// A chain-family-specific operation (signing, deposit preparation) failed.
    #[error(transparent)]
    ChainHandler(#[from] ChainHandlerError),

    /// The delivery service rejected the gRPC call at the transport or status
    /// layer.
    #[error("gRPC call failed: {0}")]
    Grpc(#[from] tonic::Status),

    /// The delivery service returned a response with a required field unset.
    #[error("Server returned empty response")]
    EmptyResponse,

    /// The delivery service accepted the call but rejected the spend intent
    /// with a human-readable reason.
    #[error("Spend rejected: {reason}")]
    SpendRejected { reason: String },

    /// Encoding to or decoding from the wire proto representation failed.
    #[error("Invalid proto conversion: {0}")]
    ProtoConversion(#[from] ProtoConversionError),

    /// [`TridentClient::deposit`] was called with `amount == 0`.
    #[error("Deposit amount must be greater than zero")]
    ZeroDepositAmount,

    /// The delivery service did not report a deployment for `domain`, so
    /// per-deployment data (e.g. expiry minimums) could not be resolved.
    #[error("No deployment found for domain {domain}")]
    MissingDeployment { domain: Domain },
}

/// Client for the Trident cross-chain delivery service.
///
/// Holds the gRPC connection plus a per-domain chain handler materialized
/// during [`TridentClientBuilder::connect`]. Construct via
/// [`TridentClientBuilder`].
///
/// The crate provides [`From`] / [`TryFrom`] impls between
/// [`alloy_primitives::Address`], [`v1::MultiChainAddress`], [`v1::U256`],
/// [`v1::Bytes32`], and friends, so building gRPC requests and decoding
/// responses off the public `delivery` field is usually a one-liner via
/// `.into()` / `.try_into()` without any hand-rolled conversion helpers.
pub struct TridentClient {
    chains: HashMap<Domain, Arc<dyn ChainHandler>>,
    /// Live gRPC connection to the Trident attester. Use this field to talk to
    /// the attester directly for RPCs the SDK does not wrap — such as
    /// `get_token_routes`, `get_domain_routes`, or `get_sync_status` — on the
    /// same connection the rest of the SDK uses.
    pub delivery: DeliveryServiceClient<Channel>,
    pub(crate) expiry_margins: HashMap<Domain, u64>,
}

#[derive(Default)]
pub struct Balance {
    /// Total deposited amount.
    pub deposited_balance: U256,
    /// Amount available for new spends.
    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 {
    /// Submits a cross-chain spend intent to the delivery service.
    ///
    /// Fills in the source spender (from the configured signer), source deposit contract,
    /// destination delivery contract, a random salt, and — unless the builder sets them
    /// explicitly — the max fee (via [`Self::estimate_fees`]) and the expiry height (via
    /// the server's minimum plus the source domain's configured margin, defaulting to
    /// 10% of the minimum). Signs the intent with the source-domain signer and sends it
    /// over gRPC.
    ///
    /// Returns a [`SpendAttestation`] that the caller must submit on-chain via
    /// [`SpendAttestation::send`] to actually execute the spend.
    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 })
            }
        }
    }

    /// Prepares a deposit of `amount` of `token` into the domain's deposit contract.
    ///
    /// Returns a [`Deposit`] the caller must submit on-chain via [`Deposit::send`].
    /// Errors if `amount` is zero, no chain is registered for `domain`, or the handler
    /// cannot decode `token` for this chain family.
    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))
    }

    /// Queries the Trident balance held by `address` for `token` on `domain`.
    ///
    /// Returns [`U256::ZERO`] when no balance is recorded.
    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)
    }

    /// Estimates the total fees for the given spend request.
    ///
    /// Asks the delivery service for a fee breakdown using a placeholder intent built
    /// from the request (zero addresses for fields filled in by [`Self::spend`]) and
    /// returns the `total_fees` field.
    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)
    }

    /// Queries the server for the minimum spend intent expiry height on the
    /// given domain and adds the domain's configured expiry margin. Falls
    /// back to 10% of the server's minimum when no per-domain margin has been
    /// registered on the builder via
    /// [`TridentClientBuilder::with_expiry_margin`].
    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(),
        ))
    }

    /// Returns an owned handle to the chain handler registered for `domain`,
    /// or [`TridentClientError::MissingChain`] if none is registered.
    fn get_handler(&self, domain: Domain) -> Result<Arc<dyn ChainHandler>, TridentClientError> {
        self.chains
            .get(&domain)
            .cloned()
            .ok_or(TridentClientError::MissingChain { domain })
    }
}

/// Resolves the spend-intent expiry block from the server's minimum and an
/// optional per-domain margin override. When no override is provided, falls
/// back to 10% of `min` (integer division). Uses saturating addition so a
/// pathological `min` close to `u64::MAX` cannot overflow.
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 })
        ));
    }
}