Skip to main content

tai_core/
reads.rs

1//! Read-side views over the on-chain Tai objects.
2//!
3//! Every Move struct field is mirrored as a typed Rust field. Sui returns
4//! numerics as decimal strings inside `Move struct JSON`; we parse them to
5//! `u64`/`u128` here so callers get strongly-typed values.
6
7use crate::error::TaiError;
8use crate::ids::{ObjectId, SuiAddress};
9use crate::rpc::RpcClient;
10use serde_json::{json, Value};
11
12/// All 17 mutable fields of the on-chain `LaunchpadConfig` plus its address.
13#[derive(Clone, Debug)]
14pub struct LaunchpadConfigView {
15    /// On-chain object id.
16    pub object_id: ObjectId,
17    /// Current admin.
18    pub admin: SuiAddress,
19    /// Platform treasury — receives the platform share of every trade fee.
20    pub platform_treasury: SuiAddress,
21
22    // Trade fee
23    /// Trade fee in basis points (default 100 = 1%).
24    pub trade_fee_bps: u64,
25    /// Trade fee NAV share (default 3000).
26    pub trade_nav_share_bps: u64,
27    /// Trade fee creator share (default 6000).
28    pub trade_creator_share_bps: u64,
29    /// Trade fee platform share (default 1000).
30    pub trade_platform_share_bps: u64,
31
32    // Service-SUI fee
33    /// Service-SUI fee NAV share (default 4000).
34    pub service_nav_share_bps: u64,
35    /// Service-SUI fee creator share (default 5000).
36    pub service_creator_share_bps: u64,
37    /// Service-SUI fee platform share (default 1000).
38    pub service_platform_share_bps: u64,
39
40    // Service-token fee
41    /// Service-token fee NAV-in-T share (default 4000).
42    pub token_service_nav_share_bps: u64,
43    /// Service-token fee burn share (default 5000).
44    pub token_service_burn_share_bps: u64,
45    /// Service-token fee creator share (default 1000).
46    pub token_service_creator_share_bps: u64,
47
48    // Curve
49    /// Virtual SUI reserves (default 10_000 SUI in MIST).
50    pub virtual_sui_reserves: u64,
51    /// Virtual token reserves (default 1.073B with 9 decimals).
52    pub virtual_token_reserves: u64,
53    /// Sale supply minted into the curve at launch.
54    pub sale_supply: u64,
55    /// LP reserve supply minted at launch and locked.
56    pub lp_supply: u64,
57
58    /// Lifetime service revenue threshold at which cred multiplier saturates at 2.0x.
59    pub cred_revenue_target: u64,
60}
61
62impl LaunchpadConfigView {
63    /// Fetch the `LaunchpadConfig` at `object_id` from the given RPC.
64    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
194/// Sui renders `Balance<T>` as `{ "value": "..." }`. Some RPC versions
195/// flatten it to a direct decimal string — handle both.
196fn 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
224/// Sui renders `Option<T>` as `{ "vec": [] }` (None) or `{ "vec": [v] }` (Some).
225fn 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// ============================================================================
267//  LaunchpadAccountView
268// ============================================================================
269
270/// Strongly-typed mirror of `tai::launchpad::LaunchpadAccount<T>`.
271///
272/// All balance fields are returned as plain `u64`. The generic coin type `T`
273/// is identified by [`coin_type`](LaunchpadAccountView::coin_type) (the Move
274/// type string).
275#[derive(Clone, Debug)]
276pub struct LaunchpadAccountView {
277    /// On-chain object ID of the LaunchpadAccount.
278    pub object_id: ObjectId,
279    /// Concrete type parameter for `T`, e.g. `0xabc::larry::LARRY`.
280    pub coin_type: String,
281
282    /// Fee-receiving wallet snapshotted at launch.
283    pub creator: SuiAddress,
284    /// Optional pointer to an external identity object (e.g. SAI AgentIdentity).
285    pub linked_identity: Option<ObjectId>,
286    /// Display name carried on the launch event.
287    pub coin_type_name: String,
288    /// Total supply minted at launch (sale + lp).
289    pub total_supply: u64,
290    /// Coin decimals — 9 in v1.
291    pub decimals: u8,
292
293    /// Real SUI in the bonding-curve pool (excludes virtual reserves + NAV).
294    pub real_sui: u64,
295    /// Real T tokens left in the bonding curve.
296    pub real_token: u64,
297    /// Virtual SUI reserves, snapshotted at launch (immutable).
298    pub virtual_sui_reserves: u64,
299    /// Virtual token reserves, snapshotted at launch (immutable).
300    pub virtual_token_reserves: u64,
301
302    /// LP reserve — locked permanently in v1.
303    pub lp_reserve: u64,
304
305    /// NAV in SUI. Non-withdrawable.
306    pub nav_sui: u64,
307    /// NAV in T (from token-denominated service payments).
308    pub nav_token: u64,
309
310    /// Token-holding threshold for token-gated services (0 = open).
311    pub access_threshold: u64,
312    /// Whether the agent opts in to coin-denominated hire payments.
313    pub accept_coin_payments: bool,
314    /// Cumulative SUI service revenue (drives the cred multiplier).
315    pub lifetime_service_revenue_sui: u64,
316    /// Saturation target snapshotted at launch.
317    pub cred_revenue_target: u64,
318
319    // Sibling-object linkage
320    /// Linked TreasuryCapHolder<T> object.
321    pub treasury_cap_holder_id: ObjectId,
322    /// Linked AgentTreasury<T> object.
323    pub agent_treasury_id: ObjectId,
324    /// OwnerCap<T> minted at launch.
325    pub owner_cap_id: ObjectId,
326    /// Reserved for v1.1 Ika adapter. None on v1 launches.
327    pub dwallets_object_id: Option<ObjectId>,
328
329    // Counters
330    /// Total successful buys executed on this agent.
331    pub total_buys: u64,
332    /// Total successful sells.
333    pub total_sells: u64,
334    /// Total SUI-denominated service payments recorded.
335    pub total_service_payments_sui: u64,
336    /// Total token-denominated service payments recorded.
337    pub total_service_payments_token: u64,
338    /// Sum of `sui_in` (buy) and `sui_gross` (sell) across all trades.
339    pub cumulative_volume_sui: u64,
340    /// Sum of trade fees collected across all trades.
341    pub cumulative_fees_sui: u64,
342    /// Clock timestamp_ms at launch.
343    pub launched_at: u64,
344}
345
346impl LaunchpadAccountView {
347    /// Fetch a LaunchpadAccount from the given RPC by object ID.
348    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    // Sui's `type` looks like
367    //   `0x<pkg>::launchpad::LaunchpadAccount<0x<coin_pkg>::larry::LARRY>`
368    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// ============================================================================
446//  AgentTreasuryView
447// ============================================================================
448
449/// Strongly-typed mirror of `tai::agent_treasury::AgentTreasury<T>`.
450#[derive(Clone, Debug)]
451pub struct AgentTreasuryView {
452    /// On-chain object id.
453    pub object_id: ObjectId,
454    /// Linked LaunchpadAccount<T>.
455    pub launchpad_account_id: ObjectId,
456    /// OwnerCap<T> that gates this treasury.
457    pub owner_cap_id: ObjectId,
458    /// Currently-active OperatorCap ids (revoked caps are removed).
459    pub active_operator_cap_ids: Vec<ObjectId>,
460    /// SUI working capital.
461    pub sui_balance: u64,
462    /// Token working capital (in T).
463    pub token_balance: u64,
464}
465
466impl AgentTreasuryView {
467    /// Fetch an AgentTreasury from the given RPC by object ID.
468    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// ============================================================================
513//  hire_quote — client-side computation matching views::hire_quote
514// ============================================================================
515
516/// Output of [`hire_quote`]. Matches `views::hire_quote<T>` on-chain so the
517/// CLI/SDK produces identical values whether reading from chain or
518/// computing locally from a [`LaunchpadAccountView`].
519#[derive(Clone, Copy, Debug, PartialEq, Eq)]
520pub struct HireQuote {
521    /// Accumulated SUI NAV.
522    pub nav_sui: u64,
523    /// Cumulative SUI service revenue (excludes self-payments).
524    pub lifetime_service_revenue_sui: u64,
525    /// Cred saturation target snapshotted at launch.
526    pub cred_revenue_target: u64,
527    /// Cred multiplier in basis points. 10_000 = 1.0x, 20_000 = 2.0x (cap).
528    pub multiplier_bps: u64,
529    /// Recommended hire price in MIST.
530    pub hire_price_sui: u64,
531}
532
533/// Compute the cred-adjusted hire price from an account view.
534///
535/// Matches Move-side `views::hire_quote` byte-for-byte:
536/// ```text
537/// bonus       = min(10_000, earned * 10_000 / target)
538/// mult_bps    = 10_000 + bonus
539/// hire_price  = nav * mult_bps / 10_000
540/// ```
541pub 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        // Captured from `sui client object 0x7aab…c680 --json` after the
570        // 2026-05-23 publish. Numeric fields are returned as decimal strings.
571        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    // ==========================================================
644    //  LaunchpadAccountView
645    // ==========================================================
646
647    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        // Balance fields parsed correctly.
705        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        // Optional<ID> rendered as { vec: [] } = None.
717        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    // ==========================================================
736    //  AgentTreasuryView
737    // ==========================================================
738
739    #[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    // ==========================================================
767    //  hire_quote — matches Move-side views::hire_quote
768    // ==========================================================
769
770    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        // 25% of target -> bonus 2500 -> mult 12500.
836        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}