1use crate::error::TaiError;
12use crate::ids::ObjectId;
13use crate::rpc::RpcClient;
14use serde_json::{json, Value};
15
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18#[repr(u8)]
19pub enum WorkOrderStatus {
20 New = 0,
22 Accepted = 1,
24 ReceiptSubmitted = 2,
26 Released = 3,
28 Refunded = 4,
30 Disputed = 5,
32}
33
34impl WorkOrderStatus {
35 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 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#[derive(Clone, Debug)]
65pub struct WorkOrderView {
66 pub object_id: ObjectId,
68 pub object_type: String,
70 pub coin_type: String,
72
73 pub buyer: String,
75 pub payee_launchpad_account_id: ObjectId,
77 pub payee_agent_treasury_id: ObjectId,
79
80 pub locked_sui: u64,
82 pub amount: u64,
84
85 pub spec_hash: Vec<u8>,
87 pub spec_url: String,
89
90 pub created_at_ms: u64,
92 pub deadline_ms: u64,
94 pub receipt_submitted_at_ms: u64,
96 pub dispute_window_ms: u64,
98
99 pub receipt_hash: Vec<u8>,
101 pub receipt_url: String,
103
104 pub status: WorkOrderStatus,
106}
107
108impl WorkOrderView {
109 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
173fn 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 if let Some(s) = v.as_str() {
214 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
237fn 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
268fn 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}