Skip to main content

sui_rosetta/
operations.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::collections::{BTreeMap, 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::Transaction as ProtoTransaction;
24use sui_rpc::proto::sui::rpc::v2::TransactionKind;
25use sui_rpc::proto::sui::rpc::v2::argument::ArgumentKind;
26use sui_rpc::proto::sui::rpc::v2::command::Command;
27use sui_rpc::proto::sui::rpc::v2::input::InputKind;
28use sui_rpc::proto::sui::rpc::v2::transaction_kind::Data as TransactionKindData;
29use sui_rpc::proto::sui::rpc::v2::transaction_kind::Kind::ProgrammableTransaction as ProgrammableTransactionKind;
30use sui_types::base_types::{ObjectID, SequenceNumber, SuiAddress};
31use sui_types::gas_coin::GasCoin;
32use sui_types::governance::{ADD_STAKE_FUN_NAME, WITHDRAW_STAKE_FUN_NAME};
33use sui_types::sui_system_state::SUI_SYSTEM_MODULE_NAME;
34use sui_types::{
35    SUI_FRAMEWORK_PACKAGE_ID, SUI_SYSTEM_ADDRESS, SUI_SYSTEM_PACKAGE_ID, SUI_SYSTEM_STATE_OBJECT_ID,
36};
37
38#[cfg(test)]
39use crate::types::RedeemPlan;
40use crate::types::internal_operation::{
41    ConsolidateAllStakedSuiToFungible, MergeAndRedeemFungibleStakedSui, PayCoin, PaySui, Stake,
42    WithdrawStake,
43};
44use crate::types::{
45    AccountIdentifier, Amount, AuxData, CoinAction, CoinChange, CoinID, CoinIdentifier, Currency,
46    InternalOperation, OperationIdentifier, OperationStatus, OperationType, RedeemMode,
47};
48use crate::{CoinMetadataCache, Error, SUI};
49
50#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
51pub struct Operations(Vec<Operation>);
52
53/// Which currency labels a payment-shaped PTB's operations, decided by the
54/// caller and applied by the parser. The parser cannot compute this itself — the
55/// coin type isn't in the PTB; it comes from the `/parse` annotation or from
56/// `balance_changes`.
57#[derive(Clone, Debug)]
58pub(crate) enum PaymentCurrency {
59    /// No non-SUI coin → PaySui ops.
60    Sui,
61    /// Exactly one resolved non-SUI coin → PayCoin(_) ops.
62    NonSui(Currency),
63    /// A non-SUI coin is involved but we can't pin it to one known currency —
64    /// its metadata didn't resolve, or two-plus non-SUI coins were present →
65    /// generic_op.
66    Unresolvable,
67}
68
69/// The currencies a transaction touches, resolved once from `balance_changes`.
70#[derive(Debug)]
71struct TxCurrencies {
72    /// `coin_type → Currency` for every resolved coin; drives the per-coin
73    /// balance-change reporting in the reconciliation pass.
74    by_coin_type: BTreeMap<String, Currency>,
75    /// How to label the payment ops (`Unresolvable` → generic_op).
76    payment: PaymentCurrency,
77}
78
79/// Resolve every coin in `balance_changes` to its `Currency` and, in the same
80/// pass, decide which currency labels the payment. See [`TxCurrencies`] for the
81/// two outputs.
82///
83/// The `payment` label is:
84/// - 0 non-SUI coins → `Sui`
85/// - exactly 1 resolved non-SUI coin → `NonSui`
86/// - ≥2 resolved non-SUI coins, or any coin with no usable metadata →
87///   `Unresolvable` (rosetta's `pay_coin_pt` produces exactly one non-SUI
88///   balance change, so anything else means we can't trust a PayCoin label and
89///   fall through to generic_op rather than guess)
90///
91/// For a non-SUI coin we degrade to `Unresolvable` only when it genuinely has no
92/// usable metadata (empty symbol / NotFound / missing, or a coin type that is not
93/// a struct and so cannot have metadata at all); every other (transient) failure
94/// returns a retriable error so `/block` stalls and retries rather than baking a
95/// generic_op into a block that should have been PayCoin (by-hash idempotency).
96async fn resolve_tx_currencies(
97    balance_changes: &[BalanceChange],
98    cache: &CoinMetadataCache,
99) -> Result<TxCurrencies, Error> {
100    let mut currencies: BTreeMap<String, Currency> = BTreeMap::new();
101    let mut any_unresolvable = false;
102    for balance_change in balance_changes {
103        let coin_type = balance_change.coin_type();
104        // SUI's metadata is fixed and known — insert it directly rather than
105        // spending an RPC per transaction. It stays in the map so SUI balance
106        // changes survive the reconciliation filter; the non-SUI count below
107        // ignores it.
108        if coin_type == SUI.metadata.coin_type {
109            currencies.insert(coin_type.to_string(), SUI.clone());
110            continue;
111        }
112        // `Coin<T>` and `Balance<T>` take an unconstrained phantom `T`, so a
113        // balance change can name a type that is not a struct at all — mainnet
114        // carries `Balance<u64>` accumulator writes. Coin metadata is keyed by
115        // `StructTag`, so such a type can never have metadata and the node
116        // rejects the lookup outright; treat it as unresolvable rather than
117        // spending a round trip on a request that is guaranteed to fail.
118        let Ok(struct_tag) = StructTag::from_str(coin_type) else {
119            tracing::debug!(coin_type, "coin type is not a struct type; generic_op");
120            any_unresolvable = true;
121            continue;
122        };
123        let type_tag = sui_types::TypeTag::Struct(Box::new(struct_tag));
124        // `get_currency` surfaces "this coin has no usable metadata" in three
125        // different shapes, depending on what the upstream node returned and
126        // where it short-circuited: an `Ok` whose symbol is empty (metadata
127        // present but blank), `Err(MissingMetadata)` (response came back but the
128        // symbol/decimals fields were absent), or `Err(SuiRpcError(NotFound))`
129        // (the node answered the lookup with a NotFound status — the common one).
130        // All three mean the same thing to us, so the next three arms collapse
131        // them into the same "degrade to generic_op" outcome.
132        match cache.get_currency(&type_tag).await {
133            Ok(currency) if !currency.symbol.is_empty() => {
134                currencies.insert(coin_type.to_string(), currency);
135            }
136            Ok(_) | Err(Error::MissingMetadata) => {
137                tracing::debug!(coin_type, "non-SUI coin metadata unresolved; generic_op");
138                any_unresolvable = true;
139            }
140            Err(Error::SuiRpcError(status)) if status.code() == tonic::Code::NotFound => {
141                tracing::debug!(coin_type, "non-SUI coin metadata not found; generic_op");
142                any_unresolvable = true;
143            }
144            // Any other error — transient (Unavailable/DeadlineExceeded/...) or an
145            // anomaly like InvalidArgument (we sent a type we'd already validated,
146            // so this shouldn't happen) — is not a clean "no metadata" signal.
147            // Surface it as retriable rather than silently degrading to generic_op.
148            Err(e) => {
149                return Err(Error::CoinMetadataUnavailable(format!(
150                    "resolving coin metadata for {coin_type}: {e}"
151                )));
152            }
153        }
154    }
155
156    let non_sui: Vec<&Currency> = currencies
157        .values()
158        .filter(|c| c.metadata.coin_type != SUI.metadata.coin_type)
159        .collect();
160    let payment = if any_unresolvable {
161        PaymentCurrency::Unresolvable
162    } else {
163        match non_sui.as_slice() {
164            [] => PaymentCurrency::Sui,
165            [c] => PaymentCurrency::NonSui((*c).clone()),
166            many => {
167                // /block indexes the entire chain history, not just rosetta txns,
168                // so multi-coin txns (swaps, multi-sends) are expected.
169                tracing::debug!(
170                    non_sui_count = many.len(),
171                    "multiple non-SUI currencies in balance changes; emitting \
172                     generic_op rather than guessing PayCoin label"
173                );
174                PaymentCurrency::Unresolvable
175            }
176        }
177    };
178    Ok(TxCurrencies {
179        by_coin_type: currencies,
180        payment,
181    })
182}
183
184impl FromIterator<Operation> for Operations {
185    fn from_iter<T: IntoIterator<Item = Operation>>(iter: T) -> Self {
186        Operations::new(iter.into_iter().collect())
187    }
188}
189
190impl FromIterator<Vec<Operation>> for Operations {
191    fn from_iter<T: IntoIterator<Item = Vec<Operation>>>(iter: T) -> Self {
192        iter.into_iter().flatten().collect()
193    }
194}
195
196impl IntoIterator for Operations {
197    type Item = Operation;
198    type IntoIter = vec::IntoIter<Operation>;
199    fn into_iter(self) -> Self::IntoIter {
200        self.0.into_iter()
201    }
202}
203
204impl Operations {
205    pub fn new(mut ops: Vec<Operation>) -> Self {
206        for (index, op) in ops.iter_mut().enumerate() {
207            op.operation_identifier = (index as u64).into()
208        }
209        Self(ops)
210    }
211
212    pub fn contains(&self, other: &Operations) -> bool {
213        for (i, other_op) in other.0.iter().enumerate() {
214            if let Some(op) = self.0.get(i) {
215                if op != other_op {
216                    return false;
217                }
218            } else {
219                return false;
220            }
221        }
222        true
223    }
224
225    pub fn set_status(mut self, status: Option<OperationStatus>) -> Self {
226        for op in &mut self.0 {
227            op.status = status
228        }
229        self
230    }
231
232    pub fn type_(&self) -> Option<OperationType> {
233        self.0.first().map(|op| op.type_)
234    }
235
236    /// Parse operation input from rosetta operation to intermediate internal operation;
237    pub fn into_internal(self) -> Result<InternalOperation, Error> {
238        let type_ = self
239            .type_()
240            .ok_or_else(|| Error::MissingInput("Operation type".into()))?;
241        match type_ {
242            OperationType::PaySui => self.pay_sui_ops_to_internal(),
243            OperationType::PayCoin => self.pay_coin_ops_to_internal(),
244            OperationType::Stake => self.stake_ops_to_internal(),
245            OperationType::WithdrawStake => self.withdraw_stake_ops_to_internal(),
246            OperationType::ConsolidateAllStakedSuiToFungible => {
247                self.consolidate_to_fungible_ops_to_internal()
248            }
249            OperationType::MergeAndRedeemFungibleStakedSui => {
250                self.merge_and_redeem_fss_ops_to_internal()
251            }
252            op => Err(Error::UnsupportedOperation(op)),
253        }
254    }
255
256    fn pay_sui_ops_to_internal(self) -> Result<InternalOperation, Error> {
257        let mut recipients = vec![];
258        let mut amounts = vec![];
259        let mut sender = None;
260        for op in self {
261            if let (Some(amount), Some(account)) = (op.amount.clone(), op.account.clone()) {
262                if amount.value.is_negative() {
263                    sender = Some(account.address)
264                } else {
265                    recipients.push(account.address);
266                    let amount = amount.value.abs();
267                    if amount > u64::MAX as i128 {
268                        return Err(Error::InvalidInput(
269                            "Input amount exceed u64::MAX".to_string(),
270                        ));
271                    }
272                    amounts.push(amount as u64)
273                }
274            }
275        }
276        let sender = sender.ok_or_else(|| Error::MissingInput("Sender address".to_string()))?;
277        Ok(InternalOperation::PaySui(PaySui {
278            sender,
279            recipients,
280            amounts,
281        }))
282    }
283
284    fn pay_coin_ops_to_internal(self) -> Result<InternalOperation, Error> {
285        let mut recipients = vec![];
286        let mut amounts = vec![];
287        let mut sender = None;
288        let mut currency = None;
289        for op in self {
290            if let (Some(amount), Some(account)) = (op.amount.clone(), op.account.clone()) {
291                currency = currency.or(Some(amount.currency));
292                if amount.value.is_negative() {
293                    sender = Some(account.address)
294                } else {
295                    recipients.push(account.address);
296                    let amount = amount.value.abs();
297                    if amount > u64::MAX as i128 {
298                        return Err(Error::InvalidInput(
299                            "Input amount exceed u64::MAX".to_string(),
300                        ));
301                    }
302                    amounts.push(amount as u64)
303                }
304            }
305        }
306        let sender = sender.ok_or_else(|| Error::MissingInput("Sender address".to_string()))?;
307        let currency = currency.ok_or_else(|| Error::MissingInput("Currency".to_string()))?;
308        Ok(InternalOperation::PayCoin(PayCoin {
309            sender,
310            recipients,
311            amounts,
312            currency,
313        }))
314    }
315
316    fn stake_ops_to_internal(self) -> Result<InternalOperation, Error> {
317        let mut ops = self
318            .0
319            .into_iter()
320            .filter(|op| op.type_ == OperationType::Stake)
321            .collect::<Vec<_>>();
322        if ops.len() != 1 {
323            return Err(Error::MalformedOperationError(
324                "Delegation should only have one operation.".into(),
325            ));
326        }
327        // Checked above, safe to unwrap.
328        let op = ops.pop().unwrap();
329        let sender = op
330            .account
331            .ok_or_else(|| Error::MissingInput("Sender address".to_string()))?
332            .address;
333        let metadata = op
334            .metadata
335            .ok_or_else(|| Error::MissingInput("Stake metadata".to_string()))?;
336
337        // Total issued SUi is less than u64, safe to cast.
338        let amount = if let Some(amount) = op.amount {
339            if amount.value.is_positive() {
340                return Err(Error::MalformedOperationError(
341                    "Stake amount should be negative.".into(),
342                ));
343            }
344            Some(amount.value.unsigned_abs() as u64)
345        } else {
346            None
347        };
348
349        let OperationMetadata::Stake { validator } = metadata else {
350            return Err(Error::InvalidInput(
351                "Cannot find delegation info from metadata.".into(),
352            ));
353        };
354
355        Ok(InternalOperation::Stake(Stake {
356            sender,
357            validator,
358            amount,
359        }))
360    }
361
362    fn withdraw_stake_ops_to_internal(self) -> Result<InternalOperation, Error> {
363        let mut ops = self
364            .0
365            .into_iter()
366            .filter(|op| op.type_ == OperationType::WithdrawStake)
367            .collect::<Vec<_>>();
368        if ops.len() != 1 {
369            return Err(Error::MalformedOperationError(
370                "Delegation should only have one operation.".into(),
371            ));
372        }
373        // Checked above, safe to unwrap.
374        let op = ops.pop().unwrap();
375        let sender = op
376            .account
377            .ok_or_else(|| Error::MissingInput("Sender address".to_string()))?
378            .address;
379
380        let stake_ids = if let Some(metadata) = op.metadata {
381            let OperationMetadata::WithdrawStake { stake_ids } = metadata else {
382                return Err(Error::InvalidInput(
383                    "Cannot find withdraw stake info from metadata.".into(),
384                ));
385            };
386            stake_ids
387        } else {
388            vec![]
389        };
390
391        Ok(InternalOperation::WithdrawStake(WithdrawStake {
392            sender,
393            stake_ids,
394        }))
395    }
396
397    fn consolidate_to_fungible_ops_to_internal(self) -> Result<InternalOperation, Error> {
398        let mut ops = self
399            .0
400            .into_iter()
401            .filter(|op| op.type_ == OperationType::ConsolidateAllStakedSuiToFungible)
402            .collect::<Vec<_>>();
403        if ops.len() != 1 {
404            return Err(Error::MalformedOperationError(
405                "ConsolidateAllStakedSuiToFungible should only have one operation.".into(),
406            ));
407        }
408        let op = ops.pop().unwrap();
409        let sender = op
410            .account
411            .ok_or_else(|| Error::MissingInput("Sender address".to_string()))?
412            .address;
413        let metadata = op.metadata.ok_or_else(|| {
414            Error::MissingInput("ConsolidateAllStakedSuiToFungible metadata".to_string())
415        })?;
416        let OperationMetadata::ConsolidateAllStakedSuiToFungible { validator, .. } = metadata
417        else {
418            return Err(Error::InvalidInput(
419                "Cannot find validator from ConsolidateAllStakedSuiToFungible metadata.".into(),
420            ));
421        };
422        let validator = validator.ok_or_else(|| {
423            Error::MissingInput("validator required for ConsolidateAllStakedSuiToFungible".into())
424        })?;
425        Ok(InternalOperation::ConsolidateAllStakedSuiToFungible(
426            ConsolidateAllStakedSuiToFungible { sender, validator },
427        ))
428    }
429
430    fn merge_and_redeem_fss_ops_to_internal(self) -> Result<InternalOperation, Error> {
431        let mut ops = self
432            .0
433            .into_iter()
434            .filter(|op| op.type_ == OperationType::MergeAndRedeemFungibleStakedSui)
435            .collect::<Vec<_>>();
436        if ops.len() != 1 {
437            return Err(Error::MalformedOperationError(
438                "MergeAndRedeemFungibleStakedSui should only have one operation.".into(),
439            ));
440        }
441        let op = ops.pop().unwrap();
442        let sender = op
443            .account
444            .ok_or_else(|| Error::MissingInput("Sender address".to_string()))?
445            .address;
446        let metadata = op.metadata.ok_or_else(|| {
447            Error::MissingInput("MergeAndRedeemFungibleStakedSui metadata".to_string())
448        })?;
449        let OperationMetadata::MergeAndRedeemFungibleStakedSui {
450            validator,
451            amount,
452            redeem_mode,
453            ..
454        } = metadata
455        else {
456            return Err(Error::InvalidInput(
457                "Cannot find MergeAndRedeemFungibleStakedSui info from metadata.".into(),
458            ));
459        };
460        let validator = validator.ok_or_else(|| {
461            Error::MissingInput("validator required for MergeAndRedeemFungibleStakedSui".into())
462        })?;
463        let redeem_mode = redeem_mode.ok_or_else(|| {
464            Error::MissingInput("redeem_mode required for MergeAndRedeemFungibleStakedSui".into())
465        })?;
466        let amount = match &redeem_mode {
467            RedeemMode::All => None,
468            _ => {
469                let amount_str = amount.ok_or_else(|| {
470                    Error::MissingInput("amount required for AtLeast/AtMost mode".to_string())
471                })?;
472                let parsed = amount_str
473                    .parse::<u64>()
474                    .map_err(|e| Error::InvalidInput(format!("Invalid amount: {}", e)))?;
475                if parsed == 0 {
476                    return Err(Error::InvalidInput(
477                        "amount must be at least 1 MIST".to_string(),
478                    ));
479                }
480                Some(parsed)
481            }
482        };
483        Ok(InternalOperation::MergeAndRedeemFungibleStakedSui(
484            MergeAndRedeemFungibleStakedSui {
485                sender,
486                validator,
487                amount,
488                redeem_mode,
489            },
490        ))
491    }
492
493    pub(crate) fn from_transaction(
494        tx: TransactionKind,
495        sender: SuiAddress,
496        status: Option<OperationStatus>,
497        currency: PaymentCurrency,
498    ) -> Result<Vec<Operation>, Error> {
499        let TransactionKind { data, kind, .. } = tx;
500        Ok(match data {
501            Some(TransactionKindData::ProgrammableTransaction(pt))
502                if status != Some(OperationStatus::Failure) =>
503            {
504                Self::parse_programmable_transaction(sender, status, pt, currency)?
505            }
506            data => {
507                let mut tx = TransactionKind::default();
508                tx.data = data;
509                tx.kind = kind;
510                vec![Operation::generic_op(status, sender, tx)]
511            }
512        })
513    }
514
515    fn parse_programmable_transaction(
516        sender: SuiAddress,
517        status: Option<OperationStatus>,
518        pt: ProgrammableTransaction,
519        currency: PaymentCurrency,
520    ) -> Result<Vec<Operation>, Error> {
521        #[derive(Debug)]
522        enum KnownValue {
523            GasCoin(u64),
524        }
525        fn resolve_result(
526            known_results: &[Vec<KnownValue>],
527            i: u32,
528            j: u32,
529        ) -> Option<&KnownValue> {
530            known_results
531                .get(i as usize)
532                .and_then(|inner| inner.get(j as usize))
533        }
534        fn split_coins(
535            inputs: &[Input],
536            known_results: &[Vec<KnownValue>],
537            coin: &Argument,
538            amounts: &[Argument],
539        ) -> Option<Vec<KnownValue>> {
540            match coin.kind() {
541                ArgumentKind::Gas => (),
542                ArgumentKind::Result => {
543                    let i = coin.result?;
544                    let subresult_idx = coin.subresult.unwrap_or(0);
545                    let KnownValue::GasCoin(_) = resolve_result(known_results, i, subresult_idx)?;
546                }
547                // Might not be a SUI coin
548                ArgumentKind::Input => (),
549                _ => return None,
550            };
551
552            let amounts = amounts
553                .iter()
554                .map(|amount| {
555                    let value: u64 = match amount.kind() {
556                        ArgumentKind::Input => {
557                            let input_idx = amount.input() as usize;
558                            let input = inputs.get(input_idx)?;
559                            match input.kind() {
560                                InputKind::Pure => {
561                                    let bytes = input.pure();
562                                    bcs::from_bytes(bytes).ok()?
563                                }
564                                _ => return None,
565                            }
566                        }
567                        _ => return None,
568                    };
569                    Some(KnownValue::GasCoin(value))
570                })
571                .collect::<Option<_>>()?;
572            Some(amounts)
573        }
574        fn transfer_object(
575            aggregated_recipients: &mut HashMap<SuiAddress, u64>,
576            inputs: &[Input],
577            known_results: &[Vec<KnownValue>],
578            objs: &[Argument],
579            recipient: &Argument,
580        ) -> Option<Vec<KnownValue>> {
581            let addr = match recipient.kind() {
582                ArgumentKind::Input => {
583                    let input_idx = recipient.input() as usize;
584                    let input = inputs.get(input_idx)?;
585                    match input.kind() {
586                        InputKind::Pure => {
587                            let bytes = input.pure();
588                            bcs::from_bytes::<SuiAddress>(bytes).ok()?
589                        }
590                        _ => return None,
591                    }
592                }
593                _ => return None,
594            };
595            for obj in objs {
596                let i = match obj.kind() {
597                    ArgumentKind::Result => obj.result(),
598                    _ => return None,
599                };
600
601                let subresult_idx = obj.subresult.unwrap_or(0);
602                let KnownValue::GasCoin(value) = resolve_result(known_results, i, subresult_idx)?;
603
604                let aggregate = aggregated_recipients.entry(addr).or_default();
605                *aggregate += value;
606            }
607            Some(vec![])
608        }
609        fn into_balance_passthrough(
610            known_results: &[Vec<KnownValue>],
611            call: &MoveCall,
612        ) -> Option<Vec<KnownValue>> {
613            let args = &call.arguments;
614            if let Some(coin_arg) = args.first() {
615                match coin_arg.kind() {
616                    ArgumentKind::Result => {
617                        let cmd_idx = coin_arg.result?;
618                        let sub_idx = coin_arg.subresult.unwrap_or(0);
619                        let KnownValue::GasCoin(val) =
620                            resolve_result(known_results, cmd_idx, sub_idx)?;
621                        Some(vec![KnownValue::GasCoin(*val)])
622                    }
623                    // Input coin (e.g. remainder send_funds) — value unknown but
624                    // downstream send_funds to sender will ignore it anyway.
625                    _ => Some(vec![KnownValue::GasCoin(0)]),
626                }
627            } else {
628                Some(vec![KnownValue::GasCoin(0)])
629            }
630        }
631        fn send_funds_transfer(
632            aggregated_recipients: &mut HashMap<SuiAddress, u64>,
633            inputs: &[Input],
634            known_results: &[Vec<KnownValue>],
635            call: &MoveCall,
636            sender: SuiAddress,
637        ) -> Option<Vec<KnownValue>> {
638            let args = &call.arguments;
639            if args.len() < 2 {
640                return Some(vec![]);
641            }
642            let balance_arg = &args[0];
643            let recipient_arg = &args[1];
644
645            // Resolve the amount from the source argument
646            let amount = match balance_arg.kind() {
647                ArgumentKind::Result => {
648                    let cmd_idx = balance_arg.result?;
649                    let sub_idx = balance_arg.subresult.unwrap_or(0);
650                    let KnownValue::GasCoin(val) = resolve_result(known_results, cmd_idx, sub_idx)?;
651                    *val
652                }
653                _ => return Some(vec![]),
654            };
655
656            // Resolve recipient address
657            let addr = match recipient_arg.kind() {
658                ArgumentKind::Input => {
659                    let input_idx = recipient_arg.input() as usize;
660                    let input = inputs.get(input_idx)?;
661                    if input.kind() == InputKind::Pure {
662                        bcs::from_bytes::<SuiAddress>(input.pure()).ok()?
663                    } else {
664                        return Some(vec![]);
665                    }
666                }
667                _ => return Some(vec![]),
668            };
669
670            // Only track transfers to non-sender addresses
671            if addr != sender {
672                *aggregated_recipients.entry(addr).or_insert(0) += amount;
673            }
674            Some(vec![])
675        }
676        fn stake_call(
677            inputs: &[Input],
678            known_results: &[Vec<KnownValue>],
679            call: &MoveCall,
680        ) -> Result<Option<(Option<u64>, SuiAddress)>, Error> {
681            let arguments = &call.arguments;
682            let (amount, validator) = match &arguments[..] {
683                [system_state_arg, coin, validator] => {
684                    let amount = match coin.kind() {
685                        ArgumentKind::Result => {
686                            let i = coin
687                                .result
688                                .ok_or_else(|| anyhow!("Result argument missing index"))?;
689                            let KnownValue::GasCoin(value) = resolve_result(known_results, i, 0)
690                                .ok_or_else(|| {
691                                    anyhow!("Cannot resolve Gas coin value at Result({i})")
692                                })?;
693                            value
694                        }
695                        _ => return Ok(None),
696                    };
697                    let system_state_idx = match system_state_arg.kind() {
698                        ArgumentKind::Input => system_state_arg.input(),
699                        _ => return Ok(None),
700                    };
701                    let (some_amount, validator) = match validator.kind() {
702                        // [WORKAROUND] - input ordering hack: validator BEFORE system_state
703                        // means a specific amount; system_state BEFORE validator means stake_all.
704                        ArgumentKind::Input => {
705                            let i = validator.input();
706                            let validator_addr = match inputs.get(i as usize) {
707                                Some(input) if input.kind() == InputKind::Pure => {
708                                    bcs::from_bytes::<SuiAddress>(input.pure()).ok()
709                                }
710                                _ => None,
711                            };
712                            (i < system_state_idx, Ok(validator_addr))
713                        }
714                        _ => return Ok(None),
715                    };
716                    (some_amount.then_some(*amount), validator)
717                }
718                _ => Err(anyhow!(
719                    "Error encountered when extracting arguments from move call, expecting 3 elements, got {}",
720                    arguments.len()
721                ))?,
722            };
723            validator.map(|v| v.map(|v| (amount, v)))
724        }
725
726        fn unstake_call(inputs: &[Input], call: &MoveCall) -> Result<Option<ObjectID>, Error> {
727            let arguments = &call.arguments;
728            let id = match &arguments[..] {
729                [system_state_arg, stake_id] => match stake_id.kind() {
730                    ArgumentKind::Input => {
731                        let i = stake_id.input();
732                        let id = match inputs.get(i as usize) {
733                            Some(input) if input.kind() == InputKind::ImmutableOrOwned => input
734                                .object_id
735                                .as_ref()
736                                .and_then(|oid| ObjectID::from_str(oid).ok()),
737                            _ => None,
738                        }
739                        .ok_or_else(|| anyhow!("Cannot find stake id from input args."))?;
740                        // [WORKAROUND] - input ordering hack: system_state BEFORE stake_id
741                        // means specific stake IDs; stake_id BEFORE system_state means withdraw_all.
742                        let system_state_idx = match system_state_arg.kind() {
743                            ArgumentKind::Input => system_state_arg.input(),
744                            _ => return Ok(None),
745                        };
746                        let some_id = system_state_idx < i;
747                        some_id.then_some(id)
748                    }
749                    _ => None,
750                },
751                _ => Err(anyhow!(
752                    "Error encountered when extracting arguments from move call, expecting 2 elements, got {}",
753                    arguments.len()
754                ))?,
755            };
756            Ok(id)
757        }
758        let inputs = &pt.inputs;
759        let commands = &pt.commands;
760        let mut known_results: Vec<Vec<KnownValue>> = vec![];
761        let mut aggregated_recipients: HashMap<SuiAddress, u64> = HashMap::new();
762        let mut needs_generic = false;
763        let mut operations = vec![];
764        let mut stake_ids = vec![];
765
766        // Detect FSS consolidation/redemption PTBs by signature MoveCalls.
767        // Order matters: a PTB with `redeem_fss` is always MergeAndRedeem (Consolidate
768        // never redeems), so we check redeem first. A PTB with `convert_fss` is always
769        // Consolidate (MergeAndRedeem never converts).
770        let has_redeem_fss = commands.iter().any(|c| {
771            matches!(
772                &c.command,
773                Some(Command::MoveCall(m)) if Self::is_redeem_fss_call(m)
774            )
775        });
776        let has_convert_fss = commands.iter().any(|c| {
777            matches!(
778                &c.command,
779                Some(Command::MoveCall(m)) if Self::is_convert_to_fss_call(m)
780            )
781        });
782        let has_join_fss = commands.iter().any(|c| {
783            matches!(
784                &c.command,
785                Some(Command::MoveCall(m)) if Self::is_join_fss_call(m)
786            )
787        });
788        if has_redeem_fss
789            && let Some(ops) = Self::parse_merge_and_redeem(sender, inputs, commands, status)
790        {
791            return Ok(ops);
792        }
793        if !has_redeem_fss
794            && (has_convert_fss || has_join_fss)
795            && let Some(ops) = Self::parse_consolidate(sender, inputs, commands, status)
796        {
797            return Ok(ops);
798        }
799        // If any FSS MoveCall was present but the corresponding sub-parser returned None,
800        // we fall through; the unrecognized MoveCalls hit `_ => None` and emit a generic_op.
801
802        for command in commands {
803            let result = match &command.command {
804                Some(Command::SplitCoins(split)) => {
805                    let coin = split.coin();
806                    split_coins(inputs, &known_results, coin, &split.amounts)
807                }
808                Some(Command::TransferObjects(transfer)) => {
809                    let addr = transfer.address();
810                    transfer_object(
811                        &mut aggregated_recipients,
812                        inputs,
813                        &known_results,
814                        &transfer.objects,
815                        addr,
816                    )
817                }
818                Some(Command::MoveCall(m)) if Self::is_stake_call(m) => {
819                    stake_call(inputs, &known_results, m)?.map(|(amount, validator)| {
820                        let amount = amount.map(|amount| Amount::new(-(amount as i128), None));
821                        operations.push(Operation {
822                            operation_identifier: Default::default(),
823                            type_: OperationType::Stake,
824                            status,
825                            account: Some(sender.into()),
826                            amount,
827                            coin_change: None,
828                            metadata: Some(OperationMetadata::Stake { validator }),
829                        });
830                        vec![]
831                    })
832                }
833                Some(Command::MoveCall(m)) if Self::is_unstake_call(m) => {
834                    let stake_id = unstake_call(inputs, m)?;
835                    stake_ids.push(stake_id);
836                    Some(vec![])
837                }
838                Some(Command::MergeCoins(_)) => {
839                    // We don't care about merge-coins, we can just skip it.
840                    Some(vec![])
841                }
842                // coin::redeem_funds produces a Coin from an address-balance withdrawal —
843                // must return a KnownValue so downstream SplitCoins can resolve its source.
844                Some(Command::MoveCall(m)) if Self::is_coin_redeem_funds_call(m) => {
845                    Some(vec![KnownValue::GasCoin(0)])
846                }
847                Some(Command::MoveCall(m)) if Self::is_coin_into_balance_call(m) => {
848                    into_balance_passthrough(&known_results, m)
849                }
850                Some(Command::MoveCall(m))
851                    if Self::is_balance_send_funds_call(m) || Self::is_coin_send_funds_call(m) =>
852                {
853                    send_funds_transfer(
854                        &mut aggregated_recipients,
855                        inputs,
856                        &known_results,
857                        m,
858                        sender,
859                    )
860                }
861                Some(Command::MoveCall(m))
862                    if Self::is_coin_destroy_zero_call(m) || Self::is_balance_join_call(m) =>
863                {
864                    Some(vec![])
865                }
866                _ => None,
867            };
868            if let Some(result) = result {
869                known_results.push(result)
870            } else {
871                needs_generic = true;
872                break;
873            }
874        }
875
876        // Drop the address-balance "change" artifact. A payment funded from
877        // address balance withdraws a coin, splits off the amount paid, and
878        // transfers the leftover back to the sender. The parser models the
879        // withdrawn coin as value 0 (it derives the sender's debit from the
880        // recipient totals instead), so that leftover transfer shows up as a
881        // meaningless `(sender, 0)` self-payment. Drop it.
882        aggregated_recipients.retain(|recipient, amount| !(*recipient == sender && *amount == 0));
883
884        if !needs_generic
885            && !matches!(currency, PaymentCurrency::Unresolvable)
886            && !aggregated_recipients.is_empty()
887        {
888            let total_paid: u64 = aggregated_recipients.values().copied().sum();
889            operations.extend(
890                aggregated_recipients
891                    .into_iter()
892                    .map(|(recipient, amount)| {
893                        match &currency {
894                            PaymentCurrency::NonSui(c) => Operation::pay_coin(
895                                status,
896                                recipient,
897                                amount.into(),
898                                Some(c.clone()),
899                            ),
900                            // Sui; Unresolvable is gated out by the `if` above.
901                            _ => Operation::pay_sui(status, recipient, amount.into()),
902                        }
903                    }),
904            );
905            match &currency {
906                PaymentCurrency::NonSui(c) => operations.push(Operation::pay_coin(
907                    status,
908                    sender,
909                    -(total_paid as i128),
910                    Some(c.clone()),
911                )),
912                _ => operations.push(Operation::pay_sui(status, sender, -(total_paid as i128))),
913            }
914        } else if !stake_ids.is_empty() {
915            let stake_ids = stake_ids.into_iter().flatten().collect::<Vec<_>>();
916            let metadata = stake_ids
917                .is_empty()
918                .not()
919                .then_some(OperationMetadata::WithdrawStake { stake_ids });
920            operations.push(Operation {
921                operation_identifier: Default::default(),
922                type_: OperationType::WithdrawStake,
923                status,
924                account: Some(sender.into()),
925                amount: None,
926                coin_change: None,
927                metadata,
928            });
929        } else if operations.is_empty() {
930            let tx_kind = TransactionKind::default()
931                .with_kind(ProgrammableTransactionKind)
932                .with_programmable_transaction(pt);
933            operations.push(Operation::generic_op(status, sender, tx_kind))
934        }
935        Ok(operations)
936    }
937
938    /// Parse a PTB that represents `ConsolidateAllStakedSuiToFungible`.
939    ///
940    /// Accepts three valid shapes produced by `consolidate_to_fungible_pt`:
941    /// 1. Pure FSS merge (S=0, F>=2): only `join_fungible_staked_sui` calls, no convert, no transfer.
942    /// 2. Convert-only (S>=1, F=0): convert(s) + optional new-FSS joins + trailing `TransferObjects` to sender.
943    /// 3. Mixed (S>=1, F>=1): existing-FSS joins + convert(s) + new-FSS joins + cross-merge join, no transfer.
944    ///
945    /// Returns `None` on any shape mismatch, causing the caller to fall through to generic op emission.
946    fn parse_consolidate(
947        sender: SuiAddress,
948        inputs: &[Input],
949        commands: &[sui_rpc::proto::sui::rpc::v2::Command],
950        status: Option<OperationStatus>,
951    ) -> Option<Vec<Operation>> {
952        use std::collections::BTreeSet;
953
954        if !Self::first_input_is_sui_system_state(inputs) {
955            return None;
956        }
957
958        let mut staked_sui_indices: Vec<u32> = Vec::new();
959        let mut fss_indices: Vec<u32> = Vec::new();
960        let mut staked_seen: BTreeSet<u32> = BTreeSet::new();
961        let mut fss_seen: BTreeSet<u32> = BTreeSet::new();
962        let mut saw_transfer = false;
963
964        for (idx, command) in commands.iter().enumerate() {
965            if saw_transfer {
966                return None;
967            }
968            match &command.command {
969                Some(Command::MoveCall(m)) if Self::is_convert_to_fss_call(m) => {
970                    if m.arguments.len() != 2 {
971                        return None;
972                    }
973                    // arguments[0] must reference inputs[0] (the SUI_SYSTEM_STATE shared input,
974                    // verified by first_input_is_sui_system_state above). Reject any other shape.
975                    if m.arguments[0].kind() != ArgumentKind::Input || m.arguments[0].input() != 0 {
976                        return None;
977                    }
978                    let staked_arg = &m.arguments[1];
979                    if staked_arg.kind() != ArgumentKind::Input {
980                        return None;
981                    }
982                    let i = staked_arg.input();
983                    if fss_seen.contains(&i) {
984                        return None;
985                    }
986                    if staked_seen.insert(i) {
987                        staked_sui_indices.push(i);
988                    }
989                }
990                Some(Command::MoveCall(m)) if Self::is_join_fss_call(m) => {
991                    if m.arguments.len() != 2 {
992                        return None;
993                    }
994                    for arg in &m.arguments {
995                        match arg.kind() {
996                            ArgumentKind::Input => {
997                                let i = arg.input();
998                                if staked_seen.contains(&i) {
999                                    return None;
1000                                }
1001                                if fss_seen.insert(i) {
1002                                    fss_indices.push(i);
1003                                }
1004                            }
1005                            ArgumentKind::Result => {}
1006                            _ => return None,
1007                        }
1008                    }
1009                }
1010                Some(Command::TransferObjects(transfer)) => {
1011                    if transfer.objects.len() != 1 {
1012                        return None;
1013                    }
1014                    if transfer.objects[0].kind() != ArgumentKind::Result {
1015                        return None;
1016                    }
1017                    let addr_arg = transfer.address();
1018                    if addr_arg.kind() != ArgumentKind::Input {
1019                        return None;
1020                    }
1021                    let recipient = inputs.get(addr_arg.input() as usize).and_then(|inp| {
1022                        if inp.kind() == InputKind::Pure {
1023                            bcs::from_bytes::<SuiAddress>(inp.pure()).ok()
1024                        } else {
1025                            None
1026                        }
1027                    })?;
1028                    if recipient != sender {
1029                        return None;
1030                    }
1031                    if idx + 1 != commands.len() {
1032                        return None;
1033                    }
1034                    saw_transfer = true;
1035                }
1036                _ => return None,
1037            }
1038        }
1039
1040        if staked_sui_indices.is_empty() && fss_indices.is_empty() {
1041            return None;
1042        }
1043
1044        // Invariant: TransferObjects is present iff F=0 && S>=1 (convert-only shape).
1045        // - convert-only (S>=1, F=0): builder emits trailing TransferObjects to sender.
1046        // - cross-merge (S>=1, F>=1): builder merges new FSS into existing; no transfer.
1047        // - pure FSS merge (S=0, F>=2): existing FSS already sender-owned; no transfer.
1048        // A mismatch indicates a non-executable shape that the builder never produces.
1049        let expect_transfer = !staked_sui_indices.is_empty() && fss_indices.is_empty();
1050        if expect_transfer != saw_transfer {
1051            return None;
1052        }
1053
1054        let staked_sui_ids = Self::input_indices_to_object_ids(inputs, &staked_sui_indices)?;
1055        let fss_ids = Self::input_indices_to_object_ids(inputs, &fss_indices)?;
1056
1057        Some(vec![Operation {
1058            operation_identifier: Default::default(),
1059            type_: OperationType::ConsolidateAllStakedSuiToFungible,
1060            status,
1061            account: Some(sender.into()),
1062            amount: None,
1063            coin_change: None,
1064            metadata: Some(OperationMetadata::ConsolidateAllStakedSuiToFungible {
1065                validator: None,
1066                staked_sui_ids,
1067                fss_ids,
1068            }),
1069        }])
1070    }
1071
1072    /// Parse a PTB that represents `MergeAndRedeemFungibleStakedSui`.
1073    ///
1074    /// Recognized shapes (all produced by `merge_and_redeem_fss_pt`):
1075    /// 1. `All`: `[join_fss]*, redeem_fss, coin::from_balance<SUI>, TransferObjects`
1076    /// 2. Partial without guard: `[join_fss]*, split_fss, redeem_fss, coin::from_balance<SUI>, TransferObjects`
1077    /// 3. `AtLeast`: `[join_fss]*, split_fss, redeem_fss, balance::split<SUI>, balance::join<SUI>, coin::from_balance<SUI>, TransferObjects`
1078    ///
1079    /// The `balance::split + balance::join` pair after `redeem_fss` is the AtLeast
1080    /// runtime guard: the chain-side `balance::split(min_sui)` aborts if the
1081    /// redeemed balance is below `min_sui`, then the join restores the original
1082    /// balance for `coin::from_balance` to consume in full. The parser also
1083    /// verifies that this guard's arguments are wired to the actual redeem
1084    /// result (not an unrelated `Balance<SUI>`) — see `is_result_of`.
1085    ///
1086    /// Emits:
1087    /// * `Some(All)` when no `split_fungible_staked_sui` is present.
1088    /// * `Some(AtLeast)` + `metadata.amount = Some(min_sui)` when a
1089    ///   `split_fungible_staked_sui` plus correctly-wired `balance::split +
1090    ///   balance::join` guard pair are present. `min_sui` is decoded from the
1091    ///   pure u64 input to `balance::split`.
1092    /// * `redeem_mode = None` when a `split_fungible_staked_sui` is present
1093    ///   without the balance guard. This corresponds to a partial redeem whose
1094    ///   user-facing intent (`AtMost(max_sui)` vs older builders that didn't
1095    ///   add a guard) cannot be recovered from PTB bytes alone — only the
1096    ///   token count is encoded, not the original `max_sui` cap.
1097    ///
1098    /// Returns `None` on any shape mismatch, causing fall-through to generic op.
1099    fn parse_merge_and_redeem(
1100        sender: SuiAddress,
1101        inputs: &[Input],
1102        commands: &[sui_rpc::proto::sui::rpc::v2::Command],
1103        status: Option<OperationStatus>,
1104    ) -> Option<Vec<Operation>> {
1105        use std::collections::BTreeSet;
1106
1107        if !Self::first_input_is_sui_system_state(inputs) {
1108            return None;
1109        }
1110
1111        #[derive(PartialEq, Eq)]
1112        enum Phase {
1113            Joins,
1114            AfterSplit,
1115            AfterRedeem,
1116            AfterBalanceSplit,
1117            AfterBalanceJoin,
1118            AfterFromBalance,
1119            Done,
1120        }
1121
1122        let mut phase = Phase::Joins;
1123        let mut fss_indices: Vec<u32> = Vec::new();
1124        let mut fss_seen: BTreeSet<u32> = BTreeSet::new();
1125        let mut has_split_fss = false;
1126        let mut has_balance_guard = false;
1127        let mut min_sui_recovered: Option<u64> = None;
1128        // Command indices used to verify the AtLeast guard wires correctly:
1129        // balance::split must consume the redeem result, balance::join must
1130        // consume the redeem result and the split result, and the final
1131        // coin::from_balance must consume the redeem result.
1132        let mut redeem_cmd_idx: Option<u32> = None;
1133        let mut balance_split_cmd_idx: Option<u32> = None;
1134        let mut coin_from_balance_cmd_idx: Option<u32> = None;
1135
1136        for (idx, command) in commands.iter().enumerate() {
1137            if phase == Phase::Done {
1138                return None;
1139            }
1140            match &command.command {
1141                Some(Command::MoveCall(m)) if Self::is_join_fss_call(m) => {
1142                    if phase != Phase::Joins {
1143                        return None;
1144                    }
1145                    if m.arguments.len() != 2 {
1146                        return None;
1147                    }
1148                    for arg in &m.arguments {
1149                        match arg.kind() {
1150                            ArgumentKind::Input => {
1151                                let i = arg.input();
1152                                if fss_seen.insert(i) {
1153                                    fss_indices.push(i);
1154                                }
1155                            }
1156                            ArgumentKind::Result => {}
1157                            _ => return None,
1158                        }
1159                    }
1160                }
1161                Some(Command::MoveCall(m)) if Self::is_split_fss_call(m) => {
1162                    if phase != Phase::Joins {
1163                        return None;
1164                    }
1165                    if m.arguments.len() != 2 {
1166                        return None;
1167                    }
1168                    let first = &m.arguments[0];
1169                    match first.kind() {
1170                        ArgumentKind::Input => {
1171                            let i = first.input();
1172                            if fss_seen.insert(i) {
1173                                fss_indices.push(i);
1174                            }
1175                        }
1176                        ArgumentKind::Result => {}
1177                        _ => return None,
1178                    }
1179                    if m.arguments[1].kind() != ArgumentKind::Input {
1180                        return None;
1181                    }
1182                    let amount_idx = m.arguments[1].input() as usize;
1183                    if inputs.get(amount_idx).map(|i| i.kind()) != Some(InputKind::Pure) {
1184                        return None;
1185                    }
1186                    has_split_fss = true;
1187                    phase = Phase::AfterSplit;
1188                }
1189                Some(Command::MoveCall(m)) if Self::is_redeem_fss_call(m) => {
1190                    if phase != Phase::Joins && phase != Phase::AfterSplit {
1191                        return None;
1192                    }
1193                    if m.arguments.len() != 2 {
1194                        return None;
1195                    }
1196                    if m.arguments[0].kind() != ArgumentKind::Input || m.arguments[0].input() != 0 {
1197                        return None;
1198                    }
1199                    let fss_arg = &m.arguments[1];
1200                    match fss_arg.kind() {
1201                        ArgumentKind::Input => {
1202                            let i = fss_arg.input();
1203                            if fss_seen.insert(i) {
1204                                fss_indices.push(i);
1205                            }
1206                        }
1207                        ArgumentKind::Result => {}
1208                        _ => return None,
1209                    }
1210                    redeem_cmd_idx = Some(idx as u32);
1211                    phase = Phase::AfterRedeem;
1212                }
1213                Some(Command::MoveCall(m)) if Self::is_balance_split_sui_call(m) => {
1214                    if phase != Phase::AfterRedeem {
1215                        return None;
1216                    }
1217                    if m.arguments.len() != 2 {
1218                        return None;
1219                    }
1220                    // arg[0] must be the redeem result we just produced.
1221                    if !Self::is_result_of(&m.arguments[0], redeem_cmd_idx) {
1222                        return None;
1223                    }
1224                    // arg[1] must be a Pure u64 split amount.
1225                    if m.arguments[1].kind() != ArgumentKind::Input {
1226                        return None;
1227                    }
1228                    let amount_idx = m.arguments[1].input() as usize;
1229                    let pure_input = inputs.get(amount_idx)?;
1230                    if pure_input.kind() != InputKind::Pure {
1231                        return None;
1232                    }
1233                    // Decode min_sui from the Pure u64 input. Failure here means
1234                    // the PTB carries a malformed split amount; fall through.
1235                    let min_sui = bcs::from_bytes::<u64>(pure_input.pure()).ok()?;
1236                    min_sui_recovered = Some(min_sui);
1237                    balance_split_cmd_idx = Some(idx as u32);
1238                    phase = Phase::AfterBalanceSplit;
1239                }
1240                Some(Command::MoveCall(m)) if Self::is_balance_join_sui_call(m) => {
1241                    if phase != Phase::AfterBalanceSplit {
1242                        return None;
1243                    }
1244                    if m.arguments.len() != 2 {
1245                        return None;
1246                    }
1247                    // arg[0] must be the redeem result; arg[1] must be the
1248                    // balance::split result. Otherwise the guard isn't actually
1249                    // protecting the redeemed balance — could be a different
1250                    // sub-balance, which means the parser cannot claim AtLeast.
1251                    if !Self::is_result_of(&m.arguments[0], redeem_cmd_idx) {
1252                        return None;
1253                    }
1254                    if !Self::is_result_of(&m.arguments[1], balance_split_cmd_idx) {
1255                        return None;
1256                    }
1257                    has_balance_guard = true;
1258                    phase = Phase::AfterBalanceJoin;
1259                }
1260                Some(Command::MoveCall(m)) if Self::is_coin_from_balance_sui_call(m) => {
1261                    if phase != Phase::AfterRedeem && phase != Phase::AfterBalanceJoin {
1262                        return None;
1263                    }
1264                    if m.arguments.len() != 1 {
1265                        return None;
1266                    }
1267                    // The Coin<SUI> handed to TransferObjects must be derived
1268                    // from the redeem result, not from some other Balance.
1269                    if !Self::is_result_of(&m.arguments[0], redeem_cmd_idx) {
1270                        return None;
1271                    }
1272                    coin_from_balance_cmd_idx = Some(idx as u32);
1273                    phase = Phase::AfterFromBalance;
1274                }
1275                Some(Command::TransferObjects(transfer)) => {
1276                    if phase != Phase::AfterFromBalance {
1277                        return None;
1278                    }
1279                    if transfer.objects.len() != 1 {
1280                        return None;
1281                    }
1282                    // The single transferred object must be the Coin<SUI>
1283                    // produced by `coin::from_balance` — anything else means
1284                    // the chain redeemed but the user's wallet doesn't get
1285                    // those funds, so this PTB is not a recognizable
1286                    // MergeAndRedeem operation.
1287                    if !Self::is_result_of(&transfer.objects[0], coin_from_balance_cmd_idx) {
1288                        return None;
1289                    }
1290                    let addr_arg = transfer.address();
1291                    if addr_arg.kind() != ArgumentKind::Input {
1292                        return None;
1293                    }
1294                    let recipient = inputs.get(addr_arg.input() as usize).and_then(|inp| {
1295                        if inp.kind() == InputKind::Pure {
1296                            bcs::from_bytes::<SuiAddress>(inp.pure()).ok()
1297                        } else {
1298                            None
1299                        }
1300                    })?;
1301                    if recipient != sender {
1302                        return None;
1303                    }
1304                    if idx + 1 != commands.len() {
1305                        return None;
1306                    }
1307                    phase = Phase::Done;
1308                }
1309                _ => return None,
1310            }
1311        }
1312
1313        if phase != Phase::Done {
1314            return None;
1315        }
1316        if fss_indices.is_empty() {
1317            return None;
1318        }
1319
1320        let fss_ids = Self::input_indices_to_object_ids(inputs, &fss_indices)?;
1321        // PTB → metadata mapping:
1322        //   no split, no guard         → All (amount = None) — could also be
1323        //                                full-redeem AtMost since `max_sui` isn't
1324        //                                encoded in PTB bytes; reporting All is
1325        //                                acceptable because the user got "at most
1326        //                                everything they had".
1327        //   split + balance guard      → AtLeast, amount = min_sui from balance::split
1328        //   no split + balance guard   → full-redeem AtLeast (binary search picked
1329        //                                exactly total_tokens, so the PTB skips
1330        //                                `split_fungible_staked_sui` to avoid
1331        //                                leaving zero-value FSS dust). Still
1332        //                                emits AtLeast + recovered min_sui.
1333        //   split, no guard            → unknown partial mode (None) — the PTB only
1334        //                                encodes token_count, not max_sui, so we
1335        //                                cannot round-trip an AtMost cap from bytes.
1336        let (redeem_mode, amount) = match (has_split_fss, has_balance_guard) {
1337            (false, false) => (Some(RedeemMode::All), None),
1338            (true, true) | (false, true) => (
1339                Some(RedeemMode::AtLeast),
1340                min_sui_recovered.map(|v| v.to_string()),
1341            ),
1342            (true, false) => (None, None),
1343        };
1344
1345        Some(vec![Operation {
1346            operation_identifier: Default::default(),
1347            type_: OperationType::MergeAndRedeemFungibleStakedSui,
1348            status,
1349            account: Some(sender.into()),
1350            amount: None,
1351            coin_change: None,
1352            metadata: Some(OperationMetadata::MergeAndRedeemFungibleStakedSui {
1353                validator: None,
1354                amount,
1355                redeem_mode,
1356                fss_ids,
1357            }),
1358        }])
1359    }
1360
1361    /// Returns true iff inputs[0] is a `SharedObject` reference to the SUI_SYSTEM_STATE (0x5).
1362    ///
1363    /// Note on mutability: the Move functions `convert_to_fungible_staked_sui` and
1364    /// `redeem_fungible_staked_sui` take `&mut SuiSystemState`, so the chain will reject
1365    /// immutable shared references at execution time. This check is therefore sufficient
1366    /// without an explicit mutable-shared flag.
1367    fn first_input_is_sui_system_state(inputs: &[Input]) -> bool {
1368        let Some(first) = inputs.first() else {
1369            return false;
1370        };
1371        if first.kind() != InputKind::Shared {
1372            return false;
1373        }
1374        let Some(oid_str) = first.object_id.as_ref() else {
1375            return false;
1376        };
1377        let Ok(oid) = ObjectID::from_str(oid_str) else {
1378            return false;
1379        };
1380        oid == SUI_SYSTEM_STATE_OBJECT_ID
1381    }
1382
1383    /// Returns true iff `arg` is exactly `Result(expected_idx)` — *not*
1384    /// `NestedResult(expected_idx, j)`. Used to verify dataflow linkage in
1385    /// `parse_merge_and_redeem` — for example, that `balance::split` actually
1386    /// consumes the result of `redeem_fss` rather than some unrelated
1387    /// `Balance<SUI>` that happens to be in scope.
1388    ///
1389    /// Both `Argument::Result` and `Argument::NestedResult` map to
1390    /// `ArgumentKind::Result` in the proto encoding (see
1391    /// `sui-types/src/rpc_proto_conversions.rs:2811-2826`); only the
1392    /// `subresult` field distinguishes them. A crafted PTB using
1393    /// `NestedResult(redeem_idx, 1)` would otherwise slip past kind/result
1394    /// checks even though chain execution would reject it.
1395    fn is_result_of(arg: &Argument, expected_idx: Option<u32>) -> bool {
1396        let Some(expected) = expected_idx else {
1397            return false;
1398        };
1399        arg.kind() == ArgumentKind::Result
1400            && arg.result() == expected
1401            && arg.subresult_opt().is_none()
1402    }
1403
1404    /// Resolves a list of input indices to ObjectIDs. Returns None if any index is
1405    /// out-of-bounds or references an input that isn't `ImmutableOrOwned`.
1406    fn input_indices_to_object_ids(inputs: &[Input], indices: &[u32]) -> Option<Vec<ObjectID>> {
1407        indices
1408            .iter()
1409            .map(|&i| {
1410                let inp = inputs.get(i as usize)?;
1411                if inp.kind() != InputKind::ImmutableOrOwned {
1412                    return None;
1413                }
1414                ObjectID::from_str(inp.object_id.as_ref()?).ok()
1415            })
1416            .collect()
1417    }
1418
1419    fn is_stake_call(tx: &MoveCall) -> bool {
1420        let package_id = match ObjectID::from_str(tx.package()) {
1421            Ok(id) => id,
1422            Err(e) => {
1423                warn!(
1424                    package = tx.package(),
1425                    error = %e,
1426                    "Failed to parse package ID for MoveCall"
1427                );
1428                return false;
1429            }
1430        };
1431
1432        package_id == SUI_SYSTEM_PACKAGE_ID
1433            && tx.module() == SUI_SYSTEM_MODULE_NAME.as_str()
1434            && tx.function() == ADD_STAKE_FUN_NAME.as_str()
1435    }
1436
1437    fn is_unstake_call(tx: &MoveCall) -> bool {
1438        let package_id = match ObjectID::from_str(tx.package()) {
1439            Ok(id) => id,
1440            Err(e) => {
1441                warn!(
1442                    package = tx.package(),
1443                    error = %e,
1444                    "Failed to parse package ID for MoveCall"
1445                );
1446                return false;
1447            }
1448        };
1449
1450        package_id == SUI_SYSTEM_PACKAGE_ID
1451            && tx.module() == SUI_SYSTEM_MODULE_NAME.as_str()
1452            && (tx.function() == WITHDRAW_STAKE_FUN_NAME.as_str()
1453                || tx.function() == "request_withdraw_stake_non_entry")
1454    }
1455
1456    /// Recognizes `0x3::sui_system::convert_to_fungible_staked_sui` — the signature
1457    /// MoveCall for `ConsolidateAllStakedSuiToFungible`.
1458    fn is_convert_to_fss_call(tx: &MoveCall) -> bool {
1459        let package_id = match ObjectID::from_str(tx.package()) {
1460            Ok(id) => id,
1461            Err(e) => {
1462                warn!(
1463                    package = tx.package(),
1464                    error = %e,
1465                    "Failed to parse package ID for MoveCall"
1466                );
1467                return false;
1468            }
1469        };
1470        package_id == SUI_SYSTEM_PACKAGE_ID
1471            && tx.module() == SUI_SYSTEM_MODULE_NAME.as_str()
1472            && tx.function() == "convert_to_fungible_staked_sui"
1473    }
1474
1475    /// Recognizes `0x3::staking_pool::join_fungible_staked_sui` — used by both
1476    /// `ConsolidateAllStakedSuiToFungible` (for merging FSS) and
1477    /// `MergeAndRedeemFungibleStakedSui`.
1478    fn is_join_fss_call(tx: &MoveCall) -> bool {
1479        let package_id = match ObjectID::from_str(tx.package()) {
1480            Ok(id) => id,
1481            Err(e) => {
1482                warn!(
1483                    package = tx.package(),
1484                    error = %e,
1485                    "Failed to parse package ID for MoveCall"
1486                );
1487                return false;
1488            }
1489        };
1490        package_id == SUI_SYSTEM_PACKAGE_ID
1491            && tx.module() == "staking_pool"
1492            && tx.function() == "join_fungible_staked_sui"
1493    }
1494
1495    /// Recognizes `0x3::sui_system::redeem_fungible_staked_sui` — the signature
1496    /// MoveCall for `MergeAndRedeemFungibleStakedSui`. Present only in redeem PTBs.
1497    fn is_redeem_fss_call(tx: &MoveCall) -> bool {
1498        let package_id = match ObjectID::from_str(tx.package()) {
1499            Ok(id) => id,
1500            Err(e) => {
1501                warn!(
1502                    package = tx.package(),
1503                    error = %e,
1504                    "Failed to parse package ID for MoveCall"
1505                );
1506                return false;
1507            }
1508        };
1509        package_id == SUI_SYSTEM_PACKAGE_ID
1510            && tx.module() == SUI_SYSTEM_MODULE_NAME.as_str()
1511            && tx.function() == "redeem_fungible_staked_sui"
1512    }
1513
1514    /// Recognizes `0x3::staking_pool::split_fungible_staked_sui` — used by
1515    /// MergeAndRedeem when the caller asks for partial (AtLeast/AtMost) redemption.
1516    fn is_split_fss_call(tx: &MoveCall) -> bool {
1517        let package_id = match ObjectID::from_str(tx.package()) {
1518            Ok(id) => id,
1519            Err(e) => {
1520                warn!(
1521                    package = tx.package(),
1522                    error = %e,
1523                    "Failed to parse package ID for MoveCall"
1524                );
1525                return false;
1526            }
1527        };
1528        package_id == SUI_SYSTEM_PACKAGE_ID
1529            && tx.module() == "staking_pool"
1530            && tx.function() == "split_fungible_staked_sui"
1531    }
1532
1533    /// Recognizes `0x2::coin::from_balance<0x2::sui::SUI>` — the bridge step that
1534    /// wraps a `Balance<SUI>` from `redeem_fungible_staked_sui` into a `Coin<SUI>`
1535    /// before transferring back to the sender.
1536    fn is_coin_from_balance_sui_call(tx: &MoveCall) -> bool {
1537        let Ok(package_id) = ObjectID::from_str(tx.package()) else {
1538            return false;
1539        };
1540        if package_id != SUI_FRAMEWORK_PACKAGE_ID {
1541            return false;
1542        }
1543        if tx.module() != "coin" || tx.function() != "from_balance" {
1544            return false;
1545        }
1546        if tx.type_arguments.len() != 1 {
1547            return false;
1548        }
1549        // Parse via TypeTag::from_str and compare structurally so any canonicalization
1550        // of the SUI type (padded, short, or legacy string forms) matches. This
1551        // future-proofs against encoder changes that emit non-canonical type strings.
1552        let Ok(parsed) = sui_types::TypeTag::from_str(&tx.type_arguments[0]) else {
1553            return false;
1554        };
1555        let Ok(expected) = sui_types::TypeTag::from_str("0x2::sui::SUI") else {
1556            return false;
1557        };
1558        parsed == expected
1559    }
1560
1561    /// Recognizes `balance::split<SUI>` calls used as the AtLeast runtime guard
1562    /// in `merge_and_redeem_fss_pt`.
1563    fn is_balance_split_sui_call(tx: &MoveCall) -> bool {
1564        Self::is_balance_op_sui_call(tx, "split")
1565    }
1566
1567    /// Recognizes `balance::join<SUI>` calls that pair with the AtLeast guard
1568    /// to put the split-off sub-balance back into the original.
1569    fn is_balance_join_sui_call(tx: &MoveCall) -> bool {
1570        Self::is_balance_op_sui_call(tx, "join")
1571    }
1572
1573    fn is_balance_op_sui_call(tx: &MoveCall, function: &str) -> bool {
1574        let Ok(package_id) = ObjectID::from_str(tx.package()) else {
1575            return false;
1576        };
1577        if package_id != SUI_FRAMEWORK_PACKAGE_ID {
1578            return false;
1579        }
1580        if tx.module() != "balance" || tx.function() != function {
1581            return false;
1582        }
1583        if tx.type_arguments.len() != 1 {
1584            return false;
1585        }
1586        let Ok(parsed) = sui_types::TypeTag::from_str(&tx.type_arguments[0]) else {
1587            return false;
1588        };
1589        let Ok(expected) = sui_types::TypeTag::from_str("0x2::sui::SUI") else {
1590            return false;
1591        };
1592        parsed == expected
1593    }
1594
1595    /// Recognizes `coin::redeem_funds<T>` calls used for address-balance withdrawals.
1596    fn is_coin_redeem_funds_call(tx: &MoveCall) -> bool {
1597        let package_id = match ObjectID::from_str(tx.package()) {
1598            Ok(id) => id,
1599            Err(_) => return false,
1600        };
1601        package_id == SUI_FRAMEWORK_PACKAGE_ID
1602            && tx.module() == "coin"
1603            && tx.function() == "redeem_funds"
1604    }
1605
1606    fn is_coin_into_balance_call(tx: &MoveCall) -> bool {
1607        let package_id = match ObjectID::from_str(tx.package()) {
1608            Ok(id) => id,
1609            Err(_) => return false,
1610        };
1611        package_id == SUI_FRAMEWORK_PACKAGE_ID
1612            && tx.module() == "coin"
1613            && tx.function() == "into_balance"
1614    }
1615
1616    fn is_balance_send_funds_call(tx: &MoveCall) -> bool {
1617        let package_id = match ObjectID::from_str(tx.package()) {
1618            Ok(id) => id,
1619            Err(_) => return false,
1620        };
1621        package_id == SUI_FRAMEWORK_PACKAGE_ID
1622            && tx.module() == "balance"
1623            && tx.function() == "send_funds"
1624    }
1625
1626    fn is_coin_send_funds_call(tx: &MoveCall) -> bool {
1627        let package_id = match ObjectID::from_str(tx.package()) {
1628            Ok(id) => id,
1629            Err(_) => return false,
1630        };
1631        package_id == SUI_FRAMEWORK_PACKAGE_ID
1632            && tx.module() == "coin"
1633            && tx.function() == "send_funds"
1634    }
1635
1636    fn is_coin_destroy_zero_call(tx: &MoveCall) -> bool {
1637        let package_id = match ObjectID::from_str(tx.package()) {
1638            Ok(id) => id,
1639            Err(_) => return false,
1640        };
1641        package_id == SUI_FRAMEWORK_PACKAGE_ID
1642            && tx.module() == "coin"
1643            && tx.function() == "destroy_zero"
1644    }
1645
1646    fn is_balance_join_call(tx: &MoveCall) -> bool {
1647        let package_id = match ObjectID::from_str(tx.package()) {
1648            Ok(id) => id,
1649            Err(_) => return false,
1650        };
1651        package_id == SUI_FRAMEWORK_PACKAGE_ID
1652            && tx.module() == "balance"
1653            && tx.function() == "join"
1654    }
1655
1656    fn process_balance_change(
1657        gas_owner: SuiAddress,
1658        gas_used: i128,
1659        balance_changes: &[(BalanceChange, Currency)],
1660        status: Option<OperationStatus>,
1661        balances: HashMap<(SuiAddress, Currency), i128>,
1662    ) -> impl Iterator<Item = Operation> {
1663        let mut balances =
1664            balance_changes
1665                .iter()
1666                .fold(balances, |mut balances, (balance_change, ccy)| {
1667                    if let (Some(addr_str), Some(amount_str)) =
1668                        (&balance_change.address, &balance_change.amount)
1669                        && let (Ok(owner), Ok(amount)) =
1670                            (SuiAddress::from_str(addr_str), i128::from_str(amount_str))
1671                    {
1672                        *balances.entry((owner, ccy.clone())).or_default() += amount;
1673                    }
1674                    balances
1675                });
1676        // separate gas from balances
1677        *balances.entry((gas_owner, SUI.clone())).or_default() -= gas_used;
1678
1679        let balance_change = balances.into_iter().filter(|(_, amount)| *amount != 0).map(
1680            move |((addr, currency), amount)| {
1681                Operation::balance_change(status, addr, amount, currency)
1682            },
1683        );
1684
1685        let gas = if gas_used != 0 {
1686            vec![Operation::gas(gas_owner, gas_used)]
1687        } else {
1688            // Gas can be 0 for system tx
1689            vec![]
1690        };
1691        balance_change.chain(gas)
1692    }
1693
1694    /// Checks to see if transferObjects is used on GasCoin
1695    fn is_gascoin_transfer(tx: &TransactionKind) -> bool {
1696        if let Some(TransactionKindData::ProgrammableTransaction(pt)) = &tx.data {
1697            return pt.commands.iter().any(|command| {
1698                if let Some(Command::TransferObjects(transfer)) = &command.command {
1699                    transfer
1700                        .objects
1701                        .iter()
1702                        .any(|arg| arg.kind() == ArgumentKind::Gas)
1703                } else {
1704                    false
1705                }
1706            });
1707        }
1708        false
1709    }
1710
1711    /// Add balance-change with zero amount if the gas owner does not have an entry.
1712    /// An entry is required for gas owner because the balance would be adjusted.
1713    fn add_missing_gas_owner(operations: &mut Vec<Operation>, gas_owner: SuiAddress) {
1714        if !operations.iter().any(|operation| {
1715            if let Some(amount) = &operation.amount
1716                && let Some(account) = &operation.account
1717                && account.address == gas_owner
1718                && amount.currency == *SUI
1719            {
1720                return true;
1721            }
1722            false
1723        }) {
1724            operations.push(Operation::balance_change(
1725                Some(OperationStatus::Success),
1726                gas_owner,
1727                0,
1728                SUI.clone(),
1729            ));
1730        }
1731    }
1732
1733    /// Compare initial balance_changes to new_operations and make sure
1734    /// the balance-changes stay the same after updating the operations
1735    fn validate_operations(
1736        initial_balance_changes: &[(BalanceChange, Currency)],
1737        new_operations: &[Operation],
1738    ) -> Result<(), anyhow::Error> {
1739        let balances: HashMap<(SuiAddress, Currency), i128> = HashMap::new();
1740        let mut initial_balances =
1741            initial_balance_changes
1742                .iter()
1743                .fold(balances, |mut balances, (balance_change, ccy)| {
1744                    if let (Some(addr_str), Some(amount_str)) =
1745                        (&balance_change.address, &balance_change.amount)
1746                        && let (Ok(owner), Ok(amount)) =
1747                            (SuiAddress::from_str(addr_str), i128::from_str(amount_str))
1748                    {
1749                        *balances.entry((owner, ccy.clone())).or_default() += amount;
1750                    }
1751                    balances
1752                });
1753
1754        let mut new_balances = HashMap::new();
1755        for op in new_operations {
1756            if let Some(Amount {
1757                currency, value, ..
1758            }) = &op.amount
1759            {
1760                if let Some(account) = &op.account {
1761                    let balance_change = new_balances
1762                        .remove(&(account.address, currency.clone()))
1763                        .unwrap_or(0)
1764                        + value;
1765                    new_balances.insert((account.address, currency.clone()), balance_change);
1766                } else {
1767                    return Err(anyhow!("Missing account for a balance-change"));
1768                }
1769            }
1770        }
1771
1772        for ((address, currency), amount_expected) in new_balances {
1773            let new_amount = initial_balances.remove(&(address, currency)).unwrap_or(0);
1774            if new_amount != amount_expected {
1775                return Err(anyhow!(
1776                    "Expected {} balance-change for {} but got {}",
1777                    amount_expected,
1778                    address,
1779                    new_amount
1780                ));
1781            }
1782        }
1783        if !initial_balances.is_empty() {
1784            return Err(anyhow!(
1785                "Expected every item in initial_balances to be mapped"
1786            ));
1787        }
1788        Ok(())
1789    }
1790
1791    /// If GasCoin is transferred as a part of transferObjects, operations need to be
1792    /// updated such that:
1793    /// 1) gas owner needs to be assigned back to the previous owner
1794    /// 2) balances of previous and new gas owners need to be adjusted for the gas
1795    fn process_gascoin_transfer(
1796        coin_change_operations: &mut impl Iterator<Item = Operation>,
1797        is_gascoin_transfer: bool,
1798        prev_gas_owner: SuiAddress,
1799        new_gas_owner: SuiAddress,
1800        gas_used: i128,
1801        initial_balance_changes: &[(BalanceChange, Currency)],
1802    ) -> Result<Vec<Operation>, anyhow::Error> {
1803        let mut operations = vec![];
1804        if is_gascoin_transfer && prev_gas_owner != new_gas_owner {
1805            operations = coin_change_operations.collect();
1806            Self::add_missing_gas_owner(&mut operations, prev_gas_owner);
1807            Self::add_missing_gas_owner(&mut operations, new_gas_owner);
1808            for operation in &mut operations {
1809                match operation.type_ {
1810                    OperationType::Gas => {
1811                        // change gas account back to the previous owner as it is the one
1812                        // who paid for the txn (this is the format Rosetta wants to process)
1813                        operation.account = Some(prev_gas_owner.into())
1814                    }
1815                    OperationType::SuiBalanceChange => {
1816                        let account = operation
1817                            .account
1818                            .as_ref()
1819                            .ok_or_else(|| anyhow!("Missing account for a balance-change"))?;
1820                        let amount = operation
1821                            .amount
1822                            .as_mut()
1823                            .ok_or_else(|| anyhow!("Missing amount for a balance-change"))?;
1824                        // adjust the balances for previous and new gas_owners
1825                        if account.address == prev_gas_owner && amount.currency == *SUI {
1826                            amount.value -= gas_used;
1827                        } else if account.address == new_gas_owner && amount.currency == *SUI {
1828                            amount.value += gas_used;
1829                        }
1830                    }
1831                    _ => {
1832                        return Err(anyhow!(
1833                            "Discarding unsupported operation type {:?}",
1834                            operation.type_
1835                        ));
1836                    }
1837                }
1838            }
1839            Self::validate_operations(initial_balance_changes, &operations)?;
1840        }
1841        Ok(operations)
1842    }
1843}
1844
1845impl Operations {
1846    pub async fn try_from_executed_transaction(
1847        executed_tx: ExecutedTransaction,
1848        cache: &CoinMetadataCache,
1849    ) -> Result<Self, Error> {
1850        let ExecutedTransaction {
1851            transaction,
1852            effects,
1853            events,
1854            balance_changes,
1855            ..
1856        } = executed_tx;
1857
1858        let transaction = transaction.ok_or_else(|| {
1859            Error::DataError("ExecutedTransaction missing transaction".to_string())
1860        })?;
1861        let effects = effects
1862            .ok_or_else(|| Error::DataError("ExecutedTransaction missing effects".to_string()))?;
1863
1864        let sender = SuiAddress::from_str(transaction.sender())?;
1865
1866        // Post-execution owner of the gas coin. This is empty when the gas coin no
1867        // longer exists after execution: a `coin::send_funds` that moves the entire
1868        // gas coin into an address balance (gasless / free-tier transfers) deletes
1869        // the gas object, so its effects carry no output owner.
1870        let gas_output_owner = effects.gas_object().output_owner().address();
1871        let gas_owner = if !gas_output_owner.is_empty() {
1872            SuiAddress::from_str(gas_output_owner)?
1873        } else if sender == SuiAddress::ZERO {
1874            // System transactions don't have a gas_object.
1875            sender
1876        } else {
1877            // No gas coin output owner: either gas was paid from the sender's address
1878            // balance (no gas coin object) or the gas coin was fully consumed/deleted.
1879            // Either way the gas payment owner is the account that paid for the txn.
1880            SuiAddress::from_str(transaction.gas_payment().owner())?
1881        };
1882
1883        let gas_summary = effects.gas_used();
1884        let gas_used = gas_summary.storage_rebate_opt().unwrap_or(0) as i128
1885            - gas_summary.storage_cost_opt().unwrap_or(0) as i128
1886            - gas_summary.computation_cost_opt().unwrap_or(0) as i128;
1887
1888        let status = Some(effects.status().into());
1889
1890        let prev_gas_owner = SuiAddress::from_str(transaction.gas_payment().owner())?;
1891
1892        let tx_kind = transaction
1893            .kind
1894            .ok_or_else(|| Error::DataError("Transaction missing kind".to_string()))?;
1895        let is_gascoin_transfer = Self::is_gascoin_transfer(&tx_kind);
1896
1897        // Resolve coins to currencies and pick the payment's currency in one pass.
1898        // `by_coin_type` is reused by the reconciliation pass below
1899        // (`balance_changes_with_currency`); `payment` is handed to the parser.
1900        let TxCurrencies {
1901            by_coin_type: currencies,
1902            payment,
1903        } = resolve_tx_currencies(&balance_changes, cache).await?;
1904        let ops = Self::new(Self::from_transaction(tx_kind, sender, status, payment)?);
1905        let ops = ops.into_iter();
1906
1907        // We will need to subtract the operation amounts from the actual balance
1908        // change amount extracted from event to prevent double counting.
1909        let mut accounted_balances =
1910            ops.as_ref()
1911                .iter()
1912                .fold(HashMap::new(), |mut balances, op| {
1913                    if let (Some(acc), Some(amount), Some(OperationStatus::Success)) =
1914                        (&op.account, &op.amount, &op.status)
1915                    {
1916                        *balances
1917                            .entry((acc.address, amount.clone().currency))
1918                            .or_default() -= amount.value;
1919                    }
1920                    balances
1921                });
1922
1923        let mut principal_amounts = 0;
1924        let mut reward_amounts = 0;
1925
1926        // Extract balance change from unstake events
1927        let events = events.as_ref().map(|e| e.events.as_slice()).unwrap_or(&[]);
1928        for event in events {
1929            let event_type = event.event_type();
1930            if let Ok(type_tag) = StructTag::from_str(event_type)
1931                && is_unstake_event(&type_tag)
1932                && let Some(json) = &event.json
1933                && let Some(Kind::StructValue(struct_val)) = &json.kind
1934            {
1935                if let Some(principal_field) = struct_val.fields.get("principal_amount")
1936                    && let Some(Kind::StringValue(s)) = &principal_field.kind
1937                    && let Ok(amount) = i128::from_str(s)
1938                {
1939                    principal_amounts += amount;
1940                }
1941                if let Some(reward_field) = struct_val.fields.get("reward_amount")
1942                    && let Some(Kind::StringValue(s)) = &reward_field.kind
1943                    && let Ok(amount) = i128::from_str(s)
1944                {
1945                    reward_amounts += amount;
1946                }
1947            }
1948        }
1949        let staking_balance = if principal_amounts != 0 {
1950            *accounted_balances.entry((sender, SUI.clone())).or_default() -= principal_amounts;
1951            *accounted_balances.entry((sender, SUI.clone())).or_default() -= reward_amounts;
1952            vec![
1953                Operation::stake_principle(status, sender, principal_amounts),
1954                Operation::stake_reward(status, sender, reward_amounts),
1955            ]
1956        } else {
1957            vec![]
1958        };
1959
1960        // Reuse the currencies map built above instead of a second
1961        // `cache.get_currency` pass per balance change.
1962        let balance_changes_with_currency: Vec<_> = balance_changes
1963            .iter()
1964            .filter_map(|bc| {
1965                currencies
1966                    .get(bc.coin_type())
1967                    .map(|c| (bc.clone(), c.clone()))
1968            })
1969            .collect();
1970
1971        // Extract coin change operations from balance changes
1972        let mut coin_change_operations = Self::process_balance_change(
1973            gas_owner,
1974            gas_used,
1975            &balance_changes_with_currency,
1976            status,
1977            accounted_balances.clone(),
1978        );
1979
1980        // Take {gas, previous gas owner, new gas owner} out of coin_change_operations
1981        // and convert BalanceChange to PaySui when GasCoin is transferred
1982        let gascoin_transfer_operations = Self::process_gascoin_transfer(
1983            &mut coin_change_operations,
1984            is_gascoin_transfer,
1985            prev_gas_owner,
1986            gas_owner,
1987            gas_used,
1988            &balance_changes_with_currency,
1989        )?;
1990
1991        let ops: Operations = ops
1992            .into_iter()
1993            .chain(coin_change_operations)
1994            .chain(gascoin_transfer_operations)
1995            .chain(staking_balance)
1996            .collect();
1997
1998        // This is a workaround for the payCoin cases that are mistakenly considered to be paySui operations
1999        // In this case we remove any irrelevant, SUI specific operation entries that sum up to 0 balance changes per address
2000        // and keep only the actual entries for the right coin type transfers, as they have been extracted from the transaction's
2001        // balance changes section.
2002        let mutually_cancelling_balances: HashMap<_, _> = ops
2003            .clone()
2004            .into_iter()
2005            .fold(
2006                HashMap::new(),
2007                |mut balances: HashMap<(SuiAddress, Currency), i128>, op| {
2008                    if let (Some(acc), Some(amount), Some(OperationStatus::Success)) =
2009                        (&op.account, &op.amount, &op.status)
2010                        && op.type_ != OperationType::Gas
2011                    {
2012                        *balances
2013                            .entry((acc.address, amount.clone().currency))
2014                            .or_default() += amount.value;
2015                    }
2016                    balances
2017                },
2018            )
2019            .into_iter()
2020            .filter(|balance| {
2021                let (_, amount) = balance;
2022                *amount == 0
2023            })
2024            .collect();
2025
2026        let ops: Operations = ops
2027            .into_iter()
2028            .filter(|op| {
2029                if let (Some(acc), Some(amount)) = (&op.account, &op.amount) {
2030                    return op.type_ == OperationType::Gas
2031                        || !mutually_cancelling_balances
2032                            .contains_key(&(acc.address, amount.clone().currency));
2033                }
2034                true
2035            })
2036            .collect();
2037        Ok(ops)
2038    }
2039}
2040
2041fn is_unstake_event(tag: &StructTag) -> bool {
2042    tag.address == SUI_SYSTEM_ADDRESS
2043        && tag.module.as_ident_str() == ident_str!("validator")
2044        && tag.name.as_ident_str() == ident_str!("UnstakingRequestEvent")
2045}
2046
2047#[derive(Deserialize, Serialize, Clone, Debug)]
2048pub struct Operation {
2049    operation_identifier: OperationIdentifier,
2050    #[serde(rename = "type")]
2051    pub type_: OperationType,
2052    #[serde(default, skip_serializing_if = "Option::is_none")]
2053    pub status: Option<OperationStatus>,
2054    #[serde(default, skip_serializing_if = "Option::is_none")]
2055    pub account: Option<AccountIdentifier>,
2056    #[serde(default, skip_serializing_if = "Option::is_none")]
2057    pub amount: Option<Amount>,
2058    #[serde(default, skip_serializing_if = "Option::is_none")]
2059    pub coin_change: Option<CoinChange>,
2060    #[serde(default, skip_serializing_if = "Option::is_none")]
2061    pub metadata: Option<OperationMetadata>,
2062}
2063
2064impl PartialEq for Operation {
2065    fn eq(&self, other: &Self) -> bool {
2066        self.operation_identifier == other.operation_identifier
2067            && self.type_ == other.type_
2068            && self.account == other.account
2069            && self.amount == other.amount
2070            && self.coin_change == other.coin_change
2071            && self.metadata == other.metadata
2072    }
2073}
2074
2075#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
2076pub enum OperationMetadata {
2077    GenericTransaction(TransactionKind),
2078    Stake {
2079        validator: SuiAddress,
2080    },
2081    WithdrawStake {
2082        stake_ids: Vec<ObjectID>,
2083    },
2084    ConsolidateAllStakedSuiToFungible {
2085        #[serde(default, skip_serializing_if = "Option::is_none")]
2086        validator: Option<SuiAddress>,
2087        #[serde(default, skip_serializing_if = "Vec::is_empty")]
2088        staked_sui_ids: Vec<ObjectID>,
2089        #[serde(default, skip_serializing_if = "Vec::is_empty")]
2090        fss_ids: Vec<ObjectID>,
2091    },
2092    MergeAndRedeemFungibleStakedSui {
2093        #[serde(default, skip_serializing_if = "Option::is_none")]
2094        validator: Option<SuiAddress>,
2095        #[serde(default, skip_serializing_if = "Option::is_none")]
2096        amount: Option<String>,
2097        #[serde(default, skip_serializing_if = "Option::is_none")]
2098        redeem_mode: Option<RedeemMode>,
2099        #[serde(default, skip_serializing_if = "Vec::is_empty")]
2100        fss_ids: Vec<ObjectID>,
2101    },
2102}
2103
2104impl Operation {
2105    fn generic_op(
2106        status: Option<OperationStatus>,
2107        sender: SuiAddress,
2108        tx: TransactionKind,
2109    ) -> Self {
2110        Operation {
2111            operation_identifier: Default::default(),
2112            type_: (&tx).into(),
2113            status,
2114            account: Some(sender.into()),
2115            amount: None,
2116            coin_change: None,
2117            metadata: Some(OperationMetadata::GenericTransaction(tx)),
2118        }
2119    }
2120
2121    pub fn genesis(index: u64, sender: SuiAddress, coin: GasCoin) -> Self {
2122        Operation {
2123            operation_identifier: index.into(),
2124            type_: OperationType::Genesis,
2125            status: Some(OperationStatus::Success),
2126            account: Some(sender.into()),
2127            amount: Some(Amount::new(coin.value().into(), None)),
2128            coin_change: Some(CoinChange {
2129                coin_identifier: CoinIdentifier {
2130                    identifier: CoinID {
2131                        id: *coin.id(),
2132                        version: SequenceNumber::new(),
2133                    },
2134                },
2135                coin_action: CoinAction::CoinCreated,
2136            }),
2137            metadata: None,
2138        }
2139    }
2140
2141    fn pay_sui(status: Option<OperationStatus>, address: SuiAddress, amount: i128) -> Self {
2142        Operation {
2143            operation_identifier: Default::default(),
2144            type_: OperationType::PaySui,
2145            status,
2146            account: Some(address.into()),
2147            amount: Some(Amount::new(amount, None)),
2148            coin_change: None,
2149            metadata: None,
2150        }
2151    }
2152
2153    fn pay_coin(
2154        status: Option<OperationStatus>,
2155        address: SuiAddress,
2156        amount: i128,
2157        currency: Option<Currency>,
2158    ) -> Self {
2159        Operation {
2160            operation_identifier: Default::default(),
2161            type_: OperationType::PayCoin,
2162            status,
2163            account: Some(address.into()),
2164            amount: Some(Amount::new(amount, currency)),
2165            coin_change: None,
2166            metadata: None,
2167        }
2168    }
2169
2170    fn balance_change(
2171        status: Option<OperationStatus>,
2172        addr: SuiAddress,
2173        amount: i128,
2174        currency: Currency,
2175    ) -> Self {
2176        Self {
2177            operation_identifier: Default::default(),
2178            type_: OperationType::SuiBalanceChange,
2179            status,
2180            account: Some(addr.into()),
2181            amount: Some(Amount::new(amount, Some(currency))),
2182            coin_change: None,
2183            metadata: None,
2184        }
2185    }
2186    fn gas(addr: SuiAddress, amount: i128) -> Self {
2187        Self {
2188            operation_identifier: Default::default(),
2189            type_: OperationType::Gas,
2190            status: Some(OperationStatus::Success),
2191            account: Some(addr.into()),
2192            amount: Some(Amount::new(amount, None)),
2193            coin_change: None,
2194            metadata: None,
2195        }
2196    }
2197    fn stake_reward(status: Option<OperationStatus>, addr: SuiAddress, amount: i128) -> Self {
2198        Self {
2199            operation_identifier: Default::default(),
2200            type_: OperationType::StakeReward,
2201            status,
2202            account: Some(addr.into()),
2203            amount: Some(Amount::new(amount, None)),
2204            coin_change: None,
2205            metadata: None,
2206        }
2207    }
2208    fn stake_principle(status: Option<OperationStatus>, addr: SuiAddress, amount: i128) -> Self {
2209        Self {
2210            operation_identifier: Default::default(),
2211            type_: OperationType::StakePrinciple,
2212            status,
2213            account: Some(addr.into()),
2214            amount: Some(Amount::new(amount, None)),
2215            coin_change: None,
2216            metadata: None,
2217        }
2218    }
2219}
2220
2221/// Reconstruct Rosetta `Operations` from a proto `Transaction`, applying the
2222/// out-of-band `AuxData`. Shared by `/parse` and `/payloads`.
2223///
2224/// The aux data carries the few labels the PTB cannot encode (PayCoin
2225/// currency, FSS validator / redeem-mode / cap), populated in `/metadata` and
2226/// carried in the wrapper; it is not cryptographically bound to the signature.
2227/// The PayCoin currency — the one label whose correctness affects fund routing
2228/// — is verified online against the simulated balance changes in `/submit`; FSS
2229/// labels are display-only (the signed PTB determines execution, and `/block`
2230/// re-derives the truth from chain). `apply_aux` still rejects aux data whose
2231/// family disagrees with the parsed transaction family.
2232///
2233/// Steps:
2234/// 1. Reconstruct operations from the transaction via the shared parser
2235///    (`from_transaction`), seeding the currency map from a `PayCoin` label so
2236///    payments are labelled correctly.
2237/// 2. Decorate FSS ops with the validator / redeem-mode / cap the PTB cannot
2238///    encode, asserting the parsed family matches the aux-data family.
2239pub fn reconstruct_operations(
2240    proto: &ProtoTransaction,
2241    aux: &AuxData,
2242    status: Option<OperationStatus>,
2243) -> Result<Operations, Error> {
2244    let sender = SuiAddress::from_str(proto.sender())
2245        .map_err(|e| Error::DataError(format!("invalid transaction sender: {e}")))?;
2246    let tx_kind = proto
2247        .kind
2248        .clone()
2249        .ok_or_else(|| Error::DataError("Transaction missing kind".to_string()))?;
2250
2251    // The PayCoin label is the only currency the PTB cannot encode; everything
2252    // else reconstructs as SUI. This path never produces `Unresolvable`.
2253    let payment_currency = match aux {
2254        AuxData::PayCoin { currency } => PaymentCurrency::NonSui(currency.clone()),
2255        _ => PaymentCurrency::Sui,
2256    };
2257    let mut ops = Operations::from_transaction(tx_kind, sender, status, payment_currency)?;
2258
2259    // Apply the labels the PTB cannot encode.
2260    apply_aux(&mut ops, aux)?;
2261    Ok(Operations::new(ops))
2262}
2263
2264/// Overlay the non-reconstructable labels from `aux` onto the parsed `ops`,
2265/// rejecting if the parsed operation family disagrees with the aux-data family.
2266fn apply_aux(ops: &mut [Operation], aux: &AuxData) -> Result<(), Error> {
2267    match aux {
2268        AuxData::None => {}
2269        AuxData::PayCoin { .. } => {
2270            // The currency map already drove the parser to label payments as
2271            // PayCoin; just assert the parsed family is a payment family so a
2272            // PayCoin label over e.g. a Stake PTB is rejected.
2273            let is_payment = ops
2274                .iter()
2275                .all(|op| matches!(op.type_, OperationType::PayCoin | OperationType::PaySui));
2276            if ops.is_empty() || !is_payment {
2277                return Err(Error::DataError(
2278                    "envelope inconsistency: PayCoin aux data over a non-payment transaction"
2279                        .to_string(),
2280                ));
2281            }
2282        }
2283        AuxData::Consolidate { validator } => {
2284            let op = single_op(ops, OperationType::ConsolidateAllStakedSuiToFungible)?;
2285            match &mut op.metadata {
2286                Some(OperationMetadata::ConsolidateAllStakedSuiToFungible {
2287                    validator: v, ..
2288                }) => {
2289                    *v = Some(*validator);
2290                }
2291                _ => {
2292                    return Err(Error::DataError(
2293                        "envelope inconsistency: Consolidate aux data but parsed op lacks \
2294                         Consolidate metadata"
2295                            .to_string(),
2296                    ));
2297                }
2298            }
2299        }
2300        AuxData::MergeAndRedeem {
2301            validator,
2302            redeem_mode,
2303            amount,
2304        } => {
2305            // Minimal sanity check (replaces the removed
2306            // `InternalOperation::validate`): AtLeast/AtMost must carry a
2307            // positive amount; All must carry none. Guards against a server
2308            // building structurally invalid aux data.
2309            match redeem_mode {
2310                RedeemMode::All if amount.is_some() => {
2311                    return Err(Error::DataError(
2312                        "MergeAndRedeem All must carry no amount".to_string(),
2313                    ));
2314                }
2315                RedeemMode::AtLeast | RedeemMode::AtMost if !matches!(amount, Some(a) if *a > 0) => {
2316                    return Err(Error::DataError(format!(
2317                        "MergeAndRedeem {redeem_mode:?} must carry a positive amount"
2318                    )));
2319                }
2320                _ => {}
2321            }
2322            let op = single_op(ops, OperationType::MergeAndRedeemFungibleStakedSui)?;
2323            match &mut op.metadata {
2324                Some(OperationMetadata::MergeAndRedeemFungibleStakedSui {
2325                    validator: v,
2326                    amount: a,
2327                    redeem_mode: m,
2328                    ..
2329                }) => {
2330                    // Override: the parser cannot distinguish AtMost from
2331                    // All/unknown-partial, so the aux data is authoritative
2332                    // for the user-declared mode + cap.
2333                    *v = Some(*validator);
2334                    *m = Some(redeem_mode.clone());
2335                    *a = amount.map(|amount| amount.to_string());
2336                }
2337                _ => {
2338                    return Err(Error::DataError(
2339                        "envelope inconsistency: MergeAndRedeem aux data but parsed op lacks \
2340                         MergeAndRedeem metadata"
2341                            .to_string(),
2342                    ));
2343                }
2344            }
2345        }
2346    }
2347    Ok(())
2348}
2349
2350/// Return the single operation of `expected` type, rejecting if the parsed
2351/// family does not match the aux-data family.
2352fn single_op(ops: &mut [Operation], expected: OperationType) -> Result<&mut Operation, Error> {
2353    match ops {
2354        [op] if op.type_ == expected => Ok(op),
2355        _ => Err(Error::DataError(format!(
2356            "envelope inconsistency: aux data expects a single {expected:?} operation, \
2357             but the transaction parsed to a different shape"
2358        ))),
2359    }
2360}
2361
2362#[cfg(test)]
2363mod tests {
2364    use super::*;
2365    use crate::types::ConstructionMetadata;
2366    use crate::types::internal_operation::{consolidate_to_fungible_pt, merge_and_redeem_fss_pt};
2367    use sui_rpc::proto::sui::rpc::v2::Transaction;
2368    use sui_types::Identifier;
2369    use sui_types::base_types::{ObjectDigest, ObjectID, ObjectRef, SequenceNumber, SuiAddress};
2370    use sui_types::programmable_transaction_builder::ProgrammableTransactionBuilder;
2371    use sui_types::transaction::{
2372        CallArg, Command as NativeCommand, ObjectArg, ProgrammableTransaction,
2373        TEST_ONLY_GAS_UNIT_FOR_TRANSFER, TransactionData,
2374    };
2375
2376    fn random_object_ref() -> ObjectRef {
2377        (
2378            ObjectID::random(),
2379            SequenceNumber::from(1),
2380            ObjectDigest::random(),
2381        )
2382    }
2383
2384    /// Parse a native `ProgrammableTransaction` via the proto pipeline.
2385    /// Exact same conversion pattern used by `test_operation_data_parsing_pay_sui` at line 1637.
2386    fn parse_pt(sender: SuiAddress, pt: ProgrammableTransaction) -> Vec<Operation> {
2387        let gas = random_object_ref();
2388        let gas_price = 10;
2389        let data = TransactionData::new_programmable(
2390            sender,
2391            vec![gas],
2392            pt,
2393            TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
2394            gas_price,
2395        );
2396        let proto_tx: Transaction = data.into();
2397        let tx_kind = proto_tx.kind.expect("tx missing kind");
2398        Operations::from_transaction(tx_kind, sender, None, PaymentCurrency::Sui)
2399            .expect("parse failed")
2400    }
2401
2402    #[tokio::test]
2403    async fn test_operation_data_parsing_pay_sui() -> Result<(), anyhow::Error> {
2404        let gas = (
2405            ObjectID::random(),
2406            SequenceNumber::new(),
2407            ObjectDigest::random(),
2408        );
2409
2410        let sender = SuiAddress::random_for_testing_only();
2411
2412        let pt = {
2413            let mut builder = ProgrammableTransactionBuilder::new();
2414            builder
2415                .pay_sui(vec![SuiAddress::random_for_testing_only()], vec![10000])
2416                .unwrap();
2417            builder.finish()
2418        };
2419        let gas_price = 10;
2420        let data = TransactionData::new_programmable(
2421            sender,
2422            vec![gas],
2423            pt,
2424            TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
2425            gas_price,
2426        );
2427
2428        let proto_tx: Transaction = data.clone().into();
2429        let ops = Operations::new(Operations::from_transaction(
2430            proto_tx
2431                .kind
2432                .ok_or_else(|| Error::DataError("Transaction missing kind".to_string()))?,
2433            sender,
2434            None,
2435            PaymentCurrency::Sui,
2436        )?);
2437        ops.0
2438            .iter()
2439            .for_each(|op| assert_eq!(op.type_, OperationType::PaySui));
2440        let metadata = ConstructionMetadata {
2441            sender,
2442            gas_coins: vec![gas],
2443            extra_gas_coins: vec![],
2444            objects: vec![],
2445            party_objects: vec![],
2446            total_coin_value: 0,
2447            gas_price,
2448            budget: TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
2449            currency: None,
2450            address_balance_withdrawal: 0,
2451            epoch: None,
2452            chain_id: None,
2453            nonce: None,
2454            fss_object_count: None,
2455            redeem_token_amount: None,
2456            redeem_plan: None,
2457            bind_epoch: None,
2458        };
2459        let parsed_data = ops.into_internal()?.try_into_data(metadata)?;
2460        assert_eq!(data, parsed_data);
2461
2462        Ok(())
2463    }
2464
2465    /// Stake operations must survive a parse round-trip: ops → internal → data →
2466    /// proto → `from_transaction` → ops. This is a pure data round-trip (no chain
2467    /// state), so it lives in-crate rather than forcing `from_transaction` /
2468    /// `PaymentCurrency` into the public API for an integration test.
2469    #[test]
2470    fn test_stake_parse_round_trip() -> Result<(), anyhow::Error> {
2471        use sui_types::transaction::TEST_ONLY_GAS_UNIT_FOR_STAKING;
2472
2473        let sender = SuiAddress::random_for_testing_only();
2474        let validator = SuiAddress::random_for_testing_only();
2475        let gas = random_object_ref();
2476        let gas_price = 10;
2477
2478        let ops: Operations = serde_json::from_value(serde_json::json!([{
2479            "operation_identifier": {"index": 0},
2480            "type": "Stake",
2481            "account": {"address": sender.to_string()},
2482            "amount": {"value": "-100000", "currency": {"symbol": "SUI", "decimals": 9}},
2483            "metadata": {"Stake": {"validator": validator.to_string()}}
2484        }]))?;
2485
2486        let metadata = ConstructionMetadata {
2487            sender,
2488            gas_coins: vec![gas],
2489            extra_gas_coins: vec![],
2490            objects: vec![],
2491            party_objects: vec![],
2492            total_coin_value: 0,
2493            gas_price,
2494            budget: gas_price * TEST_ONLY_GAS_UNIT_FOR_STAKING,
2495            currency: None,
2496            address_balance_withdrawal: 0,
2497            epoch: None,
2498            chain_id: None,
2499            nonce: None,
2500            fss_object_count: None,
2501            redeem_token_amount: None,
2502            redeem_plan: None,
2503            bind_epoch: None,
2504        };
2505        let parsed_data = ops.clone().into_internal()?.try_into_data(metadata)?;
2506
2507        let proto_tx: Transaction = parsed_data.clone().into();
2508        let parsed_ops = Operations::new(Operations::from_transaction(
2509            proto_tx
2510                .kind
2511                .ok_or_else(|| Error::DataError("Transaction missing kind".to_string()))?,
2512            sender,
2513            None,
2514            PaymentCurrency::Sui,
2515        )?);
2516
2517        assert_eq!(ops, parsed_ops, "expected {ops:#?}, got: {parsed_ops:#?}");
2518        Ok(())
2519    }
2520
2521    /// Build a `pay_coin_pt`-shaped PTB (SplitCoins + TransferObjects) and parse
2522    /// it under the given payment currency. Shared by the currency→label tests.
2523    fn parse_payment_pt(payment: PaymentCurrency) -> Result<Vec<Operation>, anyhow::Error> {
2524        use crate::SUI;
2525        use crate::types::internal_operation::pay_coin_pt;
2526
2527        let gas = (
2528            ObjectID::random(),
2529            SequenceNumber::new(),
2530            ObjectDigest::random(),
2531        );
2532        let coin = (
2533            ObjectID::random(),
2534            SequenceNumber::new(),
2535            ObjectDigest::random(),
2536        );
2537        let sender = SuiAddress::random_for_testing_only();
2538        let recipient = SuiAddress::random_for_testing_only();
2539        let pt = pay_coin_pt(sender, vec![recipient], vec![10_000], &[coin], &[], 0, &SUI)?;
2540        let gas_price = 10;
2541        let data = TransactionData::new_programmable(
2542            sender,
2543            vec![gas],
2544            pt,
2545            TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
2546            gas_price,
2547        );
2548        let proto_tx: Transaction = data.into();
2549        let tx_kind = proto_tx.kind.unwrap();
2550        Ok(Operations::from_transaction(
2551            tx_kind, sender, None, payment,
2552        )?)
2553    }
2554
2555    /// The parser is a dumb applier: `PaymentCurrency::Unresolvable` must emit
2556    /// neither PaySui nor PayCoin — it falls through to `generic_op`. This is
2557    /// what the indexing caller hands over when `balance_changes` shows a non-SUI
2558    /// coin it couldn't resolve (or two or more non-SUI coins).
2559    #[test]
2560    fn test_parse_unresolvable_emits_generic_op() -> Result<(), anyhow::Error> {
2561        let ops = parse_payment_pt(PaymentCurrency::Unresolvable)?;
2562        assert!(
2563            !ops.iter().any(|op| op.type_ == OperationType::PaySui),
2564            "Unresolvable must not silently fall back to PaySui: {ops:?}"
2565        );
2566        assert!(
2567            !ops.iter().any(|op| op.type_ == OperationType::PayCoin),
2568            "Unresolvable must not produce PayCoin (we don't know the currency): {ops:?}"
2569        );
2570        assert!(
2571            ops.iter()
2572                .any(|op| matches!(op.metadata, Some(OperationMetadata::GenericTransaction(_)))),
2573            "Unresolvable must fall through to generic_op: {ops:?}"
2574        );
2575        Ok(())
2576    }
2577
2578    /// `PaymentCurrency::NonSui(c)` must label every payment leg as PayCoin
2579    /// carrying exactly `c`, and never PaySui.
2580    #[test]
2581    fn test_parse_nonsui_emits_pay_coin() -> Result<(), anyhow::Error> {
2582        use crate::types::CurrencyMetadata;
2583
2584        let usdc = Currency {
2585            symbol: "USDC".to_string(),
2586            decimals: 6,
2587            metadata: CurrencyMetadata {
2588                coin_type: "0xaaa::usdc::USDC".to_string(),
2589            },
2590        };
2591        let ops = parse_payment_pt(PaymentCurrency::NonSui(usdc.clone()))?;
2592        assert!(
2593            !ops.iter().any(|op| op.type_ == OperationType::PaySui),
2594            "NonSui must not produce PaySui: {ops:?}"
2595        );
2596        let pay_coins: Vec<_> = ops
2597            .iter()
2598            .filter(|op| op.type_ == OperationType::PayCoin)
2599            .collect();
2600        assert!(
2601            !pay_coins.is_empty(),
2602            "NonSui must produce PayCoin: {ops:?}"
2603        );
2604        for op in pay_coins {
2605            assert_eq!(
2606                op.amount.as_ref().map(|a| &a.currency),
2607                Some(&usdc),
2608                "PayCoin op must carry the NonSui currency: {op:?}"
2609            );
2610        }
2611        Ok(())
2612    }
2613
2614    /// A cache backed by a client that never connects, so every non-SUI coin
2615    /// lookup fails with a transport (transient) error.
2616    fn unreachable_cache() -> CoinMetadataCache {
2617        use std::num::NonZeroUsize;
2618        use sui_rpc::client::Client;
2619        CoinMetadataCache::new(
2620            Client::new("http://127.0.0.1:1").unwrap(),
2621            NonZeroUsize::new(1).unwrap(),
2622        )
2623    }
2624
2625    fn balance_change(coin_type: &str) -> BalanceChange {
2626        let mut bc = BalanceChange::default();
2627        bc.coin_type = Some(coin_type.to_string());
2628        bc
2629    }
2630
2631    /// SUI takes no metadata RPC: even with an unreachable cache, a SUI-only
2632    /// transaction resolves to a `Sui` payment (with SUI inserted directly into
2633    /// the map for the reconciliation pass), never a retriable error.
2634    #[tokio::test]
2635    async fn test_resolve_sui_needs_no_lookup() {
2636        let cache = unreachable_cache();
2637        let resolved = resolve_tx_currencies(&[balance_change(&SUI.metadata.coin_type)], &cache)
2638            .await
2639            .expect("SUI must resolve without an RPC");
2640        assert!(matches!(resolved.payment, PaymentCurrency::Sui));
2641        assert_eq!(
2642            resolved.by_coin_type.get(&SUI.metadata.coin_type),
2643            Some(&*SUI)
2644        );
2645    }
2646
2647    /// `Balance<T>`/`Coin<T>` accept a non-struct `T`, so a balance change can
2648    /// name `u64`. Coin metadata is keyed by `StructTag`, so no lookup may be
2649    /// issued for such a type: `unreachable_cache` turns any attempt into a
2650    /// retriable error, so `Ok` here proves the request was never sent. Mainnet
2651    /// checkpoint 309686199 stalled `/block` on exactly this shape.
2652    #[tokio::test]
2653    async fn test_resolve_non_struct_coin_type_degrades() {
2654        let cache = unreachable_cache();
2655        let resolved = resolve_tx_currencies(&[balance_change("u64")], &cache)
2656            .await
2657            .expect("a non-struct coin type must not fail the block");
2658        assert!(
2659            matches!(resolved.payment, PaymentCurrency::Unresolvable),
2660            "a non-struct coin type must fall through to generic_op: {:?}",
2661            resolved.payment
2662        );
2663        assert!(
2664            resolved.by_coin_type.is_empty(),
2665            "a non-struct coin type must not be reported as a currency: {:?}",
2666            resolved.by_coin_type
2667        );
2668    }
2669
2670    /// Part 2 / idempotency: a transient failure resolving a non-SUI coin must
2671    /// surface as a retriable error so `/block` stalls and retries, rather than
2672    /// degrading to a generic_op and baking it into the block.
2673    #[tokio::test]
2674    async fn test_resolve_transient_non_sui_is_retriable() {
2675        let cache = unreachable_cache();
2676        let err = resolve_tx_currencies(&[balance_change("0xaaa::usdc::USDC")], &cache)
2677            .await
2678            .expect_err("a transient non-SUI lookup failure must surface as an error");
2679        assert!(
2680            matches!(err, Error::CoinMetadataUnavailable(_)),
2681            "transient failure must map to CoinMetadataUnavailable: {err:?}"
2682        );
2683        // The Mesh error response must carry `retriable: true`.
2684        let json = serde_json::to_value(&err).expect("error serializes");
2685        assert_eq!(
2686            json.get("retriable"),
2687            Some(&serde_json::Value::Bool(true)),
2688            "CoinMetadataUnavailable must serialize as retriable: {json}"
2689        );
2690    }
2691
2692    /// `pay_coin_pt` must not append a trailing `Pure` input whose bytes
2693    /// BCS-decode as a String that JSON-decodes as `Currency`. Any future
2694    /// builder change that reintroduces that shape would re-couple
2695    /// downstream parsing to a brittle "scan last input" invariant.
2696    #[test]
2697    fn test_pay_coin_pt_has_no_currency_bearer() -> Result<(), anyhow::Error> {
2698        use crate::SUI;
2699        use crate::types::internal_operation::pay_coin_pt;
2700
2701        let sender = SuiAddress::random_for_testing_only();
2702        let recipient = SuiAddress::random_for_testing_only();
2703        let coin = (
2704            ObjectID::random(),
2705            SequenceNumber::new(),
2706            ObjectDigest::random(),
2707        );
2708
2709        let pt = pay_coin_pt(sender, vec![recipient], vec![10_000], &[coin], &[], 0, &SUI)?;
2710
2711        for input in &pt.inputs {
2712            if let CallArg::Pure(bytes) = input
2713                && let Ok(s) = bcs::from_bytes::<String>(bytes)
2714                && serde_json::from_str::<Currency>(&s).is_ok()
2715            {
2716                panic!(
2717                    "pay_coin_pt produced a Pure input that decodes as a Currency JSON string: {:?}",
2718                    s
2719                );
2720            }
2721        }
2722        Ok(())
2723    }
2724
2725    /// Regression test for the gas coin being fully consumed during execution.
2726    /// A `coin::send_funds` that moves the entire gas coin into an address balance
2727    /// (gasless / free-tier transfers) deletes the gas object, so its effects carry
2728    /// a `ChangedObject` with no `output_owner`. Previously `try_from_executed_transaction`
2729    /// fed the resulting empty owner string to `SuiAddress::from_str`, which produced
2730    /// `FastCryptoError::InvalidInput` ("Invalid value was given to the function") and
2731    /// failed the whole `/block` request. It must instead fall back to the gas payment
2732    /// owner and attribute gas to it.
2733    #[tokio::test]
2734    async fn test_try_from_executed_transaction_deleted_gas_coin() -> Result<(), anyhow::Error> {
2735        use std::num::NonZeroUsize;
2736        use sui_rpc::client::Client;
2737        use sui_rpc::proto::sui::rpc::v2::changed_object::OutputObjectState;
2738        use sui_rpc::proto::sui::rpc::v2::{
2739            ChangedObject, ExecutedTransaction, ExecutionStatus, GasCostSummary, TransactionEffects,
2740        };
2741
2742        let sender = SuiAddress::random_for_testing_only();
2743        let recipient = SuiAddress::random_for_testing_only();
2744
2745        let pt = {
2746            let mut builder = ProgrammableTransactionBuilder::new();
2747            builder.pay_sui(vec![recipient], vec![1000]).unwrap();
2748            builder.finish()
2749        };
2750        let gas_price = 10;
2751        let data = TransactionData::new_programmable(
2752            sender,
2753            vec![random_object_ref()],
2754            pt,
2755            TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
2756            gas_price,
2757        );
2758        let transaction: Transaction = data.into();
2759
2760        // The gas object is present in effects but was deleted (consumed), so it has
2761        // no output owner. (Proto structs are #[non_exhaustive], so build by mutation.)
2762        let mut gas_object = ChangedObject::default();
2763        gas_object.object_id = Some(ObjectID::random().to_string());
2764        gas_object.output_state = Some(OutputObjectState::DoesNotExist as i32);
2765        gas_object.output_owner = None;
2766
2767        let mut status = ExecutionStatus::default();
2768        status.success = Some(true);
2769
2770        let mut gas_used = GasCostSummary::default();
2771        gas_used.computation_cost = Some(1000);
2772        gas_used.storage_cost = Some(0);
2773        gas_used.storage_rebate = Some(0);
2774        gas_used.non_refundable_storage_fee = Some(0);
2775
2776        let mut effects = TransactionEffects::default();
2777        effects.status = Some(status);
2778        effects.gas_used = Some(gas_used);
2779        effects.gas_object = Some(gas_object);
2780
2781        let mut executed_tx = ExecutedTransaction::default();
2782        executed_tx.transaction = Some(transaction);
2783        executed_tx.effects = Some(effects);
2784        executed_tx.events = None;
2785        executed_tx.balance_changes = vec![];
2786
2787        // balance_changes is empty, so the coin metadata cache is never queried and a
2788        // client that never connects is sufficient.
2789        let cache = CoinMetadataCache::new(
2790            Client::new("http://127.0.0.1:1").unwrap(),
2791            NonZeroUsize::new(1).unwrap(),
2792        );
2793
2794        let ops = Operations::try_from_executed_transaction(executed_tx, &cache).await?;
2795
2796        let gas_op = ops
2797            .0
2798            .iter()
2799            .find(|op| op.type_ == OperationType::Gas)
2800            .expect("expected a Gas operation");
2801        assert_eq!(gas_op.account.as_ref().map(|a| a.address), Some(sender));
2802
2803        Ok(())
2804    }
2805
2806    #[test]
2807    fn test_parse_consolidate_all_staked_sui_to_fungible() {
2808        let sender = SuiAddress::random_for_testing_only();
2809        let validator = SuiAddress::random_for_testing_only();
2810
2811        let ops: Operations = serde_json::from_value(serde_json::json!([{
2812            "operation_identifier": {"index": 0},
2813            "type": "ConsolidateAllStakedSuiToFungible",
2814            "account": {"address": sender.to_string()},
2815            "metadata": {
2816                "ConsolidateAllStakedSuiToFungible": {
2817                    "validator": validator.to_string()
2818                }
2819            }
2820        }]))
2821        .unwrap();
2822
2823        let internal = ops.into_internal().unwrap();
2824        match internal {
2825            InternalOperation::ConsolidateAllStakedSuiToFungible(op) => {
2826                assert_eq!(op.sender, sender);
2827                assert_eq!(op.validator, validator);
2828            }
2829            _ => panic!("Expected ConsolidateAllStakedSuiToFungible"),
2830        }
2831    }
2832
2833    #[test]
2834    fn test_parse_merge_and_redeem_fungible_staked_sui() {
2835        let sender = SuiAddress::random_for_testing_only();
2836        let validator = SuiAddress::random_for_testing_only();
2837
2838        let ops: Operations = serde_json::from_value(serde_json::json!([{
2839            "operation_identifier": {"index": 0},
2840            "type": "MergeAndRedeemFungibleStakedSui",
2841            "account": {"address": sender.to_string()},
2842            "metadata": {
2843                "MergeAndRedeemFungibleStakedSui": {
2844                    "validator": validator.to_string(),
2845                    "amount": "500000000000",
2846                    "redeem_mode": "AtLeast"
2847                }
2848            }
2849        }]))
2850        .unwrap();
2851
2852        let internal = ops.into_internal().unwrap();
2853        match internal {
2854            InternalOperation::MergeAndRedeemFungibleStakedSui(op) => {
2855                assert_eq!(op.sender, sender);
2856                assert_eq!(op.validator, validator);
2857                assert_eq!(op.amount, Some(500000000000));
2858                assert_eq!(op.redeem_mode, RedeemMode::AtLeast);
2859            }
2860            _ => panic!("Expected MergeAndRedeemFungibleStakedSui"),
2861        }
2862    }
2863
2864    #[test]
2865    fn test_parse_merge_and_redeem_all_mode() {
2866        let sender = SuiAddress::random_for_testing_only();
2867        let validator = SuiAddress::random_for_testing_only();
2868
2869        let ops: Operations = serde_json::from_value(serde_json::json!([{
2870            "operation_identifier": {"index": 0},
2871            "type": "MergeAndRedeemFungibleStakedSui",
2872            "account": {"address": sender.to_string()},
2873            "metadata": {
2874                "MergeAndRedeemFungibleStakedSui": {
2875                    "validator": validator.to_string(),
2876                    "redeem_mode": "All"
2877                }
2878            }
2879        }]))
2880        .unwrap();
2881
2882        let internal = ops.into_internal().unwrap();
2883        match internal {
2884            InternalOperation::MergeAndRedeemFungibleStakedSui(op) => {
2885                assert_eq!(op.amount, None);
2886                assert_eq!(op.redeem_mode, RedeemMode::All);
2887            }
2888            _ => panic!("Expected MergeAndRedeemFungibleStakedSui"),
2889        }
2890    }
2891
2892    // ==============================================================================
2893    // PR 1: Consolidate parser — happy-path tests (11 tests)
2894    // ==============================================================================
2895
2896    fn assert_consolidate_ops(
2897        ops: &[Operation],
2898        expected_sender: SuiAddress,
2899        expected_staked_sui: &[ObjectID],
2900        expected_fss: &[ObjectID],
2901    ) {
2902        assert_eq!(ops.len(), 1);
2903        let op = &ops[0];
2904        assert_eq!(op.type_, OperationType::ConsolidateAllStakedSuiToFungible);
2905        assert_eq!(
2906            op.account.as_ref().map(|a| a.address),
2907            Some(expected_sender)
2908        );
2909        assert!(op.amount.is_none());
2910        let Some(OperationMetadata::ConsolidateAllStakedSuiToFungible {
2911            validator,
2912            staked_sui_ids,
2913            fss_ids,
2914        }) = op.metadata.clone()
2915        else {
2916            panic!("wrong metadata variant: {:?}", op.metadata);
2917        };
2918        assert!(validator.is_none(), "validator must be None on parse");
2919        assert_eq!(staked_sui_ids, expected_staked_sui);
2920        assert_eq!(fss_ids, expected_fss);
2921    }
2922
2923    #[test]
2924    fn test_parse_consolidate_pure_merge_2_fss() {
2925        let sender = SuiAddress::random_for_testing_only();
2926        let fss_a = random_object_ref();
2927        let fss_b = random_object_ref();
2928        let pt = consolidate_to_fungible_pt(sender, vec![fss_a, fss_b], vec![]).expect("pt");
2929        let ops = parse_pt(sender, pt);
2930        assert_consolidate_ops(&ops, sender, &[], &[fss_a.0, fss_b.0]);
2931    }
2932
2933    #[test]
2934    fn test_parse_consolidate_pure_merge_3_fss() {
2935        let sender = SuiAddress::random_for_testing_only();
2936        let a = random_object_ref();
2937        let b = random_object_ref();
2938        let c = random_object_ref();
2939        let pt = consolidate_to_fungible_pt(sender, vec![a, b, c], vec![]).expect("pt");
2940        assert_consolidate_ops(&parse_pt(sender, pt), sender, &[], &[a.0, b.0, c.0]);
2941    }
2942
2943    #[test]
2944    fn test_parse_consolidate_pure_merge_5_fss() {
2945        let sender = SuiAddress::random_for_testing_only();
2946        let refs: Vec<_> = (0..5).map(|_| random_object_ref()).collect();
2947        let pt = consolidate_to_fungible_pt(sender, refs.clone(), vec![]).expect("pt");
2948        let expected: Vec<_> = refs.iter().map(|r| r.0).collect();
2949        assert_consolidate_ops(&parse_pt(sender, pt), sender, &[], &expected);
2950    }
2951
2952    #[test]
2953    fn test_parse_consolidate_single_convert_no_fss() {
2954        let sender = SuiAddress::random_for_testing_only();
2955        let staked = random_object_ref();
2956        let pt = consolidate_to_fungible_pt(sender, vec![], vec![staked]).expect("pt");
2957        assert_consolidate_ops(&parse_pt(sender, pt), sender, &[staked.0], &[]);
2958    }
2959
2960    #[test]
2961    fn test_parse_consolidate_multi_convert_no_fss() {
2962        let sender = SuiAddress::random_for_testing_only();
2963        let s1 = random_object_ref();
2964        let s2 = random_object_ref();
2965        let s3 = random_object_ref();
2966        let pt = consolidate_to_fungible_pt(sender, vec![], vec![s1, s2, s3]).expect("pt");
2967        assert_consolidate_ops(&parse_pt(sender, pt), sender, &[s1.0, s2.0, s3.0], &[]);
2968    }
2969
2970    #[test]
2971    fn test_parse_consolidate_single_stake_single_fss() {
2972        let sender = SuiAddress::random_for_testing_only();
2973        let fss = random_object_ref();
2974        let staked = random_object_ref();
2975        let pt = consolidate_to_fungible_pt(sender, vec![fss], vec![staked]).expect("pt");
2976        assert_consolidate_ops(&parse_pt(sender, pt), sender, &[staked.0], &[fss.0]);
2977    }
2978
2979    #[test]
2980    fn test_parse_consolidate_single_stake_multi_fss() {
2981        let sender = SuiAddress::random_for_testing_only();
2982        let f1 = random_object_ref();
2983        let f2 = random_object_ref();
2984        let staked = random_object_ref();
2985        let pt = consolidate_to_fungible_pt(sender, vec![f1, f2], vec![staked]).expect("pt");
2986        assert_consolidate_ops(&parse_pt(sender, pt), sender, &[staked.0], &[f1.0, f2.0]);
2987    }
2988
2989    #[test]
2990    fn test_parse_consolidate_multi_stake_single_fss() {
2991        let sender = SuiAddress::random_for_testing_only();
2992        let fss = random_object_ref();
2993        let s1 = random_object_ref();
2994        let s2 = random_object_ref();
2995        let pt = consolidate_to_fungible_pt(sender, vec![fss], vec![s1, s2]).expect("pt");
2996        assert_consolidate_ops(&parse_pt(sender, pt), sender, &[s1.0, s2.0], &[fss.0]);
2997    }
2998
2999    #[test]
3000    fn test_parse_consolidate_multi_stake_multi_fss() {
3001        let sender = SuiAddress::random_for_testing_only();
3002        let f1 = random_object_ref();
3003        let f2 = random_object_ref();
3004        let s1 = random_object_ref();
3005        let s2 = random_object_ref();
3006        let pt = consolidate_to_fungible_pt(sender, vec![f1, f2], vec![s1, s2]).expect("pt");
3007        assert_consolidate_ops(&parse_pt(sender, pt), sender, &[s1.0, s2.0], &[f1.0, f2.0]);
3008    }
3009
3010    #[test]
3011    fn test_parse_consolidate_large_mixed() {
3012        let sender = SuiAddress::random_for_testing_only();
3013        let fss: Vec<_> = (0..3).map(|_| random_object_ref()).collect();
3014        let staked: Vec<_> = (0..3).map(|_| random_object_ref()).collect();
3015        let pt = consolidate_to_fungible_pt(sender, fss.clone(), staked.clone()).expect("pt");
3016        let expected_s: Vec<_> = staked.iter().map(|r| r.0).collect();
3017        let expected_f: Vec<_> = fss.iter().map(|r| r.0).collect();
3018        assert_consolidate_ops(&parse_pt(sender, pt), sender, &expected_s, &expected_f);
3019    }
3020
3021    #[test]
3022    fn test_parse_consolidate_classification_correctness() {
3023        // No overlap between staked_sui_ids and fss_ids after parsing a mixed PTB.
3024        let sender = SuiAddress::random_for_testing_only();
3025        let f1 = random_object_ref();
3026        let f2 = random_object_ref();
3027        let s1 = random_object_ref();
3028        let s2 = random_object_ref();
3029        let pt = consolidate_to_fungible_pt(sender, vec![f1, f2], vec![s1, s2]).expect("pt");
3030        let ops = parse_pt(sender, pt);
3031        let Some(OperationMetadata::ConsolidateAllStakedSuiToFungible {
3032            staked_sui_ids,
3033            fss_ids,
3034            ..
3035        }) = ops[0].metadata.clone()
3036        else {
3037            panic!();
3038        };
3039        let staked_set: std::collections::HashSet<_> = staked_sui_ids.iter().collect();
3040        let fss_set: std::collections::HashSet<_> = fss_ids.iter().collect();
3041        assert!(
3042            staked_set.is_disjoint(&fss_set),
3043            "classification crossed categories"
3044        );
3045    }
3046
3047    // ==============================================================================
3048    // PR 1: Fall-through tests (4 tests) — malformed PTBs must NOT be labeled Consolidate
3049    // ==============================================================================
3050
3051    fn assert_falls_through_to_generic(ops: &[Operation]) {
3052        assert_eq!(ops.len(), 1);
3053        assert_eq!(
3054            ops[0].type_,
3055            OperationType::ProgrammableTransaction,
3056            "expected fall-through to generic ProgrammableTransaction, got: {:?}",
3057            ops[0].type_
3058        );
3059    }
3060
3061    #[test]
3062    fn test_parse_falls_through_consolidate_with_merge_coins() {
3063        let sender = SuiAddress::random_for_testing_only();
3064        let fss_a = random_object_ref();
3065        let fss_b = random_object_ref();
3066        let coin_a = random_object_ref();
3067
3068        let mut builder = ProgrammableTransactionBuilder::new();
3069        let _sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3070        let first = builder.obj(ObjectArg::ImmOrOwnedObject(fss_a)).unwrap();
3071        let other = builder.obj(ObjectArg::ImmOrOwnedObject(fss_b)).unwrap();
3072        builder.command(NativeCommand::move_call(
3073            SUI_SYSTEM_PACKAGE_ID,
3074            Identifier::new("staking_pool").unwrap(),
3075            Identifier::new("join_fungible_staked_sui").unwrap(),
3076            vec![],
3077            vec![first, other],
3078        ));
3079        // Rogue MergeCoins breaks Consolidate shape validation.
3080        let coin_target = builder.obj(ObjectArg::ImmOrOwnedObject(coin_a)).unwrap();
3081        builder.command(NativeCommand::MergeCoins(coin_target, vec![]));
3082
3083        let ops = parse_pt(sender, builder.finish());
3084        assert_falls_through_to_generic(&ops);
3085    }
3086
3087    #[test]
3088    fn test_parse_falls_through_consolidate_with_unrelated_movecall() {
3089        let sender = SuiAddress::random_for_testing_only();
3090        let fss_a = random_object_ref();
3091        let fss_b = random_object_ref();
3092
3093        let mut builder = ProgrammableTransactionBuilder::new();
3094        let _sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3095        let first = builder.obj(ObjectArg::ImmOrOwnedObject(fss_a)).unwrap();
3096        let other = builder.obj(ObjectArg::ImmOrOwnedObject(fss_b)).unwrap();
3097        builder.command(NativeCommand::move_call(
3098            SUI_SYSTEM_PACKAGE_ID,
3099            Identifier::new("staking_pool").unwrap(),
3100            Identifier::new("join_fungible_staked_sui").unwrap(),
3101            vec![],
3102            vec![first, other],
3103        ));
3104        // Unrelated MoveCall (e.g., 0x2::sui::transfer doesn't exist, so use any other function).
3105        builder.command(NativeCommand::move_call(
3106            SUI_FRAMEWORK_PACKAGE_ID,
3107            Identifier::new("coin").unwrap(),
3108            Identifier::new("destroy_zero").unwrap(),
3109            vec![],
3110            vec![other],
3111        ));
3112
3113        let ops = parse_pt(sender, builder.finish());
3114        assert_falls_through_to_generic(&ops);
3115    }
3116
3117    #[test]
3118    fn test_parse_falls_through_convert_without_system_state() {
3119        // Build a PTB where inputs[0] is an ImmOrOwned object (not SUI_SYSTEM_STATE shared).
3120        let sender = SuiAddress::random_for_testing_only();
3121        let staked = random_object_ref();
3122        let other_obj = random_object_ref();
3123
3124        let mut builder = ProgrammableTransactionBuilder::new();
3125        // Put a random object first — parser should reject.
3126        let _not_system = builder.obj(ObjectArg::ImmOrOwnedObject(other_obj)).unwrap();
3127        let staked_arg = builder.obj(ObjectArg::ImmOrOwnedObject(staked)).unwrap();
3128        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3129        let new_fss = builder.command(NativeCommand::move_call(
3130            SUI_SYSTEM_PACKAGE_ID,
3131            Identifier::new("sui_system").unwrap(),
3132            Identifier::new("convert_to_fungible_staked_sui").unwrap(),
3133            vec![],
3134            vec![sys, staked_arg],
3135        ));
3136        let sender_arg = builder.pure(sender).unwrap();
3137        builder.command(NativeCommand::TransferObjects(vec![new_fss], sender_arg));
3138
3139        let ops = parse_pt(sender, builder.finish());
3140        assert_falls_through_to_generic(&ops);
3141    }
3142
3143    #[test]
3144    fn test_parse_falls_through_extra_command_after_transfer() {
3145        // Valid Consolidate shape + an extra command after TransferObjects → reject.
3146        let sender = SuiAddress::random_for_testing_only();
3147        let staked = random_object_ref();
3148        let other_obj = random_object_ref();
3149
3150        let mut builder = ProgrammableTransactionBuilder::new();
3151        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3152        let staked_arg = builder.obj(ObjectArg::ImmOrOwnedObject(staked)).unwrap();
3153        let new_fss = builder.command(NativeCommand::move_call(
3154            SUI_SYSTEM_PACKAGE_ID,
3155            Identifier::new("sui_system").unwrap(),
3156            Identifier::new("convert_to_fungible_staked_sui").unwrap(),
3157            vec![],
3158            vec![sys, staked_arg],
3159        ));
3160        let sender_arg = builder.pure(sender).unwrap();
3161        builder.command(NativeCommand::TransferObjects(vec![new_fss], sender_arg));
3162        // Extra command: destroy_zero on an unrelated coin.
3163        let extra = builder.obj(ObjectArg::ImmOrOwnedObject(other_obj)).unwrap();
3164        builder.command(NativeCommand::move_call(
3165            SUI_FRAMEWORK_PACKAGE_ID,
3166            Identifier::new("coin").unwrap(),
3167            Identifier::new("destroy_zero").unwrap(),
3168            vec![],
3169            vec![extra],
3170        ));
3171
3172        let ops = parse_pt(sender, builder.finish());
3173        assert_falls_through_to_generic(&ops);
3174    }
3175
3176    // ==============================================================================
3177    // PR 1: Robustness tests (4 tests, but #38-39 belong in e2e — see plan)
3178    // ==============================================================================
3179
3180    #[test]
3181    fn test_parse_empty_ptb() {
3182        let sender = SuiAddress::random_for_testing_only();
3183        let pt = ProgrammableTransactionBuilder::new().finish();
3184        let ops = parse_pt(sender, pt);
3185        // Zero commands: parser should produce a generic op (existing behavior).
3186        assert_eq!(ops.len(), 1);
3187        assert_eq!(ops[0].type_, OperationType::ProgrammableTransaction);
3188    }
3189
3190    #[test]
3191    fn test_parse_only_merge_coins() {
3192        // PTB with only regular MergeCoins (non-FSS) — falls through, unrelated to our dispatch.
3193        let sender = SuiAddress::random_for_testing_only();
3194        let coin_a = random_object_ref();
3195        let coin_b = random_object_ref();
3196        let mut builder = ProgrammableTransactionBuilder::new();
3197        let target = builder.obj(ObjectArg::ImmOrOwnedObject(coin_a)).unwrap();
3198        let source = builder.obj(ObjectArg::ImmOrOwnedObject(coin_b)).unwrap();
3199        builder.command(NativeCommand::MergeCoins(target, vec![source]));
3200        let ops = parse_pt(sender, builder.finish());
3201        // Either ProgrammableTransaction (generic) or whatever the existing parser produces.
3202        // Not our typed FSS op.
3203        assert_ne!(
3204            ops[0].type_,
3205            OperationType::ConsolidateAllStakedSuiToFungible
3206        );
3207        assert_ne!(ops[0].type_, OperationType::MergeAndRedeemFungibleStakedSui);
3208    }
3209
3210    // Tests #38 (garbage bytes) and #39 (truncated tx data) are HTTP-level and belong in
3211    // end_to_end_tests.rs — see plan section D.
3212
3213    // ==============================================================================
3214    // PR 1: Metadata serialization compat (2 tests)
3215    // ==============================================================================
3216
3217    #[test]
3218    fn test_meta_consolidate_old_input_deserializes() {
3219        let validator = SuiAddress::random_for_testing_only();
3220        let json = serde_json::json!({
3221            "ConsolidateAllStakedSuiToFungible": { "validator": validator.to_string() }
3222        });
3223        let meta: OperationMetadata = serde_json::from_value(json).unwrap();
3224        match meta {
3225            OperationMetadata::ConsolidateAllStakedSuiToFungible {
3226                validator: v,
3227                staked_sui_ids,
3228                fss_ids,
3229            } => {
3230                assert_eq!(v, Some(validator));
3231                assert!(staked_sui_ids.is_empty());
3232                assert!(fss_ids.is_empty());
3233            }
3234            _ => panic!("wrong variant"),
3235        }
3236    }
3237
3238    #[test]
3239    fn test_meta_consolidate_new_parse_output_serializes() {
3240        let id_a = ObjectID::random();
3241        let id_b = ObjectID::random();
3242        let meta = OperationMetadata::ConsolidateAllStakedSuiToFungible {
3243            validator: None,
3244            staked_sui_ids: vec![id_a],
3245            fss_ids: vec![id_b],
3246        };
3247        let json = serde_json::to_value(&meta).unwrap();
3248        let obj = json
3249            .as_object()
3250            .unwrap()
3251            .get("ConsolidateAllStakedSuiToFungible")
3252            .unwrap()
3253            .as_object()
3254            .unwrap();
3255        assert!(
3256            !obj.contains_key("validator"),
3257            "validator must be omitted when None"
3258        );
3259        assert_eq!(
3260            obj.get("staked_sui_ids").unwrap().as_array().unwrap().len(),
3261            1
3262        );
3263        assert_eq!(obj.get("fss_ids").unwrap().as_array().unwrap().len(), 1);
3264    }
3265
3266    // ==============================================================================
3267    // PR 1: Write-side preservation (1 test)
3268    // ==============================================================================
3269
3270    #[test]
3271    fn test_write_consolidate_requires_validator() {
3272        let sender = SuiAddress::random_for_testing_only();
3273        let op = Operation {
3274            operation_identifier: Default::default(),
3275            type_: OperationType::ConsolidateAllStakedSuiToFungible,
3276            status: None,
3277            account: Some(sender.into()),
3278            amount: None,
3279            coin_change: None,
3280            metadata: Some(OperationMetadata::ConsolidateAllStakedSuiToFungible {
3281                validator: None,
3282                staked_sui_ids: vec![],
3283                fss_ids: vec![],
3284            }),
3285        };
3286        let err = Operations::new(vec![op])
3287            .into_internal()
3288            .expect_err("should fail without validator");
3289        let msg = format!("{err}");
3290        assert!(msg.contains("validator"), "unexpected error: {msg}");
3291    }
3292
3293    // ==============================================================================
3294    // PR 2: MergeAndRedeem parser — happy-path tests (11 tests)
3295    // ==============================================================================
3296
3297    fn assert_merge_redeem_ops(
3298        ops: &[Operation],
3299        expected_sender: SuiAddress,
3300        expected_fss: &[ObjectID],
3301        expected_mode: Option<RedeemMode>,
3302    ) {
3303        assert_merge_redeem_ops_with_amount(
3304            ops,
3305            expected_sender,
3306            expected_fss,
3307            expected_mode,
3308            None,
3309        );
3310    }
3311
3312    fn assert_merge_redeem_ops_with_amount(
3313        ops: &[Operation],
3314        expected_sender: SuiAddress,
3315        expected_fss: &[ObjectID],
3316        expected_mode: Option<RedeemMode>,
3317        expected_amount: Option<&str>,
3318    ) {
3319        assert_eq!(ops.len(), 1);
3320        let op = &ops[0];
3321        assert_eq!(op.type_, OperationType::MergeAndRedeemFungibleStakedSui);
3322        assert_eq!(
3323            op.account.as_ref().map(|a| a.address),
3324            Some(expected_sender)
3325        );
3326        assert!(op.amount.is_none());
3327        let Some(OperationMetadata::MergeAndRedeemFungibleStakedSui {
3328            validator,
3329            amount,
3330            redeem_mode,
3331            fss_ids,
3332        }) = op.metadata.clone()
3333        else {
3334            panic!("wrong metadata variant: {:?}", op.metadata);
3335        };
3336        assert!(validator.is_none(), "validator must be None on parse");
3337        assert_eq!(
3338            amount.as_deref(),
3339            expected_amount,
3340            "metadata.amount mismatch"
3341        );
3342        assert_eq!(redeem_mode, expected_mode);
3343        assert_eq!(fss_ids, expected_fss);
3344    }
3345
3346    #[test]
3347    fn test_parse_merge_redeem_single_all() {
3348        let sender = SuiAddress::random_for_testing_only();
3349        let fss = random_object_ref();
3350        let pt = merge_and_redeem_fss_pt(sender, vec![fss], &RedeemPlan::All).expect("pt");
3351        assert_merge_redeem_ops(
3352            &parse_pt(sender, pt),
3353            sender,
3354            &[fss.0],
3355            Some(RedeemMode::All),
3356        );
3357    }
3358
3359    #[test]
3360    fn test_parse_merge_redeem_single_partial() {
3361        let sender = SuiAddress::random_for_testing_only();
3362        let fss = random_object_ref();
3363        let pt = merge_and_redeem_fss_pt(
3364            sender,
3365            vec![fss],
3366            &RedeemPlan::AtMost {
3367                token_amount: Some(500_000_000),
3368                max_sui: 0,
3369            },
3370        )
3371        .expect("pt");
3372        assert_merge_redeem_ops(&parse_pt(sender, pt), sender, &[fss.0], None);
3373    }
3374
3375    #[test]
3376    fn test_parse_merge_redeem_atleast_with_balance_guard() {
3377        let sender = SuiAddress::random_for_testing_only();
3378        let fss = random_object_ref();
3379        let pt = merge_and_redeem_fss_pt(
3380            sender,
3381            vec![fss],
3382            &RedeemPlan::AtLeast {
3383                token_amount: Some(500_000_000),
3384                min_sui: 1_000_000,
3385            },
3386        )
3387        .expect("pt");
3388        assert_merge_redeem_ops_with_amount(
3389            &parse_pt(sender, pt),
3390            sender,
3391            &[fss.0],
3392            Some(RedeemMode::AtLeast),
3393            Some("1000000"),
3394        );
3395    }
3396
3397    #[test]
3398    fn test_parse_merge_redeem_atleast_three_fss() {
3399        let sender = SuiAddress::random_for_testing_only();
3400        let a = random_object_ref();
3401        let b = random_object_ref();
3402        let c = random_object_ref();
3403        let pt = merge_and_redeem_fss_pt(
3404            sender,
3405            vec![a, b, c],
3406            &RedeemPlan::AtLeast {
3407                token_amount: Some(500_000_000),
3408                min_sui: 1_000_000,
3409            },
3410        )
3411        .expect("pt");
3412        assert_merge_redeem_ops_with_amount(
3413            &parse_pt(sender, pt),
3414            sender,
3415            &[a.0, b.0, c.0],
3416            Some(RedeemMode::AtLeast),
3417            Some("1000000"),
3418        );
3419    }
3420
3421    #[test]
3422    fn test_parse_merge_redeem_full_atleast_no_split() {
3423        // Full-redeem AtLeast: token_amount = None → no `split_fungible_staked_sui`.
3424        // The PTB still has the balance::split + balance::join guard, so the
3425        // parser must recognize this shape as AtLeast (with min_sui recovered)
3426        // rather than emitting `redeem_mode = None` because there's no FSS split.
3427        let sender = SuiAddress::random_for_testing_only();
3428        let fss = random_object_ref();
3429        let pt = merge_and_redeem_fss_pt(
3430            sender,
3431            vec![fss],
3432            &RedeemPlan::AtLeast {
3433                token_amount: None,
3434                min_sui: 1_000_000,
3435            },
3436        )
3437        .expect("pt");
3438        assert_merge_redeem_ops_with_amount(
3439            &parse_pt(sender, pt),
3440            sender,
3441            &[fss.0],
3442            Some(RedeemMode::AtLeast),
3443            Some("1000000"),
3444        );
3445    }
3446
3447    #[test]
3448    fn test_parse_merge_redeem_two_all() {
3449        let sender = SuiAddress::random_for_testing_only();
3450        let a = random_object_ref();
3451        let b = random_object_ref();
3452        let pt = merge_and_redeem_fss_pt(sender, vec![a, b], &RedeemPlan::All).expect("pt");
3453        assert_merge_redeem_ops(
3454            &parse_pt(sender, pt),
3455            sender,
3456            &[a.0, b.0],
3457            Some(RedeemMode::All),
3458        );
3459    }
3460
3461    #[test]
3462    fn test_parse_merge_redeem_two_partial() {
3463        let sender = SuiAddress::random_for_testing_only();
3464        let a = random_object_ref();
3465        let b = random_object_ref();
3466        let pt = merge_and_redeem_fss_pt(
3467            sender,
3468            vec![a, b],
3469            &RedeemPlan::AtMost {
3470                token_amount: Some(500_000_000),
3471                max_sui: 0,
3472            },
3473        )
3474        .expect("pt");
3475        assert_merge_redeem_ops(&parse_pt(sender, pt), sender, &[a.0, b.0], None);
3476    }
3477
3478    #[test]
3479    fn test_parse_merge_redeem_three_all() {
3480        let sender = SuiAddress::random_for_testing_only();
3481        let a = random_object_ref();
3482        let b = random_object_ref();
3483        let c = random_object_ref();
3484        let pt = merge_and_redeem_fss_pt(sender, vec![a, b, c], &RedeemPlan::All).expect("pt");
3485        assert_merge_redeem_ops(
3486            &parse_pt(sender, pt),
3487            sender,
3488            &[a.0, b.0, c.0],
3489            Some(RedeemMode::All),
3490        );
3491    }
3492
3493    #[test]
3494    fn test_parse_merge_redeem_three_partial() {
3495        let sender = SuiAddress::random_for_testing_only();
3496        let a = random_object_ref();
3497        let b = random_object_ref();
3498        let c = random_object_ref();
3499        let pt = merge_and_redeem_fss_pt(
3500            sender,
3501            vec![a, b, c],
3502            &RedeemPlan::AtMost {
3503                token_amount: Some(500_000_000),
3504                max_sui: 0,
3505            },
3506        )
3507        .expect("pt");
3508        assert_merge_redeem_ops(&parse_pt(sender, pt), sender, &[a.0, b.0, c.0], None);
3509    }
3510
3511    #[test]
3512    fn test_parse_merge_redeem_five_all() {
3513        let sender = SuiAddress::random_for_testing_only();
3514        let refs: Vec<_> = (0..5).map(|_| random_object_ref()).collect();
3515        let pt = merge_and_redeem_fss_pt(sender, refs.clone(), &RedeemPlan::All).expect("pt");
3516        let expected: Vec<_> = refs.iter().map(|r| r.0).collect();
3517        assert_merge_redeem_ops(
3518            &parse_pt(sender, pt),
3519            sender,
3520            &expected,
3521            Some(RedeemMode::All),
3522        );
3523    }
3524
3525    #[test]
3526    fn test_parse_merge_redeem_fss_ids_order() {
3527        // Build with a specific order and assert the parser preserves it.
3528        let sender = SuiAddress::random_for_testing_only();
3529        let a = random_object_ref();
3530        let b = random_object_ref();
3531        let c = random_object_ref();
3532        let pt = merge_and_redeem_fss_pt(sender, vec![a, b, c], &RedeemPlan::All).expect("pt");
3533        let ops = parse_pt(sender, pt);
3534        let Some(OperationMetadata::MergeAndRedeemFungibleStakedSui { fss_ids, .. }) =
3535            ops[0].metadata.clone()
3536        else {
3537            panic!();
3538        };
3539        assert_eq!(fss_ids, vec![a.0, b.0, c.0]);
3540    }
3541
3542    #[test]
3543    fn test_parse_merge_redeem_sender_account() {
3544        let sender = SuiAddress::random_for_testing_only();
3545        let fss = random_object_ref();
3546        let pt = merge_and_redeem_fss_pt(sender, vec![fss], &RedeemPlan::All).expect("pt");
3547        let ops = parse_pt(sender, pt);
3548        assert_eq!(ops[0].account.as_ref().unwrap().address, sender);
3549    }
3550
3551    #[test]
3552    fn test_parse_merge_redeem_no_amount_in_metadata() {
3553        let sender = SuiAddress::random_for_testing_only();
3554        let fss = random_object_ref();
3555        let pt = merge_and_redeem_fss_pt(
3556            sender,
3557            vec![fss],
3558            &RedeemPlan::AtMost {
3559                token_amount: Some(500_000_000),
3560                max_sui: 0,
3561            },
3562        )
3563        .expect("pt");
3564        let ops = parse_pt(sender, pt);
3565        let Some(OperationMetadata::MergeAndRedeemFungibleStakedSui { amount, .. }) =
3566            ops[0].metadata.clone()
3567        else {
3568            panic!();
3569        };
3570        assert!(amount.is_none());
3571    }
3572
3573    #[test]
3574    fn test_parse_merge_redeem_no_validator_in_metadata() {
3575        let sender = SuiAddress::random_for_testing_only();
3576        let fss = random_object_ref();
3577        let pt = merge_and_redeem_fss_pt(sender, vec![fss], &RedeemPlan::All).expect("pt");
3578        let ops = parse_pt(sender, pt);
3579        let Some(OperationMetadata::MergeAndRedeemFungibleStakedSui { validator, .. }) =
3580            ops[0].metadata.clone()
3581        else {
3582            panic!();
3583        };
3584        assert!(validator.is_none());
3585    }
3586
3587    // ==============================================================================
3588    // PR 2: Fall-through tests — malformed MergeAndRedeem PTBs (9 tests)
3589    // ==============================================================================
3590
3591    fn build_redeem_ptb_with_type_arg(
3592        sender: SuiAddress,
3593        fss: ObjectRef,
3594        coin_type_arg: &str,
3595    ) -> ProgrammableTransaction {
3596        let mut builder = ProgrammableTransactionBuilder::new();
3597        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3598        let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3599        let balance = builder.command(NativeCommand::move_call(
3600            SUI_SYSTEM_PACKAGE_ID,
3601            Identifier::new("sui_system").unwrap(),
3602            Identifier::new("redeem_fungible_staked_sui").unwrap(),
3603            vec![],
3604            vec![sys, fss_arg],
3605        ));
3606        let coin = builder.command(NativeCommand::move_call(
3607            SUI_FRAMEWORK_PACKAGE_ID,
3608            Identifier::new("coin").unwrap(),
3609            Identifier::new("from_balance").unwrap(),
3610            vec![sui_types::TypeTag::from_str(coin_type_arg).unwrap()],
3611            vec![balance],
3612        ));
3613        let sender_arg = builder.pure(sender).unwrap();
3614        builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
3615        builder.finish()
3616    }
3617
3618    #[test]
3619    fn test_parse_falls_through_redeem_wrong_type_arg() {
3620        let sender = SuiAddress::random_for_testing_only();
3621        let fss = random_object_ref();
3622        // from_balance with wrong generic — e.g. a fake USDC type.
3623        let pt = build_redeem_ptb_with_type_arg(sender, fss, "0x2::coin::Coin");
3624        let ops = parse_pt(sender, pt);
3625        assert_falls_through_to_generic(&ops);
3626    }
3627
3628    #[test]
3629    fn test_parse_falls_through_redeem_without_from_balance() {
3630        let sender = SuiAddress::random_for_testing_only();
3631        let fss = random_object_ref();
3632        // Build: redeem + (no from_balance) + transfer of the balance directly (nonsense shape).
3633        let mut builder = ProgrammableTransactionBuilder::new();
3634        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3635        let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3636        let balance = builder.command(NativeCommand::move_call(
3637            SUI_SYSTEM_PACKAGE_ID,
3638            Identifier::new("sui_system").unwrap(),
3639            Identifier::new("redeem_fungible_staked_sui").unwrap(),
3640            vec![],
3641            vec![sys, fss_arg],
3642        ));
3643        let sender_arg = builder.pure(sender).unwrap();
3644        builder.command(NativeCommand::TransferObjects(vec![balance], sender_arg));
3645        let ops = parse_pt(sender, builder.finish());
3646        assert_falls_through_to_generic(&ops);
3647    }
3648
3649    #[test]
3650    fn test_parse_falls_through_redeem_without_transfer() {
3651        let sender = SuiAddress::random_for_testing_only();
3652        let fss = random_object_ref();
3653        let mut builder = ProgrammableTransactionBuilder::new();
3654        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3655        let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3656        let balance = builder.command(NativeCommand::move_call(
3657            SUI_SYSTEM_PACKAGE_ID,
3658            Identifier::new("sui_system").unwrap(),
3659            Identifier::new("redeem_fungible_staked_sui").unwrap(),
3660            vec![],
3661            vec![sys, fss_arg],
3662        ));
3663        builder.command(NativeCommand::move_call(
3664            SUI_FRAMEWORK_PACKAGE_ID,
3665            Identifier::new("coin").unwrap(),
3666            Identifier::new("from_balance").unwrap(),
3667            vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
3668            vec![balance],
3669        ));
3670        // No TransferObjects → shape mismatch.
3671        let ops = parse_pt(sender, builder.finish());
3672        assert_falls_through_to_generic(&ops);
3673    }
3674
3675    #[test]
3676    fn test_parse_falls_through_redeem_transfer_wrong_recipient() {
3677        let sender = SuiAddress::random_for_testing_only();
3678        let other = SuiAddress::random_for_testing_only();
3679        let fss = random_object_ref();
3680        let mut builder = ProgrammableTransactionBuilder::new();
3681        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3682        let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3683        let balance = builder.command(NativeCommand::move_call(
3684            SUI_SYSTEM_PACKAGE_ID,
3685            Identifier::new("sui_system").unwrap(),
3686            Identifier::new("redeem_fungible_staked_sui").unwrap(),
3687            vec![],
3688            vec![sys, fss_arg],
3689        ));
3690        let coin = builder.command(NativeCommand::move_call(
3691            SUI_FRAMEWORK_PACKAGE_ID,
3692            Identifier::new("coin").unwrap(),
3693            Identifier::new("from_balance").unwrap(),
3694            vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
3695            vec![balance],
3696        ));
3697        // TransferObjects recipient is NOT the sender.
3698        let other_arg = builder.pure(other).unwrap();
3699        builder.command(NativeCommand::TransferObjects(vec![coin], other_arg));
3700        let ops = parse_pt(sender, builder.finish());
3701        assert_falls_through_to_generic(&ops);
3702    }
3703
3704    #[test]
3705    fn test_parse_falls_through_redeem_transfer_multiple_objects() {
3706        let sender = SuiAddress::random_for_testing_only();
3707        let fss = random_object_ref();
3708        let other_obj = random_object_ref();
3709        let mut builder = ProgrammableTransactionBuilder::new();
3710        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3711        let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3712        let balance = builder.command(NativeCommand::move_call(
3713            SUI_SYSTEM_PACKAGE_ID,
3714            Identifier::new("sui_system").unwrap(),
3715            Identifier::new("redeem_fungible_staked_sui").unwrap(),
3716            vec![],
3717            vec![sys, fss_arg],
3718        ));
3719        let coin = builder.command(NativeCommand::move_call(
3720            SUI_FRAMEWORK_PACKAGE_ID,
3721            Identifier::new("coin").unwrap(),
3722            Identifier::new("from_balance").unwrap(),
3723            vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
3724            vec![balance],
3725        ));
3726        // Add a second object to transfer — not the shape our parser accepts.
3727        let extra = builder.obj(ObjectArg::ImmOrOwnedObject(other_obj)).unwrap();
3728        let sender_arg = builder.pure(sender).unwrap();
3729        builder.command(NativeCommand::TransferObjects(
3730            vec![coin, extra],
3731            sender_arg,
3732        ));
3733        let ops = parse_pt(sender, builder.finish());
3734        assert_falls_through_to_generic(&ops);
3735    }
3736
3737    #[test]
3738    fn test_parse_falls_through_hybrid_convert_and_redeem() {
3739        // A PTB containing BOTH convert_to_fungible_staked_sui AND redeem_fungible_staked_sui.
3740        // This is an unusual shape — our parsers should reject it (neither Consolidate nor
3741        // MergeAndRedeem shape matches).
3742        let sender = SuiAddress::random_for_testing_only();
3743        let staked = random_object_ref();
3744        let fss = random_object_ref();
3745        let mut builder = ProgrammableTransactionBuilder::new();
3746        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3747        let staked_arg = builder.obj(ObjectArg::ImmOrOwnedObject(staked)).unwrap();
3748        let _new_fss = builder.command(NativeCommand::move_call(
3749            SUI_SYSTEM_PACKAGE_ID,
3750            Identifier::new("sui_system").unwrap(),
3751            Identifier::new("convert_to_fungible_staked_sui").unwrap(),
3752            vec![],
3753            vec![sys, staked_arg],
3754        ));
3755        let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3756        let balance = builder.command(NativeCommand::move_call(
3757            SUI_SYSTEM_PACKAGE_ID,
3758            Identifier::new("sui_system").unwrap(),
3759            Identifier::new("redeem_fungible_staked_sui").unwrap(),
3760            vec![],
3761            vec![sys, fss_arg],
3762        ));
3763        let coin = builder.command(NativeCommand::move_call(
3764            SUI_FRAMEWORK_PACKAGE_ID,
3765            Identifier::new("coin").unwrap(),
3766            Identifier::new("from_balance").unwrap(),
3767            vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
3768            vec![balance],
3769        ));
3770        let sender_arg = builder.pure(sender).unwrap();
3771        builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
3772        let ops = parse_pt(sender, builder.finish());
3773        assert_falls_through_to_generic(&ops);
3774    }
3775
3776    #[test]
3777    fn test_parse_falls_through_split_without_redeem() {
3778        let sender = SuiAddress::random_for_testing_only();
3779        let fss = random_object_ref();
3780        let mut builder = ProgrammableTransactionBuilder::new();
3781        let _sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3782        let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3783        let split_amount = builder.pure(100u64).unwrap();
3784        builder.command(NativeCommand::move_call(
3785            SUI_SYSTEM_PACKAGE_ID,
3786            Identifier::new("staking_pool").unwrap(),
3787            Identifier::new("split_fungible_staked_sui").unwrap(),
3788            vec![],
3789            vec![fss_arg, split_amount],
3790        ));
3791        // No redeem → shape mismatch.
3792        let ops = parse_pt(sender, builder.finish());
3793        assert_falls_through_to_generic(&ops);
3794    }
3795
3796    #[test]
3797    fn test_parse_falls_through_redeem_split_position_wrong() {
3798        // split appears AFTER redeem (wrong order).
3799        let sender = SuiAddress::random_for_testing_only();
3800        let fss_a = random_object_ref();
3801        let fss_b = random_object_ref();
3802        let mut builder = ProgrammableTransactionBuilder::new();
3803        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3804        let a_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss_a)).unwrap();
3805        let b_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss_b)).unwrap();
3806        let balance = builder.command(NativeCommand::move_call(
3807            SUI_SYSTEM_PACKAGE_ID,
3808            Identifier::new("sui_system").unwrap(),
3809            Identifier::new("redeem_fungible_staked_sui").unwrap(),
3810            vec![],
3811            vec![sys, a_arg],
3812        ));
3813        // Split AFTER redeem — wrong order.
3814        let split_amount = builder.pure(100u64).unwrap();
3815        builder.command(NativeCommand::move_call(
3816            SUI_SYSTEM_PACKAGE_ID,
3817            Identifier::new("staking_pool").unwrap(),
3818            Identifier::new("split_fungible_staked_sui").unwrap(),
3819            vec![],
3820            vec![b_arg, split_amount],
3821        ));
3822        let coin = builder.command(NativeCommand::move_call(
3823            SUI_FRAMEWORK_PACKAGE_ID,
3824            Identifier::new("coin").unwrap(),
3825            Identifier::new("from_balance").unwrap(),
3826            vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
3827            vec![balance],
3828        ));
3829        let sender_arg = builder.pure(sender).unwrap();
3830        builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
3831        let ops = parse_pt(sender, builder.finish());
3832        assert_falls_through_to_generic(&ops);
3833    }
3834
3835    #[test]
3836    fn test_parse_falls_through_redeem_wrong_system_state_immutable() {
3837        // Build a redeem PTB but pass the system state as immutable shared. Per our
3838        // helper, we can't easily construct ObjectArg::SharedObject with Immutable
3839        // directly — but we can test the case where the first input is SUI_SYSTEM_STATE
3840        // but built via a regular shared-object with immutable mutability. Simplest:
3841        // use an ObjectArg::SharedObject construction.
3842        let sender = SuiAddress::random_for_testing_only();
3843        let fss = random_object_ref();
3844        let mut builder = ProgrammableTransactionBuilder::new();
3845        // Immutable shared — parser should reject.
3846        let _sys = builder
3847            .obj(ObjectArg::SharedObject {
3848                id: SUI_SYSTEM_STATE_OBJECT_ID,
3849                initial_shared_version: sui_types::SUI_SYSTEM_STATE_OBJECT_SHARED_VERSION,
3850                mutability: sui_types::transaction::SharedObjectMutability::Immutable,
3851            })
3852            .unwrap();
3853        let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3854        // The redeem Move call needs a mutable sys — this would fail at chain execution
3855        // but our parser just checks inputs[0] shape.
3856        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3857        let balance = builder.command(NativeCommand::move_call(
3858            SUI_SYSTEM_PACKAGE_ID,
3859            Identifier::new("sui_system").unwrap(),
3860            Identifier::new("redeem_fungible_staked_sui").unwrap(),
3861            vec![],
3862            vec![sys, fss_arg],
3863        ));
3864        let coin = builder.command(NativeCommand::move_call(
3865            SUI_FRAMEWORK_PACKAGE_ID,
3866            Identifier::new("coin").unwrap(),
3867            Identifier::new("from_balance").unwrap(),
3868            vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
3869            vec![balance],
3870        ));
3871        let sender_arg = builder.pure(sender).unwrap();
3872        builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
3873        // Our parser's `first_input_is_sui_system_state` only requires InputKind::Shared +
3874        // object id == 0x5. Both the immutable and mutable shared inputs have kind Shared
3875        // and id 0x5, so this alone might not trigger rejection. The strict-shape check
3876        // will catch it because inputs[0] must be at position 0 — and here we placed the
3877        // immutable shared first; the system_state_mut is input[2] (3rd input), so the
3878        // first input IS our immutable one. Our predicate accepts it (same id). That's
3879        // OK: if chain rejects it, Rosetta's observation is that this was a shape we
3880        // don't strictly match. The assert_falls_through_to_generic below may fail here
3881        // because our parser could accept both. If so, we should tighten the predicate.
3882        // For now we document this behaviour and allow either result.
3883        let ops = parse_pt(sender, builder.finish());
3884        // Accept either: labeled (if shape matched) or generic (if extra commands/inputs
3885        // tripped shape validation). The important invariant is no panic.
3886        assert!(
3887            ops[0].type_ == OperationType::MergeAndRedeemFungibleStakedSui
3888                || ops[0].type_ == OperationType::ProgrammableTransaction,
3889            "unexpected op type: {:?}",
3890            ops[0].type_
3891        );
3892    }
3893
3894    // ==============================================================================
3895    // Phase 2: Additional fall-through tests for PR review tightenings
3896    // ==============================================================================
3897
3898    /// Convert-only PTB WITHOUT the trailing `TransferObjects` — the builder always emits
3899    /// a transfer for S>=1, F=0. A `[convert]` alone leaks a FungibleStakedSui result and
3900    /// would fail on-chain execution. Parser must not label it as Consolidate.
3901    #[test]
3902    fn test_parse_falls_through_convert_without_transfer() {
3903        let sender = SuiAddress::random_for_testing_only();
3904        let staked = random_object_ref();
3905        let mut builder = ProgrammableTransactionBuilder::new();
3906        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3907        let staked_arg = builder.obj(ObjectArg::ImmOrOwnedObject(staked)).unwrap();
3908        let _new_fss = builder.command(NativeCommand::move_call(
3909            SUI_SYSTEM_PACKAGE_ID,
3910            Identifier::new("sui_system").unwrap(),
3911            Identifier::new("convert_to_fungible_staked_sui").unwrap(),
3912            vec![],
3913            vec![sys, staked_arg],
3914        ));
3915        // No TransferObjects — convert's Result is orphaned.
3916        let ops = parse_pt(sender, builder.finish());
3917        assert_falls_through_to_generic(&ops);
3918    }
3919
3920    /// Pure FSS merge with a SPURIOUS `TransferObjects` — the builder never emits a
3921    /// transfer for S=0, F>=2 (existing FSS is already sender-owned). `join` returns unit
3922    /// so the transfer can't reference a meaningful result anyway. Parser must fall through.
3923    #[test]
3924    fn test_parse_falls_through_pure_merge_with_transfer() {
3925        let sender = SuiAddress::random_for_testing_only();
3926        let fss_a = random_object_ref();
3927        let fss_b = random_object_ref();
3928        let mut builder = ProgrammableTransactionBuilder::new();
3929        let _sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3930        let first = builder.obj(ObjectArg::ImmOrOwnedObject(fss_a)).unwrap();
3931        let other = builder.obj(ObjectArg::ImmOrOwnedObject(fss_b)).unwrap();
3932        let join_result = builder.command(NativeCommand::move_call(
3933            SUI_SYSTEM_PACKAGE_ID,
3934            Identifier::new("staking_pool").unwrap(),
3935            Identifier::new("join_fungible_staked_sui").unwrap(),
3936            vec![],
3937            vec![first, other],
3938        ));
3939        // Spurious TransferObjects referencing the join's (unit) result.
3940        let sender_arg = builder.pure(sender).unwrap();
3941        builder.command(NativeCommand::TransferObjects(
3942            vec![join_result],
3943            sender_arg,
3944        ));
3945        let ops = parse_pt(sender, builder.finish());
3946        assert_falls_through_to_generic(&ops);
3947    }
3948
3949    /// `split_fungible_staked_sui`'s amount arg must be a `Pure` u64. Passing an
3950    /// `ImmOrOwnedObject` as the amount slot fails on-chain but previously parse-accepted.
3951    #[test]
3952    fn test_parse_falls_through_split_amount_not_pure() {
3953        let sender = SuiAddress::random_for_testing_only();
3954        let fss = random_object_ref();
3955        let bogus_obj = random_object_ref();
3956        let mut builder = ProgrammableTransactionBuilder::new();
3957        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3958        let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3959        // The "amount" arg is an object ref instead of a Pure u64.
3960        let bogus_arg = builder.obj(ObjectArg::ImmOrOwnedObject(bogus_obj)).unwrap();
3961        let split_result = builder.command(NativeCommand::move_call(
3962            SUI_SYSTEM_PACKAGE_ID,
3963            Identifier::new("staking_pool").unwrap(),
3964            Identifier::new("split_fungible_staked_sui").unwrap(),
3965            vec![],
3966            vec![fss_arg, bogus_arg],
3967        ));
3968        let balance = builder.command(NativeCommand::move_call(
3969            SUI_SYSTEM_PACKAGE_ID,
3970            Identifier::new("sui_system").unwrap(),
3971            Identifier::new("redeem_fungible_staked_sui").unwrap(),
3972            vec![],
3973            vec![sys, split_result],
3974        ));
3975        let coin = builder.command(NativeCommand::move_call(
3976            SUI_FRAMEWORK_PACKAGE_ID,
3977            Identifier::new("coin").unwrap(),
3978            Identifier::new("from_balance").unwrap(),
3979            vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
3980            vec![balance],
3981        ));
3982        let sender_arg = builder.pure(sender).unwrap();
3983        builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
3984        let ops = parse_pt(sender, builder.finish());
3985        assert_falls_through_to_generic(&ops);
3986    }
3987
3988    /// `convert_to_fungible_staked_sui`'s first arg must reference `inputs[0]`
3989    /// (SUI_SYSTEM_STATE). A PTB passing a different input in the system-state slot
3990    /// slips through shape validation before this tightening.
3991    #[test]
3992    fn test_parse_falls_through_convert_wrong_system_state_arg() {
3993        let sender = SuiAddress::random_for_testing_only();
3994        let staked = random_object_ref();
3995        let mut builder = ProgrammableTransactionBuilder::new();
3996        // inputs[0] = SUI_SYSTEM_MUT (passes first_input_is_sui_system_state).
3997        let _sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3998        // inputs[1] = a Pure u64 — we'll put this in the convert's system-state slot
3999        // so arguments[0].input() != 0, triggering the new check.
4000        let bogus_arg = builder.pure(0u64).unwrap();
4001        let staked_arg = builder.obj(ObjectArg::ImmOrOwnedObject(staked)).unwrap();
4002        let new_fss = builder.command(NativeCommand::move_call(
4003            SUI_SYSTEM_PACKAGE_ID,
4004            Identifier::new("sui_system").unwrap(),
4005            Identifier::new("convert_to_fungible_staked_sui").unwrap(),
4006            vec![],
4007            // arguments[0] is bogus_arg (input 1, not input 0) — shape mismatch.
4008            vec![bogus_arg, staked_arg],
4009        ));
4010        let sender_arg = builder.pure(sender).unwrap();
4011        builder.command(NativeCommand::TransferObjects(vec![new_fss], sender_arg));
4012        let ops = parse_pt(sender, builder.finish());
4013        assert_falls_through_to_generic(&ops);
4014    }
4015
4016    /// If a single input appears in BOTH a `convert_fss` call (treated as StakedSui) and
4017    /// a `join_fss` call (treated as FSS), the classification is contradictory. The
4018    /// overlap-rejection mechanism already exists in `parse_consolidate`; this test
4019    /// gives it explicit coverage.
4020    #[test]
4021    fn test_parse_falls_through_consolidate_same_input_both_convert_and_join() {
4022        let sender = SuiAddress::random_for_testing_only();
4023        let shared_input = random_object_ref();
4024        let other_fss = random_object_ref();
4025        let mut builder = ProgrammableTransactionBuilder::new();
4026        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
4027        // This single input appears in BOTH roles below.
4028        let dual = builder
4029            .obj(ObjectArg::ImmOrOwnedObject(shared_input))
4030            .unwrap();
4031        let fss_b = builder.obj(ObjectArg::ImmOrOwnedObject(other_fss)).unwrap();
4032        // join(dual, fss_b) — dual is classified as FSS.
4033        builder.command(NativeCommand::move_call(
4034            SUI_SYSTEM_PACKAGE_ID,
4035            Identifier::new("staking_pool").unwrap(),
4036            Identifier::new("join_fungible_staked_sui").unwrap(),
4037            vec![],
4038            vec![dual, fss_b],
4039        ));
4040        // convert(sys, dual) — dual is now also referenced as StakedSui (contradiction).
4041        let new_fss = builder.command(NativeCommand::move_call(
4042            SUI_SYSTEM_PACKAGE_ID,
4043            Identifier::new("sui_system").unwrap(),
4044            Identifier::new("convert_to_fungible_staked_sui").unwrap(),
4045            vec![],
4046            vec![sys, dual],
4047        ));
4048        let sender_arg = builder.pure(sender).unwrap();
4049        builder.command(NativeCommand::TransferObjects(vec![new_fss], sender_arg));
4050        let ops = parse_pt(sender, builder.finish());
4051        assert_falls_through_to_generic(&ops);
4052    }
4053
4054    // ==============================================================================
4055    // AtLeast guard dataflow linkage tests
4056    //
4057    // The AtLeast PTB shape is:
4058    //   redeem_fss → balance::split<SUI> → balance::join<SUI> → coin::from_balance<SUI>
4059    // and the parser must verify that the guard operates on the redeem result
4060    // (not on some unrelated Balance<SUI>) — otherwise a malformed PTB could be
4061    // misclassified as a typed AtLeast op even though the chain wouldn't enforce
4062    // the guarantee on the redeemed balance.
4063    // ==============================================================================
4064
4065    /// Build a malformed AtLeast PTB where the AtLeast guard operates on a
4066    /// freshly-created `Balance<SUI>` (via `balance::zero<SUI>`) rather than
4067    /// on the redeem result. Type-checks on chain (the chain doesn't care if
4068    /// the guard runs against a different balance), but the parser must NOT
4069    /// emit `Some(AtLeast)` for this PTB because the balance::split is not
4070    /// gating the redeemed balance.
4071    ///
4072    /// NOTE: chain validation might still reject the resulting PTB for other
4073    /// reasons (orphaned redeem result), but as far as the parser shape match
4074    /// goes we want it to fall through to a generic op.
4075    fn build_malformed_atleast_ptb(
4076        sender: SuiAddress,
4077        fss: ObjectRef,
4078        wire_split_to_redeem: bool,
4079        wire_join_to_redeem: bool,
4080        wire_join_arg1_to_split: bool,
4081        wire_from_balance_to_redeem: bool,
4082    ) -> ProgrammableTransaction {
4083        use sui_types::transaction::Argument;
4084        let mut builder = ProgrammableTransactionBuilder::new();
4085        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
4086        let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
4087        let split_amt = builder.pure(100u64).unwrap();
4088        // Split fss to make the shape AtLeast/AtMost-like (with split_fss before redeem).
4089        let split_fss = builder.command(NativeCommand::move_call(
4090            SUI_SYSTEM_PACKAGE_ID,
4091            Identifier::new("staking_pool").unwrap(),
4092            Identifier::new("split_fungible_staked_sui").unwrap(),
4093            vec![],
4094            vec![fss_arg, split_amt],
4095        ));
4096        let redeem_balance = builder.command(NativeCommand::move_call(
4097            SUI_SYSTEM_PACKAGE_ID,
4098            Identifier::new("sui_system").unwrap(),
4099            Identifier::new("redeem_fungible_staked_sui").unwrap(),
4100            vec![],
4101            vec![sys, split_fss],
4102        ));
4103        // Make a separate Balance<SUI> via `balance::zero<SUI>` to have a
4104        // distinct Balance<SUI> Result available for the malformed wiring.
4105        let zero_balance = builder.command(NativeCommand::move_call(
4106            SUI_FRAMEWORK_PACKAGE_ID,
4107            Identifier::new("balance").unwrap(),
4108            Identifier::new("zero").unwrap(),
4109            vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4110            vec![],
4111        ));
4112        let min_arg = builder.pure(0u64).unwrap();
4113        let split_arg0 = if wire_split_to_redeem {
4114            redeem_balance
4115        } else {
4116            zero_balance
4117        };
4118        let split_result = builder.command(NativeCommand::move_call(
4119            SUI_FRAMEWORK_PACKAGE_ID,
4120            Identifier::new("balance").unwrap(),
4121            Identifier::new("split").unwrap(),
4122            vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4123            vec![split_arg0, min_arg],
4124        ));
4125        let join_arg0 = if wire_join_to_redeem {
4126            redeem_balance
4127        } else {
4128            zero_balance
4129        };
4130        let join_arg1 = if wire_join_arg1_to_split {
4131            split_result
4132        } else {
4133            // Use a fresh zero<SUI> result so it's a Balance<SUI> Result that
4134            // is not the prior balance::split's output.
4135            builder.command(NativeCommand::move_call(
4136                SUI_FRAMEWORK_PACKAGE_ID,
4137                Identifier::new("balance").unwrap(),
4138                Identifier::new("zero").unwrap(),
4139                vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4140                vec![],
4141            ))
4142        };
4143        builder.command(NativeCommand::move_call(
4144            SUI_FRAMEWORK_PACKAGE_ID,
4145            Identifier::new("balance").unwrap(),
4146            Identifier::new("join").unwrap(),
4147            vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4148            vec![join_arg0, join_arg1],
4149        ));
4150        let from_balance_arg = if wire_from_balance_to_redeem {
4151            redeem_balance
4152        } else {
4153            zero_balance
4154        };
4155        let coin = builder.command(NativeCommand::move_call(
4156            SUI_FRAMEWORK_PACKAGE_ID,
4157            Identifier::new("coin").unwrap(),
4158            Identifier::new("from_balance").unwrap(),
4159            vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4160            vec![from_balance_arg],
4161        ));
4162        let sender_arg = builder.pure(sender).unwrap();
4163        builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
4164        let _ = Argument::GasCoin; // silence Argument unused warning when not needed
4165        builder.finish()
4166    }
4167
4168    #[test]
4169    fn test_parse_falls_through_atleast_split_arg_not_redeem_result() {
4170        let sender = SuiAddress::random_for_testing_only();
4171        let fss = random_object_ref();
4172        // balance::split arg[0] points at zero<SUI>, not at redeem result.
4173        let pt = build_malformed_atleast_ptb(sender, fss, false, true, true, true);
4174        assert_falls_through_to_generic(&parse_pt(sender, pt));
4175    }
4176
4177    #[test]
4178    fn test_parse_falls_through_atleast_join_arg0_not_redeem_result() {
4179        let sender = SuiAddress::random_for_testing_only();
4180        let fss = random_object_ref();
4181        // balance::join arg[0] points at zero<SUI>, not at redeem result.
4182        let pt = build_malformed_atleast_ptb(sender, fss, true, false, true, true);
4183        assert_falls_through_to_generic(&parse_pt(sender, pt));
4184    }
4185
4186    #[test]
4187    fn test_parse_falls_through_atleast_join_arg1_not_split_result() {
4188        let sender = SuiAddress::random_for_testing_only();
4189        let fss = random_object_ref();
4190        // balance::join arg[1] points at a different zero<SUI>, not at split result.
4191        let pt = build_malformed_atleast_ptb(sender, fss, true, true, false, true);
4192        assert_falls_through_to_generic(&parse_pt(sender, pt));
4193    }
4194
4195    #[test]
4196    fn test_parse_falls_through_atleast_from_balance_arg_not_redeem_result() {
4197        let sender = SuiAddress::random_for_testing_only();
4198        let fss = random_object_ref();
4199        // coin::from_balance arg[0] points at zero<SUI>, not at redeem result.
4200        let pt = build_malformed_atleast_ptb(sender, fss, true, true, true, false);
4201        assert_falls_through_to_generic(&parse_pt(sender, pt));
4202    }
4203
4204    /// Hand-build a PTB whose `balance::split` argument is `NestedResult(redeem_idx, 0)`
4205    /// rather than a plain `Result(redeem_idx)`. Both proto-encode as
4206    /// `ArgumentKind::Result` (only `subresult` differs) so a parser that
4207    /// only checks kind+result would slip past — `is_result_of` must also
4208    /// require `subresult` is unset.
4209    #[test]
4210    fn test_parse_falls_through_atleast_split_arg_is_nested_result() {
4211        use sui_types::transaction::Argument;
4212        let sender = SuiAddress::random_for_testing_only();
4213        let fss = random_object_ref();
4214        let mut builder = ProgrammableTransactionBuilder::new();
4215        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
4216        let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
4217        let split_amt = builder.pure(100u64).unwrap();
4218        let split_fss = builder.command(NativeCommand::move_call(
4219            SUI_SYSTEM_PACKAGE_ID,
4220            Identifier::new("staking_pool").unwrap(),
4221            Identifier::new("split_fungible_staked_sui").unwrap(),
4222            vec![],
4223            vec![fss_arg, split_amt],
4224        ));
4225        let _redeem = builder.command(NativeCommand::move_call(
4226            SUI_SYSTEM_PACKAGE_ID,
4227            Identifier::new("sui_system").unwrap(),
4228            Identifier::new("redeem_fungible_staked_sui").unwrap(),
4229            vec![],
4230            vec![sys, split_fss],
4231        ));
4232        // The redeem result is at command index 1 (split is 0). Construct
4233        // NestedResult(1, 0) by hand — it shares ArgumentKind::Result with
4234        // a plain Result(1), distinguished only by `subresult`.
4235        let nested = Argument::NestedResult(1, 0);
4236        let min_arg = builder.pure(0u64).unwrap();
4237        let split_balance = builder.command(NativeCommand::move_call(
4238            SUI_FRAMEWORK_PACKAGE_ID,
4239            Identifier::new("balance").unwrap(),
4240            Identifier::new("split").unwrap(),
4241            vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4242            vec![nested, min_arg],
4243        ));
4244        builder.command(NativeCommand::move_call(
4245            SUI_FRAMEWORK_PACKAGE_ID,
4246            Identifier::new("balance").unwrap(),
4247            Identifier::new("join").unwrap(),
4248            vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4249            vec![nested, split_balance],
4250        ));
4251        let coin = builder.command(NativeCommand::move_call(
4252            SUI_FRAMEWORK_PACKAGE_ID,
4253            Identifier::new("coin").unwrap(),
4254            Identifier::new("from_balance").unwrap(),
4255            vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4256            vec![nested],
4257        ));
4258        let sender_arg = builder.pure(sender).unwrap();
4259        builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
4260        assert_falls_through_to_generic(&parse_pt(sender, builder.finish()));
4261    }
4262
4263    /// TransferObjects must move the `coin::from_balance` result, not some
4264    /// unrelated `Result`. Build a PTB that has the right shape up to and
4265    /// including `coin::from_balance` but then transfers a different coin.
4266    #[test]
4267    fn test_parse_falls_through_transfer_not_from_balance_result() {
4268        let sender = SuiAddress::random_for_testing_only();
4269        let fss = random_object_ref();
4270        let mut builder = ProgrammableTransactionBuilder::new();
4271        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
4272        let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
4273        let redeem = builder.command(NativeCommand::move_call(
4274            SUI_SYSTEM_PACKAGE_ID,
4275            Identifier::new("sui_system").unwrap(),
4276            Identifier::new("redeem_fungible_staked_sui").unwrap(),
4277            vec![],
4278            vec![sys, fss_arg],
4279        ));
4280        let _from_balance = builder.command(NativeCommand::move_call(
4281            SUI_FRAMEWORK_PACKAGE_ID,
4282            Identifier::new("coin").unwrap(),
4283            Identifier::new("from_balance").unwrap(),
4284            vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4285            vec![redeem],
4286        ));
4287        // Construct a different Coin<SUI> via `coin::zero<SUI>` and transfer
4288        // *that* instead of the from_balance result. The PTB shape up to here
4289        // matches a recognized All-mode redeem, but the transfer target is wrong.
4290        let other_coin = builder.command(NativeCommand::move_call(
4291            SUI_FRAMEWORK_PACKAGE_ID,
4292            Identifier::new("coin").unwrap(),
4293            Identifier::new("zero").unwrap(),
4294            vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4295            vec![],
4296        ));
4297        let sender_arg = builder.pure(sender).unwrap();
4298        builder.command(NativeCommand::TransferObjects(vec![other_coin], sender_arg));
4299        assert_falls_through_to_generic(&parse_pt(sender, builder.finish()));
4300    }
4301
4302    // ==============================================================================
4303    // PR 2: Metadata serialization compat (4 tests)
4304    // ==============================================================================
4305
4306    #[test]
4307    fn test_meta_merge_redeem_old_input_all() {
4308        let v = SuiAddress::random_for_testing_only();
4309        let json = serde_json::json!({
4310            "MergeAndRedeemFungibleStakedSui": {
4311                "validator": v.to_string(),
4312                "redeem_mode": "All"
4313            }
4314        });
4315        let meta: OperationMetadata = serde_json::from_value(json).unwrap();
4316        match meta {
4317            OperationMetadata::MergeAndRedeemFungibleStakedSui {
4318                validator,
4319                amount,
4320                redeem_mode,
4321                fss_ids,
4322            } => {
4323                assert_eq!(validator, Some(v));
4324                assert!(amount.is_none());
4325                assert_eq!(redeem_mode, Some(RedeemMode::All));
4326                assert!(fss_ids.is_empty());
4327            }
4328            _ => panic!("wrong variant"),
4329        }
4330    }
4331
4332    #[test]
4333    fn test_meta_merge_redeem_old_input_atleast() {
4334        let v = SuiAddress::random_for_testing_only();
4335        let json = serde_json::json!({
4336            "MergeAndRedeemFungibleStakedSui": {
4337                "validator": v.to_string(),
4338                "amount": "500000000000",
4339                "redeem_mode": "AtLeast"
4340            }
4341        });
4342        let meta: OperationMetadata = serde_json::from_value(json).unwrap();
4343        match meta {
4344            OperationMetadata::MergeAndRedeemFungibleStakedSui {
4345                validator,
4346                amount,
4347                redeem_mode,
4348                fss_ids,
4349            } => {
4350                assert_eq!(validator, Some(v));
4351                assert_eq!(amount, Some("500000000000".to_string()));
4352                assert_eq!(redeem_mode, Some(RedeemMode::AtLeast));
4353                assert!(fss_ids.is_empty());
4354            }
4355            _ => panic!(),
4356        }
4357    }
4358
4359    #[test]
4360    fn test_meta_merge_redeem_new_parse_output() {
4361        let id = ObjectID::random();
4362        let meta = OperationMetadata::MergeAndRedeemFungibleStakedSui {
4363            validator: None,
4364            amount: None,
4365            redeem_mode: Some(RedeemMode::All),
4366            fss_ids: vec![id],
4367        };
4368        let json = serde_json::to_value(&meta).unwrap();
4369        let obj = json
4370            .as_object()
4371            .unwrap()
4372            .get("MergeAndRedeemFungibleStakedSui")
4373            .unwrap()
4374            .as_object()
4375            .unwrap();
4376        assert!(!obj.contains_key("validator"));
4377        assert!(!obj.contains_key("amount"));
4378        assert_eq!(obj.get("redeem_mode").unwrap(), "All");
4379        assert_eq!(obj.get("fss_ids").unwrap().as_array().unwrap().len(), 1);
4380    }
4381
4382    #[test]
4383    fn test_meta_merge_redeem_new_parse_output_partial() {
4384        let id = ObjectID::random();
4385        let meta = OperationMetadata::MergeAndRedeemFungibleStakedSui {
4386            validator: None,
4387            amount: None,
4388            redeem_mode: None,
4389            fss_ids: vec![id],
4390        };
4391        let json = serde_json::to_value(&meta).unwrap();
4392        let obj = json
4393            .as_object()
4394            .unwrap()
4395            .get("MergeAndRedeemFungibleStakedSui")
4396            .unwrap()
4397            .as_object()
4398            .unwrap();
4399        assert!(!obj.contains_key("validator"));
4400        assert!(!obj.contains_key("amount"));
4401        assert!(
4402            !obj.contains_key("redeem_mode"),
4403            "redeem_mode must be omitted in partial parse output"
4404        );
4405        assert_eq!(obj.get("fss_ids").unwrap().as_array().unwrap().len(), 1);
4406    }
4407
4408    // ==============================================================================
4409    // PR 2: Write-side preservation (1 test)
4410    // ==============================================================================
4411
4412    #[test]
4413    fn test_write_merge_redeem_requires_validator_and_mode() {
4414        let sender = SuiAddress::random_for_testing_only();
4415
4416        // Case 1: validator = None.
4417        let op = Operation {
4418            operation_identifier: Default::default(),
4419            type_: OperationType::MergeAndRedeemFungibleStakedSui,
4420            status: None,
4421            account: Some(sender.into()),
4422            amount: None,
4423            coin_change: None,
4424            metadata: Some(OperationMetadata::MergeAndRedeemFungibleStakedSui {
4425                validator: None,
4426                amount: None,
4427                redeem_mode: Some(RedeemMode::All),
4428                fss_ids: vec![],
4429            }),
4430        };
4431        let err = Operations::new(vec![op])
4432            .into_internal()
4433            .expect_err("should fail without validator");
4434        assert!(format!("{err}").contains("validator"));
4435
4436        // Case 2: redeem_mode = None.
4437        let op = Operation {
4438            operation_identifier: Default::default(),
4439            type_: OperationType::MergeAndRedeemFungibleStakedSui,
4440            status: None,
4441            account: Some(sender.into()),
4442            amount: None,
4443            coin_change: None,
4444            metadata: Some(OperationMetadata::MergeAndRedeemFungibleStakedSui {
4445                validator: Some(SuiAddress::random_for_testing_only()),
4446                amount: None,
4447                redeem_mode: None,
4448                fss_ids: vec![],
4449            }),
4450        };
4451        let err = Operations::new(vec![op])
4452            .into_internal()
4453            .expect_err("should fail without redeem_mode");
4454        assert!(format!("{err}").contains("redeem_mode"));
4455    }
4456
4457    // ---- reconstruct_operations tests -----------------------------------------
4458
4459    use crate::types::CurrencyMetadata;
4460    use crate::types::internal_operation::pay_coin_pt;
4461
4462    fn sample_currency() -> Currency {
4463        Currency {
4464            symbol: "USDC".to_string(),
4465            decimals: 6,
4466            metadata: CurrencyMetadata {
4467                coin_type: "0x5::usdc::USDC".to_string(),
4468            },
4469        }
4470    }
4471
4472    fn data_with_pt(sender: SuiAddress, pt: ProgrammableTransaction) -> TransactionData {
4473        let gas_price = 1000;
4474        TransactionData::new_programmable(
4475            sender,
4476            vec![random_object_ref()],
4477            pt,
4478            TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
4479            gas_price,
4480        )
4481    }
4482
4483    /// Mirror `/parse`: encode the structured proto (clearing `bcs`) then decode
4484    /// it back, so `reconstruct_operations` sees exactly what the endpoint sees.
4485    fn proto_clean(data: &TransactionData) -> Transaction {
4486        use crate::types::transaction_envelope::{decode_inner_proto, encode_inner_proto};
4487        decode_inner_proto(&encode_inner_proto(data)).unwrap()
4488    }
4489
4490    /// PayCoin currency from the aux data labels the reconstructed payment ops.
4491    #[test]
4492    fn test_reconstruct_pay_coin_currency() {
4493        let sender = SuiAddress::random_for_testing_only();
4494        let recipient = SuiAddress::random_for_testing_only();
4495        let coin = random_object_ref();
4496        let currency = sample_currency();
4497        let aux = AuxData::PayCoin {
4498            currency: currency.clone(),
4499        };
4500        let pt = pay_coin_pt(
4501            sender,
4502            vec![recipient],
4503            vec![10_000],
4504            &[coin],
4505            &[],
4506            0,
4507            &currency,
4508        )
4509        .unwrap();
4510        let proto = proto_clean(&data_with_pt(sender, pt));
4511
4512        let ops = reconstruct_operations(&proto, &aux, None).expect("reconstruct ok");
4513        assert!(ops.0.iter().any(|op| op.type_ == OperationType::PayCoin));
4514        let recip_amount = ops
4515            .0
4516            .iter()
4517            .find(|o| o.account.as_ref().map(|a| a.address) == Some(recipient))
4518            .and_then(|o| o.amount.clone())
4519            .expect("recipient op");
4520        assert_eq!(
4521            recip_amount.currency.metadata.coin_type,
4522            currency.metadata.coin_type
4523        );
4524    }
4525
4526    /// Family-mismatch guard: PayCoin aux data applied to a non-payment
4527    /// (Consolidate) transaction is rejected by `apply_aux`'s family
4528    /// assertion, regardless of the currency map.
4529    #[test]
4530    fn test_reconstruct_family_mismatch_rejected() {
4531        let sender = SuiAddress::random_for_testing_only();
4532        let pay_aux = AuxData::PayCoin {
4533            currency: sample_currency(),
4534        };
4535        let pt = consolidate_to_fungible_pt(
4536            sender,
4537            vec![random_object_ref()],
4538            vec![random_object_ref()],
4539        )
4540        .unwrap();
4541        let proto = proto_clean(&data_with_pt(sender, pt));
4542        let err = reconstruct_operations(&proto, &pay_aux, None)
4543            .expect_err("family mismatch must be rejected");
4544        assert!(format!("{err:?}").contains("non-payment"));
4545    }
4546
4547    /// FSS decoration: Consolidate validator is recovered from the aux data.
4548    #[test]
4549    fn test_reconstruct_consolidate_validator_decorated() {
4550        let sender = SuiAddress::random_for_testing_only();
4551        let validator = SuiAddress::random_for_testing_only();
4552        let aux = AuxData::Consolidate { validator };
4553        let pt = consolidate_to_fungible_pt(
4554            sender,
4555            vec![random_object_ref()],
4556            vec![random_object_ref()],
4557        )
4558        .unwrap();
4559        let proto = proto_clean(&data_with_pt(sender, pt));
4560        let ops = reconstruct_operations(&proto, &aux, None).unwrap();
4561        let Some(OperationMetadata::ConsolidateAllStakedSuiToFungible { validator: v, .. }) =
4562            ops.0[0].metadata.clone()
4563        else {
4564            panic!("expected Consolidate metadata");
4565        };
4566        assert_eq!(v, Some(validator));
4567    }
4568
4569    /// FSS decoration: MergeAndRedeem AtMost — the parser alone cannot
4570    /// distinguish AtMost, so the aux-data override must report it, with the
4571    /// validator + cap recovered.
4572    #[test]
4573    fn test_reconstruct_merge_redeem_atmost_decorated() {
4574        let sender = SuiAddress::random_for_testing_only();
4575        let validator = SuiAddress::random_for_testing_only();
4576        let aux = AuxData::MergeAndRedeem {
4577            validator,
4578            redeem_mode: RedeemMode::AtMost,
4579            amount: Some(1_000_000),
4580        };
4581        let plan = RedeemPlan::AtMost {
4582            token_amount: Some(500_000_000),
4583            max_sui: 0,
4584        };
4585        let pt = merge_and_redeem_fss_pt(sender, vec![random_object_ref()], &plan).unwrap();
4586        let proto = proto_clean(&data_with_pt(sender, pt));
4587        let ops = reconstruct_operations(&proto, &aux, None).unwrap();
4588        let Some(OperationMetadata::MergeAndRedeemFungibleStakedSui {
4589            validator: v,
4590            amount,
4591            redeem_mode,
4592            ..
4593        }) = ops.0[0].metadata.clone()
4594        else {
4595            panic!("expected MergeAndRedeem metadata");
4596        };
4597        assert_eq!(v, Some(validator));
4598        assert_eq!(redeem_mode, Some(RedeemMode::AtMost));
4599        assert_eq!(amount, Some("1000000".to_string()));
4600    }
4601
4602    /// PaySui reconstructs cleanly with `None` aux data.
4603    #[test]
4604    fn test_reconstruct_pay_sui_none_ok() {
4605        let sender = SuiAddress::random_for_testing_only();
4606        let recipient = SuiAddress::random_for_testing_only();
4607        let pt = {
4608            let mut b = ProgrammableTransactionBuilder::new();
4609            b.pay_sui(vec![recipient], vec![10_000]).unwrap();
4610            b.finish()
4611        };
4612        let proto = proto_clean(&data_with_pt(sender, pt));
4613        let ops = reconstruct_operations(&proto, &AuxData::None, None)
4614            .expect("PaySui reconstructs with no aux data");
4615        assert!(ops.0.iter().any(|op| op.type_ == OperationType::PaySui));
4616    }
4617}