Skip to main content

zing_cli/
sui.rs

1use std::str::FromStr;
2use std::time::Duration;
3use sui_crypto::ed25519::Ed25519PrivateKey;
4use sui_crypto::SuiSigner;
5use sui_rpc::field::FieldMask;
6use sui_rpc::field::FieldMaskUtil;
7use sui_rpc::proto::sui::rpc::v2::{ExecuteTransactionRequest, GetBalanceRequest, GetTransactionRequest};
8use sui_sdk_types::{Address, Digest, TypeTag};
9use sui_transaction_builder::{Function, ObjectInput, TransactionBuilder};
10
11const USDC_COIN_TYPE: &str =
12    "0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC";
13const MIN_PAYMENT_USDC: u64 = 10_000; // 0.01 USDC in micro-units
14
15/// Send exactly 0.01 USDC to the platform address.
16/// Returns the transaction digest as a string.
17pub async fn send_payment(
18    rpc_url: &str,
19    keypair: &Ed25519PrivateKey,
20    sender: &Address,
21    platform_address: &Address,
22) -> anyhow::Result<String> {
23    // Pre-check: ensure total balance (addr + coins) has enough USDC
24    let (addr_bal, coin_bal) = get_usdc_balance(rpc_url, sender).await?;
25    if addr_bal + coin_bal < MIN_PAYMENT_USDC {
26        anyhow::bail!("Insufficient USDC balance (need at least 0.01 USDC)");
27    }
28
29    // Always consolidate to avoid stale RPC cache on address_balance
30    if coin_bal > 0 {
31        consolidate_usdc_coins(rpc_url, keypair, sender, coin_bal).await?;
32    }
33
34    let mut client = sui_rpc::Client::new(rpc_url)
35        .map_err(|e| anyhow::anyhow!("Failed to create Sui RPC client: {e}"))?;
36
37    let usdc_type = TypeTag::from_str(USDC_COIN_TYPE)?;
38
39    // 2. Build PTB — withdraw from address balance and send via balance::send_funds
40    let mut tx = TransactionBuilder::new();
41
42    let balance_arg = tx.funds_withdrawal_balance(usdc_type.clone(), MIN_PAYMENT_USDC);
43    let recipient_arg = tx.pure(platform_address);
44    tx.move_call(
45        Function::new(
46            Address::TWO,
47            sui_sdk_types::Identifier::from_static("balance"),
48            sui_sdk_types::Identifier::from_static("send_funds"),
49        )
50        .with_type_args(vec![usdc_type.clone()]),
51        vec![balance_arg, recipient_arg],
52    );
53
54    tx.set_sender(*sender);
55    tx.set_gas_budget(0);
56
57    // 2. Build, sign, execute
58    let transaction = tx
59        .build(&mut client)
60        .await
61        .map_err(|e| anyhow::anyhow!("Failed to build transaction: {e}"))?;
62
63    let signature = keypair
64        .sign_transaction(&transaction)
65        .map_err(|e| anyhow::anyhow!("Signing failed: {e}"))?;
66
67    let request = ExecuteTransactionRequest::new(transaction.into())
68        .with_signatures(vec![signature.into()])
69        .with_read_mask(FieldMask::from_paths(vec!["digest"]));
70
71    let digest_str = execute_and_wait_for_checkpoint(
72        &mut client,
73        request,
74        Duration::from_secs(60),
75    )
76    .await?;
77
78    Ok(digest_str)
79}
80
81/// Execute a transaction and poll until it appears in a checkpoint.
82/// Returns the transaction digest.
83async fn execute_and_wait_for_checkpoint(
84    client: &mut sui_rpc::Client,
85    request: ExecuteTransactionRequest,
86    timeout: Duration,
87) -> anyhow::Result<String> {
88    let digest_str = {
89        let proto_tx = request
90            .transaction
91            .as_ref()
92            .ok_or_else(|| anyhow::anyhow!("Missing transaction in execution request"))?;
93        let sdk_tx = sui_sdk_types::Transaction::try_from(proto_tx)
94            .map_err(|e| anyhow::anyhow!("Failed to compute transaction digest: {e}"))?;
95        sdk_tx.digest().to_string()
96    };
97
98    client
99        .execution_client()
100        .execute_transaction(request)
101        .await
102        .map_err(|e| anyhow::anyhow!("Transaction execution failed: {e}"))?;
103
104    let start = std::time::Instant::now();
105    let mut delay = Duration::from_secs(1);
106    let max_delay = Duration::from_secs(8);
107    loop {
108        if start.elapsed() > timeout {
109            anyhow::bail!(
110                "Transaction executed but checkpoint wait timed out (waited {:.0}s)",
111                timeout.as_secs()
112            );
113        }
114
115        tokio::time::sleep(delay.min(timeout.saturating_sub(start.elapsed()))).await;
116        delay = (delay * 2).min(max_delay);
117
118        let mut poll_req = GetTransactionRequest::default();
119        poll_req.digest = Some(digest_str.clone());
120        poll_req.read_mask = Some(FieldMask::from_paths(vec!["digest", "checkpoint"]));
121
122        match client.ledger_client().get_transaction(poll_req).await {
123            Ok(resp) => {
124                if resp
125                    .get_ref()
126                    .transaction
127                    .as_ref()
128                    .and_then(|t| t.checkpoint)
129                    .is_some()
130                {
131                    return Ok(digest_str);
132                }
133            }
134            Err(_) => {
135                // Not indexed yet — expected, continue polling
136            }
137        }
138    }
139}
140
141/// Query USDC address_balance and coin_balance for the given address.
142/// Returns (address_balance, coin_balance) in USDC base units.
143pub async fn get_usdc_balance(
144    rpc_url: &str,
145    address: &Address,
146) -> anyhow::Result<(u64, u64)> {
147    let mut client = sui_rpc::Client::new(rpc_url)
148        .map_err(|e| anyhow::anyhow!("Failed to create Sui RPC client: {e}"))?;
149
150    let mut request = GetBalanceRequest::default();
151    request.owner = Some(address.to_string());
152    request.coin_type = Some(USDC_COIN_TYPE.to_string());
153
154    let response = client
155        .state_client()
156        .get_balance(request)
157        .await
158        .map_err(|e| anyhow::anyhow!("Failed to get USDC balance: {e}"))?
159        .into_inner();
160
161    match response.balance {
162        Some(b) => Ok((
163            b.address_balance.unwrap_or(0),
164            b.coin_balance.unwrap_or(0),
165        )),
166        None => Ok((0, 0)),
167    }
168}
169
170/// Consolidate all owned USDC coins into address balance via coin::send_funds.
171/// Executes a single PTB with one move_call per coin.
172pub async fn consolidate_usdc_coins(
173    rpc_url: &str,
174    keypair: &Ed25519PrivateKey,
175    sender: &Address,
176    coin_bal: u64,
177) -> anyhow::Result<String> {
178    let mut client = sui_rpc::Client::new(rpc_url)
179        .map_err(|e| anyhow::anyhow!("Failed to create Sui RPC client: {e}"))?;
180
181    let usdc_type = TypeTag::from_str(USDC_COIN_TYPE)?;
182
183    let usdc_coins = client
184        .select_coins(sender, &usdc_type, coin_bal, &[])
185        .await
186        .map_err(|e| anyhow::anyhow!("Failed to find USDC coins: {e}"))?;
187
188    if usdc_coins.is_empty() {
189        return Ok("no USDC coins to consolidate".into());
190    }
191
192    let mut tx = TransactionBuilder::new();
193
194    for usdc_coin in &usdc_coins {
195        let obj_id = Address::from_str(usdc_coin.object_id())?;
196        let digest = Digest::from_str(usdc_coin.digest())?;
197        let coin_arg = tx.object(ObjectInput::owned(obj_id, usdc_coin.version(), digest));
198        let self_arg = tx.pure(sender);
199        tx.move_call(
200            Function::new(
201                Address::TWO,
202                sui_sdk_types::Identifier::from_static("coin"),
203                sui_sdk_types::Identifier::from_static("send_funds"),
204            )
205            .with_type_args(vec![usdc_type.clone()]),
206            vec![coin_arg, self_arg],
207        );
208    }
209
210    tx.set_sender(*sender);
211    tx.set_gas_budget(0);
212
213    let transaction = tx
214        .build(&mut client)
215        .await
216        .map_err(|e| anyhow::anyhow!("Failed to build consolidation transaction: {e}"))?;
217
218    let signature = keypair
219        .sign_transaction(&transaction)
220        .map_err(|e| anyhow::anyhow!("Signing failed: {e}"))?;
221
222    let request = ExecuteTransactionRequest::new(transaction.into())
223        .with_signatures(vec![signature.into()])
224        .with_read_mask(FieldMask::from_paths(vec!["digest"]));
225
226    let digest_str = execute_and_wait_for_checkpoint(
227        &mut client,
228        request,
229        Duration::from_secs(60),
230    )
231    .await?;
232
233    Ok(digest_str)
234}