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    loop {
106        if start.elapsed() > timeout {
107            anyhow::bail!(
108                "Transaction executed but checkpoint wait timed out (waited {:.0}s)",
109                timeout.as_secs()
110            );
111        }
112
113        let mut poll_req = GetTransactionRequest::default();
114        poll_req.digest = Some(digest_str.clone());
115        poll_req.read_mask = Some(FieldMask::from_paths(vec!["digest", "checkpoint"]));
116
117        match client.ledger_client().get_transaction(poll_req).await {
118            Ok(resp) => {
119                if resp
120                    .get_ref()
121                    .transaction
122                    .as_ref()
123                    .and_then(|t| t.checkpoint)
124                    .is_some()
125                {
126                    return Ok(digest_str);
127                }
128            }
129            Err(_) => {
130                // Not indexed yet — expected, continue polling
131            }
132        }
133
134        tokio::time::sleep(Duration::from_millis(1000)).await;
135    }
136}
137
138/// Query USDC address_balance and coin_balance for the given address.
139/// Returns (address_balance, coin_balance) in USDC base units.
140pub async fn get_usdc_balance(
141    rpc_url: &str,
142    address: &Address,
143) -> anyhow::Result<(u64, u64)> {
144    let mut client = sui_rpc::Client::new(rpc_url)
145        .map_err(|e| anyhow::anyhow!("Failed to create Sui RPC client: {e}"))?;
146
147    let mut request = GetBalanceRequest::default();
148    request.owner = Some(address.to_string());
149    request.coin_type = Some(USDC_COIN_TYPE.to_string());
150
151    let response = client
152        .state_client()
153        .get_balance(request)
154        .await
155        .map_err(|e| anyhow::anyhow!("Failed to get USDC balance: {e}"))?
156        .into_inner();
157
158    match response.balance {
159        Some(b) => Ok((
160            b.address_balance.unwrap_or(0),
161            b.coin_balance.unwrap_or(0),
162        )),
163        None => Ok((0, 0)),
164    }
165}
166
167/// Consolidate all owned USDC coins into address balance via coin::send_funds.
168/// Executes a single PTB with one move_call per coin.
169pub async fn consolidate_usdc_coins(
170    rpc_url: &str,
171    keypair: &Ed25519PrivateKey,
172    sender: &Address,
173    coin_bal: u64,
174) -> anyhow::Result<String> {
175    let mut client = sui_rpc::Client::new(rpc_url)
176        .map_err(|e| anyhow::anyhow!("Failed to create Sui RPC client: {e}"))?;
177
178    let usdc_type = TypeTag::from_str(USDC_COIN_TYPE)?;
179
180    let usdc_coins = client
181        .select_coins(sender, &usdc_type, coin_bal, &[])
182        .await
183        .map_err(|e| anyhow::anyhow!("Failed to find USDC coins: {e}"))?;
184
185    if usdc_coins.is_empty() {
186        return Ok("no USDC coins to consolidate".into());
187    }
188
189    let mut tx = TransactionBuilder::new();
190
191    for usdc_coin in &usdc_coins {
192        let obj_id = Address::from_str(usdc_coin.object_id())?;
193        let digest = Digest::from_str(usdc_coin.digest())?;
194        let coin_arg = tx.object(ObjectInput::owned(obj_id, usdc_coin.version(), digest));
195        let self_arg = tx.pure(sender);
196        tx.move_call(
197            Function::new(
198                Address::TWO,
199                sui_sdk_types::Identifier::from_static("coin"),
200                sui_sdk_types::Identifier::from_static("send_funds"),
201            )
202            .with_type_args(vec![usdc_type.clone()]),
203            vec![coin_arg, self_arg],
204        );
205    }
206
207    tx.set_sender(*sender);
208    tx.set_gas_budget(0);
209
210    let transaction = tx
211        .build(&mut client)
212        .await
213        .map_err(|e| anyhow::anyhow!("Failed to build consolidation transaction: {e}"))?;
214
215    let signature = keypair
216        .sign_transaction(&transaction)
217        .map_err(|e| anyhow::anyhow!("Signing failed: {e}"))?;
218
219    let request = ExecuteTransactionRequest::new(transaction.into())
220        .with_signatures(vec![signature.into()])
221        .with_read_mask(FieldMask::from_paths(vec!["digest"]));
222
223    let digest_str = execute_and_wait_for_checkpoint(
224        &mut client,
225        request,
226        Duration::from_secs(60),
227    )
228    .await?;
229
230    Ok(digest_str)
231}