From 36f6074b28f4ee08e4e40e35b30b5c4096e99291 Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Thu, 28 May 2026 15:29:43 +0900 Subject: [PATCH 1/2] refactor sui coins and add address balance support --- crates/gem_sui/src/lib.rs | 15 +- crates/gem_sui/src/models/coin.rs | 62 +++++- crates/gem_sui/src/models/coin_asset.rs | 51 ----- crates/gem_sui/src/models/core.rs | 25 ++- crates/gem_sui/src/models/mod.rs | 4 +- crates/gem_sui/src/models/testkit.rs | 28 +++ crates/gem_sui/src/provider/preload.rs | 24 +-- crates/gem_sui/src/provider/preload_mapper.rs | 43 ++-- crates/gem_sui/src/rpc/client.rs | 57 +++--- crates/gem_sui/src/rpc/proto/balances.rs | 2 + crates/gem_sui/src/transfer_builder.rs | 32 ++- crates/gem_sui/src/tx_builder/input.rs | 6 +- crates/gem_sui/src/tx_builder/mod.rs | 1 + crates/gem_sui/src/tx_builder/prefetch.rs | 21 +- crates/gem_sui/src/tx_builder/stake.rs | 69 ++++--- crates/gem_sui/src/tx_builder/transaction.rs | 65 ++++-- crates/gem_sui/src/tx_builder/transfer.rs | 188 ++++++++++++------ crates/swapper/src/cetus_clmm/tx_builder.rs | 4 +- .../src/mayan/tx_builder/mctp/sui/prefetch.rs | 8 +- .../mayan/tx_builder/mctp/sui/transaction.rs | 4 +- 20 files changed, 408 insertions(+), 301 deletions(-) delete mode 100644 crates/gem_sui/src/models/coin_asset.rs create mode 100644 crates/gem_sui/src/models/testkit.rs diff --git a/crates/gem_sui/src/lib.rs b/crates/gem_sui/src/lib.rs index e52bcf00e..84e58e0e6 100644 --- a/crates/gem_sui/src/lib.rs +++ b/crates/gem_sui/src/lib.rs @@ -25,8 +25,8 @@ pub mod tx_builder; pub mod signer; pub use error::SuiError; -use models::Coin; pub use models::ObjectId; +use models::{Coin, OwnedCoins}; use std::error::Error; use sui_transaction_builder::ObjectInput; use sui_types::Address; @@ -71,14 +71,13 @@ pub fn sui_clock_object_input() -> ObjectInput { ObjectInput::shared(sui_clock_object_id(), 1, false) } -pub fn validate_enough_balance(coins: &[Coin], amount: u64) -> Option> { - if coins.is_empty() { - return Some("coins list is empty".into()); +pub fn validate_enough_balance(coins: &OwnedCoins, amount: u64) -> Option> { + let total = coins.total(); + if total == 0 { + return Some("no spendable coin objects or address balance".into()); } - - let total_amount: u64 = coins.iter().map(|x| x.balance).sum(); - if total_amount < amount { - return Some(format!("total amount ({}) is less than amount to send ({})", total_amount, amount).into()); + if total < amount { + return Some(format!("total amount ({}) is less than amount to send ({})", total, amount).into()); } None } diff --git a/crates/gem_sui/src/models/coin.rs b/crates/gem_sui/src/models/coin.rs index c276a5136..cdb575a03 100644 --- a/crates/gem_sui/src/models/coin.rs +++ b/crates/gem_sui/src/models/coin.rs @@ -7,6 +7,7 @@ use serde_serializers::deserialize_bigint_from_str; #[cfg(feature = "rpc")] use super::account::Owner; +use super::core::Coin; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -24,16 +25,57 @@ pub struct SuiObject { pub version: String, } -#[cfg(feature = "rpc")] -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SuiCoin { +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OwnedCoins { pub coin_type: String, - pub coin_object_id: String, - #[serde(deserialize_with = "deserialize_bigint_from_str")] - pub balance: BigInt, - pub version: String, - pub digest: String, + pub coins: Vec, + pub address_balance: u64, +} + +impl Default for OwnedCoins { + fn default() -> Self { + Self { + coin_type: String::new(), + coins: Vec::new(), + address_balance: 0, + } + } +} + +impl OwnedCoins { + pub fn new(coin_type: String, coins: Vec, address_balance: u64) -> Self { + Self { + coin_type, + coins, + address_balance, + } + } + + pub fn map(self, f: impl FnMut(T) -> U) -> OwnedCoins { + OwnedCoins { + coin_type: self.coin_type, + coins: self.coins.into_iter().map(f).collect(), + address_balance: self.address_balance, + } + } + + pub fn try_map(self, f: impl FnMut(T) -> Result) -> Result, E> { + Ok(OwnedCoins { + coin_type: self.coin_type, + coins: self.coins.into_iter().map(f).collect::>()?, + address_balance: self.address_balance, + }) + } +} + +impl OwnedCoins { + pub fn coin_total(&self) -> u64 { + self.coins.iter().map(|coin| coin.balance).sum() + } + + pub fn total(&self) -> u64 { + self.coin_total().saturating_add(self.address_balance) + } } #[cfg(feature = "rpc")] @@ -43,6 +85,8 @@ pub struct Balance { pub coin_type: String, #[serde(deserialize_with = "deserialize_bigint_from_str")] pub total_balance: BigInt, + #[serde(default)] + pub address_balance: u64, // Amount held in the per-address balance accumulator } #[cfg(feature = "rpc")] diff --git a/crates/gem_sui/src/models/coin_asset.rs b/crates/gem_sui/src/models/coin_asset.rs deleted file mode 100644 index 68fa216e2..000000000 --- a/crates/gem_sui/src/models/coin_asset.rs +++ /dev/null @@ -1,51 +0,0 @@ -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 { - pub coin_object_id: Address, - pub coin_type: String, - pub digest: Digest, - #[serde(deserialize_with = "deserialize_bigint_from_str", serialize_with = "serialize_bigint")] - pub balance: BigInt, - #[serde(deserialize_with = "deserialize_u64_from_str", serialize_with = "serialize_u64")] - pub version: u64, -} - -impl CoinAsset { - pub fn to_input(&self) -> ObjectInput { - ObjectInput::owned(self.coin_object_id, self.version, self.digest) - } -} - -#[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 { - pub data: Vec, - pub next_cursor: Option, - pub has_next_page: bool, -} diff --git a/crates/gem_sui/src/models/core.rs b/crates/gem_sui/src/models/core.rs index 068d0fdf4..c1f2cfe76 100644 --- a/crates/gem_sui/src/models/core.rs +++ b/crates/gem_sui/src/models/core.rs @@ -1,26 +1,33 @@ +use super::OwnedCoins; use bcs; use gem_encoding::encode_base64; use std::error::Error; use sui_transaction_builder::ObjectInput; -use sui_types::Transaction; +use sui_types::{Address, Digest, Transaction}; -#[derive(Debug, PartialEq, Clone)] +#[derive(Debug, PartialEq, Eq, Clone)] pub struct Coin { pub coin_type: String, pub balance: u64, pub object: Object, } -#[derive(Debug, PartialEq, Clone)] +impl Coin { + pub fn to_input(&self) -> ObjectInput { + self.object.to_input() + } +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] pub struct Object { - pub object_id: String, - pub digest: String, + pub object_id: Address, + pub digest: Digest, pub version: u64, } impl Object { pub fn to_input(&self) -> ObjectInput { - ObjectInput::owned(self.object_id.parse().unwrap(), self.version, self.digest.parse().unwrap()) + ObjectInput::owned(self.object_id, self.version, self.digest) } } @@ -36,7 +43,7 @@ pub struct StakeInput { pub validator: String, pub stake_amount: u64, pub gas: Gas, - pub coins: Vec, + pub coins: OwnedCoins, } #[derive(Debug, PartialEq, Clone)] @@ -52,7 +59,7 @@ pub struct TransferInput { pub sender: String, pub recipient: String, pub amount: u64, - pub coins: Vec, + pub coins: OwnedCoins, pub send_max: bool, pub gas: Gas, } @@ -62,7 +69,7 @@ pub struct TokenTransferInput { pub sender: String, pub recipient: String, pub amount: u64, - pub tokens: Vec, + pub tokens: OwnedCoins, pub gas: Gas, pub gas_coin: Coin, } diff --git a/crates/gem_sui/src/models/mod.rs b/crates/gem_sui/src/models/mod.rs index 28d3e48b9..a07985d89 100644 --- a/crates/gem_sui/src/models/mod.rs +++ b/crates/gem_sui/src/models/mod.rs @@ -1,14 +1,14 @@ pub mod account; pub mod coin; -pub mod coin_asset; pub mod core; pub mod inspect; pub mod object_id; pub mod staking; +#[cfg(test)] +pub mod testkit; pub mod transaction; pub use coin::*; -pub use coin_asset::{CoinAsset, CoinResponse}; pub use core::*; pub use inspect::{InspectCommandResult, InspectEffects, InspectEvent, InspectGasUsed, InspectResult, InspectReturnValue}; pub use object_id::ObjectId; diff --git a/crates/gem_sui/src/models/testkit.rs b/crates/gem_sui/src/models/testkit.rs new file mode 100644 index 000000000..c4f99eb31 --- /dev/null +++ b/crates/gem_sui/src/models/testkit.rs @@ -0,0 +1,28 @@ +use crate::SUI_COIN_TYPE; +use crate::models::{Coin, Object, OwnedCoins}; + +impl Object { + pub fn mock() -> Self { + Self { + object_id: "0xabcdef1234567890abcdef1234567890abcdef12".parse().unwrap(), + digest: "HdfF7hswRuvbXbEXjGjmUCt7gLybhvbPvvK8zZbCqyD8".parse().unwrap(), + version: 100, + } + } +} + +impl Coin { + pub fn mock_sui() -> Self { + Self { + coin_type: SUI_COIN_TYPE.to_string(), + balance: 5_000_000_000, + object: Object::mock(), + } + } +} + +impl OwnedCoins { + pub fn mock_sui() -> Self { + Self::new(SUI_COIN_TYPE.to_string(), vec![Coin::mock_sui()], 0) + } +} diff --git a/crates/gem_sui/src/provider/preload.rs b/crates/gem_sui/src/provider/preload.rs index 9394a3c64..56ed0feab 100644 --- a/crates/gem_sui/src/provider/preload.rs +++ b/crates/gem_sui/src/provider/preload.rs @@ -12,7 +12,7 @@ use primitives::{ use crate::{ ESTIMATION_GAS_BUDGET, SUI_COIN_TYPE, gas_budget::GAS_BUDGET_MULTIPLIER, - models::{SuiCoin, SuiObject}, + models::{Coin, OwnedCoins, SuiObject}, }; use crate::{ provider::preload_mapper::{map_transaction_data, map_transaction_rate_rates}, @@ -27,13 +27,13 @@ impl ChainTransactionLoad for SuiClient { } async fn get_transaction_load(&self, input: TransactionLoadInput) -> Result> { - let (gas_coins, coins, objects) = self.get_coins_for_input_type(input.sender_address.as_str(), input.input_type.clone()).await?; + let (sui_coins, token_coins, objects) = self.get_coins_for_input_type(input.sender_address.as_str(), input.input_type.clone()).await?; - let estimate_bytes = map_transaction_data(input.clone(), gas_coins.clone(), coins.clone(), objects.clone(), ESTIMATION_GAS_BUDGET)?; + let estimate_bytes = map_transaction_data(input.clone(), sui_coins.clone(), token_coins.clone(), objects.clone(), ESTIMATION_GAS_BUDGET)?; let fee = self.estimate_fee(&estimate_bytes, &input.gas_price, input.is_max_value).await?; let message_bytes = match estimated_gas_budget(&input.input_type, &fee)? { - Some(budget) => map_transaction_data(input, gas_coins, coins, objects, budget)?, + Some(budget) => map_transaction_data(input, sui_coins, token_coins, objects, budget)?, None => estimate_bytes, }; @@ -75,27 +75,27 @@ impl SuiClient { &self, address: &str, input_type: TransactionInputType, - ) -> Result<(Vec, Vec, Vec), Box> { + ) -> Result<(OwnedCoins, Option>, Vec), Box> { match input_type { TransactionInputType::Transfer(asset) => match asset.id.token_id { - None => Ok((self.get_coins(address, SUI_COIN_TYPE).await?, Vec::new(), Vec::new())), + None => Ok((self.get_coins(address, SUI_COIN_TYPE).await?, None, Vec::new())), Some(token_id) => { - let (gas_coins, coins) = futures::try_join!(self.get_coins(address, SUI_COIN_TYPE), self.get_coins(address, &token_id))?; - Ok((gas_coins, coins, Vec::new())) + let (gas_coins, token_coins) = futures::try_join!(self.get_coins(address, SUI_COIN_TYPE), self.get_coins(address, &token_id))?; + Ok((gas_coins, Some(token_coins), Vec::new())) } }, TransactionInputType::Stake(_, stake_type) => match stake_type { - StakeType::Stake(_) => Ok((self.get_coins(address, SUI_COIN_TYPE).await?, Vec::new(), Vec::new())), + StakeType::Stake(_) => Ok((self.get_coins(address, SUI_COIN_TYPE).await?, None, Vec::new())), StakeType::Unstake(delegation) => { let (gas_coins, staked_object) = futures::try_join!(self.get_coins(address, SUI_COIN_TYPE), self.get_object(delegation.base.delegation_id.clone()))?; - Ok((gas_coins, Vec::new(), vec![staked_object])) + Ok((gas_coins, None, vec![staked_object])) } StakeType::Redelegate(_) | StakeType::Rewards(_) | StakeType::Withdraw(_) | StakeType::Freeze(_) | StakeType::Unfreeze(_) => { Err("Unsupported stake type for Sui".into()) } }, - TransactionInputType::Swap(_, _, _) => Ok((Vec::new(), Vec::new(), Vec::new())), - TransactionInputType::Generic(_, _, _) => Ok((Vec::new(), Vec::new(), Vec::new())), + TransactionInputType::Swap(_, _, _) => Ok((OwnedCoins::default(), None, Vec::new())), + TransactionInputType::Generic(_, _, _) => Ok((OwnedCoins::default(), None, Vec::new())), TransactionInputType::TransferNft(_, _) | TransactionInputType::Account(_, _) => Err("Unsupported transaction type for Sui".into()), _ => Err("Unsupported transaction type for Sui".into()), } diff --git a/crates/gem_sui/src/provider/preload_mapper.rs b/crates/gem_sui/src/provider/preload_mapper.rs index 92f6837f5..edf7b40bf 100644 --- a/crates/gem_sui/src/provider/preload_mapper.rs +++ b/crates/gem_sui/src/provider/preload_mapper.rs @@ -14,24 +14,10 @@ pub fn map_transaction_rate_rates(base_gas_price: BigInt) -> Vec { ] } -impl From for crate::models::Coin { - fn from(coin: SuiCoin) -> Self { - crate::models::Coin { - coin_type: coin.coin_type, - balance: coin.balance.to_string().parse().unwrap_or(0), - object: crate::models::Object { - object_id: coin.coin_object_id, - digest: coin.digest, - version: coin.version.parse().unwrap_or(0), - }, - } - } -} - pub fn map_transaction_data( input: TransactionLoadInput, - gas_coins: Vec, - coins: Vec, + sui_coins: OwnedCoins, + token_coins: Option>, objects: Vec, gas_budget: u64, ) -> Result> { @@ -44,7 +30,7 @@ pub fn map_transaction_data( sender: input.sender_address, recipient: input.destination_address, amount: input.value.parse().unwrap_or(0), - coins: gas_coins.into_iter().map(Into::into).collect(), + coins: sui_coins, send_max: input.is_max_value, gas: Gas { budget: gas_budget, @@ -57,12 +43,13 @@ pub fn map_transaction_data( Ok(format!("{}_{}", data, digest)) } Some(_token_id) => { - let gas_coin = gas_coins.first().ok_or("No gas coins available for token transfer")?.clone().into(); + let gas_coin = sui_coins.coins.first().ok_or("No gas coins available for token transfer")?.clone(); + let tokens = token_coins.ok_or("Missing token coins set for Sui token transfer")?; let token_transfer_input = TokenTransferInput { sender: input.sender_address, recipient: input.destination_address, amount: input.value.parse().unwrap_or(0), - tokens: coins.into_iter().map(Into::into).collect(), + tokens, gas: Gas { budget: gas_budget, price: gas_price, @@ -85,7 +72,7 @@ pub fn map_transaction_data( budget: gas_budget, price: gas_price, }, - coins: gas_coins.into_iter().map(Into::into).collect(), + coins: sui_coins, }; let tx_output = encode_split_and_stake(&stake_input)?; let data = encode_base64(&tx_output.tx_data); @@ -93,16 +80,16 @@ pub fn map_transaction_data( Ok(format!("{}_{}", data, digest)) } StakeType::Unstake(delegation) => { - let gas_coin = gas_coins.first().ok_or("No gas coins available for unstake")?.clone().into(); + let gas_coin = sui_coins.coins.first().ok_or("No gas coins available for unstake")?.clone(); let staked_object = objects .iter() .find(|obj| obj.object_id == delegation.base.delegation_id) .ok_or("Staked SUI object not found in provided objects")?; let staked_sui = crate::models::Object { - object_id: staked_object.object_id.clone(), + object_id: staked_object.object_id.parse().map_err(|err| format!("invalid staked Sui object id: {err}"))?, version: staked_object.version.parse().unwrap_or(0), - digest: staked_object.digest.clone(), + digest: staked_object.digest.parse().map_err(|err| format!("invalid staked Sui object digest: {err}"))?, }; let unstake_input = UnstakeInput { @@ -154,13 +141,7 @@ mod tests { let input_type = TransactionInputType::Stake(Asset::from_chain(Chain::Sui), StakeType::Unstake(delegation)); let input = TransactionLoadInput::mock_with_input_type(input_type); - let gas_coins = vec![SuiCoin { - coin_type: "0x2::sui::SUI".to_string(), - coin_object_id: "0xabcdef1234567890abcdef1234567890abcdef12".to_string(), - balance: "5000000000".parse().unwrap(), - version: "100".to_string(), - digest: "HdfF7hswRuvbXbEXjGjmUCt7gLybhvbPvvK8zZbCqyD8".to_string(), - }]; + let gas_coins = OwnedCoins::::mock_sui(); let objects = vec![SuiObject { object_id: delegation_id.to_string(), @@ -168,7 +149,7 @@ mod tests { digest: "CU86BjXRF1XHFRjKBasCYEuaQxhHuyGBpuoJyqsrYoX5".to_string(), }]; - let result = map_transaction_data(input, gas_coins, vec![], objects, 25_000_000); + let result = map_transaction_data(input, gas_coins, None, objects, 25_000_000); assert!(result.is_ok()); } diff --git a/crates/gem_sui/src/rpc/client.rs b/crates/gem_sui/src/rpc/client.rs index 13dee60fa..674e1d59d 100644 --- a/crates/gem_sui/src/rpc/client.rs +++ b/crates/gem_sui/src/rpc/client.rs @@ -18,7 +18,7 @@ use super::proto::{ }; use super::transport::default_transport; use crate::models::transaction::{SuiBroadcastTransaction, SuiTransaction}; -use crate::models::{Balance, Checkpoint, CoinAsset, Digest, InspectResult, SuiCoin, SuiCoinMetadata, SuiObject, TransactionBlocks}; +use crate::models::{Balance, Checkpoint, Coin, Digest, InspectResult, Object, OwnedCoins, SuiCoinMetadata, SuiObject, TransactionBlocks}; use crate::{SUI_COIN_TYPE, SUI_COIN_TYPE_FULL}; const TRANSACTION_READ_MASK: &[&str] = &[ @@ -95,16 +95,20 @@ impl SuiClient { } pub async fn get_balance(&self, address: String) -> Result> { + self.get_balance_for_coin(&address, SUI_COIN_TYPE_FULL).await + } + + pub async fn get_balance_for_coin(&self, address: &str, coin_type: &str) -> Result> { let request = GetBalanceRequest { - owner: Some(address), - coin_type: Some(SUI_COIN_TYPE_FULL.to_string()), + owner: Some(address.to_string()), + coin_type: Some(coin_type.to_string()), }; let response: GetBalanceResponse = self.grpc_unary(PATH_GET_BALANCE, request).await?; - - let balance = response.balance.and_then(|balance| balance.balance).unwrap_or_default(); + let balance = response.balance.unwrap_or_default(); Ok(Balance { - coin_type: SUI_COIN_TYPE_FULL.to_string(), - total_balance: BigInt::from(balance), + coin_type: balance.coin_type.unwrap_or_else(|| coin_type.to_string()), + total_balance: BigInt::from(balance.balance.unwrap_or_default()), + address_balance: balance.address_balance.unwrap_or_default(), }) } @@ -125,6 +129,7 @@ impl SuiClient { Ok(Balance { coin_type: balance.coin_type.ok_or("missing Sui balance coin type")?, total_balance: BigInt::from(balance.balance.ok_or("missing Sui balance amount")?), + address_balance: balance.address_balance.unwrap_or_default(), }) }) .collect::, Box>>()?; @@ -167,7 +172,12 @@ impl SuiClient { Ok(BigInt::from(epoch.reference_gas_price.ok_or("missing Sui reference gas price")?)) } - pub async fn get_coins(&self, address: &str, coin_type: &str) -> Result, Box> { + pub async fn get_coins(&self, address: &str, coin_type: &str) -> Result, Box> { + let (objects, balance) = futures::try_join!(self.list_coin_objects(address, coin_type), self.get_balance_for_coin(address, coin_type),)?; + Ok(OwnedCoins::new(coin_type.to_string(), objects, balance.address_balance)) + } + + async fn list_coin_objects(&self, address: &str, coin_type: &str) -> Result, Box> { let mut request = ListOwnedObjectsRequest { owner: Some(address.to_string()), page_size: Some(1000), @@ -183,17 +193,22 @@ impl SuiClient { .objects .into_iter() .map(|object| { - Ok(SuiCoin { - coin_type: object - .object_type - .ok_or("missing Sui coin object type")? - .trim_start_matches("0x2::coin::Coin<") - .trim_end_matches('>') - .to_string(), - coin_object_id: object.object_id.ok_or("missing Sui coin object id")?, - balance: BigInt::from(object.balance.ok_or("missing Sui coin balance")?), - version: object.version.ok_or("missing Sui coin version")?.to_string(), - digest: object.digest.ok_or("missing Sui coin digest")?, + let coin_type = object + .object_type + .ok_or("missing Sui coin object type")? + .trim_start_matches("0x2::coin::Coin<") + .trim_end_matches('>') + .to_string(); + let object_id_str = object.object_id.ok_or("missing Sui coin object id")?; + let digest_str = object.digest.ok_or("missing Sui coin digest")?; + Ok(Coin { + coin_type, + balance: object.balance.ok_or("missing Sui coin balance")?, + object: Object { + object_id: Address::from_str(&object_id_str)?, + digest: sui_types::Digest::from_str(&digest_str)?, + version: object.version.ok_or("missing Sui coin version")?, + }, }) }) .collect::, Box>>()?; @@ -207,10 +222,6 @@ impl SuiClient { Ok(coins) } - 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 object_id = Address::from_str(&object_id)?; let request = GetObjectRequest::new(&object_id).with(|request| { diff --git a/crates/gem_sui/src/rpc/proto/balances.rs b/crates/gem_sui/src/rpc/proto/balances.rs index af8cb9bbb..b13b6dfa4 100644 --- a/crates/gem_sui/src/rpc/proto/balances.rs +++ b/crates/gem_sui/src/rpc/proto/balances.rs @@ -51,11 +51,13 @@ proto_decode!(ListBalancesResponse { pub struct Balance { pub coin_type: Option, pub balance: Option, + pub address_balance: Option, } proto_decode!(Balance { 1 => coin_type: optional_string, 3 => balance: optional_varint_u64, + 4 => address_balance: optional_varint_u64, }); #[derive(Clone, Debug, Default)] diff --git a/crates/gem_sui/src/transfer_builder.rs b/crates/gem_sui/src/transfer_builder.rs index 36ebc9b69..887140dae 100644 --- a/crates/gem_sui/src/transfer_builder.rs +++ b/crates/gem_sui/src/transfer_builder.rs @@ -1,7 +1,7 @@ use crate::{ ESTIMATION_GAS_BUDGET, SUI_COIN_TYPE, SuiClient, gas_budget::GAS_BUDGET_MULTIPLIER, - models::{Coin, Gas, TokenTransferInput, TransferInput}, + models::{Coin, Gas, OwnedCoins, TokenTransferInput, TransferInput}, tx_builder::{encode_token_transfer, encode_transfer}, }; use futures::try_join; @@ -16,14 +16,13 @@ pub async fn build_transfer_message_bytes( amount: u64, token_type: Option<&str>, ) -> Result> { - let (gas_price_bigint, sui_coin_objects) = try_join!(client.get_gas_price(), client.get_coins(sender, SUI_COIN_TYPE))?; + let (gas_price_bigint, sui_coins) = try_join!(client.get_gas_price(), client.get_coins(sender, SUI_COIN_TYPE))?; let gas_price = gas_price_bigint .to_u64() .ok_or_else(|| format!("Failed to convert Sui gas price to u64: {gas_price_bigint}"))?; - let sui_coins: Vec = sui_coin_objects.into_iter().map(Into::into).collect(); - if sui_coins.is_empty() { + if sui_coins.coins.is_empty() { return Err("No SUI coins available for gas budget".into()); } @@ -32,30 +31,29 @@ pub async fn build_transfer_message_bytes( Some(token_type) => Some(get_token_coins(client, sender, token_type).await?), }; - let estimate_output = build_tx_output(sender, recipient, amount, &sui_coins, token_coins.as_deref(), ESTIMATION_GAS_BUDGET, gas_price)?; + let estimate_output = build_tx_output(sender, recipient, amount, &sui_coins, token_coins.as_ref(), ESTIMATION_GAS_BUDGET, gas_price)?; let dry_run_result = client.dry_run(estimate_output.base64_encoded()).await?; let fee = dry_run_result.effects.gas_used.calculate_gas_budget()?; let gas_budget = fee * GAS_BUDGET_MULTIPLIER / 100; - let tx_output = build_tx_output(sender, recipient, amount, &sui_coins, token_coins.as_deref(), gas_budget, gas_price)?; + let tx_output = build_tx_output(sender, recipient, amount, &sui_coins, token_coins.as_ref(), gas_budget, gas_price)?; Ok(tx_output.base64_encoded()) } -async fn get_token_coins(client: &SuiClient, sender: &str, token_type: &str) -> Result, Box> { - let objs = client.get_coins(sender, token_type).await?; - let coins: Vec = objs.into_iter().map(Into::into).collect(); - if coins.is_empty() { - return Err(format!("No coins found for token type {token_type}").into()); +async fn get_token_coins(client: &SuiClient, sender: &str, token_type: &str) -> Result, Box> { + let owned = client.get_coins(sender, token_type).await?; + if owned.coins.is_empty() && owned.address_balance == 0 { + return Err(format!("No coins or address balance found for token type {token_type}").into()); } - Ok(coins) + Ok(owned) } fn build_tx_output( sender: &str, recipient: &str, amount: u64, - sui_coins: &[Coin], - token_coins: Option<&[Coin]>, + sui_coins: &OwnedCoins, + token_coins: Option<&OwnedCoins>, gas_budget: u64, gas_price: u64, ) -> Result> { @@ -70,9 +68,9 @@ fn build_tx_output( sender: sender.to_string(), recipient: recipient.to_string(), amount, - tokens: tokens.to_vec(), + tokens: tokens.clone(), gas, - gas_coin: sui_coins.first().unwrap().clone(), + gas_coin: sui_coins.coins.first().unwrap().clone(), }; encode_token_transfer(&token_transfer_input) } @@ -81,7 +79,7 @@ fn build_tx_output( sender: sender.to_string(), recipient: recipient.to_string(), amount, - coins: sui_coins.to_vec(), + coins: sui_coins.clone(), send_max: false, gas, }; diff --git a/crates/gem_sui/src/tx_builder/input.rs b/crates/gem_sui/src/tx_builder/input.rs index 00b582be1..b19217cd5 100644 --- a/crates/gem_sui/src/tx_builder/input.rs +++ b/crates/gem_sui/src/tx_builder/input.rs @@ -1,5 +1,5 @@ #[cfg(feature = "rpc")] -use crate::{SUI_COIN_TYPE, SuiClient, SuiError, models::CoinAsset}; +use crate::{SUI_COIN_TYPE, SuiClient, SuiError, models::Coin}; #[cfg(feature = "rpc")] use futures::try_join; #[cfg(feature = "rpc")] @@ -40,12 +40,12 @@ impl TransactionBuilderInput { .to_u64() .ok_or_else(|| SuiError::invalid_input("Sui gas price overflow")) }; - let gas_coins = async { client.get_coin_assets_by_type(sender, SUI_COIN_TYPE).await.map_err(SuiError::from_display) }; + let gas_coins = async { client.get_coins(sender, SUI_COIN_TYPE).await.map(|owned| owned.coins).map_err(SuiError::from_display) }; let (gas_price, gas_coins) = try_join!(gas_price, gas_coins)?; if gas_coins.is_empty() { return Err(SuiError::NoGasCoins); } - let gas_objects = gas_coins.iter().map(CoinAsset::to_input).collect(); + let gas_objects = gas_coins.iter().map(Coin::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 index 3d61099f8..2bb45af66 100644 --- a/crates/gem_sui/src/tx_builder/mod.rs +++ b/crates/gem_sui/src/tx_builder/mod.rs @@ -16,6 +16,7 @@ pub use object_resolver::{ObjectResolver, ResolvedObjectInput}; #[cfg(feature = "rpc")] pub use prefetch::PrefetchedTransactionData; pub use stake::*; +pub(crate) use transaction::build_amount_coin; pub use transaction::{build_input_coin, decode_transaction, finish_transaction, move_call, validate_and_hash, zero_coin}; #[cfg(feature = "rpc")] pub use transaction_json::{ReplayedTransaction, TransactionJsonReplay, prepare_transaction_json_replay, replay_transaction_json}; diff --git a/crates/gem_sui/src/tx_builder/prefetch.rs b/crates/gem_sui/src/tx_builder/prefetch.rs index 5861fd0f3..2b9f90cb4 100644 --- a/crates/gem_sui/src/tx_builder/prefetch.rs +++ b/crates/gem_sui/src/tx_builder/prefetch.rs @@ -1,12 +1,15 @@ use super::{ObjectResolver, TransactionBuilderInput}; -use crate::{SuiClient, SuiError, is_sui_coin, models::CoinAsset}; +use crate::{ + SuiClient, SuiError, is_sui_coin, + models::{Coin, OwnedCoins}, +}; use futures::try_join; use std::collections::HashMap; pub struct PrefetchedTransactionData { pub transaction: TransactionBuilderInput, - pub input_coins: Vec, - pub output_coin: Option, + pub input_coins: OwnedCoins, + pub output_coin: Option, pub resolver: ObjectResolver, } @@ -23,10 +26,10 @@ impl PrefetchedTransactionData { let output_coins_fut = async { match output_coin_type { Some(coin_type) => get_user_coins(client, sender, coin_type).await, - None => Ok(Vec::new()), + None => Ok(OwnedCoins::default()), } }; - let (transaction, input_coins, output_coins, resolver) = try_join!( + let (transaction, input_coins, output_owned, resolver) = try_join!( TransactionBuilderInput::prefetch(client, sender, gas_budget), get_user_coins(client, sender, input_coin_type), output_coins_fut, @@ -36,16 +39,16 @@ impl PrefetchedTransactionData { Ok(Self { transaction, input_coins, - output_coin: output_coins.into_iter().next(), + output_coin: output_owned.coins.into_iter().next(), resolver, }) } } -async fn get_user_coins(client: &SuiClient, owner: &str, coin_type: &str) -> Result, SuiError> { +async fn get_user_coins(client: &SuiClient, owner: &str, coin_type: &str) -> Result, SuiError> { if is_sui_coin(coin_type) { - Ok(Vec::new()) + Ok(OwnedCoins::default()) } else { - client.get_coin_assets_by_type(owner, coin_type).await.map_err(SuiError::from_display) + client.get_coins(owner, coin_type).await.map_err(SuiError::from_display) } } diff --git a/crates/gem_sui/src/tx_builder/stake.rs b/crates/gem_sui/src/tx_builder/stake.rs index 596e0c498..3e79900b6 100644 --- a/crates/gem_sui/src/tx_builder/stake.rs +++ b/crates/gem_sui/src/tx_builder/stake.rs @@ -6,7 +6,7 @@ use crate::{ use std::{error::Error, str::FromStr}; use sui_transaction_builder::{Function, ObjectInput, TransactionBuilder}; -use sui_types::{Address, Identifier}; +use sui_types::{Address, Identifier, TypeTag}; use super::{TransactionBuilderInput, finish_transaction}; @@ -27,11 +27,20 @@ fn build_split_and_stake_ptb(input: &StakeInput) -> Result= input.stake_amount { + let coin_type: TypeTag = input + .coins + .coin_type + .parse() + .map_err(|err| format!("invalid Sui native coin type {}: {err}", input.coins.coin_type))?; + ptb.funds_withdrawal_coin(coin_type, input.stake_amount) + } else { + let stake_amount = ptb.pure(&input.stake_amount); + let gas = ptb.gas(); + let mut split_results = ptb.split_coins(gas, vec![stake_amount]); + split_results.pop().expect("split_coins should return one argument") + }; // move call request_add_stake let function = Function::new( @@ -43,14 +52,14 @@ fn build_split_and_stake_ptb(input: &StakeInput) -> Result Result> { let ptb = build_split_and_stake_ptb(input)?; - let gas_objects = input.coins.iter().map(|x| x.object.to_input()).collect::>(); + let gas_objects = input.coins.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) } @@ -58,16 +67,8 @@ pub fn encode_split_and_stake(input: &StakeInput) -> Result Result<(TransactionBuilder, ObjectInput), Box> { let mut ptb = TransactionBuilder::new(); - let staked_sui = ptb.object(ObjectInput::owned( - input.staked_sui.object_id.parse().unwrap(), - input.staked_sui.version, - input.staked_sui.digest.parse().unwrap(), - )); - let gas_coin = ObjectInput::owned( - input.gas_coin.object.object_id.parse().unwrap(), - input.gas_coin.object.version, - input.gas_coin.object.digest.parse().unwrap(), - ); + let staked_sui = ptb.object(input.staked_sui.to_input()); + let gas_coin = input.gas_coin.to_input(); let function = Function::new( ObjectId::from(SUI_SYSTEM_PACKAGE_ID).into(), Identifier::new(SUI_SYSTEM_ID).unwrap(), @@ -92,7 +93,7 @@ mod tests { use super::*; use crate::{ SUI_COIN_TYPE, - models::{Coin, Gas, Object}, + models::{Coin, Gas, Object, OwnedCoins}, tx_builder::decode_transaction, }; use gem_encoding::encode_base64; @@ -105,15 +106,19 @@ mod tests { validator: "0x61953ea72709eed72f4441dd944eec49a11b4acabfc8e04015e89c63be81b6ab".into(), stake_amount: 1_000_000_000, gas: Gas { budget: 20_000_000, price: 750 }, - coins: vec![Coin { - coin_type: SUI_COIN_TYPE.into(), - balance: 10990277896, - object: Object { - object_id: "0x36b8380aa7531d73723657d73a114cfafedf89dc8c76b6752f6daef17e43dda2".into(), - version: 0x3f4d8e5, - digest: "HdfF7hswRuvbXbEXjGjmUCt7gLybhvbPvvK8zZbCqyD8".into(), - }, - }], + coins: OwnedCoins::new( + SUI_COIN_TYPE.into(), + vec![Coin { + coin_type: SUI_COIN_TYPE.into(), + balance: 10990277896, + object: Object { + object_id: "0x36b8380aa7531d73723657d73a114cfafedf89dc8c76b6752f6daef17e43dda2".parse().unwrap(), + version: 0x3f4d8e5, + digest: "HdfF7hswRuvbXbEXjGjmUCt7gLybhvbPvvK8zZbCqyD8".parse().unwrap(), + }, + }], + 0, + ), }; let data = encode_split_and_stake(&input).unwrap(); let tx: Transaction = bcs::from_bytes(&data.tx_data).unwrap(); @@ -135,8 +140,8 @@ mod tests { let input = UnstakeInput { sender: "0xe6af80fe1b0b42fcd96762e5c70f5e8dae39f8f0ee0f118cac0d55b74e2927c2".into(), staked_sui: Object { - object_id: "0xc8c1666ae68f46b609d40bb51d1ec23dc2e0560f986aae878643b6d215549fcf".into(), - digest: "CU86BjXRF1XHFRjKBasCYEuaQxhHuyGBpuoJyqsrYoX5".into(), + object_id: "0xc8c1666ae68f46b609d40bb51d1ec23dc2e0560f986aae878643b6d215549fcf".parse().unwrap(), + digest: "CU86BjXRF1XHFRjKBasCYEuaQxhHuyGBpuoJyqsrYoX5".parse().unwrap(), version: 64195796, }, gas: Gas { budget: 25_000_000, price: 750 }, @@ -144,9 +149,9 @@ mod tests { coin_type: SUI_COIN_TYPE.into(), balance: 631668351, object: Object { - object_id: "0x36b8380aa7531d73723657d73a114cfafedf89dc8c76b6752f6daef17e43dda2".into(), + object_id: "0x36b8380aa7531d73723657d73a114cfafedf89dc8c76b6752f6daef17e43dda2".parse().unwrap(), version: 68755407, - digest: "FHbvG5i7f8o2VrKpXnqGFHNvGxG7BBKREea5avdPN7ke".into(), + digest: "FHbvG5i7f8o2VrKpXnqGFHNvGxG7BBKREea5avdPN7ke".parse().unwrap(), }, }, }; diff --git a/crates/gem_sui/src/tx_builder/transaction.rs b/crates/gem_sui/src/tx_builder/transaction.rs index fc9066f11..8893b1e77 100644 --- a/crates/gem_sui/src/tx_builder/transaction.rs +++ b/crates/gem_sui/src/tx_builder/transaction.rs @@ -1,19 +1,50 @@ use super::TransactionBuilderInput; use crate::{ SuiError, is_sui_coin, - models::{CoinAsset, TxOutput}, + models::{Coin, OwnedCoins, TxOutput}, sui_framework_package_address, }; 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_transaction_builder::{Argument, Function, ObjectInput, TransactionBuilder}; use sui_types::{Address, Identifier, TypeTag}; const MODULE_COIN: &str = "coin"; const FUNCTION_ZERO: &str = "zero"; +/// Build a `Coin` of exactly `amount`, preferring Address Balance, then merging coin objects. +/// Caller must pre-validate that the combined sources cover `amount`. +pub(crate) fn build_amount_coin( + txb: &mut TransactionBuilder, + coin_type_tag: TypeTag, + amount: u64, + address_balance: u64, + coin_object_inputs: Vec, +) -> Result { + if address_balance >= amount { + return Ok(txb.funds_withdrawal_coin(coin_type_tag, amount)); + } + + let mut coin_args: Vec = coin_object_inputs.into_iter().map(|input| txb.object(input)).collect(); + let primary = if address_balance > 0 { + txb.funds_withdrawal_coin(coin_type_tag, address_balance) + } else if !coin_args.is_empty() { + coin_args.remove(0) + } else { + return Err(SuiError::invalid_input("no coin sources for Sui amount")); + }; + + if !coin_args.is_empty() { + txb.merge_coins(primary, coin_args); + } + + let amount_arg = txb.pure(&amount); + txb.split_coins(primary, vec![amount_arg]) + .pop() + .ok_or_else(|| SuiError::invalid_input("Sui split coin failed")) +} + pub fn move_call(txb: &mut TransactionBuilder, package: Address, module: &str, function: &str, type_args: &[&str], arguments: Vec) -> Result { let type_args = type_args .iter() @@ -36,36 +67,26 @@ pub fn zero_coin(txb: &mut TransactionBuilder, coin_type: &str) -> Result Result { +pub fn build_input_coin(txb: &mut TransactionBuilder, coin_type: &str, amount: u64, source: &OwnedCoins) -> Result { if amount == 0 { return zero_coin(txb, coin_type); } if is_sui_coin(coin_type) { - let amount = txb.pure(&amount); + let amount_arg = 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")); + return txb.split_coins(gas, vec![amount_arg]).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 { + if source.total() < amount { return Err(SuiError::InsufficientBalance { coin_type: coin_type.to_string() }); } - let mut coin_args: Vec<_> = from_coins.iter().map(|coin| txb.object(coin.to_input())).collect(); - let coin = coin_args - .first() - .copied() - .ok_or_else(|| SuiError::InsufficientBalance { coin_type: coin_type.to_string() })?; - 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")) + let type_tag: TypeTag = coin_type + .parse() + .map_err(|err| SuiError::invalid_input(format!("Invalid Sui coin type {coin_type}: {err}")))?; + let coin_inputs = source.coins.iter().map(Coin::to_input).collect(); + build_amount_coin(txb, type_tag, amount, source.address_balance, coin_inputs) } pub fn finish_transaction(mut txb: TransactionBuilder, input: TransactionBuilderInput) -> Result { diff --git a/crates/gem_sui/src/tx_builder/transfer.rs b/crates/gem_sui/src/tx_builder/transfer.rs index e21d00504..1488edf85 100644 --- a/crates/gem_sui/src/tx_builder/transfer.rs +++ b/crates/gem_sui/src/tx_builder/transfer.rs @@ -1,10 +1,10 @@ use crate::models::*; use std::error::Error; use std::str::FromStr; -use sui_transaction_builder::{Argument, ObjectInput, TransactionBuilder}; -use sui_types::Address; +use sui_transaction_builder::{ObjectInput, TransactionBuilder}; +use sui_types::{Address, TypeTag}; -use super::{TransactionBuilderInput, finish_transaction}; +use super::{TransactionBuilderInput, build_amount_coin, finish_transaction}; fn build_transfer_ptb(input: &TransferInput) -> Result> { if let Some(err) = crate::validate_enough_balance(&input.coins, input.amount) { @@ -17,66 +17,57 @@ fn build_transfer_ptb(input: &TransferInput) -> Result= input.amount { + let coin_type: TypeTag = input + .coins + .coin_type + .parse() + .map_err(|err| format!("invalid Sui native coin type {}: {err}", input.coins.coin_type))?; + ptb.funds_withdrawal_coin(coin_type, input.amount) } else { let amount = ptb.pure(&input.amount); let gas = ptb.gas(); let mut split_results = ptb.split_coins(gas, vec![amount]); - let split_result = split_results.pop().expect("split_coins should return one argument"); - let recipient_argument = ptb.pure(&recipient); + split_results.pop().expect("split_coins should return one argument") + }; - ptb.transfer_objects(vec![split_result], recipient_argument); - } + let recipient_argument = ptb.pure(&recipient); + ptb.transfer_objects(vec![send_coin], recipient_argument); Ok(ptb) } pub fn encode_transfer(input: &TransferInput) -> Result> { let ptb = build_transfer_ptb(input)?; - let gas_objects = input.coins.iter().map(|x| x.object.to_input()).collect::>(); + let gas_objects = input.coins.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> { - if let Some(err) = crate::validate_enough_balance(&input.tokens, input.amount) { + let tokens = &input.tokens; + if let Some(err) = crate::validate_enough_balance(tokens, input.amount) { return Err(err); } - let mut ptb = TransactionBuilder::new(); - let recipient = Address::from_str(&input.recipient)?; - - if input.tokens.is_empty() { - return Err("tokens vector is empty".into()); - } - - let mut coins_inputs: Vec = input.tokens.clone().into_iter().map(|x| ptb.object(x.object.to_input())).collect(); - - // Get first coin - let first_coin = coins_inputs.remove(0); - // Merge coins if more than one - if !coins_inputs.is_empty() { - ptb.merge_coins(first_coin, coins_inputs); - } - - // Split and transfer - let amount = ptb.pure(&input.amount); - let mut split_results = ptb.split_coins(first_coin, vec![amount]); - let split_result = split_results.pop().expect("split_coins should return one argument"); + let coin_type: TypeTag = tokens.coin_type.parse().map_err(|err| format!("invalid Sui token coin type {}: {err}", tokens.coin_type))?; + let recipient = Address::from_str(&input.recipient)?; + let mut ptb = TransactionBuilder::new(); + let coin_object_inputs: Vec = tokens.coins.iter().map(|coin| coin.object.to_input()).collect(); + let amount_coin = build_amount_coin(&mut ptb, coin_type, input.amount, tokens.address_balance, coin_object_inputs)?; let recipient_argument = ptb.pure(&recipient); - ptb.transfer_objects(vec![split_result], recipient_argument); + ptb.transfer_objects(vec![amount_coin], recipient_argument); Ok(ptb) } pub fn encode_token_transfer(input: &TokenTransferInput) -> Result> { 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(), - ); + let gas_coin = ObjectInput::immutable(input.gas_coin.object.object_id, input.gas_coin.object.version, input.gas_coin.object.digest); 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) } @@ -85,6 +76,7 @@ pub fn encode_token_transfer(input: &TokenTransferInput) -> Result { + assert_eq!(ptb.inputs.len(), 2, "expected withdrawal + recipient inputs only"); + assert!(matches!(ptb.inputs[0], sui_types::Input::FundsWithdrawal(_)), "first input must be FundsWithdrawal"); + assert_eq!(ptb.commands.len(), 2, "expected redeem_funds + transfer_objects"); + } + _ => panic!("expected ProgrammableTransaction"), + } + } + + #[test] + fn test_encode_token_transfer_mixed_balance_and_coin() { + let input = TokenTransferInput { + sender: "0x1b4cd8b734f2465614678ca0450ce9c4f2ff4835c6a7545522892a1a8fb67991".into(), + recipient: "0xcf3abaeecfaf42990b8481c03000000000000000000000000000000000000000".into(), + amount: 200_000_000, + tokens: OwnedCoins::new( + SUI_USDC_TOKEN_ID.into(), + vec![Coin { + coin_type: SUI_USDC_TOKEN_ID.into(), + balance: 150_000_000, + object: Object { + object_id: "0xfa8dca3e71a9ab44eef5becf50358d9c665aef33522e77940ee840c03b385bf3".parse().unwrap(), + digest: "HHwqY8eMncQPwrGtdbxGpJ7Sz1QacdvrcUNG9ywtxLs5".parse().unwrap(), + version: 895_958_996, + }, + }], + 60_000_000, + ), + gas: Gas { budget: 25_000_000, price: 750 }, + gas_coin: Coin::mock_sui(), + }; + + let output = encode_token_transfer(&input).unwrap(); + let tx: Transaction = bcs::from_bytes(&output.tx_data).unwrap(); + match tx.kind { + sui_types::TransactionKind::ProgrammableTransaction(ptb) => { + assert!(ptb.inputs.iter().any(|inp| matches!(inp, sui_types::Input::FundsWithdrawal(_)))); + assert!(ptb.inputs.iter().any(|inp| matches!(inp, sui_types::Input::ImmutableOrOwned(_)))); + } + _ => panic!("expected ProgrammableTransaction"), + } + } } diff --git a/crates/swapper/src/cetus_clmm/tx_builder.rs b/crates/swapper/src/cetus_clmm/tx_builder.rs index ed34daa9b..e6619fced 100644 --- a/crates/swapper/src/cetus_clmm/tx_builder.rs +++ b/crates/swapper/src/cetus_clmm/tx_builder.rs @@ -12,7 +12,7 @@ use gem_sui::{ address::SuiAddress, gas_budget::GAS_BUDGET_MULTIPLIER, is_sui_coin, - models::{CoinAsset, TxOutput}, + models::{Coin, OwnedCoins, TxOutput}, sui_clock_object_input, tx_builder::{ ObjectResolver, PrefetchedTransactionData, TransactionBuilderInput, balance_value, balance_zero, build_input_coin, destroy_zero_balance, finish_transaction, from_balance, @@ -28,7 +28,7 @@ use sui_types::{Address, Digest}; pub(super) struct BuildInput<'a> { pub transaction: TransactionBuilderInput, pub amount: u64, - pub from_coins: &'a [CoinAsset], + pub from_coins: &'a OwnedCoins, } impl BuildInput<'_> { diff --git a/crates/swapper/src/mayan/tx_builder/mctp/sui/prefetch.rs b/crates/swapper/src/mayan/tx_builder/mctp/sui/prefetch.rs index 7b303c0d8..38e26c705 100644 --- a/crates/swapper/src/mayan/tx_builder/mctp/sui/prefetch.rs +++ b/crates/swapper/src/mayan/tx_builder/mctp/sui/prefetch.rs @@ -9,7 +9,7 @@ use crate::{ use futures::try_join; use gem_sui::{ SuiClient, is_sui_coin, - models::CoinAsset, + models::{Coin, OwnedCoins}, tx_builder::{ResolvedObjectInput, TransactionBuilderInput}, }; use serde::Deserialize; @@ -17,7 +17,7 @@ use std::collections::HashMap; pub(super) struct PrefetchedSuiData { pub(super) transaction: TransactionBuilderInput, - pub(super) input_coins: Vec, + pub(super) input_coins: OwnedCoins, pub(super) objects: HashMap, pub(super) mctp_package_id: String, pub(super) fee_manager_package_id: Option, @@ -37,9 +37,9 @@ impl PrefetchedSuiData { let transaction = async { TransactionBuilderInput::prefetch(client, sender, gas_budget).await.map_err(sui_error) }; let input_coins = async { if route.from_token.contract.as_str() != mctp_input_contract.as_str() || is_sui_coin(&mctp_input_contract) { - Ok(Vec::new()) + Ok(OwnedCoins::default()) } else { - client.get_coin_assets_by_type(sender, &mctp_input_contract).await.map_err(sui_error) + client.get_coins(sender, &mctp_input_contract).await.map_err(sui_error) } }; let object_ids = sui_object_ids(has_auction, &from_token_verified_address, &mctp_verified_input_address, &mctp_input_treasury); diff --git a/crates/swapper/src/mayan/tx_builder/mctp/sui/transaction.rs b/crates/swapper/src/mayan/tx_builder/mctp/sui/transaction.rs index ab8652011..135e7b0c8 100644 --- a/crates/swapper/src/mayan/tx_builder/mctp/sui/transaction.rs +++ b/crates/swapper/src/mayan/tx_builder/mctp/sui/transaction.rs @@ -19,7 +19,7 @@ use crate::{ use gem_sui::{ SUI_COIN_TYPE, address::SuiAddress, - models::TxOutput, + models::{OwnedCoins, TxOutput}, sui_clock_object_input, tx_builder::{TransactionJsonReplay, build_input_coin, finish_transaction, move_call}, }; @@ -112,7 +112,7 @@ fn add_publish_wormhole_message( ) -> Result<(), SwapperError> { let fee_coin = match wh_fee_coin { Some(coin) => coin, - None => build_input_coin(txb, SUI_COIN_TYPE, bridge_fee, &[]).map_err(sui_error)?, + None => build_input_coin(txb, SUI_COIN_TYPE, bridge_fee, &OwnedCoins::default()).map_err(sui_error)?, }; let clock = txb.object(sui_clock_object_input()); let wormhole_state = txb.object(prefetched.objects[SUI_WORMHOLE_STATE].input(true)); From aa0d73836636ac1de5cc70dbe024d5d29e84a89b Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Thu, 28 May 2026 16:39:17 +0900 Subject: [PATCH 2/2] reject hybrid funding, spend both coins and address balance until we have gasless USDC support --- crates/gem_sui/src/models/coin.rs | 5 +- crates/gem_sui/src/tx_builder/stake.rs | 9 ++- crates/gem_sui/src/tx_builder/transaction.rs | 32 +++++------ crates/gem_sui/src/tx_builder/transfer.rs | 58 +++++++++++++++++++- 4 files changed, 79 insertions(+), 25 deletions(-) diff --git a/crates/gem_sui/src/models/coin.rs b/crates/gem_sui/src/models/coin.rs index cdb575a03..2f0e6b7d4 100644 --- a/crates/gem_sui/src/models/coin.rs +++ b/crates/gem_sui/src/models/coin.rs @@ -70,7 +70,7 @@ impl OwnedCoins { impl OwnedCoins { pub fn coin_total(&self) -> u64 { - self.coins.iter().map(|coin| coin.balance).sum() + self.coins.iter().map(|coin| coin.balance).fold(0, u64::saturating_add) } pub fn total(&self) -> u64 { @@ -86,7 +86,8 @@ pub struct Balance { #[serde(deserialize_with = "deserialize_bigint_from_str")] pub total_balance: BigInt, #[serde(default)] - pub address_balance: u64, // Amount held in the per-address balance accumulator + /// Amount in the per-address balance accumulator. + pub address_balance: u64, } #[cfg(feature = "rpc")] diff --git a/crates/gem_sui/src/tx_builder/stake.rs b/crates/gem_sui/src/tx_builder/stake.rs index 3e79900b6..efb6f084c 100644 --- a/crates/gem_sui/src/tx_builder/stake.rs +++ b/crates/gem_sui/src/tx_builder/stake.rs @@ -8,7 +8,7 @@ use std::{error::Error, str::FromStr}; use sui_transaction_builder::{Function, ObjectInput, TransactionBuilder}; use sui_types::{Address, Identifier, TypeTag}; -use super::{TransactionBuilderInput, finish_transaction}; +use super::{TransactionBuilderInput, finish_transaction, transfer::requires_hybrid_funding}; pub const SUI_REQUEST_ADD_STAKE: &str = "request_add_stake"; pub const SUI_REQUEST_WITHDRAW_STAKE: &str = "request_withdraw_stake"; @@ -17,6 +17,12 @@ fn build_split_and_stake_ptb(input: &StakeInput) -> Result objects, which is not supported".into()); + } let stake_chain = primitives::StakeChain::Sui; if input.stake_amount < stake_chain.get_min_stake_amount() { @@ -27,7 +33,6 @@ fn build_split_and_stake_ptb(input: &StakeInput) -> Result= input.stake_amount { let coin_type: TypeTag = input .coins diff --git a/crates/gem_sui/src/tx_builder/transaction.rs b/crates/gem_sui/src/tx_builder/transaction.rs index 8893b1e77..1ef0d79f6 100644 --- a/crates/gem_sui/src/tx_builder/transaction.rs +++ b/crates/gem_sui/src/tx_builder/transaction.rs @@ -7,37 +7,32 @@ use crate::{ use gem_encoding::decode_base64; use serde::de::DeserializeOwned; use std::{error::Error, str::FromStr}; -use sui_transaction_builder::{Argument, Function, ObjectInput, TransactionBuilder}; +use sui_transaction_builder::{Argument, Function, TransactionBuilder}; use sui_types::{Address, Identifier, TypeTag}; const MODULE_COIN: &str = "coin"; const FUNCTION_ZERO: &str = "zero"; -/// Build a `Coin` of exactly `amount`, preferring Address Balance, then merging coin objects. -/// Caller must pre-validate that the combined sources cover `amount`. -pub(crate) fn build_amount_coin( - txb: &mut TransactionBuilder, - coin_type_tag: TypeTag, - amount: u64, - address_balance: u64, - coin_object_inputs: Vec, -) -> Result { +/// Build a `Coin` of exactly `amount`: pure withdrawal if Address Balance covers it, else coin objects topped up by the shortfall. +pub(crate) fn build_amount_coin(txb: &mut TransactionBuilder, coin_type_tag: TypeTag, amount: u64, address_balance: u64, coins: &[Coin]) -> Result { if address_balance >= amount { return Ok(txb.funds_withdrawal_coin(coin_type_tag, amount)); } - let mut coin_args: Vec = coin_object_inputs.into_iter().map(|input| txb.object(input)).collect(); - let primary = if address_balance > 0 { - txb.funds_withdrawal_coin(coin_type_tag, address_balance) - } else if !coin_args.is_empty() { - coin_args.remove(0) - } else { + if coins.is_empty() { return Err(SuiError::invalid_input("no coin sources for Sui amount")); - }; + } + let coin_total: u64 = coins.iter().map(|c| c.balance).fold(0, u64::saturating_add); + let mut coin_args: Vec = coins.iter().map(|c| txb.object(c.to_input())).collect(); + let primary = coin_args.remove(0); if !coin_args.is_empty() { txb.merge_coins(primary, coin_args); } + if let Some(shortfall) = amount.checked_sub(coin_total).filter(|s| *s > 0) { + let withdrawn = txb.funds_withdrawal_coin(coin_type_tag, shortfall); + txb.merge_coins(primary, vec![withdrawn]); + } let amount_arg = txb.pure(&amount); txb.split_coins(primary, vec![amount_arg]) @@ -85,8 +80,7 @@ pub fn build_input_coin(txb: &mut TransactionBuilder, coin_type: &str, amount: u let type_tag: TypeTag = coin_type .parse() .map_err(|err| SuiError::invalid_input(format!("Invalid Sui coin type {coin_type}: {err}")))?; - let coin_inputs = source.coins.iter().map(Coin::to_input).collect(); - build_amount_coin(txb, type_tag, amount, source.address_balance, coin_inputs) + build_amount_coin(txb, type_tag, amount, source.address_balance, &source.coins) } pub fn finish_transaction(mut txb: TransactionBuilder, input: TransactionBuilderInput) -> Result { diff --git a/crates/gem_sui/src/tx_builder/transfer.rs b/crates/gem_sui/src/tx_builder/transfer.rs index 1488edf85..125873b41 100644 --- a/crates/gem_sui/src/tx_builder/transfer.rs +++ b/crates/gem_sui/src/tx_builder/transfer.rs @@ -6,10 +6,20 @@ use sui_types::{Address, TypeTag}; use super::{TransactionBuilderInput, build_amount_coin, finish_transaction}; +pub(super) fn requires_hybrid_funding(coins: &OwnedCoins, amount: u64) -> bool { + coins.address_balance < amount && coins.coin_total() < amount +} + fn build_transfer_ptb(input: &TransferInput) -> Result> { if let Some(err) = crate::validate_enough_balance(&input.coins, input.amount) { return Err(err); } + if input.coins.coins.is_empty() { + return Err("No SUI coins available for gas".into()); + } + if !input.send_max && requires_hybrid_funding(&input.coins, input.amount) { + return Err("Sui native transfer: amount requires combining Address Balance with Coin objects, which is not supported".into()); + } let recipient = Address::from_str(&input.recipient)?; @@ -57,8 +67,7 @@ fn build_token_transfer_ptb(input: &TokenTransferInput) -> Result = tokens.coins.iter().map(|coin| coin.object.to_input()).collect(); - let amount_coin = build_amount_coin(&mut ptb, coin_type, input.amount, tokens.address_balance, coin_object_inputs)?; + let amount_coin = build_amount_coin(&mut ptb, coin_type, input.amount, tokens.address_balance, &tokens.coins)?; let recipient_argument = ptb.pure(&recipient); ptb.transfer_objects(vec![amount_coin], recipient_argument); @@ -219,8 +228,53 @@ mod tests { sui_types::TransactionKind::ProgrammableTransaction(ptb) => { assert!(ptb.inputs.iter().any(|inp| matches!(inp, sui_types::Input::FundsWithdrawal(_)))); assert!(ptb.inputs.iter().any(|inp| matches!(inp, sui_types::Input::ImmutableOrOwned(_)))); + let withdrawals: Vec = ptb + .inputs + .iter() + .filter_map(|inp| match inp { + sui_types::Input::FundsWithdrawal(w) => w.amount(), + _ => None, + }) + .collect(); + assert_eq!(withdrawals, vec![50_000_000], "expected shortfall withdrawal only"); } _ => panic!("expected ProgrammableTransaction"), } } + + #[test] + fn test_encode_native_transfer_without_gas_coin_rejected() { + let input = TransferInput { + sender: "0x1b4cd8b734f2465614678ca0450ce9c4f2ff4835c6a7545522892a1a8fb67991".into(), + recipient: "0xcf3abaeecfaf42990b8481c03000000000000000000000000000000000000000".into(), + amount: 1_000_000, + coins: OwnedCoins::new(SUI_COIN_TYPE.into(), vec![], 2_000_000), + send_max: false, + gas: Gas { budget: 25_000_000, price: 750 }, + }; + let err = encode_transfer(&input).expect_err("missing Coin for gas must be rejected early"); + assert!(err.to_string().contains("No SUI coins available for gas"), "got: {err}"); + } + + #[test] + fn test_encode_native_transfer_hybrid_rejected() { + let input = TransferInput { + sender: "0x1b4cd8b734f2465614678ca0450ce9c4f2ff4835c6a7545522892a1a8fb67991".into(), + recipient: "0xcf3abaeecfaf42990b8481c03000000000000000000000000000000000000000".into(), + amount: 8_000_000_000, + coins: OwnedCoins::new( + SUI_COIN_TYPE.into(), + vec![Coin { + coin_type: SUI_COIN_TYPE.into(), + balance: 5_000_000_000, + object: Object::mock(), + }], + 4_000_000_000, + ), + send_max: false, + gas: Gas { budget: 25_000_000, price: 750 }, + }; + let err = encode_transfer(&input).expect_err("hybrid native SUI must be rejected"); + assert!(err.to_string().contains("not supported"), "error must explain hybrid is unsupported: {err}"); + } }