1use 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
32pub const SUI_CLOCK_OBJECT_ID: &str =
34 "0x0000000000000000000000000000000000000000000000000000000000000006";
35
36const TX_INTENT_PREFIX: [u8; 3] = [0, 0, 0];
38
39pub const DEFAULT_GAS_BUDGET_MIST: u64 = 100_000_000;
42
43#[derive(Clone, Debug)]
46pub struct MoveCall {
47 pub package: ObjectId,
49 pub module: String,
51 pub function: String,
53 pub type_arguments: Vec<String>,
57 pub arguments: Vec<Value>,
61 pub gas: Option<ObjectId>,
64 pub gas_budget: u64,
66}
67
68impl MoveCall {
69 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 pub fn type_arg(mut self, t: impl Into<String>) -> Self {
84 self.type_arguments.push(t.into());
85 self
86 }
87
88 pub fn arg(mut self, v: Value) -> Self {
90 self.arguments.push(v);
91 self
92 }
93
94 pub fn arg_object(self, id: ObjectId) -> Self {
96 self.arg(json!(id.to_string()))
97 }
98
99 pub fn arg_u64(self, n: u64) -> Self {
105 self.arg(json!(n.to_string()))
106 }
107
108 pub fn arg_addr(self, a: SuiAddress) -> Self {
110 self.arg(json!(a.to_string()))
111 }
112
113 pub fn arg_bool(self, b: bool) -> Self {
115 self.arg(json!(b))
116 }
117
118 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 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 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 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 pub fn arg_string(self, s: &str) -> Self {
151 self.arg(json!(s))
152 }
153
154 pub fn with_gas_budget(mut self, gas_budget: u64) -> Self {
156 self.gas_budget = gas_budget;
157 self
158 }
159}
160
161#[derive(Clone, Debug, Deserialize)]
165pub struct ExecutionResult {
166 pub digest: String,
168 pub effects: Option<Value>,
170 pub events: Option<Value>,
172 #[serde(rename = "objectChanges")]
174 pub object_changes: Option<Value>,
175 #[serde(rename = "balanceChanges")]
177 pub balance_changes: Option<Value>,
178}
179
180impl ExecutionResult {
181 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 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#[derive(Clone, Copy, Debug)]
233pub enum RequestType {
234 WaitForLocalExecution,
237 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
251pub struct TaiClient {
256 rpc: RpcClient,
257 config: TaiConfig,
258 signer: Arc<dyn Signer>,
259}
260
261impl TaiClient {
262 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 pub fn rpc(&self) -> &RpcClient {
274 &self.rpc
275 }
276
277 pub fn config(&self) -> &TaiConfig {
279 &self.config
280 }
281
282 pub fn sender(&self) -> SuiAddress {
284 self.signer.address()
285 }
286
287 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 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 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 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
345pub fn select_coin(coins: &[(ObjectId, u64)], amount: u64) -> Option<ObjectId> {
353 let preferred = coins
355 .iter()
356 .find(|(_, bal)| *bal > amount)
357 .map(|(id, _)| *id);
358 if preferred.is_some() {
359 return preferred;
360 }
361 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 }
374
375pub 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
389impl TaiClient {
394 #[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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 pub async fn split_off_coin(&self, coin_type: &str, amount: u64) -> Result<ObjectId, TaiError> {
1102 let sender = self.signer.address();
1103
1104 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 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 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 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 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#[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 let signer = Arc::new(Ed25519FileSigner::from_seed([1u8; 32]));
1345 let client = TaiClient::new(cfg(), signer);
1346
1347 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 assert!(call.arguments[4].is_array());
1371 }
1372
1373 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 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 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}