Skip to main content

tai_core/
client.rs

1//! High-level write client: ask the RPC to build a Move call, sign the
2//! returned transaction digest, submit via `sui_executeTransactionBlock`.
3//!
4//! Why `unsafe_moveCall`. Building Sui `TransactionData` client-side
5//! requires the full Sui type system (one of the larger Rust deps in the
6//! ecosystem). For v1, `unsafe_moveCall` (despite the scary name — it
7//! refers to the server-trusts-arguments aspect, not to malicious behavior)
8//! is supported on all networks and dramatically simplifies the client.
9//! When it eventually gets removed we'll swap in BCS construction; the
10//! [`TaiClient`] surface stays the same.
11//!
12//! Wire format:
13//!
14//! 1. Build: `unsafe_moveCall(signer, pkg, module, function, type_args,
15//!    arguments, gas?, gas_budget)` → `{ txBytes: base64 }`.
16//! 2. Sign: `intent_message = [0, 0, 0] || tx_bytes`, sign
17//!    `blake2b_256(intent_message)` with the signer's key.
18//! 3. Execute: `sui_executeTransactionBlock(tx_bytes_base64,
19//!    [signature_base64], options, requestType)`.
20
21use crate::config::TaiConfig;
22use crate::error::TaiError;
23use crate::ids::{ObjectId, SuiAddress};
24use crate::rpc::RpcClient;
25use crate::signer::Signer;
26use base64ct::{Base64, Encoding};
27use blake2::{digest::consts::U32, Blake2b, Digest};
28use serde::Deserialize;
29use serde_json::{json, Value};
30use std::sync::Arc;
31
32/// Sui's well-known shared Clock object id: `0x0000…06`.
33pub const SUI_CLOCK_OBJECT_ID: &str =
34    "0x0000000000000000000000000000000000000000000000000000000000000006";
35
36/// Intent prefix bytes for `TransactionData`: `[scope=0, version=0, app=0]`.
37const TX_INTENT_PREFIX: [u8; 3] = [0, 0, 0];
38
39/// Default gas budget for client-built calls — 0.1 SUI in MIST. Override via
40/// [`MoveCall::gas_budget`].
41pub const DEFAULT_GAS_BUDGET_MIST: u64 = 100_000_000;
42
43/// Description of a single Move call. The RPC server builds the
44/// `TransactionData` from this plus the signer address.
45#[derive(Clone, Debug)]
46pub struct MoveCall {
47    /// Address of the Move package (the `tai` package on the target network).
48    pub package: ObjectId,
49    /// Module name within the package, e.g. `"launchpad"`.
50    pub module: String,
51    /// Function name within the module, e.g. `"record_service_payment_sui"`.
52    pub function: String,
53    /// Type arguments (instantiates the function's generic parameters).
54    ///
55    /// Concrete Move type strings, e.g. `"0xabc::larry::LARRY"`.
56    pub type_arguments: Vec<String>,
57    /// Positional Move arguments. Each argument is serialized as either:
58    /// an object id (string), a pure number, a string, an array of pure
59    /// values, etc. The RPC server resolves shared object IDs to refs.
60    pub arguments: Vec<Value>,
61    /// Optional gas object override. `None` lets the RPC pick a coin from
62    /// the signer's owned coins.
63    pub gas: Option<ObjectId>,
64    /// Maximum gas in MIST.
65    pub gas_budget: u64,
66}
67
68impl MoveCall {
69    /// Construct a Move call with the default gas budget and auto-selected gas coin.
70    pub fn new(package: ObjectId, module: impl Into<String>, function: impl Into<String>) -> Self {
71        MoveCall {
72            package,
73            module: module.into(),
74            function: function.into(),
75            type_arguments: Vec::new(),
76            arguments: Vec::new(),
77            gas: None,
78            gas_budget: DEFAULT_GAS_BUDGET_MIST,
79        }
80    }
81
82    /// Append a type argument.
83    pub fn type_arg(mut self, t: impl Into<String>) -> Self {
84        self.type_arguments.push(t.into());
85        self
86    }
87
88    /// Append a positional argument.
89    pub fn arg(mut self, v: Value) -> Self {
90        self.arguments.push(v);
91        self
92    }
93
94    /// Append an object-id argument.
95    pub fn arg_object(self, id: ObjectId) -> Self {
96        self.arg(json!(id.to_string()))
97    }
98
99    /// Append a pure u64 argument. Sui's `unsafe_moveCall` is strict about
100    /// numeric arg encoding — it rejects JSON numbers (`0`) for u64 with
101    /// `"Unexpected arg Number(0) for expected type U64"` and only accepts
102    /// strings (`"0"`). Stringify always so we don't trip on edge cases
103    /// like the operator-daily-limit-of-zero in a sovereign launch.
104    pub fn arg_u64(self, n: u64) -> Self {
105        self.arg(json!(n.to_string()))
106    }
107
108    /// Append a pure address argument.
109    pub fn arg_addr(self, a: SuiAddress) -> Self {
110        self.arg(json!(a.to_string()))
111    }
112
113    /// Append a pure bool argument.
114    pub fn arg_bool(self, b: bool) -> Self {
115        self.arg(json!(b))
116    }
117
118    /// Append a pure `Option<ID>` argument.
119    ///
120    /// Sui's RPC format for Move Option arguments is an array:
121    /// `[]` for `None`, `[inner]` for `Some(inner)`.
122    pub fn arg_option_id(self, opt: Option<ObjectId>) -> Self {
123        match opt {
124            None => self.arg(json!([])),
125            Some(id) => self.arg(json!([id.to_string()])),
126        }
127    }
128
129    /// Append a pure `vector<address>` argument.
130    pub fn arg_vec_addr(self, addrs: &[SuiAddress]) -> Self {
131        let items: Vec<String> = addrs.iter().map(|a| a.to_string()).collect();
132        self.arg(json!(items))
133    }
134
135    /// Append a pure `vector<ID>` argument.
136    pub fn arg_vec_id(self, ids: &[ObjectId]) -> Self {
137        let items: Vec<String> = ids.iter().map(|i| i.to_string()).collect();
138        self.arg(json!(items))
139    }
140
141    /// Append a pure `vector<u8>` argument (sent as JSON array of numbers
142    /// rather than base64, which the Sui RPC also accepts and is easier to
143    /// read in tx replays).
144    pub fn arg_vec_u8(self, bytes: &[u8]) -> Self {
145        let items: Vec<u8> = bytes.to_vec();
146        self.arg(json!(items))
147    }
148
149    /// Append a pure `String` argument.
150    pub fn arg_string(self, s: &str) -> Self {
151        self.arg(json!(s))
152    }
153
154    /// Override the gas budget.
155    pub fn with_gas_budget(mut self, gas_budget: u64) -> Self {
156        self.gas_budget = gas_budget;
157        self
158    }
159}
160
161/// Trimmed view of `sui_executeTransactionBlock`'s response. Full effects /
162/// events / object changes are present in `effects` / `events` /
163/// `object_changes` for callers that want to inspect them.
164#[derive(Clone, Debug, Deserialize)]
165pub struct ExecutionResult {
166    /// Transaction digest (base58).
167    pub digest: String,
168    /// Effects block. Includes `status`, `gasUsed`, etc.
169    pub effects: Option<Value>,
170    /// Events emitted by the call.
171    pub events: Option<Value>,
172    /// Created/mutated/deleted objects.
173    #[serde(rename = "objectChanges")]
174    pub object_changes: Option<Value>,
175    /// Per-coin balance changes.
176    #[serde(rename = "balanceChanges")]
177    pub balance_changes: Option<Value>,
178}
179
180impl ExecutionResult {
181    /// Returns `Ok(())` if the transaction effects report `status: success`,
182    /// otherwise [`TaiError::TxFailed`] with the on-chain error string.
183    pub fn check_success(&self) -> Result<(), TaiError> {
184        let status = self
185            .effects
186            .as_ref()
187            .and_then(|e| e.get("status"))
188            .and_then(|s| s.get("status"))
189            .and_then(|v| v.as_str())
190            .unwrap_or("unknown");
191        if status == "success" {
192            Ok(())
193        } else {
194            let err = self
195                .effects
196                .as_ref()
197                .and_then(|e| e.get("status"))
198                .and_then(|s| s.get("error"))
199                .and_then(|v| v.as_str())
200                .unwrap_or("");
201            Err(TaiError::TxFailed(format!(
202                "tx {} status={} error={}",
203                self.digest, status, err
204            )))
205        }
206    }
207
208    /// Convenience: collect created objects of a given Move type from the
209    /// `objectChanges` block. Each entry returns the object id string.
210    pub fn created_of_type(&self, type_substring: &str) -> Vec<String> {
211        let Some(changes) = self.object_changes.as_ref().and_then(|c| c.as_array()) else {
212            return Vec::new();
213        };
214        changes
215            .iter()
216            .filter(|c| {
217                c.get("type").and_then(|t| t.as_str()) == Some("created")
218                    && c.get("objectType")
219                        .and_then(|t| t.as_str())
220                        .is_some_and(|t| t.contains(type_substring))
221            })
222            .filter_map(|c| {
223                c.get("objectId")
224                    .and_then(|i| i.as_str())
225                    .map(|s| s.to_string())
226            })
227            .collect()
228    }
229}
230
231/// What kind of execution receipt the RPC should wait for before returning.
232#[derive(Clone, Copy, Debug)]
233pub enum RequestType {
234    /// Wait until the validator that received the request has locally
235    /// executed the transaction. Slowest but safest read-after-write.
236    WaitForLocalExecution,
237    /// Wait only for the effects certificate. Faster; effects might not be
238    /// queryable on every node yet.
239    WaitForEffectsCert,
240}
241
242impl RequestType {
243    fn as_str(self) -> &'static str {
244        match self {
245            RequestType::WaitForLocalExecution => "WaitForLocalExecution",
246            RequestType::WaitForEffectsCert => "WaitForEffectsCert",
247        }
248    }
249}
250
251/// High-level Tai client.
252///
253/// Wraps a [`RpcClient`] + a [`Signer`] (boxed) + a [`TaiConfig`] (so the
254/// package + LaunchpadConfig IDs are always available without re-passing).
255pub struct TaiClient {
256    rpc: RpcClient,
257    config: TaiConfig,
258    signer: Arc<dyn Signer>,
259}
260
261impl TaiClient {
262    /// Construct a client for the given config and signer.
263    pub fn new(config: TaiConfig, signer: Arc<dyn Signer>) -> Self {
264        let rpc = RpcClient::new(&config.rpc_url);
265        TaiClient {
266            rpc,
267            config,
268            signer,
269        }
270    }
271
272    /// Access the underlying RPC client (e.g. for read calls).
273    pub fn rpc(&self) -> &RpcClient {
274        &self.rpc
275    }
276
277    /// Access the underlying config.
278    pub fn config(&self) -> &TaiConfig {
279        &self.config
280    }
281
282    /// Access the signer's address (the sender of any submitted tx).
283    pub fn sender(&self) -> SuiAddress {
284        self.signer.address()
285    }
286
287    /// Submit an arbitrary Move call as the configured signer.
288    ///
289    /// Three round-trips to the RPC:
290    /// 1. `unsafe_moveCall` to build the unsigned `TransactionData`.
291    /// 2. Compute the intent digest locally and sign with [`Signer`].
292    /// 3. `sui_executeTransactionBlock` with the signature.
293    pub async fn execute_move_call(
294        &self,
295        call: MoveCall,
296        request_type: RequestType,
297    ) -> Result<ExecutionResult, TaiError> {
298        let sender = self.signer.address();
299
300        // 1. Build.
301        let gas_param: Value = match call.gas {
302            Some(id) => json!(id.to_string()),
303            None => Value::Null,
304        };
305        let build_params = json!([
306            sender.to_string(),
307            call.package.to_string(),
308            call.module,
309            call.function,
310            call.type_arguments,
311            call.arguments,
312            gas_param,
313            call.gas_budget.to_string(),
314        ]);
315        let built: BuiltTransaction = self.rpc.call("unsafe_moveCall", build_params).await?;
316
317        // 2. Sign.
318        let tx_bytes = Base64::decode_vec(&built.tx_bytes)
319            .map_err(|e| TaiError::Rpc(format!("decode txBytes base64: {e}")))?;
320        let digest = transaction_digest(&tx_bytes);
321        let signature = self.signer.sign(&digest).await?;
322        let sig_b64 = signature.to_base64();
323
324        // 3. Execute.
325        let exec_params = json!([
326            built.tx_bytes,
327            [sig_b64],
328            {
329                "showEffects": true,
330                "showEvents": true,
331                "showObjectChanges": true,
332                "showBalanceChanges": true
333            },
334            request_type.as_str(),
335        ]);
336        let result: ExecutionResult = self
337            .rpc
338            .call("sui_executeTransactionBlock", exec_params)
339            .await?;
340        result.check_success()?;
341        Ok(result)
342    }
343}
344
345/// Select a source coin for splitting.
346///
347/// Given a list of `(object_id, balance)` pairs, returns the `ObjectId` of a
348/// coin whose balance is `>= amount`. Prefers a coin with balance **strictly
349/// greater** than `amount` (so there is change left to pay gas); falls back to
350/// any coin with `balance == amount` if none strictly larger exists. Returns
351/// `None` if no coin has a sufficient balance.
352pub fn select_coin(coins: &[(ObjectId, u64)], amount: u64) -> Option<ObjectId> {
353    // First pass: prefer balance strictly greater than amount.
354    let preferred = coins
355        .iter()
356        .find(|(_, bal)| *bal > amount)
357        .map(|(id, _)| *id);
358    if preferred.is_some() {
359        return preferred;
360    }
361    // Second pass: accept exact match.
362    coins
363        .iter()
364        .find(|(_, bal)| *bal == amount)
365        .map(|(id, _)| *id)
366}
367
368#[derive(Deserialize)]
369struct BuiltTransaction {
370    #[serde(rename = "txBytes")]
371    tx_bytes: String,
372    // gas / inputCoins are also present but we don't need them.
373}
374
375/// Compute the digest a Sui validator expects the sender to sign.
376///
377/// `digest = blake2b_256([0, 0, 0] || tx_bytes)`. The prefix encodes the
378/// intent (`TransactionData`, version 0, app Sui).
379pub fn transaction_digest(tx_bytes: &[u8]) -> [u8; 32] {
380    let mut hasher = Blake2b::<U32>::new();
381    hasher.update(TX_INTENT_PREFIX);
382    hasher.update(tx_bytes);
383    let out = hasher.finalize();
384    let mut bytes = [0u8; 32];
385    bytes.copy_from_slice(&out);
386    bytes
387}
388
389// ============================================================================
390//  Typed write helpers
391// ============================================================================
392
393impl TaiClient {
394    /// `launch_agent_coin<T>(config, treasury_cap, metadata, coin_type_name,
395    ///                        linked_identity, owner_cap_recipient,
396    ///                        operator_recipient, operator_daily_limit_sui,
397    ///                        operator_daily_limit_token, operator_allowed_targets,
398    ///                        operator_ttl_ms, clock)`.
399    ///
400    /// Consumes the freshly-minted `TreasuryCap<T>`; borrows `CoinMetadata<T>`
401    /// immutably (the metadata object survives the call).
402    /// Emits a `LaunchEvent`; the caller can pull the resulting object ids
403    /// from the tx effects.
404    #[allow(clippy::too_many_arguments)]
405    pub async fn launch_agent_coin(
406        &self,
407        coin_type: &str,
408        treasury_cap_id: ObjectId,
409        coin_metadata_id: ObjectId,
410        coin_type_name: String,
411        linked_identity: Option<ObjectId>,
412        owner_cap_recipient: SuiAddress,
413        operator_recipient: Option<SuiAddress>,
414        operator_daily_limit_sui: u64,
415        operator_daily_limit_token: u64,
416        operator_allowed_targets: &[SuiAddress],
417        operator_ttl_ms: u64,
418    ) -> Result<ExecutionResult, TaiError> {
419        let call = MoveCall::new(self.config.package_id, "launchpad", "launch_agent_coin")
420            .type_arg(coin_type)
421            .arg_object(self.config.config_id)
422            .arg_object(treasury_cap_id)
423            .arg_object(coin_metadata_id)
424            .arg_string(&coin_type_name)
425            .arg_option_id(linked_identity)
426            .arg_addr(owner_cap_recipient)
427            .arg(json!(operator_recipient
428                .map(|a| vec![a.to_string()])
429                .unwrap_or_default()))
430            .arg_u64(operator_daily_limit_sui)
431            .arg_u64(operator_daily_limit_token)
432            .arg_vec_addr(operator_allowed_targets)
433            .arg_u64(operator_ttl_ms)
434            .arg(json!(SUI_CLOCK_OBJECT_ID));
435        self.execute_move_call(call, RequestType::WaitForLocalExecution)
436            .await
437    }
438
439    /// `record_service_payment_sui<T>(config, account, payment, clock)`.
440    pub async fn record_service_payment_sui(
441        &self,
442        coin_type: &str,
443        launchpad_account_id: ObjectId,
444        payment_coin_id: ObjectId,
445    ) -> Result<ExecutionResult, TaiError> {
446        let call = MoveCall::new(
447            self.config.package_id,
448            "launchpad",
449            "record_service_payment_sui",
450        )
451        .type_arg(coin_type)
452        .arg_object(self.config.config_id)
453        .arg_object(launchpad_account_id)
454        .arg_object(payment_coin_id)
455        .arg(json!(SUI_CLOCK_OBJECT_ID));
456        self.execute_move_call(call, RequestType::WaitForLocalExecution)
457            .await
458    }
459
460    /// `buy<T>(config, account, payment_coin, min_tokens_out, clock)`.
461    pub async fn buy(
462        &self,
463        coin_type: &str,
464        launchpad_account_id: ObjectId,
465        payment_coin_id: ObjectId,
466        min_tokens_out: u64,
467    ) -> Result<ExecutionResult, TaiError> {
468        let call = MoveCall::new(self.config.package_id, "launchpad", "buy")
469            .type_arg(coin_type)
470            .arg_object(self.config.config_id)
471            .arg_object(launchpad_account_id)
472            .arg_object(payment_coin_id)
473            .arg_u64(min_tokens_out)
474            .arg(json!(SUI_CLOCK_OBJECT_ID));
475        self.execute_move_call(call, RequestType::WaitForLocalExecution)
476            .await
477    }
478
479    /// `sell<T>(config, account, tokens_coin, min_sui_out, clock)`.
480    pub async fn sell(
481        &self,
482        coin_type: &str,
483        launchpad_account_id: ObjectId,
484        tokens_coin_id: ObjectId,
485        min_sui_out: u64,
486    ) -> Result<ExecutionResult, TaiError> {
487        let call = MoveCall::new(self.config.package_id, "launchpad", "sell")
488            .type_arg(coin_type)
489            .arg_object(self.config.config_id)
490            .arg_object(launchpad_account_id)
491            .arg_object(tokens_coin_id)
492            .arg_u64(min_sui_out)
493            .arg(json!(SUI_CLOCK_OBJECT_ID));
494        self.execute_move_call(call, RequestType::WaitForLocalExecution)
495            .await
496    }
497
498    /// `set_access_config<T>(account, threshold, accept_coin)`. Creator-only.
499    pub async fn set_access_config(
500        &self,
501        coin_type: &str,
502        launchpad_account_id: ObjectId,
503        threshold: u64,
504        accept_coin_payments: bool,
505    ) -> Result<ExecutionResult, TaiError> {
506        let call = MoveCall::new(self.config.package_id, "launchpad", "set_access_config")
507            .type_arg(coin_type)
508            .arg_object(launchpad_account_id)
509            .arg_u64(threshold)
510            .arg_bool(accept_coin_payments);
511        self.execute_move_call(call, RequestType::WaitForLocalExecution)
512            .await
513    }
514
515    /// `withdraw_sui<T>(treasury, owner_cap, amount, to)`. OwnerCap-gated.
516    pub async fn withdraw_sui(
517        &self,
518        coin_type: &str,
519        treasury_id: ObjectId,
520        owner_cap_id: ObjectId,
521        amount: u64,
522        to: SuiAddress,
523    ) -> Result<ExecutionResult, TaiError> {
524        let call = MoveCall::new(self.config.package_id, "agent_treasury", "withdraw_sui")
525            .type_arg(coin_type)
526            .arg_object(treasury_id)
527            .arg_object(owner_cap_id)
528            .arg_u64(amount)
529            .arg_addr(to);
530        self.execute_move_call(call, RequestType::WaitForLocalExecution)
531            .await
532    }
533
534    /// `top_up_sui<T>(treasury, payment)` — permissionless.
535    pub async fn top_up_sui(
536        &self,
537        coin_type: &str,
538        treasury_id: ObjectId,
539        payment_coin_id: ObjectId,
540    ) -> Result<ExecutionResult, TaiError> {
541        let call = MoveCall::new(self.config.package_id, "agent_treasury", "top_up_sui")
542            .type_arg(coin_type)
543            .arg_object(treasury_id)
544            .arg_object(payment_coin_id);
545        self.execute_move_call(call, RequestType::WaitForLocalExecution)
546            .await
547    }
548
549    /// `top_up_token<T>(treasury, payment)` — permissionless.
550    pub async fn top_up_token(
551        &self,
552        coin_type: &str,
553        treasury_id: ObjectId,
554        payment_coin_id: ObjectId,
555    ) -> Result<ExecutionResult, TaiError> {
556        let call = MoveCall::new(self.config.package_id, "agent_treasury", "top_up_token")
557            .type_arg(coin_type)
558            .arg_object(treasury_id)
559            .arg_object(payment_coin_id);
560        self.execute_move_call(call, RequestType::WaitForLocalExecution)
561            .await
562    }
563
564    /// `withdraw_token<T>(treasury, owner_cap, amount, to)`. OwnerCap-gated.
565    pub async fn withdraw_token(
566        &self,
567        coin_type: &str,
568        treasury_id: ObjectId,
569        owner_cap_id: ObjectId,
570        amount: u64,
571        to: SuiAddress,
572    ) -> Result<ExecutionResult, TaiError> {
573        let call = MoveCall::new(self.config.package_id, "agent_treasury", "withdraw_token")
574            .type_arg(coin_type)
575            .arg_object(treasury_id)
576            .arg_object(owner_cap_id)
577            .arg_u64(amount)
578            .arg_addr(to);
579        self.execute_move_call(call, RequestType::WaitForLocalExecution)
580            .await
581    }
582
583    /// `claim_received_sui<T>(treasury, Receiving<Coin<SUI>>)`.
584    ///
585    /// The `received_coin_id` argument is the id of a `Coin<SUI>` that has
586    /// been transferred-to-object to the treasury's address.
587    pub async fn claim_received_sui(
588        &self,
589        coin_type: &str,
590        treasury_id: ObjectId,
591        received_coin_id: ObjectId,
592    ) -> Result<ExecutionResult, TaiError> {
593        let call = MoveCall::new(
594            self.config.package_id,
595            "agent_treasury",
596            "claim_received_sui",
597        )
598        .type_arg(coin_type)
599        .arg_object(treasury_id)
600        .arg_object(received_coin_id);
601        self.execute_move_call(call, RequestType::WaitForLocalExecution)
602            .await
603    }
604
605    /// `claim_received_token<T>(treasury, Receiving<Coin<T>>)`.
606    pub async fn claim_received_token(
607        &self,
608        coin_type: &str,
609        treasury_id: ObjectId,
610        received_coin_id: ObjectId,
611    ) -> Result<ExecutionResult, TaiError> {
612        let call = MoveCall::new(
613            self.config.package_id,
614            "agent_treasury",
615            "claim_received_token",
616        )
617        .type_arg(coin_type)
618        .arg_object(treasury_id)
619        .arg_object(received_coin_id);
620        self.execute_move_call(call, RequestType::WaitForLocalExecution)
621            .await
622    }
623
624    /// `issue_operator_cap<T>(treasury, owner_cap, recipient, daily_limit_sui,
625    /// daily_limit_token, allowed_targets, ttl_ms, clock)`. OwnerCap-gated.
626    /// Arg count mirrors the on-chain function arity.
627    #[allow(clippy::too_many_arguments)]
628    pub async fn issue_operator_cap(
629        &self,
630        coin_type: &str,
631        treasury_id: ObjectId,
632        owner_cap_id: ObjectId,
633        recipient: SuiAddress,
634        daily_limit_sui: u64,
635        daily_limit_token: u64,
636        allowed_targets: &[SuiAddress],
637        ttl_ms: u64,
638    ) -> Result<ExecutionResult, TaiError> {
639        let call = MoveCall::new(
640            self.config.package_id,
641            "agent_treasury",
642            "issue_operator_cap",
643        )
644        .type_arg(coin_type)
645        .arg_object(treasury_id)
646        .arg_object(owner_cap_id)
647        .arg_addr(recipient)
648        .arg_u64(daily_limit_sui)
649        .arg_u64(daily_limit_token)
650        .arg_vec_addr(allowed_targets)
651        .arg_u64(ttl_ms)
652        .arg(json!(SUI_CLOCK_OBJECT_ID));
653        self.execute_move_call(call, RequestType::WaitForLocalExecution)
654            .await
655    }
656
657    /// `revoke_operator_cap<T>(treasury, owner_cap, cap_id)`. OwnerCap-gated.
658    pub async fn revoke_operator_cap(
659        &self,
660        coin_type: &str,
661        treasury_id: ObjectId,
662        owner_cap_id: ObjectId,
663        cap_id: ObjectId,
664    ) -> Result<ExecutionResult, TaiError> {
665        let call = MoveCall::new(
666            self.config.package_id,
667            "agent_treasury",
668            "revoke_operator_cap",
669        )
670        .type_arg(coin_type)
671        .arg_object(treasury_id)
672        .arg_object(owner_cap_id)
673        .arg_object(cap_id);
674        self.execute_move_call(call, RequestType::WaitForLocalExecution)
675            .await
676    }
677
678    /// `operator_spend_sui<T>(treasury, op_cap, amount, to, clock)`.
679    /// OperatorCap-gated; subject to Move-enforced policy (revocation, TTL,
680    /// allowlist, daily limit).
681    pub async fn operator_spend_sui(
682        &self,
683        coin_type: &str,
684        treasury_id: ObjectId,
685        operator_cap_id: ObjectId,
686        amount: u64,
687        to: SuiAddress,
688    ) -> Result<ExecutionResult, TaiError> {
689        let call = MoveCall::new(
690            self.config.package_id,
691            "agent_treasury",
692            "operator_spend_sui",
693        )
694        .type_arg(coin_type)
695        .arg_object(treasury_id)
696        .arg_object(operator_cap_id)
697        .arg_u64(amount)
698        .arg_addr(to)
699        .arg(json!(SUI_CLOCK_OBJECT_ID));
700        self.execute_move_call(call, RequestType::WaitForLocalExecution)
701            .await
702    }
703
704    /// `record_service_payment_token<T>(config, account, holder, payment, clock)`.
705    pub async fn record_service_payment_token(
706        &self,
707        coin_type: &str,
708        launchpad_account_id: ObjectId,
709        treasury_cap_holder_id: ObjectId,
710        payment_coin_id: ObjectId,
711    ) -> Result<ExecutionResult, TaiError> {
712        let call = MoveCall::new(
713            self.config.package_id,
714            "launchpad",
715            "record_service_payment_token",
716        )
717        .type_arg(coin_type)
718        .arg_object(self.config.config_id)
719        .arg_object(launchpad_account_id)
720        .arg_object(treasury_cap_holder_id)
721        .arg_object(payment_coin_id)
722        .arg(json!(SUI_CLOCK_OBJECT_ID));
723        self.execute_move_call(call, RequestType::WaitForLocalExecution)
724            .await
725    }
726
727    /// `set_linked_identity<T>(account, Option<ID>)`. Creator-only.
728    pub async fn set_linked_identity(
729        &self,
730        coin_type: &str,
731        launchpad_account_id: ObjectId,
732        identity: Option<ObjectId>,
733    ) -> Result<ExecutionResult, TaiError> {
734        let call = MoveCall::new(self.config.package_id, "launchpad", "set_linked_identity")
735            .type_arg(coin_type)
736            .arg_object(launchpad_account_id)
737            .arg_option_id(identity);
738        self.execute_move_call(call, RequestType::WaitForLocalExecution)
739            .await
740    }
741
742    // ------------------------------------------------------------------
743    //  Admin entries on LaunchpadConfig — usable only by the configured admin.
744    // ------------------------------------------------------------------
745
746    /// `set_platform_treasury(config, new_treasury)`. Admin-only.
747    pub async fn admin_set_platform_treasury(
748        &self,
749        new_treasury: SuiAddress,
750    ) -> Result<ExecutionResult, TaiError> {
751        let call = MoveCall::new(self.config.package_id, "launchpad", "set_platform_treasury")
752            .arg_object(self.config.config_id)
753            .arg_addr(new_treasury);
754        self.execute_move_call(call, RequestType::WaitForLocalExecution)
755            .await
756    }
757
758    /// `set_trade_shares(config, nav_bps, creator_bps, platform_bps)`. Admin-only.
759    pub async fn admin_set_trade_shares(
760        &self,
761        nav_bps: u64,
762        creator_bps: u64,
763        platform_bps: u64,
764    ) -> Result<ExecutionResult, TaiError> {
765        let call = MoveCall::new(self.config.package_id, "launchpad", "set_trade_shares")
766            .arg_object(self.config.config_id)
767            .arg_u64(nav_bps)
768            .arg_u64(creator_bps)
769            .arg_u64(platform_bps);
770        self.execute_move_call(call, RequestType::WaitForLocalExecution)
771            .await
772    }
773
774    /// `set_service_shares(config, nav_bps, creator_bps, platform_bps)`. Admin-only.
775    pub async fn admin_set_service_shares(
776        &self,
777        nav_bps: u64,
778        creator_bps: u64,
779        platform_bps: u64,
780    ) -> Result<ExecutionResult, TaiError> {
781        let call = MoveCall::new(self.config.package_id, "launchpad", "set_service_shares")
782            .arg_object(self.config.config_id)
783            .arg_u64(nav_bps)
784            .arg_u64(creator_bps)
785            .arg_u64(platform_bps);
786        self.execute_move_call(call, RequestType::WaitForLocalExecution)
787            .await
788    }
789
790    /// `set_token_service_shares(config, nav_bps, burn_bps, creator_bps)`. Admin-only.
791    pub async fn admin_set_token_service_shares(
792        &self,
793        nav_bps: u64,
794        burn_bps: u64,
795        creator_bps: u64,
796    ) -> Result<ExecutionResult, TaiError> {
797        let call = MoveCall::new(
798            self.config.package_id,
799            "launchpad",
800            "set_token_service_shares",
801        )
802        .arg_object(self.config.config_id)
803        .arg_u64(nav_bps)
804        .arg_u64(burn_bps)
805        .arg_u64(creator_bps);
806        self.execute_move_call(call, RequestType::WaitForLocalExecution)
807            .await
808    }
809
810    /// `set_trade_fee_bps(config, bps)`. Admin-only.
811    pub async fn admin_set_trade_fee_bps(&self, bps: u64) -> Result<ExecutionResult, TaiError> {
812        let call = MoveCall::new(self.config.package_id, "launchpad", "set_trade_fee_bps")
813            .arg_object(self.config.config_id)
814            .arg_u64(bps);
815        self.execute_move_call(call, RequestType::WaitForLocalExecution)
816            .await
817    }
818
819    /// `set_cred_revenue_target(config, target)`. Admin-only.
820    pub async fn admin_set_cred_revenue_target(
821        &self,
822        target: u64,
823    ) -> Result<ExecutionResult, TaiError> {
824        let call = MoveCall::new(
825            self.config.package_id,
826            "launchpad",
827            "set_cred_revenue_target",
828        )
829        .arg_object(self.config.config_id)
830        .arg_u64(target);
831        self.execute_move_call(call, RequestType::WaitForLocalExecution)
832            .await
833    }
834
835    /// `propose_admin(config, new_admin)`. Admin-only — step 1 of two-step
836    /// transfer. Pending admin must then call `accept_admin`.
837    pub async fn propose_admin(&self, new_admin: SuiAddress) -> Result<ExecutionResult, TaiError> {
838        let call = MoveCall::new(self.config.package_id, "launchpad", "propose_admin")
839            .arg_object(self.config.config_id)
840            .arg_addr(new_admin);
841        self.execute_move_call(call, RequestType::WaitForLocalExecution)
842            .await
843    }
844
845    /// `accept_admin(config)`. Must be called by the pending-admin address.
846    pub async fn accept_admin(&self) -> Result<ExecutionResult, TaiError> {
847        let call = MoveCall::new(self.config.package_id, "launchpad", "accept_admin")
848            .arg_object(self.config.config_id);
849        self.execute_move_call(call, RequestType::WaitForLocalExecution)
850            .await
851    }
852
853    /// `cancel_pending_admin(config)`. Admin-only.
854    pub async fn cancel_pending_admin(&self) -> Result<ExecutionResult, TaiError> {
855        let call = MoveCall::new(self.config.package_id, "launchpad", "cancel_pending_admin")
856            .arg_object(self.config.config_id);
857        self.execute_move_call(call, RequestType::WaitForLocalExecution)
858            .await
859    }
860
861    /// `set_creator<T>(account, new_creator)`. Creator-only.
862    pub async fn set_creator(
863        &self,
864        coin_type: &str,
865        launchpad_account_id: ObjectId,
866        new_creator: SuiAddress,
867    ) -> Result<ExecutionResult, TaiError> {
868        let call = MoveCall::new(self.config.package_id, "launchpad", "set_creator")
869            .type_arg(coin_type)
870            .arg_object(launchpad_account_id)
871            .arg_addr(new_creator);
872        self.execute_move_call(call, RequestType::WaitForLocalExecution)
873            .await
874    }
875
876    // ========================================================================
877    //  work_order
878    // ========================================================================
879
880    /// `create_work_order<T>(payee_account, payment, spec_hash, spec_url,
881    ///                       deadline_ms, dispute_window_ms, clock)`.
882    /// Buyer locks SUI; returns an ExecutionResult whose effects expose the
883    /// newly-shared WorkOrder<T> object id. Arg count mirrors the on-chain fn.
884    #[allow(clippy::too_many_arguments)]
885    pub async fn work_order_create(
886        &self,
887        coin_type: &str,
888        payee_launchpad_account_id: ObjectId,
889        payment_coin_id: ObjectId,
890        spec_hash: &[u8],
891        spec_url: &str,
892        deadline_ms: u64,
893        dispute_window_ms: u64,
894    ) -> Result<ExecutionResult, TaiError> {
895        let call = MoveCall::new(self.config.package_id, "work_order", "create_work_order")
896            .type_arg(coin_type)
897            .arg_object(payee_launchpad_account_id)
898            .arg_object(payment_coin_id)
899            .arg_vec_u8(spec_hash)
900            .arg_string(spec_url)
901            .arg_u64(deadline_ms)
902            .arg_u64(dispute_window_ms)
903            .arg(json!(SUI_CLOCK_OBJECT_ID));
904        self.execute_move_call(call, RequestType::WaitForLocalExecution)
905            .await
906    }
907
908    /// `accept_work_order_with_owner<T>(order, owner_cap, clock)`.
909    pub async fn work_order_accept_with_owner(
910        &self,
911        coin_type: &str,
912        order_id: ObjectId,
913        owner_cap_id: ObjectId,
914    ) -> Result<ExecutionResult, TaiError> {
915        let call = MoveCall::new(
916            self.config.package_id,
917            "work_order",
918            "accept_work_order_with_owner",
919        )
920        .type_arg(coin_type)
921        .arg_object(order_id)
922        .arg_object(owner_cap_id)
923        .arg(json!(SUI_CLOCK_OBJECT_ID));
924        self.execute_move_call(call, RequestType::WaitForLocalExecution)
925            .await
926    }
927
928    /// `accept_work_order_with_operator_v2<T>(order, op_cap, treasury, clock)`.
929    /// The payee `AgentTreasury<T>` is required so the cap is verified against
930    /// the live active set (revoked/expired caps are rejected).
931    pub async fn work_order_accept_with_operator(
932        &self,
933        coin_type: &str,
934        order_id: ObjectId,
935        op_cap_id: ObjectId,
936        treasury_id: ObjectId,
937    ) -> Result<ExecutionResult, TaiError> {
938        let call = MoveCall::new(
939            self.config.package_id,
940            "work_order",
941            "accept_work_order_with_operator_v2",
942        )
943        .type_arg(coin_type)
944        .arg_object(order_id)
945        .arg_object(op_cap_id)
946        .arg_object(treasury_id)
947        .arg(json!(SUI_CLOCK_OBJECT_ID));
948        self.execute_move_call(call, RequestType::WaitForLocalExecution)
949            .await
950    }
951
952    /// `submit_receipt_with_owner<T>(order, owner_cap, receipt_hash,
953    ///                                receipt_url, clock)`.
954    pub async fn work_order_submit_receipt_with_owner(
955        &self,
956        coin_type: &str,
957        order_id: ObjectId,
958        owner_cap_id: ObjectId,
959        receipt_hash: &[u8],
960        receipt_url: &str,
961    ) -> Result<ExecutionResult, TaiError> {
962        let call = MoveCall::new(
963            self.config.package_id,
964            "work_order",
965            "submit_receipt_with_owner",
966        )
967        .type_arg(coin_type)
968        .arg_object(order_id)
969        .arg_object(owner_cap_id)
970        .arg_vec_u8(receipt_hash)
971        .arg_string(receipt_url)
972        .arg(json!(SUI_CLOCK_OBJECT_ID));
973        self.execute_move_call(call, RequestType::WaitForLocalExecution)
974            .await
975    }
976
977    /// `submit_receipt_with_operator_v2<T>(order, op_cap, treasury,
978    ///                                     receipt_hash, url, clk)`. The payee
979    /// `AgentTreasury<T>` is required for the revocation/expiry checks.
980    pub async fn work_order_submit_receipt_with_operator(
981        &self,
982        coin_type: &str,
983        order_id: ObjectId,
984        op_cap_id: ObjectId,
985        treasury_id: ObjectId,
986        receipt_hash: &[u8],
987        receipt_url: &str,
988    ) -> Result<ExecutionResult, TaiError> {
989        let call = MoveCall::new(
990            self.config.package_id,
991            "work_order",
992            "submit_receipt_with_operator_v2",
993        )
994        .type_arg(coin_type)
995        .arg_object(order_id)
996        .arg_object(op_cap_id)
997        .arg_object(treasury_id)
998        .arg_vec_u8(receipt_hash)
999        .arg_string(receipt_url)
1000        .arg(json!(SUI_CLOCK_OBJECT_ID));
1001        self.execute_move_call(call, RequestType::WaitForLocalExecution)
1002            .await
1003    }
1004
1005    /// `release_work_order<T>(order, config, payee_account, clock)`.
1006    /// Buyer may call any time after receipt; anyone may call after window.
1007    /// Routes locked SUI through `record_service_payment_sui<T>`.
1008    pub async fn work_order_release(
1009        &self,
1010        coin_type: &str,
1011        order_id: ObjectId,
1012        payee_launchpad_account_id: ObjectId,
1013    ) -> Result<ExecutionResult, TaiError> {
1014        let call = MoveCall::new(self.config.package_id, "work_order", "release_work_order")
1015            .type_arg(coin_type)
1016            .arg_object(order_id)
1017            .arg_object(self.config.config_id)
1018            .arg_object(payee_launchpad_account_id)
1019            .arg(json!(SUI_CLOCK_OBJECT_ID));
1020        self.execute_move_call(call, RequestType::WaitForLocalExecution)
1021            .await
1022    }
1023
1024    /// `refund_work_order<T>(order, clock)`. Buyer-only, after deadline.
1025    pub async fn work_order_refund(
1026        &self,
1027        coin_type: &str,
1028        order_id: ObjectId,
1029    ) -> Result<ExecutionResult, TaiError> {
1030        let call = MoveCall::new(self.config.package_id, "work_order", "refund_work_order")
1031            .type_arg(coin_type)
1032            .arg_object(order_id)
1033            .arg(json!(SUI_CLOCK_OBJECT_ID));
1034        self.execute_move_call(call, RequestType::WaitForLocalExecution)
1035            .await
1036    }
1037
1038    /// `open_dispute<T>(order, clock)`. Buyer-only, during dispute window.
1039    pub async fn work_order_open_dispute(
1040        &self,
1041        coin_type: &str,
1042        order_id: ObjectId,
1043    ) -> Result<ExecutionResult, TaiError> {
1044        let call = MoveCall::new(self.config.package_id, "work_order", "open_dispute")
1045            .type_arg(coin_type)
1046            .arg_object(order_id)
1047            .arg(json!(SUI_CLOCK_OBJECT_ID));
1048        self.execute_move_call(call, RequestType::WaitForLocalExecution)
1049            .await
1050    }
1051
1052    /// `admin_resolve_dispute<T>(order, config, payee_account, in_favor_of_payee, clock)`.
1053    pub async fn work_order_admin_resolve(
1054        &self,
1055        coin_type: &str,
1056        order_id: ObjectId,
1057        payee_launchpad_account_id: ObjectId,
1058        in_favor_of_payee: bool,
1059    ) -> Result<ExecutionResult, TaiError> {
1060        let call = MoveCall::new(
1061            self.config.package_id,
1062            "work_order",
1063            "admin_resolve_dispute",
1064        )
1065        .type_arg(coin_type)
1066        .arg_object(order_id)
1067        .arg_object(self.config.config_id)
1068        .arg_object(payee_launchpad_account_id)
1069        .arg_bool(in_favor_of_payee)
1070        .arg(json!(SUI_CLOCK_OBJECT_ID));
1071        self.execute_move_call(call, RequestType::WaitForLocalExecution)
1072            .await
1073    }
1074
1075    // ========================================================================
1076    //  Coin utilities
1077    // ========================================================================
1078
1079    /// Split a fresh coin of `amount` base units of `coin_type` off one of the
1080    /// signer's owned coins, and return the new coin's object id. `coin_type`
1081    /// is the full Move type string (e.g. `"0x2::sui::SUI"` for SUI, or an
1082    /// agent coin type such as `"0xabc::larry::LARRY"`).
1083    ///
1084    /// # Workflow
1085    /// 1. Fetches the signer's coins of `coin_type` via `suix_getCoins`.
1086    /// 2. Picks a source coin with `balance >= amount` (prefers balance
1087    ///    strictly greater so another coin can cover gas; ties broken by
1088    ///    first-found).
1089    /// 3. Builds the split transaction via `unsafe_splitCoin`, signs, and
1090    ///    executes it.
1091    /// 4. Extracts and returns the newly-created coin's object id from the
1092    ///    `objectChanges` block.
1093    ///
1094    /// Returns [`TaiError::Rpc`] if no suitable source coin exists.
1095    ///
1096    /// Gas note: gas is auto-selected (`gas_object = null`). When splitting
1097    /// **SUI**, the chosen coin is consumed for the split, so the wallet needs
1098    /// another SUI coin (or a strictly-greater coin) to cover gas — otherwise
1099    /// the validator rejects the tx for insufficient gas. Splitting a non-SUI
1100    /// coin type is unaffected (gas is paid from a separate SUI coin).
1101    pub async fn split_off_coin(&self, coin_type: &str, amount: u64) -> Result<ObjectId, TaiError> {
1102        let sender = self.signer.address();
1103
1104        // 1. Fetch owned coins of coin_type, paginating until we find one that
1105        //    can cover `amount` (suix_getCoins caps each page at ~50, so a
1106        //    fragmented wallet could hide a qualifying coin on a later page).
1107        let mut coins: Vec<(ObjectId, u64)> = Vec::new();
1108        let mut cursor: Value = Value::Null;
1109        let source_id = loop {
1110            let coins_resp: Value = self
1111                .rpc
1112                .call(
1113                    "suix_getCoins",
1114                    json!([sender.to_string(), coin_type, cursor, null]),
1115                )
1116                .await?;
1117            let data = coins_resp
1118                .get("data")
1119                .and_then(|d| d.as_array())
1120                .ok_or_else(|| {
1121                    TaiError::RpcShape("suix_getCoins response missing 'data' array".into())
1122                })?;
1123            for entry in data {
1124                if let (Some(id_str), Some(bal_str)) = (
1125                    entry.get("coinObjectId").and_then(|v| v.as_str()),
1126                    entry.get("balance").and_then(|v| v.as_str()),
1127                ) {
1128                    if let (Ok(id), Ok(bal)) = (id_str.parse::<ObjectId>(), bal_str.parse::<u64>())
1129                    {
1130                        coins.push((id, bal));
1131                    }
1132                }
1133            }
1134            if let Some(id) = select_coin(&coins, amount) {
1135                break id;
1136            }
1137            let has_next = coins_resp
1138                .get("hasNextPage")
1139                .and_then(|v| v.as_bool())
1140                .unwrap_or(false);
1141            if !has_next {
1142                return Err(TaiError::Rpc(format!(
1143                    "no {coin_type} coin with balance >= {amount} to split"
1144                )));
1145            }
1146            cursor = coins_resp.get("nextCursor").cloned().unwrap_or(Value::Null);
1147        };
1148
1149        // 2. Build the split transaction via unsafe_splitCoin.
1150        //    Parameters: [signer, coin_object_id, [split_amounts], gas_object (null), gas_budget]
1151        let build_params = json!([
1152            sender.to_string(),
1153            source_id.to_string(),
1154            [amount.to_string()],
1155            null,
1156            DEFAULT_GAS_BUDGET_MIST.to_string(),
1157        ]);
1158        let built: BuiltTransaction = self.rpc.call("unsafe_splitCoin", build_params).await?;
1159
1160        // 3. Sign.
1161        let tx_bytes = Base64::decode_vec(&built.tx_bytes)
1162            .map_err(|e| TaiError::Rpc(format!("decode txBytes base64: {e}")))?;
1163        let digest = transaction_digest(&tx_bytes);
1164        let signature = self.signer.sign(&digest).await?;
1165        let sig_b64 = signature.to_base64();
1166
1167        // 4. Execute.
1168        let exec_params = json!([
1169            built.tx_bytes,
1170            [sig_b64],
1171            {
1172                "showEffects": true,
1173                "showEvents": true,
1174                "showObjectChanges": true,
1175                "showBalanceChanges": true
1176            },
1177            RequestType::WaitForLocalExecution.as_str(),
1178        ]);
1179        let result: ExecutionResult = self
1180            .rpc
1181            .call("sui_executeTransactionBlock", exec_params)
1182            .await?;
1183        result.check_success()?;
1184
1185        // 5. Find the new coin's object id from objectChanges.
1186        //    Look for a "created" entry whose objectType contains coin_type
1187        //    wrapped inside `0x2::coin::Coin<…>`.
1188        let created = result.created_of_type(coin_type);
1189        let new_id_str = created.into_iter().next().ok_or_else(|| {
1190            TaiError::RpcShape(format!(
1191                "split_off_coin: no created Coin<{coin_type}> in objectChanges"
1192            ))
1193        })?;
1194        let new_id: ObjectId = new_id_str
1195            .parse()
1196            .map_err(|e| TaiError::RpcShape(format!("split_off_coin: bad objectId: {e}")))?;
1197        Ok(new_id)
1198    }
1199}
1200
1201// ============================================================================
1202//  Tests
1203// ============================================================================
1204
1205#[cfg(test)]
1206mod tests {
1207    use super::*;
1208    use crate::signer::Ed25519FileSigner;
1209
1210    fn cfg() -> TaiConfig {
1211        TaiConfig::testnet_v1()
1212    }
1213
1214    #[test]
1215    fn move_call_builder_appends_in_order() {
1216        let pkg: ObjectId = "0x7d41".parse().unwrap();
1217        let acc: ObjectId = "0xc4a8".parse().unwrap();
1218        let coin: ObjectId = "0xc01a".parse().unwrap();
1219
1220        let call = MoveCall::new(pkg, "launchpad", "buy")
1221            .type_arg("0xabc::larry::LARRY")
1222            .arg_object(acc)
1223            .arg_object(coin)
1224            .arg_u64(123)
1225            .arg(json!(SUI_CLOCK_OBJECT_ID));
1226
1227        assert_eq!(call.module, "launchpad");
1228        assert_eq!(call.function, "buy");
1229        assert_eq!(call.type_arguments, vec!["0xabc::larry::LARRY"]);
1230        assert_eq!(call.arguments.len(), 4);
1231        assert_eq!(call.gas_budget, DEFAULT_GAS_BUDGET_MIST);
1232    }
1233
1234    #[test]
1235    fn transaction_digest_is_blake2b_with_intent_prefix() {
1236        let tx_bytes = b"hello world";
1237        let mut hasher = Blake2b::<U32>::new();
1238        hasher.update([0u8, 0, 0]);
1239        hasher.update(tx_bytes);
1240        let expected: [u8; 32] = hasher.finalize().into();
1241        assert_eq!(transaction_digest(tx_bytes), expected);
1242    }
1243
1244    #[test]
1245    fn execution_result_check_success_when_status_success() {
1246        let r = ExecutionResult {
1247            digest: "abc".into(),
1248            effects: Some(json!({ "status": { "status": "success" } })),
1249            events: None,
1250            object_changes: None,
1251            balance_changes: None,
1252        };
1253        assert!(r.check_success().is_ok());
1254    }
1255
1256    #[test]
1257    fn execution_result_check_success_when_status_failure() {
1258        let r = ExecutionResult {
1259            digest: "abc".into(),
1260            effects: Some(json!({
1261                "status": { "status": "failure", "error": "MoveAbort(EFooBar=42)" }
1262            })),
1263            events: None,
1264            object_changes: None,
1265            balance_changes: None,
1266        };
1267        let err = r.check_success().unwrap_err();
1268        let msg = format!("{}", err);
1269        assert!(msg.contains("MoveAbort"), "got: {}", msg);
1270    }
1271
1272    #[test]
1273    fn created_of_type_filters_by_substring() {
1274        let r = ExecutionResult {
1275            digest: "x".into(),
1276            effects: None,
1277            events: None,
1278            object_changes: Some(json!([
1279                { "type": "created", "objectType": "0x..::launchpad::LaunchpadAccount<X>", "objectId": "0xacc" },
1280                { "type": "created", "objectType": "0x..::agent_treasury::AgentTreasury<X>", "objectId": "0xtreas" },
1281                { "type": "mutated", "objectType": "0x..::launchpad::LaunchpadAccount<X>", "objectId": "0xmut" }
1282            ])),
1283            balance_changes: None,
1284        };
1285        let accounts = r.created_of_type("LaunchpadAccount");
1286        assert_eq!(accounts, vec!["0xacc".to_string()]);
1287        let treasuries = r.created_of_type("AgentTreasury");
1288        assert_eq!(treasuries, vec!["0xtreas".to_string()]);
1289    }
1290
1291    #[test]
1292    fn client_exposes_sender_address() {
1293        let signer = Arc::new(Ed25519FileSigner::from_seed([1u8; 32]));
1294        let expected = signer.address();
1295        let client = TaiClient::new(cfg(), signer);
1296        assert_eq!(client.sender(), expected);
1297    }
1298
1299    #[test]
1300    fn arg_option_id_encodes_none_as_empty_array() {
1301        let pkg: ObjectId = "0x1".parse().unwrap();
1302        let call = MoveCall::new(pkg, "launchpad", "set_linked_identity").arg_option_id(None);
1303        assert_eq!(call.arguments[0], json!([]));
1304    }
1305
1306    #[test]
1307    fn arg_option_id_encodes_some_as_single_element_array() {
1308        let pkg: ObjectId = "0x1".parse().unwrap();
1309        let id: ObjectId = "0xfeed".parse().unwrap();
1310        let call = MoveCall::new(pkg, "launchpad", "set_linked_identity").arg_option_id(Some(id));
1311        let arr = call.arguments[0].as_array().unwrap();
1312        assert_eq!(arr.len(), 1);
1313        assert_eq!(
1314            arr[0].as_str().unwrap(),
1315            "0x000000000000000000000000000000000000000000000000000000000000feed"
1316        );
1317    }
1318
1319    #[test]
1320    fn arg_vec_addr_encodes_array_of_addresses() {
1321        let pkg: ObjectId = "0x1".parse().unwrap();
1322        let a: SuiAddress = "0xab".parse().unwrap();
1323        let b: SuiAddress = "0xcd".parse().unwrap();
1324        let call = MoveCall::new(pkg, "agent_treasury", "issue_operator_cap").arg_vec_addr(&[a, b]);
1325        let arr = call.arguments[0].as_array().unwrap();
1326        assert_eq!(arr.len(), 2);
1327        assert!(arr[0].as_str().unwrap().ends_with("ab"));
1328        assert!(arr[1].as_str().unwrap().ends_with("cd"));
1329    }
1330
1331    #[test]
1332    fn arg_vec_id_encodes_array_of_ids() {
1333        let pkg: ObjectId = "0x1".parse().unwrap();
1334        let a: ObjectId = "0xa1".parse().unwrap();
1335        let b: ObjectId = "0xb2".parse().unwrap();
1336        let call = MoveCall::new(pkg, "agent_treasury", "issue_operator_cap").arg_vec_id(&[a, b]);
1337        let arr = call.arguments[0].as_array().unwrap();
1338        assert_eq!(arr.len(), 2);
1339    }
1340
1341    #[test]
1342    fn issue_operator_cap_builds_expected_argument_layout() {
1343        // Sanity-check the helper produces the right 7-positional-arg shape.
1344        let signer = Arc::new(Ed25519FileSigner::from_seed([1u8; 32]));
1345        let client = TaiClient::new(cfg(), signer);
1346
1347        // Build the MoveCall directly to inspect its shape.
1348        let treasury: ObjectId = "0xaaaa".parse().unwrap();
1349        let owner_cap: ObjectId = "0xbbbb".parse().unwrap();
1350        let recipient: SuiAddress = "0xcc01".parse().unwrap();
1351        let allowed: SuiAddress = "0xdd01".parse().unwrap();
1352
1353        let call = MoveCall::new(
1354            client.config().package_id,
1355            "agent_treasury",
1356            "issue_operator_cap",
1357        )
1358        .type_arg("0xabc::larry::LARRY")
1359        .arg_object(treasury)
1360        .arg_object(owner_cap)
1361        .arg_addr(recipient)
1362        .arg_u64(10_000_000_000)
1363        .arg_vec_addr(&[allowed])
1364        .arg_u64(30 * 86_400_000)
1365        .arg(json!(SUI_CLOCK_OBJECT_ID));
1366
1367        assert_eq!(call.arguments.len(), 7);
1368        assert_eq!(call.type_arguments, vec!["0xabc::larry::LARRY"]);
1369        // arg 4 (zero-indexed) is the allowlist vector
1370        assert!(call.arguments[4].is_array());
1371    }
1372
1373    // -----------------------------------------------------------------------
1374    //  select_coin unit tests
1375    // -----------------------------------------------------------------------
1376
1377    fn oid(n: u8) -> ObjectId {
1378        let mut b = [0u8; 32];
1379        b[31] = n;
1380        ObjectId::from_bytes(b)
1381    }
1382
1383    #[test]
1384    fn select_coin_empty_list_returns_none() {
1385        assert!(select_coin(&[], 1_000).is_none());
1386    }
1387
1388    #[test]
1389    fn select_coin_all_below_amount_returns_none() {
1390        let coins = vec![(oid(1), 100u64), (oid(2), 50u64)];
1391        assert!(select_coin(&coins, 200).is_none());
1392    }
1393
1394    #[test]
1395    fn select_coin_exact_match_returned_when_no_greater_exists() {
1396        let coins = vec![(oid(1), 100u64), (oid(2), 50u64)];
1397        let result = select_coin(&coins, 100);
1398        assert_eq!(result, Some(oid(1)));
1399    }
1400
1401    #[test]
1402    fn select_coin_prefers_strictly_greater_over_exact() {
1403        // oid(1) is exact; oid(2) has strictly more — should pick oid(2).
1404        let coins = vec![(oid(1), 1_000u64), (oid(2), 2_000u64)];
1405        let result = select_coin(&coins, 1_000);
1406        assert_eq!(result, Some(oid(2)));
1407    }
1408
1409    #[test]
1410    fn select_coin_returns_first_strictly_greater() {
1411        // Both oid(1) and oid(2) have balance > amount; pick oid(1) (first).
1412        let coins = vec![(oid(1), 5_000u64), (oid(2), 6_000u64)];
1413        let result = select_coin(&coins, 1_000);
1414        assert_eq!(result, Some(oid(1)));
1415    }
1416}