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    /// Checks that `new_operations` reproduce the transaction's balance changes net of
1734    /// `accounted_balances`, i.e. net of amounts already carried by operations emitted
1735    /// elsewhere (parsed PTB operations and unstake principal/reward).
1736    fn validate_operations(
1737        initial_balance_changes: &[(BalanceChange, Currency)],
1738        accounted_balances: &HashMap<(SuiAddress, Currency), i128>,
1739        new_operations: &[Operation],
1740    ) -> Result<(), anyhow::Error> {
1741        let mut expected_balances = initial_balance_changes.iter().fold(
1742            accounted_balances.clone(),
1743            |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        // Net-zero entries produce no balance-change operation.
1755        expected_balances.retain(|_, amount| *amount != 0);
1756
1757        let mut new_balances: HashMap<(SuiAddress, Currency), i128> = HashMap::new();
1758        for op in new_operations {
1759            if let Some(Amount {
1760                currency, value, ..
1761            }) = &op.amount
1762            {
1763                let account = op
1764                    .account
1765                    .as_ref()
1766                    .ok_or_else(|| anyhow!("Missing account for a balance-change"))?;
1767                *new_balances
1768                    .entry((account.address, currency.clone()))
1769                    .or_default() += value;
1770            }
1771        }
1772
1773        for ((address, currency), new_amount) in new_balances {
1774            let expected_amount = expected_balances.remove(&(address, currency)).unwrap_or(0);
1775            if new_amount != expected_amount {
1776                return Err(anyhow!(
1777                    "Expected {} balance-change for {} but got {}",
1778                    expected_amount,
1779                    address,
1780                    new_amount
1781                ));
1782            }
1783        }
1784        if !expected_balances.is_empty() {
1785            return Err(anyhow!(
1786                "Expected every item in initial_balances to be mapped"
1787            ));
1788        }
1789        Ok(())
1790    }
1791
1792    /// If GasCoin is transferred as a part of transferObjects, operations need to be
1793    /// updated such that:
1794    /// 1) gas owner needs to be assigned back to the previous owner
1795    /// 2) balances of previous and new gas owners need to be adjusted for the gas
1796    fn process_gascoin_transfer(
1797        coin_change_operations: &mut impl Iterator<Item = Operation>,
1798        is_gascoin_transfer: bool,
1799        prev_gas_owner: SuiAddress,
1800        new_gas_owner: SuiAddress,
1801        gas_used: i128,
1802        initial_balance_changes: &[(BalanceChange, Currency)],
1803        accounted_balances: &HashMap<(SuiAddress, Currency), i128>,
1804    ) -> Result<Vec<Operation>, anyhow::Error> {
1805        let mut operations = vec![];
1806        if is_gascoin_transfer && prev_gas_owner != new_gas_owner {
1807            operations = coin_change_operations.collect();
1808            Self::add_missing_gas_owner(&mut operations, prev_gas_owner);
1809            Self::add_missing_gas_owner(&mut operations, new_gas_owner);
1810            for operation in &mut operations {
1811                match operation.type_ {
1812                    OperationType::Gas => {
1813                        // change gas account back to the previous owner as it is the one
1814                        // who paid for the txn (this is the format Rosetta wants to process)
1815                        operation.account = Some(prev_gas_owner.into())
1816                    }
1817                    OperationType::SuiBalanceChange => {
1818                        let account = operation
1819                            .account
1820                            .as_ref()
1821                            .ok_or_else(|| anyhow!("Missing account for a balance-change"))?;
1822                        let amount = operation
1823                            .amount
1824                            .as_mut()
1825                            .ok_or_else(|| anyhow!("Missing amount for a balance-change"))?;
1826                        // adjust the balances for previous and new gas_owners
1827                        if account.address == prev_gas_owner && amount.currency == *SUI {
1828                            amount.value -= gas_used;
1829                        } else if account.address == new_gas_owner && amount.currency == *SUI {
1830                            amount.value += gas_used;
1831                        }
1832                    }
1833                    _ => {
1834                        return Err(anyhow!(
1835                            "Discarding unsupported operation type {:?}",
1836                            operation.type_
1837                        ));
1838                    }
1839                }
1840            }
1841            Self::validate_operations(initial_balance_changes, accounted_balances, &operations)?;
1842        }
1843        Ok(operations)
1844    }
1845}
1846
1847impl Operations {
1848    pub async fn try_from_executed_transaction(
1849        executed_tx: ExecutedTransaction,
1850        cache: &CoinMetadataCache,
1851    ) -> Result<Self, Error> {
1852        let ExecutedTransaction {
1853            transaction,
1854            effects,
1855            events,
1856            balance_changes,
1857            ..
1858        } = executed_tx;
1859
1860        let transaction = transaction.ok_or_else(|| {
1861            Error::DataError("ExecutedTransaction missing transaction".to_string())
1862        })?;
1863        let effects = effects
1864            .ok_or_else(|| Error::DataError("ExecutedTransaction missing effects".to_string()))?;
1865
1866        let sender = SuiAddress::from_str(transaction.sender())?;
1867
1868        // Post-execution owner of the gas coin. This is empty when the gas coin no
1869        // longer exists after execution: a `coin::send_funds` that moves the entire
1870        // gas coin into an address balance (gasless / free-tier transfers) deletes
1871        // the gas object, so its effects carry no output owner.
1872        let gas_output_owner = effects.gas_object().output_owner().address();
1873        let gas_owner = if !gas_output_owner.is_empty() {
1874            SuiAddress::from_str(gas_output_owner)?
1875        } else if sender == SuiAddress::ZERO {
1876            // System transactions don't have a gas_object.
1877            sender
1878        } else {
1879            // No gas coin output owner: either gas was paid from the sender's address
1880            // balance (no gas coin object) or the gas coin was fully consumed/deleted.
1881            // Either way the gas payment owner is the account that paid for the txn.
1882            SuiAddress::from_str(transaction.gas_payment().owner())?
1883        };
1884
1885        let gas_summary = effects.gas_used();
1886        let gas_used = gas_summary.storage_rebate_opt().unwrap_or(0) as i128
1887            - gas_summary.storage_cost_opt().unwrap_or(0) as i128
1888            - gas_summary.computation_cost_opt().unwrap_or(0) as i128;
1889
1890        let status = Some(effects.status().into());
1891
1892        let prev_gas_owner = SuiAddress::from_str(transaction.gas_payment().owner())?;
1893
1894        let tx_kind = transaction
1895            .kind
1896            .ok_or_else(|| Error::DataError("Transaction missing kind".to_string()))?;
1897        let is_gascoin_transfer = Self::is_gascoin_transfer(&tx_kind);
1898
1899        // Resolve coins to currencies and pick the payment's currency in one pass.
1900        // `by_coin_type` is reused by the reconciliation pass below
1901        // (`balance_changes_with_currency`); `payment` is handed to the parser.
1902        let TxCurrencies {
1903            by_coin_type: currencies,
1904            payment,
1905        } = resolve_tx_currencies(&balance_changes, cache).await?;
1906        let ops = Self::new(Self::from_transaction(tx_kind, sender, status, payment)?);
1907        let ops = ops.into_iter();
1908
1909        // We will need to subtract the operation amounts from the actual balance
1910        // change amount extracted from event to prevent double counting.
1911        let mut accounted_balances =
1912            ops.as_ref()
1913                .iter()
1914                .fold(HashMap::new(), |mut balances, op| {
1915                    if let (Some(acc), Some(amount), Some(OperationStatus::Success)) =
1916                        (&op.account, &op.amount, &op.status)
1917                    {
1918                        *balances
1919                            .entry((acc.address, amount.clone().currency))
1920                            .or_default() -= amount.value;
1921                    }
1922                    balances
1923                });
1924
1925        let mut principal_amounts = 0;
1926        let mut reward_amounts = 0;
1927
1928        // Extract balance change from unstake events
1929        let events = events.as_ref().map(|e| e.events.as_slice()).unwrap_or(&[]);
1930        for event in events {
1931            let event_type = event.event_type();
1932            if let Ok(type_tag) = StructTag::from_str(event_type)
1933                && is_unstake_event(&type_tag)
1934                && let Some(json) = &event.json
1935                && let Some(Kind::StructValue(struct_val)) = &json.kind
1936            {
1937                if let Some(principal_field) = struct_val.fields.get("principal_amount")
1938                    && let Some(Kind::StringValue(s)) = &principal_field.kind
1939                    && let Ok(amount) = i128::from_str(s)
1940                {
1941                    principal_amounts += amount;
1942                }
1943                if let Some(reward_field) = struct_val.fields.get("reward_amount")
1944                    && let Some(Kind::StringValue(s)) = &reward_field.kind
1945                    && let Ok(amount) = i128::from_str(s)
1946                {
1947                    reward_amounts += amount;
1948                }
1949            }
1950        }
1951        let staking_balance = if principal_amounts != 0 {
1952            *accounted_balances.entry((sender, SUI.clone())).or_default() -= principal_amounts;
1953            *accounted_balances.entry((sender, SUI.clone())).or_default() -= reward_amounts;
1954            vec![
1955                Operation::stake_principle(status, sender, principal_amounts),
1956                Operation::stake_reward(status, sender, reward_amounts),
1957            ]
1958        } else {
1959            vec![]
1960        };
1961
1962        // Reuse the currencies map built above instead of a second
1963        // `cache.get_currency` pass per balance change.
1964        let balance_changes_with_currency: Vec<_> = balance_changes
1965            .iter()
1966            .filter_map(|bc| {
1967                currencies
1968                    .get(bc.coin_type())
1969                    .map(|c| (bc.clone(), c.clone()))
1970            })
1971            .collect();
1972
1973        // Extract coin change operations from balance changes
1974        let mut coin_change_operations = Self::process_balance_change(
1975            gas_owner,
1976            gas_used,
1977            &balance_changes_with_currency,
1978            status,
1979            accounted_balances.clone(),
1980        );
1981
1982        // Take {gas, previous gas owner, new gas owner} out of coin_change_operations
1983        // and convert BalanceChange to PaySui when GasCoin is transferred
1984        let gascoin_transfer_operations = Self::process_gascoin_transfer(
1985            &mut coin_change_operations,
1986            is_gascoin_transfer,
1987            prev_gas_owner,
1988            gas_owner,
1989            gas_used,
1990            &balance_changes_with_currency,
1991            &accounted_balances,
1992        )?;
1993
1994        let ops: Operations = ops
1995            .into_iter()
1996            .chain(coin_change_operations)
1997            .chain(gascoin_transfer_operations)
1998            .chain(staking_balance)
1999            .collect();
2000
2001        // This is a workaround for the payCoin cases that are mistakenly considered to be paySui operations
2002        // In this case we remove any irrelevant, SUI specific operation entries that sum up to 0 balance changes per address
2003        // and keep only the actual entries for the right coin type transfers, as they have been extracted from the transaction's
2004        // balance changes section.
2005        let mutually_cancelling_balances: HashMap<_, _> = ops
2006            .clone()
2007            .into_iter()
2008            .fold(
2009                HashMap::new(),
2010                |mut balances: HashMap<(SuiAddress, Currency), i128>, op| {
2011                    if let (Some(acc), Some(amount), Some(OperationStatus::Success)) =
2012                        (&op.account, &op.amount, &op.status)
2013                        && op.type_ != OperationType::Gas
2014                    {
2015                        *balances
2016                            .entry((acc.address, amount.clone().currency))
2017                            .or_default() += amount.value;
2018                    }
2019                    balances
2020                },
2021            )
2022            .into_iter()
2023            .filter(|balance| {
2024                let (_, amount) = balance;
2025                *amount == 0
2026            })
2027            .collect();
2028
2029        let ops: Operations = ops
2030            .into_iter()
2031            .filter(|op| {
2032                if let (Some(acc), Some(amount)) = (&op.account, &op.amount) {
2033                    return op.type_ == OperationType::Gas
2034                        || !mutually_cancelling_balances
2035                            .contains_key(&(acc.address, amount.clone().currency));
2036                }
2037                true
2038            })
2039            .collect();
2040        Ok(ops)
2041    }
2042}
2043
2044fn is_unstake_event(tag: &StructTag) -> bool {
2045    tag.address == SUI_SYSTEM_ADDRESS
2046        && tag.module.as_ident_str() == ident_str!("validator")
2047        && tag.name.as_ident_str() == ident_str!("UnstakingRequestEvent")
2048}
2049
2050#[derive(Deserialize, Serialize, Clone, Debug)]
2051pub struct Operation {
2052    operation_identifier: OperationIdentifier,
2053    #[serde(rename = "type")]
2054    pub type_: OperationType,
2055    #[serde(default, skip_serializing_if = "Option::is_none")]
2056    pub status: Option<OperationStatus>,
2057    #[serde(default, skip_serializing_if = "Option::is_none")]
2058    pub account: Option<AccountIdentifier>,
2059    #[serde(default, skip_serializing_if = "Option::is_none")]
2060    pub amount: Option<Amount>,
2061    #[serde(default, skip_serializing_if = "Option::is_none")]
2062    pub coin_change: Option<CoinChange>,
2063    #[serde(default, skip_serializing_if = "Option::is_none")]
2064    pub metadata: Option<OperationMetadata>,
2065}
2066
2067impl PartialEq for Operation {
2068    fn eq(&self, other: &Self) -> bool {
2069        self.operation_identifier == other.operation_identifier
2070            && self.type_ == other.type_
2071            && self.account == other.account
2072            && self.amount == other.amount
2073            && self.coin_change == other.coin_change
2074            && self.metadata == other.metadata
2075    }
2076}
2077
2078#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
2079pub enum OperationMetadata {
2080    GenericTransaction(TransactionKind),
2081    Stake {
2082        validator: SuiAddress,
2083    },
2084    WithdrawStake {
2085        stake_ids: Vec<ObjectID>,
2086    },
2087    ConsolidateAllStakedSuiToFungible {
2088        #[serde(default, skip_serializing_if = "Option::is_none")]
2089        validator: Option<SuiAddress>,
2090        #[serde(default, skip_serializing_if = "Vec::is_empty")]
2091        staked_sui_ids: Vec<ObjectID>,
2092        #[serde(default, skip_serializing_if = "Vec::is_empty")]
2093        fss_ids: Vec<ObjectID>,
2094    },
2095    MergeAndRedeemFungibleStakedSui {
2096        #[serde(default, skip_serializing_if = "Option::is_none")]
2097        validator: Option<SuiAddress>,
2098        #[serde(default, skip_serializing_if = "Option::is_none")]
2099        amount: Option<String>,
2100        #[serde(default, skip_serializing_if = "Option::is_none")]
2101        redeem_mode: Option<RedeemMode>,
2102        #[serde(default, skip_serializing_if = "Vec::is_empty")]
2103        fss_ids: Vec<ObjectID>,
2104    },
2105}
2106
2107impl Operation {
2108    fn generic_op(
2109        status: Option<OperationStatus>,
2110        sender: SuiAddress,
2111        tx: TransactionKind,
2112    ) -> Self {
2113        Operation {
2114            operation_identifier: Default::default(),
2115            type_: (&tx).into(),
2116            status,
2117            account: Some(sender.into()),
2118            amount: None,
2119            coin_change: None,
2120            metadata: Some(OperationMetadata::GenericTransaction(tx)),
2121        }
2122    }
2123
2124    pub fn genesis(index: u64, sender: SuiAddress, coin: GasCoin) -> Self {
2125        Operation {
2126            operation_identifier: index.into(),
2127            type_: OperationType::Genesis,
2128            status: Some(OperationStatus::Success),
2129            account: Some(sender.into()),
2130            amount: Some(Amount::new(coin.value().into(), None)),
2131            coin_change: Some(CoinChange {
2132                coin_identifier: CoinIdentifier {
2133                    identifier: CoinID {
2134                        id: *coin.id(),
2135                        version: SequenceNumber::new(),
2136                    },
2137                },
2138                coin_action: CoinAction::CoinCreated,
2139            }),
2140            metadata: None,
2141        }
2142    }
2143
2144    fn pay_sui(status: Option<OperationStatus>, address: SuiAddress, amount: i128) -> Self {
2145        Operation {
2146            operation_identifier: Default::default(),
2147            type_: OperationType::PaySui,
2148            status,
2149            account: Some(address.into()),
2150            amount: Some(Amount::new(amount, None)),
2151            coin_change: None,
2152            metadata: None,
2153        }
2154    }
2155
2156    fn pay_coin(
2157        status: Option<OperationStatus>,
2158        address: SuiAddress,
2159        amount: i128,
2160        currency: Option<Currency>,
2161    ) -> Self {
2162        Operation {
2163            operation_identifier: Default::default(),
2164            type_: OperationType::PayCoin,
2165            status,
2166            account: Some(address.into()),
2167            amount: Some(Amount::new(amount, currency)),
2168            coin_change: None,
2169            metadata: None,
2170        }
2171    }
2172
2173    fn balance_change(
2174        status: Option<OperationStatus>,
2175        addr: SuiAddress,
2176        amount: i128,
2177        currency: Currency,
2178    ) -> Self {
2179        Self {
2180            operation_identifier: Default::default(),
2181            type_: OperationType::SuiBalanceChange,
2182            status,
2183            account: Some(addr.into()),
2184            amount: Some(Amount::new(amount, Some(currency))),
2185            coin_change: None,
2186            metadata: None,
2187        }
2188    }
2189    fn gas(addr: SuiAddress, amount: i128) -> Self {
2190        Self {
2191            operation_identifier: Default::default(),
2192            type_: OperationType::Gas,
2193            status: Some(OperationStatus::Success),
2194            account: Some(addr.into()),
2195            amount: Some(Amount::new(amount, None)),
2196            coin_change: None,
2197            metadata: None,
2198        }
2199    }
2200    fn stake_reward(status: Option<OperationStatus>, addr: SuiAddress, amount: i128) -> Self {
2201        Self {
2202            operation_identifier: Default::default(),
2203            type_: OperationType::StakeReward,
2204            status,
2205            account: Some(addr.into()),
2206            amount: Some(Amount::new(amount, None)),
2207            coin_change: None,
2208            metadata: None,
2209        }
2210    }
2211    fn stake_principle(status: Option<OperationStatus>, addr: SuiAddress, amount: i128) -> Self {
2212        Self {
2213            operation_identifier: Default::default(),
2214            type_: OperationType::StakePrinciple,
2215            status,
2216            account: Some(addr.into()),
2217            amount: Some(Amount::new(amount, None)),
2218            coin_change: None,
2219            metadata: None,
2220        }
2221    }
2222}
2223
2224/// Reconstruct Rosetta `Operations` from a proto `Transaction`, applying the
2225/// out-of-band `AuxData`. Shared by `/parse` and `/payloads`.
2226///
2227/// The aux data carries the few labels the PTB cannot encode (PayCoin
2228/// currency, FSS validator / redeem-mode / cap), populated in `/metadata` and
2229/// carried in the wrapper; it is not cryptographically bound to the signature.
2230/// The PayCoin currency — the one label whose correctness affects fund routing
2231/// — is verified online against the simulated balance changes in `/submit`; FSS
2232/// labels are display-only (the signed PTB determines execution, and `/block`
2233/// re-derives the truth from chain). `apply_aux` still rejects aux data whose
2234/// family disagrees with the parsed transaction family.
2235///
2236/// Steps:
2237/// 1. Reconstruct operations from the transaction via the shared parser
2238///    (`from_transaction`), seeding the currency map from a `PayCoin` label so
2239///    payments are labelled correctly.
2240/// 2. Decorate FSS ops with the validator / redeem-mode / cap the PTB cannot
2241///    encode, asserting the parsed family matches the aux-data family.
2242pub fn reconstruct_operations(
2243    proto: &ProtoTransaction,
2244    aux: &AuxData,
2245    status: Option<OperationStatus>,
2246) -> Result<Operations, Error> {
2247    let sender = SuiAddress::from_str(proto.sender())
2248        .map_err(|e| Error::DataError(format!("invalid transaction sender: {e}")))?;
2249    let tx_kind = proto
2250        .kind
2251        .clone()
2252        .ok_or_else(|| Error::DataError("Transaction missing kind".to_string()))?;
2253
2254    // The PayCoin label is the only currency the PTB cannot encode; everything
2255    // else reconstructs as SUI. This path never produces `Unresolvable`.
2256    let payment_currency = match aux {
2257        AuxData::PayCoin { currency } => PaymentCurrency::NonSui(currency.clone()),
2258        _ => PaymentCurrency::Sui,
2259    };
2260    let mut ops = Operations::from_transaction(tx_kind, sender, status, payment_currency)?;
2261
2262    // Apply the labels the PTB cannot encode.
2263    apply_aux(&mut ops, aux)?;
2264    Ok(Operations::new(ops))
2265}
2266
2267/// Overlay the non-reconstructable labels from `aux` onto the parsed `ops`,
2268/// rejecting if the parsed operation family disagrees with the aux-data family.
2269fn apply_aux(ops: &mut [Operation], aux: &AuxData) -> Result<(), Error> {
2270    match aux {
2271        AuxData::None => {}
2272        AuxData::PayCoin { .. } => {
2273            // The currency map already drove the parser to label payments as
2274            // PayCoin; just assert the parsed family is a payment family so a
2275            // PayCoin label over e.g. a Stake PTB is rejected.
2276            let is_payment = ops
2277                .iter()
2278                .all(|op| matches!(op.type_, OperationType::PayCoin | OperationType::PaySui));
2279            if ops.is_empty() || !is_payment {
2280                return Err(Error::DataError(
2281                    "envelope inconsistency: PayCoin aux data over a non-payment transaction"
2282                        .to_string(),
2283                ));
2284            }
2285        }
2286        AuxData::Consolidate { validator } => {
2287            let op = single_op(ops, OperationType::ConsolidateAllStakedSuiToFungible)?;
2288            match &mut op.metadata {
2289                Some(OperationMetadata::ConsolidateAllStakedSuiToFungible {
2290                    validator: v, ..
2291                }) => {
2292                    *v = Some(*validator);
2293                }
2294                _ => {
2295                    return Err(Error::DataError(
2296                        "envelope inconsistency: Consolidate aux data but parsed op lacks \
2297                         Consolidate metadata"
2298                            .to_string(),
2299                    ));
2300                }
2301            }
2302        }
2303        AuxData::MergeAndRedeem {
2304            validator,
2305            redeem_mode,
2306            amount,
2307        } => {
2308            // Minimal sanity check (replaces the removed
2309            // `InternalOperation::validate`): AtLeast/AtMost must carry a
2310            // positive amount; All must carry none. Guards against a server
2311            // building structurally invalid aux data.
2312            match redeem_mode {
2313                RedeemMode::All if amount.is_some() => {
2314                    return Err(Error::DataError(
2315                        "MergeAndRedeem All must carry no amount".to_string(),
2316                    ));
2317                }
2318                RedeemMode::AtLeast | RedeemMode::AtMost if !matches!(amount, Some(a) if *a > 0) => {
2319                    return Err(Error::DataError(format!(
2320                        "MergeAndRedeem {redeem_mode:?} must carry a positive amount"
2321                    )));
2322                }
2323                _ => {}
2324            }
2325            let op = single_op(ops, OperationType::MergeAndRedeemFungibleStakedSui)?;
2326            match &mut op.metadata {
2327                Some(OperationMetadata::MergeAndRedeemFungibleStakedSui {
2328                    validator: v,
2329                    amount: a,
2330                    redeem_mode: m,
2331                    ..
2332                }) => {
2333                    // Override: the parser cannot distinguish AtMost from
2334                    // All/unknown-partial, so the aux data is authoritative
2335                    // for the user-declared mode + cap.
2336                    *v = Some(*validator);
2337                    *m = Some(redeem_mode.clone());
2338                    *a = amount.map(|amount| amount.to_string());
2339                }
2340                _ => {
2341                    return Err(Error::DataError(
2342                        "envelope inconsistency: MergeAndRedeem aux data but parsed op lacks \
2343                         MergeAndRedeem metadata"
2344                            .to_string(),
2345                    ));
2346                }
2347            }
2348        }
2349    }
2350    Ok(())
2351}
2352
2353/// Return the single operation of `expected` type, rejecting if the parsed
2354/// family does not match the aux-data family.
2355fn single_op(ops: &mut [Operation], expected: OperationType) -> Result<&mut Operation, Error> {
2356    match ops {
2357        [op] if op.type_ == expected => Ok(op),
2358        _ => Err(Error::DataError(format!(
2359            "envelope inconsistency: aux data expects a single {expected:?} operation, \
2360             but the transaction parsed to a different shape"
2361        ))),
2362    }
2363}
2364
2365#[cfg(test)]
2366mod tests {
2367    use super::*;
2368    use crate::types::ConstructionMetadata;
2369    use crate::types::internal_operation::{consolidate_to_fungible_pt, merge_and_redeem_fss_pt};
2370    use sui_rpc::proto::sui::rpc::v2::Transaction;
2371    use sui_types::Identifier;
2372    use sui_types::base_types::{ObjectDigest, ObjectID, ObjectRef, SequenceNumber, SuiAddress};
2373    use sui_types::programmable_transaction_builder::ProgrammableTransactionBuilder;
2374    use sui_types::transaction::{
2375        CallArg, Command as NativeCommand, ObjectArg, ProgrammableTransaction,
2376        TEST_ONLY_GAS_UNIT_FOR_TRANSFER, TransactionData,
2377    };
2378
2379    fn random_object_ref() -> ObjectRef {
2380        (
2381            ObjectID::random(),
2382            SequenceNumber::from(1),
2383            ObjectDigest::random(),
2384        )
2385    }
2386
2387    /// Parse a native `ProgrammableTransaction` via the proto pipeline.
2388    /// Exact same conversion pattern used by `test_operation_data_parsing_pay_sui` at line 1637.
2389    fn parse_pt(sender: SuiAddress, pt: ProgrammableTransaction) -> Vec<Operation> {
2390        let gas = random_object_ref();
2391        let gas_price = 10;
2392        let data = TransactionData::new_programmable(
2393            sender,
2394            vec![gas],
2395            pt,
2396            TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
2397            gas_price,
2398        );
2399        let proto_tx: Transaction = data.into();
2400        let tx_kind = proto_tx.kind.expect("tx missing kind");
2401        Operations::from_transaction(tx_kind, sender, None, PaymentCurrency::Sui)
2402            .expect("parse failed")
2403    }
2404
2405    #[tokio::test]
2406    async fn test_operation_data_parsing_pay_sui() -> Result<(), anyhow::Error> {
2407        let gas = (
2408            ObjectID::random(),
2409            SequenceNumber::new(),
2410            ObjectDigest::random(),
2411        );
2412
2413        let sender = SuiAddress::random_for_testing_only();
2414
2415        let pt = {
2416            let mut builder = ProgrammableTransactionBuilder::new();
2417            builder
2418                .pay_sui(vec![SuiAddress::random_for_testing_only()], vec![10000])
2419                .unwrap();
2420            builder.finish()
2421        };
2422        let gas_price = 10;
2423        let data = TransactionData::new_programmable(
2424            sender,
2425            vec![gas],
2426            pt,
2427            TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
2428            gas_price,
2429        );
2430
2431        let proto_tx: Transaction = data.clone().into();
2432        let ops = Operations::new(Operations::from_transaction(
2433            proto_tx
2434                .kind
2435                .ok_or_else(|| Error::DataError("Transaction missing kind".to_string()))?,
2436            sender,
2437            None,
2438            PaymentCurrency::Sui,
2439        )?);
2440        ops.0
2441            .iter()
2442            .for_each(|op| assert_eq!(op.type_, OperationType::PaySui));
2443        let metadata = ConstructionMetadata {
2444            sender,
2445            gas_coins: vec![gas],
2446            extra_gas_coins: vec![],
2447            objects: vec![],
2448            party_objects: vec![],
2449            total_coin_value: 0,
2450            gas_price,
2451            budget: TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
2452            currency: None,
2453            address_balance_withdrawal: 0,
2454            epoch: None,
2455            chain_id: None,
2456            nonce: None,
2457            fss_object_count: None,
2458            redeem_token_amount: None,
2459            redeem_plan: None,
2460            bind_epoch: None,
2461        };
2462        let parsed_data = ops.into_internal()?.try_into_data(metadata)?;
2463        assert_eq!(data, parsed_data);
2464
2465        Ok(())
2466    }
2467
2468    /// Stake operations must survive a parse round-trip: ops → internal → data →
2469    /// proto → `from_transaction` → ops. This is a pure data round-trip (no chain
2470    /// state), so it lives in-crate rather than forcing `from_transaction` /
2471    /// `PaymentCurrency` into the public API for an integration test.
2472    #[test]
2473    fn test_stake_parse_round_trip() -> Result<(), anyhow::Error> {
2474        use sui_types::transaction::TEST_ONLY_GAS_UNIT_FOR_STAKING;
2475
2476        let sender = SuiAddress::random_for_testing_only();
2477        let validator = SuiAddress::random_for_testing_only();
2478        let gas = random_object_ref();
2479        let gas_price = 10;
2480
2481        let ops: Operations = serde_json::from_value(serde_json::json!([{
2482            "operation_identifier": {"index": 0},
2483            "type": "Stake",
2484            "account": {"address": sender.to_string()},
2485            "amount": {"value": "-100000", "currency": {"symbol": "SUI", "decimals": 9}},
2486            "metadata": {"Stake": {"validator": validator.to_string()}}
2487        }]))?;
2488
2489        let metadata = ConstructionMetadata {
2490            sender,
2491            gas_coins: vec![gas],
2492            extra_gas_coins: vec![],
2493            objects: vec![],
2494            party_objects: vec![],
2495            total_coin_value: 0,
2496            gas_price,
2497            budget: gas_price * TEST_ONLY_GAS_UNIT_FOR_STAKING,
2498            currency: None,
2499            address_balance_withdrawal: 0,
2500            epoch: None,
2501            chain_id: None,
2502            nonce: None,
2503            fss_object_count: None,
2504            redeem_token_amount: None,
2505            redeem_plan: None,
2506            bind_epoch: None,
2507        };
2508        let parsed_data = ops.clone().into_internal()?.try_into_data(metadata)?;
2509
2510        let proto_tx: Transaction = parsed_data.clone().into();
2511        let parsed_ops = Operations::new(Operations::from_transaction(
2512            proto_tx
2513                .kind
2514                .ok_or_else(|| Error::DataError("Transaction missing kind".to_string()))?,
2515            sender,
2516            None,
2517            PaymentCurrency::Sui,
2518        )?);
2519
2520        assert_eq!(ops, parsed_ops, "expected {ops:#?}, got: {parsed_ops:#?}");
2521        Ok(())
2522    }
2523
2524    /// Build a `pay_coin_pt`-shaped PTB (SplitCoins + TransferObjects) and parse
2525    /// it under the given payment currency. Shared by the currency→label tests.
2526    fn parse_payment_pt(payment: PaymentCurrency) -> Result<Vec<Operation>, anyhow::Error> {
2527        use crate::SUI;
2528        use crate::types::internal_operation::pay_coin_pt;
2529
2530        let gas = (
2531            ObjectID::random(),
2532            SequenceNumber::new(),
2533            ObjectDigest::random(),
2534        );
2535        let coin = (
2536            ObjectID::random(),
2537            SequenceNumber::new(),
2538            ObjectDigest::random(),
2539        );
2540        let sender = SuiAddress::random_for_testing_only();
2541        let recipient = SuiAddress::random_for_testing_only();
2542        let pt = pay_coin_pt(sender, vec![recipient], vec![10_000], &[coin], &[], 0, &SUI)?;
2543        let gas_price = 10;
2544        let data = TransactionData::new_programmable(
2545            sender,
2546            vec![gas],
2547            pt,
2548            TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
2549            gas_price,
2550        );
2551        let proto_tx: Transaction = data.into();
2552        let tx_kind = proto_tx.kind.unwrap();
2553        Ok(Operations::from_transaction(
2554            tx_kind, sender, None, payment,
2555        )?)
2556    }
2557
2558    /// The parser is a dumb applier: `PaymentCurrency::Unresolvable` must emit
2559    /// neither PaySui nor PayCoin — it falls through to `generic_op`. This is
2560    /// what the indexing caller hands over when `balance_changes` shows a non-SUI
2561    /// coin it couldn't resolve (or two or more non-SUI coins).
2562    #[test]
2563    fn test_parse_unresolvable_emits_generic_op() -> Result<(), anyhow::Error> {
2564        let ops = parse_payment_pt(PaymentCurrency::Unresolvable)?;
2565        assert!(
2566            !ops.iter().any(|op| op.type_ == OperationType::PaySui),
2567            "Unresolvable must not silently fall back to PaySui: {ops:?}"
2568        );
2569        assert!(
2570            !ops.iter().any(|op| op.type_ == OperationType::PayCoin),
2571            "Unresolvable must not produce PayCoin (we don't know the currency): {ops:?}"
2572        );
2573        assert!(
2574            ops.iter()
2575                .any(|op| matches!(op.metadata, Some(OperationMetadata::GenericTransaction(_)))),
2576            "Unresolvable must fall through to generic_op: {ops:?}"
2577        );
2578        Ok(())
2579    }
2580
2581    /// `PaymentCurrency::NonSui(c)` must label every payment leg as PayCoin
2582    /// carrying exactly `c`, and never PaySui.
2583    #[test]
2584    fn test_parse_nonsui_emits_pay_coin() -> Result<(), anyhow::Error> {
2585        use crate::types::CurrencyMetadata;
2586
2587        let usdc = Currency {
2588            symbol: "USDC".to_string(),
2589            decimals: 6,
2590            metadata: CurrencyMetadata {
2591                coin_type: "0xaaa::usdc::USDC".to_string(),
2592            },
2593        };
2594        let ops = parse_payment_pt(PaymentCurrency::NonSui(usdc.clone()))?;
2595        assert!(
2596            !ops.iter().any(|op| op.type_ == OperationType::PaySui),
2597            "NonSui must not produce PaySui: {ops:?}"
2598        );
2599        let pay_coins: Vec<_> = ops
2600            .iter()
2601            .filter(|op| op.type_ == OperationType::PayCoin)
2602            .collect();
2603        assert!(
2604            !pay_coins.is_empty(),
2605            "NonSui must produce PayCoin: {ops:?}"
2606        );
2607        for op in pay_coins {
2608            assert_eq!(
2609                op.amount.as_ref().map(|a| &a.currency),
2610                Some(&usdc),
2611                "PayCoin op must carry the NonSui currency: {op:?}"
2612            );
2613        }
2614        Ok(())
2615    }
2616
2617    /// A cache backed by a client that never connects, so every non-SUI coin
2618    /// lookup fails with a transport (transient) error.
2619    fn unreachable_cache() -> CoinMetadataCache {
2620        use std::num::NonZeroUsize;
2621        use sui_rpc::client::Client;
2622        CoinMetadataCache::new(
2623            Client::new("http://127.0.0.1:1").unwrap(),
2624            NonZeroUsize::new(1).unwrap(),
2625        )
2626    }
2627
2628    fn balance_change(coin_type: &str) -> BalanceChange {
2629        let mut bc = BalanceChange::default();
2630        bc.coin_type = Some(coin_type.to_string());
2631        bc
2632    }
2633
2634    /// SUI takes no metadata RPC: even with an unreachable cache, a SUI-only
2635    /// transaction resolves to a `Sui` payment (with SUI inserted directly into
2636    /// the map for the reconciliation pass), never a retriable error.
2637    #[tokio::test]
2638    async fn test_resolve_sui_needs_no_lookup() {
2639        let cache = unreachable_cache();
2640        let resolved = resolve_tx_currencies(&[balance_change(&SUI.metadata.coin_type)], &cache)
2641            .await
2642            .expect("SUI must resolve without an RPC");
2643        assert!(matches!(resolved.payment, PaymentCurrency::Sui));
2644        assert_eq!(
2645            resolved.by_coin_type.get(&SUI.metadata.coin_type),
2646            Some(&*SUI)
2647        );
2648    }
2649
2650    /// `Balance<T>`/`Coin<T>` accept a non-struct `T`, so a balance change can
2651    /// name `u64`. Coin metadata is keyed by `StructTag`, so no lookup may be
2652    /// issued for such a type: `unreachable_cache` turns any attempt into a
2653    /// retriable error, so `Ok` here proves the request was never sent. Mainnet
2654    /// checkpoint 309686199 stalled `/block` on exactly this shape.
2655    #[tokio::test]
2656    async fn test_resolve_non_struct_coin_type_degrades() {
2657        let cache = unreachable_cache();
2658        let resolved = resolve_tx_currencies(&[balance_change("u64")], &cache)
2659            .await
2660            .expect("a non-struct coin type must not fail the block");
2661        assert!(
2662            matches!(resolved.payment, PaymentCurrency::Unresolvable),
2663            "a non-struct coin type must fall through to generic_op: {:?}",
2664            resolved.payment
2665        );
2666        assert!(
2667            resolved.by_coin_type.is_empty(),
2668            "a non-struct coin type must not be reported as a currency: {:?}",
2669            resolved.by_coin_type
2670        );
2671    }
2672
2673    /// Part 2 / idempotency: a transient failure resolving a non-SUI coin must
2674    /// surface as a retriable error so `/block` stalls and retries, rather than
2675    /// degrading to a generic_op and baking it into the block.
2676    #[tokio::test]
2677    async fn test_resolve_transient_non_sui_is_retriable() {
2678        let cache = unreachable_cache();
2679        let err = resolve_tx_currencies(&[balance_change("0xaaa::usdc::USDC")], &cache)
2680            .await
2681            .expect_err("a transient non-SUI lookup failure must surface as an error");
2682        assert!(
2683            matches!(err, Error::CoinMetadataUnavailable(_)),
2684            "transient failure must map to CoinMetadataUnavailable: {err:?}"
2685        );
2686        // The Mesh error response must carry `retriable: true`.
2687        let json = serde_json::to_value(&err).expect("error serializes");
2688        assert_eq!(
2689            json.get("retriable"),
2690            Some(&serde_json::Value::Bool(true)),
2691            "CoinMetadataUnavailable must serialize as retriable: {json}"
2692        );
2693    }
2694
2695    /// `pay_coin_pt` must not append a trailing `Pure` input whose bytes
2696    /// BCS-decode as a String that JSON-decodes as `Currency`. Any future
2697    /// builder change that reintroduces that shape would re-couple
2698    /// downstream parsing to a brittle "scan last input" invariant.
2699    #[test]
2700    fn test_pay_coin_pt_has_no_currency_bearer() -> Result<(), anyhow::Error> {
2701        use crate::SUI;
2702        use crate::types::internal_operation::pay_coin_pt;
2703
2704        let sender = SuiAddress::random_for_testing_only();
2705        let recipient = SuiAddress::random_for_testing_only();
2706        let coin = (
2707            ObjectID::random(),
2708            SequenceNumber::new(),
2709            ObjectDigest::random(),
2710        );
2711
2712        let pt = pay_coin_pt(sender, vec![recipient], vec![10_000], &[coin], &[], 0, &SUI)?;
2713
2714        for input in &pt.inputs {
2715            if let CallArg::Pure(bytes) = input
2716                && let Ok(s) = bcs::from_bytes::<String>(bytes)
2717                && serde_json::from_str::<Currency>(&s).is_ok()
2718            {
2719                panic!(
2720                    "pay_coin_pt produced a Pure input that decodes as a Currency JSON string: {:?}",
2721                    s
2722                );
2723            }
2724        }
2725        Ok(())
2726    }
2727
2728    /// Regression test for the gas coin being fully consumed during execution.
2729    /// A `coin::send_funds` that moves the entire gas coin into an address balance
2730    /// (gasless / free-tier transfers) deletes the gas object, so its effects carry
2731    /// a `ChangedObject` with no `output_owner`. Previously `try_from_executed_transaction`
2732    /// fed the resulting empty owner string to `SuiAddress::from_str`, which produced
2733    /// `FastCryptoError::InvalidInput` ("Invalid value was given to the function") and
2734    /// failed the whole `/block` request. It must instead fall back to the gas payment
2735    /// owner and attribute gas to it.
2736    #[tokio::test]
2737    async fn test_try_from_executed_transaction_deleted_gas_coin() -> Result<(), anyhow::Error> {
2738        use std::num::NonZeroUsize;
2739        use sui_rpc::client::Client;
2740        use sui_rpc::proto::sui::rpc::v2::changed_object::OutputObjectState;
2741        use sui_rpc::proto::sui::rpc::v2::{
2742            ChangedObject, ExecutedTransaction, ExecutionStatus, GasCostSummary, TransactionEffects,
2743        };
2744
2745        let sender = SuiAddress::random_for_testing_only();
2746        let recipient = SuiAddress::random_for_testing_only();
2747
2748        let pt = {
2749            let mut builder = ProgrammableTransactionBuilder::new();
2750            builder.pay_sui(vec![recipient], vec![1000]).unwrap();
2751            builder.finish()
2752        };
2753        let gas_price = 10;
2754        let data = TransactionData::new_programmable(
2755            sender,
2756            vec![random_object_ref()],
2757            pt,
2758            TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
2759            gas_price,
2760        );
2761        let transaction: Transaction = data.into();
2762
2763        // The gas object is present in effects but was deleted (consumed), so it has
2764        // no output owner. (Proto structs are #[non_exhaustive], so build by mutation.)
2765        let mut gas_object = ChangedObject::default();
2766        gas_object.object_id = Some(ObjectID::random().to_string());
2767        gas_object.output_state = Some(OutputObjectState::DoesNotExist as i32);
2768        gas_object.output_owner = None;
2769
2770        let mut status = ExecutionStatus::default();
2771        status.success = Some(true);
2772
2773        let mut gas_used = GasCostSummary::default();
2774        gas_used.computation_cost = Some(1000);
2775        gas_used.storage_cost = Some(0);
2776        gas_used.storage_rebate = Some(0);
2777        gas_used.non_refundable_storage_fee = Some(0);
2778
2779        let mut effects = TransactionEffects::default();
2780        effects.status = Some(status);
2781        effects.gas_used = Some(gas_used);
2782        effects.gas_object = Some(gas_object);
2783
2784        let mut executed_tx = ExecutedTransaction::default();
2785        executed_tx.transaction = Some(transaction);
2786        executed_tx.effects = Some(effects);
2787        executed_tx.events = None;
2788        executed_tx.balance_changes = vec![];
2789
2790        // balance_changes is empty, so the coin metadata cache is never queried and a
2791        // client that never connects is sufficient.
2792        let cache = CoinMetadataCache::new(
2793            Client::new("http://127.0.0.1:1").unwrap(),
2794            NonZeroUsize::new(1).unwrap(),
2795        );
2796
2797        let ops = Operations::try_from_executed_transaction(executed_tx, &cache).await?;
2798
2799        let gas_op = ops
2800            .0
2801            .iter()
2802            .find(|op| op.type_ == OperationType::Gas)
2803            .expect("expected a Gas operation");
2804        assert_eq!(gas_op.account.as_ref().map(|a| a.address), Some(sender));
2805
2806        Ok(())
2807    }
2808
2809    /// Unstake + transfer of the gas coin to another address in one PTB (mainnet
2810    /// tx 8aSAZjEfiuN3wUEx9hsyYQaHpk6TrVGvorQDjpCzDzhc, checkpoint 328343430). The
2811    /// sender's balance-change operation is net of the unstake principal/reward
2812    /// (emitted as separate StakePrinciple/StakeReward operations), and the gas-coin
2813    /// validation must account for that rather than reject the block.
2814    #[tokio::test]
2815    async fn test_try_from_executed_transaction_unstake_with_gas_coin_transfer()
2816    -> Result<(), anyhow::Error> {
2817        use std::num::NonZeroUsize;
2818        use sui_rpc::client::Client;
2819        use sui_rpc::proto::sui::rpc::v2::owner::OwnerKind;
2820        use sui_rpc::proto::sui::rpc::v2::{
2821            ChangedObject, Event, ExecutedTransaction, ExecutionStatus, GasCostSummary, Owner,
2822            TransactionEffects, TransactionEvents,
2823        };
2824        use sui_types::transaction::Argument as NativeArgument;
2825
2826        let sender = SuiAddress::random_for_testing_only();
2827        let recipient = SuiAddress::random_for_testing_only();
2828        let principal: i128 = 1_000_000_000_000;
2829        let reward: i128 = 10_000_000_000;
2830        let gas_coin_balance: i128 = 5_000_000_000;
2831        let computation_cost: i128 = 1_000;
2832
2833        let pt = {
2834            let mut builder = ProgrammableTransactionBuilder::new();
2835            let system = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
2836            let staked_sui = builder
2837                .obj(ObjectArg::ImmOrOwnedObject(random_object_ref()))
2838                .unwrap();
2839            let balance = builder.command(NativeCommand::move_call(
2840                SUI_SYSTEM_PACKAGE_ID,
2841                Identifier::new("sui_system").unwrap(),
2842                Identifier::new("request_withdraw_stake_non_entry").unwrap(),
2843                vec![],
2844                vec![system, staked_sui],
2845            ));
2846            let coin = builder.command(NativeCommand::move_call(
2847                SUI_FRAMEWORK_PACKAGE_ID,
2848                Identifier::new("coin").unwrap(),
2849                Identifier::new("from_balance").unwrap(),
2850                vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
2851                vec![balance],
2852            ));
2853            let recipient_arg = builder.pure(recipient).unwrap();
2854            builder.command(NativeCommand::TransferObjects(vec![coin], recipient_arg));
2855            builder.command(NativeCommand::TransferObjects(
2856                vec![NativeArgument::GasCoin],
2857                recipient_arg,
2858            ));
2859            builder.finish()
2860        };
2861        let gas_price = 10;
2862        let data = TransactionData::new_programmable(
2863            sender,
2864            vec![random_object_ref()],
2865            pt,
2866            TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
2867            gas_price,
2868        );
2869        let transaction: Transaction = data.into();
2870
2871        // Proto structs are #[non_exhaustive], so build by mutation.
2872        let mut gas_output_owner = Owner::default();
2873        gas_output_owner.kind = Some(OwnerKind::Address as i32);
2874        gas_output_owner.address = Some(recipient.to_string());
2875        let mut gas_object = ChangedObject::default();
2876        gas_object.object_id = Some(ObjectID::random().to_string());
2877        gas_object.output_owner = Some(gas_output_owner);
2878
2879        let mut status = ExecutionStatus::default();
2880        status.success = Some(true);
2881
2882        let mut gas_used = GasCostSummary::default();
2883        gas_used.computation_cost = Some(computation_cost as u64);
2884        gas_used.storage_cost = Some(0);
2885        gas_used.storage_rebate = Some(0);
2886        gas_used.non_refundable_storage_fee = Some(0);
2887
2888        let mut effects = TransactionEffects::default();
2889        effects.status = Some(status);
2890        effects.gas_used = Some(gas_used);
2891        effects.gas_object = Some(gas_object);
2892
2893        let unstake_fields = [("principal_amount", principal), ("reward_amount", reward)]
2894            .into_iter()
2895            .map(|(name, amount)| {
2896                (
2897                    name.to_string(),
2898                    prost_types::Value {
2899                        kind: Some(Kind::StringValue(amount.to_string())),
2900                    },
2901                )
2902            })
2903            .collect();
2904        let mut unstake_event = Event::default();
2905        unstake_event.event_type = Some("0x3::validator::UnstakingRequestEvent".to_string());
2906        unstake_event.json = Some(Box::new(prost_types::Value {
2907            kind: Some(Kind::StructValue(prost_types::Struct {
2908                fields: unstake_fields,
2909            })),
2910        }));
2911        let mut events = TransactionEvents::default();
2912        events.events = vec![unstake_event];
2913
2914        let sui_balance_change = |address: SuiAddress, amount: i128| {
2915            let mut balance_change = BalanceChange::default();
2916            balance_change.address = Some(address.to_string());
2917            balance_change.coin_type = Some(SUI.metadata.coin_type.clone());
2918            balance_change.amount = Some(amount.to_string());
2919            balance_change
2920        };
2921        // The gas coin (after paying gas) and the unstaked SUI both go to the recipient.
2922        let sender_change = -gas_coin_balance;
2923        let recipient_change = principal + reward + gas_coin_balance - computation_cost;
2924
2925        let mut executed_tx = ExecutedTransaction::default();
2926        executed_tx.transaction = Some(transaction);
2927        executed_tx.effects = Some(effects);
2928        executed_tx.events = Some(events);
2929        executed_tx.balance_changes = vec![
2930            sui_balance_change(sender, sender_change),
2931            sui_balance_change(recipient, recipient_change),
2932        ];
2933
2934        // Only SUI balance changes, which resolve without an RPC, so a client that
2935        // never connects is sufficient.
2936        let cache = CoinMetadataCache::new(
2937            Client::new("http://127.0.0.1:1").unwrap(),
2938            NonZeroUsize::new(1).unwrap(),
2939        );
2940
2941        let ops = Operations::try_from_executed_transaction(executed_tx, &cache).await?;
2942
2943        let gas_op = ops
2944            .0
2945            .iter()
2946            .find(|op| op.type_ == OperationType::Gas)
2947            .expect("expected a Gas operation");
2948        assert_eq!(gas_op.account.as_ref().map(|a| a.address), Some(sender));
2949
2950        // Every operation amount, gas included, must add up to the on-chain balance
2951        // changes per address.
2952        let mut net_by_address: HashMap<SuiAddress, i128> = HashMap::new();
2953        for op in &ops.0 {
2954            if let (Some(account), Some(amount)) = (&op.account, &op.amount) {
2955                *net_by_address.entry(account.address).or_default() += amount.value;
2956            }
2957        }
2958        assert_eq!(
2959            net_by_address,
2960            HashMap::from([(sender, sender_change), (recipient, recipient_change)]),
2961            "operations: {:?}",
2962            ops.0
2963        );
2964
2965        Ok(())
2966    }
2967
2968    #[test]
2969    fn test_parse_consolidate_all_staked_sui_to_fungible() {
2970        let sender = SuiAddress::random_for_testing_only();
2971        let validator = SuiAddress::random_for_testing_only();
2972
2973        let ops: Operations = serde_json::from_value(serde_json::json!([{
2974            "operation_identifier": {"index": 0},
2975            "type": "ConsolidateAllStakedSuiToFungible",
2976            "account": {"address": sender.to_string()},
2977            "metadata": {
2978                "ConsolidateAllStakedSuiToFungible": {
2979                    "validator": validator.to_string()
2980                }
2981            }
2982        }]))
2983        .unwrap();
2984
2985        let internal = ops.into_internal().unwrap();
2986        match internal {
2987            InternalOperation::ConsolidateAllStakedSuiToFungible(op) => {
2988                assert_eq!(op.sender, sender);
2989                assert_eq!(op.validator, validator);
2990            }
2991            _ => panic!("Expected ConsolidateAllStakedSuiToFungible"),
2992        }
2993    }
2994
2995    #[test]
2996    fn test_parse_merge_and_redeem_fungible_staked_sui() {
2997        let sender = SuiAddress::random_for_testing_only();
2998        let validator = SuiAddress::random_for_testing_only();
2999
3000        let ops: Operations = serde_json::from_value(serde_json::json!([{
3001            "operation_identifier": {"index": 0},
3002            "type": "MergeAndRedeemFungibleStakedSui",
3003            "account": {"address": sender.to_string()},
3004            "metadata": {
3005                "MergeAndRedeemFungibleStakedSui": {
3006                    "validator": validator.to_string(),
3007                    "amount": "500000000000",
3008                    "redeem_mode": "AtLeast"
3009                }
3010            }
3011        }]))
3012        .unwrap();
3013
3014        let internal = ops.into_internal().unwrap();
3015        match internal {
3016            InternalOperation::MergeAndRedeemFungibleStakedSui(op) => {
3017                assert_eq!(op.sender, sender);
3018                assert_eq!(op.validator, validator);
3019                assert_eq!(op.amount, Some(500000000000));
3020                assert_eq!(op.redeem_mode, RedeemMode::AtLeast);
3021            }
3022            _ => panic!("Expected MergeAndRedeemFungibleStakedSui"),
3023        }
3024    }
3025
3026    #[test]
3027    fn test_parse_merge_and_redeem_all_mode() {
3028        let sender = SuiAddress::random_for_testing_only();
3029        let validator = SuiAddress::random_for_testing_only();
3030
3031        let ops: Operations = serde_json::from_value(serde_json::json!([{
3032            "operation_identifier": {"index": 0},
3033            "type": "MergeAndRedeemFungibleStakedSui",
3034            "account": {"address": sender.to_string()},
3035            "metadata": {
3036                "MergeAndRedeemFungibleStakedSui": {
3037                    "validator": validator.to_string(),
3038                    "redeem_mode": "All"
3039                }
3040            }
3041        }]))
3042        .unwrap();
3043
3044        let internal = ops.into_internal().unwrap();
3045        match internal {
3046            InternalOperation::MergeAndRedeemFungibleStakedSui(op) => {
3047                assert_eq!(op.amount, None);
3048                assert_eq!(op.redeem_mode, RedeemMode::All);
3049            }
3050            _ => panic!("Expected MergeAndRedeemFungibleStakedSui"),
3051        }
3052    }
3053
3054    // ==============================================================================
3055    // PR 1: Consolidate parser — happy-path tests (11 tests)
3056    // ==============================================================================
3057
3058    fn assert_consolidate_ops(
3059        ops: &[Operation],
3060        expected_sender: SuiAddress,
3061        expected_staked_sui: &[ObjectID],
3062        expected_fss: &[ObjectID],
3063    ) {
3064        assert_eq!(ops.len(), 1);
3065        let op = &ops[0];
3066        assert_eq!(op.type_, OperationType::ConsolidateAllStakedSuiToFungible);
3067        assert_eq!(
3068            op.account.as_ref().map(|a| a.address),
3069            Some(expected_sender)
3070        );
3071        assert!(op.amount.is_none());
3072        let Some(OperationMetadata::ConsolidateAllStakedSuiToFungible {
3073            validator,
3074            staked_sui_ids,
3075            fss_ids,
3076        }) = op.metadata.clone()
3077        else {
3078            panic!("wrong metadata variant: {:?}", op.metadata);
3079        };
3080        assert!(validator.is_none(), "validator must be None on parse");
3081        assert_eq!(staked_sui_ids, expected_staked_sui);
3082        assert_eq!(fss_ids, expected_fss);
3083    }
3084
3085    #[test]
3086    fn test_parse_consolidate_pure_merge_2_fss() {
3087        let sender = SuiAddress::random_for_testing_only();
3088        let fss_a = random_object_ref();
3089        let fss_b = random_object_ref();
3090        let pt = consolidate_to_fungible_pt(sender, vec![fss_a, fss_b], vec![]).expect("pt");
3091        let ops = parse_pt(sender, pt);
3092        assert_consolidate_ops(&ops, sender, &[], &[fss_a.0, fss_b.0]);
3093    }
3094
3095    #[test]
3096    fn test_parse_consolidate_pure_merge_3_fss() {
3097        let sender = SuiAddress::random_for_testing_only();
3098        let a = random_object_ref();
3099        let b = random_object_ref();
3100        let c = random_object_ref();
3101        let pt = consolidate_to_fungible_pt(sender, vec![a, b, c], vec![]).expect("pt");
3102        assert_consolidate_ops(&parse_pt(sender, pt), sender, &[], &[a.0, b.0, c.0]);
3103    }
3104
3105    #[test]
3106    fn test_parse_consolidate_pure_merge_5_fss() {
3107        let sender = SuiAddress::random_for_testing_only();
3108        let refs: Vec<_> = (0..5).map(|_| random_object_ref()).collect();
3109        let pt = consolidate_to_fungible_pt(sender, refs.clone(), vec![]).expect("pt");
3110        let expected: Vec<_> = refs.iter().map(|r| r.0).collect();
3111        assert_consolidate_ops(&parse_pt(sender, pt), sender, &[], &expected);
3112    }
3113
3114    #[test]
3115    fn test_parse_consolidate_single_convert_no_fss() {
3116        let sender = SuiAddress::random_for_testing_only();
3117        let staked = random_object_ref();
3118        let pt = consolidate_to_fungible_pt(sender, vec![], vec![staked]).expect("pt");
3119        assert_consolidate_ops(&parse_pt(sender, pt), sender, &[staked.0], &[]);
3120    }
3121
3122    #[test]
3123    fn test_parse_consolidate_multi_convert_no_fss() {
3124        let sender = SuiAddress::random_for_testing_only();
3125        let s1 = random_object_ref();
3126        let s2 = random_object_ref();
3127        let s3 = random_object_ref();
3128        let pt = consolidate_to_fungible_pt(sender, vec![], vec![s1, s2, s3]).expect("pt");
3129        assert_consolidate_ops(&parse_pt(sender, pt), sender, &[s1.0, s2.0, s3.0], &[]);
3130    }
3131
3132    #[test]
3133    fn test_parse_consolidate_single_stake_single_fss() {
3134        let sender = SuiAddress::random_for_testing_only();
3135        let fss = random_object_ref();
3136        let staked = random_object_ref();
3137        let pt = consolidate_to_fungible_pt(sender, vec![fss], vec![staked]).expect("pt");
3138        assert_consolidate_ops(&parse_pt(sender, pt), sender, &[staked.0], &[fss.0]);
3139    }
3140
3141    #[test]
3142    fn test_parse_consolidate_single_stake_multi_fss() {
3143        let sender = SuiAddress::random_for_testing_only();
3144        let f1 = random_object_ref();
3145        let f2 = random_object_ref();
3146        let staked = random_object_ref();
3147        let pt = consolidate_to_fungible_pt(sender, vec![f1, f2], vec![staked]).expect("pt");
3148        assert_consolidate_ops(&parse_pt(sender, pt), sender, &[staked.0], &[f1.0, f2.0]);
3149    }
3150
3151    #[test]
3152    fn test_parse_consolidate_multi_stake_single_fss() {
3153        let sender = SuiAddress::random_for_testing_only();
3154        let fss = random_object_ref();
3155        let s1 = random_object_ref();
3156        let s2 = random_object_ref();
3157        let pt = consolidate_to_fungible_pt(sender, vec![fss], vec![s1, s2]).expect("pt");
3158        assert_consolidate_ops(&parse_pt(sender, pt), sender, &[s1.0, s2.0], &[fss.0]);
3159    }
3160
3161    #[test]
3162    fn test_parse_consolidate_multi_stake_multi_fss() {
3163        let sender = SuiAddress::random_for_testing_only();
3164        let f1 = random_object_ref();
3165        let f2 = random_object_ref();
3166        let s1 = random_object_ref();
3167        let s2 = random_object_ref();
3168        let pt = consolidate_to_fungible_pt(sender, vec![f1, f2], vec![s1, s2]).expect("pt");
3169        assert_consolidate_ops(&parse_pt(sender, pt), sender, &[s1.0, s2.0], &[f1.0, f2.0]);
3170    }
3171
3172    #[test]
3173    fn test_parse_consolidate_large_mixed() {
3174        let sender = SuiAddress::random_for_testing_only();
3175        let fss: Vec<_> = (0..3).map(|_| random_object_ref()).collect();
3176        let staked: Vec<_> = (0..3).map(|_| random_object_ref()).collect();
3177        let pt = consolidate_to_fungible_pt(sender, fss.clone(), staked.clone()).expect("pt");
3178        let expected_s: Vec<_> = staked.iter().map(|r| r.0).collect();
3179        let expected_f: Vec<_> = fss.iter().map(|r| r.0).collect();
3180        assert_consolidate_ops(&parse_pt(sender, pt), sender, &expected_s, &expected_f);
3181    }
3182
3183    #[test]
3184    fn test_parse_consolidate_classification_correctness() {
3185        // No overlap between staked_sui_ids and fss_ids after parsing a mixed PTB.
3186        let sender = SuiAddress::random_for_testing_only();
3187        let f1 = random_object_ref();
3188        let f2 = random_object_ref();
3189        let s1 = random_object_ref();
3190        let s2 = random_object_ref();
3191        let pt = consolidate_to_fungible_pt(sender, vec![f1, f2], vec![s1, s2]).expect("pt");
3192        let ops = parse_pt(sender, pt);
3193        let Some(OperationMetadata::ConsolidateAllStakedSuiToFungible {
3194            staked_sui_ids,
3195            fss_ids,
3196            ..
3197        }) = ops[0].metadata.clone()
3198        else {
3199            panic!();
3200        };
3201        let staked_set: std::collections::HashSet<_> = staked_sui_ids.iter().collect();
3202        let fss_set: std::collections::HashSet<_> = fss_ids.iter().collect();
3203        assert!(
3204            staked_set.is_disjoint(&fss_set),
3205            "classification crossed categories"
3206        );
3207    }
3208
3209    // ==============================================================================
3210    // PR 1: Fall-through tests (4 tests) — malformed PTBs must NOT be labeled Consolidate
3211    // ==============================================================================
3212
3213    fn assert_falls_through_to_generic(ops: &[Operation]) {
3214        assert_eq!(ops.len(), 1);
3215        assert_eq!(
3216            ops[0].type_,
3217            OperationType::ProgrammableTransaction,
3218            "expected fall-through to generic ProgrammableTransaction, got: {:?}",
3219            ops[0].type_
3220        );
3221    }
3222
3223    #[test]
3224    fn test_parse_falls_through_consolidate_with_merge_coins() {
3225        let sender = SuiAddress::random_for_testing_only();
3226        let fss_a = random_object_ref();
3227        let fss_b = random_object_ref();
3228        let coin_a = random_object_ref();
3229
3230        let mut builder = ProgrammableTransactionBuilder::new();
3231        let _sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3232        let first = builder.obj(ObjectArg::ImmOrOwnedObject(fss_a)).unwrap();
3233        let other = builder.obj(ObjectArg::ImmOrOwnedObject(fss_b)).unwrap();
3234        builder.command(NativeCommand::move_call(
3235            SUI_SYSTEM_PACKAGE_ID,
3236            Identifier::new("staking_pool").unwrap(),
3237            Identifier::new("join_fungible_staked_sui").unwrap(),
3238            vec![],
3239            vec![first, other],
3240        ));
3241        // Rogue MergeCoins breaks Consolidate shape validation.
3242        let coin_target = builder.obj(ObjectArg::ImmOrOwnedObject(coin_a)).unwrap();
3243        builder.command(NativeCommand::MergeCoins(coin_target, vec![]));
3244
3245        let ops = parse_pt(sender, builder.finish());
3246        assert_falls_through_to_generic(&ops);
3247    }
3248
3249    #[test]
3250    fn test_parse_falls_through_consolidate_with_unrelated_movecall() {
3251        let sender = SuiAddress::random_for_testing_only();
3252        let fss_a = random_object_ref();
3253        let fss_b = random_object_ref();
3254
3255        let mut builder = ProgrammableTransactionBuilder::new();
3256        let _sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3257        let first = builder.obj(ObjectArg::ImmOrOwnedObject(fss_a)).unwrap();
3258        let other = builder.obj(ObjectArg::ImmOrOwnedObject(fss_b)).unwrap();
3259        builder.command(NativeCommand::move_call(
3260            SUI_SYSTEM_PACKAGE_ID,
3261            Identifier::new("staking_pool").unwrap(),
3262            Identifier::new("join_fungible_staked_sui").unwrap(),
3263            vec![],
3264            vec![first, other],
3265        ));
3266        // Unrelated MoveCall (e.g., 0x2::sui::transfer doesn't exist, so use any other function).
3267        builder.command(NativeCommand::move_call(
3268            SUI_FRAMEWORK_PACKAGE_ID,
3269            Identifier::new("coin").unwrap(),
3270            Identifier::new("destroy_zero").unwrap(),
3271            vec![],
3272            vec![other],
3273        ));
3274
3275        let ops = parse_pt(sender, builder.finish());
3276        assert_falls_through_to_generic(&ops);
3277    }
3278
3279    #[test]
3280    fn test_parse_falls_through_convert_without_system_state() {
3281        // Build a PTB where inputs[0] is an ImmOrOwned object (not SUI_SYSTEM_STATE shared).
3282        let sender = SuiAddress::random_for_testing_only();
3283        let staked = random_object_ref();
3284        let other_obj = random_object_ref();
3285
3286        let mut builder = ProgrammableTransactionBuilder::new();
3287        // Put a random object first — parser should reject.
3288        let _not_system = builder.obj(ObjectArg::ImmOrOwnedObject(other_obj)).unwrap();
3289        let staked_arg = builder.obj(ObjectArg::ImmOrOwnedObject(staked)).unwrap();
3290        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3291        let new_fss = builder.command(NativeCommand::move_call(
3292            SUI_SYSTEM_PACKAGE_ID,
3293            Identifier::new("sui_system").unwrap(),
3294            Identifier::new("convert_to_fungible_staked_sui").unwrap(),
3295            vec![],
3296            vec![sys, staked_arg],
3297        ));
3298        let sender_arg = builder.pure(sender).unwrap();
3299        builder.command(NativeCommand::TransferObjects(vec![new_fss], sender_arg));
3300
3301        let ops = parse_pt(sender, builder.finish());
3302        assert_falls_through_to_generic(&ops);
3303    }
3304
3305    #[test]
3306    fn test_parse_falls_through_extra_command_after_transfer() {
3307        // Valid Consolidate shape + an extra command after TransferObjects → reject.
3308        let sender = SuiAddress::random_for_testing_only();
3309        let staked = random_object_ref();
3310        let other_obj = random_object_ref();
3311
3312        let mut builder = ProgrammableTransactionBuilder::new();
3313        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3314        let staked_arg = builder.obj(ObjectArg::ImmOrOwnedObject(staked)).unwrap();
3315        let new_fss = builder.command(NativeCommand::move_call(
3316            SUI_SYSTEM_PACKAGE_ID,
3317            Identifier::new("sui_system").unwrap(),
3318            Identifier::new("convert_to_fungible_staked_sui").unwrap(),
3319            vec![],
3320            vec![sys, staked_arg],
3321        ));
3322        let sender_arg = builder.pure(sender).unwrap();
3323        builder.command(NativeCommand::TransferObjects(vec![new_fss], sender_arg));
3324        // Extra command: destroy_zero on an unrelated coin.
3325        let extra = builder.obj(ObjectArg::ImmOrOwnedObject(other_obj)).unwrap();
3326        builder.command(NativeCommand::move_call(
3327            SUI_FRAMEWORK_PACKAGE_ID,
3328            Identifier::new("coin").unwrap(),
3329            Identifier::new("destroy_zero").unwrap(),
3330            vec![],
3331            vec![extra],
3332        ));
3333
3334        let ops = parse_pt(sender, builder.finish());
3335        assert_falls_through_to_generic(&ops);
3336    }
3337
3338    // ==============================================================================
3339    // PR 1: Robustness tests (4 tests, but #38-39 belong in e2e — see plan)
3340    // ==============================================================================
3341
3342    #[test]
3343    fn test_parse_empty_ptb() {
3344        let sender = SuiAddress::random_for_testing_only();
3345        let pt = ProgrammableTransactionBuilder::new().finish();
3346        let ops = parse_pt(sender, pt);
3347        // Zero commands: parser should produce a generic op (existing behavior).
3348        assert_eq!(ops.len(), 1);
3349        assert_eq!(ops[0].type_, OperationType::ProgrammableTransaction);
3350    }
3351
3352    #[test]
3353    fn test_parse_only_merge_coins() {
3354        // PTB with only regular MergeCoins (non-FSS) — falls through, unrelated to our dispatch.
3355        let sender = SuiAddress::random_for_testing_only();
3356        let coin_a = random_object_ref();
3357        let coin_b = random_object_ref();
3358        let mut builder = ProgrammableTransactionBuilder::new();
3359        let target = builder.obj(ObjectArg::ImmOrOwnedObject(coin_a)).unwrap();
3360        let source = builder.obj(ObjectArg::ImmOrOwnedObject(coin_b)).unwrap();
3361        builder.command(NativeCommand::MergeCoins(target, vec![source]));
3362        let ops = parse_pt(sender, builder.finish());
3363        // Either ProgrammableTransaction (generic) or whatever the existing parser produces.
3364        // Not our typed FSS op.
3365        assert_ne!(
3366            ops[0].type_,
3367            OperationType::ConsolidateAllStakedSuiToFungible
3368        );
3369        assert_ne!(ops[0].type_, OperationType::MergeAndRedeemFungibleStakedSui);
3370    }
3371
3372    // Tests #38 (garbage bytes) and #39 (truncated tx data) are HTTP-level and belong in
3373    // end_to_end_tests.rs — see plan section D.
3374
3375    // ==============================================================================
3376    // PR 1: Metadata serialization compat (2 tests)
3377    // ==============================================================================
3378
3379    #[test]
3380    fn test_meta_consolidate_old_input_deserializes() {
3381        let validator = SuiAddress::random_for_testing_only();
3382        let json = serde_json::json!({
3383            "ConsolidateAllStakedSuiToFungible": { "validator": validator.to_string() }
3384        });
3385        let meta: OperationMetadata = serde_json::from_value(json).unwrap();
3386        match meta {
3387            OperationMetadata::ConsolidateAllStakedSuiToFungible {
3388                validator: v,
3389                staked_sui_ids,
3390                fss_ids,
3391            } => {
3392                assert_eq!(v, Some(validator));
3393                assert!(staked_sui_ids.is_empty());
3394                assert!(fss_ids.is_empty());
3395            }
3396            _ => panic!("wrong variant"),
3397        }
3398    }
3399
3400    #[test]
3401    fn test_meta_consolidate_new_parse_output_serializes() {
3402        let id_a = ObjectID::random();
3403        let id_b = ObjectID::random();
3404        let meta = OperationMetadata::ConsolidateAllStakedSuiToFungible {
3405            validator: None,
3406            staked_sui_ids: vec![id_a],
3407            fss_ids: vec![id_b],
3408        };
3409        let json = serde_json::to_value(&meta).unwrap();
3410        let obj = json
3411            .as_object()
3412            .unwrap()
3413            .get("ConsolidateAllStakedSuiToFungible")
3414            .unwrap()
3415            .as_object()
3416            .unwrap();
3417        assert!(
3418            !obj.contains_key("validator"),
3419            "validator must be omitted when None"
3420        );
3421        assert_eq!(
3422            obj.get("staked_sui_ids").unwrap().as_array().unwrap().len(),
3423            1
3424        );
3425        assert_eq!(obj.get("fss_ids").unwrap().as_array().unwrap().len(), 1);
3426    }
3427
3428    // ==============================================================================
3429    // PR 1: Write-side preservation (1 test)
3430    // ==============================================================================
3431
3432    #[test]
3433    fn test_write_consolidate_requires_validator() {
3434        let sender = SuiAddress::random_for_testing_only();
3435        let op = Operation {
3436            operation_identifier: Default::default(),
3437            type_: OperationType::ConsolidateAllStakedSuiToFungible,
3438            status: None,
3439            account: Some(sender.into()),
3440            amount: None,
3441            coin_change: None,
3442            metadata: Some(OperationMetadata::ConsolidateAllStakedSuiToFungible {
3443                validator: None,
3444                staked_sui_ids: vec![],
3445                fss_ids: vec![],
3446            }),
3447        };
3448        let err = Operations::new(vec![op])
3449            .into_internal()
3450            .expect_err("should fail without validator");
3451        let msg = format!("{err}");
3452        assert!(msg.contains("validator"), "unexpected error: {msg}");
3453    }
3454
3455    // ==============================================================================
3456    // PR 2: MergeAndRedeem parser — happy-path tests (11 tests)
3457    // ==============================================================================
3458
3459    fn assert_merge_redeem_ops(
3460        ops: &[Operation],
3461        expected_sender: SuiAddress,
3462        expected_fss: &[ObjectID],
3463        expected_mode: Option<RedeemMode>,
3464    ) {
3465        assert_merge_redeem_ops_with_amount(
3466            ops,
3467            expected_sender,
3468            expected_fss,
3469            expected_mode,
3470            None,
3471        );
3472    }
3473
3474    fn assert_merge_redeem_ops_with_amount(
3475        ops: &[Operation],
3476        expected_sender: SuiAddress,
3477        expected_fss: &[ObjectID],
3478        expected_mode: Option<RedeemMode>,
3479        expected_amount: Option<&str>,
3480    ) {
3481        assert_eq!(ops.len(), 1);
3482        let op = &ops[0];
3483        assert_eq!(op.type_, OperationType::MergeAndRedeemFungibleStakedSui);
3484        assert_eq!(
3485            op.account.as_ref().map(|a| a.address),
3486            Some(expected_sender)
3487        );
3488        assert!(op.amount.is_none());
3489        let Some(OperationMetadata::MergeAndRedeemFungibleStakedSui {
3490            validator,
3491            amount,
3492            redeem_mode,
3493            fss_ids,
3494        }) = op.metadata.clone()
3495        else {
3496            panic!("wrong metadata variant: {:?}", op.metadata);
3497        };
3498        assert!(validator.is_none(), "validator must be None on parse");
3499        assert_eq!(
3500            amount.as_deref(),
3501            expected_amount,
3502            "metadata.amount mismatch"
3503        );
3504        assert_eq!(redeem_mode, expected_mode);
3505        assert_eq!(fss_ids, expected_fss);
3506    }
3507
3508    #[test]
3509    fn test_parse_merge_redeem_single_all() {
3510        let sender = SuiAddress::random_for_testing_only();
3511        let fss = random_object_ref();
3512        let pt = merge_and_redeem_fss_pt(sender, vec![fss], &RedeemPlan::All).expect("pt");
3513        assert_merge_redeem_ops(
3514            &parse_pt(sender, pt),
3515            sender,
3516            &[fss.0],
3517            Some(RedeemMode::All),
3518        );
3519    }
3520
3521    #[test]
3522    fn test_parse_merge_redeem_single_partial() {
3523        let sender = SuiAddress::random_for_testing_only();
3524        let fss = random_object_ref();
3525        let pt = merge_and_redeem_fss_pt(
3526            sender,
3527            vec![fss],
3528            &RedeemPlan::AtMost {
3529                token_amount: Some(500_000_000),
3530                max_sui: 0,
3531            },
3532        )
3533        .expect("pt");
3534        assert_merge_redeem_ops(&parse_pt(sender, pt), sender, &[fss.0], None);
3535    }
3536
3537    #[test]
3538    fn test_parse_merge_redeem_atleast_with_balance_guard() {
3539        let sender = SuiAddress::random_for_testing_only();
3540        let fss = random_object_ref();
3541        let pt = merge_and_redeem_fss_pt(
3542            sender,
3543            vec![fss],
3544            &RedeemPlan::AtLeast {
3545                token_amount: Some(500_000_000),
3546                min_sui: 1_000_000,
3547            },
3548        )
3549        .expect("pt");
3550        assert_merge_redeem_ops_with_amount(
3551            &parse_pt(sender, pt),
3552            sender,
3553            &[fss.0],
3554            Some(RedeemMode::AtLeast),
3555            Some("1000000"),
3556        );
3557    }
3558
3559    #[test]
3560    fn test_parse_merge_redeem_atleast_three_fss() {
3561        let sender = SuiAddress::random_for_testing_only();
3562        let a = random_object_ref();
3563        let b = random_object_ref();
3564        let c = random_object_ref();
3565        let pt = merge_and_redeem_fss_pt(
3566            sender,
3567            vec![a, b, c],
3568            &RedeemPlan::AtLeast {
3569                token_amount: Some(500_000_000),
3570                min_sui: 1_000_000,
3571            },
3572        )
3573        .expect("pt");
3574        assert_merge_redeem_ops_with_amount(
3575            &parse_pt(sender, pt),
3576            sender,
3577            &[a.0, b.0, c.0],
3578            Some(RedeemMode::AtLeast),
3579            Some("1000000"),
3580        );
3581    }
3582
3583    #[test]
3584    fn test_parse_merge_redeem_full_atleast_no_split() {
3585        // Full-redeem AtLeast: token_amount = None → no `split_fungible_staked_sui`.
3586        // The PTB still has the balance::split + balance::join guard, so the
3587        // parser must recognize this shape as AtLeast (with min_sui recovered)
3588        // rather than emitting `redeem_mode = None` because there's no FSS split.
3589        let sender = SuiAddress::random_for_testing_only();
3590        let fss = random_object_ref();
3591        let pt = merge_and_redeem_fss_pt(
3592            sender,
3593            vec![fss],
3594            &RedeemPlan::AtLeast {
3595                token_amount: None,
3596                min_sui: 1_000_000,
3597            },
3598        )
3599        .expect("pt");
3600        assert_merge_redeem_ops_with_amount(
3601            &parse_pt(sender, pt),
3602            sender,
3603            &[fss.0],
3604            Some(RedeemMode::AtLeast),
3605            Some("1000000"),
3606        );
3607    }
3608
3609    #[test]
3610    fn test_parse_merge_redeem_two_all() {
3611        let sender = SuiAddress::random_for_testing_only();
3612        let a = random_object_ref();
3613        let b = random_object_ref();
3614        let pt = merge_and_redeem_fss_pt(sender, vec![a, b], &RedeemPlan::All).expect("pt");
3615        assert_merge_redeem_ops(
3616            &parse_pt(sender, pt),
3617            sender,
3618            &[a.0, b.0],
3619            Some(RedeemMode::All),
3620        );
3621    }
3622
3623    #[test]
3624    fn test_parse_merge_redeem_two_partial() {
3625        let sender = SuiAddress::random_for_testing_only();
3626        let a = random_object_ref();
3627        let b = random_object_ref();
3628        let pt = merge_and_redeem_fss_pt(
3629            sender,
3630            vec![a, b],
3631            &RedeemPlan::AtMost {
3632                token_amount: Some(500_000_000),
3633                max_sui: 0,
3634            },
3635        )
3636        .expect("pt");
3637        assert_merge_redeem_ops(&parse_pt(sender, pt), sender, &[a.0, b.0], None);
3638    }
3639
3640    #[test]
3641    fn test_parse_merge_redeem_three_all() {
3642        let sender = SuiAddress::random_for_testing_only();
3643        let a = random_object_ref();
3644        let b = random_object_ref();
3645        let c = random_object_ref();
3646        let pt = merge_and_redeem_fss_pt(sender, vec![a, b, c], &RedeemPlan::All).expect("pt");
3647        assert_merge_redeem_ops(
3648            &parse_pt(sender, pt),
3649            sender,
3650            &[a.0, b.0, c.0],
3651            Some(RedeemMode::All),
3652        );
3653    }
3654
3655    #[test]
3656    fn test_parse_merge_redeem_three_partial() {
3657        let sender = SuiAddress::random_for_testing_only();
3658        let a = random_object_ref();
3659        let b = random_object_ref();
3660        let c = random_object_ref();
3661        let pt = merge_and_redeem_fss_pt(
3662            sender,
3663            vec![a, b, c],
3664            &RedeemPlan::AtMost {
3665                token_amount: Some(500_000_000),
3666                max_sui: 0,
3667            },
3668        )
3669        .expect("pt");
3670        assert_merge_redeem_ops(&parse_pt(sender, pt), sender, &[a.0, b.0, c.0], None);
3671    }
3672
3673    #[test]
3674    fn test_parse_merge_redeem_five_all() {
3675        let sender = SuiAddress::random_for_testing_only();
3676        let refs: Vec<_> = (0..5).map(|_| random_object_ref()).collect();
3677        let pt = merge_and_redeem_fss_pt(sender, refs.clone(), &RedeemPlan::All).expect("pt");
3678        let expected: Vec<_> = refs.iter().map(|r| r.0).collect();
3679        assert_merge_redeem_ops(
3680            &parse_pt(sender, pt),
3681            sender,
3682            &expected,
3683            Some(RedeemMode::All),
3684        );
3685    }
3686
3687    #[test]
3688    fn test_parse_merge_redeem_fss_ids_order() {
3689        // Build with a specific order and assert the parser preserves it.
3690        let sender = SuiAddress::random_for_testing_only();
3691        let a = random_object_ref();
3692        let b = random_object_ref();
3693        let c = random_object_ref();
3694        let pt = merge_and_redeem_fss_pt(sender, vec![a, b, c], &RedeemPlan::All).expect("pt");
3695        let ops = parse_pt(sender, pt);
3696        let Some(OperationMetadata::MergeAndRedeemFungibleStakedSui { fss_ids, .. }) =
3697            ops[0].metadata.clone()
3698        else {
3699            panic!();
3700        };
3701        assert_eq!(fss_ids, vec![a.0, b.0, c.0]);
3702    }
3703
3704    #[test]
3705    fn test_parse_merge_redeem_sender_account() {
3706        let sender = SuiAddress::random_for_testing_only();
3707        let fss = random_object_ref();
3708        let pt = merge_and_redeem_fss_pt(sender, vec![fss], &RedeemPlan::All).expect("pt");
3709        let ops = parse_pt(sender, pt);
3710        assert_eq!(ops[0].account.as_ref().unwrap().address, sender);
3711    }
3712
3713    #[test]
3714    fn test_parse_merge_redeem_no_amount_in_metadata() {
3715        let sender = SuiAddress::random_for_testing_only();
3716        let fss = random_object_ref();
3717        let pt = merge_and_redeem_fss_pt(
3718            sender,
3719            vec![fss],
3720            &RedeemPlan::AtMost {
3721                token_amount: Some(500_000_000),
3722                max_sui: 0,
3723            },
3724        )
3725        .expect("pt");
3726        let ops = parse_pt(sender, pt);
3727        let Some(OperationMetadata::MergeAndRedeemFungibleStakedSui { amount, .. }) =
3728            ops[0].metadata.clone()
3729        else {
3730            panic!();
3731        };
3732        assert!(amount.is_none());
3733    }
3734
3735    #[test]
3736    fn test_parse_merge_redeem_no_validator_in_metadata() {
3737        let sender = SuiAddress::random_for_testing_only();
3738        let fss = random_object_ref();
3739        let pt = merge_and_redeem_fss_pt(sender, vec![fss], &RedeemPlan::All).expect("pt");
3740        let ops = parse_pt(sender, pt);
3741        let Some(OperationMetadata::MergeAndRedeemFungibleStakedSui { validator, .. }) =
3742            ops[0].metadata.clone()
3743        else {
3744            panic!();
3745        };
3746        assert!(validator.is_none());
3747    }
3748
3749    // ==============================================================================
3750    // PR 2: Fall-through tests — malformed MergeAndRedeem PTBs (9 tests)
3751    // ==============================================================================
3752
3753    fn build_redeem_ptb_with_type_arg(
3754        sender: SuiAddress,
3755        fss: ObjectRef,
3756        coin_type_arg: &str,
3757    ) -> ProgrammableTransaction {
3758        let mut builder = ProgrammableTransactionBuilder::new();
3759        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3760        let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3761        let balance = builder.command(NativeCommand::move_call(
3762            SUI_SYSTEM_PACKAGE_ID,
3763            Identifier::new("sui_system").unwrap(),
3764            Identifier::new("redeem_fungible_staked_sui").unwrap(),
3765            vec![],
3766            vec![sys, fss_arg],
3767        ));
3768        let coin = builder.command(NativeCommand::move_call(
3769            SUI_FRAMEWORK_PACKAGE_ID,
3770            Identifier::new("coin").unwrap(),
3771            Identifier::new("from_balance").unwrap(),
3772            vec![sui_types::TypeTag::from_str(coin_type_arg).unwrap()],
3773            vec![balance],
3774        ));
3775        let sender_arg = builder.pure(sender).unwrap();
3776        builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
3777        builder.finish()
3778    }
3779
3780    #[test]
3781    fn test_parse_falls_through_redeem_wrong_type_arg() {
3782        let sender = SuiAddress::random_for_testing_only();
3783        let fss = random_object_ref();
3784        // from_balance with wrong generic — e.g. a fake USDC type.
3785        let pt = build_redeem_ptb_with_type_arg(sender, fss, "0x2::coin::Coin");
3786        let ops = parse_pt(sender, pt);
3787        assert_falls_through_to_generic(&ops);
3788    }
3789
3790    #[test]
3791    fn test_parse_falls_through_redeem_without_from_balance() {
3792        let sender = SuiAddress::random_for_testing_only();
3793        let fss = random_object_ref();
3794        // Build: redeem + (no from_balance) + transfer of the balance directly (nonsense shape).
3795        let mut builder = ProgrammableTransactionBuilder::new();
3796        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3797        let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3798        let balance = builder.command(NativeCommand::move_call(
3799            SUI_SYSTEM_PACKAGE_ID,
3800            Identifier::new("sui_system").unwrap(),
3801            Identifier::new("redeem_fungible_staked_sui").unwrap(),
3802            vec![],
3803            vec![sys, fss_arg],
3804        ));
3805        let sender_arg = builder.pure(sender).unwrap();
3806        builder.command(NativeCommand::TransferObjects(vec![balance], sender_arg));
3807        let ops = parse_pt(sender, builder.finish());
3808        assert_falls_through_to_generic(&ops);
3809    }
3810
3811    #[test]
3812    fn test_parse_falls_through_redeem_without_transfer() {
3813        let sender = SuiAddress::random_for_testing_only();
3814        let fss = random_object_ref();
3815        let mut builder = ProgrammableTransactionBuilder::new();
3816        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3817        let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3818        let balance = builder.command(NativeCommand::move_call(
3819            SUI_SYSTEM_PACKAGE_ID,
3820            Identifier::new("sui_system").unwrap(),
3821            Identifier::new("redeem_fungible_staked_sui").unwrap(),
3822            vec![],
3823            vec![sys, fss_arg],
3824        ));
3825        builder.command(NativeCommand::move_call(
3826            SUI_FRAMEWORK_PACKAGE_ID,
3827            Identifier::new("coin").unwrap(),
3828            Identifier::new("from_balance").unwrap(),
3829            vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
3830            vec![balance],
3831        ));
3832        // No TransferObjects → shape mismatch.
3833        let ops = parse_pt(sender, builder.finish());
3834        assert_falls_through_to_generic(&ops);
3835    }
3836
3837    #[test]
3838    fn test_parse_falls_through_redeem_transfer_wrong_recipient() {
3839        let sender = SuiAddress::random_for_testing_only();
3840        let other = SuiAddress::random_for_testing_only();
3841        let fss = random_object_ref();
3842        let mut builder = ProgrammableTransactionBuilder::new();
3843        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3844        let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3845        let balance = builder.command(NativeCommand::move_call(
3846            SUI_SYSTEM_PACKAGE_ID,
3847            Identifier::new("sui_system").unwrap(),
3848            Identifier::new("redeem_fungible_staked_sui").unwrap(),
3849            vec![],
3850            vec![sys, fss_arg],
3851        ));
3852        let coin = builder.command(NativeCommand::move_call(
3853            SUI_FRAMEWORK_PACKAGE_ID,
3854            Identifier::new("coin").unwrap(),
3855            Identifier::new("from_balance").unwrap(),
3856            vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
3857            vec![balance],
3858        ));
3859        // TransferObjects recipient is NOT the sender.
3860        let other_arg = builder.pure(other).unwrap();
3861        builder.command(NativeCommand::TransferObjects(vec![coin], other_arg));
3862        let ops = parse_pt(sender, builder.finish());
3863        assert_falls_through_to_generic(&ops);
3864    }
3865
3866    #[test]
3867    fn test_parse_falls_through_redeem_transfer_multiple_objects() {
3868        let sender = SuiAddress::random_for_testing_only();
3869        let fss = random_object_ref();
3870        let other_obj = random_object_ref();
3871        let mut builder = ProgrammableTransactionBuilder::new();
3872        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3873        let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3874        let balance = builder.command(NativeCommand::move_call(
3875            SUI_SYSTEM_PACKAGE_ID,
3876            Identifier::new("sui_system").unwrap(),
3877            Identifier::new("redeem_fungible_staked_sui").unwrap(),
3878            vec![],
3879            vec![sys, fss_arg],
3880        ));
3881        let coin = builder.command(NativeCommand::move_call(
3882            SUI_FRAMEWORK_PACKAGE_ID,
3883            Identifier::new("coin").unwrap(),
3884            Identifier::new("from_balance").unwrap(),
3885            vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
3886            vec![balance],
3887        ));
3888        // Add a second object to transfer — not the shape our parser accepts.
3889        let extra = builder.obj(ObjectArg::ImmOrOwnedObject(other_obj)).unwrap();
3890        let sender_arg = builder.pure(sender).unwrap();
3891        builder.command(NativeCommand::TransferObjects(
3892            vec![coin, extra],
3893            sender_arg,
3894        ));
3895        let ops = parse_pt(sender, builder.finish());
3896        assert_falls_through_to_generic(&ops);
3897    }
3898
3899    #[test]
3900    fn test_parse_falls_through_hybrid_convert_and_redeem() {
3901        // A PTB containing BOTH convert_to_fungible_staked_sui AND redeem_fungible_staked_sui.
3902        // This is an unusual shape — our parsers should reject it (neither Consolidate nor
3903        // MergeAndRedeem shape matches).
3904        let sender = SuiAddress::random_for_testing_only();
3905        let staked = random_object_ref();
3906        let fss = random_object_ref();
3907        let mut builder = ProgrammableTransactionBuilder::new();
3908        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3909        let staked_arg = builder.obj(ObjectArg::ImmOrOwnedObject(staked)).unwrap();
3910        let _new_fss = builder.command(NativeCommand::move_call(
3911            SUI_SYSTEM_PACKAGE_ID,
3912            Identifier::new("sui_system").unwrap(),
3913            Identifier::new("convert_to_fungible_staked_sui").unwrap(),
3914            vec![],
3915            vec![sys, staked_arg],
3916        ));
3917        let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3918        let balance = builder.command(NativeCommand::move_call(
3919            SUI_SYSTEM_PACKAGE_ID,
3920            Identifier::new("sui_system").unwrap(),
3921            Identifier::new("redeem_fungible_staked_sui").unwrap(),
3922            vec![],
3923            vec![sys, fss_arg],
3924        ));
3925        let coin = builder.command(NativeCommand::move_call(
3926            SUI_FRAMEWORK_PACKAGE_ID,
3927            Identifier::new("coin").unwrap(),
3928            Identifier::new("from_balance").unwrap(),
3929            vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
3930            vec![balance],
3931        ));
3932        let sender_arg = builder.pure(sender).unwrap();
3933        builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
3934        let ops = parse_pt(sender, builder.finish());
3935        assert_falls_through_to_generic(&ops);
3936    }
3937
3938    #[test]
3939    fn test_parse_falls_through_split_without_redeem() {
3940        let sender = SuiAddress::random_for_testing_only();
3941        let fss = random_object_ref();
3942        let mut builder = ProgrammableTransactionBuilder::new();
3943        let _sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3944        let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3945        let split_amount = builder.pure(100u64).unwrap();
3946        builder.command(NativeCommand::move_call(
3947            SUI_SYSTEM_PACKAGE_ID,
3948            Identifier::new("staking_pool").unwrap(),
3949            Identifier::new("split_fungible_staked_sui").unwrap(),
3950            vec![],
3951            vec![fss_arg, split_amount],
3952        ));
3953        // No redeem → shape mismatch.
3954        let ops = parse_pt(sender, builder.finish());
3955        assert_falls_through_to_generic(&ops);
3956    }
3957
3958    #[test]
3959    fn test_parse_falls_through_redeem_split_position_wrong() {
3960        // split appears AFTER redeem (wrong order).
3961        let sender = SuiAddress::random_for_testing_only();
3962        let fss_a = random_object_ref();
3963        let fss_b = random_object_ref();
3964        let mut builder = ProgrammableTransactionBuilder::new();
3965        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3966        let a_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss_a)).unwrap();
3967        let b_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss_b)).unwrap();
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, a_arg],
3974        ));
3975        // Split AFTER redeem — wrong order.
3976        let split_amount = builder.pure(100u64).unwrap();
3977        builder.command(NativeCommand::move_call(
3978            SUI_SYSTEM_PACKAGE_ID,
3979            Identifier::new("staking_pool").unwrap(),
3980            Identifier::new("split_fungible_staked_sui").unwrap(),
3981            vec![],
3982            vec![b_arg, split_amount],
3983        ));
3984        let coin = builder.command(NativeCommand::move_call(
3985            SUI_FRAMEWORK_PACKAGE_ID,
3986            Identifier::new("coin").unwrap(),
3987            Identifier::new("from_balance").unwrap(),
3988            vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
3989            vec![balance],
3990        ));
3991        let sender_arg = builder.pure(sender).unwrap();
3992        builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
3993        let ops = parse_pt(sender, builder.finish());
3994        assert_falls_through_to_generic(&ops);
3995    }
3996
3997    #[test]
3998    fn test_parse_falls_through_redeem_wrong_system_state_immutable() {
3999        // Build a redeem PTB but pass the system state as immutable shared. Per our
4000        // helper, we can't easily construct ObjectArg::SharedObject with Immutable
4001        // directly — but we can test the case where the first input is SUI_SYSTEM_STATE
4002        // but built via a regular shared-object with immutable mutability. Simplest:
4003        // use an ObjectArg::SharedObject construction.
4004        let sender = SuiAddress::random_for_testing_only();
4005        let fss = random_object_ref();
4006        let mut builder = ProgrammableTransactionBuilder::new();
4007        // Immutable shared — parser should reject.
4008        let _sys = builder
4009            .obj(ObjectArg::SharedObject {
4010                id: SUI_SYSTEM_STATE_OBJECT_ID,
4011                initial_shared_version: sui_types::SUI_SYSTEM_STATE_OBJECT_SHARED_VERSION,
4012                mutability: sui_types::transaction::SharedObjectMutability::Immutable,
4013            })
4014            .unwrap();
4015        let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
4016        // The redeem Move call needs a mutable sys — this would fail at chain execution
4017        // but our parser just checks inputs[0] shape.
4018        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
4019        let balance = builder.command(NativeCommand::move_call(
4020            SUI_SYSTEM_PACKAGE_ID,
4021            Identifier::new("sui_system").unwrap(),
4022            Identifier::new("redeem_fungible_staked_sui").unwrap(),
4023            vec![],
4024            vec![sys, fss_arg],
4025        ));
4026        let coin = builder.command(NativeCommand::move_call(
4027            SUI_FRAMEWORK_PACKAGE_ID,
4028            Identifier::new("coin").unwrap(),
4029            Identifier::new("from_balance").unwrap(),
4030            vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4031            vec![balance],
4032        ));
4033        let sender_arg = builder.pure(sender).unwrap();
4034        builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
4035        // Our parser's `first_input_is_sui_system_state` only requires InputKind::Shared +
4036        // object id == 0x5. Both the immutable and mutable shared inputs have kind Shared
4037        // and id 0x5, so this alone might not trigger rejection. The strict-shape check
4038        // will catch it because inputs[0] must be at position 0 — and here we placed the
4039        // immutable shared first; the system_state_mut is input[2] (3rd input), so the
4040        // first input IS our immutable one. Our predicate accepts it (same id). That's
4041        // OK: if chain rejects it, Rosetta's observation is that this was a shape we
4042        // don't strictly match. The assert_falls_through_to_generic below may fail here
4043        // because our parser could accept both. If so, we should tighten the predicate.
4044        // For now we document this behaviour and allow either result.
4045        let ops = parse_pt(sender, builder.finish());
4046        // Accept either: labeled (if shape matched) or generic (if extra commands/inputs
4047        // tripped shape validation). The important invariant is no panic.
4048        assert!(
4049            ops[0].type_ == OperationType::MergeAndRedeemFungibleStakedSui
4050                || ops[0].type_ == OperationType::ProgrammableTransaction,
4051            "unexpected op type: {:?}",
4052            ops[0].type_
4053        );
4054    }
4055
4056    // ==============================================================================
4057    // Phase 2: Additional fall-through tests for PR review tightenings
4058    // ==============================================================================
4059
4060    /// Convert-only PTB WITHOUT the trailing `TransferObjects` — the builder always emits
4061    /// a transfer for S>=1, F=0. A `[convert]` alone leaks a FungibleStakedSui result and
4062    /// would fail on-chain execution. Parser must not label it as Consolidate.
4063    #[test]
4064    fn test_parse_falls_through_convert_without_transfer() {
4065        let sender = SuiAddress::random_for_testing_only();
4066        let staked = random_object_ref();
4067        let mut builder = ProgrammableTransactionBuilder::new();
4068        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
4069        let staked_arg = builder.obj(ObjectArg::ImmOrOwnedObject(staked)).unwrap();
4070        let _new_fss = builder.command(NativeCommand::move_call(
4071            SUI_SYSTEM_PACKAGE_ID,
4072            Identifier::new("sui_system").unwrap(),
4073            Identifier::new("convert_to_fungible_staked_sui").unwrap(),
4074            vec![],
4075            vec![sys, staked_arg],
4076        ));
4077        // No TransferObjects — convert's Result is orphaned.
4078        let ops = parse_pt(sender, builder.finish());
4079        assert_falls_through_to_generic(&ops);
4080    }
4081
4082    /// Pure FSS merge with a SPURIOUS `TransferObjects` — the builder never emits a
4083    /// transfer for S=0, F>=2 (existing FSS is already sender-owned). `join` returns unit
4084    /// so the transfer can't reference a meaningful result anyway. Parser must fall through.
4085    #[test]
4086    fn test_parse_falls_through_pure_merge_with_transfer() {
4087        let sender = SuiAddress::random_for_testing_only();
4088        let fss_a = random_object_ref();
4089        let fss_b = random_object_ref();
4090        let mut builder = ProgrammableTransactionBuilder::new();
4091        let _sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
4092        let first = builder.obj(ObjectArg::ImmOrOwnedObject(fss_a)).unwrap();
4093        let other = builder.obj(ObjectArg::ImmOrOwnedObject(fss_b)).unwrap();
4094        let join_result = builder.command(NativeCommand::move_call(
4095            SUI_SYSTEM_PACKAGE_ID,
4096            Identifier::new("staking_pool").unwrap(),
4097            Identifier::new("join_fungible_staked_sui").unwrap(),
4098            vec![],
4099            vec![first, other],
4100        ));
4101        // Spurious TransferObjects referencing the join's (unit) result.
4102        let sender_arg = builder.pure(sender).unwrap();
4103        builder.command(NativeCommand::TransferObjects(
4104            vec![join_result],
4105            sender_arg,
4106        ));
4107        let ops = parse_pt(sender, builder.finish());
4108        assert_falls_through_to_generic(&ops);
4109    }
4110
4111    /// `split_fungible_staked_sui`'s amount arg must be a `Pure` u64. Passing an
4112    /// `ImmOrOwnedObject` as the amount slot fails on-chain but previously parse-accepted.
4113    #[test]
4114    fn test_parse_falls_through_split_amount_not_pure() {
4115        let sender = SuiAddress::random_for_testing_only();
4116        let fss = random_object_ref();
4117        let bogus_obj = random_object_ref();
4118        let mut builder = ProgrammableTransactionBuilder::new();
4119        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
4120        let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
4121        // The "amount" arg is an object ref instead of a Pure u64.
4122        let bogus_arg = builder.obj(ObjectArg::ImmOrOwnedObject(bogus_obj)).unwrap();
4123        let split_result = builder.command(NativeCommand::move_call(
4124            SUI_SYSTEM_PACKAGE_ID,
4125            Identifier::new("staking_pool").unwrap(),
4126            Identifier::new("split_fungible_staked_sui").unwrap(),
4127            vec![],
4128            vec![fss_arg, bogus_arg],
4129        ));
4130        let balance = builder.command(NativeCommand::move_call(
4131            SUI_SYSTEM_PACKAGE_ID,
4132            Identifier::new("sui_system").unwrap(),
4133            Identifier::new("redeem_fungible_staked_sui").unwrap(),
4134            vec![],
4135            vec![sys, split_result],
4136        ));
4137        let coin = builder.command(NativeCommand::move_call(
4138            SUI_FRAMEWORK_PACKAGE_ID,
4139            Identifier::new("coin").unwrap(),
4140            Identifier::new("from_balance").unwrap(),
4141            vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4142            vec![balance],
4143        ));
4144        let sender_arg = builder.pure(sender).unwrap();
4145        builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
4146        let ops = parse_pt(sender, builder.finish());
4147        assert_falls_through_to_generic(&ops);
4148    }
4149
4150    /// `convert_to_fungible_staked_sui`'s first arg must reference `inputs[0]`
4151    /// (SUI_SYSTEM_STATE). A PTB passing a different input in the system-state slot
4152    /// slips through shape validation before this tightening.
4153    #[test]
4154    fn test_parse_falls_through_convert_wrong_system_state_arg() {
4155        let sender = SuiAddress::random_for_testing_only();
4156        let staked = random_object_ref();
4157        let mut builder = ProgrammableTransactionBuilder::new();
4158        // inputs[0] = SUI_SYSTEM_MUT (passes first_input_is_sui_system_state).
4159        let _sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
4160        // inputs[1] = a Pure u64 — we'll put this in the convert's system-state slot
4161        // so arguments[0].input() != 0, triggering the new check.
4162        let bogus_arg = builder.pure(0u64).unwrap();
4163        let staked_arg = builder.obj(ObjectArg::ImmOrOwnedObject(staked)).unwrap();
4164        let new_fss = builder.command(NativeCommand::move_call(
4165            SUI_SYSTEM_PACKAGE_ID,
4166            Identifier::new("sui_system").unwrap(),
4167            Identifier::new("convert_to_fungible_staked_sui").unwrap(),
4168            vec![],
4169            // arguments[0] is bogus_arg (input 1, not input 0) — shape mismatch.
4170            vec![bogus_arg, staked_arg],
4171        ));
4172        let sender_arg = builder.pure(sender).unwrap();
4173        builder.command(NativeCommand::TransferObjects(vec![new_fss], sender_arg));
4174        let ops = parse_pt(sender, builder.finish());
4175        assert_falls_through_to_generic(&ops);
4176    }
4177
4178    /// If a single input appears in BOTH a `convert_fss` call (treated as StakedSui) and
4179    /// a `join_fss` call (treated as FSS), the classification is contradictory. The
4180    /// overlap-rejection mechanism already exists in `parse_consolidate`; this test
4181    /// gives it explicit coverage.
4182    #[test]
4183    fn test_parse_falls_through_consolidate_same_input_both_convert_and_join() {
4184        let sender = SuiAddress::random_for_testing_only();
4185        let shared_input = random_object_ref();
4186        let other_fss = random_object_ref();
4187        let mut builder = ProgrammableTransactionBuilder::new();
4188        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
4189        // This single input appears in BOTH roles below.
4190        let dual = builder
4191            .obj(ObjectArg::ImmOrOwnedObject(shared_input))
4192            .unwrap();
4193        let fss_b = builder.obj(ObjectArg::ImmOrOwnedObject(other_fss)).unwrap();
4194        // join(dual, fss_b) — dual is classified as FSS.
4195        builder.command(NativeCommand::move_call(
4196            SUI_SYSTEM_PACKAGE_ID,
4197            Identifier::new("staking_pool").unwrap(),
4198            Identifier::new("join_fungible_staked_sui").unwrap(),
4199            vec![],
4200            vec![dual, fss_b],
4201        ));
4202        // convert(sys, dual) — dual is now also referenced as StakedSui (contradiction).
4203        let new_fss = builder.command(NativeCommand::move_call(
4204            SUI_SYSTEM_PACKAGE_ID,
4205            Identifier::new("sui_system").unwrap(),
4206            Identifier::new("convert_to_fungible_staked_sui").unwrap(),
4207            vec![],
4208            vec![sys, dual],
4209        ));
4210        let sender_arg = builder.pure(sender).unwrap();
4211        builder.command(NativeCommand::TransferObjects(vec![new_fss], sender_arg));
4212        let ops = parse_pt(sender, builder.finish());
4213        assert_falls_through_to_generic(&ops);
4214    }
4215
4216    // ==============================================================================
4217    // AtLeast guard dataflow linkage tests
4218    //
4219    // The AtLeast PTB shape is:
4220    //   redeem_fss → balance::split<SUI> → balance::join<SUI> → coin::from_balance<SUI>
4221    // and the parser must verify that the guard operates on the redeem result
4222    // (not on some unrelated Balance<SUI>) — otherwise a malformed PTB could be
4223    // misclassified as a typed AtLeast op even though the chain wouldn't enforce
4224    // the guarantee on the redeemed balance.
4225    // ==============================================================================
4226
4227    /// Build a malformed AtLeast PTB where the AtLeast guard operates on a
4228    /// freshly-created `Balance<SUI>` (via `balance::zero<SUI>`) rather than
4229    /// on the redeem result. Type-checks on chain (the chain doesn't care if
4230    /// the guard runs against a different balance), but the parser must NOT
4231    /// emit `Some(AtLeast)` for this PTB because the balance::split is not
4232    /// gating the redeemed balance.
4233    ///
4234    /// NOTE: chain validation might still reject the resulting PTB for other
4235    /// reasons (orphaned redeem result), but as far as the parser shape match
4236    /// goes we want it to fall through to a generic op.
4237    fn build_malformed_atleast_ptb(
4238        sender: SuiAddress,
4239        fss: ObjectRef,
4240        wire_split_to_redeem: bool,
4241        wire_join_to_redeem: bool,
4242        wire_join_arg1_to_split: bool,
4243        wire_from_balance_to_redeem: bool,
4244    ) -> ProgrammableTransaction {
4245        use sui_types::transaction::Argument;
4246        let mut builder = ProgrammableTransactionBuilder::new();
4247        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
4248        let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
4249        let split_amt = builder.pure(100u64).unwrap();
4250        // Split fss to make the shape AtLeast/AtMost-like (with split_fss before redeem).
4251        let split_fss = builder.command(NativeCommand::move_call(
4252            SUI_SYSTEM_PACKAGE_ID,
4253            Identifier::new("staking_pool").unwrap(),
4254            Identifier::new("split_fungible_staked_sui").unwrap(),
4255            vec![],
4256            vec![fss_arg, split_amt],
4257        ));
4258        let redeem_balance = builder.command(NativeCommand::move_call(
4259            SUI_SYSTEM_PACKAGE_ID,
4260            Identifier::new("sui_system").unwrap(),
4261            Identifier::new("redeem_fungible_staked_sui").unwrap(),
4262            vec![],
4263            vec![sys, split_fss],
4264        ));
4265        // Make a separate Balance<SUI> via `balance::zero<SUI>` to have a
4266        // distinct Balance<SUI> Result available for the malformed wiring.
4267        let zero_balance = builder.command(NativeCommand::move_call(
4268            SUI_FRAMEWORK_PACKAGE_ID,
4269            Identifier::new("balance").unwrap(),
4270            Identifier::new("zero").unwrap(),
4271            vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4272            vec![],
4273        ));
4274        let min_arg = builder.pure(0u64).unwrap();
4275        let split_arg0 = if wire_split_to_redeem {
4276            redeem_balance
4277        } else {
4278            zero_balance
4279        };
4280        let split_result = builder.command(NativeCommand::move_call(
4281            SUI_FRAMEWORK_PACKAGE_ID,
4282            Identifier::new("balance").unwrap(),
4283            Identifier::new("split").unwrap(),
4284            vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4285            vec![split_arg0, min_arg],
4286        ));
4287        let join_arg0 = if wire_join_to_redeem {
4288            redeem_balance
4289        } else {
4290            zero_balance
4291        };
4292        let join_arg1 = if wire_join_arg1_to_split {
4293            split_result
4294        } else {
4295            // Use a fresh zero<SUI> result so it's a Balance<SUI> Result that
4296            // is not the prior balance::split's output.
4297            builder.command(NativeCommand::move_call(
4298                SUI_FRAMEWORK_PACKAGE_ID,
4299                Identifier::new("balance").unwrap(),
4300                Identifier::new("zero").unwrap(),
4301                vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4302                vec![],
4303            ))
4304        };
4305        builder.command(NativeCommand::move_call(
4306            SUI_FRAMEWORK_PACKAGE_ID,
4307            Identifier::new("balance").unwrap(),
4308            Identifier::new("join").unwrap(),
4309            vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4310            vec![join_arg0, join_arg1],
4311        ));
4312        let from_balance_arg = if wire_from_balance_to_redeem {
4313            redeem_balance
4314        } else {
4315            zero_balance
4316        };
4317        let coin = builder.command(NativeCommand::move_call(
4318            SUI_FRAMEWORK_PACKAGE_ID,
4319            Identifier::new("coin").unwrap(),
4320            Identifier::new("from_balance").unwrap(),
4321            vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4322            vec![from_balance_arg],
4323        ));
4324        let sender_arg = builder.pure(sender).unwrap();
4325        builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
4326        let _ = Argument::GasCoin; // silence Argument unused warning when not needed
4327        builder.finish()
4328    }
4329
4330    #[test]
4331    fn test_parse_falls_through_atleast_split_arg_not_redeem_result() {
4332        let sender = SuiAddress::random_for_testing_only();
4333        let fss = random_object_ref();
4334        // balance::split arg[0] points at zero<SUI>, not at redeem result.
4335        let pt = build_malformed_atleast_ptb(sender, fss, false, true, true, true);
4336        assert_falls_through_to_generic(&parse_pt(sender, pt));
4337    }
4338
4339    #[test]
4340    fn test_parse_falls_through_atleast_join_arg0_not_redeem_result() {
4341        let sender = SuiAddress::random_for_testing_only();
4342        let fss = random_object_ref();
4343        // balance::join arg[0] points at zero<SUI>, not at redeem result.
4344        let pt = build_malformed_atleast_ptb(sender, fss, true, false, true, true);
4345        assert_falls_through_to_generic(&parse_pt(sender, pt));
4346    }
4347
4348    #[test]
4349    fn test_parse_falls_through_atleast_join_arg1_not_split_result() {
4350        let sender = SuiAddress::random_for_testing_only();
4351        let fss = random_object_ref();
4352        // balance::join arg[1] points at a different zero<SUI>, not at split result.
4353        let pt = build_malformed_atleast_ptb(sender, fss, true, true, false, true);
4354        assert_falls_through_to_generic(&parse_pt(sender, pt));
4355    }
4356
4357    #[test]
4358    fn test_parse_falls_through_atleast_from_balance_arg_not_redeem_result() {
4359        let sender = SuiAddress::random_for_testing_only();
4360        let fss = random_object_ref();
4361        // coin::from_balance arg[0] points at zero<SUI>, not at redeem result.
4362        let pt = build_malformed_atleast_ptb(sender, fss, true, true, true, false);
4363        assert_falls_through_to_generic(&parse_pt(sender, pt));
4364    }
4365
4366    /// Hand-build a PTB whose `balance::split` argument is `NestedResult(redeem_idx, 0)`
4367    /// rather than a plain `Result(redeem_idx)`. Both proto-encode as
4368    /// `ArgumentKind::Result` (only `subresult` differs) so a parser that
4369    /// only checks kind+result would slip past — `is_result_of` must also
4370    /// require `subresult` is unset.
4371    #[test]
4372    fn test_parse_falls_through_atleast_split_arg_is_nested_result() {
4373        use sui_types::transaction::Argument;
4374        let sender = SuiAddress::random_for_testing_only();
4375        let fss = random_object_ref();
4376        let mut builder = ProgrammableTransactionBuilder::new();
4377        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
4378        let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
4379        let split_amt = builder.pure(100u64).unwrap();
4380        let split_fss = builder.command(NativeCommand::move_call(
4381            SUI_SYSTEM_PACKAGE_ID,
4382            Identifier::new("staking_pool").unwrap(),
4383            Identifier::new("split_fungible_staked_sui").unwrap(),
4384            vec![],
4385            vec![fss_arg, split_amt],
4386        ));
4387        let _redeem = builder.command(NativeCommand::move_call(
4388            SUI_SYSTEM_PACKAGE_ID,
4389            Identifier::new("sui_system").unwrap(),
4390            Identifier::new("redeem_fungible_staked_sui").unwrap(),
4391            vec![],
4392            vec![sys, split_fss],
4393        ));
4394        // The redeem result is at command index 1 (split is 0). Construct
4395        // NestedResult(1, 0) by hand — it shares ArgumentKind::Result with
4396        // a plain Result(1), distinguished only by `subresult`.
4397        let nested = Argument::NestedResult(1, 0);
4398        let min_arg = builder.pure(0u64).unwrap();
4399        let split_balance = builder.command(NativeCommand::move_call(
4400            SUI_FRAMEWORK_PACKAGE_ID,
4401            Identifier::new("balance").unwrap(),
4402            Identifier::new("split").unwrap(),
4403            vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4404            vec![nested, min_arg],
4405        ));
4406        builder.command(NativeCommand::move_call(
4407            SUI_FRAMEWORK_PACKAGE_ID,
4408            Identifier::new("balance").unwrap(),
4409            Identifier::new("join").unwrap(),
4410            vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4411            vec![nested, split_balance],
4412        ));
4413        let coin = builder.command(NativeCommand::move_call(
4414            SUI_FRAMEWORK_PACKAGE_ID,
4415            Identifier::new("coin").unwrap(),
4416            Identifier::new("from_balance").unwrap(),
4417            vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4418            vec![nested],
4419        ));
4420        let sender_arg = builder.pure(sender).unwrap();
4421        builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
4422        assert_falls_through_to_generic(&parse_pt(sender, builder.finish()));
4423    }
4424
4425    /// TransferObjects must move the `coin::from_balance` result, not some
4426    /// unrelated `Result`. Build a PTB that has the right shape up to and
4427    /// including `coin::from_balance` but then transfers a different coin.
4428    #[test]
4429    fn test_parse_falls_through_transfer_not_from_balance_result() {
4430        let sender = SuiAddress::random_for_testing_only();
4431        let fss = random_object_ref();
4432        let mut builder = ProgrammableTransactionBuilder::new();
4433        let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
4434        let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
4435        let redeem = builder.command(NativeCommand::move_call(
4436            SUI_SYSTEM_PACKAGE_ID,
4437            Identifier::new("sui_system").unwrap(),
4438            Identifier::new("redeem_fungible_staked_sui").unwrap(),
4439            vec![],
4440            vec![sys, fss_arg],
4441        ));
4442        let _from_balance = builder.command(NativeCommand::move_call(
4443            SUI_FRAMEWORK_PACKAGE_ID,
4444            Identifier::new("coin").unwrap(),
4445            Identifier::new("from_balance").unwrap(),
4446            vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4447            vec![redeem],
4448        ));
4449        // Construct a different Coin<SUI> via `coin::zero<SUI>` and transfer
4450        // *that* instead of the from_balance result. The PTB shape up to here
4451        // matches a recognized All-mode redeem, but the transfer target is wrong.
4452        let other_coin = builder.command(NativeCommand::move_call(
4453            SUI_FRAMEWORK_PACKAGE_ID,
4454            Identifier::new("coin").unwrap(),
4455            Identifier::new("zero").unwrap(),
4456            vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4457            vec![],
4458        ));
4459        let sender_arg = builder.pure(sender).unwrap();
4460        builder.command(NativeCommand::TransferObjects(vec![other_coin], sender_arg));
4461        assert_falls_through_to_generic(&parse_pt(sender, builder.finish()));
4462    }
4463
4464    // ==============================================================================
4465    // PR 2: Metadata serialization compat (4 tests)
4466    // ==============================================================================
4467
4468    #[test]
4469    fn test_meta_merge_redeem_old_input_all() {
4470        let v = SuiAddress::random_for_testing_only();
4471        let json = serde_json::json!({
4472            "MergeAndRedeemFungibleStakedSui": {
4473                "validator": v.to_string(),
4474                "redeem_mode": "All"
4475            }
4476        });
4477        let meta: OperationMetadata = serde_json::from_value(json).unwrap();
4478        match meta {
4479            OperationMetadata::MergeAndRedeemFungibleStakedSui {
4480                validator,
4481                amount,
4482                redeem_mode,
4483                fss_ids,
4484            } => {
4485                assert_eq!(validator, Some(v));
4486                assert!(amount.is_none());
4487                assert_eq!(redeem_mode, Some(RedeemMode::All));
4488                assert!(fss_ids.is_empty());
4489            }
4490            _ => panic!("wrong variant"),
4491        }
4492    }
4493
4494    #[test]
4495    fn test_meta_merge_redeem_old_input_atleast() {
4496        let v = SuiAddress::random_for_testing_only();
4497        let json = serde_json::json!({
4498            "MergeAndRedeemFungibleStakedSui": {
4499                "validator": v.to_string(),
4500                "amount": "500000000000",
4501                "redeem_mode": "AtLeast"
4502            }
4503        });
4504        let meta: OperationMetadata = serde_json::from_value(json).unwrap();
4505        match meta {
4506            OperationMetadata::MergeAndRedeemFungibleStakedSui {
4507                validator,
4508                amount,
4509                redeem_mode,
4510                fss_ids,
4511            } => {
4512                assert_eq!(validator, Some(v));
4513                assert_eq!(amount, Some("500000000000".to_string()));
4514                assert_eq!(redeem_mode, Some(RedeemMode::AtLeast));
4515                assert!(fss_ids.is_empty());
4516            }
4517            _ => panic!(),
4518        }
4519    }
4520
4521    #[test]
4522    fn test_meta_merge_redeem_new_parse_output() {
4523        let id = ObjectID::random();
4524        let meta = OperationMetadata::MergeAndRedeemFungibleStakedSui {
4525            validator: None,
4526            amount: None,
4527            redeem_mode: Some(RedeemMode::All),
4528            fss_ids: vec![id],
4529        };
4530        let json = serde_json::to_value(&meta).unwrap();
4531        let obj = json
4532            .as_object()
4533            .unwrap()
4534            .get("MergeAndRedeemFungibleStakedSui")
4535            .unwrap()
4536            .as_object()
4537            .unwrap();
4538        assert!(!obj.contains_key("validator"));
4539        assert!(!obj.contains_key("amount"));
4540        assert_eq!(obj.get("redeem_mode").unwrap(), "All");
4541        assert_eq!(obj.get("fss_ids").unwrap().as_array().unwrap().len(), 1);
4542    }
4543
4544    #[test]
4545    fn test_meta_merge_redeem_new_parse_output_partial() {
4546        let id = ObjectID::random();
4547        let meta = OperationMetadata::MergeAndRedeemFungibleStakedSui {
4548            validator: None,
4549            amount: None,
4550            redeem_mode: None,
4551            fss_ids: vec![id],
4552        };
4553        let json = serde_json::to_value(&meta).unwrap();
4554        let obj = json
4555            .as_object()
4556            .unwrap()
4557            .get("MergeAndRedeemFungibleStakedSui")
4558            .unwrap()
4559            .as_object()
4560            .unwrap();
4561        assert!(!obj.contains_key("validator"));
4562        assert!(!obj.contains_key("amount"));
4563        assert!(
4564            !obj.contains_key("redeem_mode"),
4565            "redeem_mode must be omitted in partial parse output"
4566        );
4567        assert_eq!(obj.get("fss_ids").unwrap().as_array().unwrap().len(), 1);
4568    }
4569
4570    // ==============================================================================
4571    // PR 2: Write-side preservation (1 test)
4572    // ==============================================================================
4573
4574    #[test]
4575    fn test_write_merge_redeem_requires_validator_and_mode() {
4576        let sender = SuiAddress::random_for_testing_only();
4577
4578        // Case 1: validator = None.
4579        let op = Operation {
4580            operation_identifier: Default::default(),
4581            type_: OperationType::MergeAndRedeemFungibleStakedSui,
4582            status: None,
4583            account: Some(sender.into()),
4584            amount: None,
4585            coin_change: None,
4586            metadata: Some(OperationMetadata::MergeAndRedeemFungibleStakedSui {
4587                validator: None,
4588                amount: None,
4589                redeem_mode: Some(RedeemMode::All),
4590                fss_ids: vec![],
4591            }),
4592        };
4593        let err = Operations::new(vec![op])
4594            .into_internal()
4595            .expect_err("should fail without validator");
4596        assert!(format!("{err}").contains("validator"));
4597
4598        // Case 2: redeem_mode = None.
4599        let op = Operation {
4600            operation_identifier: Default::default(),
4601            type_: OperationType::MergeAndRedeemFungibleStakedSui,
4602            status: None,
4603            account: Some(sender.into()),
4604            amount: None,
4605            coin_change: None,
4606            metadata: Some(OperationMetadata::MergeAndRedeemFungibleStakedSui {
4607                validator: Some(SuiAddress::random_for_testing_only()),
4608                amount: None,
4609                redeem_mode: None,
4610                fss_ids: vec![],
4611            }),
4612        };
4613        let err = Operations::new(vec![op])
4614            .into_internal()
4615            .expect_err("should fail without redeem_mode");
4616        assert!(format!("{err}").contains("redeem_mode"));
4617    }
4618
4619    // ---- reconstruct_operations tests -----------------------------------------
4620
4621    use crate::types::CurrencyMetadata;
4622    use crate::types::internal_operation::pay_coin_pt;
4623
4624    fn sample_currency() -> Currency {
4625        Currency {
4626            symbol: "USDC".to_string(),
4627            decimals: 6,
4628            metadata: CurrencyMetadata {
4629                coin_type: "0x5::usdc::USDC".to_string(),
4630            },
4631        }
4632    }
4633
4634    fn data_with_pt(sender: SuiAddress, pt: ProgrammableTransaction) -> TransactionData {
4635        let gas_price = 1000;
4636        TransactionData::new_programmable(
4637            sender,
4638            vec![random_object_ref()],
4639            pt,
4640            TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
4641            gas_price,
4642        )
4643    }
4644
4645    /// Mirror `/parse`: encode the structured proto (clearing `bcs`) then decode
4646    /// it back, so `reconstruct_operations` sees exactly what the endpoint sees.
4647    fn proto_clean(data: &TransactionData) -> Transaction {
4648        use crate::types::transaction_envelope::{decode_inner_proto, encode_inner_proto};
4649        decode_inner_proto(&encode_inner_proto(data)).unwrap()
4650    }
4651
4652    /// PayCoin currency from the aux data labels the reconstructed payment ops.
4653    #[test]
4654    fn test_reconstruct_pay_coin_currency() {
4655        let sender = SuiAddress::random_for_testing_only();
4656        let recipient = SuiAddress::random_for_testing_only();
4657        let coin = random_object_ref();
4658        let currency = sample_currency();
4659        let aux = AuxData::PayCoin {
4660            currency: currency.clone(),
4661        };
4662        let pt = pay_coin_pt(
4663            sender,
4664            vec![recipient],
4665            vec![10_000],
4666            &[coin],
4667            &[],
4668            0,
4669            &currency,
4670        )
4671        .unwrap();
4672        let proto = proto_clean(&data_with_pt(sender, pt));
4673
4674        let ops = reconstruct_operations(&proto, &aux, None).expect("reconstruct ok");
4675        assert!(ops.0.iter().any(|op| op.type_ == OperationType::PayCoin));
4676        let recip_amount = ops
4677            .0
4678            .iter()
4679            .find(|o| o.account.as_ref().map(|a| a.address) == Some(recipient))
4680            .and_then(|o| o.amount.clone())
4681            .expect("recipient op");
4682        assert_eq!(
4683            recip_amount.currency.metadata.coin_type,
4684            currency.metadata.coin_type
4685        );
4686    }
4687
4688    /// Family-mismatch guard: PayCoin aux data applied to a non-payment
4689    /// (Consolidate) transaction is rejected by `apply_aux`'s family
4690    /// assertion, regardless of the currency map.
4691    #[test]
4692    fn test_reconstruct_family_mismatch_rejected() {
4693        let sender = SuiAddress::random_for_testing_only();
4694        let pay_aux = AuxData::PayCoin {
4695            currency: sample_currency(),
4696        };
4697        let pt = consolidate_to_fungible_pt(
4698            sender,
4699            vec![random_object_ref()],
4700            vec![random_object_ref()],
4701        )
4702        .unwrap();
4703        let proto = proto_clean(&data_with_pt(sender, pt));
4704        let err = reconstruct_operations(&proto, &pay_aux, None)
4705            .expect_err("family mismatch must be rejected");
4706        assert!(format!("{err:?}").contains("non-payment"));
4707    }
4708
4709    /// FSS decoration: Consolidate validator is recovered from the aux data.
4710    #[test]
4711    fn test_reconstruct_consolidate_validator_decorated() {
4712        let sender = SuiAddress::random_for_testing_only();
4713        let validator = SuiAddress::random_for_testing_only();
4714        let aux = AuxData::Consolidate { validator };
4715        let pt = consolidate_to_fungible_pt(
4716            sender,
4717            vec![random_object_ref()],
4718            vec![random_object_ref()],
4719        )
4720        .unwrap();
4721        let proto = proto_clean(&data_with_pt(sender, pt));
4722        let ops = reconstruct_operations(&proto, &aux, None).unwrap();
4723        let Some(OperationMetadata::ConsolidateAllStakedSuiToFungible { validator: v, .. }) =
4724            ops.0[0].metadata.clone()
4725        else {
4726            panic!("expected Consolidate metadata");
4727        };
4728        assert_eq!(v, Some(validator));
4729    }
4730
4731    /// FSS decoration: MergeAndRedeem AtMost — the parser alone cannot
4732    /// distinguish AtMost, so the aux-data override must report it, with the
4733    /// validator + cap recovered.
4734    #[test]
4735    fn test_reconstruct_merge_redeem_atmost_decorated() {
4736        let sender = SuiAddress::random_for_testing_only();
4737        let validator = SuiAddress::random_for_testing_only();
4738        let aux = AuxData::MergeAndRedeem {
4739            validator,
4740            redeem_mode: RedeemMode::AtMost,
4741            amount: Some(1_000_000),
4742        };
4743        let plan = RedeemPlan::AtMost {
4744            token_amount: Some(500_000_000),
4745            max_sui: 0,
4746        };
4747        let pt = merge_and_redeem_fss_pt(sender, vec![random_object_ref()], &plan).unwrap();
4748        let proto = proto_clean(&data_with_pt(sender, pt));
4749        let ops = reconstruct_operations(&proto, &aux, None).unwrap();
4750        let Some(OperationMetadata::MergeAndRedeemFungibleStakedSui {
4751            validator: v,
4752            amount,
4753            redeem_mode,
4754            ..
4755        }) = ops.0[0].metadata.clone()
4756        else {
4757            panic!("expected MergeAndRedeem metadata");
4758        };
4759        assert_eq!(v, Some(validator));
4760        assert_eq!(redeem_mode, Some(RedeemMode::AtMost));
4761        assert_eq!(amount, Some("1000000".to_string()));
4762    }
4763
4764    /// PaySui reconstructs cleanly with `None` aux data.
4765    #[test]
4766    fn test_reconstruct_pay_sui_none_ok() {
4767        let sender = SuiAddress::random_for_testing_only();
4768        let recipient = SuiAddress::random_for_testing_only();
4769        let pt = {
4770            let mut b = ProgrammableTransactionBuilder::new();
4771            b.pay_sui(vec![recipient], vec![10_000]).unwrap();
4772            b.finish()
4773        };
4774        let proto = proto_clean(&data_with_pt(sender, pt));
4775        let ops = reconstruct_operations(&proto, &AuxData::None, None)
4776            .expect("PaySui reconstructs with no aux data");
4777        assert!(ops.0.iter().any(|op| op.type_ == OperationType::PaySui));
4778    }
4779}