1use 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 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 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 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 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 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 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 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 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 *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 vec![]
631 };
632 balance_change.chain(gas)
633 }
634
635 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 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 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 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 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 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 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 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 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 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 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 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 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}