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::{ModuleId, StructTag};
12use move_core_types::resolver::ModuleResolver;
13use serde::Deserialize;
14use serde::Serialize;
15
16use sui_json_rpc_types::SuiProgrammableMoveCall;
17use sui_json_rpc_types::SuiProgrammableTransactionBlock;
18use sui_json_rpc_types::{BalanceChange, SuiArgument};
19use sui_json_rpc_types::{SuiCallArg, SuiCommand};
20use sui_sdk::rpc_types::{
21    SuiTransactionBlockData, SuiTransactionBlockDataAPI, SuiTransactionBlockEffectsAPI,
22    SuiTransactionBlockKind, SuiTransactionBlockResponse,
23};
24use sui_types::base_types::{ObjectID, SequenceNumber, SuiAddress};
25use sui_types::gas_coin::GasCoin;
26use sui_types::governance::{ADD_STAKE_FUN_NAME, WITHDRAW_STAKE_FUN_NAME};
27use sui_types::object::Owner;
28use sui_types::sui_system_state::SUI_SYSTEM_MODULE_NAME;
29use sui_types::transaction::TransactionData;
30use sui_types::{SUI_SYSTEM_ADDRESS, SUI_SYSTEM_PACKAGE_ID};
31
32use crate::types::internal_operation::{PayCoin, PaySui, Stake, WithdrawStake};
33use crate::types::{
34    AccountIdentifier, Amount, CoinAction, CoinChange, CoinID, CoinIdentifier, Currency,
35    InternalOperation, OperationIdentifier, OperationStatus, OperationType,
36};
37use crate::{CoinMetadataCache, Error, SUI};
38
39#[cfg(test)]
40#[path = "unit_tests/operations_tests.rs"]
41mod operations_tests;
42
43#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
44pub struct Operations(Vec<Operation>);
45
46impl FromIterator<Operation> for Operations {
47    fn from_iter<T: IntoIterator<Item = Operation>>(iter: T) -> Self {
48        Operations::new(iter.into_iter().collect())
49    }
50}
51
52impl FromIterator<Vec<Operation>> for Operations {
53    fn from_iter<T: IntoIterator<Item = Vec<Operation>>>(iter: T) -> Self {
54        iter.into_iter().flatten().collect()
55    }
56}
57
58impl IntoIterator for Operations {
59    type Item = Operation;
60    type IntoIter = vec::IntoIter<Operation>;
61    fn into_iter(self) -> Self::IntoIter {
62        self.0.into_iter()
63    }
64}
65
66impl Operations {
67    pub fn new(mut ops: Vec<Operation>) -> Self {
68        for (index, op) in ops.iter_mut().enumerate() {
69            op.operation_identifier = (index as u64).into()
70        }
71        Self(ops)
72    }
73
74    pub fn contains(&self, other: &Operations) -> bool {
75        for (i, other_op) in other.0.iter().enumerate() {
76            if let Some(op) = self.0.get(i) {
77                if op != other_op {
78                    return false;
79                }
80            } else {
81                return false;
82            }
83        }
84        true
85    }
86
87    pub fn set_status(mut self, status: Option<OperationStatus>) -> Self {
88        for op in &mut self.0 {
89            op.status = status
90        }
91        self
92    }
93
94    pub fn type_(&self) -> Option<OperationType> {
95        self.0.first().map(|op| op.type_)
96    }
97
98    /// Parse operation input from rosetta operation to intermediate internal operation;
99    pub fn into_internal(self) -> Result<InternalOperation, Error> {
100        let type_ = self
101            .type_()
102            .ok_or_else(|| Error::MissingInput("Operation type".into()))?;
103        match type_ {
104            OperationType::PaySui => self.pay_sui_ops_to_internal(),
105            OperationType::PayCoin => self.pay_coin_ops_to_internal(),
106            OperationType::Stake => self.stake_ops_to_internal(),
107            OperationType::WithdrawStake => self.withdraw_stake_ops_to_internal(),
108            op => Err(Error::UnsupportedOperation(op)),
109        }
110    }
111
112    fn pay_sui_ops_to_internal(self) -> Result<InternalOperation, Error> {
113        let mut recipients = vec![];
114        let mut amounts = vec![];
115        let mut sender = None;
116        for op in self {
117            if let (Some(amount), Some(account)) = (op.amount.clone(), op.account.clone()) {
118                if amount.value.is_negative() {
119                    sender = Some(account.address)
120                } else {
121                    recipients.push(account.address);
122                    let amount = amount.value.abs();
123                    if amount > u64::MAX as i128 {
124                        return Err(Error::InvalidInput(
125                            "Input amount exceed u64::MAX".to_string(),
126                        ));
127                    }
128                    amounts.push(amount as u64)
129                }
130            }
131        }
132        let sender = sender.ok_or_else(|| Error::MissingInput("Sender address".to_string()))?;
133        Ok(InternalOperation::PaySui(PaySui {
134            sender,
135            recipients,
136            amounts,
137        }))
138    }
139
140    fn pay_coin_ops_to_internal(self) -> Result<InternalOperation, Error> {
141        let mut recipients = vec![];
142        let mut amounts = vec![];
143        let mut sender = None;
144        let mut currency = None;
145        for op in self {
146            if let (Some(amount), Some(account)) = (op.amount.clone(), op.account.clone()) {
147                currency = currency.or(Some(amount.currency));
148                if amount.value.is_negative() {
149                    sender = Some(account.address)
150                } else {
151                    recipients.push(account.address);
152                    let amount = amount.value.abs();
153                    if amount > u64::MAX as i128 {
154                        return Err(Error::InvalidInput(
155                            "Input amount exceed u64::MAX".to_string(),
156                        ));
157                    }
158                    amounts.push(amount as u64)
159                }
160            }
161        }
162        let sender = sender.ok_or_else(|| Error::MissingInput("Sender address".to_string()))?;
163        let currency = currency.ok_or_else(|| Error::MissingInput("Currency".to_string()))?;
164        Ok(InternalOperation::PayCoin(PayCoin {
165            sender,
166            recipients,
167            amounts,
168            currency,
169        }))
170    }
171
172    fn stake_ops_to_internal(self) -> Result<InternalOperation, Error> {
173        let mut ops = self
174            .0
175            .into_iter()
176            .filter(|op| op.type_ == OperationType::Stake)
177            .collect::<Vec<_>>();
178        if ops.len() != 1 {
179            return Err(Error::MalformedOperationError(
180                "Delegation should only have one operation.".into(),
181            ));
182        }
183        // Checked above, safe to unwrap.
184        let op = ops.pop().unwrap();
185        let sender = op
186            .account
187            .ok_or_else(|| Error::MissingInput("Sender address".to_string()))?
188            .address;
189        let metadata = op
190            .metadata
191            .ok_or_else(|| Error::MissingInput("Stake metadata".to_string()))?;
192
193        // Total issued SUi is less than u64, safe to cast.
194        let amount = if let Some(amount) = op.amount {
195            if amount.value.is_positive() {
196                return Err(Error::MalformedOperationError(
197                    "Stake amount should be negative.".into(),
198                ));
199            }
200            Some(amount.value.unsigned_abs() as u64)
201        } else {
202            None
203        };
204
205        let OperationMetadata::Stake { validator } = metadata else {
206            return Err(Error::InvalidInput(
207                "Cannot find delegation info from metadata.".into(),
208            ));
209        };
210
211        Ok(InternalOperation::Stake(Stake {
212            sender,
213            validator,
214            amount,
215        }))
216    }
217
218    fn withdraw_stake_ops_to_internal(self) -> Result<InternalOperation, Error> {
219        let mut ops = self
220            .0
221            .into_iter()
222            .filter(|op| op.type_ == OperationType::WithdrawStake)
223            .collect::<Vec<_>>();
224        if ops.len() != 1 {
225            return Err(Error::MalformedOperationError(
226                "Delegation should only have one operation.".into(),
227            ));
228        }
229        // Checked above, safe to unwrap.
230        let op = ops.pop().unwrap();
231        let sender = op
232            .account
233            .ok_or_else(|| Error::MissingInput("Sender address".to_string()))?
234            .address;
235
236        let stake_ids = if let Some(metadata) = op.metadata {
237            let OperationMetadata::WithdrawStake { stake_ids } = metadata else {
238                return Err(Error::InvalidInput(
239                    "Cannot find withdraw stake info from metadata.".into(),
240                ));
241            };
242            stake_ids
243        } else {
244            vec![]
245        };
246
247        Ok(InternalOperation::WithdrawStake(WithdrawStake {
248            sender,
249            stake_ids,
250        }))
251    }
252
253    fn from_transaction(
254        tx: SuiTransactionBlockKind,
255        sender: SuiAddress,
256        status: Option<OperationStatus>,
257    ) -> Result<Vec<Operation>, Error> {
258        Ok(match tx {
259            SuiTransactionBlockKind::ProgrammableTransaction(pt)
260                if status != Some(OperationStatus::Failure) =>
261            {
262                Self::parse_programmable_transaction(sender, status, pt)?
263            }
264            _ => vec![Operation::generic_op(status, sender, tx)],
265        })
266    }
267
268    fn parse_programmable_transaction(
269        sender: SuiAddress,
270        status: Option<OperationStatus>,
271        pt: SuiProgrammableTransactionBlock,
272    ) -> Result<Vec<Operation>, Error> {
273        #[derive(Debug)]
274        enum KnownValue {
275            GasCoin(u64),
276        }
277        fn resolve_result(
278            known_results: &[Vec<KnownValue>],
279            i: u16,
280            j: u16,
281        ) -> Option<&KnownValue> {
282            known_results
283                .get(i as usize)
284                .and_then(|inner| inner.get(j as usize))
285        }
286        fn split_coins(
287            inputs: &[SuiCallArg],
288            known_results: &[Vec<KnownValue>],
289            coin: SuiArgument,
290            amounts: &[SuiArgument],
291        ) -> Option<Vec<KnownValue>> {
292            match coin {
293                SuiArgument::Result(i) => {
294                    let KnownValue::GasCoin(_) = resolve_result(known_results, i, 0)?;
295                }
296                SuiArgument::NestedResult(i, j) => {
297                    let KnownValue::GasCoin(_) = resolve_result(known_results, i, j)?;
298                }
299                SuiArgument::GasCoin => (),
300                // Might not be a SUI coin
301                SuiArgument::Input(_) => (),
302            };
303            let amounts = amounts
304                .iter()
305                .map(|amount| {
306                    let value: u64 = match *amount {
307                        SuiArgument::Input(i) => {
308                            u64::from_str(inputs.get(i as usize)?.pure()?.to_json_value().as_str()?)
309                                .ok()?
310                        }
311                        SuiArgument::GasCoin
312                        | SuiArgument::Result(_)
313                        | SuiArgument::NestedResult(_, _) => return None,
314                    };
315                    Some(KnownValue::GasCoin(value))
316                })
317                .collect::<Option<_>>()?;
318            Some(amounts)
319        }
320        fn transfer_object(
321            aggregated_recipients: &mut HashMap<SuiAddress, u64>,
322            inputs: &[SuiCallArg],
323            known_results: &[Vec<KnownValue>],
324            objs: &[SuiArgument],
325            recipient: SuiArgument,
326        ) -> Option<Vec<KnownValue>> {
327            let addr = match recipient {
328                SuiArgument::Input(i) => inputs.get(i as usize)?.pure()?.to_sui_address().ok()?,
329                SuiArgument::GasCoin | SuiArgument::Result(_) | SuiArgument::NestedResult(_, _) => {
330                    return None;
331                }
332            };
333            for obj in objs {
334                let value = match *obj {
335                    SuiArgument::Result(i) => {
336                        let KnownValue::GasCoin(value) = resolve_result(known_results, i, 0)?;
337                        value
338                    }
339                    SuiArgument::NestedResult(i, j) => {
340                        let KnownValue::GasCoin(value) = resolve_result(known_results, i, j)?;
341                        value
342                    }
343                    SuiArgument::GasCoin | SuiArgument::Input(_) => return None,
344                };
345                let aggregate = aggregated_recipients.entry(addr).or_default();
346                *aggregate += value;
347            }
348            Some(vec![])
349        }
350        fn stake_call(
351            inputs: &[SuiCallArg],
352            known_results: &[Vec<KnownValue>],
353            call: &SuiProgrammableMoveCall,
354        ) -> Result<Option<(Option<u64>, SuiAddress)>, Error> {
355            let SuiProgrammableMoveCall { arguments, .. } = call;
356            let (amount, validator) = match &arguments[..] {
357                [_, coin, validator] => {
358                    let amount = match coin {
359                        SuiArgument::Result(i) => {
360                            let KnownValue::GasCoin(value) = resolve_result(known_results, *i, 0)
361                                .ok_or_else(|| {
362                                anyhow!("Cannot resolve Gas coin value at Result({i})")
363                            })?;
364                            value
365                        }
366                        _ => return Ok(None),
367                    };
368                    let (some_amount, validator) = match validator {
369                        // [WORKAROUND] - this is a hack to work out if the staking ops is for a selected amount or None amount (whole wallet).
370                        // We use the position of the validator arg as a indicator of if the rosetta stake
371                        // transaction is staking the whole wallet or not, if staking whole wallet,
372                        // we have to omit the amount value in the final operation output.
373                        SuiArgument::Input(i) => (
374                            *i == 1,
375                            inputs
376                                .get(*i as usize)
377                                .and_then(|input| input.pure())
378                                .map(|v| v.to_sui_address())
379                                .transpose(),
380                        ),
381                        _ => return Ok(None),
382                    };
383                    (some_amount.then_some(*amount), validator)
384                }
385                _ => Err(anyhow!(
386                    "Error encountered when extracting arguments from move call, expecting 3 elements, got {}",
387                    arguments.len()
388                ))?,
389            };
390            Ok(validator.map(|v| v.map(|v| (amount, v)))?)
391        }
392
393        fn unstake_call(
394            inputs: &[SuiCallArg],
395            call: &SuiProgrammableMoveCall,
396        ) -> Result<Option<ObjectID>, Error> {
397            let SuiProgrammableMoveCall { arguments, .. } = call;
398            let id = match &arguments[..] {
399                [_, stake_id] => {
400                    match stake_id {
401                        SuiArgument::Input(i) => {
402                            let id = inputs
403                                .get(*i as usize)
404                                .and_then(|input| input.object())
405                                .ok_or_else(|| anyhow!("Cannot find stake id from input args."))?;
406                            // [WORKAROUND] - this is a hack to work out if the withdraw stake ops is for a selected stake or None (all stakes).
407                            // this hack is similar to the one in stake_call.
408                            let some_id = i % 2 == 1;
409                            some_id.then_some(id)
410                        }
411                        _ => return Ok(None),
412                    }
413                }
414                _ => Err(anyhow!(
415                    "Error encountered when extracting arguments from move call, expecting 3 elements, got {}",
416                    arguments.len()
417                ))?,
418            };
419            Ok(id.cloned())
420        }
421        let SuiProgrammableTransactionBlock { inputs, commands } = &pt;
422        let mut known_results: Vec<Vec<KnownValue>> = vec![];
423        let mut aggregated_recipients: HashMap<SuiAddress, u64> = HashMap::new();
424        let mut needs_generic = false;
425        let mut operations = vec![];
426        let mut stake_ids = vec![];
427        let mut currency: Option<Currency> = None;
428
429        for command in commands {
430            let result = match command {
431                SuiCommand::SplitCoins(coin, amounts) => {
432                    split_coins(inputs, &known_results, *coin, amounts)
433                }
434                SuiCommand::TransferObjects(objs, addr) => transfer_object(
435                    &mut aggregated_recipients,
436                    inputs,
437                    &known_results,
438                    objs,
439                    *addr,
440                ),
441                SuiCommand::MoveCall(m) if Self::is_stake_call(m) => {
442                    stake_call(inputs, &known_results, m)?.map(|(amount, validator)| {
443                        let amount = amount.map(|amount| Amount::new(-(amount as i128), None));
444                        operations.push(Operation {
445                            operation_identifier: Default::default(),
446                            type_: OperationType::Stake,
447                            status,
448                            account: Some(sender.into()),
449                            amount,
450                            coin_change: None,
451                            metadata: Some(OperationMetadata::Stake { validator }),
452                        });
453                        vec![]
454                    })
455                }
456                SuiCommand::MoveCall(m) if Self::is_unstake_call(m) => {
457                    let stake_id = unstake_call(inputs, m)?;
458                    stake_ids.push(stake_id);
459                    Some(vec![])
460                }
461                SuiCommand::MergeCoins(_merge_into, _merges) => {
462                    // We don't care about merge-coins, we can just skip it.
463                    Some(vec![])
464                }
465                _ => None,
466            };
467            if let Some(result) = result {
468                known_results.push(result)
469            } else {
470                needs_generic = true;
471                break;
472            }
473        }
474
475        if !needs_generic && !aggregated_recipients.is_empty() {
476            let total_paid: u64 = aggregated_recipients.values().copied().sum();
477            operations.extend(
478                aggregated_recipients
479                    .into_iter()
480                    .map(|(recipient, amount)| {
481                        currency = inputs.iter().last().and_then(|arg| {
482                            if let SuiCallArg::Pure(value) = arg {
483                                let bytes = value
484                                    .value()
485                                    .to_json_value()
486                                    .as_array()?
487                                    .clone()
488                                    .into_iter()
489                                    .map(|v| v.as_u64().map(|n| n as u8))
490                                    .collect::<Option<Vec<u8>>>()?;
491                                bcs::from_bytes::<String>(&bytes)
492                                    .ok()
493                                    .and_then(|bcs_str| serde_json::from_str(&bcs_str).ok())
494                            } else {
495                                None
496                            }
497                        });
498                        match currency {
499                            Some(_) => Operation::pay_coin(
500                                status,
501                                recipient,
502                                amount.into(),
503                                currency.clone(),
504                            ),
505                            None => Operation::pay_sui(status, recipient, amount.into()),
506                        }
507                    }),
508            );
509            match currency {
510                Some(_) => operations.push(Operation::pay_coin(
511                    status,
512                    sender,
513                    -(total_paid as i128),
514                    currency.clone(),
515                )),
516                _ => operations.push(Operation::pay_sui(status, sender, -(total_paid as i128))),
517            }
518        } else if !stake_ids.is_empty() {
519            let stake_ids = stake_ids.into_iter().flatten().collect::<Vec<_>>();
520            let metadata = stake_ids
521                .is_empty()
522                .not()
523                .then_some(OperationMetadata::WithdrawStake { stake_ids });
524            operations.push(Operation {
525                operation_identifier: Default::default(),
526                type_: OperationType::WithdrawStake,
527                status,
528                account: Some(sender.into()),
529                amount: None,
530                coin_change: None,
531                metadata,
532            });
533        } else if operations.is_empty() {
534            operations.push(Operation::generic_op(
535                status,
536                sender,
537                SuiTransactionBlockKind::ProgrammableTransaction(pt),
538            ))
539        }
540        Ok(operations)
541    }
542
543    fn is_stake_call(tx: &SuiProgrammableMoveCall) -> bool {
544        tx.package == SUI_SYSTEM_PACKAGE_ID
545            && tx.module == SUI_SYSTEM_MODULE_NAME.as_str()
546            && tx.function == ADD_STAKE_FUN_NAME.as_str()
547    }
548
549    fn is_unstake_call(tx: &SuiProgrammableMoveCall) -> bool {
550        tx.package == SUI_SYSTEM_PACKAGE_ID
551            && tx.module == SUI_SYSTEM_MODULE_NAME.as_str()
552            && tx.function == WITHDRAW_STAKE_FUN_NAME.as_str()
553    }
554
555    fn process_balance_change(
556        gas_owner: SuiAddress,
557        gas_used: i128,
558        balance_changes: &[(BalanceChange, Currency)],
559        status: Option<OperationStatus>,
560        balances: HashMap<(SuiAddress, Currency), i128>,
561    ) -> impl Iterator<Item = Operation> {
562        let mut balances =
563            balance_changes
564                .iter()
565                .fold(balances, |mut balances, (balance_change, ccy)| {
566                    // Rosetta only care about address owner
567                    if let Owner::AddressOwner(owner) = balance_change.owner {
568                        *balances.entry((owner, ccy.clone())).or_default() += balance_change.amount;
569                    }
570                    balances
571                });
572        // separate gas from balances
573        *balances.entry((gas_owner, SUI.clone())).or_default() -= gas_used;
574
575        let balance_change = balances.into_iter().filter(|(_, amount)| *amount != 0).map(
576            move |((addr, currency), amount)| {
577                Operation::balance_change(status, addr, amount, currency)
578            },
579        );
580
581        let gas = if gas_used != 0 {
582            vec![Operation::gas(gas_owner, gas_used)]
583        } else {
584            // Gas can be 0 for system tx
585            vec![]
586        };
587        balance_change.chain(gas)
588    }
589
590    /// Checks to see if transferObjects is used on GasCoin
591    fn is_gascoin_transfer(tx: &SuiTransactionBlockKind) -> bool {
592        if let SuiTransactionBlockKind::ProgrammableTransaction(pt) = tx {
593            let SuiProgrammableTransactionBlock {
594                inputs: _,
595                commands,
596            } = &pt;
597            return commands.iter().any(|command| match command {
598                SuiCommand::TransferObjects(objs, _) => objs.contains(&SuiArgument::GasCoin),
599                _ => false,
600            });
601        }
602        false
603    }
604
605    /// Add balance-change with zero amount if the gas owner does not have an entry.
606    /// An entry is required for gas owner because the balance would be adjusted.
607    fn add_missing_gas_owner(operations: &mut Vec<Operation>, gas_owner: SuiAddress) {
608        if !operations.iter().any(|operation| {
609            if let Some(amount) = &operation.amount
610                && let Some(account) = &operation.account
611                && account.address == gas_owner
612                && amount.currency == *SUI
613            {
614                return true;
615            }
616            false
617        }) {
618            operations.push(Operation::balance_change(
619                Some(OperationStatus::Success),
620                gas_owner,
621                0,
622                SUI.clone(),
623            ));
624        }
625    }
626
627    /// Compare initial balance_changes to new_operations and make sure
628    /// the balance-changes stay the same after updating the operations
629    fn validate_operations(
630        initial_balance_changes: &[(BalanceChange, Currency)],
631        new_operations: &[Operation],
632    ) -> Result<(), anyhow::Error> {
633        let balances: HashMap<(SuiAddress, Currency), i128> = HashMap::new();
634        let mut initial_balances =
635            initial_balance_changes
636                .iter()
637                .fold(balances, |mut balances, (balance_change, ccy)| {
638                    if let Owner::AddressOwner(owner) = balance_change.owner {
639                        *balances.entry((owner, ccy.clone())).or_default() += balance_change.amount;
640                    }
641                    balances
642                });
643
644        let mut new_balances = HashMap::new();
645        for op in new_operations {
646            if let Some(Amount {
647                currency, value, ..
648            }) = &op.amount
649            {
650                if let Some(account) = &op.account {
651                    let balance_change = new_balances
652                        .remove(&(account.address, currency.clone()))
653                        .unwrap_or(0)
654                        + value;
655                    new_balances.insert((account.address, currency.clone()), balance_change);
656                } else {
657                    return Err(anyhow!("Missing account for a balance-change"));
658                }
659            }
660        }
661
662        for ((address, currency), amount_expected) in new_balances {
663            let new_amount = initial_balances.remove(&(address, currency)).unwrap_or(0);
664            if new_amount != amount_expected {
665                return Err(anyhow!(
666                    "Expected {} balance-change for {} but got {}",
667                    amount_expected,
668                    address,
669                    new_amount
670                ));
671            }
672        }
673        if !initial_balances.is_empty() {
674            return Err(anyhow!(
675                "Expected every item in initial_balances to be mapped"
676            ));
677        }
678        Ok(())
679    }
680
681    /// If GasCoin is transferred as a part of transferObjects, operations need to be
682    /// updated such that:
683    /// 1) gas owner needs to be assigned back to the previous owner
684    /// 2) balances of previous and new gas owners need to be adjusted for the gas
685    fn process_gascoin_transfer(
686        coin_change_operations: &mut impl Iterator<Item = Operation>,
687        data: SuiTransactionBlockData,
688        new_gas_owner: SuiAddress,
689        gas_used: i128,
690        initial_balance_changes: &[(BalanceChange, Currency)],
691    ) -> Result<Vec<Operation>, anyhow::Error> {
692        let tx = data.transaction();
693        let prev_gas_owner = data.gas_data().owner;
694        let mut operations = vec![];
695        if Self::is_gascoin_transfer(tx) && prev_gas_owner != new_gas_owner {
696            operations = coin_change_operations.collect();
697            Self::add_missing_gas_owner(&mut operations, prev_gas_owner);
698            Self::add_missing_gas_owner(&mut operations, new_gas_owner);
699            for operation in &mut operations {
700                match operation.type_ {
701                    OperationType::Gas => {
702                        // change gas account back to the previous owner as it is the one
703                        // who paid for the txn (this is the format Rosetta wants to process)
704                        operation.account = Some(prev_gas_owner.into())
705                    }
706                    OperationType::SuiBalanceChange => {
707                        let account = operation
708                            .account
709                            .as_ref()
710                            .ok_or_else(|| anyhow!("Missing account for a balance-change"))?;
711                        let amount = operation
712                            .amount
713                            .as_mut()
714                            .ok_or_else(|| anyhow!("Missing amount for a balance-change"))?;
715                        // adjust the balances for previous and new gas_owners
716                        if account.address == prev_gas_owner && amount.currency == *SUI {
717                            amount.value -= gas_used;
718                        } else if account.address == new_gas_owner && amount.currency == *SUI {
719                            amount.value += gas_used;
720                        }
721                    }
722                    _ => {
723                        return Err(anyhow!(
724                            "Discarding unsupported operation type {:?}",
725                            operation.type_
726                        ));
727                    }
728                }
729            }
730            Self::validate_operations(initial_balance_changes, &operations)?;
731        }
732        Ok(operations)
733    }
734}
735
736impl Operations {
737    fn try_from_data(
738        data: SuiTransactionBlockData,
739        status: Option<OperationStatus>,
740    ) -> Result<Self, anyhow::Error> {
741        let sender = *data.sender();
742        Ok(Self::new(Self::from_transaction(
743            data.transaction().clone(),
744            sender,
745            status,
746        )?))
747    }
748}
749impl Operations {
750    pub async fn try_from_response(
751        response: SuiTransactionBlockResponse,
752        cache: &CoinMetadataCache,
753    ) -> Result<Self, Error> {
754        let tx = response
755            .transaction
756            .ok_or_else(|| anyhow!("Response input should not be empty"))?;
757        let sender = *tx.data.sender();
758        let effect = response
759            .effects
760            .ok_or_else(|| anyhow!("Response effects should not be empty"))?;
761        let gas_owner = effect.gas_object().owner.get_owner_address()?;
762        let gas_summary = effect.gas_cost_summary();
763        let gas_used = gas_summary.storage_rebate as i128
764            - gas_summary.storage_cost as i128
765            - gas_summary.computation_cost as i128;
766
767        let status = Some(effect.into_status().into());
768        let ops = Operations::try_from_data(tx.data.clone(), status)?;
769        let ops = ops.into_iter();
770
771        // We will need to subtract the operation amounts from the actual balance
772        // change amount extracted from event to prevent double counting.
773        let mut accounted_balances =
774            ops.as_ref()
775                .iter()
776                .fold(HashMap::new(), |mut balances, op| {
777                    if let (Some(acc), Some(amount), Some(OperationStatus::Success)) =
778                        (&op.account, &op.amount, &op.status)
779                    {
780                        *balances
781                            .entry((acc.address, amount.clone().currency))
782                            .or_default() -= amount.value;
783                    }
784                    balances
785                });
786
787        let mut principal_amounts = 0;
788        let mut reward_amounts = 0;
789        // Extract balance change from unstake events
790
791        if let Some(events) = response.events {
792            for event in events.data {
793                if is_unstake_event(&event.type_) {
794                    let principal_amount = event
795                        .parsed_json
796                        .pointer("/principal_amount")
797                        .and_then(|v| v.as_str())
798                        .and_then(|v| i128::from_str(v).ok());
799                    let reward_amount = event
800                        .parsed_json
801                        .pointer("/reward_amount")
802                        .and_then(|v| v.as_str())
803                        .and_then(|v| i128::from_str(v).ok());
804                    if let (Some(principal_amount), Some(reward_amount)) =
805                        (principal_amount, reward_amount)
806                    {
807                        principal_amounts += principal_amount;
808                        reward_amounts += reward_amount;
809                    }
810                }
811            }
812        }
813        let staking_balance = if principal_amounts != 0 {
814            *accounted_balances.entry((sender, SUI.clone())).or_default() -= principal_amounts;
815            *accounted_balances.entry((sender, SUI.clone())).or_default() -= reward_amounts;
816            vec![
817                Operation::stake_principle(status, sender, principal_amounts),
818                Operation::stake_reward(status, sender, reward_amounts),
819            ]
820        } else {
821            vec![]
822        };
823
824        let mut balance_changes = vec![];
825
826        for balance_change in &response
827            .balance_changes
828            .ok_or_else(|| anyhow!("Response balance changes should not be empty."))?
829        {
830            if let Ok(currency) = cache.get_currency(&balance_change.coin_type).await
831                && !currency.symbol.is_empty()
832            {
833                balance_changes.push((balance_change.clone(), currency));
834            }
835        }
836
837        // Extract coin change operations from balance changes
838        let mut coin_change_operations = Self::process_balance_change(
839            gas_owner,
840            gas_used,
841            &balance_changes,
842            status,
843            accounted_balances.clone(),
844        );
845
846        // Take {gas, previous gas owner, new gas owner} out of coin_change_operations
847        // and convert BalanceChange to PaySui when GasCoin is transferred
848        let gascoin_transfer_operations = Self::process_gascoin_transfer(
849            &mut coin_change_operations,
850            tx.data,
851            gas_owner,
852            gas_used,
853            &balance_changes,
854        )?;
855
856        let ops: Operations = ops
857            .into_iter()
858            .chain(coin_change_operations)
859            .chain(gascoin_transfer_operations)
860            .chain(staking_balance)
861            .collect();
862
863        // This is a workaround for the payCoin cases that are mistakenly considered to be paySui operations
864        // In this case we remove any irrelevant, SUI specific operation entries that sum up to 0 balance changes per address
865        // and keep only the actual entries for the right coin type transfers, as they have been extracted from the transaction's
866        // balance changes section.
867        let mutually_cancelling_balances: HashMap<_, _> = ops
868            .clone()
869            .into_iter()
870            .fold(
871                HashMap::new(),
872                |mut balances: HashMap<(SuiAddress, Currency), i128>, op| {
873                    if let (Some(acc), Some(amount), Some(OperationStatus::Success)) =
874                        (&op.account, &op.amount, &op.status)
875                        && op.type_ != OperationType::Gas
876                    {
877                        *balances
878                            .entry((acc.address, amount.clone().currency))
879                            .or_default() += amount.value;
880                    }
881                    balances
882                },
883            )
884            .into_iter()
885            .filter(|balance| {
886                let (_, amount) = balance;
887                *amount == 0
888            })
889            .collect();
890
891        let ops: Operations = ops
892            .into_iter()
893            .filter(|op| {
894                if let (Some(acc), Some(amount)) = (&op.account, &op.amount) {
895                    return op.type_ == OperationType::Gas
896                        || !mutually_cancelling_balances
897                            .contains_key(&(acc.address, amount.clone().currency));
898                }
899                true
900            })
901            .collect();
902        Ok(ops)
903    }
904}
905
906fn is_unstake_event(tag: &StructTag) -> bool {
907    tag.address == SUI_SYSTEM_ADDRESS
908        && tag.module.as_ident_str() == ident_str!("validator")
909        && tag.name.as_ident_str() == ident_str!("UnstakingRequestEvent")
910}
911
912impl TryFrom<TransactionData> for Operations {
913    type Error = Error;
914    fn try_from(data: TransactionData) -> Result<Self, Self::Error> {
915        struct NoOpsModuleResolver;
916        impl ModuleResolver for NoOpsModuleResolver {
917            type Error = Error;
918            fn get_module(&self, _id: &ModuleId) -> Result<Option<Vec<u8>>, Self::Error> {
919                Ok(None)
920            }
921        }
922        // Rosetta don't need the call args to be parsed into readable format
923        Ok(Operations::try_from_data(
924            SuiTransactionBlockData::try_from_with_module_cache(data, &&mut NoOpsModuleResolver)?,
925            None,
926        )?)
927    }
928}
929
930#[derive(Deserialize, Serialize, Clone, Debug)]
931pub struct Operation {
932    operation_identifier: OperationIdentifier,
933    #[serde(rename = "type")]
934    pub type_: OperationType,
935    #[serde(default, skip_serializing_if = "Option::is_none")]
936    pub status: Option<OperationStatus>,
937    #[serde(default, skip_serializing_if = "Option::is_none")]
938    pub account: Option<AccountIdentifier>,
939    #[serde(default, skip_serializing_if = "Option::is_none")]
940    pub amount: Option<Amount>,
941    #[serde(default, skip_serializing_if = "Option::is_none")]
942    pub coin_change: Option<CoinChange>,
943    #[serde(default, skip_serializing_if = "Option::is_none")]
944    pub metadata: Option<OperationMetadata>,
945}
946
947impl PartialEq for Operation {
948    fn eq(&self, other: &Self) -> bool {
949        self.operation_identifier == other.operation_identifier
950            && self.type_ == other.type_
951            && self.account == other.account
952            && self.amount == other.amount
953            && self.coin_change == other.coin_change
954            && self.metadata == other.metadata
955    }
956}
957
958#[derive(Deserialize, Serialize, Clone, Debug, Eq, PartialEq)]
959pub enum OperationMetadata {
960    GenericTransaction(SuiTransactionBlockKind),
961    Stake { validator: SuiAddress },
962    WithdrawStake { stake_ids: Vec<ObjectID> },
963}
964
965impl Operation {
966    fn generic_op(
967        status: Option<OperationStatus>,
968        sender: SuiAddress,
969        tx: SuiTransactionBlockKind,
970    ) -> Self {
971        Operation {
972            operation_identifier: Default::default(),
973            type_: (&tx).into(),
974            status,
975            account: Some(sender.into()),
976            amount: None,
977            coin_change: None,
978            metadata: Some(OperationMetadata::GenericTransaction(tx)),
979        }
980    }
981
982    pub fn genesis(index: u64, sender: SuiAddress, coin: GasCoin) -> Self {
983        Operation {
984            operation_identifier: index.into(),
985            type_: OperationType::Genesis,
986            status: Some(OperationStatus::Success),
987            account: Some(sender.into()),
988            amount: Some(Amount::new(coin.value().into(), None)),
989            coin_change: Some(CoinChange {
990                coin_identifier: CoinIdentifier {
991                    identifier: CoinID {
992                        id: *coin.id(),
993                        version: SequenceNumber::new(),
994                    },
995                },
996                coin_action: CoinAction::CoinCreated,
997            }),
998            metadata: None,
999        }
1000    }
1001
1002    fn pay_sui(status: Option<OperationStatus>, address: SuiAddress, amount: i128) -> Self {
1003        Operation {
1004            operation_identifier: Default::default(),
1005            type_: OperationType::PaySui,
1006            status,
1007            account: Some(address.into()),
1008            amount: Some(Amount::new(amount, None)),
1009            coin_change: None,
1010            metadata: None,
1011        }
1012    }
1013
1014    fn pay_coin(
1015        status: Option<OperationStatus>,
1016        address: SuiAddress,
1017        amount: i128,
1018        currency: Option<Currency>,
1019    ) -> Self {
1020        Operation {
1021            operation_identifier: Default::default(),
1022            type_: OperationType::PayCoin,
1023            status,
1024            account: Some(address.into()),
1025            amount: Some(Amount::new(amount, currency)),
1026            coin_change: None,
1027            metadata: None,
1028        }
1029    }
1030
1031    fn balance_change(
1032        status: Option<OperationStatus>,
1033        addr: SuiAddress,
1034        amount: i128,
1035        currency: Currency,
1036    ) -> Self {
1037        Self {
1038            operation_identifier: Default::default(),
1039            type_: OperationType::SuiBalanceChange,
1040            status,
1041            account: Some(addr.into()),
1042            amount: Some(Amount::new(amount, Some(currency))),
1043            coin_change: None,
1044            metadata: None,
1045        }
1046    }
1047    fn gas(addr: SuiAddress, amount: i128) -> Self {
1048        Self {
1049            operation_identifier: Default::default(),
1050            type_: OperationType::Gas,
1051            status: Some(OperationStatus::Success),
1052            account: Some(addr.into()),
1053            amount: Some(Amount::new(amount, None)),
1054            coin_change: None,
1055            metadata: None,
1056        }
1057    }
1058    fn stake_reward(status: Option<OperationStatus>, addr: SuiAddress, amount: i128) -> Self {
1059        Self {
1060            operation_identifier: Default::default(),
1061            type_: OperationType::StakeReward,
1062            status,
1063            account: Some(addr.into()),
1064            amount: Some(Amount::new(amount, None)),
1065            coin_change: None,
1066            metadata: None,
1067        }
1068    }
1069    fn stake_principle(status: Option<OperationStatus>, addr: SuiAddress, amount: i128) -> Self {
1070        Self {
1071            operation_identifier: Default::default(),
1072            type_: OperationType::StakePrinciple,
1073            status,
1074            account: Some(addr.into()),
1075            amount: Some(Amount::new(amount, None)),
1076            coin_change: None,
1077            metadata: None,
1078        }
1079    }
1080}