sui_rosetta/
operations.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::collections::HashMap;
5use std::ops::Not;
6use std::str::FromStr;
7use std::vec;
8
9use anyhow::anyhow;
10use move_core_types::ident_str;
11use move_core_types::language_storage::StructTag;
12use prost_types::value::Kind;
13use serde::Deserialize;
14use serde::Serialize;
15use tracing::warn;
16
17use sui_rpc::proto::sui::rpc::v2::Argument;
18use sui_rpc::proto::sui::rpc::v2::BalanceChange;
19use sui_rpc::proto::sui::rpc::v2::ExecutedTransaction;
20use sui_rpc::proto::sui::rpc::v2::Input;
21use sui_rpc::proto::sui::rpc::v2::MoveCall;
22use sui_rpc::proto::sui::rpc::v2::ProgrammableTransaction;
23use sui_rpc::proto::sui::rpc::v2::TransactionKind;
24use sui_rpc::proto::sui::rpc::v2::argument::ArgumentKind;
25use sui_rpc::proto::sui::rpc::v2::command::Command;
26use sui_rpc::proto::sui::rpc::v2::input::InputKind;
27use sui_rpc::proto::sui::rpc::v2::transaction_kind::Data as TransactionKindData;
28use sui_rpc::proto::sui::rpc::v2::transaction_kind::Kind::ProgrammableTransaction as ProgrammableTransactionKind;
29use sui_types::base_types::{ObjectID, SequenceNumber, SuiAddress};
30use sui_types::gas_coin::GasCoin;
31use sui_types::governance::{ADD_STAKE_FUN_NAME, WITHDRAW_STAKE_FUN_NAME};
32use sui_types::sui_system_state::SUI_SYSTEM_MODULE_NAME;
33use sui_types::{SUI_FRAMEWORK_PACKAGE_ID, SUI_SYSTEM_ADDRESS, SUI_SYSTEM_PACKAGE_ID};
34
35use crate::types::internal_operation::{PayCoin, PaySui, Stake, WithdrawStake};
36use crate::types::{
37    AccountIdentifier, Amount, CoinAction, CoinChange, CoinID, CoinIdentifier, Currency,
38    InternalOperation, OperationIdentifier, OperationStatus, OperationType,
39};
40use crate::{CoinMetadataCache, Error, SUI};
41
42#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
43pub struct Operations(Vec<Operation>);
44
45impl FromIterator<Operation> for Operations {
46    fn from_iter<T: IntoIterator<Item = Operation>>(iter: T) -> Self {
47        Operations::new(iter.into_iter().collect())
48    }
49}
50
51impl FromIterator<Vec<Operation>> for Operations {
52    fn from_iter<T: IntoIterator<Item = Vec<Operation>>>(iter: T) -> Self {
53        iter.into_iter().flatten().collect()
54    }
55}
56
57impl IntoIterator for Operations {
58    type Item = Operation;
59    type IntoIter = vec::IntoIter<Operation>;
60    fn into_iter(self) -> Self::IntoIter {
61        self.0.into_iter()
62    }
63}
64
65impl Operations {
66    pub fn new(mut ops: Vec<Operation>) -> Self {
67        for (index, op) in ops.iter_mut().enumerate() {
68            op.operation_identifier = (index as u64).into()
69        }
70        Self(ops)
71    }
72
73    pub fn contains(&self, other: &Operations) -> bool {
74        for (i, other_op) in other.0.iter().enumerate() {
75            if let Some(op) = self.0.get(i) {
76                if op != other_op {
77                    return false;
78                }
79            } else {
80                return false;
81            }
82        }
83        true
84    }
85
86    pub fn set_status(mut self, status: Option<OperationStatus>) -> Self {
87        for op in &mut self.0 {
88            op.status = status
89        }
90        self
91    }
92
93    pub fn type_(&self) -> Option<OperationType> {
94        self.0.first().map(|op| op.type_)
95    }
96
97    /// Parse operation input from rosetta operation to intermediate internal operation;
98    pub fn into_internal(self) -> Result<InternalOperation, Error> {
99        let type_ = self
100            .type_()
101            .ok_or_else(|| Error::MissingInput("Operation type".into()))?;
102        match type_ {
103            OperationType::PaySui => self.pay_sui_ops_to_internal(),
104            OperationType::PayCoin => self.pay_coin_ops_to_internal(),
105            OperationType::Stake => self.stake_ops_to_internal(),
106            OperationType::WithdrawStake => self.withdraw_stake_ops_to_internal(),
107            op => Err(Error::UnsupportedOperation(op)),
108        }
109    }
110
111    fn pay_sui_ops_to_internal(self) -> Result<InternalOperation, Error> {
112        let mut recipients = vec![];
113        let mut amounts = vec![];
114        let mut sender = None;
115        for op in self {
116            if let (Some(amount), Some(account)) = (op.amount.clone(), op.account.clone()) {
117                if amount.value.is_negative() {
118                    sender = Some(account.address)
119                } else {
120                    recipients.push(account.address);
121                    let amount = amount.value.abs();
122                    if amount > u64::MAX as i128 {
123                        return Err(Error::InvalidInput(
124                            "Input amount exceed u64::MAX".to_string(),
125                        ));
126                    }
127                    amounts.push(amount as u64)
128                }
129            }
130        }
131        let sender = sender.ok_or_else(|| Error::MissingInput("Sender address".to_string()))?;
132        Ok(InternalOperation::PaySui(PaySui {
133            sender,
134            recipients,
135            amounts,
136        }))
137    }
138
139    fn pay_coin_ops_to_internal(self) -> Result<InternalOperation, Error> {
140        let mut recipients = vec![];
141        let mut amounts = vec![];
142        let mut sender = None;
143        let mut currency = None;
144        for op in self {
145            if let (Some(amount), Some(account)) = (op.amount.clone(), op.account.clone()) {
146                currency = currency.or(Some(amount.currency));
147                if amount.value.is_negative() {
148                    sender = Some(account.address)
149                } else {
150                    recipients.push(account.address);
151                    let amount = amount.value.abs();
152                    if amount > u64::MAX as i128 {
153                        return Err(Error::InvalidInput(
154                            "Input amount exceed u64::MAX".to_string(),
155                        ));
156                    }
157                    amounts.push(amount as u64)
158                }
159            }
160        }
161        let sender = sender.ok_or_else(|| Error::MissingInput("Sender address".to_string()))?;
162        let currency = currency.ok_or_else(|| Error::MissingInput("Currency".to_string()))?;
163        Ok(InternalOperation::PayCoin(PayCoin {
164            sender,
165            recipients,
166            amounts,
167            currency,
168        }))
169    }
170
171    fn stake_ops_to_internal(self) -> Result<InternalOperation, Error> {
172        let mut ops = self
173            .0
174            .into_iter()
175            .filter(|op| op.type_ == OperationType::Stake)
176            .collect::<Vec<_>>();
177        if ops.len() != 1 {
178            return Err(Error::MalformedOperationError(
179                "Delegation should only have one operation.".into(),
180            ));
181        }
182        // Checked above, safe to unwrap.
183        let op = ops.pop().unwrap();
184        let sender = op
185            .account
186            .ok_or_else(|| Error::MissingInput("Sender address".to_string()))?
187            .address;
188        let metadata = op
189            .metadata
190            .ok_or_else(|| Error::MissingInput("Stake metadata".to_string()))?;
191
192        // Total issued SUi is less than u64, safe to cast.
193        let amount = if let Some(amount) = op.amount {
194            if amount.value.is_positive() {
195                return Err(Error::MalformedOperationError(
196                    "Stake amount should be negative.".into(),
197                ));
198            }
199            Some(amount.value.unsigned_abs() as u64)
200        } else {
201            None
202        };
203
204        let OperationMetadata::Stake { validator } = metadata else {
205            return Err(Error::InvalidInput(
206                "Cannot find delegation info from metadata.".into(),
207            ));
208        };
209
210        Ok(InternalOperation::Stake(Stake {
211            sender,
212            validator,
213            amount,
214        }))
215    }
216
217    fn withdraw_stake_ops_to_internal(self) -> Result<InternalOperation, Error> {
218        let mut ops = self
219            .0
220            .into_iter()
221            .filter(|op| op.type_ == OperationType::WithdrawStake)
222            .collect::<Vec<_>>();
223        if ops.len() != 1 {
224            return Err(Error::MalformedOperationError(
225                "Delegation should only have one operation.".into(),
226            ));
227        }
228        // Checked above, safe to unwrap.
229        let op = ops.pop().unwrap();
230        let sender = op
231            .account
232            .ok_or_else(|| Error::MissingInput("Sender address".to_string()))?
233            .address;
234
235        let stake_ids = if let Some(metadata) = op.metadata {
236            let OperationMetadata::WithdrawStake { stake_ids } = metadata else {
237                return Err(Error::InvalidInput(
238                    "Cannot find withdraw stake info from metadata.".into(),
239                ));
240            };
241            stake_ids
242        } else {
243            vec![]
244        };
245
246        Ok(InternalOperation::WithdrawStake(WithdrawStake {
247            sender,
248            stake_ids,
249        }))
250    }
251
252    pub fn from_transaction(
253        tx: TransactionKind,
254        sender: SuiAddress,
255        status: Option<OperationStatus>,
256    ) -> Result<Vec<Operation>, Error> {
257        let TransactionKind { data, kind, .. } = tx;
258        Ok(match data {
259            Some(TransactionKindData::ProgrammableTransaction(pt))
260                if status != Some(OperationStatus::Failure) =>
261            {
262                Self::parse_programmable_transaction(sender, status, pt)?
263            }
264            data => {
265                let mut tx = TransactionKind::default();
266                tx.data = data;
267                tx.kind = kind;
268                vec![Operation::generic_op(status, sender, tx)]
269            }
270        })
271    }
272
273    fn parse_programmable_transaction(
274        sender: SuiAddress,
275        status: Option<OperationStatus>,
276        pt: ProgrammableTransaction,
277    ) -> Result<Vec<Operation>, Error> {
278        #[derive(Debug)]
279        enum KnownValue {
280            GasCoin(u64),
281        }
282        fn resolve_result(
283            known_results: &[Vec<KnownValue>],
284            i: u32,
285            j: u32,
286        ) -> Option<&KnownValue> {
287            known_results
288                .get(i as usize)
289                .and_then(|inner| inner.get(j as usize))
290        }
291        fn split_coins(
292            inputs: &[Input],
293            known_results: &[Vec<KnownValue>],
294            coin: &Argument,
295            amounts: &[Argument],
296        ) -> Option<Vec<KnownValue>> {
297            match coin.kind() {
298                ArgumentKind::Gas => (),
299                ArgumentKind::Result => {
300                    let i = coin.result?;
301                    let subresult_idx = coin.subresult.unwrap_or(0);
302                    let KnownValue::GasCoin(_) = resolve_result(known_results, i, subresult_idx)?;
303                }
304                // Might not be a SUI coin
305                ArgumentKind::Input => (),
306                _ => return None,
307            };
308
309            let amounts = amounts
310                .iter()
311                .map(|amount| {
312                    let value: u64 = match amount.kind() {
313                        ArgumentKind::Input => {
314                            let input_idx = amount.input() as usize;
315                            let input = inputs.get(input_idx)?;
316                            match input.kind() {
317                                InputKind::Pure => {
318                                    let bytes = input.pure();
319                                    bcs::from_bytes(bytes).ok()?
320                                }
321                                _ => return None,
322                            }
323                        }
324                        _ => return None,
325                    };
326                    Some(KnownValue::GasCoin(value))
327                })
328                .collect::<Option<_>>()?;
329            Some(amounts)
330        }
331        fn transfer_object(
332            aggregated_recipients: &mut HashMap<SuiAddress, u64>,
333            inputs: &[Input],
334            known_results: &[Vec<KnownValue>],
335            objs: &[Argument],
336            recipient: &Argument,
337        ) -> Option<Vec<KnownValue>> {
338            let addr = match recipient.kind() {
339                ArgumentKind::Input => {
340                    let input_idx = recipient.input() as usize;
341                    let input = inputs.get(input_idx)?;
342                    match input.kind() {
343                        InputKind::Pure => {
344                            let bytes = input.pure();
345                            bcs::from_bytes::<SuiAddress>(bytes).ok()?
346                        }
347                        _ => return None,
348                    }
349                }
350                _ => return None,
351            };
352            for obj in objs {
353                let i = match obj.kind() {
354                    ArgumentKind::Result => obj.result(),
355                    _ => return None,
356                };
357
358                let subresult_idx = obj.subresult.unwrap_or(0);
359                let KnownValue::GasCoin(value) = resolve_result(known_results, i, subresult_idx)?;
360
361                let aggregate = aggregated_recipients.entry(addr).or_default();
362                *aggregate += value;
363            }
364            Some(vec![])
365        }
366        fn into_balance_passthrough(
367            known_results: &[Vec<KnownValue>],
368            call: &MoveCall,
369        ) -> Option<Vec<KnownValue>> {
370            let args = &call.arguments;
371            if let Some(coin_arg) = args.first() {
372                match coin_arg.kind() {
373                    ArgumentKind::Result => {
374                        let cmd_idx = coin_arg.result?;
375                        let sub_idx = coin_arg.subresult.unwrap_or(0);
376                        let KnownValue::GasCoin(val) =
377                            resolve_result(known_results, cmd_idx, sub_idx)?;
378                        Some(vec![KnownValue::GasCoin(*val)])
379                    }
380                    // Input coin (e.g. remainder send_funds) — value unknown but
381                    // downstream send_funds to sender will ignore it anyway.
382                    _ => Some(vec![KnownValue::GasCoin(0)]),
383                }
384            } else {
385                Some(vec![KnownValue::GasCoin(0)])
386            }
387        }
388        fn send_funds_transfer(
389            aggregated_recipients: &mut HashMap<SuiAddress, u64>,
390            inputs: &[Input],
391            known_results: &[Vec<KnownValue>],
392            call: &MoveCall,
393            sender: SuiAddress,
394        ) -> Option<Vec<KnownValue>> {
395            let args = &call.arguments;
396            if args.len() < 2 {
397                return Some(vec![]);
398            }
399            let balance_arg = &args[0];
400            let recipient_arg = &args[1];
401
402            // Resolve the amount from the source argument
403            let amount = match balance_arg.kind() {
404                ArgumentKind::Result => {
405                    let cmd_idx = balance_arg.result?;
406                    let sub_idx = balance_arg.subresult.unwrap_or(0);
407                    let KnownValue::GasCoin(val) = resolve_result(known_results, cmd_idx, sub_idx)?;
408                    *val
409                }
410                _ => return Some(vec![]),
411            };
412
413            // Resolve recipient address
414            let addr = match recipient_arg.kind() {
415                ArgumentKind::Input => {
416                    let input_idx = recipient_arg.input() as usize;
417                    let input = inputs.get(input_idx)?;
418                    if input.kind() == InputKind::Pure {
419                        bcs::from_bytes::<SuiAddress>(input.pure()).ok()?
420                    } else {
421                        return Some(vec![]);
422                    }
423                }
424                _ => return Some(vec![]),
425            };
426
427            // Only track transfers to non-sender addresses
428            if addr != sender {
429                *aggregated_recipients.entry(addr).or_insert(0) += amount;
430            }
431            Some(vec![])
432        }
433        fn stake_call(
434            inputs: &[Input],
435            known_results: &[Vec<KnownValue>],
436            call: &MoveCall,
437        ) -> Result<Option<(Option<u64>, SuiAddress)>, Error> {
438            let arguments = &call.arguments;
439            let (amount, validator) = match &arguments[..] {
440                [system_state_arg, coin, validator] => {
441                    let amount = match coin.kind() {
442                        ArgumentKind::Result => {
443                            let i = coin
444                                .result
445                                .ok_or_else(|| anyhow!("Result argument missing index"))?;
446                            let KnownValue::GasCoin(value) = resolve_result(known_results, i, 0)
447                                .ok_or_else(|| {
448                                    anyhow!("Cannot resolve Gas coin value at Result({i})")
449                                })?;
450                            value
451                        }
452                        _ => return Ok(None),
453                    };
454                    let system_state_idx = match system_state_arg.kind() {
455                        ArgumentKind::Input => system_state_arg.input(),
456                        _ => return Ok(None),
457                    };
458                    let (some_amount, validator) = match validator.kind() {
459                        // [WORKAROUND] - input ordering hack: validator BEFORE system_state
460                        // means a specific amount; system_state BEFORE validator means stake_all.
461                        ArgumentKind::Input => {
462                            let i = validator.input();
463                            let validator_addr = match inputs.get(i as usize) {
464                                Some(input) if input.kind() == InputKind::Pure => {
465                                    bcs::from_bytes::<SuiAddress>(input.pure()).ok()
466                                }
467                                _ => None,
468                            };
469                            (i < system_state_idx, Ok(validator_addr))
470                        }
471                        _ => return Ok(None),
472                    };
473                    (some_amount.then_some(*amount), validator)
474                }
475                _ => Err(anyhow!(
476                    "Error encountered when extracting arguments from move call, expecting 3 elements, got {}",
477                    arguments.len()
478                ))?,
479            };
480            validator.map(|v| v.map(|v| (amount, v)))
481        }
482
483        fn unstake_call(inputs: &[Input], call: &MoveCall) -> Result<Option<ObjectID>, Error> {
484            let arguments = &call.arguments;
485            let id = match &arguments[..] {
486                [system_state_arg, stake_id] => match stake_id.kind() {
487                    ArgumentKind::Input => {
488                        let i = stake_id.input();
489                        let id = match inputs.get(i as usize) {
490                            Some(input) if input.kind() == InputKind::ImmutableOrOwned => input
491                                .object_id
492                                .as_ref()
493                                .and_then(|oid| ObjectID::from_str(oid).ok()),
494                            _ => None,
495                        }
496                        .ok_or_else(|| anyhow!("Cannot find stake id from input args."))?;
497                        // [WORKAROUND] - input ordering hack: system_state BEFORE stake_id
498                        // means specific stake IDs; stake_id BEFORE system_state means withdraw_all.
499                        let system_state_idx = match system_state_arg.kind() {
500                            ArgumentKind::Input => system_state_arg.input(),
501                            _ => return Ok(None),
502                        };
503                        let some_id = system_state_idx < i;
504                        some_id.then_some(id)
505                    }
506                    _ => None,
507                },
508                _ => Err(anyhow!(
509                    "Error encountered when extracting arguments from move call, expecting 2 elements, got {}",
510                    arguments.len()
511                ))?,
512            };
513            Ok(id)
514        }
515        let inputs = &pt.inputs;
516        let commands = &pt.commands;
517        let mut known_results: Vec<Vec<KnownValue>> = vec![];
518        let mut aggregated_recipients: HashMap<SuiAddress, u64> = HashMap::new();
519        let mut needs_generic = false;
520        let mut operations = vec![];
521        let mut stake_ids = vec![];
522        let mut currency: Option<Currency> = None;
523
524        for command in commands {
525            let result = match &command.command {
526                Some(Command::SplitCoins(split)) => {
527                    let coin = split.coin();
528                    split_coins(inputs, &known_results, coin, &split.amounts)
529                }
530                Some(Command::TransferObjects(transfer)) => {
531                    let addr = transfer.address();
532                    transfer_object(
533                        &mut aggregated_recipients,
534                        inputs,
535                        &known_results,
536                        &transfer.objects,
537                        addr,
538                    )
539                }
540                Some(Command::MoveCall(m)) if Self::is_stake_call(m) => {
541                    stake_call(inputs, &known_results, m)?.map(|(amount, validator)| {
542                        let amount = amount.map(|amount| Amount::new(-(amount as i128), None));
543                        operations.push(Operation {
544                            operation_identifier: Default::default(),
545                            type_: OperationType::Stake,
546                            status,
547                            account: Some(sender.into()),
548                            amount,
549                            coin_change: None,
550                            metadata: Some(OperationMetadata::Stake { validator }),
551                        });
552                        vec![]
553                    })
554                }
555                Some(Command::MoveCall(m)) if Self::is_unstake_call(m) => {
556                    let stake_id = unstake_call(inputs, m)?;
557                    stake_ids.push(stake_id);
558                    Some(vec![])
559                }
560                Some(Command::MergeCoins(_)) => {
561                    // We don't care about merge-coins, we can just skip it.
562                    Some(vec![])
563                }
564                // coin::redeem_funds produces a Coin from an address-balance withdrawal —
565                // must return a KnownValue so downstream SplitCoins can resolve its source.
566                Some(Command::MoveCall(m)) if Self::is_coin_redeem_funds_call(m) => {
567                    Some(vec![KnownValue::GasCoin(0)])
568                }
569                Some(Command::MoveCall(m)) if Self::is_coin_into_balance_call(m) => {
570                    into_balance_passthrough(&known_results, m)
571                }
572                Some(Command::MoveCall(m))
573                    if Self::is_balance_send_funds_call(m) || Self::is_coin_send_funds_call(m) =>
574                {
575                    send_funds_transfer(
576                        &mut aggregated_recipients,
577                        inputs,
578                        &known_results,
579                        m,
580                        sender,
581                    )
582                }
583                Some(Command::MoveCall(m))
584                    if Self::is_coin_destroy_zero_call(m) || Self::is_balance_join_call(m) =>
585                {
586                    Some(vec![])
587                }
588                _ => None,
589            };
590            if let Some(result) = result {
591                known_results.push(result)
592            } else {
593                needs_generic = true;
594                break;
595            }
596        }
597
598        if !needs_generic && !aggregated_recipients.is_empty() {
599            let total_paid: u64 = aggregated_recipients.values().copied().sum();
600            operations.extend(
601                aggregated_recipients
602                    .into_iter()
603                    .map(|(recipient, amount)| {
604                        currency = inputs.iter().last().and_then(|input| {
605                            if input.kind() == InputKind::Pure {
606                                let bytes = input.pure();
607                                bcs::from_bytes::<String>(bytes).ok().and_then(|json_str| {
608                                    serde_json::from_str::<Currency>(&json_str).ok()
609                                })
610                            } else {
611                                None
612                            }
613                        });
614                        match currency {
615                            Some(_) => Operation::pay_coin(
616                                status,
617                                recipient,
618                                amount.into(),
619                                currency.clone(),
620                            ),
621                            None => Operation::pay_sui(status, recipient, amount.into()),
622                        }
623                    }),
624            );
625            match currency {
626                Some(_) => operations.push(Operation::pay_coin(
627                    status,
628                    sender,
629                    -(total_paid as i128),
630                    currency.clone(),
631                )),
632                _ => operations.push(Operation::pay_sui(status, sender, -(total_paid as i128))),
633            }
634        } else if !stake_ids.is_empty() {
635            let stake_ids = stake_ids.into_iter().flatten().collect::<Vec<_>>();
636            let metadata = stake_ids
637                .is_empty()
638                .not()
639                .then_some(OperationMetadata::WithdrawStake { stake_ids });
640            operations.push(Operation {
641                operation_identifier: Default::default(),
642                type_: OperationType::WithdrawStake,
643                status,
644                account: Some(sender.into()),
645                amount: None,
646                coin_change: None,
647                metadata,
648            });
649        } else if operations.is_empty() {
650            let tx_kind = TransactionKind::default()
651                .with_kind(ProgrammableTransactionKind)
652                .with_programmable_transaction(pt);
653            operations.push(Operation::generic_op(status, sender, tx_kind))
654        }
655        Ok(operations)
656    }
657
658    fn is_stake_call(tx: &MoveCall) -> bool {
659        let package_id = match ObjectID::from_str(tx.package()) {
660            Ok(id) => id,
661            Err(e) => {
662                warn!(
663                    package = tx.package(),
664                    error = %e,
665                    "Failed to parse package ID for MoveCall"
666                );
667                return false;
668            }
669        };
670
671        package_id == SUI_SYSTEM_PACKAGE_ID
672            && tx.module() == SUI_SYSTEM_MODULE_NAME.as_str()
673            && tx.function() == ADD_STAKE_FUN_NAME.as_str()
674    }
675
676    fn is_unstake_call(tx: &MoveCall) -> bool {
677        let package_id = match ObjectID::from_str(tx.package()) {
678            Ok(id) => id,
679            Err(e) => {
680                warn!(
681                    package = tx.package(),
682                    error = %e,
683                    "Failed to parse package ID for MoveCall"
684                );
685                return false;
686            }
687        };
688
689        package_id == SUI_SYSTEM_PACKAGE_ID
690            && tx.module() == SUI_SYSTEM_MODULE_NAME.as_str()
691            && (tx.function() == WITHDRAW_STAKE_FUN_NAME.as_str()
692                || tx.function() == "request_withdraw_stake_non_entry")
693    }
694
695    /// Recognizes `coin::redeem_funds<T>` calls used for address-balance withdrawals.
696    fn is_coin_redeem_funds_call(tx: &MoveCall) -> bool {
697        let package_id = match ObjectID::from_str(tx.package()) {
698            Ok(id) => id,
699            Err(_) => return false,
700        };
701        package_id == SUI_FRAMEWORK_PACKAGE_ID
702            && tx.module() == "coin"
703            && tx.function() == "redeem_funds"
704    }
705
706    fn is_coin_into_balance_call(tx: &MoveCall) -> bool {
707        let package_id = match ObjectID::from_str(tx.package()) {
708            Ok(id) => id,
709            Err(_) => return false,
710        };
711        package_id == SUI_FRAMEWORK_PACKAGE_ID
712            && tx.module() == "coin"
713            && tx.function() == "into_balance"
714    }
715
716    fn is_balance_send_funds_call(tx: &MoveCall) -> bool {
717        let package_id = match ObjectID::from_str(tx.package()) {
718            Ok(id) => id,
719            Err(_) => return false,
720        };
721        package_id == SUI_FRAMEWORK_PACKAGE_ID
722            && tx.module() == "balance"
723            && tx.function() == "send_funds"
724    }
725
726    fn is_coin_send_funds_call(tx: &MoveCall) -> bool {
727        let package_id = match ObjectID::from_str(tx.package()) {
728            Ok(id) => id,
729            Err(_) => return false,
730        };
731        package_id == SUI_FRAMEWORK_PACKAGE_ID
732            && tx.module() == "coin"
733            && tx.function() == "send_funds"
734    }
735
736    fn is_coin_destroy_zero_call(tx: &MoveCall) -> bool {
737        let package_id = match ObjectID::from_str(tx.package()) {
738            Ok(id) => id,
739            Err(_) => return false,
740        };
741        package_id == SUI_FRAMEWORK_PACKAGE_ID
742            && tx.module() == "coin"
743            && tx.function() == "destroy_zero"
744    }
745
746    fn is_balance_join_call(tx: &MoveCall) -> bool {
747        let package_id = match ObjectID::from_str(tx.package()) {
748            Ok(id) => id,
749            Err(_) => return false,
750        };
751        package_id == SUI_FRAMEWORK_PACKAGE_ID
752            && tx.module() == "balance"
753            && tx.function() == "join"
754    }
755
756    fn process_balance_change(
757        gas_owner: SuiAddress,
758        gas_used: i128,
759        balance_changes: &[(BalanceChange, Currency)],
760        status: Option<OperationStatus>,
761        balances: HashMap<(SuiAddress, Currency), i128>,
762    ) -> impl Iterator<Item = Operation> {
763        let mut balances =
764            balance_changes
765                .iter()
766                .fold(balances, |mut balances, (balance_change, ccy)| {
767                    if let (Some(addr_str), Some(amount_str)) =
768                        (&balance_change.address, &balance_change.amount)
769                        && let (Ok(owner), Ok(amount)) =
770                            (SuiAddress::from_str(addr_str), i128::from_str(amount_str))
771                    {
772                        *balances.entry((owner, ccy.clone())).or_default() += amount;
773                    }
774                    balances
775                });
776        // separate gas from balances
777        *balances.entry((gas_owner, SUI.clone())).or_default() -= gas_used;
778
779        let balance_change = balances.into_iter().filter(|(_, amount)| *amount != 0).map(
780            move |((addr, currency), amount)| {
781                Operation::balance_change(status, addr, amount, currency)
782            },
783        );
784
785        let gas = if gas_used != 0 {
786            vec![Operation::gas(gas_owner, gas_used)]
787        } else {
788            // Gas can be 0 for system tx
789            vec![]
790        };
791        balance_change.chain(gas)
792    }
793
794    /// Checks to see if transferObjects is used on GasCoin
795    fn is_gascoin_transfer(tx: &TransactionKind) -> bool {
796        if let Some(TransactionKindData::ProgrammableTransaction(pt)) = &tx.data {
797            return pt.commands.iter().any(|command| {
798                if let Some(Command::TransferObjects(transfer)) = &command.command {
799                    transfer
800                        .objects
801                        .iter()
802                        .any(|arg| arg.kind() == ArgumentKind::Gas)
803                } else {
804                    false
805                }
806            });
807        }
808        false
809    }
810
811    /// Add balance-change with zero amount if the gas owner does not have an entry.
812    /// An entry is required for gas owner because the balance would be adjusted.
813    fn add_missing_gas_owner(operations: &mut Vec<Operation>, gas_owner: SuiAddress) {
814        if !operations.iter().any(|operation| {
815            if let Some(amount) = &operation.amount
816                && let Some(account) = &operation.account
817                && account.address == gas_owner
818                && amount.currency == *SUI
819            {
820                return true;
821            }
822            false
823        }) {
824            operations.push(Operation::balance_change(
825                Some(OperationStatus::Success),
826                gas_owner,
827                0,
828                SUI.clone(),
829            ));
830        }
831    }
832
833    /// Compare initial balance_changes to new_operations and make sure
834    /// the balance-changes stay the same after updating the operations
835    fn validate_operations(
836        initial_balance_changes: &[(BalanceChange, Currency)],
837        new_operations: &[Operation],
838    ) -> Result<(), anyhow::Error> {
839        let balances: HashMap<(SuiAddress, Currency), i128> = HashMap::new();
840        let mut initial_balances =
841            initial_balance_changes
842                .iter()
843                .fold(balances, |mut balances, (balance_change, ccy)| {
844                    if let (Some(addr_str), Some(amount_str)) =
845                        (&balance_change.address, &balance_change.amount)
846                        && let (Ok(owner), Ok(amount)) =
847                            (SuiAddress::from_str(addr_str), i128::from_str(amount_str))
848                    {
849                        *balances.entry((owner, ccy.clone())).or_default() += amount;
850                    }
851                    balances
852                });
853
854        let mut new_balances = HashMap::new();
855        for op in new_operations {
856            if let Some(Amount {
857                currency, value, ..
858            }) = &op.amount
859            {
860                if let Some(account) = &op.account {
861                    let balance_change = new_balances
862                        .remove(&(account.address, currency.clone()))
863                        .unwrap_or(0)
864                        + value;
865                    new_balances.insert((account.address, currency.clone()), balance_change);
866                } else {
867                    return Err(anyhow!("Missing account for a balance-change"));
868                }
869            }
870        }
871
872        for ((address, currency), amount_expected) in new_balances {
873            let new_amount = initial_balances.remove(&(address, currency)).unwrap_or(0);
874            if new_amount != amount_expected {
875                return Err(anyhow!(
876                    "Expected {} balance-change for {} but got {}",
877                    amount_expected,
878                    address,
879                    new_amount
880                ));
881            }
882        }
883        if !initial_balances.is_empty() {
884            return Err(anyhow!(
885                "Expected every item in initial_balances to be mapped"
886            ));
887        }
888        Ok(())
889    }
890
891    /// If GasCoin is transferred as a part of transferObjects, operations need to be
892    /// updated such that:
893    /// 1) gas owner needs to be assigned back to the previous owner
894    /// 2) balances of previous and new gas owners need to be adjusted for the gas
895    fn process_gascoin_transfer(
896        coin_change_operations: &mut impl Iterator<Item = Operation>,
897        is_gascoin_transfer: bool,
898        prev_gas_owner: SuiAddress,
899        new_gas_owner: SuiAddress,
900        gas_used: i128,
901        initial_balance_changes: &[(BalanceChange, Currency)],
902    ) -> Result<Vec<Operation>, anyhow::Error> {
903        let mut operations = vec![];
904        if is_gascoin_transfer && prev_gas_owner != new_gas_owner {
905            operations = coin_change_operations.collect();
906            Self::add_missing_gas_owner(&mut operations, prev_gas_owner);
907            Self::add_missing_gas_owner(&mut operations, new_gas_owner);
908            for operation in &mut operations {
909                match operation.type_ {
910                    OperationType::Gas => {
911                        // change gas account back to the previous owner as it is the one
912                        // who paid for the txn (this is the format Rosetta wants to process)
913                        operation.account = Some(prev_gas_owner.into())
914                    }
915                    OperationType::SuiBalanceChange => {
916                        let account = operation
917                            .account
918                            .as_ref()
919                            .ok_or_else(|| anyhow!("Missing account for a balance-change"))?;
920                        let amount = operation
921                            .amount
922                            .as_mut()
923                            .ok_or_else(|| anyhow!("Missing amount for a balance-change"))?;
924                        // adjust the balances for previous and new gas_owners
925                        if account.address == prev_gas_owner && amount.currency == *SUI {
926                            amount.value -= gas_used;
927                        } else if account.address == new_gas_owner && amount.currency == *SUI {
928                            amount.value += gas_used;
929                        }
930                    }
931                    _ => {
932                        return Err(anyhow!(
933                            "Discarding unsupported operation type {:?}",
934                            operation.type_
935                        ));
936                    }
937                }
938            }
939            Self::validate_operations(initial_balance_changes, &operations)?;
940        }
941        Ok(operations)
942    }
943}
944
945impl Operations {
946    pub async fn try_from_executed_transaction(
947        executed_tx: ExecutedTransaction,
948        cache: &CoinMetadataCache,
949    ) -> Result<Self, Error> {
950        let ExecutedTransaction {
951            transaction,
952            effects,
953            events,
954            balance_changes,
955            ..
956        } = executed_tx;
957
958        let transaction = transaction.ok_or_else(|| {
959            Error::DataError("ExecutedTransaction missing transaction".to_string())
960        })?;
961        let effects = effects
962            .ok_or_else(|| Error::DataError("ExecutedTransaction missing effects".to_string()))?;
963
964        let sender = SuiAddress::from_str(transaction.sender())?;
965
966        let gas_owner = if effects.gas_object.is_some() {
967            let gas_object = effects.gas_object();
968            let owner = gas_object.output_owner();
969            SuiAddress::from_str(owner.address())?
970        } else if sender == SuiAddress::ZERO {
971            // System transactions don't have a gas_object.
972            sender
973        } else {
974            // Address-balance gas payment: gas is paid from the sender's address balance,
975            // not from an explicit gas coin object. Use gas_payment owner from tx data.
976            SuiAddress::from_str(transaction.gas_payment().owner())?
977        };
978
979        let gas_summary = effects.gas_used();
980        let gas_used = gas_summary.storage_rebate_opt().unwrap_or(0) as i128
981            - gas_summary.storage_cost_opt().unwrap_or(0) as i128
982            - gas_summary.computation_cost_opt().unwrap_or(0) as i128;
983
984        let status = Some(effects.status().into());
985
986        let prev_gas_owner = SuiAddress::from_str(transaction.gas_payment().owner())?;
987
988        let tx_kind = transaction
989            .kind
990            .ok_or_else(|| Error::DataError("Transaction missing kind".to_string()))?;
991        let is_gascoin_transfer = Self::is_gascoin_transfer(&tx_kind);
992        let ops = Self::new(Self::from_transaction(tx_kind, sender, status)?);
993        let ops = ops.into_iter();
994
995        // We will need to subtract the operation amounts from the actual balance
996        // change amount extracted from event to prevent double counting.
997        let mut accounted_balances =
998            ops.as_ref()
999                .iter()
1000                .fold(HashMap::new(), |mut balances, op| {
1001                    if let (Some(acc), Some(amount), Some(OperationStatus::Success)) =
1002                        (&op.account, &op.amount, &op.status)
1003                    {
1004                        *balances
1005                            .entry((acc.address, amount.clone().currency))
1006                            .or_default() -= amount.value;
1007                    }
1008                    balances
1009                });
1010
1011        let mut principal_amounts = 0;
1012        let mut reward_amounts = 0;
1013
1014        // Extract balance change from unstake events
1015        let events = events.as_ref().map(|e| e.events.as_slice()).unwrap_or(&[]);
1016        for event in events {
1017            let event_type = event.event_type();
1018            if let Ok(type_tag) = StructTag::from_str(event_type)
1019                && is_unstake_event(&type_tag)
1020                && let Some(json) = &event.json
1021                && let Some(Kind::StructValue(struct_val)) = &json.kind
1022            {
1023                if let Some(principal_field) = struct_val.fields.get("principal_amount")
1024                    && let Some(Kind::StringValue(s)) = &principal_field.kind
1025                    && let Ok(amount) = i128::from_str(s)
1026                {
1027                    principal_amounts += amount;
1028                }
1029                if let Some(reward_field) = struct_val.fields.get("reward_amount")
1030                    && let Some(Kind::StringValue(s)) = &reward_field.kind
1031                    && let Ok(amount) = i128::from_str(s)
1032                {
1033                    reward_amounts += amount;
1034                }
1035            }
1036        }
1037        let staking_balance = if principal_amounts != 0 {
1038            *accounted_balances.entry((sender, SUI.clone())).or_default() -= principal_amounts;
1039            *accounted_balances.entry((sender, SUI.clone())).or_default() -= reward_amounts;
1040            vec![
1041                Operation::stake_principle(status, sender, principal_amounts),
1042                Operation::stake_reward(status, sender, reward_amounts),
1043            ]
1044        } else {
1045            vec![]
1046        };
1047
1048        let mut balance_changes_with_currency = vec![];
1049
1050        for balance_change in &balance_changes {
1051            let coin_type = balance_change.coin_type();
1052            let type_tag = sui_types::TypeTag::from_str(coin_type)
1053                .map_err(|e| anyhow!("Invalid coin type: {}", e))?;
1054
1055            if let Ok(currency) = cache.get_currency(&type_tag).await
1056                && !currency.symbol.is_empty()
1057            {
1058                balance_changes_with_currency.push((balance_change.clone(), currency));
1059            }
1060        }
1061
1062        // Extract coin change operations from balance changes
1063        let mut coin_change_operations = Self::process_balance_change(
1064            gas_owner,
1065            gas_used,
1066            &balance_changes_with_currency,
1067            status,
1068            accounted_balances.clone(),
1069        );
1070
1071        // Take {gas, previous gas owner, new gas owner} out of coin_change_operations
1072        // and convert BalanceChange to PaySui when GasCoin is transferred
1073        let gascoin_transfer_operations = Self::process_gascoin_transfer(
1074            &mut coin_change_operations,
1075            is_gascoin_transfer,
1076            prev_gas_owner,
1077            gas_owner,
1078            gas_used,
1079            &balance_changes_with_currency,
1080        )?;
1081
1082        let ops: Operations = ops
1083            .into_iter()
1084            .chain(coin_change_operations)
1085            .chain(gascoin_transfer_operations)
1086            .chain(staking_balance)
1087            .collect();
1088
1089        // This is a workaround for the payCoin cases that are mistakenly considered to be paySui operations
1090        // In this case we remove any irrelevant, SUI specific operation entries that sum up to 0 balance changes per address
1091        // and keep only the actual entries for the right coin type transfers, as they have been extracted from the transaction's
1092        // balance changes section.
1093        let mutually_cancelling_balances: HashMap<_, _> = ops
1094            .clone()
1095            .into_iter()
1096            .fold(
1097                HashMap::new(),
1098                |mut balances: HashMap<(SuiAddress, Currency), i128>, op| {
1099                    if let (Some(acc), Some(amount), Some(OperationStatus::Success)) =
1100                        (&op.account, &op.amount, &op.status)
1101                        && op.type_ != OperationType::Gas
1102                    {
1103                        *balances
1104                            .entry((acc.address, amount.clone().currency))
1105                            .or_default() += amount.value;
1106                    }
1107                    balances
1108                },
1109            )
1110            .into_iter()
1111            .filter(|balance| {
1112                let (_, amount) = balance;
1113                *amount == 0
1114            })
1115            .collect();
1116
1117        let ops: Operations = ops
1118            .into_iter()
1119            .filter(|op| {
1120                if let (Some(acc), Some(amount)) = (&op.account, &op.amount) {
1121                    return op.type_ == OperationType::Gas
1122                        || !mutually_cancelling_balances
1123                            .contains_key(&(acc.address, amount.clone().currency));
1124                }
1125                true
1126            })
1127            .collect();
1128        Ok(ops)
1129    }
1130}
1131
1132fn is_unstake_event(tag: &StructTag) -> bool {
1133    tag.address == SUI_SYSTEM_ADDRESS
1134        && tag.module.as_ident_str() == ident_str!("validator")
1135        && tag.name.as_ident_str() == ident_str!("UnstakingRequestEvent")
1136}
1137
1138#[derive(Deserialize, Serialize, Clone, Debug)]
1139pub struct Operation {
1140    operation_identifier: OperationIdentifier,
1141    #[serde(rename = "type")]
1142    pub type_: OperationType,
1143    #[serde(default, skip_serializing_if = "Option::is_none")]
1144    pub status: Option<OperationStatus>,
1145    #[serde(default, skip_serializing_if = "Option::is_none")]
1146    pub account: Option<AccountIdentifier>,
1147    #[serde(default, skip_serializing_if = "Option::is_none")]
1148    pub amount: Option<Amount>,
1149    #[serde(default, skip_serializing_if = "Option::is_none")]
1150    pub coin_change: Option<CoinChange>,
1151    #[serde(default, skip_serializing_if = "Option::is_none")]
1152    pub metadata: Option<OperationMetadata>,
1153}
1154
1155impl PartialEq for Operation {
1156    fn eq(&self, other: &Self) -> bool {
1157        self.operation_identifier == other.operation_identifier
1158            && self.type_ == other.type_
1159            && self.account == other.account
1160            && self.amount == other.amount
1161            && self.coin_change == other.coin_change
1162            && self.metadata == other.metadata
1163    }
1164}
1165
1166#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
1167pub enum OperationMetadata {
1168    GenericTransaction(TransactionKind),
1169    Stake { validator: SuiAddress },
1170    WithdrawStake { stake_ids: Vec<ObjectID> },
1171}
1172
1173impl Operation {
1174    fn generic_op(
1175        status: Option<OperationStatus>,
1176        sender: SuiAddress,
1177        tx: TransactionKind,
1178    ) -> Self {
1179        Operation {
1180            operation_identifier: Default::default(),
1181            type_: (&tx).into(),
1182            status,
1183            account: Some(sender.into()),
1184            amount: None,
1185            coin_change: None,
1186            metadata: Some(OperationMetadata::GenericTransaction(tx)),
1187        }
1188    }
1189
1190    pub fn genesis(index: u64, sender: SuiAddress, coin: GasCoin) -> Self {
1191        Operation {
1192            operation_identifier: index.into(),
1193            type_: OperationType::Genesis,
1194            status: Some(OperationStatus::Success),
1195            account: Some(sender.into()),
1196            amount: Some(Amount::new(coin.value().into(), None)),
1197            coin_change: Some(CoinChange {
1198                coin_identifier: CoinIdentifier {
1199                    identifier: CoinID {
1200                        id: *coin.id(),
1201                        version: SequenceNumber::new(),
1202                    },
1203                },
1204                coin_action: CoinAction::CoinCreated,
1205            }),
1206            metadata: None,
1207        }
1208    }
1209
1210    fn pay_sui(status: Option<OperationStatus>, address: SuiAddress, amount: i128) -> Self {
1211        Operation {
1212            operation_identifier: Default::default(),
1213            type_: OperationType::PaySui,
1214            status,
1215            account: Some(address.into()),
1216            amount: Some(Amount::new(amount, None)),
1217            coin_change: None,
1218            metadata: None,
1219        }
1220    }
1221
1222    fn pay_coin(
1223        status: Option<OperationStatus>,
1224        address: SuiAddress,
1225        amount: i128,
1226        currency: Option<Currency>,
1227    ) -> Self {
1228        Operation {
1229            operation_identifier: Default::default(),
1230            type_: OperationType::PayCoin,
1231            status,
1232            account: Some(address.into()),
1233            amount: Some(Amount::new(amount, currency)),
1234            coin_change: None,
1235            metadata: None,
1236        }
1237    }
1238
1239    fn balance_change(
1240        status: Option<OperationStatus>,
1241        addr: SuiAddress,
1242        amount: i128,
1243        currency: Currency,
1244    ) -> Self {
1245        Self {
1246            operation_identifier: Default::default(),
1247            type_: OperationType::SuiBalanceChange,
1248            status,
1249            account: Some(addr.into()),
1250            amount: Some(Amount::new(amount, Some(currency))),
1251            coin_change: None,
1252            metadata: None,
1253        }
1254    }
1255    fn gas(addr: SuiAddress, amount: i128) -> Self {
1256        Self {
1257            operation_identifier: Default::default(),
1258            type_: OperationType::Gas,
1259            status: Some(OperationStatus::Success),
1260            account: Some(addr.into()),
1261            amount: Some(Amount::new(amount, None)),
1262            coin_change: None,
1263            metadata: None,
1264        }
1265    }
1266    fn stake_reward(status: Option<OperationStatus>, addr: SuiAddress, amount: i128) -> Self {
1267        Self {
1268            operation_identifier: Default::default(),
1269            type_: OperationType::StakeReward,
1270            status,
1271            account: Some(addr.into()),
1272            amount: Some(Amount::new(amount, None)),
1273            coin_change: None,
1274            metadata: None,
1275        }
1276    }
1277    fn stake_principle(status: Option<OperationStatus>, addr: SuiAddress, amount: i128) -> Self {
1278        Self {
1279            operation_identifier: Default::default(),
1280            type_: OperationType::StakePrinciple,
1281            status,
1282            account: Some(addr.into()),
1283            amount: Some(Amount::new(amount, None)),
1284            coin_change: None,
1285            metadata: None,
1286        }
1287    }
1288}
1289
1290#[cfg(test)]
1291mod tests {
1292    use super::*;
1293    use crate::SUI;
1294    use crate::types::ConstructionMetadata;
1295    use sui_rpc::proto::sui::rpc::v2::Transaction;
1296    use sui_types::base_types::{ObjectDigest, ObjectID, SequenceNumber, SuiAddress};
1297    use sui_types::programmable_transaction_builder::ProgrammableTransactionBuilder;
1298    use sui_types::transaction::{TEST_ONLY_GAS_UNIT_FOR_TRANSFER, TransactionData};
1299
1300    #[tokio::test]
1301    async fn test_operation_data_parsing_pay_sui() -> Result<(), anyhow::Error> {
1302        let gas = (
1303            ObjectID::random(),
1304            SequenceNumber::new(),
1305            ObjectDigest::random(),
1306        );
1307
1308        let sender = SuiAddress::random_for_testing_only();
1309
1310        let pt = {
1311            let mut builder = ProgrammableTransactionBuilder::new();
1312            builder
1313                .pay_sui(vec![SuiAddress::random_for_testing_only()], vec![10000])
1314                .unwrap();
1315            builder.finish()
1316        };
1317        let gas_price = 10;
1318        let data = TransactionData::new_programmable(
1319            sender,
1320            vec![gas],
1321            pt,
1322            TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
1323            gas_price,
1324        );
1325
1326        let proto_tx: Transaction = data.clone().into();
1327        let ops = Operations::new(Operations::from_transaction(
1328            proto_tx
1329                .kind
1330                .ok_or_else(|| Error::DataError("Transaction missing kind".to_string()))?,
1331            sender,
1332            None,
1333        )?);
1334        ops.0
1335            .iter()
1336            .for_each(|op| assert_eq!(op.type_, OperationType::PaySui));
1337        let metadata = ConstructionMetadata {
1338            sender,
1339            gas_coins: vec![gas],
1340            extra_gas_coins: vec![],
1341            objects: vec![],
1342            party_objects: vec![],
1343            total_coin_value: 0,
1344            gas_price,
1345            budget: TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
1346            currency: None,
1347            address_balance_withdrawal: 0,
1348            epoch: None,
1349            chain_id: None,
1350        };
1351        let parsed_data = ops.into_internal()?.try_into_data(metadata)?;
1352        assert_eq!(data, parsed_data);
1353
1354        Ok(())
1355    }
1356
1357    #[tokio::test]
1358    async fn test_operation_data_parsing_pay_coin() -> Result<(), anyhow::Error> {
1359        use crate::types::internal_operation::pay_coin_pt;
1360
1361        let gas = (
1362            ObjectID::random(),
1363            SequenceNumber::new(),
1364            ObjectDigest::random(),
1365        );
1366
1367        let coin = (
1368            ObjectID::random(),
1369            SequenceNumber::new(),
1370            ObjectDigest::random(),
1371        );
1372
1373        let sender = SuiAddress::random_for_testing_only();
1374        let recipient = SuiAddress::random_for_testing_only();
1375
1376        let pt = pay_coin_pt(sender, vec![recipient], vec![10000], &[coin], &[], 0, &SUI)?;
1377        let gas_price = 10;
1378        let data = TransactionData::new_programmable(
1379            sender,
1380            vec![gas],
1381            pt,
1382            TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
1383            gas_price,
1384        );
1385
1386        let proto_tx: Transaction = data.clone().into();
1387        let ops = Operations::new(Operations::from_transaction(
1388            proto_tx
1389                .kind
1390                .ok_or_else(|| Error::DataError("Transaction missing kind".to_string()))?,
1391            sender,
1392            None,
1393        )?);
1394        ops.0
1395            .iter()
1396            .for_each(|op| assert_eq!(op.type_, OperationType::PayCoin));
1397        let metadata = ConstructionMetadata {
1398            sender,
1399            gas_coins: vec![gas],
1400            extra_gas_coins: vec![],
1401            objects: vec![coin],
1402            party_objects: vec![],
1403            total_coin_value: 0,
1404            gas_price,
1405            budget: TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
1406            currency: Some(SUI.clone()),
1407            address_balance_withdrawal: 0,
1408            epoch: None,
1409            chain_id: None,
1410        };
1411        let parsed_data = ops.into_internal()?.try_into_data(metadata)?;
1412        assert_eq!(data, parsed_data);
1413
1414        Ok(())
1415    }
1416}