use crate::config::TaiConfig;
use crate::error::TaiError;
use crate::ids::{ObjectId, SuiAddress};
use crate::rpc::RpcClient;
use crate::signer::Signer;
use base64ct::{Base64, Encoding};
use blake2::{digest::consts::U32, Blake2b, Digest};
use serde::Deserialize;
use serde_json::{json, Value};
use std::sync::Arc;
pub const SUI_CLOCK_OBJECT_ID: &str =
"0x0000000000000000000000000000000000000000000000000000000000000006";
const TX_INTENT_PREFIX: [u8; 3] = [0, 0, 0];
pub const DEFAULT_GAS_BUDGET_MIST: u64 = 100_000_000;
#[derive(Clone, Debug)]
pub struct MoveCall {
pub package: ObjectId,
pub module: String,
pub function: String,
pub type_arguments: Vec<String>,
pub arguments: Vec<Value>,
pub gas: Option<ObjectId>,
pub gas_budget: u64,
}
impl MoveCall {
pub fn new(package: ObjectId, module: impl Into<String>, function: impl Into<String>) -> Self {
MoveCall {
package,
module: module.into(),
function: function.into(),
type_arguments: Vec::new(),
arguments: Vec::new(),
gas: None,
gas_budget: DEFAULT_GAS_BUDGET_MIST,
}
}
pub fn type_arg(mut self, t: impl Into<String>) -> Self {
self.type_arguments.push(t.into());
self
}
pub fn arg(mut self, v: Value) -> Self {
self.arguments.push(v);
self
}
pub fn arg_object(self, id: ObjectId) -> Self {
self.arg(json!(id.to_string()))
}
pub fn arg_u64(self, n: u64) -> Self {
self.arg(json!(n.to_string()))
}
pub fn arg_addr(self, a: SuiAddress) -> Self {
self.arg(json!(a.to_string()))
}
pub fn arg_bool(self, b: bool) -> Self {
self.arg(json!(b))
}
pub fn arg_option_id(self, opt: Option<ObjectId>) -> Self {
match opt {
None => self.arg(json!([])),
Some(id) => self.arg(json!([id.to_string()])),
}
}
pub fn arg_vec_addr(self, addrs: &[SuiAddress]) -> Self {
let items: Vec<String> = addrs.iter().map(|a| a.to_string()).collect();
self.arg(json!(items))
}
pub fn arg_vec_id(self, ids: &[ObjectId]) -> Self {
let items: Vec<String> = ids.iter().map(|i| i.to_string()).collect();
self.arg(json!(items))
}
pub fn arg_vec_u8(self, bytes: &[u8]) -> Self {
let items: Vec<u8> = bytes.to_vec();
self.arg(json!(items))
}
pub fn arg_string(self, s: &str) -> Self {
self.arg(json!(s))
}
pub fn with_gas_budget(mut self, gas_budget: u64) -> Self {
self.gas_budget = gas_budget;
self
}
}
#[derive(Clone, Debug, Deserialize)]
pub struct ExecutionResult {
pub digest: String,
pub effects: Option<Value>,
pub events: Option<Value>,
#[serde(rename = "objectChanges")]
pub object_changes: Option<Value>,
#[serde(rename = "balanceChanges")]
pub balance_changes: Option<Value>,
}
impl ExecutionResult {
pub fn check_success(&self) -> Result<(), TaiError> {
let status = self
.effects
.as_ref()
.and_then(|e| e.get("status"))
.and_then(|s| s.get("status"))
.and_then(|v| v.as_str())
.unwrap_or("unknown");
if status == "success" {
Ok(())
} else {
let err = self
.effects
.as_ref()
.and_then(|e| e.get("status"))
.and_then(|s| s.get("error"))
.and_then(|v| v.as_str())
.unwrap_or("");
Err(TaiError::TxFailed(format!(
"tx {} status={} error={}",
self.digest, status, err
)))
}
}
pub fn created_of_type(&self, type_substring: &str) -> Vec<String> {
let Some(changes) = self.object_changes.as_ref().and_then(|c| c.as_array()) else {
return Vec::new();
};
changes
.iter()
.filter(|c| {
c.get("type").and_then(|t| t.as_str()) == Some("created")
&& c.get("objectType")
.and_then(|t| t.as_str())
.is_some_and(|t| t.contains(type_substring))
})
.filter_map(|c| {
c.get("objectId")
.and_then(|i| i.as_str())
.map(|s| s.to_string())
})
.collect()
}
}
#[derive(Clone, Copy, Debug)]
pub enum RequestType {
WaitForLocalExecution,
WaitForEffectsCert,
}
impl RequestType {
fn as_str(self) -> &'static str {
match self {
RequestType::WaitForLocalExecution => "WaitForLocalExecution",
RequestType::WaitForEffectsCert => "WaitForEffectsCert",
}
}
}
pub struct TaiClient {
rpc: RpcClient,
config: TaiConfig,
signer: Arc<dyn Signer>,
}
impl TaiClient {
pub fn new(config: TaiConfig, signer: Arc<dyn Signer>) -> Self {
let rpc = RpcClient::new(&config.rpc_url);
TaiClient {
rpc,
config,
signer,
}
}
pub fn rpc(&self) -> &RpcClient {
&self.rpc
}
pub fn config(&self) -> &TaiConfig {
&self.config
}
pub fn sender(&self) -> SuiAddress {
self.signer.address()
}
pub async fn execute_move_call(
&self,
call: MoveCall,
request_type: RequestType,
) -> Result<ExecutionResult, TaiError> {
let sender = self.signer.address();
let gas_param: Value = match call.gas {
Some(id) => json!(id.to_string()),
None => Value::Null,
};
let build_params = json!([
sender.to_string(),
call.package.to_string(),
call.module,
call.function,
call.type_arguments,
call.arguments,
gas_param,
call.gas_budget.to_string(),
]);
let built: BuiltTransaction = self.rpc.call("unsafe_moveCall", build_params).await?;
let tx_bytes = Base64::decode_vec(&built.tx_bytes)
.map_err(|e| TaiError::Rpc(format!("decode txBytes base64: {e}")))?;
let digest = transaction_digest(&tx_bytes);
let signature = self.signer.sign(&digest).await?;
let sig_b64 = signature.to_base64();
let exec_params = json!([
built.tx_bytes,
[sig_b64],
{
"showEffects": true,
"showEvents": true,
"showObjectChanges": true,
"showBalanceChanges": true
},
request_type.as_str(),
]);
let result: ExecutionResult = self
.rpc
.call("sui_executeTransactionBlock", exec_params)
.await?;
result.check_success()?;
Ok(result)
}
}
pub fn select_coin(coins: &[(ObjectId, u64)], amount: u64) -> Option<ObjectId> {
let preferred = coins
.iter()
.find(|(_, bal)| *bal > amount)
.map(|(id, _)| *id);
if preferred.is_some() {
return preferred;
}
coins
.iter()
.find(|(_, bal)| *bal == amount)
.map(|(id, _)| *id)
}
#[derive(Deserialize)]
struct BuiltTransaction {
#[serde(rename = "txBytes")]
tx_bytes: String,
}
pub fn transaction_digest(tx_bytes: &[u8]) -> [u8; 32] {
let mut hasher = Blake2b::<U32>::new();
hasher.update(TX_INTENT_PREFIX);
hasher.update(tx_bytes);
let out = hasher.finalize();
let mut bytes = [0u8; 32];
bytes.copy_from_slice(&out);
bytes
}
impl TaiClient {
#[allow(clippy::too_many_arguments)]
pub async fn launch_agent_coin(
&self,
coin_type: &str,
treasury_cap_id: ObjectId,
coin_metadata_id: ObjectId,
coin_type_name: String,
linked_identity: Option<ObjectId>,
owner_cap_recipient: SuiAddress,
operator_recipient: Option<SuiAddress>,
operator_daily_limit_sui: u64,
operator_daily_limit_token: u64,
operator_allowed_targets: &[SuiAddress],
operator_ttl_ms: u64,
) -> Result<ExecutionResult, TaiError> {
let call = MoveCall::new(self.config.package_id, "launchpad", "launch_agent_coin")
.type_arg(coin_type)
.arg_object(self.config.config_id)
.arg_object(treasury_cap_id)
.arg_object(coin_metadata_id)
.arg_string(&coin_type_name)
.arg_option_id(linked_identity)
.arg_addr(owner_cap_recipient)
.arg(json!(operator_recipient
.map(|a| vec![a.to_string()])
.unwrap_or_default()))
.arg_u64(operator_daily_limit_sui)
.arg_u64(operator_daily_limit_token)
.arg_vec_addr(operator_allowed_targets)
.arg_u64(operator_ttl_ms)
.arg(json!(SUI_CLOCK_OBJECT_ID));
self.execute_move_call(call, RequestType::WaitForLocalExecution)
.await
}
pub async fn record_service_payment_sui(
&self,
coin_type: &str,
launchpad_account_id: ObjectId,
payment_coin_id: ObjectId,
) -> Result<ExecutionResult, TaiError> {
let call = MoveCall::new(
self.config.package_id,
"launchpad",
"record_service_payment_sui",
)
.type_arg(coin_type)
.arg_object(self.config.config_id)
.arg_object(launchpad_account_id)
.arg_object(payment_coin_id)
.arg(json!(SUI_CLOCK_OBJECT_ID));
self.execute_move_call(call, RequestType::WaitForLocalExecution)
.await
}
pub async fn buy(
&self,
coin_type: &str,
launchpad_account_id: ObjectId,
payment_coin_id: ObjectId,
min_tokens_out: u64,
) -> Result<ExecutionResult, TaiError> {
let call = MoveCall::new(self.config.package_id, "launchpad", "buy")
.type_arg(coin_type)
.arg_object(self.config.config_id)
.arg_object(launchpad_account_id)
.arg_object(payment_coin_id)
.arg_u64(min_tokens_out)
.arg(json!(SUI_CLOCK_OBJECT_ID));
self.execute_move_call(call, RequestType::WaitForLocalExecution)
.await
}
pub async fn sell(
&self,
coin_type: &str,
launchpad_account_id: ObjectId,
tokens_coin_id: ObjectId,
min_sui_out: u64,
) -> Result<ExecutionResult, TaiError> {
let call = MoveCall::new(self.config.package_id, "launchpad", "sell")
.type_arg(coin_type)
.arg_object(self.config.config_id)
.arg_object(launchpad_account_id)
.arg_object(tokens_coin_id)
.arg_u64(min_sui_out)
.arg(json!(SUI_CLOCK_OBJECT_ID));
self.execute_move_call(call, RequestType::WaitForLocalExecution)
.await
}
pub async fn set_access_config(
&self,
coin_type: &str,
launchpad_account_id: ObjectId,
threshold: u64,
accept_coin_payments: bool,
) -> Result<ExecutionResult, TaiError> {
let call = MoveCall::new(self.config.package_id, "launchpad", "set_access_config")
.type_arg(coin_type)
.arg_object(launchpad_account_id)
.arg_u64(threshold)
.arg_bool(accept_coin_payments);
self.execute_move_call(call, RequestType::WaitForLocalExecution)
.await
}
pub async fn withdraw_sui(
&self,
coin_type: &str,
treasury_id: ObjectId,
owner_cap_id: ObjectId,
amount: u64,
to: SuiAddress,
) -> Result<ExecutionResult, TaiError> {
let call = MoveCall::new(self.config.package_id, "agent_treasury", "withdraw_sui")
.type_arg(coin_type)
.arg_object(treasury_id)
.arg_object(owner_cap_id)
.arg_u64(amount)
.arg_addr(to);
self.execute_move_call(call, RequestType::WaitForLocalExecution)
.await
}
pub async fn top_up_sui(
&self,
coin_type: &str,
treasury_id: ObjectId,
payment_coin_id: ObjectId,
) -> Result<ExecutionResult, TaiError> {
let call = MoveCall::new(self.config.package_id, "agent_treasury", "top_up_sui")
.type_arg(coin_type)
.arg_object(treasury_id)
.arg_object(payment_coin_id);
self.execute_move_call(call, RequestType::WaitForLocalExecution)
.await
}
pub async fn top_up_token(
&self,
coin_type: &str,
treasury_id: ObjectId,
payment_coin_id: ObjectId,
) -> Result<ExecutionResult, TaiError> {
let call = MoveCall::new(self.config.package_id, "agent_treasury", "top_up_token")
.type_arg(coin_type)
.arg_object(treasury_id)
.arg_object(payment_coin_id);
self.execute_move_call(call, RequestType::WaitForLocalExecution)
.await
}
pub async fn withdraw_token(
&self,
coin_type: &str,
treasury_id: ObjectId,
owner_cap_id: ObjectId,
amount: u64,
to: SuiAddress,
) -> Result<ExecutionResult, TaiError> {
let call = MoveCall::new(self.config.package_id, "agent_treasury", "withdraw_token")
.type_arg(coin_type)
.arg_object(treasury_id)
.arg_object(owner_cap_id)
.arg_u64(amount)
.arg_addr(to);
self.execute_move_call(call, RequestType::WaitForLocalExecution)
.await
}
pub async fn claim_received_sui(
&self,
coin_type: &str,
treasury_id: ObjectId,
received_coin_id: ObjectId,
) -> Result<ExecutionResult, TaiError> {
let call = MoveCall::new(
self.config.package_id,
"agent_treasury",
"claim_received_sui",
)
.type_arg(coin_type)
.arg_object(treasury_id)
.arg_object(received_coin_id);
self.execute_move_call(call, RequestType::WaitForLocalExecution)
.await
}
pub async fn claim_received_token(
&self,
coin_type: &str,
treasury_id: ObjectId,
received_coin_id: ObjectId,
) -> Result<ExecutionResult, TaiError> {
let call = MoveCall::new(
self.config.package_id,
"agent_treasury",
"claim_received_token",
)
.type_arg(coin_type)
.arg_object(treasury_id)
.arg_object(received_coin_id);
self.execute_move_call(call, RequestType::WaitForLocalExecution)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn issue_operator_cap(
&self,
coin_type: &str,
treasury_id: ObjectId,
owner_cap_id: ObjectId,
recipient: SuiAddress,
daily_limit_sui: u64,
daily_limit_token: u64,
allowed_targets: &[SuiAddress],
ttl_ms: u64,
) -> Result<ExecutionResult, TaiError> {
let call = MoveCall::new(
self.config.package_id,
"agent_treasury",
"issue_operator_cap",
)
.type_arg(coin_type)
.arg_object(treasury_id)
.arg_object(owner_cap_id)
.arg_addr(recipient)
.arg_u64(daily_limit_sui)
.arg_u64(daily_limit_token)
.arg_vec_addr(allowed_targets)
.arg_u64(ttl_ms)
.arg(json!(SUI_CLOCK_OBJECT_ID));
self.execute_move_call(call, RequestType::WaitForLocalExecution)
.await
}
pub async fn revoke_operator_cap(
&self,
coin_type: &str,
treasury_id: ObjectId,
owner_cap_id: ObjectId,
cap_id: ObjectId,
) -> Result<ExecutionResult, TaiError> {
let call = MoveCall::new(
self.config.package_id,
"agent_treasury",
"revoke_operator_cap",
)
.type_arg(coin_type)
.arg_object(treasury_id)
.arg_object(owner_cap_id)
.arg_object(cap_id);
self.execute_move_call(call, RequestType::WaitForLocalExecution)
.await
}
pub async fn operator_spend_sui(
&self,
coin_type: &str,
treasury_id: ObjectId,
operator_cap_id: ObjectId,
amount: u64,
to: SuiAddress,
) -> Result<ExecutionResult, TaiError> {
let call = MoveCall::new(
self.config.package_id,
"agent_treasury",
"operator_spend_sui",
)
.type_arg(coin_type)
.arg_object(treasury_id)
.arg_object(operator_cap_id)
.arg_u64(amount)
.arg_addr(to)
.arg(json!(SUI_CLOCK_OBJECT_ID));
self.execute_move_call(call, RequestType::WaitForLocalExecution)
.await
}
pub async fn record_service_payment_token(
&self,
coin_type: &str,
launchpad_account_id: ObjectId,
treasury_cap_holder_id: ObjectId,
payment_coin_id: ObjectId,
) -> Result<ExecutionResult, TaiError> {
let call = MoveCall::new(
self.config.package_id,
"launchpad",
"record_service_payment_token",
)
.type_arg(coin_type)
.arg_object(self.config.config_id)
.arg_object(launchpad_account_id)
.arg_object(treasury_cap_holder_id)
.arg_object(payment_coin_id)
.arg(json!(SUI_CLOCK_OBJECT_ID));
self.execute_move_call(call, RequestType::WaitForLocalExecution)
.await
}
pub async fn set_linked_identity(
&self,
coin_type: &str,
launchpad_account_id: ObjectId,
identity: Option<ObjectId>,
) -> Result<ExecutionResult, TaiError> {
let call = MoveCall::new(self.config.package_id, "launchpad", "set_linked_identity")
.type_arg(coin_type)
.arg_object(launchpad_account_id)
.arg_option_id(identity);
self.execute_move_call(call, RequestType::WaitForLocalExecution)
.await
}
pub async fn admin_set_platform_treasury(
&self,
new_treasury: SuiAddress,
) -> Result<ExecutionResult, TaiError> {
let call = MoveCall::new(self.config.package_id, "launchpad", "set_platform_treasury")
.arg_object(self.config.config_id)
.arg_addr(new_treasury);
self.execute_move_call(call, RequestType::WaitForLocalExecution)
.await
}
pub async fn admin_set_trade_shares(
&self,
nav_bps: u64,
creator_bps: u64,
platform_bps: u64,
) -> Result<ExecutionResult, TaiError> {
let call = MoveCall::new(self.config.package_id, "launchpad", "set_trade_shares")
.arg_object(self.config.config_id)
.arg_u64(nav_bps)
.arg_u64(creator_bps)
.arg_u64(platform_bps);
self.execute_move_call(call, RequestType::WaitForLocalExecution)
.await
}
pub async fn admin_set_service_shares(
&self,
nav_bps: u64,
creator_bps: u64,
platform_bps: u64,
) -> Result<ExecutionResult, TaiError> {
let call = MoveCall::new(self.config.package_id, "launchpad", "set_service_shares")
.arg_object(self.config.config_id)
.arg_u64(nav_bps)
.arg_u64(creator_bps)
.arg_u64(platform_bps);
self.execute_move_call(call, RequestType::WaitForLocalExecution)
.await
}
pub async fn admin_set_token_service_shares(
&self,
nav_bps: u64,
burn_bps: u64,
creator_bps: u64,
) -> Result<ExecutionResult, TaiError> {
let call = MoveCall::new(
self.config.package_id,
"launchpad",
"set_token_service_shares",
)
.arg_object(self.config.config_id)
.arg_u64(nav_bps)
.arg_u64(burn_bps)
.arg_u64(creator_bps);
self.execute_move_call(call, RequestType::WaitForLocalExecution)
.await
}
pub async fn admin_set_trade_fee_bps(&self, bps: u64) -> Result<ExecutionResult, TaiError> {
let call = MoveCall::new(self.config.package_id, "launchpad", "set_trade_fee_bps")
.arg_object(self.config.config_id)
.arg_u64(bps);
self.execute_move_call(call, RequestType::WaitForLocalExecution)
.await
}
pub async fn admin_set_cred_revenue_target(
&self,
target: u64,
) -> Result<ExecutionResult, TaiError> {
let call = MoveCall::new(
self.config.package_id,
"launchpad",
"set_cred_revenue_target",
)
.arg_object(self.config.config_id)
.arg_u64(target);
self.execute_move_call(call, RequestType::WaitForLocalExecution)
.await
}
pub async fn propose_admin(&self, new_admin: SuiAddress) -> Result<ExecutionResult, TaiError> {
let call = MoveCall::new(self.config.package_id, "launchpad", "propose_admin")
.arg_object(self.config.config_id)
.arg_addr(new_admin);
self.execute_move_call(call, RequestType::WaitForLocalExecution)
.await
}
pub async fn accept_admin(&self) -> Result<ExecutionResult, TaiError> {
let call = MoveCall::new(self.config.package_id, "launchpad", "accept_admin")
.arg_object(self.config.config_id);
self.execute_move_call(call, RequestType::WaitForLocalExecution)
.await
}
pub async fn cancel_pending_admin(&self) -> Result<ExecutionResult, TaiError> {
let call = MoveCall::new(self.config.package_id, "launchpad", "cancel_pending_admin")
.arg_object(self.config.config_id);
self.execute_move_call(call, RequestType::WaitForLocalExecution)
.await
}
pub async fn set_creator(
&self,
coin_type: &str,
launchpad_account_id: ObjectId,
new_creator: SuiAddress,
) -> Result<ExecutionResult, TaiError> {
let call = MoveCall::new(self.config.package_id, "launchpad", "set_creator")
.type_arg(coin_type)
.arg_object(launchpad_account_id)
.arg_addr(new_creator);
self.execute_move_call(call, RequestType::WaitForLocalExecution)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn work_order_create(
&self,
coin_type: &str,
payee_launchpad_account_id: ObjectId,
payment_coin_id: ObjectId,
spec_hash: &[u8],
spec_url: &str,
deadline_ms: u64,
dispute_window_ms: u64,
) -> Result<ExecutionResult, TaiError> {
let call = MoveCall::new(self.config.package_id, "work_order", "create_work_order")
.type_arg(coin_type)
.arg_object(payee_launchpad_account_id)
.arg_object(payment_coin_id)
.arg_vec_u8(spec_hash)
.arg_string(spec_url)
.arg_u64(deadline_ms)
.arg_u64(dispute_window_ms)
.arg(json!(SUI_CLOCK_OBJECT_ID));
self.execute_move_call(call, RequestType::WaitForLocalExecution)
.await
}
pub async fn work_order_accept_with_owner(
&self,
coin_type: &str,
order_id: ObjectId,
owner_cap_id: ObjectId,
) -> Result<ExecutionResult, TaiError> {
let call = MoveCall::new(
self.config.package_id,
"work_order",
"accept_work_order_with_owner",
)
.type_arg(coin_type)
.arg_object(order_id)
.arg_object(owner_cap_id)
.arg(json!(SUI_CLOCK_OBJECT_ID));
self.execute_move_call(call, RequestType::WaitForLocalExecution)
.await
}
pub async fn work_order_accept_with_operator(
&self,
coin_type: &str,
order_id: ObjectId,
op_cap_id: ObjectId,
treasury_id: ObjectId,
) -> Result<ExecutionResult, TaiError> {
let call = MoveCall::new(
self.config.package_id,
"work_order",
"accept_work_order_with_operator_v2",
)
.type_arg(coin_type)
.arg_object(order_id)
.arg_object(op_cap_id)
.arg_object(treasury_id)
.arg(json!(SUI_CLOCK_OBJECT_ID));
self.execute_move_call(call, RequestType::WaitForLocalExecution)
.await
}
pub async fn work_order_submit_receipt_with_owner(
&self,
coin_type: &str,
order_id: ObjectId,
owner_cap_id: ObjectId,
receipt_hash: &[u8],
receipt_url: &str,
) -> Result<ExecutionResult, TaiError> {
let call = MoveCall::new(
self.config.package_id,
"work_order",
"submit_receipt_with_owner",
)
.type_arg(coin_type)
.arg_object(order_id)
.arg_object(owner_cap_id)
.arg_vec_u8(receipt_hash)
.arg_string(receipt_url)
.arg(json!(SUI_CLOCK_OBJECT_ID));
self.execute_move_call(call, RequestType::WaitForLocalExecution)
.await
}
pub async fn work_order_submit_receipt_with_operator(
&self,
coin_type: &str,
order_id: ObjectId,
op_cap_id: ObjectId,
treasury_id: ObjectId,
receipt_hash: &[u8],
receipt_url: &str,
) -> Result<ExecutionResult, TaiError> {
let call = MoveCall::new(
self.config.package_id,
"work_order",
"submit_receipt_with_operator_v2",
)
.type_arg(coin_type)
.arg_object(order_id)
.arg_object(op_cap_id)
.arg_object(treasury_id)
.arg_vec_u8(receipt_hash)
.arg_string(receipt_url)
.arg(json!(SUI_CLOCK_OBJECT_ID));
self.execute_move_call(call, RequestType::WaitForLocalExecution)
.await
}
pub async fn work_order_release(
&self,
coin_type: &str,
order_id: ObjectId,
payee_launchpad_account_id: ObjectId,
) -> Result<ExecutionResult, TaiError> {
let call = MoveCall::new(self.config.package_id, "work_order", "release_work_order")
.type_arg(coin_type)
.arg_object(order_id)
.arg_object(self.config.config_id)
.arg_object(payee_launchpad_account_id)
.arg(json!(SUI_CLOCK_OBJECT_ID));
self.execute_move_call(call, RequestType::WaitForLocalExecution)
.await
}
pub async fn work_order_refund(
&self,
coin_type: &str,
order_id: ObjectId,
) -> Result<ExecutionResult, TaiError> {
let call = MoveCall::new(self.config.package_id, "work_order", "refund_work_order")
.type_arg(coin_type)
.arg_object(order_id)
.arg(json!(SUI_CLOCK_OBJECT_ID));
self.execute_move_call(call, RequestType::WaitForLocalExecution)
.await
}
pub async fn work_order_open_dispute(
&self,
coin_type: &str,
order_id: ObjectId,
) -> Result<ExecutionResult, TaiError> {
let call = MoveCall::new(self.config.package_id, "work_order", "open_dispute")
.type_arg(coin_type)
.arg_object(order_id)
.arg(json!(SUI_CLOCK_OBJECT_ID));
self.execute_move_call(call, RequestType::WaitForLocalExecution)
.await
}
pub async fn work_order_admin_resolve(
&self,
coin_type: &str,
order_id: ObjectId,
payee_launchpad_account_id: ObjectId,
in_favor_of_payee: bool,
) -> Result<ExecutionResult, TaiError> {
let call = MoveCall::new(
self.config.package_id,
"work_order",
"admin_resolve_dispute",
)
.type_arg(coin_type)
.arg_object(order_id)
.arg_object(self.config.config_id)
.arg_object(payee_launchpad_account_id)
.arg_bool(in_favor_of_payee)
.arg(json!(SUI_CLOCK_OBJECT_ID));
self.execute_move_call(call, RequestType::WaitForLocalExecution)
.await
}
pub async fn split_off_coin(&self, coin_type: &str, amount: u64) -> Result<ObjectId, TaiError> {
let sender = self.signer.address();
let mut coins: Vec<(ObjectId, u64)> = Vec::new();
let mut cursor: Value = Value::Null;
let source_id = loop {
let coins_resp: Value = self
.rpc
.call(
"suix_getCoins",
json!([sender.to_string(), coin_type, cursor, null]),
)
.await?;
let data = coins_resp
.get("data")
.and_then(|d| d.as_array())
.ok_or_else(|| {
TaiError::RpcShape("suix_getCoins response missing 'data' array".into())
})?;
for entry in data {
if let (Some(id_str), Some(bal_str)) = (
entry.get("coinObjectId").and_then(|v| v.as_str()),
entry.get("balance").and_then(|v| v.as_str()),
) {
if let (Ok(id), Ok(bal)) = (id_str.parse::<ObjectId>(), bal_str.parse::<u64>())
{
coins.push((id, bal));
}
}
}
if let Some(id) = select_coin(&coins, amount) {
break id;
}
let has_next = coins_resp
.get("hasNextPage")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !has_next {
return Err(TaiError::Rpc(format!(
"no {coin_type} coin with balance >= {amount} to split"
)));
}
cursor = coins_resp.get("nextCursor").cloned().unwrap_or(Value::Null);
};
let build_params = json!([
sender.to_string(),
source_id.to_string(),
[amount.to_string()],
null,
DEFAULT_GAS_BUDGET_MIST.to_string(),
]);
let built: BuiltTransaction = self.rpc.call("unsafe_splitCoin", build_params).await?;
let tx_bytes = Base64::decode_vec(&built.tx_bytes)
.map_err(|e| TaiError::Rpc(format!("decode txBytes base64: {e}")))?;
let digest = transaction_digest(&tx_bytes);
let signature = self.signer.sign(&digest).await?;
let sig_b64 = signature.to_base64();
let exec_params = json!([
built.tx_bytes,
[sig_b64],
{
"showEffects": true,
"showEvents": true,
"showObjectChanges": true,
"showBalanceChanges": true
},
RequestType::WaitForLocalExecution.as_str(),
]);
let result: ExecutionResult = self
.rpc
.call("sui_executeTransactionBlock", exec_params)
.await?;
result.check_success()?;
let created = result.created_of_type(coin_type);
let new_id_str = created.into_iter().next().ok_or_else(|| {
TaiError::RpcShape(format!(
"split_off_coin: no created Coin<{coin_type}> in objectChanges"
))
})?;
let new_id: ObjectId = new_id_str
.parse()
.map_err(|e| TaiError::RpcShape(format!("split_off_coin: bad objectId: {e}")))?;
Ok(new_id)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::signer::Ed25519FileSigner;
fn cfg() -> TaiConfig {
TaiConfig::testnet_v1()
}
#[test]
fn move_call_builder_appends_in_order() {
let pkg: ObjectId = "0x7d41".parse().unwrap();
let acc: ObjectId = "0xc4a8".parse().unwrap();
let coin: ObjectId = "0xc01a".parse().unwrap();
let call = MoveCall::new(pkg, "launchpad", "buy")
.type_arg("0xabc::larry::LARRY")
.arg_object(acc)
.arg_object(coin)
.arg_u64(123)
.arg(json!(SUI_CLOCK_OBJECT_ID));
assert_eq!(call.module, "launchpad");
assert_eq!(call.function, "buy");
assert_eq!(call.type_arguments, vec!["0xabc::larry::LARRY"]);
assert_eq!(call.arguments.len(), 4);
assert_eq!(call.gas_budget, DEFAULT_GAS_BUDGET_MIST);
}
#[test]
fn transaction_digest_is_blake2b_with_intent_prefix() {
let tx_bytes = b"hello world";
let mut hasher = Blake2b::<U32>::new();
hasher.update([0u8, 0, 0]);
hasher.update(tx_bytes);
let expected: [u8; 32] = hasher.finalize().into();
assert_eq!(transaction_digest(tx_bytes), expected);
}
#[test]
fn execution_result_check_success_when_status_success() {
let r = ExecutionResult {
digest: "abc".into(),
effects: Some(json!({ "status": { "status": "success" } })),
events: None,
object_changes: None,
balance_changes: None,
};
assert!(r.check_success().is_ok());
}
#[test]
fn execution_result_check_success_when_status_failure() {
let r = ExecutionResult {
digest: "abc".into(),
effects: Some(json!({
"status": { "status": "failure", "error": "MoveAbort(EFooBar=42)" }
})),
events: None,
object_changes: None,
balance_changes: None,
};
let err = r.check_success().unwrap_err();
let msg = format!("{}", err);
assert!(msg.contains("MoveAbort"), "got: {}", msg);
}
#[test]
fn created_of_type_filters_by_substring() {
let r = ExecutionResult {
digest: "x".into(),
effects: None,
events: None,
object_changes: Some(json!([
{ "type": "created", "objectType": "0x..::launchpad::LaunchpadAccount<X>", "objectId": "0xacc" },
{ "type": "created", "objectType": "0x..::agent_treasury::AgentTreasury<X>", "objectId": "0xtreas" },
{ "type": "mutated", "objectType": "0x..::launchpad::LaunchpadAccount<X>", "objectId": "0xmut" }
])),
balance_changes: None,
};
let accounts = r.created_of_type("LaunchpadAccount");
assert_eq!(accounts, vec!["0xacc".to_string()]);
let treasuries = r.created_of_type("AgentTreasury");
assert_eq!(treasuries, vec!["0xtreas".to_string()]);
}
#[test]
fn client_exposes_sender_address() {
let signer = Arc::new(Ed25519FileSigner::from_seed([1u8; 32]));
let expected = signer.address();
let client = TaiClient::new(cfg(), signer);
assert_eq!(client.sender(), expected);
}
#[test]
fn arg_option_id_encodes_none_as_empty_array() {
let pkg: ObjectId = "0x1".parse().unwrap();
let call = MoveCall::new(pkg, "launchpad", "set_linked_identity").arg_option_id(None);
assert_eq!(call.arguments[0], json!([]));
}
#[test]
fn arg_option_id_encodes_some_as_single_element_array() {
let pkg: ObjectId = "0x1".parse().unwrap();
let id: ObjectId = "0xfeed".parse().unwrap();
let call = MoveCall::new(pkg, "launchpad", "set_linked_identity").arg_option_id(Some(id));
let arr = call.arguments[0].as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(
arr[0].as_str().unwrap(),
"0x000000000000000000000000000000000000000000000000000000000000feed"
);
}
#[test]
fn arg_vec_addr_encodes_array_of_addresses() {
let pkg: ObjectId = "0x1".parse().unwrap();
let a: SuiAddress = "0xab".parse().unwrap();
let b: SuiAddress = "0xcd".parse().unwrap();
let call = MoveCall::new(pkg, "agent_treasury", "issue_operator_cap").arg_vec_addr(&[a, b]);
let arr = call.arguments[0].as_array().unwrap();
assert_eq!(arr.len(), 2);
assert!(arr[0].as_str().unwrap().ends_with("ab"));
assert!(arr[1].as_str().unwrap().ends_with("cd"));
}
#[test]
fn arg_vec_id_encodes_array_of_ids() {
let pkg: ObjectId = "0x1".parse().unwrap();
let a: ObjectId = "0xa1".parse().unwrap();
let b: ObjectId = "0xb2".parse().unwrap();
let call = MoveCall::new(pkg, "agent_treasury", "issue_operator_cap").arg_vec_id(&[a, b]);
let arr = call.arguments[0].as_array().unwrap();
assert_eq!(arr.len(), 2);
}
#[test]
fn issue_operator_cap_builds_expected_argument_layout() {
let signer = Arc::new(Ed25519FileSigner::from_seed([1u8; 32]));
let client = TaiClient::new(cfg(), signer);
let treasury: ObjectId = "0xaaaa".parse().unwrap();
let owner_cap: ObjectId = "0xbbbb".parse().unwrap();
let recipient: SuiAddress = "0xcc01".parse().unwrap();
let allowed: SuiAddress = "0xdd01".parse().unwrap();
let call = MoveCall::new(
client.config().package_id,
"agent_treasury",
"issue_operator_cap",
)
.type_arg("0xabc::larry::LARRY")
.arg_object(treasury)
.arg_object(owner_cap)
.arg_addr(recipient)
.arg_u64(10_000_000_000)
.arg_vec_addr(&[allowed])
.arg_u64(30 * 86_400_000)
.arg(json!(SUI_CLOCK_OBJECT_ID));
assert_eq!(call.arguments.len(), 7);
assert_eq!(call.type_arguments, vec!["0xabc::larry::LARRY"]);
assert!(call.arguments[4].is_array());
}
fn oid(n: u8) -> ObjectId {
let mut b = [0u8; 32];
b[31] = n;
ObjectId::from_bytes(b)
}
#[test]
fn select_coin_empty_list_returns_none() {
assert!(select_coin(&[], 1_000).is_none());
}
#[test]
fn select_coin_all_below_amount_returns_none() {
let coins = vec![(oid(1), 100u64), (oid(2), 50u64)];
assert!(select_coin(&coins, 200).is_none());
}
#[test]
fn select_coin_exact_match_returned_when_no_greater_exists() {
let coins = vec![(oid(1), 100u64), (oid(2), 50u64)];
let result = select_coin(&coins, 100);
assert_eq!(result, Some(oid(1)));
}
#[test]
fn select_coin_prefers_strictly_greater_over_exact() {
let coins = vec![(oid(1), 1_000u64), (oid(2), 2_000u64)];
let result = select_coin(&coins, 1_000);
assert_eq!(result, Some(oid(2)));
}
#[test]
fn select_coin_returns_first_strictly_greater() {
let coins = vec![(oid(1), 5_000u64), (oid(2), 6_000u64)];
let result = select_coin(&coins, 1_000);
assert_eq!(result, Some(oid(1)));
}
}