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_FRAMEWORK_PACKAGE_ID, SUI_SYSTEM_ADDRESS, SUI_SYSTEM_PACKAGE_ID};
34
35use crate::types::internal_operation::{PayCoin, PaySui, Stake, WithdrawStake};
36use crate::types::{
37 AccountIdentifier, Amount, CoinAction, CoinChange, CoinID, CoinIdentifier, Currency,
38 InternalOperation, OperationIdentifier, OperationStatus, OperationType,
39};
40use crate::{CoinMetadataCache, Error, SUI};
41
42#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
43pub struct Operations(Vec<Operation>);
44
45impl FromIterator<Operation> for Operations {
46 fn from_iter<T: IntoIterator<Item = Operation>>(iter: T) -> Self {
47 Operations::new(iter.into_iter().collect())
48 }
49}
50
51impl FromIterator<Vec<Operation>> for Operations {
52 fn from_iter<T: IntoIterator<Item = Vec<Operation>>>(iter: T) -> Self {
53 iter.into_iter().flatten().collect()
54 }
55}
56
57impl IntoIterator for Operations {
58 type Item = Operation;
59 type IntoIter = vec::IntoIter<Operation>;
60 fn into_iter(self) -> Self::IntoIter {
61 self.0.into_iter()
62 }
63}
64
65impl Operations {
66 pub fn new(mut ops: Vec<Operation>) -> Self {
67 for (index, op) in ops.iter_mut().enumerate() {
68 op.operation_identifier = (index as u64).into()
69 }
70 Self(ops)
71 }
72
73 pub fn contains(&self, other: &Operations) -> bool {
74 for (i, other_op) in other.0.iter().enumerate() {
75 if let Some(op) = self.0.get(i) {
76 if op != other_op {
77 return false;
78 }
79 } else {
80 return false;
81 }
82 }
83 true
84 }
85
86 pub fn set_status(mut self, status: Option<OperationStatus>) -> Self {
87 for op in &mut self.0 {
88 op.status = status
89 }
90 self
91 }
92
93 pub fn type_(&self) -> Option<OperationType> {
94 self.0.first().map(|op| op.type_)
95 }
96
97 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 into_balance_passthrough(
367 known_results: &[Vec<KnownValue>],
368 call: &MoveCall,
369 ) -> Option<Vec<KnownValue>> {
370 let args = &call.arguments;
371 if let Some(coin_arg) = args.first() {
372 match coin_arg.kind() {
373 ArgumentKind::Result => {
374 let cmd_idx = coin_arg.result?;
375 let sub_idx = coin_arg.subresult.unwrap_or(0);
376 let KnownValue::GasCoin(val) =
377 resolve_result(known_results, cmd_idx, sub_idx)?;
378 Some(vec![KnownValue::GasCoin(*val)])
379 }
380 _ => Some(vec![KnownValue::GasCoin(0)]),
383 }
384 } else {
385 Some(vec![KnownValue::GasCoin(0)])
386 }
387 }
388 fn send_funds_transfer(
389 aggregated_recipients: &mut HashMap<SuiAddress, u64>,
390 inputs: &[Input],
391 known_results: &[Vec<KnownValue>],
392 call: &MoveCall,
393 sender: SuiAddress,
394 ) -> Option<Vec<KnownValue>> {
395 let args = &call.arguments;
396 if args.len() < 2 {
397 return Some(vec![]);
398 }
399 let balance_arg = &args[0];
400 let recipient_arg = &args[1];
401
402 let amount = match balance_arg.kind() {
404 ArgumentKind::Result => {
405 let cmd_idx = balance_arg.result?;
406 let sub_idx = balance_arg.subresult.unwrap_or(0);
407 let KnownValue::GasCoin(val) = resolve_result(known_results, cmd_idx, sub_idx)?;
408 *val
409 }
410 _ => return Some(vec![]),
411 };
412
413 let addr = match recipient_arg.kind() {
415 ArgumentKind::Input => {
416 let input_idx = recipient_arg.input() as usize;
417 let input = inputs.get(input_idx)?;
418 if input.kind() == InputKind::Pure {
419 bcs::from_bytes::<SuiAddress>(input.pure()).ok()?
420 } else {
421 return Some(vec![]);
422 }
423 }
424 _ => return Some(vec![]),
425 };
426
427 if addr != sender {
429 *aggregated_recipients.entry(addr).or_insert(0) += amount;
430 }
431 Some(vec![])
432 }
433 fn stake_call(
434 inputs: &[Input],
435 known_results: &[Vec<KnownValue>],
436 call: &MoveCall,
437 ) -> Result<Option<(Option<u64>, SuiAddress)>, Error> {
438 let arguments = &call.arguments;
439 let (amount, validator) = match &arguments[..] {
440 [system_state_arg, coin, validator] => {
441 let amount = match coin.kind() {
442 ArgumentKind::Result => {
443 let i = coin
444 .result
445 .ok_or_else(|| anyhow!("Result argument missing index"))?;
446 let KnownValue::GasCoin(value) = resolve_result(known_results, i, 0)
447 .ok_or_else(|| {
448 anyhow!("Cannot resolve Gas coin value at Result({i})")
449 })?;
450 value
451 }
452 _ => return Ok(None),
453 };
454 let system_state_idx = match system_state_arg.kind() {
455 ArgumentKind::Input => system_state_arg.input(),
456 _ => return Ok(None),
457 };
458 let (some_amount, validator) = match validator.kind() {
459 ArgumentKind::Input => {
462 let i = validator.input();
463 let validator_addr = match inputs.get(i as usize) {
464 Some(input) if input.kind() == InputKind::Pure => {
465 bcs::from_bytes::<SuiAddress>(input.pure()).ok()
466 }
467 _ => None,
468 };
469 (i < system_state_idx, Ok(validator_addr))
470 }
471 _ => return Ok(None),
472 };
473 (some_amount.then_some(*amount), validator)
474 }
475 _ => Err(anyhow!(
476 "Error encountered when extracting arguments from move call, expecting 3 elements, got {}",
477 arguments.len()
478 ))?,
479 };
480 validator.map(|v| v.map(|v| (amount, v)))
481 }
482
483 fn unstake_call(inputs: &[Input], call: &MoveCall) -> Result<Option<ObjectID>, Error> {
484 let arguments = &call.arguments;
485 let id = match &arguments[..] {
486 [system_state_arg, stake_id] => match stake_id.kind() {
487 ArgumentKind::Input => {
488 let i = stake_id.input();
489 let id = match inputs.get(i as usize) {
490 Some(input) if input.kind() == InputKind::ImmutableOrOwned => input
491 .object_id
492 .as_ref()
493 .and_then(|oid| ObjectID::from_str(oid).ok()),
494 _ => None,
495 }
496 .ok_or_else(|| anyhow!("Cannot find stake id from input args."))?;
497 let system_state_idx = match system_state_arg.kind() {
500 ArgumentKind::Input => system_state_arg.input(),
501 _ => return Ok(None),
502 };
503 let some_id = system_state_idx < i;
504 some_id.then_some(id)
505 }
506 _ => None,
507 },
508 _ => Err(anyhow!(
509 "Error encountered when extracting arguments from move call, expecting 2 elements, got {}",
510 arguments.len()
511 ))?,
512 };
513 Ok(id)
514 }
515 let inputs = &pt.inputs;
516 let commands = &pt.commands;
517 let mut known_results: Vec<Vec<KnownValue>> = vec![];
518 let mut aggregated_recipients: HashMap<SuiAddress, u64> = HashMap::new();
519 let mut needs_generic = false;
520 let mut operations = vec![];
521 let mut stake_ids = vec![];
522 let mut currency: Option<Currency> = None;
523
524 for command in commands {
525 let result = match &command.command {
526 Some(Command::SplitCoins(split)) => {
527 let coin = split.coin();
528 split_coins(inputs, &known_results, coin, &split.amounts)
529 }
530 Some(Command::TransferObjects(transfer)) => {
531 let addr = transfer.address();
532 transfer_object(
533 &mut aggregated_recipients,
534 inputs,
535 &known_results,
536 &transfer.objects,
537 addr,
538 )
539 }
540 Some(Command::MoveCall(m)) if Self::is_stake_call(m) => {
541 stake_call(inputs, &known_results, m)?.map(|(amount, validator)| {
542 let amount = amount.map(|amount| Amount::new(-(amount as i128), None));
543 operations.push(Operation {
544 operation_identifier: Default::default(),
545 type_: OperationType::Stake,
546 status,
547 account: Some(sender.into()),
548 amount,
549 coin_change: None,
550 metadata: Some(OperationMetadata::Stake { validator }),
551 });
552 vec![]
553 })
554 }
555 Some(Command::MoveCall(m)) if Self::is_unstake_call(m) => {
556 let stake_id = unstake_call(inputs, m)?;
557 stake_ids.push(stake_id);
558 Some(vec![])
559 }
560 Some(Command::MergeCoins(_)) => {
561 Some(vec![])
563 }
564 Some(Command::MoveCall(m)) if Self::is_coin_redeem_funds_call(m) => {
567 Some(vec![KnownValue::GasCoin(0)])
568 }
569 Some(Command::MoveCall(m)) if Self::is_coin_into_balance_call(m) => {
570 into_balance_passthrough(&known_results, m)
571 }
572 Some(Command::MoveCall(m))
573 if Self::is_balance_send_funds_call(m) || Self::is_coin_send_funds_call(m) =>
574 {
575 send_funds_transfer(
576 &mut aggregated_recipients,
577 inputs,
578 &known_results,
579 m,
580 sender,
581 )
582 }
583 Some(Command::MoveCall(m))
584 if Self::is_coin_destroy_zero_call(m) || Self::is_balance_join_call(m) =>
585 {
586 Some(vec![])
587 }
588 _ => None,
589 };
590 if let Some(result) = result {
591 known_results.push(result)
592 } else {
593 needs_generic = true;
594 break;
595 }
596 }
597
598 if !needs_generic && !aggregated_recipients.is_empty() {
599 let total_paid: u64 = aggregated_recipients.values().copied().sum();
600 operations.extend(
601 aggregated_recipients
602 .into_iter()
603 .map(|(recipient, amount)| {
604 currency = inputs.iter().last().and_then(|input| {
605 if input.kind() == InputKind::Pure {
606 let bytes = input.pure();
607 bcs::from_bytes::<String>(bytes).ok().and_then(|json_str| {
608 serde_json::from_str::<Currency>(&json_str).ok()
609 })
610 } else {
611 None
612 }
613 });
614 match currency {
615 Some(_) => Operation::pay_coin(
616 status,
617 recipient,
618 amount.into(),
619 currency.clone(),
620 ),
621 None => Operation::pay_sui(status, recipient, amount.into()),
622 }
623 }),
624 );
625 match currency {
626 Some(_) => operations.push(Operation::pay_coin(
627 status,
628 sender,
629 -(total_paid as i128),
630 currency.clone(),
631 )),
632 _ => operations.push(Operation::pay_sui(status, sender, -(total_paid as i128))),
633 }
634 } else if !stake_ids.is_empty() {
635 let stake_ids = stake_ids.into_iter().flatten().collect::<Vec<_>>();
636 let metadata = stake_ids
637 .is_empty()
638 .not()
639 .then_some(OperationMetadata::WithdrawStake { stake_ids });
640 operations.push(Operation {
641 operation_identifier: Default::default(),
642 type_: OperationType::WithdrawStake,
643 status,
644 account: Some(sender.into()),
645 amount: None,
646 coin_change: None,
647 metadata,
648 });
649 } else if operations.is_empty() {
650 let tx_kind = TransactionKind::default()
651 .with_kind(ProgrammableTransactionKind)
652 .with_programmable_transaction(pt);
653 operations.push(Operation::generic_op(status, sender, tx_kind))
654 }
655 Ok(operations)
656 }
657
658 fn is_stake_call(tx: &MoveCall) -> bool {
659 let package_id = match ObjectID::from_str(tx.package()) {
660 Ok(id) => id,
661 Err(e) => {
662 warn!(
663 package = tx.package(),
664 error = %e,
665 "Failed to parse package ID for MoveCall"
666 );
667 return false;
668 }
669 };
670
671 package_id == SUI_SYSTEM_PACKAGE_ID
672 && tx.module() == SUI_SYSTEM_MODULE_NAME.as_str()
673 && tx.function() == ADD_STAKE_FUN_NAME.as_str()
674 }
675
676 fn is_unstake_call(tx: &MoveCall) -> bool {
677 let package_id = match ObjectID::from_str(tx.package()) {
678 Ok(id) => id,
679 Err(e) => {
680 warn!(
681 package = tx.package(),
682 error = %e,
683 "Failed to parse package ID for MoveCall"
684 );
685 return false;
686 }
687 };
688
689 package_id == SUI_SYSTEM_PACKAGE_ID
690 && tx.module() == SUI_SYSTEM_MODULE_NAME.as_str()
691 && (tx.function() == WITHDRAW_STAKE_FUN_NAME.as_str()
692 || tx.function() == "request_withdraw_stake_non_entry")
693 }
694
695 fn is_coin_redeem_funds_call(tx: &MoveCall) -> bool {
697 let package_id = match ObjectID::from_str(tx.package()) {
698 Ok(id) => id,
699 Err(_) => return false,
700 };
701 package_id == SUI_FRAMEWORK_PACKAGE_ID
702 && tx.module() == "coin"
703 && tx.function() == "redeem_funds"
704 }
705
706 fn is_coin_into_balance_call(tx: &MoveCall) -> bool {
707 let package_id = match ObjectID::from_str(tx.package()) {
708 Ok(id) => id,
709 Err(_) => return false,
710 };
711 package_id == SUI_FRAMEWORK_PACKAGE_ID
712 && tx.module() == "coin"
713 && tx.function() == "into_balance"
714 }
715
716 fn is_balance_send_funds_call(tx: &MoveCall) -> bool {
717 let package_id = match ObjectID::from_str(tx.package()) {
718 Ok(id) => id,
719 Err(_) => return false,
720 };
721 package_id == SUI_FRAMEWORK_PACKAGE_ID
722 && tx.module() == "balance"
723 && tx.function() == "send_funds"
724 }
725
726 fn is_coin_send_funds_call(tx: &MoveCall) -> bool {
727 let package_id = match ObjectID::from_str(tx.package()) {
728 Ok(id) => id,
729 Err(_) => return false,
730 };
731 package_id == SUI_FRAMEWORK_PACKAGE_ID
732 && tx.module() == "coin"
733 && tx.function() == "send_funds"
734 }
735
736 fn is_coin_destroy_zero_call(tx: &MoveCall) -> bool {
737 let package_id = match ObjectID::from_str(tx.package()) {
738 Ok(id) => id,
739 Err(_) => return false,
740 };
741 package_id == SUI_FRAMEWORK_PACKAGE_ID
742 && tx.module() == "coin"
743 && tx.function() == "destroy_zero"
744 }
745
746 fn is_balance_join_call(tx: &MoveCall) -> bool {
747 let package_id = match ObjectID::from_str(tx.package()) {
748 Ok(id) => id,
749 Err(_) => return false,
750 };
751 package_id == SUI_FRAMEWORK_PACKAGE_ID
752 && tx.module() == "balance"
753 && tx.function() == "join"
754 }
755
756 fn process_balance_change(
757 gas_owner: SuiAddress,
758 gas_used: i128,
759 balance_changes: &[(BalanceChange, Currency)],
760 status: Option<OperationStatus>,
761 balances: HashMap<(SuiAddress, Currency), i128>,
762 ) -> impl Iterator<Item = Operation> {
763 let mut balances =
764 balance_changes
765 .iter()
766 .fold(balances, |mut balances, (balance_change, ccy)| {
767 if let (Some(addr_str), Some(amount_str)) =
768 (&balance_change.address, &balance_change.amount)
769 && let (Ok(owner), Ok(amount)) =
770 (SuiAddress::from_str(addr_str), i128::from_str(amount_str))
771 {
772 *balances.entry((owner, ccy.clone())).or_default() += amount;
773 }
774 balances
775 });
776 *balances.entry((gas_owner, SUI.clone())).or_default() -= gas_used;
778
779 let balance_change = balances.into_iter().filter(|(_, amount)| *amount != 0).map(
780 move |((addr, currency), amount)| {
781 Operation::balance_change(status, addr, amount, currency)
782 },
783 );
784
785 let gas = if gas_used != 0 {
786 vec![Operation::gas(gas_owner, gas_used)]
787 } else {
788 vec![]
790 };
791 balance_change.chain(gas)
792 }
793
794 fn is_gascoin_transfer(tx: &TransactionKind) -> bool {
796 if let Some(TransactionKindData::ProgrammableTransaction(pt)) = &tx.data {
797 return pt.commands.iter().any(|command| {
798 if let Some(Command::TransferObjects(transfer)) = &command.command {
799 transfer
800 .objects
801 .iter()
802 .any(|arg| arg.kind() == ArgumentKind::Gas)
803 } else {
804 false
805 }
806 });
807 }
808 false
809 }
810
811 fn add_missing_gas_owner(operations: &mut Vec<Operation>, gas_owner: SuiAddress) {
814 if !operations.iter().any(|operation| {
815 if let Some(amount) = &operation.amount
816 && let Some(account) = &operation.account
817 && account.address == gas_owner
818 && amount.currency == *SUI
819 {
820 return true;
821 }
822 false
823 }) {
824 operations.push(Operation::balance_change(
825 Some(OperationStatus::Success),
826 gas_owner,
827 0,
828 SUI.clone(),
829 ));
830 }
831 }
832
833 fn validate_operations(
836 initial_balance_changes: &[(BalanceChange, Currency)],
837 new_operations: &[Operation],
838 ) -> Result<(), anyhow::Error> {
839 let balances: HashMap<(SuiAddress, Currency), i128> = HashMap::new();
840 let mut initial_balances =
841 initial_balance_changes
842 .iter()
843 .fold(balances, |mut balances, (balance_change, ccy)| {
844 if let (Some(addr_str), Some(amount_str)) =
845 (&balance_change.address, &balance_change.amount)
846 && let (Ok(owner), Ok(amount)) =
847 (SuiAddress::from_str(addr_str), i128::from_str(amount_str))
848 {
849 *balances.entry((owner, ccy.clone())).or_default() += amount;
850 }
851 balances
852 });
853
854 let mut new_balances = HashMap::new();
855 for op in new_operations {
856 if let Some(Amount {
857 currency, value, ..
858 }) = &op.amount
859 {
860 if let Some(account) = &op.account {
861 let balance_change = new_balances
862 .remove(&(account.address, currency.clone()))
863 .unwrap_or(0)
864 + value;
865 new_balances.insert((account.address, currency.clone()), balance_change);
866 } else {
867 return Err(anyhow!("Missing account for a balance-change"));
868 }
869 }
870 }
871
872 for ((address, currency), amount_expected) in new_balances {
873 let new_amount = initial_balances.remove(&(address, currency)).unwrap_or(0);
874 if new_amount != amount_expected {
875 return Err(anyhow!(
876 "Expected {} balance-change for {} but got {}",
877 amount_expected,
878 address,
879 new_amount
880 ));
881 }
882 }
883 if !initial_balances.is_empty() {
884 return Err(anyhow!(
885 "Expected every item in initial_balances to be mapped"
886 ));
887 }
888 Ok(())
889 }
890
891 fn process_gascoin_transfer(
896 coin_change_operations: &mut impl Iterator<Item = Operation>,
897 is_gascoin_transfer: bool,
898 prev_gas_owner: SuiAddress,
899 new_gas_owner: SuiAddress,
900 gas_used: i128,
901 initial_balance_changes: &[(BalanceChange, Currency)],
902 ) -> Result<Vec<Operation>, anyhow::Error> {
903 let mut operations = vec![];
904 if is_gascoin_transfer && prev_gas_owner != new_gas_owner {
905 operations = coin_change_operations.collect();
906 Self::add_missing_gas_owner(&mut operations, prev_gas_owner);
907 Self::add_missing_gas_owner(&mut operations, new_gas_owner);
908 for operation in &mut operations {
909 match operation.type_ {
910 OperationType::Gas => {
911 operation.account = Some(prev_gas_owner.into())
914 }
915 OperationType::SuiBalanceChange => {
916 let account = operation
917 .account
918 .as_ref()
919 .ok_or_else(|| anyhow!("Missing account for a balance-change"))?;
920 let amount = operation
921 .amount
922 .as_mut()
923 .ok_or_else(|| anyhow!("Missing amount for a balance-change"))?;
924 if account.address == prev_gas_owner && amount.currency == *SUI {
926 amount.value -= gas_used;
927 } else if account.address == new_gas_owner && amount.currency == *SUI {
928 amount.value += gas_used;
929 }
930 }
931 _ => {
932 return Err(anyhow!(
933 "Discarding unsupported operation type {:?}",
934 operation.type_
935 ));
936 }
937 }
938 }
939 Self::validate_operations(initial_balance_changes, &operations)?;
940 }
941 Ok(operations)
942 }
943}
944
945impl Operations {
946 pub async fn try_from_executed_transaction(
947 executed_tx: ExecutedTransaction,
948 cache: &CoinMetadataCache,
949 ) -> Result<Self, Error> {
950 let ExecutedTransaction {
951 transaction,
952 effects,
953 events,
954 balance_changes,
955 ..
956 } = executed_tx;
957
958 let transaction = transaction.ok_or_else(|| {
959 Error::DataError("ExecutedTransaction missing transaction".to_string())
960 })?;
961 let effects = effects
962 .ok_or_else(|| Error::DataError("ExecutedTransaction missing effects".to_string()))?;
963
964 let sender = SuiAddress::from_str(transaction.sender())?;
965
966 let gas_owner = if effects.gas_object.is_some() {
967 let gas_object = effects.gas_object();
968 let owner = gas_object.output_owner();
969 SuiAddress::from_str(owner.address())?
970 } else if sender == SuiAddress::ZERO {
971 sender
973 } else {
974 SuiAddress::from_str(transaction.gas_payment().owner())?
977 };
978
979 let gas_summary = effects.gas_used();
980 let gas_used = gas_summary.storage_rebate_opt().unwrap_or(0) as i128
981 - gas_summary.storage_cost_opt().unwrap_or(0) as i128
982 - gas_summary.computation_cost_opt().unwrap_or(0) as i128;
983
984 let status = Some(effects.status().into());
985
986 let prev_gas_owner = SuiAddress::from_str(transaction.gas_payment().owner())?;
987
988 let tx_kind = transaction
989 .kind
990 .ok_or_else(|| Error::DataError("Transaction missing kind".to_string()))?;
991 let is_gascoin_transfer = Self::is_gascoin_transfer(&tx_kind);
992 let ops = Self::new(Self::from_transaction(tx_kind, sender, status)?);
993 let ops = ops.into_iter();
994
995 let mut accounted_balances =
998 ops.as_ref()
999 .iter()
1000 .fold(HashMap::new(), |mut balances, op| {
1001 if let (Some(acc), Some(amount), Some(OperationStatus::Success)) =
1002 (&op.account, &op.amount, &op.status)
1003 {
1004 *balances
1005 .entry((acc.address, amount.clone().currency))
1006 .or_default() -= amount.value;
1007 }
1008 balances
1009 });
1010
1011 let mut principal_amounts = 0;
1012 let mut reward_amounts = 0;
1013
1014 let events = events.as_ref().map(|e| e.events.as_slice()).unwrap_or(&[]);
1016 for event in events {
1017 let event_type = event.event_type();
1018 if let Ok(type_tag) = StructTag::from_str(event_type)
1019 && is_unstake_event(&type_tag)
1020 && let Some(json) = &event.json
1021 && let Some(Kind::StructValue(struct_val)) = &json.kind
1022 {
1023 if let Some(principal_field) = struct_val.fields.get("principal_amount")
1024 && let Some(Kind::StringValue(s)) = &principal_field.kind
1025 && let Ok(amount) = i128::from_str(s)
1026 {
1027 principal_amounts += amount;
1028 }
1029 if let Some(reward_field) = struct_val.fields.get("reward_amount")
1030 && let Some(Kind::StringValue(s)) = &reward_field.kind
1031 && let Ok(amount) = i128::from_str(s)
1032 {
1033 reward_amounts += amount;
1034 }
1035 }
1036 }
1037 let staking_balance = if principal_amounts != 0 {
1038 *accounted_balances.entry((sender, SUI.clone())).or_default() -= principal_amounts;
1039 *accounted_balances.entry((sender, SUI.clone())).or_default() -= reward_amounts;
1040 vec![
1041 Operation::stake_principle(status, sender, principal_amounts),
1042 Operation::stake_reward(status, sender, reward_amounts),
1043 ]
1044 } else {
1045 vec![]
1046 };
1047
1048 let mut balance_changes_with_currency = vec![];
1049
1050 for balance_change in &balance_changes {
1051 let coin_type = balance_change.coin_type();
1052 let type_tag = sui_types::TypeTag::from_str(coin_type)
1053 .map_err(|e| anyhow!("Invalid coin type: {}", e))?;
1054
1055 if let Ok(currency) = cache.get_currency(&type_tag).await
1056 && !currency.symbol.is_empty()
1057 {
1058 balance_changes_with_currency.push((balance_change.clone(), currency));
1059 }
1060 }
1061
1062 let mut coin_change_operations = Self::process_balance_change(
1064 gas_owner,
1065 gas_used,
1066 &balance_changes_with_currency,
1067 status,
1068 accounted_balances.clone(),
1069 );
1070
1071 let gascoin_transfer_operations = Self::process_gascoin_transfer(
1074 &mut coin_change_operations,
1075 is_gascoin_transfer,
1076 prev_gas_owner,
1077 gas_owner,
1078 gas_used,
1079 &balance_changes_with_currency,
1080 )?;
1081
1082 let ops: Operations = ops
1083 .into_iter()
1084 .chain(coin_change_operations)
1085 .chain(gascoin_transfer_operations)
1086 .chain(staking_balance)
1087 .collect();
1088
1089 let mutually_cancelling_balances: HashMap<_, _> = ops
1094 .clone()
1095 .into_iter()
1096 .fold(
1097 HashMap::new(),
1098 |mut balances: HashMap<(SuiAddress, Currency), i128>, op| {
1099 if let (Some(acc), Some(amount), Some(OperationStatus::Success)) =
1100 (&op.account, &op.amount, &op.status)
1101 && op.type_ != OperationType::Gas
1102 {
1103 *balances
1104 .entry((acc.address, amount.clone().currency))
1105 .or_default() += amount.value;
1106 }
1107 balances
1108 },
1109 )
1110 .into_iter()
1111 .filter(|balance| {
1112 let (_, amount) = balance;
1113 *amount == 0
1114 })
1115 .collect();
1116
1117 let ops: Operations = ops
1118 .into_iter()
1119 .filter(|op| {
1120 if let (Some(acc), Some(amount)) = (&op.account, &op.amount) {
1121 return op.type_ == OperationType::Gas
1122 || !mutually_cancelling_balances
1123 .contains_key(&(acc.address, amount.clone().currency));
1124 }
1125 true
1126 })
1127 .collect();
1128 Ok(ops)
1129 }
1130}
1131
1132fn is_unstake_event(tag: &StructTag) -> bool {
1133 tag.address == SUI_SYSTEM_ADDRESS
1134 && tag.module.as_ident_str() == ident_str!("validator")
1135 && tag.name.as_ident_str() == ident_str!("UnstakingRequestEvent")
1136}
1137
1138#[derive(Deserialize, Serialize, Clone, Debug)]
1139pub struct Operation {
1140 operation_identifier: OperationIdentifier,
1141 #[serde(rename = "type")]
1142 pub type_: OperationType,
1143 #[serde(default, skip_serializing_if = "Option::is_none")]
1144 pub status: Option<OperationStatus>,
1145 #[serde(default, skip_serializing_if = "Option::is_none")]
1146 pub account: Option<AccountIdentifier>,
1147 #[serde(default, skip_serializing_if = "Option::is_none")]
1148 pub amount: Option<Amount>,
1149 #[serde(default, skip_serializing_if = "Option::is_none")]
1150 pub coin_change: Option<CoinChange>,
1151 #[serde(default, skip_serializing_if = "Option::is_none")]
1152 pub metadata: Option<OperationMetadata>,
1153}
1154
1155impl PartialEq for Operation {
1156 fn eq(&self, other: &Self) -> bool {
1157 self.operation_identifier == other.operation_identifier
1158 && self.type_ == other.type_
1159 && self.account == other.account
1160 && self.amount == other.amount
1161 && self.coin_change == other.coin_change
1162 && self.metadata == other.metadata
1163 }
1164}
1165
1166#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
1167pub enum OperationMetadata {
1168 GenericTransaction(TransactionKind),
1169 Stake { validator: SuiAddress },
1170 WithdrawStake { stake_ids: Vec<ObjectID> },
1171}
1172
1173impl Operation {
1174 fn generic_op(
1175 status: Option<OperationStatus>,
1176 sender: SuiAddress,
1177 tx: TransactionKind,
1178 ) -> Self {
1179 Operation {
1180 operation_identifier: Default::default(),
1181 type_: (&tx).into(),
1182 status,
1183 account: Some(sender.into()),
1184 amount: None,
1185 coin_change: None,
1186 metadata: Some(OperationMetadata::GenericTransaction(tx)),
1187 }
1188 }
1189
1190 pub fn genesis(index: u64, sender: SuiAddress, coin: GasCoin) -> Self {
1191 Operation {
1192 operation_identifier: index.into(),
1193 type_: OperationType::Genesis,
1194 status: Some(OperationStatus::Success),
1195 account: Some(sender.into()),
1196 amount: Some(Amount::new(coin.value().into(), None)),
1197 coin_change: Some(CoinChange {
1198 coin_identifier: CoinIdentifier {
1199 identifier: CoinID {
1200 id: *coin.id(),
1201 version: SequenceNumber::new(),
1202 },
1203 },
1204 coin_action: CoinAction::CoinCreated,
1205 }),
1206 metadata: None,
1207 }
1208 }
1209
1210 fn pay_sui(status: Option<OperationStatus>, address: SuiAddress, amount: i128) -> Self {
1211 Operation {
1212 operation_identifier: Default::default(),
1213 type_: OperationType::PaySui,
1214 status,
1215 account: Some(address.into()),
1216 amount: Some(Amount::new(amount, None)),
1217 coin_change: None,
1218 metadata: None,
1219 }
1220 }
1221
1222 fn pay_coin(
1223 status: Option<OperationStatus>,
1224 address: SuiAddress,
1225 amount: i128,
1226 currency: Option<Currency>,
1227 ) -> Self {
1228 Operation {
1229 operation_identifier: Default::default(),
1230 type_: OperationType::PayCoin,
1231 status,
1232 account: Some(address.into()),
1233 amount: Some(Amount::new(amount, currency)),
1234 coin_change: None,
1235 metadata: None,
1236 }
1237 }
1238
1239 fn balance_change(
1240 status: Option<OperationStatus>,
1241 addr: SuiAddress,
1242 amount: i128,
1243 currency: Currency,
1244 ) -> Self {
1245 Self {
1246 operation_identifier: Default::default(),
1247 type_: OperationType::SuiBalanceChange,
1248 status,
1249 account: Some(addr.into()),
1250 amount: Some(Amount::new(amount, Some(currency))),
1251 coin_change: None,
1252 metadata: None,
1253 }
1254 }
1255 fn gas(addr: SuiAddress, amount: i128) -> Self {
1256 Self {
1257 operation_identifier: Default::default(),
1258 type_: OperationType::Gas,
1259 status: Some(OperationStatus::Success),
1260 account: Some(addr.into()),
1261 amount: Some(Amount::new(amount, None)),
1262 coin_change: None,
1263 metadata: None,
1264 }
1265 }
1266 fn stake_reward(status: Option<OperationStatus>, addr: SuiAddress, amount: i128) -> Self {
1267 Self {
1268 operation_identifier: Default::default(),
1269 type_: OperationType::StakeReward,
1270 status,
1271 account: Some(addr.into()),
1272 amount: Some(Amount::new(amount, None)),
1273 coin_change: None,
1274 metadata: None,
1275 }
1276 }
1277 fn stake_principle(status: Option<OperationStatus>, addr: SuiAddress, amount: i128) -> Self {
1278 Self {
1279 operation_identifier: Default::default(),
1280 type_: OperationType::StakePrinciple,
1281 status,
1282 account: Some(addr.into()),
1283 amount: Some(Amount::new(amount, None)),
1284 coin_change: None,
1285 metadata: None,
1286 }
1287 }
1288}
1289
1290#[cfg(test)]
1291mod tests {
1292 use super::*;
1293 use crate::SUI;
1294 use crate::types::ConstructionMetadata;
1295 use sui_rpc::proto::sui::rpc::v2::Transaction;
1296 use sui_types::base_types::{ObjectDigest, ObjectID, SequenceNumber, SuiAddress};
1297 use sui_types::programmable_transaction_builder::ProgrammableTransactionBuilder;
1298 use sui_types::transaction::{TEST_ONLY_GAS_UNIT_FOR_TRANSFER, TransactionData};
1299
1300 #[tokio::test]
1301 async fn test_operation_data_parsing_pay_sui() -> Result<(), anyhow::Error> {
1302 let gas = (
1303 ObjectID::random(),
1304 SequenceNumber::new(),
1305 ObjectDigest::random(),
1306 );
1307
1308 let sender = SuiAddress::random_for_testing_only();
1309
1310 let pt = {
1311 let mut builder = ProgrammableTransactionBuilder::new();
1312 builder
1313 .pay_sui(vec![SuiAddress::random_for_testing_only()], vec![10000])
1314 .unwrap();
1315 builder.finish()
1316 };
1317 let gas_price = 10;
1318 let data = TransactionData::new_programmable(
1319 sender,
1320 vec![gas],
1321 pt,
1322 TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
1323 gas_price,
1324 );
1325
1326 let proto_tx: Transaction = data.clone().into();
1327 let ops = Operations::new(Operations::from_transaction(
1328 proto_tx
1329 .kind
1330 .ok_or_else(|| Error::DataError("Transaction missing kind".to_string()))?,
1331 sender,
1332 None,
1333 )?);
1334 ops.0
1335 .iter()
1336 .for_each(|op| assert_eq!(op.type_, OperationType::PaySui));
1337 let metadata = ConstructionMetadata {
1338 sender,
1339 gas_coins: vec![gas],
1340 extra_gas_coins: vec![],
1341 objects: vec![],
1342 party_objects: vec![],
1343 total_coin_value: 0,
1344 gas_price,
1345 budget: TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
1346 currency: None,
1347 address_balance_withdrawal: 0,
1348 epoch: None,
1349 chain_id: None,
1350 };
1351 let parsed_data = ops.into_internal()?.try_into_data(metadata)?;
1352 assert_eq!(data, parsed_data);
1353
1354 Ok(())
1355 }
1356
1357 #[tokio::test]
1358 async fn test_operation_data_parsing_pay_coin() -> Result<(), anyhow::Error> {
1359 use crate::types::internal_operation::pay_coin_pt;
1360
1361 let gas = (
1362 ObjectID::random(),
1363 SequenceNumber::new(),
1364 ObjectDigest::random(),
1365 );
1366
1367 let coin = (
1368 ObjectID::random(),
1369 SequenceNumber::new(),
1370 ObjectDigest::random(),
1371 );
1372
1373 let sender = SuiAddress::random_for_testing_only();
1374 let recipient = SuiAddress::random_for_testing_only();
1375
1376 let pt = pay_coin_pt(sender, vec![recipient], vec![10000], &[coin], &[], 0, &SUI)?;
1377 let gas_price = 10;
1378 let data = TransactionData::new_programmable(
1379 sender,
1380 vec![gas],
1381 pt,
1382 TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
1383 gas_price,
1384 );
1385
1386 let proto_tx: Transaction = data.clone().into();
1387 let ops = Operations::new(Operations::from_transaction(
1388 proto_tx
1389 .kind
1390 .ok_or_else(|| Error::DataError("Transaction missing kind".to_string()))?,
1391 sender,
1392 None,
1393 )?);
1394 ops.0
1395 .iter()
1396 .for_each(|op| assert_eq!(op.type_, OperationType::PayCoin));
1397 let metadata = ConstructionMetadata {
1398 sender,
1399 gas_coins: vec![gas],
1400 extra_gas_coins: vec![],
1401 objects: vec![coin],
1402 party_objects: vec![],
1403 total_coin_value: 0,
1404 gas_price,
1405 budget: TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
1406 currency: Some(SUI.clone()),
1407 address_balance_withdrawal: 0,
1408 epoch: None,
1409 chain_id: None,
1410 };
1411 let parsed_data = ops.into_internal()?.try_into_data(metadata)?;
1412 assert_eq!(data, parsed_data);
1413
1414 Ok(())
1415 }
1416}