sui_rosetta/
operations.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::collections::HashMap;
5use std::ops::Not;
6use std::str::FromStr;
7use std::vec;
8
9use anyhow::anyhow;
10use move_core_types::ident_str;
11use move_core_types::language_storage::StructTag;
12use prost_types::value::Kind;
13use serde::Deserialize;
14use serde::Serialize;
15use tracing::warn;
16
17use sui_rpc::proto::sui::rpc::v2::Argument;
18use sui_rpc::proto::sui::rpc::v2::BalanceChange;
19use sui_rpc::proto::sui::rpc::v2::ExecutedTransaction;
20use sui_rpc::proto::sui::rpc::v2::Input;
21use sui_rpc::proto::sui::rpc::v2::MoveCall;
22use sui_rpc::proto::sui::rpc::v2::ProgrammableTransaction;
23use sui_rpc::proto::sui::rpc::v2::TransactionKind;
24use sui_rpc::proto::sui::rpc::v2::argument::ArgumentKind;
25use sui_rpc::proto::sui::rpc::v2::command::Command;
26use sui_rpc::proto::sui::rpc::v2::input::InputKind;
27use sui_rpc::proto::sui::rpc::v2::transaction_kind::Data as TransactionKindData;
28use sui_rpc::proto::sui::rpc::v2::transaction_kind::Kind::ProgrammableTransaction as ProgrammableTransactionKind;
29use sui_types::base_types::{ObjectID, SequenceNumber, SuiAddress};
30use sui_types::gas_coin::GasCoin;
31use sui_types::governance::{ADD_STAKE_FUN_NAME, WITHDRAW_STAKE_FUN_NAME};
32use sui_types::sui_system_state::SUI_SYSTEM_MODULE_NAME;
33use sui_types::{
34    SUI_FRAMEWORK_PACKAGE_ID, SUI_SYSTEM_ADDRESS, SUI_SYSTEM_PACKAGE_ID, SUI_SYSTEM_STATE_OBJECT_ID,
35};
36
37use crate::types::internal_operation::{
38    ConsolidateAllStakedSuiToFungible, MergeAndRedeemFungibleStakedSui, PayCoin, PaySui, Stake,
39    WithdrawStake,
40};
41use crate::types::{
42    AccountIdentifier, Amount, CoinAction, CoinChange, CoinID, CoinIdentifier, Currency,
43    InternalOperation, OperationIdentifier, OperationStatus, OperationType, RedeemMode,
44};
45use crate::{CoinMetadataCache, Error, SUI};
46
47#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
48pub struct Operations(Vec<Operation>);
49
50impl FromIterator<Operation> for Operations {
51    fn from_iter<T: IntoIterator<Item = Operation>>(iter: T) -> Self {
52        Operations::new(iter.into_iter().collect())
53    }
54}
55
56impl FromIterator<Vec<Operation>> for Operations {
57    fn from_iter<T: IntoIterator<Item = Vec<Operation>>>(iter: T) -> Self {
58        iter.into_iter().flatten().collect()
59    }
60}
61
62impl IntoIterator for Operations {
63    type Item = Operation;
64    type IntoIter = vec::IntoIter<Operation>;
65    fn into_iter(self) -> Self::IntoIter {
66        self.0.into_iter()
67    }
68}
69
70impl Operations {
71    pub fn new(mut ops: Vec<Operation>) -> Self {
72        for (index, op) in ops.iter_mut().enumerate() {
73            op.operation_identifier = (index as u64).into()
74        }
75        Self(ops)
76    }
77
78    pub fn contains(&self, other: &Operations) -> bool {
79        for (i, other_op) in other.0.iter().enumerate() {
80            if let Some(op) = self.0.get(i) {
81                if op != other_op {
82                    return false;
83                }
84            } else {
85                return false;
86            }
87        }
88        true
89    }
90
91    pub fn set_status(mut self, status: Option<OperationStatus>) -> Self {
92        for op in &mut self.0 {
93            op.status = status
94        }
95        self
96    }
97
98    pub fn type_(&self) -> Option<OperationType> {
99        self.0.first().map(|op| op.type_)
100    }
101
102    /// Parse operation input from rosetta operation to intermediate internal operation;
103    pub fn into_internal(self) -> Result<InternalOperation, Error> {
104        let type_ = self
105            .type_()
106            .ok_or_else(|| Error::MissingInput("Operation type".into()))?;
107        match type_ {
108            OperationType::PaySui => self.pay_sui_ops_to_internal(),
109            OperationType::PayCoin => self.pay_coin_ops_to_internal(),
110            OperationType::Stake => self.stake_ops_to_internal(),
111            OperationType::WithdrawStake => self.withdraw_stake_ops_to_internal(),
112            OperationType::ConsolidateAllStakedSuiToFungible => {
113                self.consolidate_to_fungible_ops_to_internal()
114            }
115            OperationType::MergeAndRedeemFungibleStakedSui => {
116                self.merge_and_redeem_fss_ops_to_internal()
117            }
118            op => Err(Error::UnsupportedOperation(op)),
119        }
120    }
121
122    fn pay_sui_ops_to_internal(self) -> Result<InternalOperation, Error> {
123        let mut recipients = vec![];
124        let mut amounts = vec![];
125        let mut sender = None;
126        for op in self {
127            if let (Some(amount), Some(account)) = (op.amount.clone(), op.account.clone()) {
128                if amount.value.is_negative() {
129                    sender = Some(account.address)
130                } else {
131                    recipients.push(account.address);
132                    let amount = amount.value.abs();
133                    if amount > u64::MAX as i128 {
134                        return Err(Error::InvalidInput(
135                            "Input amount exceed u64::MAX".to_string(),
136                        ));
137                    }
138                    amounts.push(amount as u64)
139                }
140            }
141        }
142        let sender = sender.ok_or_else(|| Error::MissingInput("Sender address".to_string()))?;
143        Ok(InternalOperation::PaySui(PaySui {
144            sender,
145            recipients,
146            amounts,
147        }))
148    }
149
150    fn pay_coin_ops_to_internal(self) -> Result<InternalOperation, Error> {
151        let mut recipients = vec![];
152        let mut amounts = vec![];
153        let mut sender = None;
154        let mut currency = None;
155        for op in self {
156            if let (Some(amount), Some(account)) = (op.amount.clone(), op.account.clone()) {
157                currency = currency.or(Some(amount.currency));
158                if amount.value.is_negative() {
159                    sender = Some(account.address)
160                } else {
161                    recipients.push(account.address);
162                    let amount = amount.value.abs();
163                    if amount > u64::MAX as i128 {
164                        return Err(Error::InvalidInput(
165                            "Input amount exceed u64::MAX".to_string(),
166                        ));
167                    }
168                    amounts.push(amount as u64)
169                }
170            }
171        }
172        let sender = sender.ok_or_else(|| Error::MissingInput("Sender address".to_string()))?;
173        let currency = currency.ok_or_else(|| Error::MissingInput("Currency".to_string()))?;
174        Ok(InternalOperation::PayCoin(PayCoin {
175            sender,
176            recipients,
177            amounts,
178            currency,
179        }))
180    }
181
182    fn stake_ops_to_internal(self) -> Result<InternalOperation, Error> {
183        let mut ops = self
184            .0
185            .into_iter()
186            .filter(|op| op.type_ == OperationType::Stake)
187            .collect::<Vec<_>>();
188        if ops.len() != 1 {
189            return Err(Error::MalformedOperationError(
190                "Delegation should only have one operation.".into(),
191            ));
192        }
193        // Checked above, safe to unwrap.
194        let op = ops.pop().unwrap();
195        let sender = op
196            .account
197            .ok_or_else(|| Error::MissingInput("Sender address".to_string()))?
198            .address;
199        let metadata = op
200            .metadata
201            .ok_or_else(|| Error::MissingInput("Stake metadata".to_string()))?;
202
203        // Total issued SUi is less than u64, safe to cast.
204        let amount = if let Some(amount) = op.amount {
205            if amount.value.is_positive() {
206                return Err(Error::MalformedOperationError(
207                    "Stake amount should be negative.".into(),
208                ));
209            }
210            Some(amount.value.unsigned_abs() as u64)
211        } else {
212            None
213        };
214
215        let OperationMetadata::Stake { validator } = metadata else {
216            return Err(Error::InvalidInput(
217                "Cannot find delegation info from metadata.".into(),
218            ));
219        };
220
221        Ok(InternalOperation::Stake(Stake {
222            sender,
223            validator,
224            amount,
225        }))
226    }
227
228    fn withdraw_stake_ops_to_internal(self) -> Result<InternalOperation, Error> {
229        let mut ops = self
230            .0
231            .into_iter()
232            .filter(|op| op.type_ == OperationType::WithdrawStake)
233            .collect::<Vec<_>>();
234        if ops.len() != 1 {
235            return Err(Error::MalformedOperationError(
236                "Delegation should only have one operation.".into(),
237            ));
238        }
239        // Checked above, safe to unwrap.
240        let op = ops.pop().unwrap();
241        let sender = op
242            .account
243            .ok_or_else(|| Error::MissingInput("Sender address".to_string()))?
244            .address;
245
246        let stake_ids = if let Some(metadata) = op.metadata {
247            let OperationMetadata::WithdrawStake { stake_ids } = metadata else {
248                return Err(Error::InvalidInput(
249                    "Cannot find withdraw stake info from metadata.".into(),
250                ));
251            };
252            stake_ids
253        } else {
254            vec![]
255        };
256
257        Ok(InternalOperation::WithdrawStake(WithdrawStake {
258            sender,
259            stake_ids,
260        }))
261    }
262
263    fn consolidate_to_fungible_ops_to_internal(self) -> Result<InternalOperation, Error> {
264        let mut ops = self
265            .0
266            .into_iter()
267            .filter(|op| op.type_ == OperationType::ConsolidateAllStakedSuiToFungible)
268            .collect::<Vec<_>>();
269        if ops.len() != 1 {
270            return Err(Error::MalformedOperationError(
271                "ConsolidateAllStakedSuiToFungible should only have one operation.".into(),
272            ));
273        }
274        let op = ops.pop().unwrap();
275        let sender = op
276            .account
277            .ok_or_else(|| Error::MissingInput("Sender address".to_string()))?
278            .address;
279        let metadata = op.metadata.ok_or_else(|| {
280            Error::MissingInput("ConsolidateAllStakedSuiToFungible metadata".to_string())
281        })?;
282        let OperationMetadata::ConsolidateAllStakedSuiToFungible { validator, .. } = metadata
283        else {
284            return Err(Error::InvalidInput(
285                "Cannot find validator from ConsolidateAllStakedSuiToFungible metadata.".into(),
286            ));
287        };
288        let validator = validator.ok_or_else(|| {
289            Error::MissingInput("validator required for ConsolidateAllStakedSuiToFungible".into())
290        })?;
291        Ok(InternalOperation::ConsolidateAllStakedSuiToFungible(
292            ConsolidateAllStakedSuiToFungible { sender, validator },
293        ))
294    }
295
296    fn merge_and_redeem_fss_ops_to_internal(self) -> Result<InternalOperation, Error> {
297        let mut ops = self
298            .0
299            .into_iter()
300            .filter(|op| op.type_ == OperationType::MergeAndRedeemFungibleStakedSui)
301            .collect::<Vec<_>>();
302        if ops.len() != 1 {
303            return Err(Error::MalformedOperationError(
304                "MergeAndRedeemFungibleStakedSui should only have one operation.".into(),
305            ));
306        }
307        let op = ops.pop().unwrap();
308        let sender = op
309            .account
310            .ok_or_else(|| Error::MissingInput("Sender address".to_string()))?
311            .address;
312        let metadata = op.metadata.ok_or_else(|| {
313            Error::MissingInput("MergeAndRedeemFungibleStakedSui metadata".to_string())
314        })?;
315        let OperationMetadata::MergeAndRedeemFungibleStakedSui {
316            validator,
317            amount,
318            redeem_mode,
319            ..
320        } = metadata
321        else {
322            return Err(Error::InvalidInput(
323                "Cannot find MergeAndRedeemFungibleStakedSui info from metadata.".into(),
324            ));
325        };
326        let validator = validator.ok_or_else(|| {
327            Error::MissingInput("validator required for MergeAndRedeemFungibleStakedSui".into())
328        })?;
329        let redeem_mode = redeem_mode.ok_or_else(|| {
330            Error::MissingInput("redeem_mode required for MergeAndRedeemFungibleStakedSui".into())
331        })?;
332        let amount = match &redeem_mode {
333            RedeemMode::All => None,
334            _ => {
335                let amount_str = amount.ok_or_else(|| {
336                    Error::MissingInput("amount required for AtLeast/AtMost mode".to_string())
337                })?;
338                let parsed = amount_str
339                    .parse::<u64>()
340                    .map_err(|e| Error::InvalidInput(format!("Invalid amount: {}", e)))?;
341                if parsed == 0 {
342                    return Err(Error::InvalidInput(
343                        "amount must be at least 1 MIST".to_string(),
344                    ));
345                }
346                Some(parsed)
347            }
348        };
349        Ok(InternalOperation::MergeAndRedeemFungibleStakedSui(
350            MergeAndRedeemFungibleStakedSui {
351                sender,
352                validator,
353                amount,
354                redeem_mode,
355            },
356        ))
357    }
358
359    pub fn from_transaction(
360        tx: TransactionKind,
361        sender: SuiAddress,
362        status: Option<OperationStatus>,
363    ) -> Result<Vec<Operation>, Error> {
364        let TransactionKind { data, kind, .. } = tx;
365        Ok(match data {
366            Some(TransactionKindData::ProgrammableTransaction(pt))
367                if status != Some(OperationStatus::Failure) =>
368            {
369                Self::parse_programmable_transaction(sender, status, pt)?
370            }
371            data => {
372                let mut tx = TransactionKind::default();
373                tx.data = data;
374                tx.kind = kind;
375                vec![Operation::generic_op(status, sender, tx)]
376            }
377        })
378    }
379
380    fn parse_programmable_transaction(
381        sender: SuiAddress,
382        status: Option<OperationStatus>,
383        pt: ProgrammableTransaction,
384    ) -> Result<Vec<Operation>, Error> {
385        #[derive(Debug)]
386        enum KnownValue {
387            GasCoin(u64),
388        }
389        fn resolve_result(
390            known_results: &[Vec<KnownValue>],
391            i: u32,
392            j: u32,
393        ) -> Option<&KnownValue> {
394            known_results
395                .get(i as usize)
396                .and_then(|inner| inner.get(j as usize))
397        }
398        fn split_coins(
399            inputs: &[Input],
400            known_results: &[Vec<KnownValue>],
401            coin: &Argument,
402            amounts: &[Argument],
403        ) -> Option<Vec<KnownValue>> {
404            match coin.kind() {
405                ArgumentKind::Gas => (),
406                ArgumentKind::Result => {
407                    let i = coin.result?;
408                    let subresult_idx = coin.subresult.unwrap_or(0);
409                    let KnownValue::GasCoin(_) = resolve_result(known_results, i, subresult_idx)?;
410                }
411                // Might not be a SUI coin
412                ArgumentKind::Input => (),
413                _ => return None,
414            };
415
416            let amounts = amounts
417                .iter()
418                .map(|amount| {
419                    let value: u64 = match amount.kind() {
420                        ArgumentKind::Input => {
421                            let input_idx = amount.input() as usize;
422                            let input = inputs.get(input_idx)?;
423                            match input.kind() {
424                                InputKind::Pure => {
425                                    let bytes = input.pure();
426                                    bcs::from_bytes(bytes).ok()?
427                                }
428                                _ => return None,
429                            }
430                        }
431                        _ => return None,
432                    };
433                    Some(KnownValue::GasCoin(value))
434                })
435                .collect::<Option<_>>()?;
436            Some(amounts)
437        }
438        fn transfer_object(
439            aggregated_recipients: &mut HashMap<SuiAddress, u64>,
440            inputs: &[Input],
441            known_results: &[Vec<KnownValue>],
442            objs: &[Argument],
443            recipient: &Argument,
444        ) -> Option<Vec<KnownValue>> {
445            let addr = match recipient.kind() {
446                ArgumentKind::Input => {
447                    let input_idx = recipient.input() as usize;
448                    let input = inputs.get(input_idx)?;
449                    match input.kind() {
450                        InputKind::Pure => {
451                            let bytes = input.pure();
452                            bcs::from_bytes::<SuiAddress>(bytes).ok()?
453                        }
454                        _ => return None,
455                    }
456                }
457                _ => return None,
458            };
459            for obj in objs {
460                let i = match obj.kind() {
461                    ArgumentKind::Result => obj.result(),
462                    _ => return None,
463                };
464
465                let subresult_idx = obj.subresult.unwrap_or(0);
466                let KnownValue::GasCoin(value) = resolve_result(known_results, i, subresult_idx)?;
467
468                let aggregate = aggregated_recipients.entry(addr).or_default();
469                *aggregate += value;
470            }
471            Some(vec![])
472        }
473        fn into_balance_passthrough(
474            known_results: &[Vec<KnownValue>],
475            call: &MoveCall,
476        ) -> Option<Vec<KnownValue>> {
477            let args = &call.arguments;
478            if let Some(coin_arg) = args.first() {
479                match coin_arg.kind() {
480                    ArgumentKind::Result => {
481                        let cmd_idx = coin_arg.result?;
482                        let sub_idx = coin_arg.subresult.unwrap_or(0);
483                        let KnownValue::GasCoin(val) =
484                            resolve_result(known_results, cmd_idx, sub_idx)?;
485                        Some(vec![KnownValue::GasCoin(*val)])
486                    }
487                    // Input coin (e.g. remainder send_funds) — value unknown but
488                    // downstream send_funds to sender will ignore it anyway.
489                    _ => Some(vec![KnownValue::GasCoin(0)]),
490                }
491            } else {
492                Some(vec![KnownValue::GasCoin(0)])
493            }
494        }
495        fn send_funds_transfer(
496            aggregated_recipients: &mut HashMap<SuiAddress, u64>,
497            inputs: &[Input],
498            known_results: &[Vec<KnownValue>],
499            call: &MoveCall,
500            sender: SuiAddress,
501        ) -> Option<Vec<KnownValue>> {
502            let args = &call.arguments;
503            if args.len() < 2 {
504                return Some(vec![]);
505            }
506            let balance_arg = &args[0];
507            let recipient_arg = &args[1];
508
509            // Resolve the amount from the source argument
510            let amount = match balance_arg.kind() {
511                ArgumentKind::Result => {
512                    let cmd_idx = balance_arg.result?;
513                    let sub_idx = balance_arg.subresult.unwrap_or(0);
514                    let KnownValue::GasCoin(val) = resolve_result(known_results, cmd_idx, sub_idx)?;
515                    *val
516                }
517                _ => return Some(vec![]),
518            };
519
520            // Resolve recipient address
521            let addr = match recipient_arg.kind() {
522                ArgumentKind::Input => {
523                    let input_idx = recipient_arg.input() as usize;
524                    let input = inputs.get(input_idx)?;
525                    if input.kind() == InputKind::Pure {
526                        bcs::from_bytes::<SuiAddress>(input.pure()).ok()?
527                    } else {
528                        return Some(vec![]);
529                    }
530                }
531                _ => return Some(vec![]),
532            };
533
534            // Only track transfers to non-sender addresses
535            if addr != sender {
536                *aggregated_recipients.entry(addr).or_insert(0) += amount;
537            }
538            Some(vec![])
539        }
540        fn stake_call(
541            inputs: &[Input],
542            known_results: &[Vec<KnownValue>],
543            call: &MoveCall,
544        ) -> Result<Option<(Option<u64>, SuiAddress)>, Error> {
545            let arguments = &call.arguments;
546            let (amount, validator) = match &arguments[..] {
547                [system_state_arg, coin, validator] => {
548                    let amount = match coin.kind() {
549                        ArgumentKind::Result => {
550                            let i = coin
551                                .result
552                                .ok_or_else(|| anyhow!("Result argument missing index"))?;
553                            let KnownValue::GasCoin(value) = resolve_result(known_results, i, 0)
554                                .ok_or_else(|| {
555                                    anyhow!("Cannot resolve Gas coin value at Result({i})")
556                                })?;
557                            value
558                        }
559                        _ => return Ok(None),
560                    };
561                    let system_state_idx = match system_state_arg.kind() {
562                        ArgumentKind::Input => system_state_arg.input(),
563                        _ => return Ok(None),
564                    };
565                    let (some_amount, validator) = match validator.kind() {
566                        // [WORKAROUND] - input ordering hack: validator BEFORE system_state
567                        // means a specific amount; system_state BEFORE validator means stake_all.
568                        ArgumentKind::Input => {
569                            let i = validator.input();
570                            let validator_addr = match inputs.get(i as usize) {
571                                Some(input) if input.kind() == InputKind::Pure => {
572                                    bcs::from_bytes::<SuiAddress>(input.pure()).ok()
573                                }
574                                _ => None,
575                            };
576                            (i < system_state_idx, Ok(validator_addr))
577                        }
578                        _ => return Ok(None),
579                    };
580                    (some_amount.then_some(*amount), validator)
581                }
582                _ => Err(anyhow!(
583                    "Error encountered when extracting arguments from move call, expecting 3 elements, got {}",
584                    arguments.len()
585                ))?,
586            };
587            validator.map(|v| v.map(|v| (amount, v)))
588        }
589
590        fn unstake_call(inputs: &[Input], call: &MoveCall) -> Result<Option<ObjectID>, Error> {
591            let arguments = &call.arguments;
592            let id = match &arguments[..] {
593                [system_state_arg, stake_id] => match stake_id.kind() {
594                    ArgumentKind::Input => {
595                        let i = stake_id.input();
596                        let id = match inputs.get(i as usize) {
597                            Some(input) if input.kind() == InputKind::ImmutableOrOwned => input
598                                .object_id
599                                .as_ref()
600                                .and_then(|oid| ObjectID::from_str(oid).ok()),
601                            _ => None,
602                        }
603                        .ok_or_else(|| anyhow!("Cannot find stake id from input args."))?;
604                        // [WORKAROUND] - input ordering hack: system_state BEFORE stake_id
605                        // means specific stake IDs; stake_id BEFORE system_state means withdraw_all.
606                        let system_state_idx = match system_state_arg.kind() {
607                            ArgumentKind::Input => system_state_arg.input(),
608                            _ => return Ok(None),
609                        };
610                        let some_id = system_state_idx < i;
611                        some_id.then_some(id)
612                    }
613                    _ => None,
614                },
615                _ => Err(anyhow!(
616                    "Error encountered when extracting arguments from move call, expecting 2 elements, got {}",
617                    arguments.len()
618                ))?,
619            };
620            Ok(id)
621        }
622        let inputs = &pt.inputs;
623        let commands = &pt.commands;
624        let mut known_results: Vec<Vec<KnownValue>> = vec![];
625        let mut aggregated_recipients: HashMap<SuiAddress, u64> = HashMap::new();
626        let mut needs_generic = false;
627        let mut operations = vec![];
628        let mut stake_ids = vec![];
629        let mut currency: Option<Currency> = None;
630
631        // Detect FSS consolidation/redemption PTBs by signature MoveCalls.
632        // Order matters: a PTB with `redeem_fss` is always MergeAndRedeem (Consolidate
633        // never redeems), so we check redeem first. A PTB with `convert_fss` is always
634        // Consolidate (MergeAndRedeem never converts).
635        let has_redeem_fss = commands.iter().any(|c| {
636            matches!(
637                &c.command,
638                Some(Command::MoveCall(m)) if Self::is_redeem_fss_call(m)
639            )
640        });
641        let has_convert_fss = commands.iter().any(|c| {
642            matches!(
643                &c.command,
644                Some(Command::MoveCall(m)) if Self::is_convert_to_fss_call(m)
645            )
646        });
647        let has_join_fss = commands.iter().any(|c| {
648            matches!(
649                &c.command,
650                Some(Command::MoveCall(m)) if Self::is_join_fss_call(m)
651            )
652        });
653        if has_redeem_fss
654            && let Some(ops) = Self::parse_merge_and_redeem(sender, inputs, commands, status)
655        {
656            return Ok(ops);
657        }
658        if !has_redeem_fss
659            && (has_convert_fss || has_join_fss)
660            && let Some(ops) = Self::parse_consolidate(sender, inputs, commands, status)
661        {
662            return Ok(ops);
663        }
664        // If any FSS MoveCall was present but the corresponding sub-parser returned None,
665        // we fall through; the unrecognized MoveCalls hit `_ => None` and emit a generic_op.
666
667        for command in commands {
668            let result = match &command.command {
669                Some(Command::SplitCoins(split)) => {
670                    let coin = split.coin();
671                    split_coins(inputs, &known_results, coin, &split.amounts)
672                }
673                Some(Command::TransferObjects(transfer)) => {
674                    let addr = transfer.address();
675                    transfer_object(
676                        &mut aggregated_recipients,
677                        inputs,
678                        &known_results,
679                        &transfer.objects,
680                        addr,
681                    )
682                }
683                Some(Command::MoveCall(m)) if Self::is_stake_call(m) => {
684                    stake_call(inputs, &known_results, m)?.map(|(amount, validator)| {
685                        let amount = amount.map(|amount| Amount::new(-(amount as i128), None));
686                        operations.push(Operation {
687                            operation_identifier: Default::default(),
688                            type_: OperationType::Stake,
689                            status,
690                            account: Some(sender.into()),
691                            amount,
692                            coin_change: None,
693                            metadata: Some(OperationMetadata::Stake { validator }),
694                        });
695                        vec![]
696                    })
697                }
698                Some(Command::MoveCall(m)) if Self::is_unstake_call(m) => {
699                    let stake_id = unstake_call(inputs, m)?;
700                    stake_ids.push(stake_id);
701                    Some(vec![])
702                }
703                Some(Command::MergeCoins(_)) => {
704                    // We don't care about merge-coins, we can just skip it.
705                    Some(vec![])
706                }
707                // coin::redeem_funds produces a Coin from an address-balance withdrawal —
708                // must return a KnownValue so downstream SplitCoins can resolve its source.
709                Some(Command::MoveCall(m)) if Self::is_coin_redeem_funds_call(m) => {
710                    Some(vec![KnownValue::GasCoin(0)])
711                }
712                Some(Command::MoveCall(m)) if Self::is_coin_into_balance_call(m) => {
713                    into_balance_passthrough(&known_results, m)
714                }
715                Some(Command::MoveCall(m))
716                    if Self::is_balance_send_funds_call(m) || Self::is_coin_send_funds_call(m) =>
717                {
718                    send_funds_transfer(
719                        &mut aggregated_recipients,
720                        inputs,
721                        &known_results,
722                        m,
723                        sender,
724                    )
725                }
726                Some(Command::MoveCall(m))
727                    if Self::is_coin_destroy_zero_call(m) || Self::is_balance_join_call(m) =>
728                {
729                    Some(vec![])
730                }
731                _ => None,
732            };
733            if let Some(result) = result {
734                known_results.push(result)
735            } else {
736                needs_generic = true;
737                break;
738            }
739        }
740
741        if !needs_generic && !aggregated_recipients.is_empty() {
742            let total_paid: u64 = aggregated_recipients.values().copied().sum();
743            operations.extend(
744                aggregated_recipients
745                    .into_iter()
746                    .map(|(recipient, amount)| {
747                        currency = inputs.iter().last().and_then(|input| {
748                            if input.kind() == InputKind::Pure {
749                                let bytes = input.pure();
750                                bcs::from_bytes::<String>(bytes).ok().and_then(|json_str| {
751                                    serde_json::from_str::<Currency>(&json_str).ok()
752                                })
753                            } else {
754                                None
755                            }
756                        });
757                        match currency {
758                            Some(_) => Operation::pay_coin(
759                                status,
760                                recipient,
761                                amount.into(),
762                                currency.clone(),
763                            ),
764                            None => Operation::pay_sui(status, recipient, amount.into()),
765                        }
766                    }),
767            );
768            match currency {
769                Some(_) => operations.push(Operation::pay_coin(
770                    status,
771                    sender,
772                    -(total_paid as i128),
773                    currency.clone(),
774                )),
775                _ => operations.push(Operation::pay_sui(status, sender, -(total_paid as i128))),
776            }
777        } else if !stake_ids.is_empty() {
778            let stake_ids = stake_ids.into_iter().flatten().collect::<Vec<_>>();
779            let metadata = stake_ids
780                .is_empty()
781                .not()
782                .then_some(OperationMetadata::WithdrawStake { stake_ids });
783            operations.push(Operation {
784                operation_identifier: Default::default(),
785                type_: OperationType::WithdrawStake,
786                status,
787                account: Some(sender.into()),
788                amount: None,
789                coin_change: None,
790                metadata,
791            });
792        } else if operations.is_empty() {
793            let tx_kind = TransactionKind::default()
794                .with_kind(ProgrammableTransactionKind)
795                .with_programmable_transaction(pt);
796            operations.push(Operation::generic_op(status, sender, tx_kind))
797        }
798        Ok(operations)
799    }
800
801    /// Parse a PTB that represents `ConsolidateAllStakedSuiToFungible`.
802    ///
803    /// Accepts three valid shapes produced by `consolidate_to_fungible_pt`:
804    /// 1. Pure FSS merge (S=0, F>=2): only `join_fungible_staked_sui` calls, no convert, no transfer.
805    /// 2. Convert-only (S>=1, F=0): convert(s) + optional new-FSS joins + trailing `TransferObjects` to sender.
806    /// 3. Mixed (S>=1, F>=1): existing-FSS joins + convert(s) + new-FSS joins + cross-merge join, no transfer.
807    ///
808    /// Returns `None` on any shape mismatch, causing the caller to fall through to generic op emission.
809    fn parse_consolidate(
810        sender: SuiAddress,
811        inputs: &[Input],
812        commands: &[sui_rpc::proto::sui::rpc::v2::Command],
813        status: Option<OperationStatus>,
814    ) -> Option<Vec<Operation>> {
815        use std::collections::BTreeSet;
816
817        if !Self::first_input_is_sui_system_state(inputs) {
818            return None;
819        }
820
821        let mut staked_sui_indices: Vec<u32> = Vec::new();
822        let mut fss_indices: Vec<u32> = Vec::new();
823        let mut staked_seen: BTreeSet<u32> = BTreeSet::new();
824        let mut fss_seen: BTreeSet<u32> = BTreeSet::new();
825        let mut saw_transfer = false;
826
827        for (idx, command) in commands.iter().enumerate() {
828            if saw_transfer {
829                return None;
830            }
831            match &command.command {
832                Some(Command::MoveCall(m)) if Self::is_convert_to_fss_call(m) => {
833                    if m.arguments.len() != 2 {
834                        return None;
835                    }
836                    // arguments[0] must reference inputs[0] (the SUI_SYSTEM_STATE shared input,
837                    // verified by first_input_is_sui_system_state above). Reject any other shape.
838                    if m.arguments[0].kind() != ArgumentKind::Input || m.arguments[0].input() != 0 {
839                        return None;
840                    }
841                    let staked_arg = &m.arguments[1];
842                    if staked_arg.kind() != ArgumentKind::Input {
843                        return None;
844                    }
845                    let i = staked_arg.input();
846                    if fss_seen.contains(&i) {
847                        return None;
848                    }
849                    if staked_seen.insert(i) {
850                        staked_sui_indices.push(i);
851                    }
852                }
853                Some(Command::MoveCall(m)) if Self::is_join_fss_call(m) => {
854                    if m.arguments.len() != 2 {
855                        return None;
856                    }
857                    for arg in &m.arguments {
858                        match arg.kind() {
859                            ArgumentKind::Input => {
860                                let i = arg.input();
861                                if staked_seen.contains(&i) {
862                                    return None;
863                                }
864                                if fss_seen.insert(i) {
865                                    fss_indices.push(i);
866                                }
867                            }
868                            ArgumentKind::Result => {}
869                            _ => return None,
870                        }
871                    }
872                }
873                Some(Command::TransferObjects(transfer)) => {
874                    if transfer.objects.len() != 1 {
875                        return None;
876                    }
877                    if transfer.objects[0].kind() != ArgumentKind::Result {
878                        return None;
879                    }
880                    let addr_arg = transfer.address();
881                    if addr_arg.kind() != ArgumentKind::Input {
882                        return None;
883                    }
884                    let recipient = inputs.get(addr_arg.input() as usize).and_then(|inp| {
885                        if inp.kind() == InputKind::Pure {
886                            bcs::from_bytes::<SuiAddress>(inp.pure()).ok()
887                        } else {
888                            None
889                        }
890                    })?;
891                    if recipient != sender {
892                        return None;
893                    }
894                    if idx + 1 != commands.len() {
895                        return None;
896                    }
897                    saw_transfer = true;
898                }
899                _ => return None,
900            }
901        }
902
903        if staked_sui_indices.is_empty() && fss_indices.is_empty() {
904            return None;
905        }
906
907        // Invariant: TransferObjects is present iff F=0 && S>=1 (convert-only shape).
908        // - convert-only (S>=1, F=0): builder emits trailing TransferObjects to sender.
909        // - cross-merge (S>=1, F>=1): builder merges new FSS into existing; no transfer.
910        // - pure FSS merge (S=0, F>=2): existing FSS already sender-owned; no transfer.
911        // A mismatch indicates a non-executable shape that the builder never produces.
912        let expect_transfer = !staked_sui_indices.is_empty() && fss_indices.is_empty();
913        if expect_transfer != saw_transfer {
914            return None;
915        }
916
917        let staked_sui_ids = Self::input_indices_to_object_ids(inputs, &staked_sui_indices)?;
918        let fss_ids = Self::input_indices_to_object_ids(inputs, &fss_indices)?;
919
920        Some(vec![Operation {
921            operation_identifier: Default::default(),
922            type_: OperationType::ConsolidateAllStakedSuiToFungible,
923            status,
924            account: Some(sender.into()),
925            amount: None,
926            coin_change: None,
927            metadata: Some(OperationMetadata::ConsolidateAllStakedSuiToFungible {
928                validator: None,
929                staked_sui_ids,
930                fss_ids,
931            }),
932        }])
933    }
934
935    /// Parse a PTB that represents `MergeAndRedeemFungibleStakedSui`.
936    ///
937    /// Strict shape produced by `merge_and_redeem_fss_pt`:
938    ///   `[join_fss]*, [split_fss]?, redeem_fss, coin::from_balance<SUI>, TransferObjects`
939    /// where:
940    /// - `[join_fss]*` means zero or more joins (N-1 joins for N FSS inputs)
941    /// - `[split_fss]?` is present only for partial redemption (AtLeast/AtMost modes)
942    /// - exactly one `redeem_fss`, `from_balance<SUI>`, and trailing `TransferObjects`
943    ///
944    /// Emits `redeem_mode = Some(All)` when no split is present, `None` when split is
945    /// present (AtLeast vs AtMost are indistinguishable from PTB bytes). Returns `None`
946    /// on any shape mismatch, causing fall-through to generic op.
947    fn parse_merge_and_redeem(
948        sender: SuiAddress,
949        inputs: &[Input],
950        commands: &[sui_rpc::proto::sui::rpc::v2::Command],
951        status: Option<OperationStatus>,
952    ) -> Option<Vec<Operation>> {
953        use std::collections::BTreeSet;
954
955        if !Self::first_input_is_sui_system_state(inputs) {
956            return None;
957        }
958
959        #[derive(PartialEq, Eq)]
960        enum Phase {
961            Joins,
962            AfterSplit,
963            AfterRedeem,
964            AfterFromBalance,
965            Done,
966        }
967
968        let mut phase = Phase::Joins;
969        let mut fss_indices: Vec<u32> = Vec::new();
970        let mut fss_seen: BTreeSet<u32> = BTreeSet::new();
971        let mut has_split = false;
972
973        for (idx, command) in commands.iter().enumerate() {
974            if phase == Phase::Done {
975                return None;
976            }
977            match &command.command {
978                Some(Command::MoveCall(m)) if Self::is_join_fss_call(m) => {
979                    if phase != Phase::Joins {
980                        return None;
981                    }
982                    if m.arguments.len() != 2 {
983                        return None;
984                    }
985                    for arg in &m.arguments {
986                        match arg.kind() {
987                            ArgumentKind::Input => {
988                                let i = arg.input();
989                                if fss_seen.insert(i) {
990                                    fss_indices.push(i);
991                                }
992                            }
993                            ArgumentKind::Result => {}
994                            _ => return None,
995                        }
996                    }
997                }
998                Some(Command::MoveCall(m)) if Self::is_split_fss_call(m) => {
999                    if phase != Phase::Joins {
1000                        return None;
1001                    }
1002                    if m.arguments.len() != 2 {
1003                        return None;
1004                    }
1005                    // First arg = the FSS being split (Input or Result from prior joins).
1006                    let first = &m.arguments[0];
1007                    match first.kind() {
1008                        ArgumentKind::Input => {
1009                            let i = first.input();
1010                            if fss_seen.insert(i) {
1011                                fss_indices.push(i);
1012                            }
1013                        }
1014                        ArgumentKind::Result => {}
1015                        _ => return None,
1016                    }
1017                    // Second arg must be a pure u64 split amount. We don't decode it for
1018                    // metadata, but we verify the input kind is Pure (not an object ref).
1019                    if m.arguments[1].kind() != ArgumentKind::Input {
1020                        return None;
1021                    }
1022                    let amount_idx = m.arguments[1].input() as usize;
1023                    if inputs.get(amount_idx).map(|i| i.kind()) != Some(InputKind::Pure) {
1024                        return None;
1025                    }
1026                    has_split = true;
1027                    phase = Phase::AfterSplit;
1028                }
1029                Some(Command::MoveCall(m)) if Self::is_redeem_fss_call(m) => {
1030                    if phase != Phase::Joins && phase != Phase::AfterSplit {
1031                        return None;
1032                    }
1033                    if m.arguments.len() != 2 {
1034                        return None;
1035                    }
1036                    // arguments[0] must reference inputs[0] (SUI_SYSTEM_STATE, verified above).
1037                    if m.arguments[0].kind() != ArgumentKind::Input || m.arguments[0].input() != 0 {
1038                        return None;
1039                    }
1040                    let fss_arg = &m.arguments[1];
1041                    match fss_arg.kind() {
1042                        ArgumentKind::Input => {
1043                            let i = fss_arg.input();
1044                            if fss_seen.insert(i) {
1045                                fss_indices.push(i);
1046                            }
1047                        }
1048                        ArgumentKind::Result => {}
1049                        _ => return None,
1050                    }
1051                    phase = Phase::AfterRedeem;
1052                }
1053                Some(Command::MoveCall(m)) if Self::is_coin_from_balance_sui_call(m) => {
1054                    if phase != Phase::AfterRedeem {
1055                        return None;
1056                    }
1057                    phase = Phase::AfterFromBalance;
1058                }
1059                Some(Command::TransferObjects(transfer)) => {
1060                    if phase != Phase::AfterFromBalance {
1061                        return None;
1062                    }
1063                    if transfer.objects.len() != 1 {
1064                        return None;
1065                    }
1066                    if transfer.objects[0].kind() != ArgumentKind::Result {
1067                        return None;
1068                    }
1069                    let addr_arg = transfer.address();
1070                    if addr_arg.kind() != ArgumentKind::Input {
1071                        return None;
1072                    }
1073                    let recipient = inputs.get(addr_arg.input() as usize).and_then(|inp| {
1074                        if inp.kind() == InputKind::Pure {
1075                            bcs::from_bytes::<SuiAddress>(inp.pure()).ok()
1076                        } else {
1077                            None
1078                        }
1079                    })?;
1080                    if recipient != sender {
1081                        return None;
1082                    }
1083                    if idx + 1 != commands.len() {
1084                        return None;
1085                    }
1086                    phase = Phase::Done;
1087                }
1088                _ => return None,
1089            }
1090        }
1091
1092        if phase != Phase::Done {
1093            return None;
1094        }
1095        if fss_indices.is_empty() {
1096            return None;
1097        }
1098
1099        let fss_ids = Self::input_indices_to_object_ids(inputs, &fss_indices)?;
1100        let redeem_mode = if has_split {
1101            None
1102        } else {
1103            Some(RedeemMode::All)
1104        };
1105
1106        Some(vec![Operation {
1107            operation_identifier: Default::default(),
1108            type_: OperationType::MergeAndRedeemFungibleStakedSui,
1109            status,
1110            account: Some(sender.into()),
1111            amount: None,
1112            coin_change: None,
1113            metadata: Some(OperationMetadata::MergeAndRedeemFungibleStakedSui {
1114                validator: None,
1115                amount: None,
1116                redeem_mode,
1117                fss_ids,
1118            }),
1119        }])
1120    }
1121
1122    /// Returns true iff inputs[0] is a `SharedObject` reference to the SUI_SYSTEM_STATE (0x5).
1123    ///
1124    /// Note on mutability: the Move functions `convert_to_fungible_staked_sui` and
1125    /// `redeem_fungible_staked_sui` take `&mut SuiSystemState`, so the chain will reject
1126    /// immutable shared references at execution time. This check is therefore sufficient
1127    /// without an explicit mutable-shared flag.
1128    fn first_input_is_sui_system_state(inputs: &[Input]) -> bool {
1129        let Some(first) = inputs.first() else {
1130            return false;
1131        };
1132        if first.kind() != InputKind::Shared {
1133            return false;
1134        }
1135        let Some(oid_str) = first.object_id.as_ref() else {
1136            return false;
1137        };
1138        let Ok(oid) = ObjectID::from_str(oid_str) else {
1139            return false;
1140        };
1141        oid == SUI_SYSTEM_STATE_OBJECT_ID
1142    }
1143
1144    /// Resolves a list of input indices to ObjectIDs. Returns None if any index is
1145    /// out-of-bounds or references an input that isn't `ImmutableOrOwned`.
1146    fn input_indices_to_object_ids(inputs: &[Input], indices: &[u32]) -> Option<Vec<ObjectID>> {
1147        indices
1148            .iter()
1149            .map(|&i| {
1150                let inp = inputs.get(i as usize)?;
1151                if inp.kind() != InputKind::ImmutableOrOwned {
1152                    return None;
1153                }
1154                ObjectID::from_str(inp.object_id.as_ref()?).ok()
1155            })
1156            .collect()
1157    }
1158
1159    fn is_stake_call(tx: &MoveCall) -> bool {
1160        let package_id = match ObjectID::from_str(tx.package()) {
1161            Ok(id) => id,
1162            Err(e) => {
1163                warn!(
1164                    package = tx.package(),
1165                    error = %e,
1166                    "Failed to parse package ID for MoveCall"
1167                );
1168                return false;
1169            }
1170        };
1171
1172        package_id == SUI_SYSTEM_PACKAGE_ID
1173            && tx.module() == SUI_SYSTEM_MODULE_NAME.as_str()
1174            && tx.function() == ADD_STAKE_FUN_NAME.as_str()
1175    }
1176
1177    fn is_unstake_call(tx: &MoveCall) -> bool {
1178        let package_id = match ObjectID::from_str(tx.package()) {
1179            Ok(id) => id,
1180            Err(e) => {
1181                warn!(
1182                    package = tx.package(),
1183                    error = %e,
1184                    "Failed to parse package ID for MoveCall"
1185                );
1186                return false;
1187            }
1188        };
1189
1190        package_id == SUI_SYSTEM_PACKAGE_ID
1191            && tx.module() == SUI_SYSTEM_MODULE_NAME.as_str()
1192            && (tx.function() == WITHDRAW_STAKE_FUN_NAME.as_str()
1193                || tx.function() == "request_withdraw_stake_non_entry")
1194    }
1195
1196    /// Recognizes `0x3::sui_system::convert_to_fungible_staked_sui` — the signature
1197    /// MoveCall for `ConsolidateAllStakedSuiToFungible`.
1198    fn is_convert_to_fss_call(tx: &MoveCall) -> bool {
1199        let package_id = match ObjectID::from_str(tx.package()) {
1200            Ok(id) => id,
1201            Err(e) => {
1202                warn!(
1203                    package = tx.package(),
1204                    error = %e,
1205                    "Failed to parse package ID for MoveCall"
1206                );
1207                return false;
1208            }
1209        };
1210        package_id == SUI_SYSTEM_PACKAGE_ID
1211            && tx.module() == SUI_SYSTEM_MODULE_NAME.as_str()
1212            && tx.function() == "convert_to_fungible_staked_sui"
1213    }
1214
1215    /// Recognizes `0x3::staking_pool::join_fungible_staked_sui` — used by both
1216    /// `ConsolidateAllStakedSuiToFungible` (for merging FSS) and
1217    /// `MergeAndRedeemFungibleStakedSui`.
1218    fn is_join_fss_call(tx: &MoveCall) -> bool {
1219        let package_id = match ObjectID::from_str(tx.package()) {
1220            Ok(id) => id,
1221            Err(e) => {
1222                warn!(
1223                    package = tx.package(),
1224                    error = %e,
1225                    "Failed to parse package ID for MoveCall"
1226                );
1227                return false;
1228            }
1229        };
1230        package_id == SUI_SYSTEM_PACKAGE_ID
1231            && tx.module() == "staking_pool"
1232            && tx.function() == "join_fungible_staked_sui"
1233    }
1234
1235    /// Recognizes `0x3::sui_system::redeem_fungible_staked_sui` — the signature
1236    /// MoveCall for `MergeAndRedeemFungibleStakedSui`. Present only in redeem PTBs.
1237    fn is_redeem_fss_call(tx: &MoveCall) -> bool {
1238        let package_id = match ObjectID::from_str(tx.package()) {
1239            Ok(id) => id,
1240            Err(e) => {
1241                warn!(
1242                    package = tx.package(),
1243                    error = %e,
1244                    "Failed to parse package ID for MoveCall"
1245                );
1246                return false;
1247            }
1248        };
1249        package_id == SUI_SYSTEM_PACKAGE_ID
1250            && tx.module() == SUI_SYSTEM_MODULE_NAME.as_str()
1251            && tx.function() == "redeem_fungible_staked_sui"
1252    }
1253
1254    /// Recognizes `0x3::staking_pool::split_fungible_staked_sui` — used by
1255    /// MergeAndRedeem when the caller asks for partial (AtLeast/AtMost) redemption.
1256    fn is_split_fss_call(tx: &MoveCall) -> bool {
1257        let package_id = match ObjectID::from_str(tx.package()) {
1258            Ok(id) => id,
1259            Err(e) => {
1260                warn!(
1261                    package = tx.package(),
1262                    error = %e,
1263                    "Failed to parse package ID for MoveCall"
1264                );
1265                return false;
1266            }
1267        };
1268        package_id == SUI_SYSTEM_PACKAGE_ID
1269            && tx.module() == "staking_pool"
1270            && tx.function() == "split_fungible_staked_sui"
1271    }
1272
1273    /// Recognizes `0x2::coin::from_balance<0x2::sui::SUI>` — the bridge step that
1274    /// wraps a `Balance<SUI>` from `redeem_fungible_staked_sui` into a `Coin<SUI>`
1275    /// before transferring back to the sender.
1276    fn is_coin_from_balance_sui_call(tx: &MoveCall) -> bool {
1277        let Ok(package_id) = ObjectID::from_str(tx.package()) else {
1278            return false;
1279        };
1280        if package_id != SUI_FRAMEWORK_PACKAGE_ID {
1281            return false;
1282        }
1283        if tx.module() != "coin" || tx.function() != "from_balance" {
1284            return false;
1285        }
1286        if tx.type_arguments.len() != 1 {
1287            return false;
1288        }
1289        // Parse via TypeTag::from_str and compare structurally so any canonicalization
1290        // of the SUI type (padded, short, or legacy string forms) matches. This
1291        // future-proofs against encoder changes that emit non-canonical type strings.
1292        let Ok(parsed) = sui_types::TypeTag::from_str(&tx.type_arguments[0]) else {
1293            return false;
1294        };
1295        let Ok(expected) = sui_types::TypeTag::from_str("0x2::sui::SUI") else {
1296            return false;
1297        };
1298        parsed == expected
1299    }
1300
1301    /// Recognizes `coin::redeem_funds<T>` calls used for address-balance withdrawals.
1302    fn is_coin_redeem_funds_call(tx: &MoveCall) -> bool {
1303        let package_id = match ObjectID::from_str(tx.package()) {
1304            Ok(id) => id,
1305            Err(_) => return false,
1306        };
1307        package_id == SUI_FRAMEWORK_PACKAGE_ID
1308            && tx.module() == "coin"
1309            && tx.function() == "redeem_funds"
1310    }
1311
1312    fn is_coin_into_balance_call(tx: &MoveCall) -> bool {
1313        let package_id = match ObjectID::from_str(tx.package()) {
1314            Ok(id) => id,
1315            Err(_) => return false,
1316        };
1317        package_id == SUI_FRAMEWORK_PACKAGE_ID
1318            && tx.module() == "coin"
1319            && tx.function() == "into_balance"
1320    }
1321
1322    fn is_balance_send_funds_call(tx: &MoveCall) -> bool {
1323        let package_id = match ObjectID::from_str(tx.package()) {
1324            Ok(id) => id,
1325            Err(_) => return false,
1326        };
1327        package_id == SUI_FRAMEWORK_PACKAGE_ID
1328            && tx.module() == "balance"
1329            && tx.function() == "send_funds"
1330    }
1331
1332    fn is_coin_send_funds_call(tx: &MoveCall) -> bool {
1333        let package_id = match ObjectID::from_str(tx.package()) {
1334            Ok(id) => id,
1335            Err(_) => return false,
1336        };
1337        package_id == SUI_FRAMEWORK_PACKAGE_ID
1338            && tx.module() == "coin"
1339            && tx.function() == "send_funds"
1340    }
1341
1342    fn is_coin_destroy_zero_call(tx: &MoveCall) -> bool {
1343        let package_id = match ObjectID::from_str(tx.package()) {
1344            Ok(id) => id,
1345            Err(_) => return false,
1346        };
1347        package_id == SUI_FRAMEWORK_PACKAGE_ID
1348            && tx.module() == "coin"
1349            && tx.function() == "destroy_zero"
1350    }
1351
1352    fn is_balance_join_call(tx: &MoveCall) -> bool {
1353        let package_id = match ObjectID::from_str(tx.package()) {
1354            Ok(id) => id,
1355            Err(_) => return false,
1356        };
1357        package_id == SUI_FRAMEWORK_PACKAGE_ID
1358            && tx.module() == "balance"
1359            && tx.function() == "join"
1360    }
1361
1362    fn process_balance_change(
1363        gas_owner: SuiAddress,
1364        gas_used: i128,
1365        balance_changes: &[(BalanceChange, Currency)],
1366        status: Option<OperationStatus>,
1367        balances: HashMap<(SuiAddress, Currency), i128>,
1368    ) -> impl Iterator<Item = Operation> {
1369        let mut balances =
1370            balance_changes
1371                .iter()
1372                .fold(balances, |mut balances, (balance_change, ccy)| {
1373                    if let (Some(addr_str), Some(amount_str)) =
1374                        (&balance_change.address, &balance_change.amount)
1375                        && let (Ok(owner), Ok(amount)) =
1376                            (SuiAddress::from_str(addr_str), i128::from_str(amount_str))
1377                    {
1378                        *balances.entry((owner, ccy.clone())).or_default() += amount;
1379                    }
1380                    balances
1381                });
1382        // separate gas from balances
1383        *balances.entry((gas_owner, SUI.clone())).or_default() -= gas_used;
1384
1385        let balance_change = balances.into_iter().filter(|(_, amount)| *amount != 0).map(
1386            move |((addr, currency), amount)| {
1387                Operation::balance_change(status, addr, amount, currency)
1388            },
1389        );
1390
1391        let gas = if gas_used != 0 {
1392            vec![Operation::gas(gas_owner, gas_used)]
1393        } else {
1394            // Gas can be 0 for system tx
1395            vec![]
1396        };
1397        balance_change.chain(gas)
1398    }
1399
1400    /// Checks to see if transferObjects is used on GasCoin
1401    fn is_gascoin_transfer(tx: &TransactionKind) -> bool {
1402        if let Some(TransactionKindData::ProgrammableTransaction(pt)) = &tx.data {
1403            return pt.commands.iter().any(|command| {
1404                if let Some(Command::TransferObjects(transfer)) = &command.command {
1405                    transfer
1406                        .objects
1407                        .iter()
1408                        .any(|arg| arg.kind() == ArgumentKind::Gas)
1409                } else {
1410                    false
1411                }
1412            });
1413        }
1414        false
1415    }
1416
1417    /// Add balance-change with zero amount if the gas owner does not have an entry.
1418    /// An entry is required for gas owner because the balance would be adjusted.
1419    fn add_missing_gas_owner(operations: &mut Vec<Operation>, gas_owner: SuiAddress) {
1420        if !operations.iter().any(|operation| {
1421            if let Some(amount) = &operation.amount
1422                && let Some(account) = &operation.account
1423                && account.address == gas_owner
1424                && amount.currency == *SUI
1425            {
1426                return true;
1427            }
1428            false
1429        }) {
1430            operations.push(Operation::balance_change(
1431                Some(OperationStatus::Success),
1432                gas_owner,
1433                0,
1434                SUI.clone(),
1435            ));
1436        }
1437    }
1438
1439    /// Compare initial balance_changes to new_operations and make sure
1440    /// the balance-changes stay the same after updating the operations
1441    fn validate_operations(
1442        initial_balance_changes: &[(BalanceChange, Currency)],
1443        new_operations: &[Operation],
1444    ) -> Result<(), anyhow::Error> {
1445        let balances: HashMap<(SuiAddress, Currency), i128> = HashMap::new();
1446        let mut initial_balances =
1447            initial_balance_changes
1448                .iter()
1449                .fold(balances, |mut balances, (balance_change, ccy)| {
1450                    if let (Some(addr_str), Some(amount_str)) =
1451                        (&balance_change.address, &balance_change.amount)
1452                        && let (Ok(owner), Ok(amount)) =
1453                            (SuiAddress::from_str(addr_str), i128::from_str(amount_str))
1454                    {
1455                        *balances.entry((owner, ccy.clone())).or_default() += amount;
1456                    }
1457                    balances
1458                });
1459
1460        let mut new_balances = HashMap::new();
1461        for op in new_operations {
1462            if let Some(Amount {
1463                currency, value, ..
1464            }) = &op.amount
1465            {
1466                if let Some(account) = &op.account {
1467                    let balance_change = new_balances
1468                        .remove(&(account.address, currency.clone()))
1469                        .unwrap_or(0)
1470                        + value;
1471                    new_balances.insert((account.address, currency.clone()), balance_change);
1472                } else {
1473                    return Err(anyhow!("Missing account for a balance-change"));
1474                }
1475            }
1476        }
1477
1478        for ((address, currency), amount_expected) in new_balances {
1479            let new_amount = initial_balances.remove(&(address, currency)).unwrap_or(0);
1480            if new_amount != amount_expected {
1481                return Err(anyhow!(
1482                    "Expected {} balance-change for {} but got {}",
1483                    amount_expected,
1484                    address,
1485                    new_amount
1486                ));
1487            }
1488        }
1489        if !initial_balances.is_empty() {
1490            return Err(anyhow!(
1491                "Expected every item in initial_balances to be mapped"
1492            ));
1493        }
1494        Ok(())
1495    }
1496
1497    /// If GasCoin is transferred as a part of transferObjects, operations need to be
1498    /// updated such that:
1499    /// 1) gas owner needs to be assigned back to the previous owner
1500    /// 2) balances of previous and new gas owners need to be adjusted for the gas
1501    fn process_gascoin_transfer(
1502        coin_change_operations: &mut impl Iterator<Item = Operation>,
1503        is_gascoin_transfer: bool,
1504        prev_gas_owner: SuiAddress,
1505        new_gas_owner: SuiAddress,
1506        gas_used: i128,
1507        initial_balance_changes: &[(BalanceChange, Currency)],
1508    ) -> Result<Vec<Operation>, anyhow::Error> {
1509        let mut operations = vec![];
1510        if is_gascoin_transfer && prev_gas_owner != new_gas_owner {
1511            operations = coin_change_operations.collect();
1512            Self::add_missing_gas_owner(&mut operations, prev_gas_owner);
1513            Self::add_missing_gas_owner(&mut operations, new_gas_owner);
1514            for operation in &mut operations {
1515                match operation.type_ {
1516                    OperationType::Gas => {
1517                        // change gas account back to the previous owner as it is the one
1518                        // who paid for the txn (this is the format Rosetta wants to process)
1519                        operation.account = Some(prev_gas_owner.into())
1520                    }
1521                    OperationType::SuiBalanceChange => {
1522                        let account = operation
1523                            .account
1524                            .as_ref()
1525                            .ok_or_else(|| anyhow!("Missing account for a balance-change"))?;
1526                        let amount = operation
1527                            .amount
1528                            .as_mut()
1529                            .ok_or_else(|| anyhow!("Missing amount for a balance-change"))?;
1530                        // adjust the balances for previous and new gas_owners
1531                        if account.address == prev_gas_owner && amount.currency == *SUI {
1532                            amount.value -= gas_used;
1533                        } else if account.address == new_gas_owner && amount.currency == *SUI {
1534                            amount.value += gas_used;
1535                        }
1536                    }
1537                    _ => {
1538                        return Err(anyhow!(
1539                            "Discarding unsupported operation type {:?}",
1540                            operation.type_
1541                        ));
1542                    }
1543                }
1544            }
1545            Self::validate_operations(initial_balance_changes, &operations)?;
1546        }
1547        Ok(operations)
1548    }
1549}
1550
1551impl Operations {
1552    pub async fn try_from_executed_transaction(
1553        executed_tx: ExecutedTransaction,
1554        cache: &CoinMetadataCache,
1555    ) -> Result<Self, Error> {
1556        let ExecutedTransaction {
1557            transaction,
1558            effects,
1559            events,
1560            balance_changes,
1561            ..
1562        } = executed_tx;
1563
1564        let transaction = transaction.ok_or_else(|| {
1565            Error::DataError("ExecutedTransaction missing transaction".to_string())
1566        })?;
1567        let effects = effects
1568            .ok_or_else(|| Error::DataError("ExecutedTransaction missing effects".to_string()))?;
1569
1570        let sender = SuiAddress::from_str(transaction.sender())?;
1571
1572        let gas_owner = if effects.gas_object.is_some() {
1573            let gas_object = effects.gas_object();
1574            let owner = gas_object.output_owner();
1575            SuiAddress::from_str(owner.address())?
1576        } else if sender == SuiAddress::ZERO {
1577            // System transactions don't have a gas_object.
1578            sender
1579        } else {
1580            // Address-balance gas payment: gas is paid from the sender's address balance,
1581            // not from an explicit gas coin object. Use gas_payment owner from tx data.
1582            SuiAddress::from_str(transaction.gas_payment().owner())?
1583        };
1584
1585        let gas_summary = effects.gas_used();
1586        let gas_used = gas_summary.storage_rebate_opt().unwrap_or(0) as i128
1587            - gas_summary.storage_cost_opt().unwrap_or(0) as i128
1588            - gas_summary.computation_cost_opt().unwrap_or(0) as i128;
1589
1590        let status = Some(effects.status().into());
1591
1592        let prev_gas_owner = SuiAddress::from_str(transaction.gas_payment().owner())?;
1593
1594        let tx_kind = transaction
1595            .kind
1596            .ok_or_else(|| Error::DataError("Transaction missing kind".to_string()))?;
1597        let is_gascoin_transfer = Self::is_gascoin_transfer(&tx_kind);
1598        let ops = Self::new(Self::from_transaction(tx_kind, sender, status)?);
1599        let ops = ops.into_iter();
1600
1601        // We will need to subtract the operation amounts from the actual balance
1602        // change amount extracted from event to prevent double counting.
1603        let mut accounted_balances =
1604            ops.as_ref()
1605                .iter()
1606                .fold(HashMap::new(), |mut balances, op| {
1607                    if let (Some(acc), Some(amount), Some(OperationStatus::Success)) =
1608                        (&op.account, &op.amount, &op.status)
1609                    {
1610                        *balances
1611                            .entry((acc.address, amount.clone().currency))
1612                            .or_default() -= amount.value;
1613                    }
1614                    balances
1615                });
1616
1617        let mut principal_amounts = 0;
1618        let mut reward_amounts = 0;
1619
1620        // Extract balance change from unstake events
1621        let events = events.as_ref().map(|e| e.events.as_slice()).unwrap_or(&[]);
1622        for event in events {
1623            let event_type = event.event_type();
1624            if let Ok(type_tag) = StructTag::from_str(event_type)
1625                && is_unstake_event(&type_tag)
1626                && let Some(json) = &event.json
1627                && let Some(Kind::StructValue(struct_val)) = &json.kind
1628            {
1629                if let Some(principal_field) = struct_val.fields.get("principal_amount")
1630                    && let Some(Kind::StringValue(s)) = &principal_field.kind
1631                    && let Ok(amount) = i128::from_str(s)
1632                {
1633                    principal_amounts += amount;
1634                }
1635                if let Some(reward_field) = struct_val.fields.get("reward_amount")
1636                    && let Some(Kind::StringValue(s)) = &reward_field.kind
1637                    && let Ok(amount) = i128::from_str(s)
1638                {
1639                    reward_amounts += amount;
1640                }
1641            }
1642        }
1643        let staking_balance = if principal_amounts != 0 {
1644            *accounted_balances.entry((sender, SUI.clone())).or_default() -= principal_amounts;
1645            *accounted_balances.entry((sender, SUI.clone())).or_default() -= reward_amounts;
1646            vec![
1647                Operation::stake_principle(status, sender, principal_amounts),
1648                Operation::stake_reward(status, sender, reward_amounts),
1649            ]
1650        } else {
1651            vec![]
1652        };
1653
1654        let mut balance_changes_with_currency = vec![];
1655
1656        for balance_change in &balance_changes {
1657            let coin_type = balance_change.coin_type();
1658            let type_tag = sui_types::TypeTag::from_str(coin_type)
1659                .map_err(|e| anyhow!("Invalid coin type: {}", e))?;
1660
1661            if let Ok(currency) = cache.get_currency(&type_tag).await
1662                && !currency.symbol.is_empty()
1663            {
1664                balance_changes_with_currency.push((balance_change.clone(), currency));
1665            }
1666        }
1667
1668        // Extract coin change operations from balance changes
1669        let mut coin_change_operations = Self::process_balance_change(
1670            gas_owner,
1671            gas_used,
1672            &balance_changes_with_currency,
1673            status,
1674            accounted_balances.clone(),
1675        );
1676
1677        // Take {gas, previous gas owner, new gas owner} out of coin_change_operations
1678        // and convert BalanceChange to PaySui when GasCoin is transferred
1679        let gascoin_transfer_operations = Self::process_gascoin_transfer(
1680            &mut coin_change_operations,
1681            is_gascoin_transfer,
1682            prev_gas_owner,
1683            gas_owner,
1684            gas_used,
1685            &balance_changes_with_currency,
1686        )?;
1687
1688        let ops: Operations = ops
1689            .into_iter()
1690            .chain(coin_change_operations)
1691            .chain(gascoin_transfer_operations)
1692            .chain(staking_balance)
1693            .collect();
1694
1695        // This is a workaround for the payCoin cases that are mistakenly considered to be paySui operations
1696        // In this case we remove any irrelevant, SUI specific operation entries that sum up to 0 balance changes per address
1697        // and keep only the actual entries for the right coin type transfers, as they have been extracted from the transaction's
1698        // balance changes section.
1699        let mutually_cancelling_balances: HashMap<_, _> = ops
1700            .clone()
1701            .into_iter()
1702            .fold(
1703                HashMap::new(),
1704                |mut balances: HashMap<(SuiAddress, Currency), i128>, op| {
1705                    if let (Some(acc), Some(amount), Some(OperationStatus::Success)) =
1706                        (&op.account, &op.amount, &op.status)
1707                        && op.type_ != OperationType::Gas
1708                    {
1709                        *balances
1710                            .entry((acc.address, amount.clone().currency))
1711                            .or_default() += amount.value;
1712                    }
1713                    balances
1714                },
1715            )
1716            .into_iter()
1717            .filter(|balance| {
1718                let (_, amount) = balance;
1719                *amount == 0
1720            })
1721            .collect();
1722
1723        let ops: Operations = ops
1724            .into_iter()
1725            .filter(|op| {
1726                if let (Some(acc), Some(amount)) = (&op.account, &op.amount) {
1727                    return op.type_ == OperationType::Gas
1728                        || !mutually_cancelling_balances
1729                            .contains_key(&(acc.address, amount.clone().currency));
1730                }
1731                true
1732            })
1733            .collect();
1734        Ok(ops)
1735    }
1736}
1737
1738fn is_unstake_event(tag: &StructTag) -> bool {
1739    tag.address == SUI_SYSTEM_ADDRESS
1740        && tag.module.as_ident_str() == ident_str!("validator")
1741        && tag.name.as_ident_str() == ident_str!("UnstakingRequestEvent")
1742}
1743
1744#[derive(Deserialize, Serialize, Clone, Debug)]
1745pub struct Operation {
1746    operation_identifier: OperationIdentifier,
1747    #[serde(rename = "type")]
1748    pub type_: OperationType,
1749    #[serde(default, skip_serializing_if = "Option::is_none")]
1750    pub status: Option<OperationStatus>,
1751    #[serde(default, skip_serializing_if = "Option::is_none")]
1752    pub account: Option<AccountIdentifier>,
1753    #[serde(default, skip_serializing_if = "Option::is_none")]
1754    pub amount: Option<Amount>,
1755    #[serde(default, skip_serializing_if = "Option::is_none")]
1756    pub coin_change: Option<CoinChange>,
1757    #[serde(default, skip_serializing_if = "Option::is_none")]
1758    pub metadata: Option<OperationMetadata>,
1759}
1760
1761impl PartialEq for Operation {
1762    fn eq(&self, other: &Self) -> bool {
1763        self.operation_identifier == other.operation_identifier
1764            && self.type_ == other.type_
1765            && self.account == other.account
1766            && self.amount == other.amount
1767            && self.coin_change == other.coin_change
1768            && self.metadata == other.metadata
1769    }
1770}
1771
1772#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
1773pub enum OperationMetadata {
1774    GenericTransaction(TransactionKind),
1775    Stake {
1776        validator: SuiAddress,
1777    },
1778    WithdrawStake {
1779        stake_ids: Vec<ObjectID>,
1780    },
1781    ConsolidateAllStakedSuiToFungible {
1782        #[serde(default, skip_serializing_if = "Option::is_none")]
1783        validator: Option<SuiAddress>,
1784        #[serde(default, skip_serializing_if = "Vec::is_empty")]
1785        staked_sui_ids: Vec<ObjectID>,
1786        #[serde(default, skip_serializing_if = "Vec::is_empty")]
1787        fss_ids: Vec<ObjectID>,
1788    },
1789    MergeAndRedeemFungibleStakedSui {
1790        #[serde(default, skip_serializing_if = "Option::is_none")]
1791        validator: Option<SuiAddress>,
1792        #[serde(default, skip_serializing_if = "Option::is_none")]
1793        amount: Option<String>,
1794        #[serde(default, skip_serializing_if = "Option::is_none")]
1795        redeem_mode: Option<RedeemMode>,
1796        #[serde(default, skip_serializing_if = "Vec::is_empty")]
1797        fss_ids: Vec<ObjectID>,
1798    },
1799}
1800
1801impl Operation {
1802    fn generic_op(
1803        status: Option<OperationStatus>,
1804        sender: SuiAddress,
1805        tx: TransactionKind,
1806    ) -> Self {
1807        Operation {
1808            operation_identifier: Default::default(),
1809            type_: (&tx).into(),
1810            status,
1811            account: Some(sender.into()),
1812            amount: None,
1813            coin_change: None,
1814            metadata: Some(OperationMetadata::GenericTransaction(tx)),
1815        }
1816    }
1817
1818    pub fn genesis(index: u64, sender: SuiAddress, coin: GasCoin) -> Self {
1819        Operation {
1820            operation_identifier: index.into(),
1821            type_: OperationType::Genesis,
1822            status: Some(OperationStatus::Success),
1823            account: Some(sender.into()),
1824            amount: Some(Amount::new(coin.value().into(), None)),
1825            coin_change: Some(CoinChange {
1826                coin_identifier: CoinIdentifier {
1827                    identifier: CoinID {
1828                        id: *coin.id(),
1829                        version: SequenceNumber::new(),
1830                    },
1831                },
1832                coin_action: CoinAction::CoinCreated,
1833            }),
1834            metadata: None,
1835        }
1836    }
1837
1838    fn pay_sui(status: Option<OperationStatus>, address: SuiAddress, amount: i128) -> Self {
1839        Operation {
1840            operation_identifier: Default::default(),
1841            type_: OperationType::PaySui,
1842            status,
1843            account: Some(address.into()),
1844            amount: Some(Amount::new(amount, None)),
1845            coin_change: None,
1846            metadata: None,
1847        }
1848    }
1849
1850    fn pay_coin(
1851        status: Option<OperationStatus>,
1852        address: SuiAddress,
1853        amount: i128,
1854        currency: Option<Currency>,
1855    ) -> Self {
1856        Operation {
1857            operation_identifier: Default::default(),
1858            type_: OperationType::PayCoin,
1859            status,
1860            account: Some(address.into()),
1861            amount: Some(Amount::new(amount, currency)),
1862            coin_change: None,
1863            metadata: None,
1864        }
1865    }
1866
1867    fn balance_change(
1868        status: Option<OperationStatus>,
1869        addr: SuiAddress,
1870        amount: i128,
1871        currency: Currency,
1872    ) -> Self {
1873        Self {
1874            operation_identifier: Default::default(),
1875            type_: OperationType::SuiBalanceChange,
1876            status,
1877            account: Some(addr.into()),
1878            amount: Some(Amount::new(amount, Some(currency))),
1879            coin_change: None,
1880            metadata: None,
1881        }
1882    }
1883    fn gas(addr: SuiAddress, amount: i128) -> Self {
1884        Self {
1885            operation_identifier: Default::default(),
1886            type_: OperationType::Gas,
1887            status: Some(OperationStatus::Success),
1888            account: Some(addr.into()),
1889            amount: Some(Amount::new(amount, None)),
1890            coin_change: None,
1891            metadata: None,
1892        }
1893    }
1894    fn stake_reward(status: Option<OperationStatus>, addr: SuiAddress, amount: i128) -> Self {
1895        Self {
1896            operation_identifier: Default::default(),
1897            type_: OperationType::StakeReward,
1898            status,
1899            account: Some(addr.into()),
1900            amount: Some(Amount::new(amount, None)),
1901            coin_change: None,
1902            metadata: None,
1903        }
1904    }
1905    fn stake_principle(status: Option<OperationStatus>, addr: SuiAddress, amount: i128) -> Self {
1906        Self {
1907            operation_identifier: Default::default(),
1908            type_: OperationType::StakePrinciple,
1909            status,
1910            account: Some(addr.into()),
1911            amount: Some(Amount::new(amount, None)),
1912            coin_change: None,
1913            metadata: None,
1914        }
1915    }
1916}
1917
1918#[cfg(test)]
1919mod tests {
1920    use super::*;
1921    use crate::SUI;
1922    use crate::types::ConstructionMetadata;
1923    use crate::types::internal_operation::{consolidate_to_fungible_pt, merge_and_redeem_fss_pt};
1924    use sui_rpc::proto::sui::rpc::v2::Transaction;
1925    use sui_types::Identifier;
1926    use sui_types::base_types::{ObjectDigest, ObjectID, ObjectRef, SequenceNumber, SuiAddress};
1927    use sui_types::programmable_transaction_builder::ProgrammableTransactionBuilder;
1928    use sui_types::transaction::{
1929        CallArg, Command as NativeCommand, ObjectArg, ProgrammableTransaction,
1930        TEST_ONLY_GAS_UNIT_FOR_TRANSFER, TransactionData,
1931    };
1932
1933    fn random_object_ref() -> ObjectRef {
1934        (
1935            ObjectID::random(),
1936            SequenceNumber::from(1),
1937            ObjectDigest::random(),
1938        )
1939    }
1940
1941    /// Parse a native `ProgrammableTransaction` via the proto pipeline.
1942    /// Exact same conversion pattern used by `test_operation_data_parsing_pay_sui` at line 1637.
1943    fn parse_pt(sender: SuiAddress, pt: ProgrammableTransaction) -> Vec<Operation> {
1944        let gas = random_object_ref();
1945        let gas_price = 10;
1946        let data = TransactionData::new_programmable(
1947            sender,
1948            vec![gas],
1949            pt,
1950            TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
1951            gas_price,
1952        );
1953        let proto_tx: Transaction = data.into();
1954        let tx_kind = proto_tx.kind.expect("tx missing kind");
1955        Operations::from_transaction(tx_kind, sender, None).expect("parse failed")
1956    }
1957
1958    #[tokio::test]
1959    async fn test_operation_data_parsing_pay_sui() -> Result<(), anyhow::Error> {
1960        let gas = (
1961            ObjectID::random(),
1962            SequenceNumber::new(),
1963            ObjectDigest::random(),
1964        );
1965
1966        let sender = SuiAddress::random_for_testing_only();
1967
1968        let pt = {
1969            let mut builder = ProgrammableTransactionBuilder::new();
1970            builder
1971                .pay_sui(vec![SuiAddress::random_for_testing_only()], vec![10000])
1972                .unwrap();
1973            builder.finish()
1974        };
1975        let gas_price = 10;
1976        let data = TransactionData::new_programmable(
1977            sender,
1978            vec![gas],
1979            pt,
1980            TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
1981            gas_price,
1982        );
1983
1984        let proto_tx: Transaction = data.clone().into();
1985        let ops = Operations::new(Operations::from_transaction(
1986            proto_tx
1987                .kind
1988                .ok_or_else(|| Error::DataError("Transaction missing kind".to_string()))?,
1989            sender,
1990            None,
1991        )?);
1992        ops.0
1993            .iter()
1994            .for_each(|op| assert_eq!(op.type_, OperationType::PaySui));
1995        let metadata = ConstructionMetadata {
1996            sender,
1997            gas_coins: vec![gas],
1998            extra_gas_coins: vec![],
1999            objects: vec![],
2000            party_objects: vec![],
2001            total_coin_value: 0,
2002            gas_price,
2003            budget: TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
2004            currency: None,
2005            address_balance_withdrawal: 0,
2006            epoch: None,
2007            chain_id: None,
2008            fss_object_count: None,
2009            redeem_token_amount: None,
2010        };
2011        let parsed_data = ops.into_internal()?.try_into_data(metadata)?;
2012        assert_eq!(data, parsed_data);
2013
2014        Ok(())
2015    }
2016
2017    #[tokio::test]
2018    async fn test_operation_data_parsing_pay_coin() -> Result<(), anyhow::Error> {
2019        use crate::types::internal_operation::pay_coin_pt;
2020
2021        let gas = (
2022            ObjectID::random(),
2023            SequenceNumber::new(),
2024            ObjectDigest::random(),
2025        );
2026
2027        let coin = (
2028            ObjectID::random(),
2029            SequenceNumber::new(),
2030            ObjectDigest::random(),
2031        );
2032
2033        let sender = SuiAddress::random_for_testing_only();
2034        let recipient = SuiAddress::random_for_testing_only();
2035
2036        let pt = pay_coin_pt(sender, vec![recipient], vec![10000], &[coin], &[], 0, &SUI)?;
2037        let gas_price = 10;
2038        let data = TransactionData::new_programmable(
2039            sender,
2040            vec![gas],
2041            pt,
2042            TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
2043            gas_price,
2044        );
2045
2046        let proto_tx: Transaction = data.clone().into();
2047        let ops = Operations::new(Operations::from_transaction(
2048            proto_tx
2049                .kind
2050                .ok_or_else(|| Error::DataError("Transaction missing kind".to_string()))?,
2051            sender,
2052            None,
2053        )?);
2054        ops.0
2055            .iter()
2056            .for_each(|op| assert_eq!(op.type_, OperationType::PayCoin));
2057        let metadata = ConstructionMetadata {
2058            sender,
2059            gas_coins: vec![gas],
2060            extra_gas_coins: vec![],
2061            objects: vec![coin],
2062            party_objects: vec![],
2063            total_coin_value: 0,
2064            gas_price,
2065            budget: TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
2066            currency: Some(SUI.clone()),
2067            address_balance_withdrawal: 0,
2068            epoch: None,
2069            chain_id: None,
2070            fss_object_count: None,
2071            redeem_token_amount: None,
2072        };
2073        let parsed_data = ops.into_internal()?.try_into_data(metadata)?;
2074        assert_eq!(data, parsed_data);
2075
2076        Ok(())
2077    }
2078
2079    #[test]
2080    fn test_parse_consolidate_all_staked_sui_to_fungible() {
2081        let sender = SuiAddress::random_for_testing_only();
2082        let validator = SuiAddress::random_for_testing_only();
2083
2084        let ops: Operations = serde_json::from_value(serde_json::json!([{
2085            "operation_identifier": {"index": 0},
2086            "type": "ConsolidateAllStakedSuiToFungible",
2087            "account": {"address": sender.to_string()},
2088            "metadata": {
2089                "ConsolidateAllStakedSuiToFungible": {
2090                    "validator": validator.to_string()
2091                }
2092            }
2093        }]))
2094        .unwrap();
2095
2096        let internal = ops.into_internal().unwrap();
2097        match internal {
2098            InternalOperation::ConsolidateAllStakedSuiToFungible(op) => {
2099                assert_eq!(op.sender, sender);
2100                assert_eq!(op.validator, validator);
2101            }
2102            _ => panic!("Expected ConsolidateAllStakedSuiToFungible"),
2103        }
2104    }
2105
2106    #[test]
2107    fn test_parse_merge_and_redeem_fungible_staked_sui() {
2108        let sender = SuiAddress::random_for_testing_only();
2109        let validator = SuiAddress::random_for_testing_only();
2110
2111        let ops: Operations = serde_json::from_value(serde_json::json!([{
2112            "operation_identifier": {"index": 0},
2113            "type": "MergeAndRedeemFungibleStakedSui",
2114            "account": {"address": sender.to_string()},
2115            "metadata": {
2116                "MergeAndRedeemFungibleStakedSui": {
2117                    "validator": validator.to_string(),
2118                    "amount": "500000000000",
2119                    "redeem_mode": "AtLeast"
2120                }
2121            }
2122        }]))
2123        .unwrap();
2124
2125        let internal = ops.into_internal().unwrap();
2126        match internal {
2127            InternalOperation::MergeAndRedeemFungibleStakedSui(op) => {
2128                assert_eq!(op.sender, sender);
2129                assert_eq!(op.validator, validator);
2130                assert_eq!(op.amount, Some(500000000000));
2131                assert_eq!(op.redeem_mode, RedeemMode::AtLeast);
2132            }
2133            _ => panic!("Expected MergeAndRedeemFungibleStakedSui"),
2134        }
2135    }
2136
2137    #[test]
2138    fn test_parse_merge_and_redeem_all_mode() {
2139        let sender = SuiAddress::random_for_testing_only();
2140        let validator = SuiAddress::random_for_testing_only();
2141
2142        let ops: Operations = serde_json::from_value(serde_json::json!([{
2143            "operation_identifier": {"index": 0},
2144            "type": "MergeAndRedeemFungibleStakedSui",
2145            "account": {"address": sender.to_string()},
2146            "metadata": {
2147                "MergeAndRedeemFungibleStakedSui": {
2148                    "validator": validator.to_string(),
2149                    "redeem_mode": "All"
2150                }
2151            }
2152        }]))
2153        .unwrap();
2154
2155        let internal = ops.into_internal().unwrap();
2156        match internal {
2157            InternalOperation::MergeAndRedeemFungibleStakedSui(op) => {
2158                assert_eq!(op.amount, None);
2159                assert_eq!(op.redeem_mode, RedeemMode::All);
2160            }
2161            _ => panic!("Expected MergeAndRedeemFungibleStakedSui"),
2162        }
2163    }
2164
2165    // ==============================================================================
2166    // PR 1: Consolidate parser — happy-path tests (11 tests)
2167    // ==============================================================================
2168
2169    fn assert_consolidate_ops(
2170        ops: &[Operation],
2171        expected_sender: SuiAddress,
2172        expected_staked_sui: &[ObjectID],
2173        expected_fss: &[ObjectID],
2174    ) {
2175        assert_eq!(ops.len(), 1);
2176        let op = &ops[0];
2177        assert_eq!(op.type_, OperationType::ConsolidateAllStakedSuiToFungible);
2178        assert_eq!(
2179            op.account.as_ref().map(|a| a.address),
2180            Some(expected_sender)
2181        );
2182        assert!(op.amount.is_none());
2183        let Some(OperationMetadata::ConsolidateAllStakedSuiToFungible {
2184            validator,
2185            staked_sui_ids,
2186            fss_ids,
2187        }) = op.metadata.clone()
2188        else {
2189            panic!("wrong metadata variant: {:?}", op.metadata);
2190        };
2191        assert!(validator.is_none(), "validator must be None on parse");
2192        assert_eq!(staked_sui_ids, expected_staked_sui);
2193        assert_eq!(fss_ids, expected_fss);
2194    }
2195
2196    #[test]
2197    fn test_parse_consolidate_pure_merge_2_fss() {
2198        let sender = SuiAddress::random_for_testing_only();
2199        let fss_a = random_object_ref();
2200        let fss_b = random_object_ref();
2201        let pt = consolidate_to_fungible_pt(sender, vec![fss_a, fss_b], vec![]).expect("pt");
2202        let ops = parse_pt(sender, pt);
2203        assert_consolidate_ops(&ops, sender, &[], &[fss_a.0, fss_b.0]);
2204    }
2205
2206    #[test]
2207    fn test_parse_consolidate_pure_merge_3_fss() {
2208        let sender = SuiAddress::random_for_testing_only();
2209        let a = random_object_ref();
2210        let b = random_object_ref();
2211        let c = random_object_ref();
2212        let pt = consolidate_to_fungible_pt(sender, vec![a, b, c], vec![]).expect("pt");
2213        assert_consolidate_ops(&parse_pt(sender, pt), sender, &[], &[a.0, b.0, c.0]);
2214    }
2215
2216    #[test]
2217    fn test_parse_consolidate_pure_merge_5_fss() {
2218        let sender = SuiAddress::random_for_testing_only();
2219        let refs: Vec<_> = (0..5).map(|_| random_object_ref()).collect();
2220        let pt = consolidate_to_fungible_pt(sender, refs.clone(), vec![]).expect("pt");
2221        let expected: Vec<_> = refs.iter().map(|r| r.0).collect();
2222        assert_consolidate_ops(&parse_pt(sender, pt), sender, &[], &expected);
2223    }
2224
2225    #[test]
2226    fn test_parse_consolidate_single_convert_no_fss() {
2227        let sender = SuiAddress::random_for_testing_only();
2228        let staked = random_object_ref();
2229        let pt = consolidate_to_fungible_pt(sender, vec![], vec![staked]).expect("pt");
2230        assert_consolidate_ops(&parse_pt(sender, pt), sender, &[staked.0], &[]);
2231    }
2232
2233    #[test]
2234    fn test_parse_consolidate_multi_convert_no_fss() {
2235        let sender = SuiAddress::random_for_testing_only();
2236        let s1 = random_object_ref();
2237        let s2 = random_object_ref();
2238        let s3 = random_object_ref();
2239        let pt = consolidate_to_fungible_pt(sender, vec![], vec![s1, s2, s3]).expect("pt");
2240        assert_consolidate_ops(&parse_pt(sender, pt), sender, &[s1.0, s2.0, s3.0], &[]);
2241    }
2242
2243    #[test]
2244    fn test_parse_consolidate_single_stake_single_fss() {
2245        let sender = SuiAddress::random_for_testing_only();
2246        let fss = random_object_ref();
2247        let staked = random_object_ref();
2248        let pt = consolidate_to_fungible_pt(sender, vec![fss], vec![staked]).expect("pt");
2249        assert_consolidate_ops(&parse_pt(sender, pt), sender, &[staked.0], &[fss.0]);
2250    }
2251
2252    #[test]
2253    fn test_parse_consolidate_single_stake_multi_fss() {
2254        let sender = SuiAddress::random_for_testing_only();
2255        let f1 = random_object_ref();
2256        let f2 = random_object_ref();
2257        let staked = random_object_ref();
2258        let pt = consolidate_to_fungible_pt(sender, vec![f1, f2], vec![staked]).expect("pt");
2259        assert_consolidate_ops(&parse_pt(sender, pt), sender, &[staked.0], &[f1.0, f2.0]);
2260    }
2261
2262    #[test]
2263    fn test_parse_consolidate_multi_stake_single_fss() {
2264        let sender = SuiAddress::random_for_testing_only();
2265        let fss = random_object_ref();
2266        let s1 = random_object_ref();
2267        let s2 = random_object_ref();
2268        let pt = consolidate_to_fungible_pt(sender, vec![fss], vec![s1, s2]).expect("pt");
2269        assert_consolidate_ops(&parse_pt(sender, pt), sender, &[s1.0, s2.0], &[fss.0]);
2270    }
2271
2272    #[test]
2273    fn test_parse_consolidate_multi_stake_multi_fss() {
2274        let sender = SuiAddress::random_for_testing_only();
2275        let f1 = random_object_ref();
2276        let f2 = random_object_ref();
2277        let s1 = random_object_ref();
2278        let s2 = random_object_ref();
2279        let pt = consolidate_to_fungible_pt(sender, vec![f1, f2], vec![s1, s2]).expect("pt");
2280        assert_consolidate_ops(&parse_pt(sender, pt), sender, &[s1.0, s2.0], &[f1.0, f2.0]);
2281    }
2282
2283    #[test]
2284    fn test_parse_consolidate_large_mixed() {
2285        let sender = SuiAddress::random_for_testing_only();
2286        let fss: Vec<_> = (0..3).map(|_| random_object_ref()).collect();
2287        let staked: Vec<_> = (0..3).map(|_| random_object_ref()).collect();
2288        let pt = consolidate_to_fungible_pt(sender, fss.clone(), staked.clone()).expect("pt");
2289        let expected_s: Vec<_> = staked.iter().map(|r| r.0).collect();
2290        let expected_f: Vec<_> = fss.iter().map(|r| r.0).collect();
2291        assert_consolidate_ops(&parse_pt(sender, pt), sender, &expected_s, &expected_f);
2292    }
2293
2294    #[test]
2295    fn test_parse_consolidate_classification_correctness() {
2296        // No overlap between staked_sui_ids and fss_ids after parsing a mixed PTB.
2297        let sender = SuiAddress::random_for_testing_only();
2298        let f1 = random_object_ref();
2299        let f2 = random_object_ref();
2300        let s1 = random_object_ref();
2301        let s2 = random_object_ref();
2302        let pt = consolidate_to_fungible_pt(sender, vec![f1, f2], vec![s1, s2]).expect("pt");
2303        let ops = parse_pt(sender, pt);
2304        let Some(OperationMetadata::ConsolidateAllStakedSuiToFungible {
2305            staked_sui_ids,
2306            fss_ids,
2307            ..
2308        }) = ops[0].metadata.clone()
2309        else {
2310            panic!();
2311        };
2312        let staked_set: std::collections::HashSet<_> = staked_sui_ids.iter().collect();
2313        let fss_set: std::collections::HashSet<_> = fss_ids.iter().collect();
2314        assert!(
2315            staked_set.is_disjoint(&fss_set),
2316            "classification crossed categories"
2317        );
2318    }
2319
2320    // ==============================================================================
2321    // PR 1: Fall-through tests (4 tests) — malformed PTBs must NOT be labeled Consolidate
2322    // ==============================================================================
2323
2324    fn assert_falls_through_to_generic(ops: &[Operation]) {
2325        assert_eq!(ops.len(), 1);
2326        assert_eq!(
2327            ops[0].type_,
2328            OperationType::ProgrammableTransaction,
2329            "expected fall-through to generic ProgrammableTransaction, got: {:?}",
2330            ops[0].type_
2331        );
2332    }
2333
2334    #[test]
2335    fn test_parse_falls_through_consolidate_with_merge_coins() {
2336        let sender = SuiAddress::random_for_testing_only();
2337        let fss_a = random_object_ref();
2338        let fss_b = random_object_ref();
2339        let coin_a = random_object_ref();
2340
2341        let mut builder = ProgrammableTransactionBuilder::new();
2342        let _sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
2343        let first = builder.obj(ObjectArg::ImmOrOwnedObject(fss_a)).unwrap();
2344        let other = builder.obj(ObjectArg::ImmOrOwnedObject(fss_b)).unwrap();
2345        builder.command(NativeCommand::move_call(
2346            SUI_SYSTEM_PACKAGE_ID,
2347            Identifier::new("staking_pool").unwrap(),
2348            Identifier::new("join_fungible_staked_sui").unwrap(),
2349            vec![],
2350            vec![first, other],
2351        ));
2352        // Rogue MergeCoins breaks Consolidate shape validation.
2353        let coin_target = builder.obj(ObjectArg::ImmOrOwnedObject(coin_a)).unwrap();
2354        builder.command(NativeCommand::MergeCoins(coin_target, vec![]));
2355
2356        let ops = parse_pt(sender, builder.finish());
2357        assert_falls_through_to_generic(&ops);
2358    }
2359
2360    #[test]
2361    fn test_parse_falls_through_consolidate_with_unrelated_movecall() {
2362        let sender = SuiAddress::random_for_testing_only();
2363        let fss_a = random_object_ref();
2364        let fss_b = random_object_ref();
2365
2366        let mut builder = ProgrammableTransactionBuilder::new();
2367        let _sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
2368        let first = builder.obj(ObjectArg::ImmOrOwnedObject(fss_a)).unwrap();
2369        let other = builder.obj(ObjectArg::ImmOrOwnedObject(fss_b)).unwrap();
2370        builder.command(NativeCommand::move_call(
2371            SUI_SYSTEM_PACKAGE_ID,
2372            Identifier::new("staking_pool").unwrap(),
2373            Identifier::new("join_fungible_staked_sui").unwrap(),
2374            vec![],
2375            vec![first, other],
2376        ));
2377        // Unrelated MoveCall (e.g., 0x2::sui::transfer doesn't exist, so use any other function).
2378        builder.command(NativeCommand::move_call(
2379            SUI_FRAMEWORK_PACKAGE_ID,
2380            Identifier::new("coin").unwrap(),
2381            Identifier::new("destroy_zero").unwrap(),
2382            vec![],
2383            vec![other],
2384        ));
2385
2386        let ops = parse_pt(sender, builder.finish());
2387        assert_falls_through_to_generic(&ops);
2388    }
2389
2390    #[test]
2391    fn test_parse_falls_through_convert_without_system_state() {
2392        // Build a PTB where inputs[0] is an ImmOrOwned object (not SUI_SYSTEM_STATE shared).
2393        let sender = SuiAddress::random_for_testing_only();
2394        let staked = random_object_ref();
2395        let other_obj = random_object_ref();
2396
2397        let mut builder = ProgrammableTransactionBuilder::new();
2398        // Put a random object first — parser should reject.
2399        let _not_system = builder.obj(ObjectArg::ImmOrOwnedObject(other_obj)).unwrap();
2400        let staked_arg = builder.obj(ObjectArg::ImmOrOwnedObject(staked)).unwrap();
2401        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
2402        let new_fss = builder.command(NativeCommand::move_call(
2403            SUI_SYSTEM_PACKAGE_ID,
2404            Identifier::new("sui_system").unwrap(),
2405            Identifier::new("convert_to_fungible_staked_sui").unwrap(),
2406            vec![],
2407            vec![sys, staked_arg],
2408        ));
2409        let sender_arg = builder.pure(sender).unwrap();
2410        builder.command(NativeCommand::TransferObjects(vec![new_fss], sender_arg));
2411
2412        let ops = parse_pt(sender, builder.finish());
2413        assert_falls_through_to_generic(&ops);
2414    }
2415
2416    #[test]
2417    fn test_parse_falls_through_extra_command_after_transfer() {
2418        // Valid Consolidate shape + an extra command after TransferObjects → reject.
2419        let sender = SuiAddress::random_for_testing_only();
2420        let staked = random_object_ref();
2421        let other_obj = random_object_ref();
2422
2423        let mut builder = ProgrammableTransactionBuilder::new();
2424        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
2425        let staked_arg = builder.obj(ObjectArg::ImmOrOwnedObject(staked)).unwrap();
2426        let new_fss = builder.command(NativeCommand::move_call(
2427            SUI_SYSTEM_PACKAGE_ID,
2428            Identifier::new("sui_system").unwrap(),
2429            Identifier::new("convert_to_fungible_staked_sui").unwrap(),
2430            vec![],
2431            vec![sys, staked_arg],
2432        ));
2433        let sender_arg = builder.pure(sender).unwrap();
2434        builder.command(NativeCommand::TransferObjects(vec![new_fss], sender_arg));
2435        // Extra command: destroy_zero on an unrelated coin.
2436        let extra = builder.obj(ObjectArg::ImmOrOwnedObject(other_obj)).unwrap();
2437        builder.command(NativeCommand::move_call(
2438            SUI_FRAMEWORK_PACKAGE_ID,
2439            Identifier::new("coin").unwrap(),
2440            Identifier::new("destroy_zero").unwrap(),
2441            vec![],
2442            vec![extra],
2443        ));
2444
2445        let ops = parse_pt(sender, builder.finish());
2446        assert_falls_through_to_generic(&ops);
2447    }
2448
2449    // ==============================================================================
2450    // PR 1: Robustness tests (4 tests, but #38-39 belong in e2e — see plan)
2451    // ==============================================================================
2452
2453    #[test]
2454    fn test_parse_empty_ptb() {
2455        let sender = SuiAddress::random_for_testing_only();
2456        let pt = ProgrammableTransactionBuilder::new().finish();
2457        let ops = parse_pt(sender, pt);
2458        // Zero commands: parser should produce a generic op (existing behavior).
2459        assert_eq!(ops.len(), 1);
2460        assert_eq!(ops[0].type_, OperationType::ProgrammableTransaction);
2461    }
2462
2463    #[test]
2464    fn test_parse_only_merge_coins() {
2465        // PTB with only regular MergeCoins (non-FSS) — falls through, unrelated to our dispatch.
2466        let sender = SuiAddress::random_for_testing_only();
2467        let coin_a = random_object_ref();
2468        let coin_b = random_object_ref();
2469        let mut builder = ProgrammableTransactionBuilder::new();
2470        let target = builder.obj(ObjectArg::ImmOrOwnedObject(coin_a)).unwrap();
2471        let source = builder.obj(ObjectArg::ImmOrOwnedObject(coin_b)).unwrap();
2472        builder.command(NativeCommand::MergeCoins(target, vec![source]));
2473        let ops = parse_pt(sender, builder.finish());
2474        // Either ProgrammableTransaction (generic) or whatever the existing parser produces.
2475        // Not our typed FSS op.
2476        assert_ne!(
2477            ops[0].type_,
2478            OperationType::ConsolidateAllStakedSuiToFungible
2479        );
2480        assert_ne!(ops[0].type_, OperationType::MergeAndRedeemFungibleStakedSui);
2481    }
2482
2483    // Tests #38 (garbage bytes) and #39 (truncated tx data) are HTTP-level and belong in
2484    // end_to_end_tests.rs — see plan section D.
2485
2486    // ==============================================================================
2487    // PR 1: Metadata serialization compat (2 tests)
2488    // ==============================================================================
2489
2490    #[test]
2491    fn test_meta_consolidate_old_input_deserializes() {
2492        let validator = SuiAddress::random_for_testing_only();
2493        let json = serde_json::json!({
2494            "ConsolidateAllStakedSuiToFungible": { "validator": validator.to_string() }
2495        });
2496        let meta: OperationMetadata = serde_json::from_value(json).unwrap();
2497        match meta {
2498            OperationMetadata::ConsolidateAllStakedSuiToFungible {
2499                validator: v,
2500                staked_sui_ids,
2501                fss_ids,
2502            } => {
2503                assert_eq!(v, Some(validator));
2504                assert!(staked_sui_ids.is_empty());
2505                assert!(fss_ids.is_empty());
2506            }
2507            _ => panic!("wrong variant"),
2508        }
2509    }
2510
2511    #[test]
2512    fn test_meta_consolidate_new_parse_output_serializes() {
2513        let id_a = ObjectID::random();
2514        let id_b = ObjectID::random();
2515        let meta = OperationMetadata::ConsolidateAllStakedSuiToFungible {
2516            validator: None,
2517            staked_sui_ids: vec![id_a],
2518            fss_ids: vec![id_b],
2519        };
2520        let json = serde_json::to_value(&meta).unwrap();
2521        let obj = json
2522            .as_object()
2523            .unwrap()
2524            .get("ConsolidateAllStakedSuiToFungible")
2525            .unwrap()
2526            .as_object()
2527            .unwrap();
2528        assert!(
2529            !obj.contains_key("validator"),
2530            "validator must be omitted when None"
2531        );
2532        assert_eq!(
2533            obj.get("staked_sui_ids").unwrap().as_array().unwrap().len(),
2534            1
2535        );
2536        assert_eq!(obj.get("fss_ids").unwrap().as_array().unwrap().len(), 1);
2537    }
2538
2539    // ==============================================================================
2540    // PR 1: Write-side preservation (1 test)
2541    // ==============================================================================
2542
2543    #[test]
2544    fn test_write_consolidate_requires_validator() {
2545        let sender = SuiAddress::random_for_testing_only();
2546        let op = Operation {
2547            operation_identifier: Default::default(),
2548            type_: OperationType::ConsolidateAllStakedSuiToFungible,
2549            status: None,
2550            account: Some(sender.into()),
2551            amount: None,
2552            coin_change: None,
2553            metadata: Some(OperationMetadata::ConsolidateAllStakedSuiToFungible {
2554                validator: None,
2555                staked_sui_ids: vec![],
2556                fss_ids: vec![],
2557            }),
2558        };
2559        let err = Operations::new(vec![op])
2560            .into_internal()
2561            .expect_err("should fail without validator");
2562        let msg = format!("{err}");
2563        assert!(msg.contains("validator"), "unexpected error: {msg}");
2564    }
2565
2566    // ==============================================================================
2567    // PR 2: MergeAndRedeem parser — happy-path tests (11 tests)
2568    // ==============================================================================
2569
2570    fn assert_merge_redeem_ops(
2571        ops: &[Operation],
2572        expected_sender: SuiAddress,
2573        expected_fss: &[ObjectID],
2574        expected_mode: Option<RedeemMode>,
2575    ) {
2576        assert_eq!(ops.len(), 1);
2577        let op = &ops[0];
2578        assert_eq!(op.type_, OperationType::MergeAndRedeemFungibleStakedSui);
2579        assert_eq!(
2580            op.account.as_ref().map(|a| a.address),
2581            Some(expected_sender)
2582        );
2583        assert!(op.amount.is_none());
2584        let Some(OperationMetadata::MergeAndRedeemFungibleStakedSui {
2585            validator,
2586            amount,
2587            redeem_mode,
2588            fss_ids,
2589        }) = op.metadata.clone()
2590        else {
2591            panic!("wrong metadata variant: {:?}", op.metadata);
2592        };
2593        assert!(validator.is_none(), "validator must be None on parse");
2594        assert!(
2595            amount.is_none(),
2596            "amount must be None on parse (not in PTB)"
2597        );
2598        assert_eq!(redeem_mode, expected_mode);
2599        assert_eq!(fss_ids, expected_fss);
2600    }
2601
2602    #[test]
2603    fn test_parse_merge_redeem_single_all() {
2604        let sender = SuiAddress::random_for_testing_only();
2605        let fss = random_object_ref();
2606        let pt = merge_and_redeem_fss_pt(sender, vec![fss], None).expect("pt");
2607        assert_merge_redeem_ops(
2608            &parse_pt(sender, pt),
2609            sender,
2610            &[fss.0],
2611            Some(RedeemMode::All),
2612        );
2613    }
2614
2615    #[test]
2616    fn test_parse_merge_redeem_single_partial() {
2617        let sender = SuiAddress::random_for_testing_only();
2618        let fss = random_object_ref();
2619        let pt = merge_and_redeem_fss_pt(sender, vec![fss], Some(500_000_000)).expect("pt");
2620        assert_merge_redeem_ops(&parse_pt(sender, pt), sender, &[fss.0], None);
2621    }
2622
2623    #[test]
2624    fn test_parse_merge_redeem_two_all() {
2625        let sender = SuiAddress::random_for_testing_only();
2626        let a = random_object_ref();
2627        let b = random_object_ref();
2628        let pt = merge_and_redeem_fss_pt(sender, vec![a, b], None).expect("pt");
2629        assert_merge_redeem_ops(
2630            &parse_pt(sender, pt),
2631            sender,
2632            &[a.0, b.0],
2633            Some(RedeemMode::All),
2634        );
2635    }
2636
2637    #[test]
2638    fn test_parse_merge_redeem_two_partial() {
2639        let sender = SuiAddress::random_for_testing_only();
2640        let a = random_object_ref();
2641        let b = random_object_ref();
2642        let pt = merge_and_redeem_fss_pt(sender, vec![a, b], Some(500_000_000)).expect("pt");
2643        assert_merge_redeem_ops(&parse_pt(sender, pt), sender, &[a.0, b.0], None);
2644    }
2645
2646    #[test]
2647    fn test_parse_merge_redeem_three_all() {
2648        let sender = SuiAddress::random_for_testing_only();
2649        let a = random_object_ref();
2650        let b = random_object_ref();
2651        let c = random_object_ref();
2652        let pt = merge_and_redeem_fss_pt(sender, vec![a, b, c], None).expect("pt");
2653        assert_merge_redeem_ops(
2654            &parse_pt(sender, pt),
2655            sender,
2656            &[a.0, b.0, c.0],
2657            Some(RedeemMode::All),
2658        );
2659    }
2660
2661    #[test]
2662    fn test_parse_merge_redeem_three_partial() {
2663        let sender = SuiAddress::random_for_testing_only();
2664        let a = random_object_ref();
2665        let b = random_object_ref();
2666        let c = random_object_ref();
2667        let pt = merge_and_redeem_fss_pt(sender, vec![a, b, c], Some(500_000_000)).expect("pt");
2668        assert_merge_redeem_ops(&parse_pt(sender, pt), sender, &[a.0, b.0, c.0], None);
2669    }
2670
2671    #[test]
2672    fn test_parse_merge_redeem_five_all() {
2673        let sender = SuiAddress::random_for_testing_only();
2674        let refs: Vec<_> = (0..5).map(|_| random_object_ref()).collect();
2675        let pt = merge_and_redeem_fss_pt(sender, refs.clone(), None).expect("pt");
2676        let expected: Vec<_> = refs.iter().map(|r| r.0).collect();
2677        assert_merge_redeem_ops(
2678            &parse_pt(sender, pt),
2679            sender,
2680            &expected,
2681            Some(RedeemMode::All),
2682        );
2683    }
2684
2685    #[test]
2686    fn test_parse_merge_redeem_fss_ids_order() {
2687        // Build with a specific order and assert the parser preserves it.
2688        let sender = SuiAddress::random_for_testing_only();
2689        let a = random_object_ref();
2690        let b = random_object_ref();
2691        let c = random_object_ref();
2692        let pt = merge_and_redeem_fss_pt(sender, vec![a, b, c], None).expect("pt");
2693        let ops = parse_pt(sender, pt);
2694        let Some(OperationMetadata::MergeAndRedeemFungibleStakedSui { fss_ids, .. }) =
2695            ops[0].metadata.clone()
2696        else {
2697            panic!();
2698        };
2699        assert_eq!(fss_ids, vec![a.0, b.0, c.0]);
2700    }
2701
2702    #[test]
2703    fn test_parse_merge_redeem_sender_account() {
2704        let sender = SuiAddress::random_for_testing_only();
2705        let fss = random_object_ref();
2706        let pt = merge_and_redeem_fss_pt(sender, vec![fss], None).expect("pt");
2707        let ops = parse_pt(sender, pt);
2708        assert_eq!(ops[0].account.as_ref().unwrap().address, sender);
2709    }
2710
2711    #[test]
2712    fn test_parse_merge_redeem_no_amount_in_metadata() {
2713        let sender = SuiAddress::random_for_testing_only();
2714        let fss = random_object_ref();
2715        let pt = merge_and_redeem_fss_pt(sender, vec![fss], Some(500_000_000)).expect("pt");
2716        let ops = parse_pt(sender, pt);
2717        let Some(OperationMetadata::MergeAndRedeemFungibleStakedSui { amount, .. }) =
2718            ops[0].metadata.clone()
2719        else {
2720            panic!();
2721        };
2722        assert!(amount.is_none());
2723    }
2724
2725    #[test]
2726    fn test_parse_merge_redeem_no_validator_in_metadata() {
2727        let sender = SuiAddress::random_for_testing_only();
2728        let fss = random_object_ref();
2729        let pt = merge_and_redeem_fss_pt(sender, vec![fss], None).expect("pt");
2730        let ops = parse_pt(sender, pt);
2731        let Some(OperationMetadata::MergeAndRedeemFungibleStakedSui { validator, .. }) =
2732            ops[0].metadata.clone()
2733        else {
2734            panic!();
2735        };
2736        assert!(validator.is_none());
2737    }
2738
2739    // ==============================================================================
2740    // PR 2: Fall-through tests — malformed MergeAndRedeem PTBs (9 tests)
2741    // ==============================================================================
2742
2743    fn build_redeem_ptb_with_type_arg(
2744        sender: SuiAddress,
2745        fss: ObjectRef,
2746        coin_type_arg: &str,
2747    ) -> ProgrammableTransaction {
2748        let mut builder = ProgrammableTransactionBuilder::new();
2749        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
2750        let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
2751        let balance = builder.command(NativeCommand::move_call(
2752            SUI_SYSTEM_PACKAGE_ID,
2753            Identifier::new("sui_system").unwrap(),
2754            Identifier::new("redeem_fungible_staked_sui").unwrap(),
2755            vec![],
2756            vec![sys, fss_arg],
2757        ));
2758        let coin = builder.command(NativeCommand::move_call(
2759            SUI_FRAMEWORK_PACKAGE_ID,
2760            Identifier::new("coin").unwrap(),
2761            Identifier::new("from_balance").unwrap(),
2762            vec![sui_types::TypeTag::from_str(coin_type_arg).unwrap()],
2763            vec![balance],
2764        ));
2765        let sender_arg = builder.pure(sender).unwrap();
2766        builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
2767        builder.finish()
2768    }
2769
2770    #[test]
2771    fn test_parse_falls_through_redeem_wrong_type_arg() {
2772        let sender = SuiAddress::random_for_testing_only();
2773        let fss = random_object_ref();
2774        // from_balance with wrong generic — e.g. a fake USDC type.
2775        let pt = build_redeem_ptb_with_type_arg(sender, fss, "0x2::coin::Coin");
2776        let ops = parse_pt(sender, pt);
2777        assert_falls_through_to_generic(&ops);
2778    }
2779
2780    #[test]
2781    fn test_parse_falls_through_redeem_without_from_balance() {
2782        let sender = SuiAddress::random_for_testing_only();
2783        let fss = random_object_ref();
2784        // Build: redeem + (no from_balance) + transfer of the balance directly (nonsense shape).
2785        let mut builder = ProgrammableTransactionBuilder::new();
2786        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
2787        let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
2788        let balance = builder.command(NativeCommand::move_call(
2789            SUI_SYSTEM_PACKAGE_ID,
2790            Identifier::new("sui_system").unwrap(),
2791            Identifier::new("redeem_fungible_staked_sui").unwrap(),
2792            vec![],
2793            vec![sys, fss_arg],
2794        ));
2795        let sender_arg = builder.pure(sender).unwrap();
2796        builder.command(NativeCommand::TransferObjects(vec![balance], sender_arg));
2797        let ops = parse_pt(sender, builder.finish());
2798        assert_falls_through_to_generic(&ops);
2799    }
2800
2801    #[test]
2802    fn test_parse_falls_through_redeem_without_transfer() {
2803        let sender = SuiAddress::random_for_testing_only();
2804        let fss = random_object_ref();
2805        let mut builder = ProgrammableTransactionBuilder::new();
2806        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
2807        let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
2808        let balance = builder.command(NativeCommand::move_call(
2809            SUI_SYSTEM_PACKAGE_ID,
2810            Identifier::new("sui_system").unwrap(),
2811            Identifier::new("redeem_fungible_staked_sui").unwrap(),
2812            vec![],
2813            vec![sys, fss_arg],
2814        ));
2815        builder.command(NativeCommand::move_call(
2816            SUI_FRAMEWORK_PACKAGE_ID,
2817            Identifier::new("coin").unwrap(),
2818            Identifier::new("from_balance").unwrap(),
2819            vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
2820            vec![balance],
2821        ));
2822        // No TransferObjects → shape mismatch.
2823        let ops = parse_pt(sender, builder.finish());
2824        assert_falls_through_to_generic(&ops);
2825    }
2826
2827    #[test]
2828    fn test_parse_falls_through_redeem_transfer_wrong_recipient() {
2829        let sender = SuiAddress::random_for_testing_only();
2830        let other = SuiAddress::random_for_testing_only();
2831        let fss = random_object_ref();
2832        let mut builder = ProgrammableTransactionBuilder::new();
2833        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
2834        let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
2835        let balance = builder.command(NativeCommand::move_call(
2836            SUI_SYSTEM_PACKAGE_ID,
2837            Identifier::new("sui_system").unwrap(),
2838            Identifier::new("redeem_fungible_staked_sui").unwrap(),
2839            vec![],
2840            vec![sys, fss_arg],
2841        ));
2842        let coin = builder.command(NativeCommand::move_call(
2843            SUI_FRAMEWORK_PACKAGE_ID,
2844            Identifier::new("coin").unwrap(),
2845            Identifier::new("from_balance").unwrap(),
2846            vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
2847            vec![balance],
2848        ));
2849        // TransferObjects recipient is NOT the sender.
2850        let other_arg = builder.pure(other).unwrap();
2851        builder.command(NativeCommand::TransferObjects(vec![coin], other_arg));
2852        let ops = parse_pt(sender, builder.finish());
2853        assert_falls_through_to_generic(&ops);
2854    }
2855
2856    #[test]
2857    fn test_parse_falls_through_redeem_transfer_multiple_objects() {
2858        let sender = SuiAddress::random_for_testing_only();
2859        let fss = random_object_ref();
2860        let other_obj = random_object_ref();
2861        let mut builder = ProgrammableTransactionBuilder::new();
2862        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
2863        let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
2864        let balance = builder.command(NativeCommand::move_call(
2865            SUI_SYSTEM_PACKAGE_ID,
2866            Identifier::new("sui_system").unwrap(),
2867            Identifier::new("redeem_fungible_staked_sui").unwrap(),
2868            vec![],
2869            vec![sys, fss_arg],
2870        ));
2871        let coin = builder.command(NativeCommand::move_call(
2872            SUI_FRAMEWORK_PACKAGE_ID,
2873            Identifier::new("coin").unwrap(),
2874            Identifier::new("from_balance").unwrap(),
2875            vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
2876            vec![balance],
2877        ));
2878        // Add a second object to transfer — not the shape our parser accepts.
2879        let extra = builder.obj(ObjectArg::ImmOrOwnedObject(other_obj)).unwrap();
2880        let sender_arg = builder.pure(sender).unwrap();
2881        builder.command(NativeCommand::TransferObjects(
2882            vec![coin, extra],
2883            sender_arg,
2884        ));
2885        let ops = parse_pt(sender, builder.finish());
2886        assert_falls_through_to_generic(&ops);
2887    }
2888
2889    #[test]
2890    fn test_parse_falls_through_hybrid_convert_and_redeem() {
2891        // A PTB containing BOTH convert_to_fungible_staked_sui AND redeem_fungible_staked_sui.
2892        // This is an unusual shape — our parsers should reject it (neither Consolidate nor
2893        // MergeAndRedeem shape matches).
2894        let sender = SuiAddress::random_for_testing_only();
2895        let staked = random_object_ref();
2896        let fss = random_object_ref();
2897        let mut builder = ProgrammableTransactionBuilder::new();
2898        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
2899        let staked_arg = builder.obj(ObjectArg::ImmOrOwnedObject(staked)).unwrap();
2900        let _new_fss = builder.command(NativeCommand::move_call(
2901            SUI_SYSTEM_PACKAGE_ID,
2902            Identifier::new("sui_system").unwrap(),
2903            Identifier::new("convert_to_fungible_staked_sui").unwrap(),
2904            vec![],
2905            vec![sys, staked_arg],
2906        ));
2907        let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
2908        let balance = builder.command(NativeCommand::move_call(
2909            SUI_SYSTEM_PACKAGE_ID,
2910            Identifier::new("sui_system").unwrap(),
2911            Identifier::new("redeem_fungible_staked_sui").unwrap(),
2912            vec![],
2913            vec![sys, fss_arg],
2914        ));
2915        let coin = builder.command(NativeCommand::move_call(
2916            SUI_FRAMEWORK_PACKAGE_ID,
2917            Identifier::new("coin").unwrap(),
2918            Identifier::new("from_balance").unwrap(),
2919            vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
2920            vec![balance],
2921        ));
2922        let sender_arg = builder.pure(sender).unwrap();
2923        builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
2924        let ops = parse_pt(sender, builder.finish());
2925        assert_falls_through_to_generic(&ops);
2926    }
2927
2928    #[test]
2929    fn test_parse_falls_through_split_without_redeem() {
2930        let sender = SuiAddress::random_for_testing_only();
2931        let fss = random_object_ref();
2932        let mut builder = ProgrammableTransactionBuilder::new();
2933        let _sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
2934        let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
2935        let split_amount = builder.pure(100u64).unwrap();
2936        builder.command(NativeCommand::move_call(
2937            SUI_SYSTEM_PACKAGE_ID,
2938            Identifier::new("staking_pool").unwrap(),
2939            Identifier::new("split_fungible_staked_sui").unwrap(),
2940            vec![],
2941            vec![fss_arg, split_amount],
2942        ));
2943        // No redeem → shape mismatch.
2944        let ops = parse_pt(sender, builder.finish());
2945        assert_falls_through_to_generic(&ops);
2946    }
2947
2948    #[test]
2949    fn test_parse_falls_through_redeem_split_position_wrong() {
2950        // split appears AFTER redeem (wrong order).
2951        let sender = SuiAddress::random_for_testing_only();
2952        let fss_a = random_object_ref();
2953        let fss_b = random_object_ref();
2954        let mut builder = ProgrammableTransactionBuilder::new();
2955        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
2956        let a_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss_a)).unwrap();
2957        let b_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss_b)).unwrap();
2958        let balance = builder.command(NativeCommand::move_call(
2959            SUI_SYSTEM_PACKAGE_ID,
2960            Identifier::new("sui_system").unwrap(),
2961            Identifier::new("redeem_fungible_staked_sui").unwrap(),
2962            vec![],
2963            vec![sys, a_arg],
2964        ));
2965        // Split AFTER redeem — wrong order.
2966        let split_amount = builder.pure(100u64).unwrap();
2967        builder.command(NativeCommand::move_call(
2968            SUI_SYSTEM_PACKAGE_ID,
2969            Identifier::new("staking_pool").unwrap(),
2970            Identifier::new("split_fungible_staked_sui").unwrap(),
2971            vec![],
2972            vec![b_arg, split_amount],
2973        ));
2974        let coin = builder.command(NativeCommand::move_call(
2975            SUI_FRAMEWORK_PACKAGE_ID,
2976            Identifier::new("coin").unwrap(),
2977            Identifier::new("from_balance").unwrap(),
2978            vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
2979            vec![balance],
2980        ));
2981        let sender_arg = builder.pure(sender).unwrap();
2982        builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
2983        let ops = parse_pt(sender, builder.finish());
2984        assert_falls_through_to_generic(&ops);
2985    }
2986
2987    #[test]
2988    fn test_parse_falls_through_redeem_wrong_system_state_immutable() {
2989        // Build a redeem PTB but pass the system state as immutable shared. Per our
2990        // helper, we can't easily construct ObjectArg::SharedObject with Immutable
2991        // directly — but we can test the case where the first input is SUI_SYSTEM_STATE
2992        // but built via a regular shared-object with immutable mutability. Simplest:
2993        // use an ObjectArg::SharedObject construction.
2994        let sender = SuiAddress::random_for_testing_only();
2995        let fss = random_object_ref();
2996        let mut builder = ProgrammableTransactionBuilder::new();
2997        // Immutable shared — parser should reject.
2998        let _sys = builder
2999            .obj(ObjectArg::SharedObject {
3000                id: SUI_SYSTEM_STATE_OBJECT_ID,
3001                initial_shared_version: sui_types::SUI_SYSTEM_STATE_OBJECT_SHARED_VERSION,
3002                mutability: sui_types::transaction::SharedObjectMutability::Immutable,
3003            })
3004            .unwrap();
3005        let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3006        // The redeem Move call needs a mutable sys — this would fail at chain execution
3007        // but our parser just checks inputs[0] shape.
3008        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3009        let balance = builder.command(NativeCommand::move_call(
3010            SUI_SYSTEM_PACKAGE_ID,
3011            Identifier::new("sui_system").unwrap(),
3012            Identifier::new("redeem_fungible_staked_sui").unwrap(),
3013            vec![],
3014            vec![sys, fss_arg],
3015        ));
3016        let coin = builder.command(NativeCommand::move_call(
3017            SUI_FRAMEWORK_PACKAGE_ID,
3018            Identifier::new("coin").unwrap(),
3019            Identifier::new("from_balance").unwrap(),
3020            vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
3021            vec![balance],
3022        ));
3023        let sender_arg = builder.pure(sender).unwrap();
3024        builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
3025        // Our parser's `first_input_is_sui_system_state` only requires InputKind::Shared +
3026        // object id == 0x5. Both the immutable and mutable shared inputs have kind Shared
3027        // and id 0x5, so this alone might not trigger rejection. The strict-shape check
3028        // will catch it because inputs[0] must be at position 0 — and here we placed the
3029        // immutable shared first; the system_state_mut is input[2] (3rd input), so the
3030        // first input IS our immutable one. Our predicate accepts it (same id). That's
3031        // OK: if chain rejects it, Rosetta's observation is that this was a shape we
3032        // don't strictly match. The assert_falls_through_to_generic below may fail here
3033        // because our parser could accept both. If so, we should tighten the predicate.
3034        // For now we document this behaviour and allow either result.
3035        let ops = parse_pt(sender, builder.finish());
3036        // Accept either: labeled (if shape matched) or generic (if extra commands/inputs
3037        // tripped shape validation). The important invariant is no panic.
3038        assert!(
3039            ops[0].type_ == OperationType::MergeAndRedeemFungibleStakedSui
3040                || ops[0].type_ == OperationType::ProgrammableTransaction,
3041            "unexpected op type: {:?}",
3042            ops[0].type_
3043        );
3044    }
3045
3046    // ==============================================================================
3047    // Phase 2: Additional fall-through tests for PR review tightenings
3048    // ==============================================================================
3049
3050    /// Convert-only PTB WITHOUT the trailing `TransferObjects` — the builder always emits
3051    /// a transfer for S>=1, F=0. A `[convert]` alone leaks a FungibleStakedSui result and
3052    /// would fail on-chain execution. Parser must not label it as Consolidate.
3053    #[test]
3054    fn test_parse_falls_through_convert_without_transfer() {
3055        let sender = SuiAddress::random_for_testing_only();
3056        let staked = random_object_ref();
3057        let mut builder = ProgrammableTransactionBuilder::new();
3058        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3059        let staked_arg = builder.obj(ObjectArg::ImmOrOwnedObject(staked)).unwrap();
3060        let _new_fss = builder.command(NativeCommand::move_call(
3061            SUI_SYSTEM_PACKAGE_ID,
3062            Identifier::new("sui_system").unwrap(),
3063            Identifier::new("convert_to_fungible_staked_sui").unwrap(),
3064            vec![],
3065            vec![sys, staked_arg],
3066        ));
3067        // No TransferObjects — convert's Result is orphaned.
3068        let ops = parse_pt(sender, builder.finish());
3069        assert_falls_through_to_generic(&ops);
3070    }
3071
3072    /// Pure FSS merge with a SPURIOUS `TransferObjects` — the builder never emits a
3073    /// transfer for S=0, F>=2 (existing FSS is already sender-owned). `join` returns unit
3074    /// so the transfer can't reference a meaningful result anyway. Parser must fall through.
3075    #[test]
3076    fn test_parse_falls_through_pure_merge_with_transfer() {
3077        let sender = SuiAddress::random_for_testing_only();
3078        let fss_a = random_object_ref();
3079        let fss_b = random_object_ref();
3080        let mut builder = ProgrammableTransactionBuilder::new();
3081        let _sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3082        let first = builder.obj(ObjectArg::ImmOrOwnedObject(fss_a)).unwrap();
3083        let other = builder.obj(ObjectArg::ImmOrOwnedObject(fss_b)).unwrap();
3084        let join_result = builder.command(NativeCommand::move_call(
3085            SUI_SYSTEM_PACKAGE_ID,
3086            Identifier::new("staking_pool").unwrap(),
3087            Identifier::new("join_fungible_staked_sui").unwrap(),
3088            vec![],
3089            vec![first, other],
3090        ));
3091        // Spurious TransferObjects referencing the join's (unit) result.
3092        let sender_arg = builder.pure(sender).unwrap();
3093        builder.command(NativeCommand::TransferObjects(
3094            vec![join_result],
3095            sender_arg,
3096        ));
3097        let ops = parse_pt(sender, builder.finish());
3098        assert_falls_through_to_generic(&ops);
3099    }
3100
3101    /// `split_fungible_staked_sui`'s amount arg must be a `Pure` u64. Passing an
3102    /// `ImmOrOwnedObject` as the amount slot fails on-chain but previously parse-accepted.
3103    #[test]
3104    fn test_parse_falls_through_split_amount_not_pure() {
3105        let sender = SuiAddress::random_for_testing_only();
3106        let fss = random_object_ref();
3107        let bogus_obj = random_object_ref();
3108        let mut builder = ProgrammableTransactionBuilder::new();
3109        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3110        let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3111        // The "amount" arg is an object ref instead of a Pure u64.
3112        let bogus_arg = builder.obj(ObjectArg::ImmOrOwnedObject(bogus_obj)).unwrap();
3113        let split_result = builder.command(NativeCommand::move_call(
3114            SUI_SYSTEM_PACKAGE_ID,
3115            Identifier::new("staking_pool").unwrap(),
3116            Identifier::new("split_fungible_staked_sui").unwrap(),
3117            vec![],
3118            vec![fss_arg, bogus_arg],
3119        ));
3120        let balance = builder.command(NativeCommand::move_call(
3121            SUI_SYSTEM_PACKAGE_ID,
3122            Identifier::new("sui_system").unwrap(),
3123            Identifier::new("redeem_fungible_staked_sui").unwrap(),
3124            vec![],
3125            vec![sys, split_result],
3126        ));
3127        let coin = builder.command(NativeCommand::move_call(
3128            SUI_FRAMEWORK_PACKAGE_ID,
3129            Identifier::new("coin").unwrap(),
3130            Identifier::new("from_balance").unwrap(),
3131            vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
3132            vec![balance],
3133        ));
3134        let sender_arg = builder.pure(sender).unwrap();
3135        builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
3136        let ops = parse_pt(sender, builder.finish());
3137        assert_falls_through_to_generic(&ops);
3138    }
3139
3140    /// `convert_to_fungible_staked_sui`'s first arg must reference `inputs[0]`
3141    /// (SUI_SYSTEM_STATE). A PTB passing a different input in the system-state slot
3142    /// slips through shape validation before this tightening.
3143    #[test]
3144    fn test_parse_falls_through_convert_wrong_system_state_arg() {
3145        let sender = SuiAddress::random_for_testing_only();
3146        let staked = random_object_ref();
3147        let mut builder = ProgrammableTransactionBuilder::new();
3148        // inputs[0] = SUI_SYSTEM_MUT (passes first_input_is_sui_system_state).
3149        let _sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3150        // inputs[1] = a Pure u64 — we'll put this in the convert's system-state slot
3151        // so arguments[0].input() != 0, triggering the new check.
3152        let bogus_arg = builder.pure(0u64).unwrap();
3153        let staked_arg = builder.obj(ObjectArg::ImmOrOwnedObject(staked)).unwrap();
3154        let new_fss = builder.command(NativeCommand::move_call(
3155            SUI_SYSTEM_PACKAGE_ID,
3156            Identifier::new("sui_system").unwrap(),
3157            Identifier::new("convert_to_fungible_staked_sui").unwrap(),
3158            vec![],
3159            // arguments[0] is bogus_arg (input 1, not input 0) — shape mismatch.
3160            vec![bogus_arg, staked_arg],
3161        ));
3162        let sender_arg = builder.pure(sender).unwrap();
3163        builder.command(NativeCommand::TransferObjects(vec![new_fss], sender_arg));
3164        let ops = parse_pt(sender, builder.finish());
3165        assert_falls_through_to_generic(&ops);
3166    }
3167
3168    /// If a single input appears in BOTH a `convert_fss` call (treated as StakedSui) and
3169    /// a `join_fss` call (treated as FSS), the classification is contradictory. The
3170    /// overlap-rejection mechanism already exists in `parse_consolidate`; this test
3171    /// gives it explicit coverage.
3172    #[test]
3173    fn test_parse_falls_through_consolidate_same_input_both_convert_and_join() {
3174        let sender = SuiAddress::random_for_testing_only();
3175        let shared_input = random_object_ref();
3176        let other_fss = random_object_ref();
3177        let mut builder = ProgrammableTransactionBuilder::new();
3178        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3179        // This single input appears in BOTH roles below.
3180        let dual = builder
3181            .obj(ObjectArg::ImmOrOwnedObject(shared_input))
3182            .unwrap();
3183        let fss_b = builder.obj(ObjectArg::ImmOrOwnedObject(other_fss)).unwrap();
3184        // join(dual, fss_b) — dual is classified as FSS.
3185        builder.command(NativeCommand::move_call(
3186            SUI_SYSTEM_PACKAGE_ID,
3187            Identifier::new("staking_pool").unwrap(),
3188            Identifier::new("join_fungible_staked_sui").unwrap(),
3189            vec![],
3190            vec![dual, fss_b],
3191        ));
3192        // convert(sys, dual) — dual is now also referenced as StakedSui (contradiction).
3193        let new_fss = builder.command(NativeCommand::move_call(
3194            SUI_SYSTEM_PACKAGE_ID,
3195            Identifier::new("sui_system").unwrap(),
3196            Identifier::new("convert_to_fungible_staked_sui").unwrap(),
3197            vec![],
3198            vec![sys, dual],
3199        ));
3200        let sender_arg = builder.pure(sender).unwrap();
3201        builder.command(NativeCommand::TransferObjects(vec![new_fss], sender_arg));
3202        let ops = parse_pt(sender, builder.finish());
3203        assert_falls_through_to_generic(&ops);
3204    }
3205
3206    // ==============================================================================
3207    // PR 2: Metadata serialization compat (4 tests)
3208    // ==============================================================================
3209
3210    #[test]
3211    fn test_meta_merge_redeem_old_input_all() {
3212        let v = SuiAddress::random_for_testing_only();
3213        let json = serde_json::json!({
3214            "MergeAndRedeemFungibleStakedSui": {
3215                "validator": v.to_string(),
3216                "redeem_mode": "All"
3217            }
3218        });
3219        let meta: OperationMetadata = serde_json::from_value(json).unwrap();
3220        match meta {
3221            OperationMetadata::MergeAndRedeemFungibleStakedSui {
3222                validator,
3223                amount,
3224                redeem_mode,
3225                fss_ids,
3226            } => {
3227                assert_eq!(validator, Some(v));
3228                assert!(amount.is_none());
3229                assert_eq!(redeem_mode, Some(RedeemMode::All));
3230                assert!(fss_ids.is_empty());
3231            }
3232            _ => panic!("wrong variant"),
3233        }
3234    }
3235
3236    #[test]
3237    fn test_meta_merge_redeem_old_input_atleast() {
3238        let v = SuiAddress::random_for_testing_only();
3239        let json = serde_json::json!({
3240            "MergeAndRedeemFungibleStakedSui": {
3241                "validator": v.to_string(),
3242                "amount": "500000000000",
3243                "redeem_mode": "AtLeast"
3244            }
3245        });
3246        let meta: OperationMetadata = serde_json::from_value(json).unwrap();
3247        match meta {
3248            OperationMetadata::MergeAndRedeemFungibleStakedSui {
3249                validator,
3250                amount,
3251                redeem_mode,
3252                fss_ids,
3253            } => {
3254                assert_eq!(validator, Some(v));
3255                assert_eq!(amount, Some("500000000000".to_string()));
3256                assert_eq!(redeem_mode, Some(RedeemMode::AtLeast));
3257                assert!(fss_ids.is_empty());
3258            }
3259            _ => panic!(),
3260        }
3261    }
3262
3263    #[test]
3264    fn test_meta_merge_redeem_new_parse_output() {
3265        let id = ObjectID::random();
3266        let meta = OperationMetadata::MergeAndRedeemFungibleStakedSui {
3267            validator: None,
3268            amount: None,
3269            redeem_mode: Some(RedeemMode::All),
3270            fss_ids: vec![id],
3271        };
3272        let json = serde_json::to_value(&meta).unwrap();
3273        let obj = json
3274            .as_object()
3275            .unwrap()
3276            .get("MergeAndRedeemFungibleStakedSui")
3277            .unwrap()
3278            .as_object()
3279            .unwrap();
3280        assert!(!obj.contains_key("validator"));
3281        assert!(!obj.contains_key("amount"));
3282        assert_eq!(obj.get("redeem_mode").unwrap(), "All");
3283        assert_eq!(obj.get("fss_ids").unwrap().as_array().unwrap().len(), 1);
3284    }
3285
3286    #[test]
3287    fn test_meta_merge_redeem_new_parse_output_partial() {
3288        let id = ObjectID::random();
3289        let meta = OperationMetadata::MergeAndRedeemFungibleStakedSui {
3290            validator: None,
3291            amount: None,
3292            redeem_mode: None,
3293            fss_ids: vec![id],
3294        };
3295        let json = serde_json::to_value(&meta).unwrap();
3296        let obj = json
3297            .as_object()
3298            .unwrap()
3299            .get("MergeAndRedeemFungibleStakedSui")
3300            .unwrap()
3301            .as_object()
3302            .unwrap();
3303        assert!(!obj.contains_key("validator"));
3304        assert!(!obj.contains_key("amount"));
3305        assert!(
3306            !obj.contains_key("redeem_mode"),
3307            "redeem_mode must be omitted in partial parse output"
3308        );
3309        assert_eq!(obj.get("fss_ids").unwrap().as_array().unwrap().len(), 1);
3310    }
3311
3312    // ==============================================================================
3313    // PR 2: Write-side preservation (1 test)
3314    // ==============================================================================
3315
3316    #[test]
3317    fn test_write_merge_redeem_requires_validator_and_mode() {
3318        let sender = SuiAddress::random_for_testing_only();
3319
3320        // Case 1: validator = None.
3321        let op = Operation {
3322            operation_identifier: Default::default(),
3323            type_: OperationType::MergeAndRedeemFungibleStakedSui,
3324            status: None,
3325            account: Some(sender.into()),
3326            amount: None,
3327            coin_change: None,
3328            metadata: Some(OperationMetadata::MergeAndRedeemFungibleStakedSui {
3329                validator: None,
3330                amount: None,
3331                redeem_mode: Some(RedeemMode::All),
3332                fss_ids: vec![],
3333            }),
3334        };
3335        let err = Operations::new(vec![op])
3336            .into_internal()
3337            .expect_err("should fail without validator");
3338        assert!(format!("{err}").contains("validator"));
3339
3340        // Case 2: redeem_mode = None.
3341        let op = Operation {
3342            operation_identifier: Default::default(),
3343            type_: OperationType::MergeAndRedeemFungibleStakedSui,
3344            status: None,
3345            account: Some(sender.into()),
3346            amount: None,
3347            coin_change: None,
3348            metadata: Some(OperationMetadata::MergeAndRedeemFungibleStakedSui {
3349                validator: Some(SuiAddress::random_for_testing_only()),
3350                amount: None,
3351                redeem_mode: None,
3352                fss_ids: vec![],
3353            }),
3354        };
3355        let err = Operations::new(vec![op])
3356            .into_internal()
3357            .expect_err("should fail without redeem_mode");
3358        assert!(format!("{err}").contains("redeem_mode"));
3359    }
3360}