diff --git a/Cargo.lock b/Cargo.lock index 2bf70c1010..fa4e478f99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7455,6 +7455,8 @@ dependencies = [ "sha2 0.11.0", "solana-primitives", "strum", + "sui-sdk-types", + "sui-transaction-builder", "tokio", "tracing", "typeshare", diff --git a/crates/gem_client/src/lib.rs b/crates/gem_client/src/lib.rs index e5e6ea4619..de467802c8 100644 --- a/crates/gem_client/src/lib.rs +++ b/crates/gem_client/src/lib.rs @@ -51,6 +51,14 @@ pub trait Client: Send + Sync + Debug { R: DeserializeOwned; } +pub trait ClientBounds: Client + Clone + Send + Sync + 'static {} + +impl ClientBounds for T where T: Client + Clone + Send + Sync + 'static {} + +pub trait DebugClientBounds: ClientBounds + Debug {} + +impl DebugClientBounds for T where T: ClientBounds + Debug {} + #[async_trait] pub trait ClientExt: Client { async fn get(&self, path: &str) -> Result diff --git a/crates/gem_sui/src/coin_type.rs b/crates/gem_sui/src/coin_type.rs new file mode 100644 index 0000000000..89c58e0023 --- /dev/null +++ b/crates/gem_sui/src/coin_type.rs @@ -0,0 +1,68 @@ +use crate::{SUI_COIN_TYPE, SUI_COIN_TYPE_FULL}; +use primitives::hex::decode_hex; + +const SUI_ADDRESS_LENGTH: usize = 32; + +pub fn full_coin_type(coin_type: &str) -> String { + let Some((prefix, rest)) = coin_type.split_once("::") else { + return coin_type.to_string(); + }; + match decode_hex(prefix) { + Ok(bytes) if bytes.len() <= SUI_ADDRESS_LENGTH => { + let mut padded = [0u8; SUI_ADDRESS_LENGTH]; + padded[SUI_ADDRESS_LENGTH - bytes.len()..].copy_from_slice(&bytes); + format!("0x{}::{rest}", hex::encode(padded)) + } + _ => coin_type.to_string(), + } +} + +pub fn coin_type_matches(a: &str, b: &str) -> bool { + full_coin_type(a) == full_coin_type(b) +} + +pub fn is_sui_coin(coin_type: &str) -> bool { + coin_type == SUI_COIN_TYPE || coin_type == SUI_COIN_TYPE_FULL +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_full_coin_type() { + assert_eq!( + full_coin_type("0x2::sui::SUI"), + "0x0000000000000000000000000000000000000000000000000000000000000002::sui::SUI" + ); + assert_eq!( + full_coin_type("2::sui::SUI"), + "0x0000000000000000000000000000000000000000000000000000000000000002::sui::SUI" + ); + assert_eq!( + full_coin_type("0x0000000000000000000000000000000000000000000000000000000000000002::sui::SUI"), + "0x0000000000000000000000000000000000000000000000000000000000000002::sui::SUI" + ); + assert_eq!(full_coin_type("0xabc"), "0xabc"); + assert_eq!(full_coin_type("not-a-type::coin::COIN"), "not-a-type::coin::COIN"); + } + + #[test] + fn test_coin_type_matches() { + assert!(coin_type_matches("0x2::sui::SUI", "0x2::sui::SUI")); + assert!(coin_type_matches("0x2::sui::SUI", "2::sui::SUI")); + assert!(coin_type_matches("2::sui::SUI", "0x2::sui::SUI")); + assert!(coin_type_matches( + "0x2::sui::SUI", + "0x0000000000000000000000000000000000000000000000000000000000000002::sui::SUI" + )); + assert!(!coin_type_matches("0x2::sui::SUI", "0x3::token::TOKEN")); + } + + #[test] + fn test_is_sui_coin() { + assert!(is_sui_coin(SUI_COIN_TYPE)); + assert!(is_sui_coin(SUI_COIN_TYPE_FULL)); + assert!(!is_sui_coin("0x3::token::TOKEN")); + } +} diff --git a/crates/gem_sui/src/error.rs b/crates/gem_sui/src/error.rs new file mode 100644 index 0000000000..17084a6915 --- /dev/null +++ b/crates/gem_sui/src/error.rs @@ -0,0 +1,29 @@ +use std::{ + error::Error, + fmt::{Display, Formatter}, +}; + +#[derive(Debug)] +pub enum SuiError { + InvalidInput(String), + InsufficientBalance, + NoGasCoins, +} + +impl Display for SuiError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidInput(message) => write!(f, "{message}"), + Self::InsufficientBalance => write!(f, "insufficient Sui coin balance"), + Self::NoGasCoins => write!(f, "No SUI coins available for gas"), + } + } +} + +impl Error for SuiError {} + +impl SuiError { + pub fn invalid_input(message: impl Into) -> Self { + Self::InvalidInput(message.into()) + } +} diff --git a/crates/gem_sui/src/jsonrpc.rs b/crates/gem_sui/src/jsonrpc.rs index 76afd0ecf9..8a26554a1d 100644 --- a/crates/gem_sui/src/jsonrpc.rs +++ b/crates/gem_sui/src/jsonrpc.rs @@ -96,6 +96,18 @@ impl Default for ObjectDataOptions { } } +impl ObjectDataOptions { + pub fn owner_only() -> Self { + Self { + show_type: false, + show_owner: true, + show_display: false, + show_content: false, + show_bcs: false, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SuiData { diff --git a/crates/gem_sui/src/lib.rs b/crates/gem_sui/src/lib.rs index a006c08ef2..d479b4b5f8 100644 --- a/crates/gem_sui/src/lib.rs +++ b/crates/gem_sui/src/lib.rs @@ -1,5 +1,7 @@ pub mod address; pub use address::validate_address; +pub mod coin_type; +pub use coin_type::{coin_type_matches, full_coin_type, is_sui_coin}; #[cfg(feature = "rpc")] pub mod rpc; #[cfg(feature = "rpc")] @@ -15,18 +17,21 @@ pub mod transfer_builder; #[cfg(feature = "rpc")] pub use transfer_builder::*; +pub mod error; pub mod gas_budget; pub mod jsonrpc; -pub mod operations; +pub mod tx_builder; #[cfg(feature = "signer")] pub mod signer; +pub use error::SuiError; use models::Coin; pub use models::ObjectId; -pub use operations::*; use std::error::Error; use sui_transaction_builder::ObjectInput; +pub use tx_builder::{decode_transaction, stake::*, transfer::*, validate_and_hash}; +pub use tx_builder::{stake, transfer}; pub const SUI_SYSTEM_ID: &str = "sui_system"; diff --git a/crates/gem_sui/src/models/coin_asset.rs b/crates/gem_sui/src/models/coin_asset.rs index 8c21ff3164..68fa216e29 100644 --- a/crates/gem_sui/src/models/coin_asset.rs +++ b/crates/gem_sui/src/models/coin_asset.rs @@ -1,9 +1,14 @@ use num_bigint::BigInt; use serde::{Deserialize, Serialize}; use serde_serializers::{deserialize_bigint_from_str, deserialize_u64_from_str, serialize_bigint, serialize_u64}; +#[cfg(feature = "rpc")] +use std::{error::Error, str::FromStr}; use sui_transaction_builder::ObjectInput; use sui_types::{Address, Digest}; +#[cfg(feature = "rpc")] +use super::SuiCoin; + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CoinAsset { @@ -22,6 +27,21 @@ impl CoinAsset { } } +#[cfg(feature = "rpc")] +impl TryFrom for CoinAsset { + type Error = Box; + + fn try_from(coin: SuiCoin) -> Result { + Ok(Self { + coin_object_id: Address::from_str(&coin.coin_object_id)?, + coin_type: coin.coin_type, + digest: Digest::from_str(&coin.digest)?, + balance: coin.balance, + version: coin.version.parse()?, + }) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CoinResponse { diff --git a/crates/gem_sui/src/models/transaction.rs b/crates/gem_sui/src/models/transaction.rs index e4fa0a9c61..3d09011074 100644 --- a/crates/gem_sui/src/models/transaction.rs +++ b/crates/gem_sui/src/models/transaction.rs @@ -24,6 +24,7 @@ pub struct SuiTransaction { #[serde(rename_all = "camelCase")] pub struct SuiStatus { pub status: String, + pub error: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/gem_sui/src/operations/mod.rs b/crates/gem_sui/src/operations/mod.rs deleted file mode 100644 index 218987d81d..0000000000 --- a/crates/gem_sui/src/operations/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod stake; -pub mod transfer; -pub mod tx; - -pub use stake::*; -pub use transfer::*; -pub use tx::*; diff --git a/crates/gem_sui/src/operations/tx.rs b/crates/gem_sui/src/operations/tx.rs deleted file mode 100644 index de18710f04..0000000000 --- a/crates/gem_sui/src/operations/tx.rs +++ /dev/null @@ -1,52 +0,0 @@ -use gem_encoding::decode_base64; -use serde::de::DeserializeOwned; -use std::error::Error; -use sui_transaction_builder::{ObjectInput, TransactionBuilder}; -use sui_types::Address; - -use crate::models::TxOutput; - -pub fn decode_transaction(tx: &str) -> Result> { - let bytes = decode_base64(tx)?; - let tx = bcs::from_bytes::(&bytes)?; - Ok(tx) -} - -pub fn validate_and_hash(encoded: &str) -> Result> { - if encoded.trim().is_empty() { - return Err("Missing Sui transaction data".into()); - } - - let tx = decode_transaction(encoded).map_err(|err| format!("Invalid Sui transaction payload: {err}"))?; - TxOutput::from_tx(&tx) -} - -pub fn fill_tx(ptb: &mut TransactionBuilder, sender_address: Address, gas_price: u64, gas_budget: u64, gas_objects: Vec) { - ptb.set_sender(sender_address); - ptb.set_gas_price(gas_price); - ptb.set_gas_budget(gas_budget); - ptb.add_gas_objects(gas_objects); -} - -#[cfg(test)] -mod tests { - use super::*; - use sui_types::{Transaction, TransactionKind}; - - #[test] - fn test_decode_transaction() { - let tx = "AAAPAAhkx5NBAAAAAAAIKUO8sgMAAAAAAQAAAQAAAQAACGTHk0EAAAAAAQFexM/GvrUlJRacMqd+FsKIt7/Lm4mCielL8xCFcLPvpBbjZwAAAAAAAQEB2qRikmMsPE2PMfI+oPmzaij/NnfpaEmA5EOEA6Z6PY8uBRgAAAAAAAABAYBJ0AkRYmmsBO4UIGt6/YtktYASefhUAe5LOXefgJE0zicvAAAAAAABAQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABgEAAAAAAAAAAAEB8ZTZsbytly5Fp91n3Umz7h4zV6AKUIUMUs1Ru0UOE7QXwmUAAAAAAAABASjkmd/16GSi6v5HYmmk9QNfHBbzONp74YsQNJmr8nHO7fIyAAAAAAABAQHwxA1nsHgADhgDIzTDMlxHueyfPZrkEovoINVGY9FOO+/yMgAAAAAAAQEBNdNbDlsXdZPYw6gBRiSFVy/DCGHmzpalWvbcRzBwknju8jIAAAAAAAAAIJP2W4wWwmM0O79mz5+O72nLHbyS0T8MMxsNyut2tKq2BgIAAQEAAADcFXIbqoK6ZIItWFpzSaFQj3bZSugOiZsG5INpwld1Dghzd2FwX2NhcBFvYnRhaW5fcm91dGVyX2NhcAIHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIDc3VpA1NVSQAH5COc2VH2xT2cQeJScNgNMfklrRZV5bpbVDhD1KZpde4EU1VJUARTVUlQAAUCAAABAQABAgABAwABBAAA3BVyG6qCumSCLVhac0mhUI922UroDombBuSDacJXdQ4Ic3dhcF9jYXANaW5pdGlhdGVfcGF0aAEHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIDc3VpA1NVSQACAgEAAQUAAB7GqMWsC4uXwofNNLn8apS1OgfJMKhQWVJnncjUs3gKBnJvdXRlchBzd2FwX2JfdG9fYV9ieV9iAwcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgNzdWkDU1VJAAfkI5zZUfbFPZxB4lJw2A0x+SWtFlXlultUOEPUpml17gRTVUlQBFNVSVAABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACA3N1aQNTVUkABgEGAAIBAAEHAAEIAAICAAEJAADcFXIbqoK6ZIItWFpzSaFQj3bZSugOiZsG5INpwld1Dghzd2FwX2NhcBFyZXR1cm5fcm91dGVyX2NhcAIHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIDc3VpA1NVSQAH5COc2VH2xT2cQeJScNgNMfklrRZV5bpbVDhD1KZpde4EU1VJUARTVUlQAAYCAQACAwABCgABCwABDAABDQABAQIDAAEOAJP2W4wWwmM0O79mz5+O72nLHbyS0T8MMxsNyut2tKq2AQAX1Cs2B1S8591qpdZjDUOB/CBDy2V8/6tqhBbwbdyxj734BAAAAAAg6yrtiW5R0TC68GDMmZye6U+KDjfZlq21n3bztRGzXjuT9luMFsJjNDu/Zs+fju9pyx28ktE/DDMbDcrrdrSqtu4CAAAAAAAA3P9fAAAAAAAA"; - let tx_data: Transaction = decode_transaction(tx).unwrap(); - - assert_eq!(tx_data.sender.to_string(), "0x93f65b8c16c263343bbf66cf9f8eef69cb1dbc92d13f0c331b0dcaeb76b4aab6"); - match tx_data.kind { - TransactionKind::ProgrammableTransaction(programmable) => { - assert_eq!(programmable.commands.len(), 6); - } - _ => panic!("wrong kind"), - } - - let output = validate_and_hash(tx).unwrap(); - assert_eq!(hex::encode(output.hash), "883f6f54145fdaf357e3d404a8353b1f6eda265bc2b28ec8178631e092c24e3b"); - } -} diff --git a/crates/gem_sui/src/provider/balances_mapper.rs b/crates/gem_sui/src/provider/balances_mapper.rs index 648ebe77bc..411939286c 100644 --- a/crates/gem_sui/src/provider/balances_mapper.rs +++ b/crates/gem_sui/src/provider/balances_mapper.rs @@ -1,6 +1,6 @@ use crate::models::Balance as SuiBalance; use crate::models::staking::SuiStakeDelegation; -use crate::{SUI_COIN_TYPE, SUI_COIN_TYPE_FULL}; +use crate::{coin_type_matches, is_sui_coin}; use num_bigint::BigUint; use primitives::{AssetBalance, AssetId, Balance, Chain}; @@ -60,7 +60,7 @@ pub fn map_assets_balances(balances: Vec) -> Vec { balances .into_iter() .filter_map(|balance| { - let asset_id = if balance.coin_type == SUI_COIN_TYPE || balance.coin_type == SUI_COIN_TYPE_FULL { + let asset_id = if is_sui_coin(&balance.coin_type) { None // Skip native coin as it's handled separately } else { Some(AssetId::from_token(Chain::Sui, &balance.coin_type)) @@ -71,14 +71,6 @@ pub fn map_assets_balances(balances: Vec) -> Vec { .collect() } -fn coin_type_matches(coin_type: &str, token_id: &str) -> bool { - // Remove 0x prefix and normalize for comparison - let coin_type_normalized = coin_type.strip_prefix("0x").unwrap_or(coin_type).to_lowercase(); - let token_id_normalized = token_id.strip_prefix("0x").unwrap_or(token_id).to_lowercase(); - - coin_type_normalized == token_id_normalized -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/gem_sui/src/rpc/client.rs b/crates/gem_sui/src/rpc/client.rs index b61549572b..810b90f0c1 100644 --- a/crates/gem_sui/src/rpc/client.rs +++ b/crates/gem_sui/src/rpc/client.rs @@ -123,6 +123,10 @@ impl SuiClient { Ok(self.client.call::>>("suix_getCoins", params).await?.data) } + pub async fn get_coin_assets_by_type(&self, address: &str, coin_type: &str) -> Result, Box> { + self.get_coins(address, coin_type).await?.into_iter().map(CoinAsset::try_from).collect() + } + pub async fn get_object(&self, object_id: String) -> Result> { let params = serde_json::json!([object_id, {"showContent": true}]); Ok(self.client.call::>("sui_getObject", params).await?.data) diff --git a/crates/gem_sui/src/transfer_builder.rs b/crates/gem_sui/src/transfer_builder.rs index 0a0bd73b31..619c3bc428 100644 --- a/crates/gem_sui/src/transfer_builder.rs +++ b/crates/gem_sui/src/transfer_builder.rs @@ -2,7 +2,7 @@ use crate::{ ESTIMATION_GAS_BUDGET, SUI_COIN_TYPE, SuiClient, gas_budget::GAS_BUDGET_MULTIPLIER, models::{Coin, Gas, TokenTransferInput, TransferInput}, - operations::{encode_token_transfer, encode_transfer}, + tx_builder::{encode_token_transfer, encode_transfer}, }; use futures::try_join; use gem_client::Client; diff --git a/crates/gem_sui/src/tx_builder/input.rs b/crates/gem_sui/src/tx_builder/input.rs new file mode 100644 index 0000000000..7c7e81ddfa --- /dev/null +++ b/crates/gem_sui/src/tx_builder/input.rs @@ -0,0 +1,52 @@ +#[cfg(feature = "rpc")] +use crate::{SUI_COIN_TYPE, SuiClient, SuiError, models::CoinAsset}; +#[cfg(feature = "rpc")] +use gem_client::Client; +#[cfg(feature = "rpc")] +use num_traits::ToPrimitive; +use sui_transaction_builder::ObjectInput; + +#[derive(Clone)] +pub struct TransactionBuilderInput { + pub sender: String, + pub gas_price: u64, + pub gas_budget: u64, + pub gas_objects: Vec, +} + +impl TransactionBuilderInput { + pub fn new(sender: impl Into, gas_price: u64, gas_budget: u64, gas_objects: Vec) -> Self { + Self { + sender: sender.into(), + gas_price, + gas_budget, + gas_objects, + } + } + + pub fn with_gas_budget(&self, gas_budget: u64) -> Self { + let mut input = self.clone(); + input.gas_budget = gas_budget; + input + } + + #[cfg(feature = "rpc")] + pub async fn prefetch(client: &SuiClient, sender: &str, gas_budget: u64) -> Result { + let gas_price = client + .get_gas_price() + .await + .map_err(|err| SuiError::invalid_input(err.to_string()))? + .to_u64() + .ok_or_else(|| SuiError::invalid_input("Sui gas price overflow"))?; + let gas_coins = client + .get_coin_assets_by_type(sender, SUI_COIN_TYPE) + .await + .map_err(|err| SuiError::invalid_input(err.to_string()))?; + if gas_coins.is_empty() { + return Err(SuiError::NoGasCoins); + } + let gas_objects = gas_coins.iter().map(CoinAsset::to_input).collect(); + + Ok(Self::new(sender, gas_price, gas_budget, gas_objects)) + } +} diff --git a/crates/gem_sui/src/tx_builder/mod.rs b/crates/gem_sui/src/tx_builder/mod.rs new file mode 100644 index 0000000000..fe99fc7642 --- /dev/null +++ b/crates/gem_sui/src/tx_builder/mod.rs @@ -0,0 +1,17 @@ +mod input; +#[cfg(feature = "rpc")] +pub(crate) mod object_resolver; +#[cfg(feature = "rpc")] +mod prefetch; +pub mod stake; +mod transaction; +pub mod transfer; + +pub use input::TransactionBuilderInput; +#[cfg(feature = "rpc")] +pub use object_resolver::ObjectResolver; +#[cfg(feature = "rpc")] +pub use prefetch::PrefetchedTransactionData; +pub use stake::*; +pub use transaction::{build_input_coin, decode_transaction, finish_transaction, move_call, validate_and_hash, zero_coin}; +pub use transfer::*; diff --git a/crates/gem_sui/src/tx_builder/object_resolver.rs b/crates/gem_sui/src/tx_builder/object_resolver.rs new file mode 100644 index 0000000000..8cddfbbcb2 --- /dev/null +++ b/crates/gem_sui/src/tx_builder/object_resolver.rs @@ -0,0 +1,58 @@ +use crate::{ + SuiClient, SuiError, + jsonrpc::{DataObject, ObjectDataOptions, SuiRpc}, + models::ResultData, +}; +use gem_client::Client; +use std::{ + collections::{BTreeSet, HashMap}, + str::FromStr, +}; +use sui_transaction_builder::{Argument, ObjectInput, TransactionBuilder}; +use sui_types::Address; + +pub struct ObjectResolver { + shared_versions: HashMap, +} + +impl ObjectResolver { + pub async fn prefetch(client: &SuiClient, object_ids: Vec, pinned: &HashMap) -> Result { + let unique_ids: Vec = object_ids.into_iter().collect::>().into_iter().collect(); + let missing: Vec = unique_ids.iter().filter(|id| !pinned.contains_key(*id)).cloned().collect(); + + let fetched: Vec>> = if missing.is_empty() { + Vec::new() + } else { + client + .rpc_call(SuiRpc::GetMultipleObjects(missing.clone(), Some(ObjectDataOptions::owner_only()))) + .await + .map_err(|err| SuiError::invalid_input(err.to_string()))? + }; + + let mut shared_versions: HashMap = fetched + .into_iter() + .zip(&missing) + .filter_map(|(result, id)| result.data.initial_shared_version().map(|version| (id.clone(), version))) + .collect(); + for id in &unique_ids { + if let Some(&version) = pinned.get(id) { + shared_versions.insert(id.clone(), version); + } + } + Ok(Self { shared_versions }) + } + + pub fn shared_object_input(&self, object_id: &str, mutable: bool) -> Result { + let version = self + .shared_versions + .get(object_id) + .copied() + .ok_or_else(|| SuiError::invalid_input(format!("Sui shared object was not prefetched: {object_id}")))?; + let address = Address::from_str(object_id).map_err(|err| SuiError::invalid_input(format!("Invalid Sui address {object_id}: {err}")))?; + Ok(ObjectInput::shared(address, version, mutable)) + } + + pub fn shared_object(&self, txb: &mut TransactionBuilder, object_id: &str, mutable: bool) -> Result { + Ok(txb.object(self.shared_object_input(object_id, mutable)?)) + } +} diff --git a/crates/gem_sui/src/tx_builder/prefetch.rs b/crates/gem_sui/src/tx_builder/prefetch.rs new file mode 100644 index 0000000000..cfeed54f8c --- /dev/null +++ b/crates/gem_sui/src/tx_builder/prefetch.rs @@ -0,0 +1,59 @@ +use super::{ObjectResolver, TransactionBuilderInput}; +use crate::{SuiClient, SuiError, is_sui_coin, models::CoinAsset}; +use futures::try_join; +use gem_client::Client; +use std::collections::HashMap; + +pub struct PrefetchedTransactionData { + pub transaction: TransactionBuilderInput, + pub input_coins: Vec, + pub output_coin: Option, + pub resolver: ObjectResolver, +} + +impl PrefetchedTransactionData { + pub async fn prefetch( + client: &SuiClient, + sender: &str, + input_coin_type: &str, + output_coin_type: &str, + object_ids: Vec, + pinned: &HashMap, + gas_budget: u64, + ) -> Result { + let (transaction, input_coins, output_coin, resolver) = try_join!( + TransactionBuilderInput::prefetch(client, sender, gas_budget), + fetch_input_coins(client, sender, input_coin_type), + fetch_output_coin(client, sender, output_coin_type), + ObjectResolver::prefetch(client, object_ids, pinned), + )?; + + Ok(Self { + transaction, + input_coins, + output_coin, + resolver, + }) + } +} + +async fn fetch_input_coins(client: &SuiClient, owner: &str, coin_type: &str) -> Result, SuiError> { + if is_sui_coin(coin_type) { + Ok(Vec::new()) + } else { + client.get_coin_assets_by_type(owner, coin_type).await.map_err(|err| SuiError::invalid_input(err.to_string())) + } +} + +async fn fetch_output_coin(client: &SuiClient, owner: &str, coin_type: &str) -> Result, SuiError> { + if is_sui_coin(coin_type) { + Ok(None) + } else { + Ok(client + .get_coin_assets_by_type(owner, coin_type) + .await + .map_err(|err| SuiError::invalid_input(err.to_string()))? + .into_iter() + .next()) + } +} diff --git a/crates/gem_sui/src/operations/stake.rs b/crates/gem_sui/src/tx_builder/stake.rs similarity index 87% rename from crates/gem_sui/src/operations/stake.rs rename to crates/gem_sui/src/tx_builder/stake.rs index 4d94894dbd..596e0c4982 100644 --- a/crates/gem_sui/src/operations/stake.rs +++ b/crates/gem_sui/src/tx_builder/stake.rs @@ -8,10 +8,12 @@ use std::{error::Error, str::FromStr}; use sui_transaction_builder::{Function, ObjectInput, TransactionBuilder}; use sui_types::{Address, Identifier}; +use super::{TransactionBuilderInput, finish_transaction}; + pub const SUI_REQUEST_ADD_STAKE: &str = "request_add_stake"; pub const SUI_REQUEST_WITHDRAW_STAKE: &str = "request_withdraw_stake"; -fn build_split_and_stake_ptb(input: &StakeInput) -> Result<(TransactionBuilder, Address), Box> { +fn build_split_and_stake_ptb(input: &StakeInput) -> Result> { if let Some(err) = crate::validate_enough_balance(&input.coins, input.stake_amount) { return Err(err); } @@ -21,7 +23,6 @@ fn build_split_and_stake_ptb(input: &StakeInput) -> Result<(TransactionBuilder, return Err("stake amount is too small".into()); } - let sender = Address::from_str(&input.sender)?; let validator = Address::from_str(&input.validator)?; let mut ptb = TransactionBuilder::new(); @@ -39,32 +40,24 @@ fn build_split_and_stake_ptb(input: &StakeInput) -> Result<(TransactionBuilder, Identifier::new(SUI_REQUEST_ADD_STAKE).unwrap(), ); - // Get system state object let sys_state = ptb.object(sui_system_state_object_input()); let validator_argument = ptb.pure(&validator); ptb.move_call(function, vec![sys_state, split_result, validator_argument]); - Ok((ptb, sender)) + Ok(ptb) } pub fn encode_split_and_stake(input: &StakeInput) -> Result> { - let (mut ptb, sender) = build_split_and_stake_ptb(input)?; - crate::tx::fill_tx( - &mut ptb, - sender, - input.gas.price, - input.gas.budget, - input.coins.iter().map(|x| x.object.to_input()).collect(), - ); - let tx = ptb.try_build()?; - TxOutput::from_tx(&tx) + let ptb = build_split_and_stake_ptb(input)?; + let gas_objects = input.coins.iter().map(|x| x.object.to_input()).collect::>(); + finish_transaction(ptb, TransactionBuilderInput::new(input.sender.as_str(), input.gas.price, input.gas.budget, gas_objects)) + .map_err(|err| Box::new(err) as Box) } -fn build_unstake_ptb(input: &UnstakeInput) -> Result<(TransactionBuilder, Address, ObjectInput), Box> { +fn build_unstake_ptb(input: &UnstakeInput) -> Result<(TransactionBuilder, ObjectInput), Box> { let mut ptb = TransactionBuilder::new(); - let sender = Address::from_str(&input.sender)?; let staked_sui = ptb.object(ObjectInput::owned( input.staked_sui.object_id.parse().unwrap(), input.staked_sui.version, @@ -81,19 +74,17 @@ fn build_unstake_ptb(input: &UnstakeInput) -> Result<(TransactionBuilder, Addres Identifier::new(SUI_REQUEST_WITHDRAW_STAKE).unwrap(), ); - // Get system state object let sys_state = ptb.object(sui_system_state_object_input()); ptb.move_call(function, vec![sys_state, staked_sui]); - Ok((ptb, sender, gas_coin)) + Ok((ptb, gas_coin)) } pub fn encode_unstake(input: &UnstakeInput) -> Result> { - let (mut ptb, sender, gas_coin) = build_unstake_ptb(input)?; - crate::tx::fill_tx(&mut ptb, sender, input.gas.price, input.gas.budget, vec![gas_coin]); - let tx = ptb.try_build()?; - TxOutput::from_tx(&tx) + let (ptb, gas_coin) = build_unstake_ptb(input)?; + finish_transaction(ptb, TransactionBuilderInput::new(input.sender.as_str(), input.gas.price, input.gas.budget, vec![gas_coin])) + .map_err(|err| Box::new(err) as Box) } #[cfg(test)] @@ -102,7 +93,7 @@ mod tests { use crate::{ SUI_COIN_TYPE, models::{Coin, Gas, Object}, - tx::decode_transaction, + tx_builder::decode_transaction, }; use gem_encoding::encode_base64; use sui_types::Transaction; diff --git a/crates/gem_sui/src/tx_builder/transaction.rs b/crates/gem_sui/src/tx_builder/transaction.rs new file mode 100644 index 0000000000..3e1a61f841 --- /dev/null +++ b/crates/gem_sui/src/tx_builder/transaction.rs @@ -0,0 +1,113 @@ +use super::TransactionBuilderInput; +use crate::{ + SuiError, is_sui_coin, + models::{CoinAsset, TxOutput}, +}; +use gem_encoding::decode_base64; +use num_traits::ToPrimitive; +use serde::de::DeserializeOwned; +use std::{error::Error, str::FromStr}; +use sui_transaction_builder::{Argument, Function, TransactionBuilder}; +use sui_types::{Address, Identifier, TypeTag}; + +const MODULE_COIN: &str = "coin"; +const FUNCTION_ZERO: &str = "zero"; + +pub fn move_call(txb: &mut TransactionBuilder, package: &str, module: &str, function: &str, type_args: &[&str], arguments: Vec) -> Result { + let type_args = type_args + .iter() + .map(|value| { + value + .parse::() + .map_err(|err| SuiError::invalid_input(format!("Invalid Sui type argument {value}: {err}"))) + }) + .collect::, _>>()?; + let function = Function::new( + Address::from_str(package).map_err(|err| SuiError::invalid_input(format!("Invalid Sui address {package}: {err}")))?, + Identifier::new(module).map_err(|err| SuiError::invalid_input(err.to_string()))?, + Identifier::new(function).map_err(|err| SuiError::invalid_input(err.to_string()))?, + ) + .with_type_args(type_args); + Ok(txb.move_call(function, arguments)) +} + +pub fn zero_coin(txb: &mut TransactionBuilder, coin_type: &str) -> Result { + move_call(txb, "0x2", MODULE_COIN, FUNCTION_ZERO, &[coin_type], vec![]) +} + +pub fn build_input_coin(txb: &mut TransactionBuilder, coin_type: &str, amount: u64, from_coins: &[CoinAsset]) -> Result { + if amount == 0 { + return zero_coin(txb, coin_type); + } + + if is_sui_coin(coin_type) { + let amount = txb.pure(&amount); + let gas = txb.gas(); + return txb.split_coins(gas, vec![amount]).pop().ok_or_else(|| SuiError::invalid_input("Sui split coin failed")); + } + + let total = from_coins.iter().try_fold(0_u64, |total, coin| { + let balance = coin.balance.to_u64().ok_or_else(|| SuiError::invalid_input("Sui coin balance overflow"))?; + total.checked_add(balance).ok_or_else(|| SuiError::invalid_input("Sui coin balance overflow")) + })?; + if total < amount { + return Err(SuiError::InsufficientBalance); + } + + let mut coin_args = from_coins.iter().map(|coin| Ok(txb.object(coin.to_input()))).collect::, SuiError>>()?; + let coin = coin_args.first().copied().ok_or(SuiError::InsufficientBalance)?; + if coin_args.len() > 1 { + txb.merge_coins(coin, coin_args.split_off(1)); + } + + let amount = txb.pure(&amount); + txb.split_coins(coin, vec![amount]).pop().ok_or_else(|| SuiError::invalid_input("Sui split coin failed")) +} + +pub fn finish_transaction(mut txb: TransactionBuilder, input: TransactionBuilderInput) -> Result { + txb.set_sender(Address::from_str(&input.sender).map_err(|err| SuiError::invalid_input(format!("Invalid Sui address {}: {err}", input.sender)))?); + txb.set_gas_price(input.gas_price); + txb.set_gas_budget(input.gas_budget); + txb.add_gas_objects(input.gas_objects); + + let tx = txb.try_build().map_err(|err| SuiError::invalid_input(err.to_string()))?; + TxOutput::from_tx(&tx).map_err(|err| SuiError::invalid_input(err.to_string())) +} + +pub fn decode_transaction(encoded: &str) -> Result> { + let bytes = decode_base64(encoded)?; + let transaction = bcs::from_bytes::(&bytes)?; + Ok(transaction) +} + +pub fn validate_and_hash(encoded: &str) -> Result> { + if encoded.trim().is_empty() { + return Err("Missing Sui transaction data".into()); + } + + let transaction = decode_transaction(encoded).map_err(|err| format!("Invalid Sui transaction payload: {err}"))?; + TxOutput::from_tx(&transaction) +} + +#[cfg(test)] +mod tests { + use super::*; + use sui_types::{Transaction, TransactionKind}; + + #[test] + fn test_decode_transaction() { + let encoded = "AAAPAAhkx5NBAAAAAAAIKUO8sgMAAAAAAQAAAQAAAQAACGTHk0EAAAAAAQFexM/GvrUlJRacMqd+FsKIt7/Lm4mCielL8xCFcLPvpBbjZwAAAAAAAQEB2qRikmMsPE2PMfI+oPmzaij/NnfpaEmA5EOEA6Z6PY8uBRgAAAAAAAABAYBJ0AkRYmmsBO4UIGt6/YtktYASefhUAe5LOXefgJE0zicvAAAAAAABAQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABgEAAAAAAAAAAAEB8ZTZsbytly5Fp91n3Umz7h4zV6AKUIUMUs1Ru0UOE7QXwmUAAAAAAAABASjkmd/16GSi6v5HYmmk9QNfHBbzONp74YsQNJmr8nHO7fIyAAAAAAABAQHwxA1nsHgADhgDIzTDMlxHueyfPZrkEovoINVGY9FOO+/yMgAAAAAAAQEBNdNbDlsXdZPYw6gBRiSFVy/DCGHmzpalWvbcRzBwknju8jIAAAAAAAAAIJP2W4wWwmM0O79mz5+O72nLHbyS0T8MMxsNyut2tKq2BgIAAQEAAADcFXIbqoK6ZIItWFpzSaFQj3bZSugOiZsG5INpwld1Dghzd2FwX2NhcBFvYnRhaW5fcm91dGVyX2NhcAIHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIDc3VpA1NVSQAH5COc2VH2xT2cQeJScNgNMfklrRZV5bpbVDhD1KZpde4EU1VJUARTVUlQAAUCAAABAQABAgABAwABBAAA3BVyG6qCumSCLVhac0mhUI922UroDombBuSDacJXdQ4Ic3dhcF9jYXANaW5pdGlhdGVfcGF0aAEHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIDc3VpA1NVSQACAgEAAQUAAB7GqMWsC4uXwofNNLn8apS1OgfJMKhQWVJnncjUs3gKBnJvdXRlchBzd2FwX2JfdG9fYV9ieV9iAwcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgNzdWkDU1VJAAfkI5zZUfbFPZxB4lJw2A0x+SWtFlXlultUOEPUpml17gRTVUlQBFNVSVAABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACA3N1aQNTVUkABgEGAAIBAAEHAAEIAAICAAEJAADcFXIbqoK6ZIItWFpzSaFQj3bZSugOiZsG5INpwld1Dghzd2FwX2NhcBFyZXR1cm5fcm91dGVyX2NhcAIHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIDc3VpA1NVSQAH5COc2VH2xT2cQeJScNgNMfklrRZV5bpbVDhD1KZpde4EU1VJUARTVUlQAAYCAQACAwABCgABCwABDAABDQABAQIDAAEOAJP2W4wWwmM0O79mz5+O72nLHbyS0T8MMxsNyut2tKq2AQAX1Cs2B1S8591qpdZjDUOB/CBDy2V8/6tqhBbwbdyxj734BAAAAAAg6yrtiW5R0TC68GDMmZye6U+KDjfZlq21n3bztRGzXjuT9luMFsJjNDu/Zs+fju9pyx28ktE/DDMbDcrrdrSqtu4CAAAAAAAA3P9fAAAAAAAA"; + let transaction: Transaction = decode_transaction(encoded).unwrap(); + + assert_eq!(transaction.sender.to_string(), "0x93f65b8c16c263343bbf66cf9f8eef69cb1dbc92d13f0c331b0dcaeb76b4aab6"); + match transaction.kind { + TransactionKind::ProgrammableTransaction(programmable) => { + assert_eq!(programmable.commands.len(), 6); + } + _ => panic!("wrong kind"), + } + + let output = validate_and_hash(encoded).unwrap(); + assert_eq!(hex::encode(output.hash), "883f6f54145fdaf357e3d404a8353b1f6eda265bc2b28ec8178631e092c24e3b"); + } +} diff --git a/crates/gem_sui/src/operations/transfer.rs b/crates/gem_sui/src/tx_builder/transfer.rs similarity index 88% rename from crates/gem_sui/src/operations/transfer.rs rename to crates/gem_sui/src/tx_builder/transfer.rs index bbc889e82f..e21d00504d 100644 --- a/crates/gem_sui/src/operations/transfer.rs +++ b/crates/gem_sui/src/tx_builder/transfer.rs @@ -4,12 +4,13 @@ use std::str::FromStr; use sui_transaction_builder::{Argument, ObjectInput, TransactionBuilder}; use sui_types::Address; -fn build_transfer_ptb(input: &TransferInput) -> Result<(TransactionBuilder, Address), Box> { +use super::{TransactionBuilderInput, finish_transaction}; + +fn build_transfer_ptb(input: &TransferInput) -> Result> { if let Some(err) = crate::validate_enough_balance(&input.coins, input.amount) { return Err(err); } - let sender = Address::from_str(&input.sender)?; let recipient = Address::from_str(&input.recipient)?; let mut ptb = TransactionBuilder::new(); @@ -28,24 +29,21 @@ fn build_transfer_ptb(input: &TransferInput) -> Result<(TransactionBuilder, Addr ptb.transfer_objects(vec![split_result], recipient_argument); } - Ok((ptb, sender)) + Ok(ptb) } pub fn encode_transfer(input: &TransferInput) -> Result> { - let (mut ptb, sender) = build_transfer_ptb(input)?; - let coins = input.coins.iter().map(|x| x.object.to_input()).collect::>(); - super::tx::fill_tx(&mut ptb, sender, input.gas.price, input.gas.budget, coins); - let tx = ptb.try_build()?; - - TxOutput::from_tx(&tx) + let ptb = build_transfer_ptb(input)?; + let gas_objects = input.coins.iter().map(|x| x.object.to_input()).collect::>(); + finish_transaction(ptb, TransactionBuilderInput::new(input.sender.as_str(), input.gas.price, input.gas.budget, gas_objects)) + .map_err(|err| Box::new(err) as Box) } -fn build_token_transfer_ptb(input: &TokenTransferInput) -> Result<(TransactionBuilder, Address), Box> { +fn build_token_transfer_ptb(input: &TokenTransferInput) -> Result> { if let Some(err) = crate::validate_enough_balance(&input.tokens, input.amount) { return Err(err); } let mut ptb = TransactionBuilder::new(); - let sender = Address::from_str(&input.sender)?; let recipient = Address::from_str(&input.recipient)?; if input.tokens.is_empty() { @@ -69,24 +67,23 @@ fn build_token_transfer_ptb(input: &TokenTransferInput) -> Result<(TransactionBu let recipient_argument = ptb.pure(&recipient); ptb.transfer_objects(vec![split_result], recipient_argument); - Ok((ptb, sender)) + Ok(ptb) } pub fn encode_token_transfer(input: &TokenTransferInput) -> Result> { - let (mut ptb, sender) = build_token_transfer_ptb(input)?; + let ptb = build_token_transfer_ptb(input)?; let gas_coin = ObjectInput::immutable( input.gas_coin.object.object_id.parse().unwrap(), input.gas_coin.object.version, input.gas_coin.object.digest.parse().unwrap(), ); - super::tx::fill_tx(&mut ptb, sender, input.gas.price, input.gas.budget, vec![gas_coin]); - let tx = ptb.try_build()?; - TxOutput::from_tx(&tx) + finish_transaction(ptb, TransactionBuilderInput::new(input.sender.as_str(), input.gas.price, input.gas.budget, vec![gas_coin])) + .map_err(|err| Box::new(err) as Box) } #[cfg(test)] mod tests { - use crate::{SUI_COIN_TYPE, tx::decode_transaction}; + use crate::{SUI_COIN_TYPE, tx_builder::decode_transaction}; use gem_encoding::encode_base64; use sui_types::Transaction; diff --git a/crates/swapper/Cargo.toml b/crates/swapper/Cargo.toml index 704b72e1d6..62a447775e 100644 --- a/crates/swapper/Cargo.toml +++ b/crates/swapper/Cargo.toml @@ -24,6 +24,8 @@ gem_client = { path = "../gem_client" } gem_hypercore = { path = "../gem_hypercore" } serde_serializers = { path = "../serde_serializers", features = ["bigint"] } number_formatter = { path = "../number_formatter" } +sui-types = { workspace = true } +sui-transaction-builder = { package = "sui-transaction-builder", version = "0.3.0" } reqwest = { workspace = true, optional = true } typeshare = { version = "1.0.4" } diff --git a/crates/swapper/src/cetus/client.rs b/crates/swapper/src/cetus/client.rs new file mode 100644 index 0000000000..cc86b604b6 --- /dev/null +++ b/crates/swapper/src/cetus/client.rs @@ -0,0 +1,46 @@ +use super::{ + constants::{BLUEFIN, CETUS, CETUS_DLMM, DEEPBOOK_V3, ROUTER_API_VERSION}, + model::{RouterData, RouterRequest, RouterResponse}, +}; +use crate::SwapperError; +use gem_client::{ClientBounds, ClientExt, build_path_with_query}; + +#[derive(Debug)] +pub struct CetusClient +where + C: ClientBounds, +{ + client: C, +} + +impl CetusClient +where + C: ClientBounds, +{ + pub fn new(client: C) -> Self { + Self { client } + } + + pub async fn get_router(&self, from: String, target: String, amount: String) -> Result { + let request = RouterRequest { + from, + target, + amount, + by_amount_in: true, + providers: [CETUS, CETUS_DLMM, DEEPBOOK_V3, BLUEFIN].join(","), + v: ROUTER_API_VERSION, + }; + let path = build_path_with_query("/find_routes", &request)?; + let response: RouterResponse = self.client.get(&path).await?; + + match response { + RouterResponse::Ok { data } => Ok(data), + RouterResponse::Err { code, msg } => { + if code == 5000 || msg.to_ascii_lowercase().contains("liquidity") { + return Err(SwapperError::NoQuoteAvailable); + } + Err(SwapperError::ComputeQuoteError(msg)) + } + } + } +} diff --git a/crates/swapper/src/cetus/constants.rs b/crates/swapper/src/cetus/constants.rs new file mode 100644 index 0000000000..e5a52d0f27 --- /dev/null +++ b/crates/swapper/src/cetus/constants.rs @@ -0,0 +1,41 @@ +use std::{collections::HashMap, sync::LazyLock}; + +pub const CETUS: &str = "CETUS"; +pub const CETUS_DLMM: &str = "CETUSDLMM"; +pub const DEEPBOOK_V3: &str = "DEEPBOOKV3"; +pub const BLUEFIN: &str = "BLUEFIN"; + +pub const DEFAULT_AGGREGATOR_PATH: &str = "cetus/router_v3"; +pub const ROUTER_API_VERSION: u32 = 1_010_502; + +pub const AGGREGATOR_V3_PACKAGE: &str = "aggregator_v3"; +pub const DEFAULT_AGGREGATOR_V3: &str = "0xde5d696a79714ca5cb910b9aed99d41f67353abb00715ceaeb0663d57ee39640"; + +pub const CETUS_GLOBAL_CONFIG: &str = "0xdaa46292632c3c4d8f31f23ea0f9b36a28ff3677e9684980e4438403a67a3d8f"; +pub const CETUS_PARTNER: &str = "0x08b1875b6541c847f05ed71d04cbcfa66e4e8619bf3b8923b07c5b5409433366"; +pub const CETUS_DLMM_GLOBAL_CONFIG: &str = "0xf31b605d117f959b9730e8c07b08b856cb05143c5e81d5751c90d2979e82f599"; +pub const CETUS_DLMM_PARTNER: &str = "0x59ae16f6c42f578063c2da9b9c0173fe58120ceae08e40fd8212c8eceb80bb86"; +pub const CETUS_DLMM_VERSIONED: &str = "0x05370b2d656612dd5759cbe80463de301e3b94a921dfc72dd9daa2ecdeb2d0a8"; +pub const BLUEFIN_GLOBAL_CONFIG: &str = "0x03db251ba509a8d5d8777b6338836082335d93eecbdd09a11e190a1cff51c352"; +pub const DEEPBOOK_V3_GLOBAL_CONFIG: &str = "0x699d455ab8c5e02075b4345ea1f91be55bf46064ae6026cc2528e701ce3ac135"; +pub const DEEPBOOK_V3_DEEP_FEE_TYPE: &str = "0xdeeb7a4662eec9f2f3def03fb937a663dddaa2e215b8078a284d026b7946c270::deep::DEEP"; + +pub const CETUS_GLOBAL_CONFIG_INIT_VER: u64 = 1_574_190; +pub const CETUS_PARTNER_INIT_VER: u64 = 507_739_678; +pub const CETUS_DLMM_GLOBAL_CONFIG_INIT_VER: u64 = 640_780_258; +pub const CETUS_DLMM_PARTNER_INIT_VER: u64 = 643_251_782; +pub const CETUS_DLMM_VERSIONED_INIT_VER: u64 = 640_780_258; +pub const BLUEFIN_GLOBAL_CONFIG_INIT_VER: u64 = 406_496_849; +pub const DEEPBOOK_V3_GLOBAL_CONFIG_INIT_VER: u64 = 526_005_852; + +pub static PINNED_VERSIONS: LazyLock> = LazyLock::new(|| { + HashMap::from([ + (CETUS_GLOBAL_CONFIG.to_string(), CETUS_GLOBAL_CONFIG_INIT_VER), + (CETUS_PARTNER.to_string(), CETUS_PARTNER_INIT_VER), + (CETUS_DLMM_GLOBAL_CONFIG.to_string(), CETUS_DLMM_GLOBAL_CONFIG_INIT_VER), + (CETUS_DLMM_PARTNER.to_string(), CETUS_DLMM_PARTNER_INIT_VER), + (CETUS_DLMM_VERSIONED.to_string(), CETUS_DLMM_VERSIONED_INIT_VER), + (BLUEFIN_GLOBAL_CONFIG.to_string(), BLUEFIN_GLOBAL_CONFIG_INIT_VER), + (DEEPBOOK_V3_GLOBAL_CONFIG.to_string(), DEEPBOOK_V3_GLOBAL_CONFIG_INIT_VER), + ]) +}); diff --git a/crates/swapper/src/cetus/mod.rs b/crates/swapper/src/cetus/mod.rs new file mode 100644 index 0000000000..d58d2904dd --- /dev/null +++ b/crates/swapper/src/cetus/mod.rs @@ -0,0 +1,9 @@ +pub mod client; +pub mod constants; +pub mod model; +pub mod provider; +#[cfg(test)] +pub(crate) mod testkit; +pub mod tx_builder; + +pub use provider::Cetus; diff --git a/crates/swapper/src/cetus/model.rs b/crates/swapper/src/cetus/model.rs new file mode 100644 index 0000000000..3472af0781 --- /dev/null +++ b/crates/swapper/src/cetus/model.rs @@ -0,0 +1,165 @@ +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +use super::constants::{AGGREGATOR_V3_PACKAGE, DEFAULT_AGGREGATOR_V3}; + +#[derive(Debug, Clone, Serialize)] +pub struct RouterRequest { + pub from: String, + pub target: String, + pub amount: String, + pub by_amount_in: bool, + pub providers: String, + pub v: u32, +} + +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum RouterResponse { + Ok { data: RouterData }, + Err { code: u32, msg: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RouterData { + pub request_id: String, + pub amount_out: u64, + pub paths: Vec, + pub packages: Option>, +} + +impl RouterData { + pub fn aggregator_v3(&self) -> String { + self.packages + .as_ref() + .and_then(|packages| packages.get(AGGREGATOR_V3_PACKAGE)) + .cloned() + .unwrap_or_else(|| DEFAULT_AGGREGATOR_V3.to_string()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Path { + pub id: String, + pub direction: bool, + pub provider: String, + pub from: String, + pub target: String, + pub amount_in: u64, + pub published_at: Option, + pub extended_details: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ExtendedDetails { + pub deepbookv3_need_add_deep_price_point: Option, + pub deepbookv3_reference_pool_id: Option, + pub deepbookv3_reference_pool_base_type: Option, + pub deepbookv3_reference_pool_quote_type: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct FlattenedPath { + pub path: Path, + pub is_last_use_of_intermediate_token: bool, +} + +impl FlattenedPath { + pub fn amount_in(&self) -> u64 { + if self.is_last_use_of_intermediate_token { u64::MAX } else { self.path.amount_in } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ProcessedRouterData { + pub request_id: String, + pub from_coin_type: String, + pub target_coin_type: String, + pub flattened_paths: Vec, +} + +impl TryFrom<&RouterData> for ProcessedRouterData { + type Error = crate::SwapperError; + + fn try_from(router: &RouterData) -> Result { + let first = router.paths.first().ok_or(crate::SwapperError::InvalidRoute)?; + let last = router.paths.last().ok_or(crate::SwapperError::InvalidRoute)?; + let mut flattened_paths: Vec<_> = router + .paths + .iter() + .cloned() + .map(|path| FlattenedPath { + path, + is_last_use_of_intermediate_token: false, + }) + .collect(); + + let mut seen_tokens = BTreeMap::new(); + for flattened_path in flattened_paths.iter_mut().rev() { + if !seen_tokens.contains_key(&flattened_path.path.from) { + seen_tokens.insert(flattened_path.path.from.clone(), true); + flattened_path.is_last_use_of_intermediate_token = true; + } + } + + Ok(Self { + request_id: router.request_id.clone(), + from_coin_type: first.from.clone(), + target_coin_type: last.target.clone(), + flattened_paths, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + fn router_response() -> RouterResponse { + serde_json::from_str(include_str!("testdata/router_response.json")).unwrap() + } + + fn router_data() -> RouterData { + match router_response() { + RouterResponse::Ok { data } => data, + RouterResponse::Err { .. } => panic!("Expected router response"), + } + } + + #[test] + fn test_parse_router_response() { + let data = router_data(); + + assert_eq!(data.request_id, "quote-id"); + assert_eq!(data.amount_out, 1916345); + assert_eq!(data.paths[0].amount_in, 300000000); + assert_eq!(data.aggregator_v3(), "0xaggregator"); + let value: Value = serde_json::to_value(&data).unwrap(); + assert_eq!(value.get("request_id").and_then(Value::as_str), Some("quote-id")); + } + + #[test] + fn test_process_flattened_paths() { + let data = router_data(); + let processed = ProcessedRouterData::try_from(&data).unwrap(); + + assert_eq!(processed.from_coin_type, "0x2::sui::SUI"); + assert_eq!(processed.target_coin_type, "0xdba::usdc::USDC"); + assert_eq!( + processed.flattened_paths.iter().map(|path| path.is_last_use_of_intermediate_token).collect::>(), + vec![false, true, true] + ); + } + + #[test] + fn test_parse_error_response() { + assert_eq!( + serde_json::from_str::(r#"{"code":5000,"msg":"Insufficient liquidity"}"#).unwrap(), + RouterResponse::Err { + code: 5000, + msg: "Insufficient liquidity".to_string(), + } + ); + } +} diff --git a/crates/swapper/src/cetus/provider.rs b/crates/swapper/src/cetus/provider.rs new file mode 100644 index 0000000000..e04045bf1b --- /dev/null +++ b/crates/swapper/src/cetus/provider.rs @@ -0,0 +1,144 @@ +use super::{client::CetusClient, constants::DEFAULT_AGGREGATOR_PATH, model::RouterData, tx_builder}; +use crate::{ + FetchQuoteData, ProviderData, ProviderType, Quote, QuoteRequest, Route, RpcClient, RpcProvider, Swapper, SwapperChainAsset, SwapperError, SwapperProvider, SwapperQuoteData, + client_factory::create_client_with_chain, + config::get_swap_proxy_url, + fees::{ReferralFee, apply_slippage_in_bp, default_referral_fees, quote_value_after_reserve_by_chain}, +}; +use async_trait::async_trait; +use gem_client::DebugClientBounds; +use gem_sui::{SUI_COIN_TYPE, SuiClient, full_coin_type}; +use primitives::Chain; +use std::{fmt::Debug, sync::Arc}; + +pub struct Cetus +where + C: DebugClientBounds, + R: DebugClientBounds, +{ + provider: ProviderType, + cetus_client: CetusClient, + sui_client: SuiClient, +} + +impl Debug for Cetus +where + C: DebugClientBounds, + R: DebugClientBounds, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Cetus").field("provider", &self.provider).finish() + } +} + +impl Cetus { + pub fn new(rpc_provider: Arc) -> Self { + let cetus_client = RpcClient::new(get_swap_proxy_url(DEFAULT_AGGREGATOR_PATH), rpc_provider.clone()); + let sui_client = create_client_with_chain(rpc_provider, Chain::Sui); + Self::with_clients(CetusClient::new(cetus_client), SuiClient::new(sui_client)) + } +} + +impl Cetus +where + C: DebugClientBounds, + R: DebugClientBounds, +{ + pub fn with_clients(cetus_client: CetusClient, sui_client: SuiClient) -> Self { + Self { + provider: ProviderType::new(SwapperProvider::CetusAggregator), + cetus_client, + sui_client, + } + } + + fn referral_fee(request: &QuoteRequest) -> ReferralFee { + request.options.fee.clone().map(|fees| fees.sui).unwrap_or_else(|| default_referral_fees().sui) + } +} + +#[async_trait] +impl Swapper for Cetus +where + C: DebugClientBounds, + R: DebugClientBounds, +{ + fn provider(&self) -> &ProviderType { + &self.provider + } + + fn supported_assets(&self) -> Vec { + vec![SwapperChainAsset::All(Chain::Sui)] + } + + async fn get_quote(&self, request: &QuoteRequest) -> Result { + let from_value = quote_value_after_reserve_by_chain(request)?; + let from_asset = request.from_asset.asset_id(); + let to_asset = request.to_asset.asset_id(); + if from_asset.chain != Chain::Sui || to_asset.chain != Chain::Sui { + return Err(SwapperError::NotSupportedChain); + } + let from = full_coin_type(from_asset.token_id.as_deref().unwrap_or(SUI_COIN_TYPE)); + let target = full_coin_type(to_asset.token_id.as_deref().unwrap_or(SUI_COIN_TYPE)); + let route = self.cetus_client.get_router(from, target, from_value.clone()).await?; + let referral_fee = Self::referral_fee(request); + let output_value = apply_slippage_in_bp(&route.amount_out, referral_fee.bps).to_string(); + + Ok(Quote { + from_value, + to_value: output_value, + data: ProviderData { + provider: self.provider().clone(), + routes: vec![Route { + input: from_asset, + output: to_asset, + route_data: serde_json::to_string(&route)?, + }], + slippage_bps: request.options.slippage.bps, + }, + request: request.clone(), + eta_in_seconds: Some(0), + }) + } + + async fn get_quote_data(&self, quote: &Quote, _data: FetchQuoteData) -> Result { + let route = quote.data.routes.first().ok_or(SwapperError::InvalidRoute)?; + let router: RouterData = serde_json::from_str(&route.route_data).map_err(|_| SwapperError::InvalidRoute)?; + tx_builder::build_quote_data(&self.sui_client, quote, &router, &Self::referral_fee("e.request)).await + } +} + +#[cfg(all(test, feature = "swap_integration_tests"))] +mod swap_integration_tests { + use super::*; + use crate::{FetchQuoteData, SwapperQuoteAsset, alien::reqwest_provider::NativeProvider, models::Options}; + use primitives::{AssetId, asset_constants::SUI_USDC_TOKEN_ID}; + + const TEST_WALLET: &str = "0xa9bd0493f9bd1f792a4aedc1f99d54535a75a46c38fd56a8f2c6b7c8d75817a1"; + + #[tokio::test] + async fn test_cetus_provider_fetch_quote_and_data() -> Result<(), SwapperError> { + let rpc_provider = Arc::new(NativeProvider::default()); + let provider = Cetus::new(rpc_provider); + let request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Sui)), + to_asset: SwapperQuoteAsset::from(AssetId::from(Chain::Sui, Some(SUI_USDC_TOKEN_ID.to_string()))), + wallet_address: TEST_WALLET.to_string(), + destination_address: TEST_WALLET.to_string(), + value: "1500000000".to_string(), + options: Options::new_with_slippage(50.into()), + }; + + let quote = provider.get_quote(&request).await?; + let quote_data = provider.get_quote_data("e, FetchQuoteData::None).await?; + + println!("Cetus quote: {quote:#?}"); + println!("Cetus quote data: {quote_data:#?}"); + + assert!(quote.to_value.parse::().unwrap() > 0); + assert!(!quote_data.data.is_empty()); + assert!(quote_data.gas_limit.is_some()); + + Ok(()) + } +} diff --git a/crates/swapper/src/cetus/testdata/router_response.json b/crates/swapper/src/cetus/testdata/router_response.json new file mode 100644 index 0000000000..ef36b5b168 --- /dev/null +++ b/crates/swapper/src/cetus/testdata/router_response.json @@ -0,0 +1,44 @@ +{ + "code": 200, + "msg": "Success", + "data": { + "request_id": "quote-id", + "amount_in": 1500000000, + "amount_out": 1916345, + "paths": [ + { + "id": "0xpool1", + "provider": "BLUEFIN", + "from": "0x2::sui::SUI", + "target": "0xabc::coin::A", + "direction": false, + "amount_in": 300000000, + "amount_out": 290361611, + "published_at": "0xbluefin" + }, + { + "id": "0xpool2", + "provider": "CETUS", + "from": "0x2::sui::SUI", + "target": "0xdef::coin::B", + "direction": false, + "amount_in": 1200000000, + "amount_out": 400, + "published_at": "0xcetus" + }, + { + "id": "0xpool3", + "provider": "CETUS", + "from": "0xdef::coin::B", + "target": "0xdba::usdc::USDC", + "direction": true, + "amount_in": 400, + "amount_out": 100, + "published_at": "0xcetus" + } + ], + "packages": { + "aggregator_v3": "0xaggregator" + } + } +} diff --git a/crates/swapper/src/cetus/testkit.rs b/crates/swapper/src/cetus/testkit.rs new file mode 100644 index 0000000000..ccb152ab9f --- /dev/null +++ b/crates/swapper/src/cetus/testkit.rs @@ -0,0 +1,72 @@ +use crate::{ + ProviderData, ProviderType, Quote, QuoteRequest, Route, SwapperProvider, SwapperQuoteAsset, + cetus::{ + constants::CETUS, + model::{FlattenedPath, Path, RouterData}, + }, + fees::ReferralFee, + models::Options, +}; +use primitives::{AssetId, Chain}; +use sui_types::Address; + +pub(crate) fn quote(slippage_bps: u32) -> Quote { + Quote { + from_value: "1000".to_string(), + to_value: "2000".to_string(), + data: ProviderData { + provider: ProviderType::new(SwapperProvider::CetusAggregator), + routes: vec![Route { + input: AssetId::from_chain(Chain::Sui), + output: AssetId::from_chain(Chain::Sui), + route_data: String::new(), + }], + slippage_bps, + }, + request: QuoteRequest { + from_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Sui)), + to_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Sui)), + wallet_address: Address::ZERO.to_string(), + destination_address: Address::ZERO.to_string(), + value: "1000".to_string(), + options: Options::default(), + }, + eta_in_seconds: Some(0), + } +} + +pub(crate) fn router(amount_out: u64) -> RouterData { + RouterData { + request_id: "quote".to_string(), + amount_out, + paths: vec![], + packages: None, + } +} + +pub(crate) fn route_path(direction: bool, published_at: Option) -> Path { + Path { + id: "0x1".to_string(), + direction, + provider: CETUS.to_string(), + from: "0x2::sui::SUI".to_string(), + target: "0xabc::coin::A".to_string(), + amount_in: 123, + published_at, + extended_details: None, + } +} + +pub(crate) fn flattened_path(path: Path, is_last_use_of_intermediate_token: bool) -> FlattenedPath { + FlattenedPath { + path, + is_last_use_of_intermediate_token, + } +} + +pub(crate) fn referral_fee(bps: u32) -> ReferralFee { + ReferralFee { + address: Address::ZERO.to_string(), + bps, + } +} diff --git a/crates/swapper/src/cetus/tx_builder/constants.rs b/crates/swapper/src/cetus/tx_builder/constants.rs new file mode 100644 index 0000000000..03ce9be53a --- /dev/null +++ b/crates/swapper/src/cetus/tx_builder/constants.rs @@ -0,0 +1,11 @@ +pub(super) const MODULE_ROUTER: &str = "router"; +pub(super) const MODULE_CETUS: &str = "cetus"; +pub(super) const MODULE_CETUS_DLMM: &str = "cetus_dlmm"; +pub(super) const MODULE_BLUEFIN: &str = "bluefin"; +pub(super) const MODULE_DEEPBOOK_V3: &str = "deepbookv3"; + +pub(super) const FUNCTION_NEW_SWAP_CONTEXT: &str = "new_swap_context"; +pub(super) const FUNCTION_CONFIRM_SWAP: &str = "confirm_swap"; +pub(super) const FUNCTION_TRANSFER_OR_DESTROY_COIN: &str = "transfer_or_destroy_coin"; +pub(super) const FUNCTION_SWAP: &str = "swap"; +pub(super) const FUNCTION_ADD_DEEP_PRICE_POINT: &str = "add_deep_price_point_v2"; diff --git a/crates/swapper/src/cetus/tx_builder/error.rs b/crates/swapper/src/cetus/tx_builder/error.rs new file mode 100644 index 0000000000..ebf73665c9 --- /dev/null +++ b/crates/swapper/src/cetus/tx_builder/error.rs @@ -0,0 +1,11 @@ +use crate::SwapperError; +use gem_sui::SuiError; +use std::fmt::Display; + +pub(super) fn tx_error(error: impl Display) -> SwapperError { + SwapperError::TransactionError(error.to_string()) +} + +pub(super) fn sui_error(error: SuiError) -> SwapperError { + SwapperError::TransactionError(error.to_string()) +} diff --git a/crates/swapper/src/cetus/tx_builder/mod.rs b/crates/swapper/src/cetus/tx_builder/mod.rs new file mode 100644 index 0000000000..99c9b75024 --- /dev/null +++ b/crates/swapper/src/cetus/tx_builder/mod.rs @@ -0,0 +1,8 @@ +mod constants; +mod error; +mod model; +mod quote_data; +mod swap; +mod transaction; + +pub use quote_data::build_quote_data; diff --git a/crates/swapper/src/cetus/tx_builder/model.rs b/crates/swapper/src/cetus/tx_builder/model.rs new file mode 100644 index 0000000000..80981bbeda --- /dev/null +++ b/crates/swapper/src/cetus/tx_builder/model.rs @@ -0,0 +1,102 @@ +use crate::{ + Quote, SwapperError, + cetus::model::{FlattenedPath, Path, RouterData}, + fees::{ReferralFee, apply_slippage_in_bp}, +}; +use sui_types::Address; + +pub(super) struct SwapStep<'a> { + pub(super) path: &'a Path, + pub(super) coin_a: &'a str, + pub(super) coin_b: &'a str, + pub(super) amount_in: u64, + pub(super) published_at: &'a str, +} + +impl<'a> TryFrom<&'a FlattenedPath> for SwapStep<'a> { + type Error = SwapperError; + + fn try_from(flattened_path: &'a FlattenedPath) -> Result { + let path = &flattened_path.path; + let (coin_a, coin_b) = if path.direction { + (path.from.as_str(), path.target.as_str()) + } else { + (path.target.as_str(), path.from.as_str()) + }; + Ok(Self { + path, + coin_a, + coin_b, + amount_in: flattened_path.amount_in(), + published_at: path.published_at.as_deref().ok_or(SwapperError::InvalidRoute)?, + }) + } +} + +pub(super) struct SwapLimits { + pub(super) expected_amount_out: u64, + pub(super) amount_out_limit: u64, + pub(super) fee_rate: u32, + pub(super) fee_recipient: Address, +} + +impl SwapLimits { + pub(super) fn new(quote: &Quote, router: &RouterData, referral_fee: &ReferralFee) -> Result { + let expected_amount_out = apply_slippage_in_bp(&router.amount_out, referral_fee.bps); + let amount_out_limit = apply_slippage_in_bp(&expected_amount_out, quote.data.slippage_bps); + let fee_rate = referral_fee + .bps + .checked_mul(100) + .ok_or_else(|| SwapperError::ComputeQuoteError("Cetus referral fee overflow".to_string()))?; + let fee_recipient = if referral_fee.address.is_empty() { + if fee_rate > 0 { + return Err(SwapperError::ComputeQuoteError("Cetus referral address is required".to_string())); + } + Address::ZERO + } else { + referral_fee + .address + .parse() + .map_err(|err| SwapperError::TransactionError(format!("Invalid Sui address {}: {err}", referral_fee.address)))? + }; + + Ok(Self { + expected_amount_out, + amount_out_limit, + fee_rate, + fee_recipient, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cetus::testkit::{flattened_path, quote, referral_fee, route_path, router}; + + #[test] + fn test_swap_step_and_limits() { + let fee = referral_fee(50); + let limits = SwapLimits::new("e(100), &router(10000), &fee).unwrap(); + assert_eq!(limits.expected_amount_out, 9950); + assert_eq!(limits.amount_out_limit, 9850); + assert_eq!(limits.fee_rate, 5000); + assert_eq!(limits.fee_recipient, Address::ZERO); + + let base_path = route_path(false, Some("0x1".to_string())); + let flattened_path_value = flattened_path(base_path.clone(), false); + let step = SwapStep::try_from(&flattened_path_value).unwrap(); + assert_eq!(step.coin_a, "0xabc::coin::A"); + assert_eq!(step.coin_b, "0x2::sui::SUI"); + assert_eq!(step.amount_in, 123); + assert_eq!(step.published_at, "0x1"); + + let last_intermediate_use = flattened_path(base_path, true); + let step = SwapStep::try_from(&last_intermediate_use).unwrap(); + assert_eq!(step.amount_in, u64::MAX); + + let missing_published_at_path = flattened_path(route_path(true, None), false); + let missing_published_at = SwapStep::try_from(&missing_published_at_path); + assert!(matches!(missing_published_at, Err(SwapperError::InvalidRoute))); + } +} diff --git a/crates/swapper/src/cetus/tx_builder/quote_data.rs b/crates/swapper/src/cetus/tx_builder/quote_data.rs new file mode 100644 index 0000000000..49eff59301 --- /dev/null +++ b/crates/swapper/src/cetus/tx_builder/quote_data.rs @@ -0,0 +1,65 @@ +use super::{ + error::sui_error, + swap::shared_object_ids, + transaction::{BuildInput, build_transaction}, +}; +use crate::{ + Quote, SwapperError, SwapperQuoteData, + cetus::{constants::PINNED_VERSIONS, model::RouterData}, + fees::ReferralFee, +}; +use gem_client::ClientBounds; +use gem_sui::{ESTIMATION_GAS_BUDGET, SuiClient, gas_budget::GAS_BUDGET_MULTIPLIER, tx_builder::PrefetchedTransactionData}; + +pub async fn build_quote_data(client: &SuiClient, quote: &Quote, router: &RouterData, referral_fee: &ReferralFee) -> Result { + let sender = quote.request.wallet_address.as_str(); + let from_coin_type = router.paths.first().ok_or(SwapperError::InvalidRoute)?.from.clone(); + let target_coin_type = router.paths.last().ok_or(SwapperError::InvalidRoute)?.target.clone(); + let amount = quote.from_value.parse::()?; + let prefetched = PrefetchedTransactionData::prefetch( + client, + sender, + &from_coin_type, + &target_coin_type, + shared_object_ids(router)?, + &PINNED_VERSIONS, + ESTIMATION_GAS_BUDGET, + ) + .await + .map_err(sui_error)?; + + let input = BuildInput { + transaction: prefetched.transaction.clone(), + from_coin_type: &from_coin_type, + target_coin_type: &target_coin_type, + amount, + from_coins: &prefetched.input_coins, + target_merge_coin: prefetched.output_coin.as_ref(), + }; + + let estimate = build_transaction(&prefetched.resolver, quote, router, referral_fee, &input)?; + let dry_run = client + .dry_run(estimate.base64_encoded()) + .await + .map_err(|err| SwapperError::TransactionError(err.to_string()))?; + if dry_run.effects.status.status != "success" { + let detail = dry_run.effects.status.error.as_deref().unwrap_or("no details available"); + return Err(SwapperError::TransactionError(format!("Sui swap simulation failed: {detail}"))); + } + + let fee = dry_run + .effects + .gas_used + .calculate_gas_budget() + .map_err(|err| SwapperError::TransactionError(err.to_string()))?; + let gas_budget = fee * GAS_BUDGET_MULTIPLIER / 100; + let output = build_transaction(&prefetched.resolver, quote, router, referral_fee, &input.with_gas_budget(gas_budget))?; + + Ok(SwapperQuoteData::new_contract( + String::new(), + "0".to_string(), + output.base64_encoded(), + None, + Some(gas_budget.to_string()), + )) +} diff --git a/crates/swapper/src/cetus/tx_builder/swap/bluefin.rs b/crates/swapper/src/cetus/tx_builder/swap/bluefin.rs new file mode 100644 index 0000000000..10b7196c1a --- /dev/null +++ b/crates/swapper/src/cetus/tx_builder/swap/bluefin.rs @@ -0,0 +1,15 @@ +use super::{finalize_swap, prepare_swap_inputs}; +use super::super::constants::MODULE_BLUEFIN; +use crate::{ + SwapperError, + cetus::{constants::BLUEFIN_GLOBAL_CONFIG, model::FlattenedPath}, +}; +use gem_sui::tx_builder::ObjectResolver; +use sui_transaction_builder::{Argument, TransactionBuilder}; + +// Move sig: `::bluefin::swap(swap_context, global_config, pool, direction, amount_in, clock)` +// Source: https://github.com/CetusProtocol/aggregator/blob/main/src/movecall/bluefin.ts +pub(super) fn build_swap(txb: &mut TransactionBuilder, resolver: &ObjectResolver, flattened_path: &FlattenedPath, swap_context: Argument) -> Result<(), SwapperError> { + let s = prepare_swap_inputs(txb, resolver, flattened_path, BLUEFIN_GLOBAL_CONFIG)?; + finalize_swap(txb, &s.step, MODULE_BLUEFIN, vec![swap_context, s.global_config, s.pool, s.direction, s.amount_in, s.clock]) +} diff --git a/crates/swapper/src/cetus/tx_builder/swap/cetus.rs b/crates/swapper/src/cetus/tx_builder/swap/cetus.rs new file mode 100644 index 0000000000..c5f1f5c3a9 --- /dev/null +++ b/crates/swapper/src/cetus/tx_builder/swap/cetus.rs @@ -0,0 +1,36 @@ +use super::{finalize_swap, prepare_swap_inputs}; +use super::super::{ + constants::{MODULE_CETUS, MODULE_CETUS_DLMM}, + error::tx_error, +}; +use crate::{ + SwapperError, + cetus::{ + constants::{CETUS_DLMM_GLOBAL_CONFIG, CETUS_DLMM_PARTNER, CETUS_DLMM_VERSIONED, CETUS_GLOBAL_CONFIG, CETUS_PARTNER}, + model::FlattenedPath, + }, +}; +use gem_sui::tx_builder::ObjectResolver; +use sui_transaction_builder::{Argument, TransactionBuilder}; + +// Move sig: `::cetus::swap(swap_context, global_config, pool, partner, direction, amount_in, clock)` +// Source: https://github.com/CetusProtocol/aggregator/blob/main/src/movecall/cetus.ts +pub(super) fn build_clmm_swap(txb: &mut TransactionBuilder, resolver: &ObjectResolver, flattened_path: &FlattenedPath, swap_context: Argument) -> Result<(), SwapperError> { + let s = prepare_swap_inputs(txb, resolver, flattened_path, CETUS_GLOBAL_CONFIG)?; + let partner = resolver.shared_object(txb, CETUS_PARTNER, true).map_err(tx_error)?; + finalize_swap(txb, &s.step, MODULE_CETUS, vec![swap_context, s.global_config, s.pool, partner, s.direction, s.amount_in, s.clock]) +} + +// Move sig: `::cetus_dlmm::swap(swap_context, global_config, pool, partner, direction, amount_in, versioned, clock)` +// Source: https://github.com/CetusProtocol/aggregator/blob/main/src/movecall/cetus_dlmm.ts +pub(super) fn build_dlmm_swap(txb: &mut TransactionBuilder, resolver: &ObjectResolver, flattened_path: &FlattenedPath, swap_context: Argument) -> Result<(), SwapperError> { + let s = prepare_swap_inputs(txb, resolver, flattened_path, CETUS_DLMM_GLOBAL_CONFIG)?; + let partner = resolver.shared_object(txb, CETUS_DLMM_PARTNER, true).map_err(tx_error)?; + let versioned = resolver.shared_object(txb, CETUS_DLMM_VERSIONED, false).map_err(tx_error)?; + finalize_swap( + txb, + &s.step, + MODULE_CETUS_DLMM, + vec![swap_context, s.global_config, s.pool, partner, s.direction, s.amount_in, versioned, s.clock], + ) +} diff --git a/crates/swapper/src/cetus/tx_builder/swap/deepbook.rs b/crates/swapper/src/cetus/tx_builder/swap/deepbook.rs new file mode 100644 index 0000000000..fd1146296b --- /dev/null +++ b/crates/swapper/src/cetus/tx_builder/swap/deepbook.rs @@ -0,0 +1,64 @@ +use super::{finalize_swap, prepare_swap_inputs}; +use super::super::{ + constants::{FUNCTION_ADD_DEEP_PRICE_POINT, MODULE_DEEPBOOK_V3}, + error::tx_error, +}; +use crate::{ + SwapperError, + cetus::{ + constants::{DEEPBOOK_V3_DEEP_FEE_TYPE, DEEPBOOK_V3_GLOBAL_CONFIG}, + model::{ExtendedDetails, FlattenedPath, Path}, + }, +}; +use gem_sui::{ + sui_clock_object_input, + tx_builder::{ObjectResolver, move_call, zero_coin}, +}; +use sui_transaction_builder::{Argument, TransactionBuilder}; + +// Move sigs: +// `::deepbookv3::swap(swap_context, global_config, pool, amount_in, direction, deep_coin, clock)` +// `::deepbookv3::add_deep_price_point_v2(pool, reference_pool, clock)` +// Source: https://github.com/CetusProtocol/aggregator/blob/main/src/movecall/deepbook_v3.ts +pub(super) fn build_swap(txb: &mut TransactionBuilder, resolver: &ObjectResolver, flattened_path: &FlattenedPath, swap_context: Argument) -> Result<(), SwapperError> { + let path = &flattened_path.path; + if path.extended_details.as_ref().and_then(|d| d.deepbookv3_need_add_deep_price_point).unwrap_or(false) { + add_deep_price_point(txb, resolver, path, path.extended_details.as_ref().ok_or(SwapperError::InvalidRoute)?)?; + } + + let s = prepare_swap_inputs(txb, resolver, flattened_path, DEEPBOOK_V3_GLOBAL_CONFIG)?; + let deep_coin = zero_coin(txb, DEEPBOOK_V3_DEEP_FEE_TYPE).map_err(tx_error)?; + // deepbook uses (amount_in, direction) order, with deep_coin appended before clock. + finalize_swap( + txb, + &s.step, + MODULE_DEEPBOOK_V3, + vec![swap_context, s.global_config, s.pool, s.amount_in, s.direction, deep_coin, s.clock], + ) +} + +fn add_deep_price_point(txb: &mut TransactionBuilder, resolver: &ObjectResolver, path: &Path, details: &ExtendedDetails) -> Result<(), SwapperError> { + let published_at = path.published_at.as_deref().ok_or(SwapperError::InvalidRoute)?; + let reference_pool_id = details.deepbookv3_reference_pool_id.as_deref().ok_or(SwapperError::InvalidRoute)?; + let reference_pool_base_type = details.deepbookv3_reference_pool_base_type.as_deref().ok_or(SwapperError::InvalidRoute)?; + let reference_pool_quote_type = details.deepbookv3_reference_pool_quote_type.as_deref().ok_or(SwapperError::InvalidRoute)?; + let (coin_a, coin_b) = if path.direction { + (path.from.as_str(), path.target.as_str()) + } else { + (path.target.as_str(), path.from.as_str()) + }; + let pool = resolver.shared_object(txb, &path.id, true).map_err(tx_error)?; + let reference_pool = resolver.shared_object(txb, reference_pool_id, true).map_err(tx_error)?; + let clock = txb.object(sui_clock_object_input()); + + move_call( + txb, + published_at, + MODULE_DEEPBOOK_V3, + FUNCTION_ADD_DEEP_PRICE_POINT, + &[coin_a, coin_b, reference_pool_base_type, reference_pool_quote_type], + vec![pool, reference_pool, clock], + ) + .map_err(tx_error)?; + Ok(()) +} diff --git a/crates/swapper/src/cetus/tx_builder/swap/mod.rs b/crates/swapper/src/cetus/tx_builder/swap/mod.rs new file mode 100644 index 0000000000..9b4baa47ab --- /dev/null +++ b/crates/swapper/src/cetus/tx_builder/swap/mod.rs @@ -0,0 +1,231 @@ +mod bluefin; +mod cetus; +mod deepbook; + +use super::{ + constants::{FUNCTION_CONFIRM_SWAP, FUNCTION_NEW_SWAP_CONTEXT, FUNCTION_SWAP, MODULE_ROUTER}, + error::tx_error, + model::{SwapLimits, SwapStep}, +}; +use crate::{ + Quote, SwapperError, + cetus::{ + constants::{ + BLUEFIN, BLUEFIN_GLOBAL_CONFIG, CETUS, CETUS_DLMM, CETUS_DLMM_GLOBAL_CONFIG, CETUS_DLMM_PARTNER, CETUS_DLMM_VERSIONED, CETUS_GLOBAL_CONFIG, CETUS_PARTNER, + DEEPBOOK_V3, DEEPBOOK_V3_GLOBAL_CONFIG, + }, + model::{FlattenedPath, ProcessedRouterData, RouterData}, + }, + fees::ReferralFee, +}; +use gem_sui::{ + sui_clock_object_input, + tx_builder::{ObjectResolver, move_call}, +}; +use std::collections::BTreeSet; +use sui_transaction_builder::{Argument, TransactionBuilder}; + +pub(super) struct SwapInputs<'a> { + pub step: SwapStep<'a>, + pub global_config: Argument, + pub pool: Argument, + pub direction: Argument, + pub amount_in: Argument, + pub clock: Argument, +} + +pub(super) fn prepare_swap_inputs<'a>( + txb: &mut TransactionBuilder, + resolver: &ObjectResolver, + flattened_path: &'a FlattenedPath, + global_config_id: &str, +) -> Result, SwapperError> { + let step = SwapStep::try_from(flattened_path)?; + let global_config = resolver.shared_object(txb, global_config_id, true).map_err(tx_error)?; + let pool = resolver.shared_object(txb, &step.path.id, true).map_err(tx_error)?; + let direction = txb.pure(&step.path.direction); + let amount_in = txb.pure(&step.amount_in); + let clock = txb.object(sui_clock_object_input()); + Ok(SwapInputs { + step, + global_config, + pool, + direction, + amount_in, + clock, + }) +} + +pub(super) fn finalize_swap(txb: &mut TransactionBuilder, step: &SwapStep<'_>, module: &str, args: Vec) -> Result<(), SwapperError> { + move_call(txb, step.published_at, module, FUNCTION_SWAP, &[step.coin_a, step.coin_b], args).map_err(tx_error)?; + Ok(()) +} + +pub(super) fn shared_object_ids(router: &RouterData) -> Result, SwapperError> { + let mut object_ids = BTreeSet::new(); + for path in &router.paths { + match path.provider.as_str() { + CETUS => { + object_ids.insert(CETUS_GLOBAL_CONFIG.to_string()); + object_ids.insert(path.id.clone()); + object_ids.insert(CETUS_PARTNER.to_string()); + } + CETUS_DLMM => { + object_ids.insert(CETUS_DLMM_GLOBAL_CONFIG.to_string()); + object_ids.insert(path.id.clone()); + object_ids.insert(CETUS_DLMM_PARTNER.to_string()); + object_ids.insert(CETUS_DLMM_VERSIONED.to_string()); + } + BLUEFIN => { + object_ids.insert(BLUEFIN_GLOBAL_CONFIG.to_string()); + object_ids.insert(path.id.clone()); + } + DEEPBOOK_V3 => { + object_ids.insert(DEEPBOOK_V3_GLOBAL_CONFIG.to_string()); + object_ids.insert(path.id.clone()); + if path + .extended_details + .as_ref() + .and_then(|details| details.deepbookv3_need_add_deep_price_point) + .unwrap_or(false) + { + let reference_pool_id = path + .extended_details + .as_ref() + .and_then(|details| details.deepbookv3_reference_pool_id.as_ref()) + .ok_or(SwapperError::InvalidRoute)?; + object_ids.insert(reference_pool_id.clone()); + } + } + provider => return Err(SwapperError::TransactionError(format!("Unsupported Cetus route provider: {provider}"))), + } + } + Ok(object_ids.into_iter().collect()) +} + +pub(super) fn build_swap( + txb: &mut TransactionBuilder, + resolver: &ObjectResolver, + quote: &Quote, + router: &RouterData, + referral_fee: &ReferralFee, + input_coin: Argument, +) -> Result { + let processed = ProcessedRouterData::try_from(router)?; + let limits = SwapLimits::new(quote, router, referral_fee)?; + let request_id = txb.pure(&processed.request_id); + let expected_amount_out = txb.pure(&limits.expected_amount_out); + let amount_out_limit = txb.pure(&limits.amount_out_limit); + let fee_rate = txb.pure(&limits.fee_rate); + let fee_recipient = txb.pure(&limits.fee_recipient); + let swap_context = move_call( + txb, + &router.aggregator_v3(), + MODULE_ROUTER, + FUNCTION_NEW_SWAP_CONTEXT, + &[&processed.from_coin_type, &processed.target_coin_type], + vec![request_id, expected_amount_out, amount_out_limit, input_coin, fee_rate, fee_recipient], + ) + .map_err(tx_error)?; + + for flattened_path in &processed.flattened_paths { + match flattened_path.path.provider.as_str() { + CETUS => cetus::build_clmm_swap(txb, resolver, flattened_path, swap_context)?, + CETUS_DLMM => cetus::build_dlmm_swap(txb, resolver, flattened_path, swap_context)?, + BLUEFIN => bluefin::build_swap(txb, resolver, flattened_path, swap_context)?, + DEEPBOOK_V3 => deepbook::build_swap(txb, resolver, flattened_path, swap_context)?, + provider => return Err(SwapperError::TransactionError(format!("Unsupported Cetus route provider: {provider}"))), + } + } + + move_call( + txb, + &router.aggregator_v3(), + MODULE_ROUTER, + FUNCTION_CONFIRM_SWAP, + &[&processed.target_coin_type], + vec![swap_context], + ) + .map_err(tx_error) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cetus::{ + constants::DEEPBOOK_V3, + model::{ExtendedDetails, RouterResponse}, + testkit::{route_path, router}, + }; + + fn fixture_router() -> RouterData { + match serde_json::from_str::(include_str!("../../testdata/router_response.json")).unwrap() { + RouterResponse::Ok { data } => data, + RouterResponse::Err { .. } => panic!("Expected router response"), + } + } + + #[test] + fn test_shared_object_ids() { + let mut router_data = router(1000); + router_data.paths = vec![route_path(true, Some("0x1".to_string()))]; + + let object_ids: BTreeSet = shared_object_ids(&router_data).unwrap().into_iter().collect(); + let expected: BTreeSet = [CETUS_PARTNER, "0x1", CETUS_GLOBAL_CONFIG].iter().map(|s| s.to_string()).collect(); + + assert_eq!(object_ids, expected); + + let fixture_ids: BTreeSet = shared_object_ids(&fixture_router()).unwrap().into_iter().collect(); + let expected_fixture: BTreeSet = [ + BLUEFIN_GLOBAL_CONFIG, + CETUS_GLOBAL_CONFIG, + CETUS_PARTNER, + "0xpool1", + "0xpool2", + "0xpool3", + ] + .iter() + .map(|s| s.to_string()) + .collect(); + assert_eq!(fixture_ids, expected_fixture); + + let mut deepbook_router = router(1000); + let mut path = route_path(true, Some("0xdb".to_string())); + path.provider = DEEPBOOK_V3.to_string(); + path.id = "0xdb_pool".to_string(); + path.extended_details = Some(ExtendedDetails { + deepbookv3_need_add_deep_price_point: Some(true), + deepbookv3_reference_pool_id: Some("0xref_pool".to_string()), + deepbookv3_reference_pool_base_type: None, + deepbookv3_reference_pool_quote_type: None, + }); + deepbook_router.paths = vec![path]; + let deepbook_ids: BTreeSet = shared_object_ids(&deepbook_router).unwrap().into_iter().collect(); + let expected_deepbook: BTreeSet = [DEEPBOOK_V3_GLOBAL_CONFIG, "0xdb_pool", "0xref_pool"].iter().map(|s| s.to_string()).collect(); + assert_eq!(deepbook_ids, expected_deepbook); + + let mut dlmm_router = router(1000); + let mut dlmm_path = route_path(true, Some("0xdlmm".to_string())); + dlmm_path.provider = CETUS_DLMM.to_string(); + dlmm_path.id = "0xdlmm_pool".to_string(); + dlmm_router.paths = vec![dlmm_path]; + let dlmm_ids: BTreeSet = shared_object_ids(&dlmm_router).unwrap().into_iter().collect(); + let expected_dlmm: BTreeSet = [CETUS_DLMM_GLOBAL_CONFIG, CETUS_DLMM_PARTNER, CETUS_DLMM_VERSIONED, "0xdlmm_pool"] + .iter() + .map(|s| s.to_string()) + .collect(); + assert_eq!(dlmm_ids, expected_dlmm); + + let mut bad_router = router(1000); + let mut bad_path = route_path(true, Some("0xdb".to_string())); + bad_path.provider = DEEPBOOK_V3.to_string(); + bad_path.extended_details = Some(ExtendedDetails { + deepbookv3_need_add_deep_price_point: Some(true), + deepbookv3_reference_pool_id: None, + deepbookv3_reference_pool_base_type: None, + deepbookv3_reference_pool_quote_type: None, + }); + bad_router.paths = vec![bad_path]; + assert!(matches!(shared_object_ids(&bad_router), Err(SwapperError::InvalidRoute))); + } +} diff --git a/crates/swapper/src/cetus/tx_builder/transaction.rs b/crates/swapper/src/cetus/tx_builder/transaction.rs new file mode 100644 index 0000000000..79f24325eb --- /dev/null +++ b/crates/swapper/src/cetus/tx_builder/transaction.rs @@ -0,0 +1,63 @@ +use super::{ + constants::{FUNCTION_TRANSFER_OR_DESTROY_COIN, MODULE_ROUTER}, + error::{sui_error, tx_error}, + swap::build_swap, +}; +use crate::{Quote, SwapperError, cetus::model::RouterData, fees::ReferralFee}; +use gem_sui::{ + is_sui_coin, + models::{CoinAsset, TxOutput}, + tx_builder::{ObjectResolver, TransactionBuilderInput, build_input_coin, finish_transaction, move_call}, +}; +use sui_transaction_builder::TransactionBuilder; + +#[derive(Clone)] +pub(super) struct BuildInput<'a> { + pub(super) transaction: TransactionBuilderInput, + pub(super) from_coin_type: &'a str, + pub(super) target_coin_type: &'a str, + pub(super) amount: u64, + pub(super) from_coins: &'a [CoinAsset], + pub(super) target_merge_coin: Option<&'a CoinAsset>, +} + +impl BuildInput<'_> { + pub(super) fn with_gas_budget(&self, gas_budget: u64) -> Self { + Self { + transaction: self.transaction.with_gas_budget(gas_budget), + ..self.clone() + } + } +} + +pub(super) fn build_transaction( + resolver: &ObjectResolver, + quote: &Quote, + router: &RouterData, + referral_fee: &ReferralFee, + input: &BuildInput<'_>, +) -> Result { + let mut txb = TransactionBuilder::new(); + let input_coin = build_input_coin(&mut txb, input.from_coin_type, input.amount, input.from_coins).map_err(sui_error)?; + let target_coin = build_swap(&mut txb, resolver, quote, router, referral_fee, input_coin)?; + + if is_sui_coin(input.target_coin_type) { + let gas = txb.gas(); + txb.merge_coins(gas, vec![target_coin]); + } else if let Some(merge_coin) = input.target_merge_coin { + let coin = txb.object(merge_coin.to_input()); + txb.merge_coins(coin, vec![target_coin]); + } else { + move_call( + &mut txb, + &router.aggregator_v3(), + MODULE_ROUTER, + FUNCTION_TRANSFER_OR_DESTROY_COIN, + &[input.target_coin_type], + vec![target_coin], + ) + .map_err(tx_error)?; + } + + finish_transaction(txb, input.transaction.clone()).map_err(sui_error) +} diff --git a/crates/swapper/src/lib.rs b/crates/swapper/src/lib.rs index 8c401e62d4..923cf781b4 100644 --- a/crates/swapper/src/lib.rs +++ b/crates/swapper/src/lib.rs @@ -10,6 +10,7 @@ mod swapper_trait; pub mod testkit; pub mod across; +pub mod cetus; pub mod chainflip; pub mod client_factory; pub mod config; diff --git a/crates/swapper/src/proxy/provider.rs b/crates/swapper/src/proxy/provider.rs index 7126459307..7d648a6b96 100644 --- a/crates/swapper/src/proxy/provider.rs +++ b/crates/swapper/src/proxy/provider.rs @@ -146,10 +146,6 @@ impl ProxyProvider { ) } - pub fn new_cetus_aggregator(rpc_provider: Arc) -> Self { - Self::new_with_path(SwapperProvider::CetusAggregator, "cetus", vec![SwapperChainAsset::All(Chain::Sui)], rpc_provider) - } - pub fn new_mayan(rpc_provider: Arc) -> Self { let assets = vec![ SwapperChainAsset::Assets( @@ -371,7 +367,7 @@ mod swap_integration_tests { alien::reqwest_provider::NativeProvider, {SwapperQuoteAsset, models::Options}, }; - use primitives::{AssetId, asset_constants::SUI_USDC_TOKEN_ID, swap::SwapStatus}; + use primitives::{AssetId, swap::SwapStatus}; #[tokio::test] async fn test_mayan_provider_fetch_quote() -> Result<(), SwapperError> { @@ -406,38 +402,6 @@ mod swap_integration_tests { Ok(()) } - #[tokio::test] - async fn test_cetus_provider_fetch_quote() -> Result<(), SwapperError> { - let rpc_provider = Arc::new(NativeProvider::default()); - let provider = ProxyProvider::new_cetus_aggregator(rpc_provider); - - let options = Options::new_with_slippage(50.into()); - - let request = QuoteRequest { - from_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Sui)), - to_asset: SwapperQuoteAsset::from(AssetId::from(Chain::Sui, Some(SUI_USDC_TOKEN_ID.to_string()))), - wallet_address: "0xa9bd0493f9bd1f792a4aedc1f99d54535a75a46c38fd56a8f2c6b7c8d75817a1".to_string(), - destination_address: "0xa9bd0493f9bd1f792a4aedc1f99d54535a75a46c38fd56a8f2c6b7c8d75817a1".to_string(), - value: "1500000000".to_string(), - options, - }; - - let quote = provider.get_quote(&request).await?; - - assert_eq!(quote.from_value, request.value); - assert!(quote.to_value.parse::().unwrap() > 0); - assert_eq!(quote.data.provider, provider.provider().clone()); - assert_eq!(quote.data.routes.len(), 1); - assert_eq!(quote.data.slippage_bps, 50); - - let route = "e.data.routes[0]; - assert_eq!(route.input, AssetId::from_chain(Chain::Sui)); - assert_eq!(route.output, AssetId::from(Chain::Sui, Some(SUI_USDC_TOKEN_ID.to_string()))); - assert!(!route.route_data.is_empty()); - - Ok(()) - } - #[tokio::test] #[cfg(feature = "swap_integration_tests")] async fn test_mayan_get_swap_result() -> Result<(), Box> { diff --git a/crates/swapper/src/proxy/provider_factory.rs b/crates/swapper/src/proxy/provider_factory.rs index 0888591ba2..e674c7ee4c 100644 --- a/crates/swapper/src/proxy/provider_factory.rs +++ b/crates/swapper/src/proxy/provider_factory.rs @@ -11,10 +11,6 @@ pub fn new_okx(rpc_provider: Arc) -> ProxyProvider { ProxyProvider::new_okx(rpc_provider) } -pub fn new_cetus_aggregator(rpc_provider: Arc) -> ProxyProvider { - ProxyProvider::new_cetus_aggregator(rpc_provider) -} - pub fn new_mayan(rpc_provider: Arc) -> ProxyProvider { ProxyProvider::new_mayan(rpc_provider) } diff --git a/crates/swapper/src/swapper.rs b/crates/swapper/src/swapper.rs index db365fca45..2b5b997f8a 100644 --- a/crates/swapper/src/swapper.rs +++ b/crates/swapper/src/swapper.rs @@ -1,7 +1,7 @@ use crate::{ AssetList, FetchQuoteData, Permit2ApprovalData, ProviderType, Quote, QuoteRequest, SwapResult, Swapper, SwapperChainAsset, SwapperError, SwapperProvider, SwapperProviderMode, - SwapperQuoteData, across, alien::RpcProvider, chainflip, cross_chain::VaultAddresses, fees::DEFAULT_STABLE_SWAP_REFERRAL_BPS, fees::is_stablecoin_symbol, hyperliquid, jupiter, - near_intents, panora, proxy::provider_factory, relay, squid, stonfi, thorchain, uniswap, + SwapperQuoteData, across, alien::RpcProvider, cetus, chainflip, cross_chain::VaultAddresses, fees::DEFAULT_STABLE_SWAP_REFERRAL_BPS, fees::is_stablecoin_symbol, hyperliquid, + jupiter, near_intents, panora, proxy::provider_factory, relay, squid, stonfi, thorchain, uniswap, }; use num_bigint::BigInt; use num_traits::ToPrimitive; @@ -131,7 +131,7 @@ impl GemSwapper { Box::new(panora::Panora::new(rpc_provider.clone())), Box::new(near_intents::NearIntents::new(rpc_provider.clone())), Box::new(chainflip::ChainflipProvider::new(rpc_provider.clone())), - Box::new(provider_factory::new_cetus_aggregator(rpc_provider.clone())), + Box::new(cetus::Cetus::new(rpc_provider.clone())), Box::new(relay::Relay::new(rpc_provider.clone())), Box::new(squid::Squid::new(rpc_provider.clone())), uniswap::default::boxed_aerodrome(rpc_provider.clone()),