Skip to main content

tai_core/
work_order.rs

1//! WorkOrder<T> — typed read view + status constants.
2//!
3//! Mirrors the on-chain `tai::work_order` module added in v1.1.0. Read-only
4//! today; PTB builders for create/accept/submit-receipt/release/refund/dispute
5//! are exposed via `TaiClient` (see `client.rs`).
6//!
7//! The on-chain object holds `Balance<SUI>` only; the agent's coin type `T`
8//! is reflected in the object type (`0xPKG::work_order::WorkOrder<0xCOIN>`).
9//! Parsers extract `T` from the `objectType` string when needed.
10
11use crate::error::TaiError;
12use crate::ids::ObjectId;
13use crate::rpc::RpcClient;
14use serde_json::{json, Value};
15
16/// Status constants — must match `tai::work_order` Move constants.
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18#[repr(u8)]
19pub enum WorkOrderStatus {
20    /// Buyer created and locked SUI; payee has not acknowledged.
21    New = 0,
22    /// Payee accepted via Owner- or OperatorCap.
23    Accepted = 1,
24    /// Payee submitted proof of delivered work. Dispute window started.
25    ReceiptSubmitted = 2,
26    /// Funds released to the payee's launchpad account.
27    Released = 3,
28    /// Funds refunded to the buyer.
29    Refunded = 4,
30    /// Buyer opened a dispute during the window. Awaits admin resolution.
31    Disputed = 5,
32}
33
34impl WorkOrderStatus {
35    /// Parse an on-chain u8 status code.
36    pub fn from_u8(v: u8) -> Result<Self, TaiError> {
37        match v {
38            0 => Ok(Self::New),
39            1 => Ok(Self::Accepted),
40            2 => Ok(Self::ReceiptSubmitted),
41            3 => Ok(Self::Released),
42            4 => Ok(Self::Refunded),
43            5 => Ok(Self::Disputed),
44            other => Err(TaiError::Decode(format!(
45                "unknown WorkOrder status code: {other}"
46            ))),
47        }
48    }
49
50    /// Lowercase string label, useful for CLI output and indexer filters.
51    pub fn label(self) -> &'static str {
52        match self {
53            Self::New => "new",
54            Self::Accepted => "accepted",
55            Self::ReceiptSubmitted => "receipt_submitted",
56            Self::Released => "released",
57            Self::Refunded => "refunded",
58            Self::Disputed => "disputed",
59        }
60    }
61}
62
63/// Read-only snapshot of a `WorkOrder<T>` shared object.
64#[derive(Clone, Debug)]
65pub struct WorkOrderView {
66    /// The work order's ObjectId.
67    pub object_id: ObjectId,
68    /// Full object type, including the `<T>` parameter.
69    pub object_type: String,
70    /// Inner `T` extracted from `WorkOrder<T>` — the payee's coin type.
71    pub coin_type: String,
72
73    /// Buyer who created the order and locked SUI.
74    pub buyer: String,
75    /// Payee's LaunchpadAccount<T> id.
76    pub payee_launchpad_account_id: ObjectId,
77    /// Payee's AgentTreasury<T> id (cross-linkage check at construction).
78    pub payee_agent_treasury_id: ObjectId,
79
80    /// MIST of SUI currently locked in the order (may be 0 after settlement).
81    pub locked_sui: u64,
82    /// Original amount locked at creation.
83    pub amount: u64,
84
85    /// Free-form content hash of the work spec.
86    pub spec_hash: Vec<u8>,
87    /// Off-chain URL to the human-readable spec.
88    pub spec_url: String,
89
90    /// Creation time (UNIX ms).
91    pub created_at_ms: u64,
92    /// Deadline (UNIX ms) for refund eligibility if no receipt yet.
93    pub deadline_ms: u64,
94    /// Receipt submission time (UNIX ms). 0 if not yet submitted.
95    pub receipt_submitted_at_ms: u64,
96    /// Length of the post-receipt dispute window (ms).
97    pub dispute_window_ms: u64,
98
99    /// Receipt content hash (empty if not submitted).
100    pub receipt_hash: Vec<u8>,
101    /// Off-chain receipt URL (empty string if not submitted).
102    pub receipt_url: String,
103
104    /// Status code parsed into the enum.
105    pub status: WorkOrderStatus,
106}
107
108impl WorkOrderView {
109    /// Fetch a WorkOrder by object id.
110    pub async fn fetch(rpc: &RpcClient, object_id: ObjectId) -> Result<Self, TaiError> {
111        let params = json!([
112            object_id.to_string(),
113            { "showContent": true, "showType": true }
114        ]);
115        let raw: Value = rpc.call("sui_getObject", params).await?;
116        decode_work_order(&raw, object_id)
117    }
118}
119
120fn decode_work_order(raw: &Value, expected_id: ObjectId) -> Result<WorkOrderView, TaiError> {
121    let data = raw
122        .get("data")
123        .ok_or_else(|| TaiError::Decode("missing `data` in getObject".into()))?;
124    let content = data
125        .get("content")
126        .ok_or_else(|| TaiError::Decode("missing `content`".into()))?;
127    let data_type = content
128        .get("dataType")
129        .and_then(|v| v.as_str())
130        .unwrap_or("");
131    if data_type != "moveObject" {
132        return Err(TaiError::Decode(format!(
133            "expected moveObject, got {data_type}"
134        )));
135    }
136    let object_type = content
137        .get("type")
138        .and_then(|v| v.as_str())
139        .ok_or_else(|| TaiError::Decode("missing object type".into()))?
140        .to_string();
141
142    let coin_type = extract_coin_type(&object_type)
143        .ok_or_else(|| TaiError::Decode(format!("malformed WorkOrder type: {object_type}")))?;
144
145    let fields = content
146        .get("fields")
147        .ok_or_else(|| TaiError::Decode("missing `fields`".into()))?;
148
149    let status_u8 = parse_u64_str(fields, "status")? as u8;
150    let status = WorkOrderStatus::from_u8(status_u8)?;
151
152    Ok(WorkOrderView {
153        object_id: expected_id,
154        object_type,
155        coin_type,
156        buyer: parse_string(fields, "buyer")?,
157        payee_launchpad_account_id: parse_id(fields, "payee_launchpad_account_id")?,
158        payee_agent_treasury_id: parse_id(fields, "payee_agent_treasury_id")?,
159        locked_sui: parse_balance(fields, "locked")?,
160        amount: parse_u64_str(fields, "amount")?,
161        spec_hash: parse_byte_vec(fields, "spec_hash")?,
162        spec_url: parse_string(fields, "spec_url")?,
163        created_at_ms: parse_u64_str(fields, "created_at_ms")?,
164        deadline_ms: parse_u64_str(fields, "deadline_ms")?,
165        receipt_submitted_at_ms: parse_u64_str(fields, "receipt_submitted_at_ms")?,
166        dispute_window_ms: parse_u64_str(fields, "dispute_window_ms")?,
167        receipt_hash: parse_byte_vec(fields, "receipt_hash")?,
168        receipt_url: parse_string(fields, "receipt_url")?,
169        status,
170    })
171}
172
173// ============================================================================
174//  Local JSON helpers (kept private — reads.rs has its own copies, but
175//  duplicating these tiny helpers avoids cross-module pub visibility churn).
176// ============================================================================
177
178fn parse_u64_str(fields: &Value, key: &str) -> Result<u64, TaiError> {
179    let v = fields
180        .get(key)
181        .ok_or_else(|| TaiError::Decode(format!("missing field {key}")))?;
182    if let Some(s) = v.as_str() {
183        s.parse::<u64>()
184            .map_err(|e| TaiError::Decode(format!("field {key} not u64: {e}")))
185    } else if let Some(n) = v.as_u64() {
186        Ok(n)
187    } else {
188        Err(TaiError::Decode(format!("field {key} not number-like")))
189    }
190}
191
192fn parse_string(fields: &Value, key: &str) -> Result<String, TaiError> {
193    fields
194        .get(key)
195        .and_then(|v| v.as_str())
196        .map(str::to_string)
197        .ok_or_else(|| TaiError::Decode(format!("field {key} not a string")))
198}
199
200fn parse_id(fields: &Value, key: &str) -> Result<ObjectId, TaiError> {
201    use std::str::FromStr;
202    let s = parse_string(fields, key)?;
203    ObjectId::from_str(&s).map_err(|e| TaiError::Decode(format!("field {key}: {e}")))
204}
205
206fn parse_byte_vec(fields: &Value, key: &str) -> Result<Vec<u8>, TaiError> {
207    let v = fields
208        .get(key)
209        .ok_or_else(|| TaiError::Decode(format!("missing field {key}")))?;
210    // Sui surface for vector<u8> can be:
211    //   - a base64 string (default), or
212    //   - a JSON array of numbers.
213    if let Some(s) = v.as_str() {
214        // Best-effort base64 decode. If it's not valid base64, treat as ASCII.
215        use base64ct::{Base64, Encoding};
216        match Base64::decode_vec(s) {
217            Ok(bytes) => Ok(bytes),
218            Err(_) => Ok(s.as_bytes().to_vec()),
219        }
220    } else if let Some(arr) = v.as_array() {
221        let mut out = Vec::with_capacity(arr.len());
222        for elem in arr {
223            let n = elem
224                .as_u64()
225                .ok_or_else(|| TaiError::Decode(format!("field {key}: non-u8 element")))?;
226            if n > 255 {
227                return Err(TaiError::Decode(format!("field {key}: byte >255")));
228            }
229            out.push(n as u8);
230        }
231        Ok(out)
232    } else {
233        Err(TaiError::Decode(format!("field {key}: not bytes")))
234    }
235}
236
237/// `Balance<SUI>` can show up as either `{ "value": "123" }` or a plain
238/// number depending on the RPC view options. Handle both.
239fn parse_balance(fields: &Value, key: &str) -> Result<u64, TaiError> {
240    let v = fields
241        .get(key)
242        .ok_or_else(|| TaiError::Decode(format!("missing field {key}")))?;
243    if let Some(obj) = v.as_object() {
244        if let Some(inner) = obj.get("value") {
245            if let Some(s) = inner.as_str() {
246                return s
247                    .parse::<u64>()
248                    .map_err(|e| TaiError::Decode(format!("balance {key}.value: {e}")));
249            }
250            if let Some(n) = inner.as_u64() {
251                return Ok(n);
252            }
253        }
254    }
255    if let Some(s) = v.as_str() {
256        return s
257            .parse::<u64>()
258            .map_err(|e| TaiError::Decode(format!("balance {key}: {e}")));
259    }
260    if let Some(n) = v.as_u64() {
261        return Ok(n);
262    }
263    Err(TaiError::Decode(format!(
264        "balance {key}: unrecognized shape"
265    )))
266}
267
268/// Extract the inner `T` from `0xPKG::work_order::WorkOrder<T>`.
269fn extract_coin_type(t: &str) -> Option<String> {
270    let lt = t.find('<')?;
271    let gt = t.rfind('>')?;
272    if gt <= lt + 1 {
273        return None;
274    }
275    Some(t[lt + 1..gt].to_string())
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn status_round_trips() {
284        for code in 0u8..=5 {
285            let s = WorkOrderStatus::from_u8(code).unwrap();
286            assert_eq!(s as u8, code);
287        }
288        assert!(WorkOrderStatus::from_u8(99).is_err());
289    }
290
291    #[test]
292    fn coin_type_extracted_from_object_type() {
293        let t = "0xabc::work_order::WorkOrder<0xdef::larry::LARRY>";
294        assert_eq!(extract_coin_type(t).unwrap(), "0xdef::larry::LARRY");
295    }
296
297    #[test]
298    fn labels_are_lowercase_snake_case() {
299        assert_eq!(WorkOrderStatus::New.label(), "new");
300        assert_eq!(
301            WorkOrderStatus::ReceiptSubmitted.label(),
302            "receipt_submitted"
303        );
304    }
305}