1use crate::error::TaiError;
8use crate::ids::{ObjectId, SuiAddress};
9use crate::rpc::RpcClient;
10use serde_json::{json, Value};
11
12#[derive(Clone, Debug)]
14pub struct LaunchpadConfigView {
15 pub object_id: ObjectId,
17 pub admin: SuiAddress,
19 pub platform_treasury: SuiAddress,
21
22 pub trade_fee_bps: u64,
25 pub trade_nav_share_bps: u64,
27 pub trade_creator_share_bps: u64,
29 pub trade_platform_share_bps: u64,
31
32 pub service_nav_share_bps: u64,
35 pub service_creator_share_bps: u64,
37 pub service_platform_share_bps: u64,
39
40 pub token_service_nav_share_bps: u64,
43 pub token_service_burn_share_bps: u64,
45 pub token_service_creator_share_bps: u64,
47
48 pub virtual_sui_reserves: u64,
51 pub virtual_token_reserves: u64,
53 pub sale_supply: u64,
55 pub lp_supply: u64,
57
58 pub cred_revenue_target: u64,
60}
61
62impl LaunchpadConfigView {
63 pub async fn fetch(rpc: &RpcClient, object_id: ObjectId) -> Result<Self, TaiError> {
65 let params = json!([
66 object_id.to_string(),
67 { "showContent": true }
68 ]);
69 let raw: Value = rpc.call("sui_getObject", params).await?;
70 decode_launchpad_config(&raw, object_id)
71 }
72}
73
74fn decode_launchpad_config(
75 raw: &Value,
76 expected_id: ObjectId,
77) -> Result<LaunchpadConfigView, TaiError> {
78 let data = raw
79 .get("data")
80 .ok_or_else(|| TaiError::Decode("missing `data` in getObject response".into()))?;
81 let content = data
82 .get("content")
83 .ok_or_else(|| TaiError::Decode("missing `content` in getObject response".into()))?;
84
85 let data_type = content
86 .get("dataType")
87 .and_then(|v| v.as_str())
88 .unwrap_or("");
89 if data_type != "moveObject" {
90 return Err(TaiError::Decode(format!(
91 "expected moveObject, got {}",
92 data_type
93 )));
94 }
95
96 let fields = content
97 .get("fields")
98 .ok_or_else(|| TaiError::Decode("missing `fields`".into()))?;
99
100 Ok(LaunchpadConfigView {
101 object_id: expected_id,
102 admin: parse_addr(fields, "admin")?,
103 platform_treasury: parse_addr(fields, "platform_treasury")?,
104
105 trade_fee_bps: parse_u64(fields, "trade_fee_bps")?,
106 trade_nav_share_bps: parse_u64(fields, "trade_nav_share_bps")?,
107 trade_creator_share_bps: parse_u64(fields, "trade_creator_share_bps")?,
108 trade_platform_share_bps: parse_u64(fields, "trade_platform_share_bps")?,
109
110 service_nav_share_bps: parse_u64(fields, "service_nav_share_bps")?,
111 service_creator_share_bps: parse_u64(fields, "service_creator_share_bps")?,
112 service_platform_share_bps: parse_u64(fields, "service_platform_share_bps")?,
113
114 token_service_nav_share_bps: parse_u64(fields, "token_service_nav_share_bps")?,
115 token_service_burn_share_bps: parse_u64(fields, "token_service_burn_share_bps")?,
116 token_service_creator_share_bps: parse_u64(fields, "token_service_creator_share_bps")?,
117
118 virtual_sui_reserves: parse_u64(fields, "virtual_sui_reserves")?,
119 virtual_token_reserves: parse_u64(fields, "virtual_token_reserves")?,
120 sale_supply: parse_u64(fields, "sale_supply")?,
121 lp_supply: parse_u64(fields, "lp_supply")?,
122
123 cred_revenue_target: parse_u64(fields, "cred_revenue_target")?,
124 })
125}
126
127fn parse_u64(fields: &Value, key: &str) -> Result<u64, TaiError> {
128 let v = fields
129 .get(key)
130 .ok_or_else(|| TaiError::Decode(format!("missing field `{}`", key)))?;
131 if let Some(n) = v.as_u64() {
132 return Ok(n);
133 }
134 if let Some(s) = v.as_str() {
135 return s
136 .parse::<u64>()
137 .map_err(|e| TaiError::Decode(format!("u64 parse `{}`: {}", key, e)));
138 }
139 Err(TaiError::Decode(format!(
140 "field `{}` is neither u64 nor decimal string",
141 key
142 )))
143}
144
145fn parse_addr(fields: &Value, key: &str) -> Result<SuiAddress, TaiError> {
146 let v = fields
147 .get(key)
148 .and_then(|v| v.as_str())
149 .ok_or_else(|| TaiError::Decode(format!("missing/non-string field `{}`", key)))?;
150 v.parse::<SuiAddress>()
151}
152
153fn parse_object_id(fields: &Value, key: &str) -> Result<ObjectId, TaiError> {
154 let v = fields
155 .get(key)
156 .and_then(|v| v.as_str())
157 .ok_or_else(|| TaiError::Decode(format!("missing/non-string field `{}`", key)))?;
158 v.parse::<ObjectId>()
159}
160
161fn parse_bool(fields: &Value, key: &str) -> Result<bool, TaiError> {
162 fields
163 .get(key)
164 .and_then(|v| v.as_bool())
165 .ok_or_else(|| TaiError::Decode(format!("missing/non-bool field `{}`", key)))
166}
167
168fn parse_u8(fields: &Value, key: &str) -> Result<u8, TaiError> {
169 let v = fields
170 .get(key)
171 .ok_or_else(|| TaiError::Decode(format!("missing field `{}`", key)))?;
172 if let Some(n) = v.as_u64() {
173 return u8::try_from(n).map_err(|_| TaiError::Decode(format!("u8 overflow on `{}`", key)));
174 }
175 if let Some(s) = v.as_str() {
176 return s
177 .parse::<u8>()
178 .map_err(|e| TaiError::Decode(format!("u8 parse `{}`: {}", key, e)));
179 }
180 Err(TaiError::Decode(format!(
181 "field `{}` is neither u8 nor decimal string",
182 key
183 )))
184}
185
186fn parse_string(fields: &Value, key: &str) -> Result<String, TaiError> {
187 fields
188 .get(key)
189 .and_then(|v| v.as_str())
190 .map(|s| s.to_string())
191 .ok_or_else(|| TaiError::Decode(format!("missing/non-string field `{}`", key)))
192}
193
194fn parse_balance(fields: &Value, key: &str) -> Result<u64, TaiError> {
197 let v = fields
198 .get(key)
199 .ok_or_else(|| TaiError::Decode(format!("missing field `{}`", key)))?;
200 if let Some(inner) = v.get("value") {
201 return parse_u64_value(inner, key);
202 }
203 if let Some(inner) = v.get("fields").and_then(|f| f.get("value")) {
204 return parse_u64_value(inner, key);
205 }
206 parse_u64_value(v, key)
207}
208
209fn parse_u64_value(v: &Value, key: &str) -> Result<u64, TaiError> {
210 if let Some(n) = v.as_u64() {
211 return Ok(n);
212 }
213 if let Some(s) = v.as_str() {
214 return s
215 .parse::<u64>()
216 .map_err(|e| TaiError::Decode(format!("u64 parse `{}`: {}", key, e)));
217 }
218 Err(TaiError::Decode(format!(
219 "field `{}` is neither u64 nor decimal string (got {:?})",
220 key, v
221 )))
222}
223
224fn parse_option_object_id(fields: &Value, key: &str) -> Result<Option<ObjectId>, TaiError> {
226 let v = fields
227 .get(key)
228 .ok_or_else(|| TaiError::Decode(format!("missing field `{}`", key)))?;
229 if v.is_null() {
230 return Ok(None);
231 }
232 let vec = v
233 .get("vec")
234 .and_then(|x| x.as_array())
235 .ok_or_else(|| TaiError::Decode(format!("`{}` is not an Option (no `vec` array)", key)))?;
236 match vec.len() {
237 0 => Ok(None),
238 1 => {
239 let s = vec[0]
240 .as_str()
241 .ok_or_else(|| TaiError::Decode(format!("`{}` inner is not a string", key)))?;
242 Ok(Some(s.parse::<ObjectId>()?))
243 }
244 n => Err(TaiError::Decode(format!(
245 "Option `{}` has {} entries; expected 0 or 1",
246 key, n
247 ))),
248 }
249}
250
251fn parse_vec_object_id(fields: &Value, key: &str) -> Result<Vec<ObjectId>, TaiError> {
252 let arr = fields
253 .get(key)
254 .and_then(|v| v.as_array())
255 .ok_or_else(|| TaiError::Decode(format!("missing/non-array field `{}`", key)))?;
256 arr.iter()
257 .enumerate()
258 .map(|(i, v)| {
259 v.as_str()
260 .ok_or_else(|| TaiError::Decode(format!("`{}`[{}] not a string", key, i)))
261 .and_then(|s| s.parse::<ObjectId>())
262 })
263 .collect()
264}
265
266#[derive(Clone, Debug)]
276pub struct LaunchpadAccountView {
277 pub object_id: ObjectId,
279 pub coin_type: String,
281
282 pub creator: SuiAddress,
284 pub linked_identity: Option<ObjectId>,
286 pub coin_type_name: String,
288 pub total_supply: u64,
290 pub decimals: u8,
292
293 pub real_sui: u64,
295 pub real_token: u64,
297 pub virtual_sui_reserves: u64,
299 pub virtual_token_reserves: u64,
301
302 pub lp_reserve: u64,
304
305 pub nav_sui: u64,
307 pub nav_token: u64,
309
310 pub access_threshold: u64,
312 pub accept_coin_payments: bool,
314 pub lifetime_service_revenue_sui: u64,
316 pub cred_revenue_target: u64,
318
319 pub treasury_cap_holder_id: ObjectId,
322 pub agent_treasury_id: ObjectId,
324 pub owner_cap_id: ObjectId,
326 pub dwallets_object_id: Option<ObjectId>,
328
329 pub total_buys: u64,
332 pub total_sells: u64,
334 pub total_service_payments_sui: u64,
336 pub total_service_payments_token: u64,
338 pub cumulative_volume_sui: u64,
340 pub cumulative_fees_sui: u64,
342 pub launched_at: u64,
344}
345
346impl LaunchpadAccountView {
347 pub async fn fetch(rpc: &RpcClient, object_id: ObjectId) -> Result<Self, TaiError> {
349 let params = json!([
350 object_id.to_string(),
351 { "showContent": true, "showType": true }
352 ]);
353 let raw: Value = rpc.call("sui_getObject", params).await?;
354 decode_launchpad_account(&raw, object_id)
355 }
356}
357
358fn decode_launchpad_account(
359 raw: &Value,
360 expected_id: ObjectId,
361) -> Result<LaunchpadAccountView, TaiError> {
362 let data = raw
363 .get("data")
364 .ok_or_else(|| TaiError::Decode("missing `data`".into()))?;
365
366 let full_type = data
369 .get("type")
370 .and_then(|v| v.as_str())
371 .or_else(|| {
372 data.get("content")
373 .and_then(|c| c.get("type"))
374 .and_then(|v| v.as_str())
375 })
376 .ok_or_else(|| TaiError::Decode("missing `type` on object".into()))?;
377 let coin_type = extract_generic_argument(full_type).unwrap_or_default();
378
379 let content = data
380 .get("content")
381 .ok_or_else(|| TaiError::Decode("missing `content`".into()))?;
382 let data_type = content
383 .get("dataType")
384 .and_then(|v| v.as_str())
385 .unwrap_or("");
386 if data_type != "moveObject" {
387 return Err(TaiError::Decode(format!(
388 "expected moveObject, got {}",
389 data_type
390 )));
391 }
392 let fields = content
393 .get("fields")
394 .ok_or_else(|| TaiError::Decode("missing `fields`".into()))?;
395
396 Ok(LaunchpadAccountView {
397 object_id: expected_id,
398 coin_type,
399
400 creator: parse_addr(fields, "creator")?,
401 linked_identity: parse_option_object_id(fields, "linked_identity")?,
402 coin_type_name: parse_string(fields, "coin_type_name")?,
403 total_supply: parse_u64(fields, "total_supply")?,
404 decimals: parse_u8(fields, "decimals")?,
405
406 real_sui: parse_balance(fields, "real_sui_balance")?,
407 real_token: parse_balance(fields, "real_token_balance")?,
408 virtual_sui_reserves: parse_u64(fields, "virtual_sui_reserves")?,
409 virtual_token_reserves: parse_u64(fields, "virtual_token_reserves")?,
410
411 lp_reserve: parse_balance(fields, "lp_reserve")?,
412
413 nav_sui: parse_balance(fields, "nav_sui")?,
414 nav_token: parse_balance(fields, "nav_token")?,
415
416 access_threshold: parse_u64(fields, "access_threshold")?,
417 accept_coin_payments: parse_bool(fields, "accept_coin_payments")?,
418 lifetime_service_revenue_sui: parse_u64(fields, "lifetime_service_revenue_sui")?,
419 cred_revenue_target: parse_u64(fields, "cred_revenue_target")?,
420
421 treasury_cap_holder_id: parse_object_id(fields, "treasury_cap_holder_id")?,
422 agent_treasury_id: parse_object_id(fields, "agent_treasury_id")?,
423 owner_cap_id: parse_object_id(fields, "owner_cap_id")?,
424 dwallets_object_id: parse_option_object_id(fields, "dwallets_object_id")?,
425
426 total_buys: parse_u64(fields, "total_buys")?,
427 total_sells: parse_u64(fields, "total_sells")?,
428 total_service_payments_sui: parse_u64(fields, "total_service_payments_sui")?,
429 total_service_payments_token: parse_u64(fields, "total_service_payments_token")?,
430 cumulative_volume_sui: parse_u64(fields, "cumulative_volume_sui")?,
431 cumulative_fees_sui: parse_u64(fields, "cumulative_fees_sui")?,
432 launched_at: parse_u64(fields, "launched_at")?,
433 })
434}
435
436fn extract_generic_argument(type_str: &str) -> Option<String> {
437 let lt = type_str.find('<')?;
438 let gt = type_str.rfind('>')?;
439 if gt <= lt {
440 return None;
441 }
442 Some(type_str[lt + 1..gt].to_string())
443}
444
445#[derive(Clone, Debug)]
451pub struct AgentTreasuryView {
452 pub object_id: ObjectId,
454 pub launchpad_account_id: ObjectId,
456 pub owner_cap_id: ObjectId,
458 pub active_operator_cap_ids: Vec<ObjectId>,
460 pub sui_balance: u64,
462 pub token_balance: u64,
464}
465
466impl AgentTreasuryView {
467 pub async fn fetch(rpc: &RpcClient, object_id: ObjectId) -> Result<Self, TaiError> {
469 let params = json!([
470 object_id.to_string(),
471 { "showContent": true, "showType": true }
472 ]);
473 let raw: Value = rpc.call("sui_getObject", params).await?;
474 decode_agent_treasury(&raw, object_id)
475 }
476}
477
478fn decode_agent_treasury(
479 raw: &Value,
480 expected_id: ObjectId,
481) -> Result<AgentTreasuryView, TaiError> {
482 let data = raw
483 .get("data")
484 .ok_or_else(|| TaiError::Decode("missing `data`".into()))?;
485 let content = data
486 .get("content")
487 .ok_or_else(|| TaiError::Decode("missing `content`".into()))?;
488 let data_type = content
489 .get("dataType")
490 .and_then(|v| v.as_str())
491 .unwrap_or("");
492 if data_type != "moveObject" {
493 return Err(TaiError::Decode(format!(
494 "expected moveObject, got {}",
495 data_type
496 )));
497 }
498 let fields = content
499 .get("fields")
500 .ok_or_else(|| TaiError::Decode("missing `fields`".into()))?;
501
502 Ok(AgentTreasuryView {
503 object_id: expected_id,
504 launchpad_account_id: parse_object_id(fields, "launchpad_account_id")?,
505 owner_cap_id: parse_object_id(fields, "owner_cap_id")?,
506 active_operator_cap_ids: parse_vec_object_id(fields, "active_operator_cap_ids")?,
507 sui_balance: parse_balance(fields, "sui_balance")?,
508 token_balance: parse_balance(fields, "token_balance")?,
509 })
510}
511
512#[derive(Clone, Copy, Debug, PartialEq, Eq)]
520pub struct HireQuote {
521 pub nav_sui: u64,
523 pub lifetime_service_revenue_sui: u64,
525 pub cred_revenue_target: u64,
527 pub multiplier_bps: u64,
529 pub hire_price_sui: u64,
531}
532
533pub fn hire_quote(account: &LaunchpadAccountView) -> HireQuote {
542 const BPS: u128 = 10_000;
543 const BPS_CAP: u128 = 10_000;
544
545 let nav = account.nav_sui as u128;
546 let earned = account.lifetime_service_revenue_sui as u128;
547 let target = account.cred_revenue_target.max(1) as u128;
548
549 let bonus = (earned * BPS) / target;
550 let capped_bonus = bonus.min(BPS_CAP);
551 let mult_bps = BPS + capped_bonus;
552 let hire_price = nav * mult_bps / BPS;
553
554 HireQuote {
555 nav_sui: account.nav_sui,
556 lifetime_service_revenue_sui: account.lifetime_service_revenue_sui,
557 cred_revenue_target: account.cred_revenue_target,
558 multiplier_bps: mult_bps as u64,
559 hire_price_sui: hire_price as u64,
560 }
561}
562
563#[cfg(test)]
564mod tests {
565 use super::*;
566
567 #[test]
568 fn decode_launchpad_config_from_fixture() {
569 let fixture = json!({
572 "data": {
573 "content": {
574 "dataType": "moveObject",
575 "fields": {
576 "admin": "0x2ce41c43a6ee1192adc2fe6cc620eef80ca4f57940a5c6cc2d51664514616c14",
577 "platform_treasury": "0x2ce41c43a6ee1192adc2fe6cc620eef80ca4f57940a5c6cc2d51664514616c14",
578 "trade_fee_bps": "100",
579 "trade_nav_share_bps": "3000",
580 "trade_creator_share_bps": "6000",
581 "trade_platform_share_bps": "1000",
582 "service_nav_share_bps": "4000",
583 "service_creator_share_bps": "5000",
584 "service_platform_share_bps": "1000",
585 "token_service_nav_share_bps": "4000",
586 "token_service_burn_share_bps": "5000",
587 "token_service_creator_share_bps": "1000",
588 "virtual_sui_reserves": "10000000000000",
589 "virtual_token_reserves": "1073000000000000000",
590 "sale_supply": "800000000000000000",
591 "lp_supply": "200000000000000000",
592 "cred_revenue_target": "1000000000000"
593 }
594 }
595 }
596 });
597 let id: ObjectId = "0x7aab8b56eceb6d12239ea54d52655c0a35b33bc59bc7c7b2111bbeba0ee6c680"
598 .parse()
599 .unwrap();
600 let cfg = decode_launchpad_config(&fixture, id).unwrap();
601
602 assert_eq!(cfg.trade_fee_bps, 100);
603 assert_eq!(cfg.trade_nav_share_bps, 3000);
604 assert_eq!(cfg.trade_creator_share_bps, 6000);
605 assert_eq!(cfg.trade_platform_share_bps, 1000);
606 assert_eq!(cfg.service_nav_share_bps, 4000);
607 assert_eq!(cfg.service_creator_share_bps, 5000);
608 assert_eq!(cfg.service_platform_share_bps, 1000);
609 assert_eq!(cfg.token_service_nav_share_bps, 4000);
610 assert_eq!(cfg.token_service_burn_share_bps, 5000);
611 assert_eq!(cfg.token_service_creator_share_bps, 1000);
612 assert_eq!(cfg.virtual_sui_reserves, 10_000_000_000_000);
613 assert_eq!(cfg.virtual_token_reserves, 1_073_000_000_000_000_000);
614 assert_eq!(cfg.sale_supply, 800_000_000_000_000_000);
615 assert_eq!(cfg.lp_supply, 200_000_000_000_000_000);
616 assert_eq!(cfg.cred_revenue_target, 1_000_000_000_000);
617 assert_eq!(
618 cfg.admin.to_string(),
619 "0x2ce41c43a6ee1192adc2fe6cc620eef80ca4f57940a5c6cc2d51664514616c14"
620 );
621 }
622
623 #[test]
624 fn rejects_non_moveobject() {
625 let fixture = json!({
626 "data": { "content": { "dataType": "package" } }
627 });
628 let id = ObjectId::from_bytes([0u8; 32]);
629 assert!(decode_launchpad_config(&fixture, id).is_err());
630 }
631
632 #[test]
633 fn missing_field_is_diagnostic_error() {
634 let fixture = json!({
635 "data": { "content": { "dataType": "moveObject", "fields": {} } }
636 });
637 let id = ObjectId::from_bytes([0u8; 32]);
638 let err = decode_launchpad_config(&fixture, id).unwrap_err();
639 let msg = format!("{}", err);
640 assert!(msg.contains("admin"), "got: {}", msg);
641 }
642
643 fn launchpad_account_fixture() -> Value {
648 json!({
649 "data": {
650 "type": "0x7d41072ae77b18b752292b47468e07e6332cd9a6ef9b052752f98f22d9844f8d::launchpad::LaunchpadAccount<0xabc0000000000000000000000000000000000000000000000000000000000abc::larry::LARRY>",
651 "content": {
652 "dataType": "moveObject",
653 "fields": {
654 "creator": "0x2ce41c43a6ee1192adc2fe6cc620eef80ca4f57940a5c6cc2d51664514616c14",
655 "linked_identity": { "vec": [] },
656 "coin_type_name": "0xabc::larry::LARRY",
657 "total_supply": "1000000000000000000",
658 "decimals": 9,
659
660 "real_sui_balance": { "value": "990000000" },
661 "real_token_balance": { "value": "799814591355455809" },
662 "virtual_sui_reserves": "10000000000000",
663 "virtual_token_reserves": "1073000000000000000",
664
665 "lp_reserve": { "value": "200000000000000000" },
666
667 "nav_sui": { "value": "3000000" },
668 "nav_token": { "value": "0" },
669
670 "access_threshold": "0",
671 "accept_coin_payments": false,
672 "lifetime_service_revenue_sui": "0",
673 "cred_revenue_target": "1000000000000",
674
675 "treasury_cap_holder_id": "0x1111111111111111111111111111111111111111111111111111111111111111",
676 "agent_treasury_id": "0x2222222222222222222222222222222222222222222222222222222222222222",
677 "owner_cap_id": "0x3333333333333333333333333333333333333333333333333333333333333333",
678 "dwallets_object_id": { "vec": [] },
679
680 "total_buys": "1",
681 "total_sells": "0",
682 "total_service_payments_sui": "0",
683 "total_service_payments_token": "0",
684 "cumulative_volume_sui": "1000000000",
685 "cumulative_fees_sui": "10000000",
686 "launched_at": "1779568299473"
687 }
688 }
689 }
690 })
691 }
692
693 #[test]
694 fn decode_launchpad_account_full_shape() {
695 let id: ObjectId = "0xc4a8".parse().unwrap();
696 let acc = decode_launchpad_account(&launchpad_account_fixture(), id).unwrap();
697
698 assert_eq!(acc.object_id, id);
699 assert!(acc.coin_type.contains("::larry::LARRY"));
700 assert_eq!(acc.coin_type_name, "0xabc::larry::LARRY");
701 assert_eq!(acc.total_supply, 1_000_000_000_000_000_000);
702 assert_eq!(acc.decimals, 9);
703
704 assert_eq!(acc.real_sui, 990_000_000);
706 assert_eq!(acc.real_token, 799_814_591_355_455_809);
707 assert_eq!(acc.lp_reserve, 200_000_000_000_000_000);
708 assert_eq!(acc.nav_sui, 3_000_000);
709 assert_eq!(acc.nav_token, 0);
710
711 assert_eq!(acc.access_threshold, 0);
712 assert!(!acc.accept_coin_payments);
713 assert_eq!(acc.lifetime_service_revenue_sui, 0);
714 assert_eq!(acc.cred_revenue_target, 1_000_000_000_000);
715
716 assert_eq!(acc.linked_identity, None);
718 assert_eq!(acc.dwallets_object_id, None);
719
720 assert_eq!(acc.total_buys, 1);
721 assert_eq!(acc.cumulative_volume_sui, 1_000_000_000);
722 }
723
724 #[test]
725 fn decode_launchpad_account_with_linked_identity_some() {
726 let mut fixture = launchpad_account_fixture();
727 fixture["data"]["content"]["fields"]["linked_identity"] = json!({
728 "vec": ["0xfeed"]
729 });
730 let id: ObjectId = "0xc4a8".parse().unwrap();
731 let acc = decode_launchpad_account(&fixture, id).unwrap();
732 assert!(acc.linked_identity.is_some());
733 }
734
735 #[test]
740 fn decode_agent_treasury_view() {
741 let fixture = json!({
742 "data": {
743 "content": {
744 "dataType": "moveObject",
745 "fields": {
746 "launchpad_account_id": "0xaaaa",
747 "owner_cap_id": "0xbbbb",
748 "active_operator_cap_ids": [
749 "0xccc1",
750 "0xccc2"
751 ],
752 "sui_balance": { "value": "5000000000" },
753 "token_balance": { "value": "1500000" }
754 }
755 }
756 }
757 });
758 let id: ObjectId = "0x7777".parse().unwrap();
759 let t = decode_agent_treasury(&fixture, id).unwrap();
760 assert_eq!(t.object_id, id);
761 assert_eq!(t.sui_balance, 5_000_000_000);
762 assert_eq!(t.token_balance, 1_500_000);
763 assert_eq!(t.active_operator_cap_ids.len(), 2);
764 }
765
766 fn account_with(nav: u64, earned: u64, target: u64) -> LaunchpadAccountView {
771 LaunchpadAccountView {
772 object_id: ObjectId::from_bytes([0u8; 32]),
773 coin_type: "x".into(),
774 creator: SuiAddress::ZERO,
775 linked_identity: None,
776 coin_type_name: "x".into(),
777 total_supply: 0,
778 decimals: 9,
779 real_sui: 0,
780 real_token: 0,
781 virtual_sui_reserves: 0,
782 virtual_token_reserves: 0,
783 lp_reserve: 0,
784 nav_sui: nav,
785 nav_token: 0,
786 access_threshold: 0,
787 accept_coin_payments: false,
788 lifetime_service_revenue_sui: earned,
789 cred_revenue_target: target,
790 treasury_cap_holder_id: ObjectId::from_bytes([0u8; 32]),
791 agent_treasury_id: ObjectId::from_bytes([0u8; 32]),
792 owner_cap_id: ObjectId::from_bytes([0u8; 32]),
793 dwallets_object_id: None,
794 total_buys: 0,
795 total_sells: 0,
796 total_service_payments_sui: 0,
797 total_service_payments_token: 0,
798 cumulative_volume_sui: 0,
799 cumulative_fees_sui: 0,
800 launched_at: 0,
801 }
802 }
803
804 #[test]
805 fn hire_quote_zero_revenue_is_one_x() {
806 let q = hire_quote(&account_with(1_000_000, 0, 1_000_000_000_000));
807 assert_eq!(q.multiplier_bps, 10_000);
808 assert_eq!(q.hire_price_sui, 1_000_000);
809 }
810
811 #[test]
812 fn hire_quote_at_target_doubles_nav() {
813 let q = hire_quote(&account_with(
814 1_000_000,
815 1_000_000_000_000,
816 1_000_000_000_000,
817 ));
818 assert_eq!(q.multiplier_bps, 20_000);
819 assert_eq!(q.hire_price_sui, 2_000_000);
820 }
821
822 #[test]
823 fn hire_quote_above_target_saturates_at_two_x() {
824 let q = hire_quote(&account_with(
825 1_000_000,
826 5_000_000_000_000,
827 1_000_000_000_000,
828 ));
829 assert_eq!(q.multiplier_bps, 20_000);
830 assert_eq!(q.hire_price_sui, 2_000_000);
831 }
832
833 #[test]
834 fn hire_quote_partial_revenue_is_linear() {
835 let q = hire_quote(&account_with(1_000_000, 250_000_000_000, 1_000_000_000_000));
837 assert_eq!(q.multiplier_bps, 12_500);
838 assert_eq!(q.hire_price_sui, 1_250_000);
839 }
840}