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_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 stake_call(
367            inputs: &[Input],
368            known_results: &[Vec<KnownValue>],
369            call: &MoveCall,
370        ) -> Result<Option<(Option<u64>, SuiAddress)>, Error> {
371            let arguments = &call.arguments;
372            let (amount, validator) = match &arguments[..] {
373                [_, coin, validator] => {
374                    let amount = match coin.kind() {
375                        ArgumentKind::Result => {
376                            let i = coin
377                                .result
378                                .ok_or_else(|| anyhow!("Result argument missing index"))?;
379                            let KnownValue::GasCoin(value) = resolve_result(known_results, i, 0)
380                                .ok_or_else(|| {
381                                    anyhow!("Cannot resolve Gas coin value at Result({i})")
382                                })?;
383                            value
384                        }
385                        _ => return Ok(None),
386                    };
387                    let (some_amount, validator) = match validator.kind() {
388                        // [WORKAROUND] - this is a hack to work out if the staking ops is for a selected amount or None amount (whole wallet).
389                        // We use the position of the validator arg as a indicator of if the rosetta stake
390                        // transaction is staking the whole wallet or not, if staking whole wallet,
391                        // we have to omit the amount value in the final operation output.
392                        ArgumentKind::Input => {
393                            let i = validator.input();
394                            let validator_addr = match inputs.get(i as usize) {
395                                Some(input) if input.kind() == InputKind::Pure => {
396                                    bcs::from_bytes::<SuiAddress>(input.pure()).ok()
397                                }
398                                _ => None,
399                            };
400                            (i == 1, Ok(validator_addr))
401                        }
402                        _ => return Ok(None),
403                    };
404                    (some_amount.then_some(*amount), validator)
405                }
406                _ => Err(anyhow!(
407                    "Error encountered when extracting arguments from move call, expecting 3 elements, got {}",
408                    arguments.len()
409                ))?,
410            };
411            validator.map(|v| v.map(|v| (amount, v)))
412        }
413
414        fn unstake_call(inputs: &[Input], call: &MoveCall) -> Result<Option<ObjectID>, Error> {
415            let arguments = &call.arguments;
416            let id = match &arguments[..] {
417                [_, stake_id] => match stake_id.kind() {
418                    ArgumentKind::Input => {
419                        let i = stake_id.input();
420                        let id = match inputs.get(i as usize) {
421                            Some(input) if input.kind() == InputKind::ImmutableOrOwned => input
422                                .object_id
423                                .as_ref()
424                                .and_then(|oid| ObjectID::from_str(oid).ok()),
425                            _ => None,
426                        }
427                        .ok_or_else(|| anyhow!("Cannot find stake id from input args."))?;
428                        // [WORKAROUND] - this is a hack to work out if the withdraw stake ops is for a selected stake or None (all stakes).
429                        // this hack is similar to the one in stake_call.
430                        let some_id = i % 2 == 1;
431                        some_id.then_some(id)
432                    }
433                    _ => None,
434                },
435                _ => Err(anyhow!(
436                    "Error encountered when extracting arguments from move call, expecting 2 elements, got {}",
437                    arguments.len()
438                ))?,
439            };
440            Ok(id)
441        }
442        let inputs = &pt.inputs;
443        let commands = &pt.commands;
444        let mut known_results: Vec<Vec<KnownValue>> = vec![];
445        let mut aggregated_recipients: HashMap<SuiAddress, u64> = HashMap::new();
446        let mut needs_generic = false;
447        let mut operations = vec![];
448        let mut stake_ids = vec![];
449        let mut currency: Option<Currency> = None;
450
451        for command in commands {
452            let result = match &command.command {
453                Some(Command::SplitCoins(split)) => {
454                    let coin = split.coin();
455                    split_coins(inputs, &known_results, coin, &split.amounts)
456                }
457                Some(Command::TransferObjects(transfer)) => {
458                    let addr = transfer.address();
459                    transfer_object(
460                        &mut aggregated_recipients,
461                        inputs,
462                        &known_results,
463                        &transfer.objects,
464                        addr,
465                    )
466                }
467                Some(Command::MoveCall(m)) if Self::is_stake_call(m) => {
468                    stake_call(inputs, &known_results, m)?.map(|(amount, validator)| {
469                        let amount = amount.map(|amount| Amount::new(-(amount as i128), None));
470                        operations.push(Operation {
471                            operation_identifier: Default::default(),
472                            type_: OperationType::Stake,
473                            status,
474                            account: Some(sender.into()),
475                            amount,
476                            coin_change: None,
477                            metadata: Some(OperationMetadata::Stake { validator }),
478                        });
479                        vec![]
480                    })
481                }
482                Some(Command::MoveCall(m)) if Self::is_unstake_call(m) => {
483                    let stake_id = unstake_call(inputs, m)?;
484                    stake_ids.push(stake_id);
485                    Some(vec![])
486                }
487                Some(Command::MergeCoins(_)) => {
488                    // We don't care about merge-coins, we can just skip it.
489                    Some(vec![])
490                }
491                _ => None,
492            };
493            if let Some(result) = result {
494                known_results.push(result)
495            } else {
496                needs_generic = true;
497                break;
498            }
499        }
500
501        if !needs_generic && !aggregated_recipients.is_empty() {
502            let total_paid: u64 = aggregated_recipients.values().copied().sum();
503            operations.extend(
504                aggregated_recipients
505                    .into_iter()
506                    .map(|(recipient, amount)| {
507                        currency = inputs.iter().last().and_then(|input| {
508                            if input.kind() == InputKind::Pure {
509                                let bytes = input.pure();
510                                bcs::from_bytes::<String>(bytes).ok().and_then(|json_str| {
511                                    serde_json::from_str::<Currency>(&json_str).ok()
512                                })
513                            } else {
514                                None
515                            }
516                        });
517                        match currency {
518                            Some(_) => Operation::pay_coin(
519                                status,
520                                recipient,
521                                amount.into(),
522                                currency.clone(),
523                            ),
524                            None => Operation::pay_sui(status, recipient, amount.into()),
525                        }
526                    }),
527            );
528            match currency {
529                Some(_) => operations.push(Operation::pay_coin(
530                    status,
531                    sender,
532                    -(total_paid as i128),
533                    currency.clone(),
534                )),
535                _ => operations.push(Operation::pay_sui(status, sender, -(total_paid as i128))),
536            }
537        } else if !stake_ids.is_empty() {
538            let stake_ids = stake_ids.into_iter().flatten().collect::<Vec<_>>();
539            let metadata = stake_ids
540                .is_empty()
541                .not()
542                .then_some(OperationMetadata::WithdrawStake { stake_ids });
543            operations.push(Operation {
544                operation_identifier: Default::default(),
545                type_: OperationType::WithdrawStake,
546                status,
547                account: Some(sender.into()),
548                amount: None,
549                coin_change: None,
550                metadata,
551            });
552        } else if operations.is_empty() {
553            let tx_kind = TransactionKind::default()
554                .with_kind(ProgrammableTransactionKind)
555                .with_programmable_transaction(pt);
556            operations.push(Operation::generic_op(status, sender, tx_kind))
557        }
558        Ok(operations)
559    }
560
561    fn is_stake_call(tx: &MoveCall) -> bool {
562        let package_id = match ObjectID::from_str(tx.package()) {
563            Ok(id) => id,
564            Err(e) => {
565                warn!(
566                    package = tx.package(),
567                    error = %e,
568                    "Failed to parse package ID for MoveCall"
569                );
570                return false;
571            }
572        };
573
574        package_id == SUI_SYSTEM_PACKAGE_ID
575            && tx.module() == SUI_SYSTEM_MODULE_NAME.as_str()
576            && tx.function() == ADD_STAKE_FUN_NAME.as_str()
577    }
578
579    fn is_unstake_call(tx: &MoveCall) -> bool {
580        let package_id = match ObjectID::from_str(tx.package()) {
581            Ok(id) => id,
582            Err(e) => {
583                warn!(
584                    package = tx.package(),
585                    error = %e,
586                    "Failed to parse package ID for MoveCall"
587                );
588                return false;
589            }
590        };
591
592        package_id == SUI_SYSTEM_PACKAGE_ID
593            && tx.module() == SUI_SYSTEM_MODULE_NAME.as_str()
594            && tx.function() == WITHDRAW_STAKE_FUN_NAME.as_str()
595    }
596
597    fn process_balance_change(
598        gas_owner: SuiAddress,
599        gas_used: i128,
600        balance_changes: &[(BalanceChange, Currency)],
601        status: Option<OperationStatus>,
602        balances: HashMap<(SuiAddress, Currency), i128>,
603    ) -> impl Iterator<Item = Operation> {
604        let mut balances =
605            balance_changes
606                .iter()
607                .fold(balances, |mut balances, (balance_change, ccy)| {
608                    if let (Some(addr_str), Some(amount_str)) =
609                        (&balance_change.address, &balance_change.amount)
610                        && let (Ok(owner), Ok(amount)) =
611                            (SuiAddress::from_str(addr_str), i128::from_str(amount_str))
612                    {
613                        *balances.entry((owner, ccy.clone())).or_default() += amount;
614                    }
615                    balances
616                });
617        // separate gas from balances
618        *balances.entry((gas_owner, SUI.clone())).or_default() -= gas_used;
619
620        let balance_change = balances.into_iter().filter(|(_, amount)| *amount != 0).map(
621            move |((addr, currency), amount)| {
622                Operation::balance_change(status, addr, amount, currency)
623            },
624        );
625
626        let gas = if gas_used != 0 {
627            vec![Operation::gas(gas_owner, gas_used)]
628        } else {
629            // Gas can be 0 for system tx
630            vec![]
631        };
632        balance_change.chain(gas)
633    }
634
635    /// Checks to see if transferObjects is used on GasCoin
636    fn is_gascoin_transfer(tx: &TransactionKind) -> bool {
637        if let Some(TransactionKindData::ProgrammableTransaction(pt)) = &tx.data {
638            return pt.commands.iter().any(|command| {
639                if let Some(Command::TransferObjects(transfer)) = &command.command {
640                    transfer
641                        .objects
642                        .iter()
643                        .any(|arg| arg.kind() == ArgumentKind::Gas)
644                } else {
645                    false
646                }
647            });
648        }
649        false
650    }
651
652    /// Add balance-change with zero amount if the gas owner does not have an entry.
653    /// An entry is required for gas owner because the balance would be adjusted.
654    fn add_missing_gas_owner(operations: &mut Vec<Operation>, gas_owner: SuiAddress) {
655        if !operations.iter().any(|operation| {
656            if let Some(amount) = &operation.amount
657                && let Some(account) = &operation.account
658                && account.address == gas_owner
659                && amount.currency == *SUI
660            {
661                return true;
662            }
663            false
664        }) {
665            operations.push(Operation::balance_change(
666                Some(OperationStatus::Success),
667                gas_owner,
668                0,
669                SUI.clone(),
670            ));
671        }
672    }
673
674    /// Compare initial balance_changes to new_operations and make sure
675    /// the balance-changes stay the same after updating the operations
676    fn validate_operations(
677        initial_balance_changes: &[(BalanceChange, Currency)],
678        new_operations: &[Operation],
679    ) -> Result<(), anyhow::Error> {
680        let balances: HashMap<(SuiAddress, Currency), i128> = HashMap::new();
681        let mut initial_balances =
682            initial_balance_changes
683                .iter()
684                .fold(balances, |mut balances, (balance_change, ccy)| {
685                    if let (Some(addr_str), Some(amount_str)) =
686                        (&balance_change.address, &balance_change.amount)
687                        && let (Ok(owner), Ok(amount)) =
688                            (SuiAddress::from_str(addr_str), i128::from_str(amount_str))
689                    {
690                        *balances.entry((owner, ccy.clone())).or_default() += amount;
691                    }
692                    balances
693                });
694
695        let mut new_balances = HashMap::new();
696        for op in new_operations {
697            if let Some(Amount {
698                currency, value, ..
699            }) = &op.amount
700            {
701                if let Some(account) = &op.account {
702                    let balance_change = new_balances
703                        .remove(&(account.address, currency.clone()))
704                        .unwrap_or(0)
705                        + value;
706                    new_balances.insert((account.address, currency.clone()), balance_change);
707                } else {
708                    return Err(anyhow!("Missing account for a balance-change"));
709                }
710            }
711        }
712
713        for ((address, currency), amount_expected) in new_balances {
714            let new_amount = initial_balances.remove(&(address, currency)).unwrap_or(0);
715            if new_amount != amount_expected {
716                return Err(anyhow!(
717                    "Expected {} balance-change for {} but got {}",
718                    amount_expected,
719                    address,
720                    new_amount
721                ));
722            }
723        }
724        if !initial_balances.is_empty() {
725            return Err(anyhow!(
726                "Expected every item in initial_balances to be mapped"
727            ));
728        }
729        Ok(())
730    }
731
732    /// If GasCoin is transferred as a part of transferObjects, operations need to be
733    /// updated such that:
734    /// 1) gas owner needs to be assigned back to the previous owner
735    /// 2) balances of previous and new gas owners need to be adjusted for the gas
736    fn process_gascoin_transfer(
737        coin_change_operations: &mut impl Iterator<Item = Operation>,
738        is_gascoin_transfer: bool,
739        prev_gas_owner: SuiAddress,
740        new_gas_owner: SuiAddress,
741        gas_used: i128,
742        initial_balance_changes: &[(BalanceChange, Currency)],
743    ) -> Result<Vec<Operation>, anyhow::Error> {
744        let mut operations = vec![];
745        if is_gascoin_transfer && prev_gas_owner != new_gas_owner {
746            operations = coin_change_operations.collect();
747            Self::add_missing_gas_owner(&mut operations, prev_gas_owner);
748            Self::add_missing_gas_owner(&mut operations, new_gas_owner);
749            for operation in &mut operations {
750                match operation.type_ {
751                    OperationType::Gas => {
752                        // change gas account back to the previous owner as it is the one
753                        // who paid for the txn (this is the format Rosetta wants to process)
754                        operation.account = Some(prev_gas_owner.into())
755                    }
756                    OperationType::SuiBalanceChange => {
757                        let account = operation
758                            .account
759                            .as_ref()
760                            .ok_or_else(|| anyhow!("Missing account for a balance-change"))?;
761                        let amount = operation
762                            .amount
763                            .as_mut()
764                            .ok_or_else(|| anyhow!("Missing amount for a balance-change"))?;
765                        // adjust the balances for previous and new gas_owners
766                        if account.address == prev_gas_owner && amount.currency == *SUI {
767                            amount.value -= gas_used;
768                        } else if account.address == new_gas_owner && amount.currency == *SUI {
769                            amount.value += gas_used;
770                        }
771                    }
772                    _ => {
773                        return Err(anyhow!(
774                            "Discarding unsupported operation type {:?}",
775                            operation.type_
776                        ));
777                    }
778                }
779            }
780            Self::validate_operations(initial_balance_changes, &operations)?;
781        }
782        Ok(operations)
783    }
784}
785
786impl Operations {
787    pub async fn try_from_executed_transaction(
788        executed_tx: ExecutedTransaction,
789        cache: &CoinMetadataCache,
790    ) -> Result<Self, Error> {
791        let ExecutedTransaction {
792            transaction,
793            effects,
794            events,
795            balance_changes,
796            ..
797        } = executed_tx;
798
799        let transaction = transaction.ok_or_else(|| {
800            Error::DataError("ExecutedTransaction missing transaction".to_string())
801        })?;
802        let effects = effects
803            .ok_or_else(|| Error::DataError("ExecutedTransaction missing effects".to_string()))?;
804
805        let sender = SuiAddress::from_str(transaction.sender())?;
806
807        let gas_owner = if effects.gas_object.is_some() {
808            let gas_object = effects.gas_object();
809            let owner = gas_object.output_owner();
810            SuiAddress::from_str(owner.address())?
811        } else if sender == SuiAddress::ZERO {
812            // System transactions don't have a gas_object.
813            sender
814        } else {
815            return Err(Error::DataError(format!(
816                "Non-system transaction (sender={}) missing gas_object: {}",
817                sender,
818                transaction.digest()
819            )));
820        };
821
822        let gas_summary = effects.gas_used();
823        let gas_used = gas_summary.storage_rebate_opt().unwrap_or(0) as i128
824            - gas_summary.storage_cost_opt().unwrap_or(0) as i128
825            - gas_summary.computation_cost_opt().unwrap_or(0) as i128;
826
827        let status = Some(effects.status().into());
828
829        let prev_gas_owner = SuiAddress::from_str(transaction.gas_payment().owner())?;
830
831        let tx_kind = transaction
832            .kind
833            .ok_or_else(|| Error::DataError("Transaction missing kind".to_string()))?;
834        let is_gascoin_transfer = Self::is_gascoin_transfer(&tx_kind);
835        let ops = Self::new(Self::from_transaction(tx_kind, sender, status)?);
836        let ops = ops.into_iter();
837
838        // We will need to subtract the operation amounts from the actual balance
839        // change amount extracted from event to prevent double counting.
840        let mut accounted_balances =
841            ops.as_ref()
842                .iter()
843                .fold(HashMap::new(), |mut balances, op| {
844                    if let (Some(acc), Some(amount), Some(OperationStatus::Success)) =
845                        (&op.account, &op.amount, &op.status)
846                    {
847                        *balances
848                            .entry((acc.address, amount.clone().currency))
849                            .or_default() -= amount.value;
850                    }
851                    balances
852                });
853
854        let mut principal_amounts = 0;
855        let mut reward_amounts = 0;
856
857        // Extract balance change from unstake events
858        let events = events.as_ref().map(|e| e.events.as_slice()).unwrap_or(&[]);
859        for event in events {
860            let event_type = event.event_type();
861            if let Ok(type_tag) = StructTag::from_str(event_type)
862                && is_unstake_event(&type_tag)
863                && let Some(json) = &event.json
864                && let Some(Kind::StructValue(struct_val)) = &json.kind
865            {
866                if let Some(principal_field) = struct_val.fields.get("principal_amount")
867                    && let Some(Kind::StringValue(s)) = &principal_field.kind
868                    && let Ok(amount) = i128::from_str(s)
869                {
870                    principal_amounts += amount;
871                }
872                if let Some(reward_field) = struct_val.fields.get("reward_amount")
873                    && let Some(Kind::StringValue(s)) = &reward_field.kind
874                    && let Ok(amount) = i128::from_str(s)
875                {
876                    reward_amounts += amount;
877                }
878            }
879        }
880        let staking_balance = if principal_amounts != 0 {
881            *accounted_balances.entry((sender, SUI.clone())).or_default() -= principal_amounts;
882            *accounted_balances.entry((sender, SUI.clone())).or_default() -= reward_amounts;
883            vec![
884                Operation::stake_principle(status, sender, principal_amounts),
885                Operation::stake_reward(status, sender, reward_amounts),
886            ]
887        } else {
888            vec![]
889        };
890
891        let mut balance_changes_with_currency = vec![];
892
893        for balance_change in &balance_changes {
894            let coin_type = balance_change.coin_type();
895            let type_tag = sui_types::TypeTag::from_str(coin_type)
896                .map_err(|e| anyhow!("Invalid coin type: {}", e))?;
897
898            if let Ok(currency) = cache.get_currency(&type_tag).await
899                && !currency.symbol.is_empty()
900            {
901                balance_changes_with_currency.push((balance_change.clone(), currency));
902            }
903        }
904
905        // Extract coin change operations from balance changes
906        let mut coin_change_operations = Self::process_balance_change(
907            gas_owner,
908            gas_used,
909            &balance_changes_with_currency,
910            status,
911            accounted_balances.clone(),
912        );
913
914        // Take {gas, previous gas owner, new gas owner} out of coin_change_operations
915        // and convert BalanceChange to PaySui when GasCoin is transferred
916        let gascoin_transfer_operations = Self::process_gascoin_transfer(
917            &mut coin_change_operations,
918            is_gascoin_transfer,
919            prev_gas_owner,
920            gas_owner,
921            gas_used,
922            &balance_changes_with_currency,
923        )?;
924
925        let ops: Operations = ops
926            .into_iter()
927            .chain(coin_change_operations)
928            .chain(gascoin_transfer_operations)
929            .chain(staking_balance)
930            .collect();
931
932        // This is a workaround for the payCoin cases that are mistakenly considered to be paySui operations
933        // In this case we remove any irrelevant, SUI specific operation entries that sum up to 0 balance changes per address
934        // and keep only the actual entries for the right coin type transfers, as they have been extracted from the transaction's
935        // balance changes section.
936        let mutually_cancelling_balances: HashMap<_, _> = ops
937            .clone()
938            .into_iter()
939            .fold(
940                HashMap::new(),
941                |mut balances: HashMap<(SuiAddress, Currency), i128>, op| {
942                    if let (Some(acc), Some(amount), Some(OperationStatus::Success)) =
943                        (&op.account, &op.amount, &op.status)
944                        && op.type_ != OperationType::Gas
945                    {
946                        *balances
947                            .entry((acc.address, amount.clone().currency))
948                            .or_default() += amount.value;
949                    }
950                    balances
951                },
952            )
953            .into_iter()
954            .filter(|balance| {
955                let (_, amount) = balance;
956                *amount == 0
957            })
958            .collect();
959
960        let ops: Operations = ops
961            .into_iter()
962            .filter(|op| {
963                if let (Some(acc), Some(amount)) = (&op.account, &op.amount) {
964                    return op.type_ == OperationType::Gas
965                        || !mutually_cancelling_balances
966                            .contains_key(&(acc.address, amount.clone().currency));
967                }
968                true
969            })
970            .collect();
971        Ok(ops)
972    }
973}
974
975fn is_unstake_event(tag: &StructTag) -> bool {
976    tag.address == SUI_SYSTEM_ADDRESS
977        && tag.module.as_ident_str() == ident_str!("validator")
978        && tag.name.as_ident_str() == ident_str!("UnstakingRequestEvent")
979}
980
981#[derive(Deserialize, Serialize, Clone, Debug)]
982pub struct Operation {
983    operation_identifier: OperationIdentifier,
984    #[serde(rename = "type")]
985    pub type_: OperationType,
986    #[serde(default, skip_serializing_if = "Option::is_none")]
987    pub status: Option<OperationStatus>,
988    #[serde(default, skip_serializing_if = "Option::is_none")]
989    pub account: Option<AccountIdentifier>,
990    #[serde(default, skip_serializing_if = "Option::is_none")]
991    pub amount: Option<Amount>,
992    #[serde(default, skip_serializing_if = "Option::is_none")]
993    pub coin_change: Option<CoinChange>,
994    #[serde(default, skip_serializing_if = "Option::is_none")]
995    pub metadata: Option<OperationMetadata>,
996}
997
998impl PartialEq for Operation {
999    fn eq(&self, other: &Self) -> bool {
1000        self.operation_identifier == other.operation_identifier
1001            && self.type_ == other.type_
1002            && self.account == other.account
1003            && self.amount == other.amount
1004            && self.coin_change == other.coin_change
1005            && self.metadata == other.metadata
1006    }
1007}
1008
1009#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
1010pub enum OperationMetadata {
1011    GenericTransaction(TransactionKind),
1012    Stake { validator: SuiAddress },
1013    WithdrawStake { stake_ids: Vec<ObjectID> },
1014}
1015
1016impl Operation {
1017    fn generic_op(
1018        status: Option<OperationStatus>,
1019        sender: SuiAddress,
1020        tx: TransactionKind,
1021    ) -> Self {
1022        Operation {
1023            operation_identifier: Default::default(),
1024            type_: (&tx).into(),
1025            status,
1026            account: Some(sender.into()),
1027            amount: None,
1028            coin_change: None,
1029            metadata: Some(OperationMetadata::GenericTransaction(tx)),
1030        }
1031    }
1032
1033    pub fn genesis(index: u64, sender: SuiAddress, coin: GasCoin) -> Self {
1034        Operation {
1035            operation_identifier: index.into(),
1036            type_: OperationType::Genesis,
1037            status: Some(OperationStatus::Success),
1038            account: Some(sender.into()),
1039            amount: Some(Amount::new(coin.value().into(), None)),
1040            coin_change: Some(CoinChange {
1041                coin_identifier: CoinIdentifier {
1042                    identifier: CoinID {
1043                        id: *coin.id(),
1044                        version: SequenceNumber::new(),
1045                    },
1046                },
1047                coin_action: CoinAction::CoinCreated,
1048            }),
1049            metadata: None,
1050        }
1051    }
1052
1053    fn pay_sui(status: Option<OperationStatus>, address: SuiAddress, amount: i128) -> Self {
1054        Operation {
1055            operation_identifier: Default::default(),
1056            type_: OperationType::PaySui,
1057            status,
1058            account: Some(address.into()),
1059            amount: Some(Amount::new(amount, None)),
1060            coin_change: None,
1061            metadata: None,
1062        }
1063    }
1064
1065    fn pay_coin(
1066        status: Option<OperationStatus>,
1067        address: SuiAddress,
1068        amount: i128,
1069        currency: Option<Currency>,
1070    ) -> Self {
1071        Operation {
1072            operation_identifier: Default::default(),
1073            type_: OperationType::PayCoin,
1074            status,
1075            account: Some(address.into()),
1076            amount: Some(Amount::new(amount, currency)),
1077            coin_change: None,
1078            metadata: None,
1079        }
1080    }
1081
1082    fn balance_change(
1083        status: Option<OperationStatus>,
1084        addr: SuiAddress,
1085        amount: i128,
1086        currency: Currency,
1087    ) -> Self {
1088        Self {
1089            operation_identifier: Default::default(),
1090            type_: OperationType::SuiBalanceChange,
1091            status,
1092            account: Some(addr.into()),
1093            amount: Some(Amount::new(amount, Some(currency))),
1094            coin_change: None,
1095            metadata: None,
1096        }
1097    }
1098    fn gas(addr: SuiAddress, amount: i128) -> Self {
1099        Self {
1100            operation_identifier: Default::default(),
1101            type_: OperationType::Gas,
1102            status: Some(OperationStatus::Success),
1103            account: Some(addr.into()),
1104            amount: Some(Amount::new(amount, None)),
1105            coin_change: None,
1106            metadata: None,
1107        }
1108    }
1109    fn stake_reward(status: Option<OperationStatus>, addr: SuiAddress, amount: i128) -> Self {
1110        Self {
1111            operation_identifier: Default::default(),
1112            type_: OperationType::StakeReward,
1113            status,
1114            account: Some(addr.into()),
1115            amount: Some(Amount::new(amount, None)),
1116            coin_change: None,
1117            metadata: None,
1118        }
1119    }
1120    fn stake_principle(status: Option<OperationStatus>, addr: SuiAddress, amount: i128) -> Self {
1121        Self {
1122            operation_identifier: Default::default(),
1123            type_: OperationType::StakePrinciple,
1124            status,
1125            account: Some(addr.into()),
1126            amount: Some(Amount::new(amount, None)),
1127            coin_change: None,
1128            metadata: None,
1129        }
1130    }
1131}
1132
1133#[cfg(test)]
1134mod tests {
1135    use super::*;
1136    use crate::SUI;
1137    use crate::types::ConstructionMetadata;
1138    use sui_rpc::proto::sui::rpc::v2::Transaction;
1139    use sui_types::base_types::{ObjectDigest, ObjectID, SequenceNumber, SuiAddress};
1140    use sui_types::programmable_transaction_builder::ProgrammableTransactionBuilder;
1141    use sui_types::transaction::{TEST_ONLY_GAS_UNIT_FOR_TRANSFER, TransactionData};
1142
1143    #[tokio::test]
1144    async fn test_operation_data_parsing_pay_sui() -> Result<(), anyhow::Error> {
1145        let gas = (
1146            ObjectID::random(),
1147            SequenceNumber::new(),
1148            ObjectDigest::random(),
1149        );
1150
1151        let sender = SuiAddress::random_for_testing_only();
1152
1153        let pt = {
1154            let mut builder = ProgrammableTransactionBuilder::new();
1155            builder
1156                .pay_sui(vec![SuiAddress::random_for_testing_only()], vec![10000])
1157                .unwrap();
1158            builder.finish()
1159        };
1160        let gas_price = 10;
1161        let data = TransactionData::new_programmable(
1162            sender,
1163            vec![gas],
1164            pt,
1165            TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
1166            gas_price,
1167        );
1168
1169        let proto_tx: Transaction = data.clone().into();
1170        let ops = Operations::new(Operations::from_transaction(
1171            proto_tx
1172                .kind
1173                .ok_or_else(|| Error::DataError("Transaction missing kind".to_string()))?,
1174            sender,
1175            None,
1176        )?);
1177        ops.0
1178            .iter()
1179            .for_each(|op| assert_eq!(op.type_, OperationType::PaySui));
1180        let metadata = ConstructionMetadata {
1181            sender,
1182            gas_coins: vec![gas],
1183            extra_gas_coins: vec![],
1184            objects: vec![],
1185            party_objects: vec![],
1186            total_coin_value: 0,
1187            gas_price,
1188            budget: TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
1189            currency: None,
1190        };
1191        let parsed_data = ops.into_internal()?.try_into_data(metadata)?;
1192        assert_eq!(data, parsed_data);
1193
1194        Ok(())
1195    }
1196
1197    #[tokio::test]
1198    async fn test_operation_data_parsing_pay_coin() -> Result<(), anyhow::Error> {
1199        let gas = (
1200            ObjectID::random(),
1201            SequenceNumber::new(),
1202            ObjectDigest::random(),
1203        );
1204
1205        let coin = (
1206            ObjectID::random(),
1207            SequenceNumber::new(),
1208            ObjectDigest::random(),
1209        );
1210
1211        let sender = SuiAddress::random_for_testing_only();
1212
1213        let pt = {
1214            let mut builder = ProgrammableTransactionBuilder::new();
1215            builder
1216                .pay(
1217                    vec![coin],
1218                    vec![SuiAddress::random_for_testing_only()],
1219                    vec![10000],
1220                )
1221                .unwrap();
1222            // the following is important in order to be able to transfer the coin type info between the various flow steps
1223            builder.pure(serde_json::to_string(&SUI.clone())?)?;
1224            builder.finish()
1225        };
1226        let gas_price = 10;
1227        let data = TransactionData::new_programmable(
1228            sender,
1229            vec![gas],
1230            pt,
1231            TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
1232            gas_price,
1233        );
1234
1235        let proto_tx: Transaction = data.clone().into();
1236        let ops = Operations::new(Operations::from_transaction(
1237            proto_tx
1238                .kind
1239                .ok_or_else(|| Error::DataError("Transaction missing kind".to_string()))?,
1240            sender,
1241            None,
1242        )?);
1243        ops.0
1244            .iter()
1245            .for_each(|op| assert_eq!(op.type_, OperationType::PayCoin));
1246        let metadata = ConstructionMetadata {
1247            sender,
1248            gas_coins: vec![gas],
1249            extra_gas_coins: vec![],
1250            objects: vec![coin],
1251            party_objects: vec![],
1252            total_coin_value: 0,
1253            gas_price,
1254            budget: TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
1255            currency: Some(SUI.clone()),
1256        };
1257        let parsed_data = ops.into_internal()?.try_into_data(metadata)?;
1258        assert_eq!(data, parsed_data);
1259
1260        Ok(())
1261    }
1262}