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::{
34 SUI_FRAMEWORK_PACKAGE_ID, SUI_SYSTEM_ADDRESS, SUI_SYSTEM_PACKAGE_ID, SUI_SYSTEM_STATE_OBJECT_ID,
35};
36
37use crate::types::internal_operation::{
38 ConsolidateAllStakedSuiToFungible, MergeAndRedeemFungibleStakedSui, PayCoin, PaySui, Stake,
39 WithdrawStake,
40};
41use crate::types::{
42 AccountIdentifier, Amount, CoinAction, CoinChange, CoinID, CoinIdentifier, Currency,
43 InternalOperation, OperationIdentifier, OperationStatus, OperationType, RedeemMode,
44};
45use crate::{CoinMetadataCache, Error, SUI};
46
47#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
48pub struct Operations(Vec<Operation>);
49
50impl FromIterator<Operation> for Operations {
51 fn from_iter<T: IntoIterator<Item = Operation>>(iter: T) -> Self {
52 Operations::new(iter.into_iter().collect())
53 }
54}
55
56impl FromIterator<Vec<Operation>> for Operations {
57 fn from_iter<T: IntoIterator<Item = Vec<Operation>>>(iter: T) -> Self {
58 iter.into_iter().flatten().collect()
59 }
60}
61
62impl IntoIterator for Operations {
63 type Item = Operation;
64 type IntoIter = vec::IntoIter<Operation>;
65 fn into_iter(self) -> Self::IntoIter {
66 self.0.into_iter()
67 }
68}
69
70impl Operations {
71 pub fn new(mut ops: Vec<Operation>) -> Self {
72 for (index, op) in ops.iter_mut().enumerate() {
73 op.operation_identifier = (index as u64).into()
74 }
75 Self(ops)
76 }
77
78 pub fn contains(&self, other: &Operations) -> bool {
79 for (i, other_op) in other.0.iter().enumerate() {
80 if let Some(op) = self.0.get(i) {
81 if op != other_op {
82 return false;
83 }
84 } else {
85 return false;
86 }
87 }
88 true
89 }
90
91 pub fn set_status(mut self, status: Option<OperationStatus>) -> Self {
92 for op in &mut self.0 {
93 op.status = status
94 }
95 self
96 }
97
98 pub fn type_(&self) -> Option<OperationType> {
99 self.0.first().map(|op| op.type_)
100 }
101
102 pub fn into_internal(self) -> Result<InternalOperation, Error> {
104 let type_ = self
105 .type_()
106 .ok_or_else(|| Error::MissingInput("Operation type".into()))?;
107 match type_ {
108 OperationType::PaySui => self.pay_sui_ops_to_internal(),
109 OperationType::PayCoin => self.pay_coin_ops_to_internal(),
110 OperationType::Stake => self.stake_ops_to_internal(),
111 OperationType::WithdrawStake => self.withdraw_stake_ops_to_internal(),
112 OperationType::ConsolidateAllStakedSuiToFungible => {
113 self.consolidate_to_fungible_ops_to_internal()
114 }
115 OperationType::MergeAndRedeemFungibleStakedSui => {
116 self.merge_and_redeem_fss_ops_to_internal()
117 }
118 op => Err(Error::UnsupportedOperation(op)),
119 }
120 }
121
122 fn pay_sui_ops_to_internal(self) -> Result<InternalOperation, Error> {
123 let mut recipients = vec![];
124 let mut amounts = vec![];
125 let mut sender = None;
126 for op in self {
127 if let (Some(amount), Some(account)) = (op.amount.clone(), op.account.clone()) {
128 if amount.value.is_negative() {
129 sender = Some(account.address)
130 } else {
131 recipients.push(account.address);
132 let amount = amount.value.abs();
133 if amount > u64::MAX as i128 {
134 return Err(Error::InvalidInput(
135 "Input amount exceed u64::MAX".to_string(),
136 ));
137 }
138 amounts.push(amount as u64)
139 }
140 }
141 }
142 let sender = sender.ok_or_else(|| Error::MissingInput("Sender address".to_string()))?;
143 Ok(InternalOperation::PaySui(PaySui {
144 sender,
145 recipients,
146 amounts,
147 }))
148 }
149
150 fn pay_coin_ops_to_internal(self) -> Result<InternalOperation, Error> {
151 let mut recipients = vec![];
152 let mut amounts = vec![];
153 let mut sender = None;
154 let mut currency = None;
155 for op in self {
156 if let (Some(amount), Some(account)) = (op.amount.clone(), op.account.clone()) {
157 currency = currency.or(Some(amount.currency));
158 if amount.value.is_negative() {
159 sender = Some(account.address)
160 } else {
161 recipients.push(account.address);
162 let amount = amount.value.abs();
163 if amount > u64::MAX as i128 {
164 return Err(Error::InvalidInput(
165 "Input amount exceed u64::MAX".to_string(),
166 ));
167 }
168 amounts.push(amount as u64)
169 }
170 }
171 }
172 let sender = sender.ok_or_else(|| Error::MissingInput("Sender address".to_string()))?;
173 let currency = currency.ok_or_else(|| Error::MissingInput("Currency".to_string()))?;
174 Ok(InternalOperation::PayCoin(PayCoin {
175 sender,
176 recipients,
177 amounts,
178 currency,
179 }))
180 }
181
182 fn stake_ops_to_internal(self) -> Result<InternalOperation, Error> {
183 let mut ops = self
184 .0
185 .into_iter()
186 .filter(|op| op.type_ == OperationType::Stake)
187 .collect::<Vec<_>>();
188 if ops.len() != 1 {
189 return Err(Error::MalformedOperationError(
190 "Delegation should only have one operation.".into(),
191 ));
192 }
193 let op = ops.pop().unwrap();
195 let sender = op
196 .account
197 .ok_or_else(|| Error::MissingInput("Sender address".to_string()))?
198 .address;
199 let metadata = op
200 .metadata
201 .ok_or_else(|| Error::MissingInput("Stake metadata".to_string()))?;
202
203 let amount = if let Some(amount) = op.amount {
205 if amount.value.is_positive() {
206 return Err(Error::MalformedOperationError(
207 "Stake amount should be negative.".into(),
208 ));
209 }
210 Some(amount.value.unsigned_abs() as u64)
211 } else {
212 None
213 };
214
215 let OperationMetadata::Stake { validator } = metadata else {
216 return Err(Error::InvalidInput(
217 "Cannot find delegation info from metadata.".into(),
218 ));
219 };
220
221 Ok(InternalOperation::Stake(Stake {
222 sender,
223 validator,
224 amount,
225 }))
226 }
227
228 fn withdraw_stake_ops_to_internal(self) -> Result<InternalOperation, Error> {
229 let mut ops = self
230 .0
231 .into_iter()
232 .filter(|op| op.type_ == OperationType::WithdrawStake)
233 .collect::<Vec<_>>();
234 if ops.len() != 1 {
235 return Err(Error::MalformedOperationError(
236 "Delegation should only have one operation.".into(),
237 ));
238 }
239 let op = ops.pop().unwrap();
241 let sender = op
242 .account
243 .ok_or_else(|| Error::MissingInput("Sender address".to_string()))?
244 .address;
245
246 let stake_ids = if let Some(metadata) = op.metadata {
247 let OperationMetadata::WithdrawStake { stake_ids } = metadata else {
248 return Err(Error::InvalidInput(
249 "Cannot find withdraw stake info from metadata.".into(),
250 ));
251 };
252 stake_ids
253 } else {
254 vec![]
255 };
256
257 Ok(InternalOperation::WithdrawStake(WithdrawStake {
258 sender,
259 stake_ids,
260 }))
261 }
262
263 fn consolidate_to_fungible_ops_to_internal(self) -> Result<InternalOperation, Error> {
264 let mut ops = self
265 .0
266 .into_iter()
267 .filter(|op| op.type_ == OperationType::ConsolidateAllStakedSuiToFungible)
268 .collect::<Vec<_>>();
269 if ops.len() != 1 {
270 return Err(Error::MalformedOperationError(
271 "ConsolidateAllStakedSuiToFungible should only have one operation.".into(),
272 ));
273 }
274 let op = ops.pop().unwrap();
275 let sender = op
276 .account
277 .ok_or_else(|| Error::MissingInput("Sender address".to_string()))?
278 .address;
279 let metadata = op.metadata.ok_or_else(|| {
280 Error::MissingInput("ConsolidateAllStakedSuiToFungible metadata".to_string())
281 })?;
282 let OperationMetadata::ConsolidateAllStakedSuiToFungible { validator, .. } = metadata
283 else {
284 return Err(Error::InvalidInput(
285 "Cannot find validator from ConsolidateAllStakedSuiToFungible metadata.".into(),
286 ));
287 };
288 let validator = validator.ok_or_else(|| {
289 Error::MissingInput("validator required for ConsolidateAllStakedSuiToFungible".into())
290 })?;
291 Ok(InternalOperation::ConsolidateAllStakedSuiToFungible(
292 ConsolidateAllStakedSuiToFungible { sender, validator },
293 ))
294 }
295
296 fn merge_and_redeem_fss_ops_to_internal(self) -> Result<InternalOperation, Error> {
297 let mut ops = self
298 .0
299 .into_iter()
300 .filter(|op| op.type_ == OperationType::MergeAndRedeemFungibleStakedSui)
301 .collect::<Vec<_>>();
302 if ops.len() != 1 {
303 return Err(Error::MalformedOperationError(
304 "MergeAndRedeemFungibleStakedSui should only have one operation.".into(),
305 ));
306 }
307 let op = ops.pop().unwrap();
308 let sender = op
309 .account
310 .ok_or_else(|| Error::MissingInput("Sender address".to_string()))?
311 .address;
312 let metadata = op.metadata.ok_or_else(|| {
313 Error::MissingInput("MergeAndRedeemFungibleStakedSui metadata".to_string())
314 })?;
315 let OperationMetadata::MergeAndRedeemFungibleStakedSui {
316 validator,
317 amount,
318 redeem_mode,
319 ..
320 } = metadata
321 else {
322 return Err(Error::InvalidInput(
323 "Cannot find MergeAndRedeemFungibleStakedSui info from metadata.".into(),
324 ));
325 };
326 let validator = validator.ok_or_else(|| {
327 Error::MissingInput("validator required for MergeAndRedeemFungibleStakedSui".into())
328 })?;
329 let redeem_mode = redeem_mode.ok_or_else(|| {
330 Error::MissingInput("redeem_mode required for MergeAndRedeemFungibleStakedSui".into())
331 })?;
332 let amount = match &redeem_mode {
333 RedeemMode::All => None,
334 _ => {
335 let amount_str = amount.ok_or_else(|| {
336 Error::MissingInput("amount required for AtLeast/AtMost mode".to_string())
337 })?;
338 let parsed = amount_str
339 .parse::<u64>()
340 .map_err(|e| Error::InvalidInput(format!("Invalid amount: {}", e)))?;
341 if parsed == 0 {
342 return Err(Error::InvalidInput(
343 "amount must be at least 1 MIST".to_string(),
344 ));
345 }
346 Some(parsed)
347 }
348 };
349 Ok(InternalOperation::MergeAndRedeemFungibleStakedSui(
350 MergeAndRedeemFungibleStakedSui {
351 sender,
352 validator,
353 amount,
354 redeem_mode,
355 },
356 ))
357 }
358
359 pub fn from_transaction(
360 tx: TransactionKind,
361 sender: SuiAddress,
362 status: Option<OperationStatus>,
363 ) -> Result<Vec<Operation>, Error> {
364 let TransactionKind { data, kind, .. } = tx;
365 Ok(match data {
366 Some(TransactionKindData::ProgrammableTransaction(pt))
367 if status != Some(OperationStatus::Failure) =>
368 {
369 Self::parse_programmable_transaction(sender, status, pt)?
370 }
371 data => {
372 let mut tx = TransactionKind::default();
373 tx.data = data;
374 tx.kind = kind;
375 vec![Operation::generic_op(status, sender, tx)]
376 }
377 })
378 }
379
380 fn parse_programmable_transaction(
381 sender: SuiAddress,
382 status: Option<OperationStatus>,
383 pt: ProgrammableTransaction,
384 ) -> Result<Vec<Operation>, Error> {
385 #[derive(Debug)]
386 enum KnownValue {
387 GasCoin(u64),
388 }
389 fn resolve_result(
390 known_results: &[Vec<KnownValue>],
391 i: u32,
392 j: u32,
393 ) -> Option<&KnownValue> {
394 known_results
395 .get(i as usize)
396 .and_then(|inner| inner.get(j as usize))
397 }
398 fn split_coins(
399 inputs: &[Input],
400 known_results: &[Vec<KnownValue>],
401 coin: &Argument,
402 amounts: &[Argument],
403 ) -> Option<Vec<KnownValue>> {
404 match coin.kind() {
405 ArgumentKind::Gas => (),
406 ArgumentKind::Result => {
407 let i = coin.result?;
408 let subresult_idx = coin.subresult.unwrap_or(0);
409 let KnownValue::GasCoin(_) = resolve_result(known_results, i, subresult_idx)?;
410 }
411 ArgumentKind::Input => (),
413 _ => return None,
414 };
415
416 let amounts = amounts
417 .iter()
418 .map(|amount| {
419 let value: u64 = match amount.kind() {
420 ArgumentKind::Input => {
421 let input_idx = amount.input() as usize;
422 let input = inputs.get(input_idx)?;
423 match input.kind() {
424 InputKind::Pure => {
425 let bytes = input.pure();
426 bcs::from_bytes(bytes).ok()?
427 }
428 _ => return None,
429 }
430 }
431 _ => return None,
432 };
433 Some(KnownValue::GasCoin(value))
434 })
435 .collect::<Option<_>>()?;
436 Some(amounts)
437 }
438 fn transfer_object(
439 aggregated_recipients: &mut HashMap<SuiAddress, u64>,
440 inputs: &[Input],
441 known_results: &[Vec<KnownValue>],
442 objs: &[Argument],
443 recipient: &Argument,
444 ) -> Option<Vec<KnownValue>> {
445 let addr = match recipient.kind() {
446 ArgumentKind::Input => {
447 let input_idx = recipient.input() as usize;
448 let input = inputs.get(input_idx)?;
449 match input.kind() {
450 InputKind::Pure => {
451 let bytes = input.pure();
452 bcs::from_bytes::<SuiAddress>(bytes).ok()?
453 }
454 _ => return None,
455 }
456 }
457 _ => return None,
458 };
459 for obj in objs {
460 let i = match obj.kind() {
461 ArgumentKind::Result => obj.result(),
462 _ => return None,
463 };
464
465 let subresult_idx = obj.subresult.unwrap_or(0);
466 let KnownValue::GasCoin(value) = resolve_result(known_results, i, subresult_idx)?;
467
468 let aggregate = aggregated_recipients.entry(addr).or_default();
469 *aggregate += value;
470 }
471 Some(vec![])
472 }
473 fn into_balance_passthrough(
474 known_results: &[Vec<KnownValue>],
475 call: &MoveCall,
476 ) -> Option<Vec<KnownValue>> {
477 let args = &call.arguments;
478 if let Some(coin_arg) = args.first() {
479 match coin_arg.kind() {
480 ArgumentKind::Result => {
481 let cmd_idx = coin_arg.result?;
482 let sub_idx = coin_arg.subresult.unwrap_or(0);
483 let KnownValue::GasCoin(val) =
484 resolve_result(known_results, cmd_idx, sub_idx)?;
485 Some(vec![KnownValue::GasCoin(*val)])
486 }
487 _ => Some(vec![KnownValue::GasCoin(0)]),
490 }
491 } else {
492 Some(vec![KnownValue::GasCoin(0)])
493 }
494 }
495 fn send_funds_transfer(
496 aggregated_recipients: &mut HashMap<SuiAddress, u64>,
497 inputs: &[Input],
498 known_results: &[Vec<KnownValue>],
499 call: &MoveCall,
500 sender: SuiAddress,
501 ) -> Option<Vec<KnownValue>> {
502 let args = &call.arguments;
503 if args.len() < 2 {
504 return Some(vec![]);
505 }
506 let balance_arg = &args[0];
507 let recipient_arg = &args[1];
508
509 let amount = match balance_arg.kind() {
511 ArgumentKind::Result => {
512 let cmd_idx = balance_arg.result?;
513 let sub_idx = balance_arg.subresult.unwrap_or(0);
514 let KnownValue::GasCoin(val) = resolve_result(known_results, cmd_idx, sub_idx)?;
515 *val
516 }
517 _ => return Some(vec![]),
518 };
519
520 let addr = match recipient_arg.kind() {
522 ArgumentKind::Input => {
523 let input_idx = recipient_arg.input() as usize;
524 let input = inputs.get(input_idx)?;
525 if input.kind() == InputKind::Pure {
526 bcs::from_bytes::<SuiAddress>(input.pure()).ok()?
527 } else {
528 return Some(vec![]);
529 }
530 }
531 _ => return Some(vec![]),
532 };
533
534 if addr != sender {
536 *aggregated_recipients.entry(addr).or_insert(0) += amount;
537 }
538 Some(vec![])
539 }
540 fn stake_call(
541 inputs: &[Input],
542 known_results: &[Vec<KnownValue>],
543 call: &MoveCall,
544 ) -> Result<Option<(Option<u64>, SuiAddress)>, Error> {
545 let arguments = &call.arguments;
546 let (amount, validator) = match &arguments[..] {
547 [system_state_arg, coin, validator] => {
548 let amount = match coin.kind() {
549 ArgumentKind::Result => {
550 let i = coin
551 .result
552 .ok_or_else(|| anyhow!("Result argument missing index"))?;
553 let KnownValue::GasCoin(value) = resolve_result(known_results, i, 0)
554 .ok_or_else(|| {
555 anyhow!("Cannot resolve Gas coin value at Result({i})")
556 })?;
557 value
558 }
559 _ => return Ok(None),
560 };
561 let system_state_idx = match system_state_arg.kind() {
562 ArgumentKind::Input => system_state_arg.input(),
563 _ => return Ok(None),
564 };
565 let (some_amount, validator) = match validator.kind() {
566 ArgumentKind::Input => {
569 let i = validator.input();
570 let validator_addr = match inputs.get(i as usize) {
571 Some(input) if input.kind() == InputKind::Pure => {
572 bcs::from_bytes::<SuiAddress>(input.pure()).ok()
573 }
574 _ => None,
575 };
576 (i < system_state_idx, Ok(validator_addr))
577 }
578 _ => return Ok(None),
579 };
580 (some_amount.then_some(*amount), validator)
581 }
582 _ => Err(anyhow!(
583 "Error encountered when extracting arguments from move call, expecting 3 elements, got {}",
584 arguments.len()
585 ))?,
586 };
587 validator.map(|v| v.map(|v| (amount, v)))
588 }
589
590 fn unstake_call(inputs: &[Input], call: &MoveCall) -> Result<Option<ObjectID>, Error> {
591 let arguments = &call.arguments;
592 let id = match &arguments[..] {
593 [system_state_arg, stake_id] => match stake_id.kind() {
594 ArgumentKind::Input => {
595 let i = stake_id.input();
596 let id = match inputs.get(i as usize) {
597 Some(input) if input.kind() == InputKind::ImmutableOrOwned => input
598 .object_id
599 .as_ref()
600 .and_then(|oid| ObjectID::from_str(oid).ok()),
601 _ => None,
602 }
603 .ok_or_else(|| anyhow!("Cannot find stake id from input args."))?;
604 let system_state_idx = match system_state_arg.kind() {
607 ArgumentKind::Input => system_state_arg.input(),
608 _ => return Ok(None),
609 };
610 let some_id = system_state_idx < i;
611 some_id.then_some(id)
612 }
613 _ => None,
614 },
615 _ => Err(anyhow!(
616 "Error encountered when extracting arguments from move call, expecting 2 elements, got {}",
617 arguments.len()
618 ))?,
619 };
620 Ok(id)
621 }
622 let inputs = &pt.inputs;
623 let commands = &pt.commands;
624 let mut known_results: Vec<Vec<KnownValue>> = vec![];
625 let mut aggregated_recipients: HashMap<SuiAddress, u64> = HashMap::new();
626 let mut needs_generic = false;
627 let mut operations = vec![];
628 let mut stake_ids = vec![];
629 let mut currency: Option<Currency> = None;
630
631 let has_redeem_fss = commands.iter().any(|c| {
636 matches!(
637 &c.command,
638 Some(Command::MoveCall(m)) if Self::is_redeem_fss_call(m)
639 )
640 });
641 let has_convert_fss = commands.iter().any(|c| {
642 matches!(
643 &c.command,
644 Some(Command::MoveCall(m)) if Self::is_convert_to_fss_call(m)
645 )
646 });
647 let has_join_fss = commands.iter().any(|c| {
648 matches!(
649 &c.command,
650 Some(Command::MoveCall(m)) if Self::is_join_fss_call(m)
651 )
652 });
653 if has_redeem_fss
654 && let Some(ops) = Self::parse_merge_and_redeem(sender, inputs, commands, status)
655 {
656 return Ok(ops);
657 }
658 if !has_redeem_fss
659 && (has_convert_fss || has_join_fss)
660 && let Some(ops) = Self::parse_consolidate(sender, inputs, commands, status)
661 {
662 return Ok(ops);
663 }
664 for command in commands {
668 let result = match &command.command {
669 Some(Command::SplitCoins(split)) => {
670 let coin = split.coin();
671 split_coins(inputs, &known_results, coin, &split.amounts)
672 }
673 Some(Command::TransferObjects(transfer)) => {
674 let addr = transfer.address();
675 transfer_object(
676 &mut aggregated_recipients,
677 inputs,
678 &known_results,
679 &transfer.objects,
680 addr,
681 )
682 }
683 Some(Command::MoveCall(m)) if Self::is_stake_call(m) => {
684 stake_call(inputs, &known_results, m)?.map(|(amount, validator)| {
685 let amount = amount.map(|amount| Amount::new(-(amount as i128), None));
686 operations.push(Operation {
687 operation_identifier: Default::default(),
688 type_: OperationType::Stake,
689 status,
690 account: Some(sender.into()),
691 amount,
692 coin_change: None,
693 metadata: Some(OperationMetadata::Stake { validator }),
694 });
695 vec![]
696 })
697 }
698 Some(Command::MoveCall(m)) if Self::is_unstake_call(m) => {
699 let stake_id = unstake_call(inputs, m)?;
700 stake_ids.push(stake_id);
701 Some(vec![])
702 }
703 Some(Command::MergeCoins(_)) => {
704 Some(vec![])
706 }
707 Some(Command::MoveCall(m)) if Self::is_coin_redeem_funds_call(m) => {
710 Some(vec![KnownValue::GasCoin(0)])
711 }
712 Some(Command::MoveCall(m)) if Self::is_coin_into_balance_call(m) => {
713 into_balance_passthrough(&known_results, m)
714 }
715 Some(Command::MoveCall(m))
716 if Self::is_balance_send_funds_call(m) || Self::is_coin_send_funds_call(m) =>
717 {
718 send_funds_transfer(
719 &mut aggregated_recipients,
720 inputs,
721 &known_results,
722 m,
723 sender,
724 )
725 }
726 Some(Command::MoveCall(m))
727 if Self::is_coin_destroy_zero_call(m) || Self::is_balance_join_call(m) =>
728 {
729 Some(vec![])
730 }
731 _ => None,
732 };
733 if let Some(result) = result {
734 known_results.push(result)
735 } else {
736 needs_generic = true;
737 break;
738 }
739 }
740
741 if !needs_generic && !aggregated_recipients.is_empty() {
742 let total_paid: u64 = aggregated_recipients.values().copied().sum();
743 operations.extend(
744 aggregated_recipients
745 .into_iter()
746 .map(|(recipient, amount)| {
747 currency = inputs.iter().last().and_then(|input| {
748 if input.kind() == InputKind::Pure {
749 let bytes = input.pure();
750 bcs::from_bytes::<String>(bytes).ok().and_then(|json_str| {
751 serde_json::from_str::<Currency>(&json_str).ok()
752 })
753 } else {
754 None
755 }
756 });
757 match currency {
758 Some(_) => Operation::pay_coin(
759 status,
760 recipient,
761 amount.into(),
762 currency.clone(),
763 ),
764 None => Operation::pay_sui(status, recipient, amount.into()),
765 }
766 }),
767 );
768 match currency {
769 Some(_) => operations.push(Operation::pay_coin(
770 status,
771 sender,
772 -(total_paid as i128),
773 currency.clone(),
774 )),
775 _ => operations.push(Operation::pay_sui(status, sender, -(total_paid as i128))),
776 }
777 } else if !stake_ids.is_empty() {
778 let stake_ids = stake_ids.into_iter().flatten().collect::<Vec<_>>();
779 let metadata = stake_ids
780 .is_empty()
781 .not()
782 .then_some(OperationMetadata::WithdrawStake { stake_ids });
783 operations.push(Operation {
784 operation_identifier: Default::default(),
785 type_: OperationType::WithdrawStake,
786 status,
787 account: Some(sender.into()),
788 amount: None,
789 coin_change: None,
790 metadata,
791 });
792 } else if operations.is_empty() {
793 let tx_kind = TransactionKind::default()
794 .with_kind(ProgrammableTransactionKind)
795 .with_programmable_transaction(pt);
796 operations.push(Operation::generic_op(status, sender, tx_kind))
797 }
798 Ok(operations)
799 }
800
801 fn parse_consolidate(
810 sender: SuiAddress,
811 inputs: &[Input],
812 commands: &[sui_rpc::proto::sui::rpc::v2::Command],
813 status: Option<OperationStatus>,
814 ) -> Option<Vec<Operation>> {
815 use std::collections::BTreeSet;
816
817 if !Self::first_input_is_sui_system_state(inputs) {
818 return None;
819 }
820
821 let mut staked_sui_indices: Vec<u32> = Vec::new();
822 let mut fss_indices: Vec<u32> = Vec::new();
823 let mut staked_seen: BTreeSet<u32> = BTreeSet::new();
824 let mut fss_seen: BTreeSet<u32> = BTreeSet::new();
825 let mut saw_transfer = false;
826
827 for (idx, command) in commands.iter().enumerate() {
828 if saw_transfer {
829 return None;
830 }
831 match &command.command {
832 Some(Command::MoveCall(m)) if Self::is_convert_to_fss_call(m) => {
833 if m.arguments.len() != 2 {
834 return None;
835 }
836 if m.arguments[0].kind() != ArgumentKind::Input || m.arguments[0].input() != 0 {
839 return None;
840 }
841 let staked_arg = &m.arguments[1];
842 if staked_arg.kind() != ArgumentKind::Input {
843 return None;
844 }
845 let i = staked_arg.input();
846 if fss_seen.contains(&i) {
847 return None;
848 }
849 if staked_seen.insert(i) {
850 staked_sui_indices.push(i);
851 }
852 }
853 Some(Command::MoveCall(m)) if Self::is_join_fss_call(m) => {
854 if m.arguments.len() != 2 {
855 return None;
856 }
857 for arg in &m.arguments {
858 match arg.kind() {
859 ArgumentKind::Input => {
860 let i = arg.input();
861 if staked_seen.contains(&i) {
862 return None;
863 }
864 if fss_seen.insert(i) {
865 fss_indices.push(i);
866 }
867 }
868 ArgumentKind::Result => {}
869 _ => return None,
870 }
871 }
872 }
873 Some(Command::TransferObjects(transfer)) => {
874 if transfer.objects.len() != 1 {
875 return None;
876 }
877 if transfer.objects[0].kind() != ArgumentKind::Result {
878 return None;
879 }
880 let addr_arg = transfer.address();
881 if addr_arg.kind() != ArgumentKind::Input {
882 return None;
883 }
884 let recipient = inputs.get(addr_arg.input() as usize).and_then(|inp| {
885 if inp.kind() == InputKind::Pure {
886 bcs::from_bytes::<SuiAddress>(inp.pure()).ok()
887 } else {
888 None
889 }
890 })?;
891 if recipient != sender {
892 return None;
893 }
894 if idx + 1 != commands.len() {
895 return None;
896 }
897 saw_transfer = true;
898 }
899 _ => return None,
900 }
901 }
902
903 if staked_sui_indices.is_empty() && fss_indices.is_empty() {
904 return None;
905 }
906
907 let expect_transfer = !staked_sui_indices.is_empty() && fss_indices.is_empty();
913 if expect_transfer != saw_transfer {
914 return None;
915 }
916
917 let staked_sui_ids = Self::input_indices_to_object_ids(inputs, &staked_sui_indices)?;
918 let fss_ids = Self::input_indices_to_object_ids(inputs, &fss_indices)?;
919
920 Some(vec![Operation {
921 operation_identifier: Default::default(),
922 type_: OperationType::ConsolidateAllStakedSuiToFungible,
923 status,
924 account: Some(sender.into()),
925 amount: None,
926 coin_change: None,
927 metadata: Some(OperationMetadata::ConsolidateAllStakedSuiToFungible {
928 validator: None,
929 staked_sui_ids,
930 fss_ids,
931 }),
932 }])
933 }
934
935 fn parse_merge_and_redeem(
948 sender: SuiAddress,
949 inputs: &[Input],
950 commands: &[sui_rpc::proto::sui::rpc::v2::Command],
951 status: Option<OperationStatus>,
952 ) -> Option<Vec<Operation>> {
953 use std::collections::BTreeSet;
954
955 if !Self::first_input_is_sui_system_state(inputs) {
956 return None;
957 }
958
959 #[derive(PartialEq, Eq)]
960 enum Phase {
961 Joins,
962 AfterSplit,
963 AfterRedeem,
964 AfterFromBalance,
965 Done,
966 }
967
968 let mut phase = Phase::Joins;
969 let mut fss_indices: Vec<u32> = Vec::new();
970 let mut fss_seen: BTreeSet<u32> = BTreeSet::new();
971 let mut has_split = false;
972
973 for (idx, command) in commands.iter().enumerate() {
974 if phase == Phase::Done {
975 return None;
976 }
977 match &command.command {
978 Some(Command::MoveCall(m)) if Self::is_join_fss_call(m) => {
979 if phase != Phase::Joins {
980 return None;
981 }
982 if m.arguments.len() != 2 {
983 return None;
984 }
985 for arg in &m.arguments {
986 match arg.kind() {
987 ArgumentKind::Input => {
988 let i = arg.input();
989 if fss_seen.insert(i) {
990 fss_indices.push(i);
991 }
992 }
993 ArgumentKind::Result => {}
994 _ => return None,
995 }
996 }
997 }
998 Some(Command::MoveCall(m)) if Self::is_split_fss_call(m) => {
999 if phase != Phase::Joins {
1000 return None;
1001 }
1002 if m.arguments.len() != 2 {
1003 return None;
1004 }
1005 let first = &m.arguments[0];
1007 match first.kind() {
1008 ArgumentKind::Input => {
1009 let i = first.input();
1010 if fss_seen.insert(i) {
1011 fss_indices.push(i);
1012 }
1013 }
1014 ArgumentKind::Result => {}
1015 _ => return None,
1016 }
1017 if m.arguments[1].kind() != ArgumentKind::Input {
1020 return None;
1021 }
1022 let amount_idx = m.arguments[1].input() as usize;
1023 if inputs.get(amount_idx).map(|i| i.kind()) != Some(InputKind::Pure) {
1024 return None;
1025 }
1026 has_split = true;
1027 phase = Phase::AfterSplit;
1028 }
1029 Some(Command::MoveCall(m)) if Self::is_redeem_fss_call(m) => {
1030 if phase != Phase::Joins && phase != Phase::AfterSplit {
1031 return None;
1032 }
1033 if m.arguments.len() != 2 {
1034 return None;
1035 }
1036 if m.arguments[0].kind() != ArgumentKind::Input || m.arguments[0].input() != 0 {
1038 return None;
1039 }
1040 let fss_arg = &m.arguments[1];
1041 match fss_arg.kind() {
1042 ArgumentKind::Input => {
1043 let i = fss_arg.input();
1044 if fss_seen.insert(i) {
1045 fss_indices.push(i);
1046 }
1047 }
1048 ArgumentKind::Result => {}
1049 _ => return None,
1050 }
1051 phase = Phase::AfterRedeem;
1052 }
1053 Some(Command::MoveCall(m)) if Self::is_coin_from_balance_sui_call(m) => {
1054 if phase != Phase::AfterRedeem {
1055 return None;
1056 }
1057 phase = Phase::AfterFromBalance;
1058 }
1059 Some(Command::TransferObjects(transfer)) => {
1060 if phase != Phase::AfterFromBalance {
1061 return None;
1062 }
1063 if transfer.objects.len() != 1 {
1064 return None;
1065 }
1066 if transfer.objects[0].kind() != ArgumentKind::Result {
1067 return None;
1068 }
1069 let addr_arg = transfer.address();
1070 if addr_arg.kind() != ArgumentKind::Input {
1071 return None;
1072 }
1073 let recipient = inputs.get(addr_arg.input() as usize).and_then(|inp| {
1074 if inp.kind() == InputKind::Pure {
1075 bcs::from_bytes::<SuiAddress>(inp.pure()).ok()
1076 } else {
1077 None
1078 }
1079 })?;
1080 if recipient != sender {
1081 return None;
1082 }
1083 if idx + 1 != commands.len() {
1084 return None;
1085 }
1086 phase = Phase::Done;
1087 }
1088 _ => return None,
1089 }
1090 }
1091
1092 if phase != Phase::Done {
1093 return None;
1094 }
1095 if fss_indices.is_empty() {
1096 return None;
1097 }
1098
1099 let fss_ids = Self::input_indices_to_object_ids(inputs, &fss_indices)?;
1100 let redeem_mode = if has_split {
1101 None
1102 } else {
1103 Some(RedeemMode::All)
1104 };
1105
1106 Some(vec![Operation {
1107 operation_identifier: Default::default(),
1108 type_: OperationType::MergeAndRedeemFungibleStakedSui,
1109 status,
1110 account: Some(sender.into()),
1111 amount: None,
1112 coin_change: None,
1113 metadata: Some(OperationMetadata::MergeAndRedeemFungibleStakedSui {
1114 validator: None,
1115 amount: None,
1116 redeem_mode,
1117 fss_ids,
1118 }),
1119 }])
1120 }
1121
1122 fn first_input_is_sui_system_state(inputs: &[Input]) -> bool {
1129 let Some(first) = inputs.first() else {
1130 return false;
1131 };
1132 if first.kind() != InputKind::Shared {
1133 return false;
1134 }
1135 let Some(oid_str) = first.object_id.as_ref() else {
1136 return false;
1137 };
1138 let Ok(oid) = ObjectID::from_str(oid_str) else {
1139 return false;
1140 };
1141 oid == SUI_SYSTEM_STATE_OBJECT_ID
1142 }
1143
1144 fn input_indices_to_object_ids(inputs: &[Input], indices: &[u32]) -> Option<Vec<ObjectID>> {
1147 indices
1148 .iter()
1149 .map(|&i| {
1150 let inp = inputs.get(i as usize)?;
1151 if inp.kind() != InputKind::ImmutableOrOwned {
1152 return None;
1153 }
1154 ObjectID::from_str(inp.object_id.as_ref()?).ok()
1155 })
1156 .collect()
1157 }
1158
1159 fn is_stake_call(tx: &MoveCall) -> bool {
1160 let package_id = match ObjectID::from_str(tx.package()) {
1161 Ok(id) => id,
1162 Err(e) => {
1163 warn!(
1164 package = tx.package(),
1165 error = %e,
1166 "Failed to parse package ID for MoveCall"
1167 );
1168 return false;
1169 }
1170 };
1171
1172 package_id == SUI_SYSTEM_PACKAGE_ID
1173 && tx.module() == SUI_SYSTEM_MODULE_NAME.as_str()
1174 && tx.function() == ADD_STAKE_FUN_NAME.as_str()
1175 }
1176
1177 fn is_unstake_call(tx: &MoveCall) -> bool {
1178 let package_id = match ObjectID::from_str(tx.package()) {
1179 Ok(id) => id,
1180 Err(e) => {
1181 warn!(
1182 package = tx.package(),
1183 error = %e,
1184 "Failed to parse package ID for MoveCall"
1185 );
1186 return false;
1187 }
1188 };
1189
1190 package_id == SUI_SYSTEM_PACKAGE_ID
1191 && tx.module() == SUI_SYSTEM_MODULE_NAME.as_str()
1192 && (tx.function() == WITHDRAW_STAKE_FUN_NAME.as_str()
1193 || tx.function() == "request_withdraw_stake_non_entry")
1194 }
1195
1196 fn is_convert_to_fss_call(tx: &MoveCall) -> bool {
1199 let package_id = match ObjectID::from_str(tx.package()) {
1200 Ok(id) => id,
1201 Err(e) => {
1202 warn!(
1203 package = tx.package(),
1204 error = %e,
1205 "Failed to parse package ID for MoveCall"
1206 );
1207 return false;
1208 }
1209 };
1210 package_id == SUI_SYSTEM_PACKAGE_ID
1211 && tx.module() == SUI_SYSTEM_MODULE_NAME.as_str()
1212 && tx.function() == "convert_to_fungible_staked_sui"
1213 }
1214
1215 fn is_join_fss_call(tx: &MoveCall) -> bool {
1219 let package_id = match ObjectID::from_str(tx.package()) {
1220 Ok(id) => id,
1221 Err(e) => {
1222 warn!(
1223 package = tx.package(),
1224 error = %e,
1225 "Failed to parse package ID for MoveCall"
1226 );
1227 return false;
1228 }
1229 };
1230 package_id == SUI_SYSTEM_PACKAGE_ID
1231 && tx.module() == "staking_pool"
1232 && tx.function() == "join_fungible_staked_sui"
1233 }
1234
1235 fn is_redeem_fss_call(tx: &MoveCall) -> bool {
1238 let package_id = match ObjectID::from_str(tx.package()) {
1239 Ok(id) => id,
1240 Err(e) => {
1241 warn!(
1242 package = tx.package(),
1243 error = %e,
1244 "Failed to parse package ID for MoveCall"
1245 );
1246 return false;
1247 }
1248 };
1249 package_id == SUI_SYSTEM_PACKAGE_ID
1250 && tx.module() == SUI_SYSTEM_MODULE_NAME.as_str()
1251 && tx.function() == "redeem_fungible_staked_sui"
1252 }
1253
1254 fn is_split_fss_call(tx: &MoveCall) -> bool {
1257 let package_id = match ObjectID::from_str(tx.package()) {
1258 Ok(id) => id,
1259 Err(e) => {
1260 warn!(
1261 package = tx.package(),
1262 error = %e,
1263 "Failed to parse package ID for MoveCall"
1264 );
1265 return false;
1266 }
1267 };
1268 package_id == SUI_SYSTEM_PACKAGE_ID
1269 && tx.module() == "staking_pool"
1270 && tx.function() == "split_fungible_staked_sui"
1271 }
1272
1273 fn is_coin_from_balance_sui_call(tx: &MoveCall) -> bool {
1277 let Ok(package_id) = ObjectID::from_str(tx.package()) else {
1278 return false;
1279 };
1280 if package_id != SUI_FRAMEWORK_PACKAGE_ID {
1281 return false;
1282 }
1283 if tx.module() != "coin" || tx.function() != "from_balance" {
1284 return false;
1285 }
1286 if tx.type_arguments.len() != 1 {
1287 return false;
1288 }
1289 let Ok(parsed) = sui_types::TypeTag::from_str(&tx.type_arguments[0]) else {
1293 return false;
1294 };
1295 let Ok(expected) = sui_types::TypeTag::from_str("0x2::sui::SUI") else {
1296 return false;
1297 };
1298 parsed == expected
1299 }
1300
1301 fn is_coin_redeem_funds_call(tx: &MoveCall) -> bool {
1303 let package_id = match ObjectID::from_str(tx.package()) {
1304 Ok(id) => id,
1305 Err(_) => return false,
1306 };
1307 package_id == SUI_FRAMEWORK_PACKAGE_ID
1308 && tx.module() == "coin"
1309 && tx.function() == "redeem_funds"
1310 }
1311
1312 fn is_coin_into_balance_call(tx: &MoveCall) -> bool {
1313 let package_id = match ObjectID::from_str(tx.package()) {
1314 Ok(id) => id,
1315 Err(_) => return false,
1316 };
1317 package_id == SUI_FRAMEWORK_PACKAGE_ID
1318 && tx.module() == "coin"
1319 && tx.function() == "into_balance"
1320 }
1321
1322 fn is_balance_send_funds_call(tx: &MoveCall) -> bool {
1323 let package_id = match ObjectID::from_str(tx.package()) {
1324 Ok(id) => id,
1325 Err(_) => return false,
1326 };
1327 package_id == SUI_FRAMEWORK_PACKAGE_ID
1328 && tx.module() == "balance"
1329 && tx.function() == "send_funds"
1330 }
1331
1332 fn is_coin_send_funds_call(tx: &MoveCall) -> bool {
1333 let package_id = match ObjectID::from_str(tx.package()) {
1334 Ok(id) => id,
1335 Err(_) => return false,
1336 };
1337 package_id == SUI_FRAMEWORK_PACKAGE_ID
1338 && tx.module() == "coin"
1339 && tx.function() == "send_funds"
1340 }
1341
1342 fn is_coin_destroy_zero_call(tx: &MoveCall) -> bool {
1343 let package_id = match ObjectID::from_str(tx.package()) {
1344 Ok(id) => id,
1345 Err(_) => return false,
1346 };
1347 package_id == SUI_FRAMEWORK_PACKAGE_ID
1348 && tx.module() == "coin"
1349 && tx.function() == "destroy_zero"
1350 }
1351
1352 fn is_balance_join_call(tx: &MoveCall) -> bool {
1353 let package_id = match ObjectID::from_str(tx.package()) {
1354 Ok(id) => id,
1355 Err(_) => return false,
1356 };
1357 package_id == SUI_FRAMEWORK_PACKAGE_ID
1358 && tx.module() == "balance"
1359 && tx.function() == "join"
1360 }
1361
1362 fn process_balance_change(
1363 gas_owner: SuiAddress,
1364 gas_used: i128,
1365 balance_changes: &[(BalanceChange, Currency)],
1366 status: Option<OperationStatus>,
1367 balances: HashMap<(SuiAddress, Currency), i128>,
1368 ) -> impl Iterator<Item = Operation> {
1369 let mut balances =
1370 balance_changes
1371 .iter()
1372 .fold(balances, |mut balances, (balance_change, ccy)| {
1373 if let (Some(addr_str), Some(amount_str)) =
1374 (&balance_change.address, &balance_change.amount)
1375 && let (Ok(owner), Ok(amount)) =
1376 (SuiAddress::from_str(addr_str), i128::from_str(amount_str))
1377 {
1378 *balances.entry((owner, ccy.clone())).or_default() += amount;
1379 }
1380 balances
1381 });
1382 *balances.entry((gas_owner, SUI.clone())).or_default() -= gas_used;
1384
1385 let balance_change = balances.into_iter().filter(|(_, amount)| *amount != 0).map(
1386 move |((addr, currency), amount)| {
1387 Operation::balance_change(status, addr, amount, currency)
1388 },
1389 );
1390
1391 let gas = if gas_used != 0 {
1392 vec![Operation::gas(gas_owner, gas_used)]
1393 } else {
1394 vec![]
1396 };
1397 balance_change.chain(gas)
1398 }
1399
1400 fn is_gascoin_transfer(tx: &TransactionKind) -> bool {
1402 if let Some(TransactionKindData::ProgrammableTransaction(pt)) = &tx.data {
1403 return pt.commands.iter().any(|command| {
1404 if let Some(Command::TransferObjects(transfer)) = &command.command {
1405 transfer
1406 .objects
1407 .iter()
1408 .any(|arg| arg.kind() == ArgumentKind::Gas)
1409 } else {
1410 false
1411 }
1412 });
1413 }
1414 false
1415 }
1416
1417 fn add_missing_gas_owner(operations: &mut Vec<Operation>, gas_owner: SuiAddress) {
1420 if !operations.iter().any(|operation| {
1421 if let Some(amount) = &operation.amount
1422 && let Some(account) = &operation.account
1423 && account.address == gas_owner
1424 && amount.currency == *SUI
1425 {
1426 return true;
1427 }
1428 false
1429 }) {
1430 operations.push(Operation::balance_change(
1431 Some(OperationStatus::Success),
1432 gas_owner,
1433 0,
1434 SUI.clone(),
1435 ));
1436 }
1437 }
1438
1439 fn validate_operations(
1442 initial_balance_changes: &[(BalanceChange, Currency)],
1443 new_operations: &[Operation],
1444 ) -> Result<(), anyhow::Error> {
1445 let balances: HashMap<(SuiAddress, Currency), i128> = HashMap::new();
1446 let mut initial_balances =
1447 initial_balance_changes
1448 .iter()
1449 .fold(balances, |mut balances, (balance_change, ccy)| {
1450 if let (Some(addr_str), Some(amount_str)) =
1451 (&balance_change.address, &balance_change.amount)
1452 && let (Ok(owner), Ok(amount)) =
1453 (SuiAddress::from_str(addr_str), i128::from_str(amount_str))
1454 {
1455 *balances.entry((owner, ccy.clone())).or_default() += amount;
1456 }
1457 balances
1458 });
1459
1460 let mut new_balances = HashMap::new();
1461 for op in new_operations {
1462 if let Some(Amount {
1463 currency, value, ..
1464 }) = &op.amount
1465 {
1466 if let Some(account) = &op.account {
1467 let balance_change = new_balances
1468 .remove(&(account.address, currency.clone()))
1469 .unwrap_or(0)
1470 + value;
1471 new_balances.insert((account.address, currency.clone()), balance_change);
1472 } else {
1473 return Err(anyhow!("Missing account for a balance-change"));
1474 }
1475 }
1476 }
1477
1478 for ((address, currency), amount_expected) in new_balances {
1479 let new_amount = initial_balances.remove(&(address, currency)).unwrap_or(0);
1480 if new_amount != amount_expected {
1481 return Err(anyhow!(
1482 "Expected {} balance-change for {} but got {}",
1483 amount_expected,
1484 address,
1485 new_amount
1486 ));
1487 }
1488 }
1489 if !initial_balances.is_empty() {
1490 return Err(anyhow!(
1491 "Expected every item in initial_balances to be mapped"
1492 ));
1493 }
1494 Ok(())
1495 }
1496
1497 fn process_gascoin_transfer(
1502 coin_change_operations: &mut impl Iterator<Item = Operation>,
1503 is_gascoin_transfer: bool,
1504 prev_gas_owner: SuiAddress,
1505 new_gas_owner: SuiAddress,
1506 gas_used: i128,
1507 initial_balance_changes: &[(BalanceChange, Currency)],
1508 ) -> Result<Vec<Operation>, anyhow::Error> {
1509 let mut operations = vec![];
1510 if is_gascoin_transfer && prev_gas_owner != new_gas_owner {
1511 operations = coin_change_operations.collect();
1512 Self::add_missing_gas_owner(&mut operations, prev_gas_owner);
1513 Self::add_missing_gas_owner(&mut operations, new_gas_owner);
1514 for operation in &mut operations {
1515 match operation.type_ {
1516 OperationType::Gas => {
1517 operation.account = Some(prev_gas_owner.into())
1520 }
1521 OperationType::SuiBalanceChange => {
1522 let account = operation
1523 .account
1524 .as_ref()
1525 .ok_or_else(|| anyhow!("Missing account for a balance-change"))?;
1526 let amount = operation
1527 .amount
1528 .as_mut()
1529 .ok_or_else(|| anyhow!("Missing amount for a balance-change"))?;
1530 if account.address == prev_gas_owner && amount.currency == *SUI {
1532 amount.value -= gas_used;
1533 } else if account.address == new_gas_owner && amount.currency == *SUI {
1534 amount.value += gas_used;
1535 }
1536 }
1537 _ => {
1538 return Err(anyhow!(
1539 "Discarding unsupported operation type {:?}",
1540 operation.type_
1541 ));
1542 }
1543 }
1544 }
1545 Self::validate_operations(initial_balance_changes, &operations)?;
1546 }
1547 Ok(operations)
1548 }
1549}
1550
1551impl Operations {
1552 pub async fn try_from_executed_transaction(
1553 executed_tx: ExecutedTransaction,
1554 cache: &CoinMetadataCache,
1555 ) -> Result<Self, Error> {
1556 let ExecutedTransaction {
1557 transaction,
1558 effects,
1559 events,
1560 balance_changes,
1561 ..
1562 } = executed_tx;
1563
1564 let transaction = transaction.ok_or_else(|| {
1565 Error::DataError("ExecutedTransaction missing transaction".to_string())
1566 })?;
1567 let effects = effects
1568 .ok_or_else(|| Error::DataError("ExecutedTransaction missing effects".to_string()))?;
1569
1570 let sender = SuiAddress::from_str(transaction.sender())?;
1571
1572 let gas_owner = if effects.gas_object.is_some() {
1573 let gas_object = effects.gas_object();
1574 let owner = gas_object.output_owner();
1575 SuiAddress::from_str(owner.address())?
1576 } else if sender == SuiAddress::ZERO {
1577 sender
1579 } else {
1580 SuiAddress::from_str(transaction.gas_payment().owner())?
1583 };
1584
1585 let gas_summary = effects.gas_used();
1586 let gas_used = gas_summary.storage_rebate_opt().unwrap_or(0) as i128
1587 - gas_summary.storage_cost_opt().unwrap_or(0) as i128
1588 - gas_summary.computation_cost_opt().unwrap_or(0) as i128;
1589
1590 let status = Some(effects.status().into());
1591
1592 let prev_gas_owner = SuiAddress::from_str(transaction.gas_payment().owner())?;
1593
1594 let tx_kind = transaction
1595 .kind
1596 .ok_or_else(|| Error::DataError("Transaction missing kind".to_string()))?;
1597 let is_gascoin_transfer = Self::is_gascoin_transfer(&tx_kind);
1598 let ops = Self::new(Self::from_transaction(tx_kind, sender, status)?);
1599 let ops = ops.into_iter();
1600
1601 let mut accounted_balances =
1604 ops.as_ref()
1605 .iter()
1606 .fold(HashMap::new(), |mut balances, op| {
1607 if let (Some(acc), Some(amount), Some(OperationStatus::Success)) =
1608 (&op.account, &op.amount, &op.status)
1609 {
1610 *balances
1611 .entry((acc.address, amount.clone().currency))
1612 .or_default() -= amount.value;
1613 }
1614 balances
1615 });
1616
1617 let mut principal_amounts = 0;
1618 let mut reward_amounts = 0;
1619
1620 let events = events.as_ref().map(|e| e.events.as_slice()).unwrap_or(&[]);
1622 for event in events {
1623 let event_type = event.event_type();
1624 if let Ok(type_tag) = StructTag::from_str(event_type)
1625 && is_unstake_event(&type_tag)
1626 && let Some(json) = &event.json
1627 && let Some(Kind::StructValue(struct_val)) = &json.kind
1628 {
1629 if let Some(principal_field) = struct_val.fields.get("principal_amount")
1630 && let Some(Kind::StringValue(s)) = &principal_field.kind
1631 && let Ok(amount) = i128::from_str(s)
1632 {
1633 principal_amounts += amount;
1634 }
1635 if let Some(reward_field) = struct_val.fields.get("reward_amount")
1636 && let Some(Kind::StringValue(s)) = &reward_field.kind
1637 && let Ok(amount) = i128::from_str(s)
1638 {
1639 reward_amounts += amount;
1640 }
1641 }
1642 }
1643 let staking_balance = if principal_amounts != 0 {
1644 *accounted_balances.entry((sender, SUI.clone())).or_default() -= principal_amounts;
1645 *accounted_balances.entry((sender, SUI.clone())).or_default() -= reward_amounts;
1646 vec![
1647 Operation::stake_principle(status, sender, principal_amounts),
1648 Operation::stake_reward(status, sender, reward_amounts),
1649 ]
1650 } else {
1651 vec![]
1652 };
1653
1654 let mut balance_changes_with_currency = vec![];
1655
1656 for balance_change in &balance_changes {
1657 let coin_type = balance_change.coin_type();
1658 let type_tag = sui_types::TypeTag::from_str(coin_type)
1659 .map_err(|e| anyhow!("Invalid coin type: {}", e))?;
1660
1661 if let Ok(currency) = cache.get_currency(&type_tag).await
1662 && !currency.symbol.is_empty()
1663 {
1664 balance_changes_with_currency.push((balance_change.clone(), currency));
1665 }
1666 }
1667
1668 let mut coin_change_operations = Self::process_balance_change(
1670 gas_owner,
1671 gas_used,
1672 &balance_changes_with_currency,
1673 status,
1674 accounted_balances.clone(),
1675 );
1676
1677 let gascoin_transfer_operations = Self::process_gascoin_transfer(
1680 &mut coin_change_operations,
1681 is_gascoin_transfer,
1682 prev_gas_owner,
1683 gas_owner,
1684 gas_used,
1685 &balance_changes_with_currency,
1686 )?;
1687
1688 let ops: Operations = ops
1689 .into_iter()
1690 .chain(coin_change_operations)
1691 .chain(gascoin_transfer_operations)
1692 .chain(staking_balance)
1693 .collect();
1694
1695 let mutually_cancelling_balances: HashMap<_, _> = ops
1700 .clone()
1701 .into_iter()
1702 .fold(
1703 HashMap::new(),
1704 |mut balances: HashMap<(SuiAddress, Currency), i128>, op| {
1705 if let (Some(acc), Some(amount), Some(OperationStatus::Success)) =
1706 (&op.account, &op.amount, &op.status)
1707 && op.type_ != OperationType::Gas
1708 {
1709 *balances
1710 .entry((acc.address, amount.clone().currency))
1711 .or_default() += amount.value;
1712 }
1713 balances
1714 },
1715 )
1716 .into_iter()
1717 .filter(|balance| {
1718 let (_, amount) = balance;
1719 *amount == 0
1720 })
1721 .collect();
1722
1723 let ops: Operations = ops
1724 .into_iter()
1725 .filter(|op| {
1726 if let (Some(acc), Some(amount)) = (&op.account, &op.amount) {
1727 return op.type_ == OperationType::Gas
1728 || !mutually_cancelling_balances
1729 .contains_key(&(acc.address, amount.clone().currency));
1730 }
1731 true
1732 })
1733 .collect();
1734 Ok(ops)
1735 }
1736}
1737
1738fn is_unstake_event(tag: &StructTag) -> bool {
1739 tag.address == SUI_SYSTEM_ADDRESS
1740 && tag.module.as_ident_str() == ident_str!("validator")
1741 && tag.name.as_ident_str() == ident_str!("UnstakingRequestEvent")
1742}
1743
1744#[derive(Deserialize, Serialize, Clone, Debug)]
1745pub struct Operation {
1746 operation_identifier: OperationIdentifier,
1747 #[serde(rename = "type")]
1748 pub type_: OperationType,
1749 #[serde(default, skip_serializing_if = "Option::is_none")]
1750 pub status: Option<OperationStatus>,
1751 #[serde(default, skip_serializing_if = "Option::is_none")]
1752 pub account: Option<AccountIdentifier>,
1753 #[serde(default, skip_serializing_if = "Option::is_none")]
1754 pub amount: Option<Amount>,
1755 #[serde(default, skip_serializing_if = "Option::is_none")]
1756 pub coin_change: Option<CoinChange>,
1757 #[serde(default, skip_serializing_if = "Option::is_none")]
1758 pub metadata: Option<OperationMetadata>,
1759}
1760
1761impl PartialEq for Operation {
1762 fn eq(&self, other: &Self) -> bool {
1763 self.operation_identifier == other.operation_identifier
1764 && self.type_ == other.type_
1765 && self.account == other.account
1766 && self.amount == other.amount
1767 && self.coin_change == other.coin_change
1768 && self.metadata == other.metadata
1769 }
1770}
1771
1772#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
1773pub enum OperationMetadata {
1774 GenericTransaction(TransactionKind),
1775 Stake {
1776 validator: SuiAddress,
1777 },
1778 WithdrawStake {
1779 stake_ids: Vec<ObjectID>,
1780 },
1781 ConsolidateAllStakedSuiToFungible {
1782 #[serde(default, skip_serializing_if = "Option::is_none")]
1783 validator: Option<SuiAddress>,
1784 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1785 staked_sui_ids: Vec<ObjectID>,
1786 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1787 fss_ids: Vec<ObjectID>,
1788 },
1789 MergeAndRedeemFungibleStakedSui {
1790 #[serde(default, skip_serializing_if = "Option::is_none")]
1791 validator: Option<SuiAddress>,
1792 #[serde(default, skip_serializing_if = "Option::is_none")]
1793 amount: Option<String>,
1794 #[serde(default, skip_serializing_if = "Option::is_none")]
1795 redeem_mode: Option<RedeemMode>,
1796 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1797 fss_ids: Vec<ObjectID>,
1798 },
1799}
1800
1801impl Operation {
1802 fn generic_op(
1803 status: Option<OperationStatus>,
1804 sender: SuiAddress,
1805 tx: TransactionKind,
1806 ) -> Self {
1807 Operation {
1808 operation_identifier: Default::default(),
1809 type_: (&tx).into(),
1810 status,
1811 account: Some(sender.into()),
1812 amount: None,
1813 coin_change: None,
1814 metadata: Some(OperationMetadata::GenericTransaction(tx)),
1815 }
1816 }
1817
1818 pub fn genesis(index: u64, sender: SuiAddress, coin: GasCoin) -> Self {
1819 Operation {
1820 operation_identifier: index.into(),
1821 type_: OperationType::Genesis,
1822 status: Some(OperationStatus::Success),
1823 account: Some(sender.into()),
1824 amount: Some(Amount::new(coin.value().into(), None)),
1825 coin_change: Some(CoinChange {
1826 coin_identifier: CoinIdentifier {
1827 identifier: CoinID {
1828 id: *coin.id(),
1829 version: SequenceNumber::new(),
1830 },
1831 },
1832 coin_action: CoinAction::CoinCreated,
1833 }),
1834 metadata: None,
1835 }
1836 }
1837
1838 fn pay_sui(status: Option<OperationStatus>, address: SuiAddress, amount: i128) -> Self {
1839 Operation {
1840 operation_identifier: Default::default(),
1841 type_: OperationType::PaySui,
1842 status,
1843 account: Some(address.into()),
1844 amount: Some(Amount::new(amount, None)),
1845 coin_change: None,
1846 metadata: None,
1847 }
1848 }
1849
1850 fn pay_coin(
1851 status: Option<OperationStatus>,
1852 address: SuiAddress,
1853 amount: i128,
1854 currency: Option<Currency>,
1855 ) -> Self {
1856 Operation {
1857 operation_identifier: Default::default(),
1858 type_: OperationType::PayCoin,
1859 status,
1860 account: Some(address.into()),
1861 amount: Some(Amount::new(amount, currency)),
1862 coin_change: None,
1863 metadata: None,
1864 }
1865 }
1866
1867 fn balance_change(
1868 status: Option<OperationStatus>,
1869 addr: SuiAddress,
1870 amount: i128,
1871 currency: Currency,
1872 ) -> Self {
1873 Self {
1874 operation_identifier: Default::default(),
1875 type_: OperationType::SuiBalanceChange,
1876 status,
1877 account: Some(addr.into()),
1878 amount: Some(Amount::new(amount, Some(currency))),
1879 coin_change: None,
1880 metadata: None,
1881 }
1882 }
1883 fn gas(addr: SuiAddress, amount: i128) -> Self {
1884 Self {
1885 operation_identifier: Default::default(),
1886 type_: OperationType::Gas,
1887 status: Some(OperationStatus::Success),
1888 account: Some(addr.into()),
1889 amount: Some(Amount::new(amount, None)),
1890 coin_change: None,
1891 metadata: None,
1892 }
1893 }
1894 fn stake_reward(status: Option<OperationStatus>, addr: SuiAddress, amount: i128) -> Self {
1895 Self {
1896 operation_identifier: Default::default(),
1897 type_: OperationType::StakeReward,
1898 status,
1899 account: Some(addr.into()),
1900 amount: Some(Amount::new(amount, None)),
1901 coin_change: None,
1902 metadata: None,
1903 }
1904 }
1905 fn stake_principle(status: Option<OperationStatus>, addr: SuiAddress, amount: i128) -> Self {
1906 Self {
1907 operation_identifier: Default::default(),
1908 type_: OperationType::StakePrinciple,
1909 status,
1910 account: Some(addr.into()),
1911 amount: Some(Amount::new(amount, None)),
1912 coin_change: None,
1913 metadata: None,
1914 }
1915 }
1916}
1917
1918#[cfg(test)]
1919mod tests {
1920 use super::*;
1921 use crate::SUI;
1922 use crate::types::ConstructionMetadata;
1923 use crate::types::internal_operation::{consolidate_to_fungible_pt, merge_and_redeem_fss_pt};
1924 use sui_rpc::proto::sui::rpc::v2::Transaction;
1925 use sui_types::Identifier;
1926 use sui_types::base_types::{ObjectDigest, ObjectID, ObjectRef, SequenceNumber, SuiAddress};
1927 use sui_types::programmable_transaction_builder::ProgrammableTransactionBuilder;
1928 use sui_types::transaction::{
1929 CallArg, Command as NativeCommand, ObjectArg, ProgrammableTransaction,
1930 TEST_ONLY_GAS_UNIT_FOR_TRANSFER, TransactionData,
1931 };
1932
1933 fn random_object_ref() -> ObjectRef {
1934 (
1935 ObjectID::random(),
1936 SequenceNumber::from(1),
1937 ObjectDigest::random(),
1938 )
1939 }
1940
1941 fn parse_pt(sender: SuiAddress, pt: ProgrammableTransaction) -> Vec<Operation> {
1944 let gas = random_object_ref();
1945 let gas_price = 10;
1946 let data = TransactionData::new_programmable(
1947 sender,
1948 vec![gas],
1949 pt,
1950 TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
1951 gas_price,
1952 );
1953 let proto_tx: Transaction = data.into();
1954 let tx_kind = proto_tx.kind.expect("tx missing kind");
1955 Operations::from_transaction(tx_kind, sender, None).expect("parse failed")
1956 }
1957
1958 #[tokio::test]
1959 async fn test_operation_data_parsing_pay_sui() -> Result<(), anyhow::Error> {
1960 let gas = (
1961 ObjectID::random(),
1962 SequenceNumber::new(),
1963 ObjectDigest::random(),
1964 );
1965
1966 let sender = SuiAddress::random_for_testing_only();
1967
1968 let pt = {
1969 let mut builder = ProgrammableTransactionBuilder::new();
1970 builder
1971 .pay_sui(vec![SuiAddress::random_for_testing_only()], vec![10000])
1972 .unwrap();
1973 builder.finish()
1974 };
1975 let gas_price = 10;
1976 let data = TransactionData::new_programmable(
1977 sender,
1978 vec![gas],
1979 pt,
1980 TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
1981 gas_price,
1982 );
1983
1984 let proto_tx: Transaction = data.clone().into();
1985 let ops = Operations::new(Operations::from_transaction(
1986 proto_tx
1987 .kind
1988 .ok_or_else(|| Error::DataError("Transaction missing kind".to_string()))?,
1989 sender,
1990 None,
1991 )?);
1992 ops.0
1993 .iter()
1994 .for_each(|op| assert_eq!(op.type_, OperationType::PaySui));
1995 let metadata = ConstructionMetadata {
1996 sender,
1997 gas_coins: vec![gas],
1998 extra_gas_coins: vec![],
1999 objects: vec![],
2000 party_objects: vec![],
2001 total_coin_value: 0,
2002 gas_price,
2003 budget: TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
2004 currency: None,
2005 address_balance_withdrawal: 0,
2006 epoch: None,
2007 chain_id: None,
2008 fss_object_count: None,
2009 redeem_token_amount: None,
2010 };
2011 let parsed_data = ops.into_internal()?.try_into_data(metadata)?;
2012 assert_eq!(data, parsed_data);
2013
2014 Ok(())
2015 }
2016
2017 #[tokio::test]
2018 async fn test_operation_data_parsing_pay_coin() -> Result<(), anyhow::Error> {
2019 use crate::types::internal_operation::pay_coin_pt;
2020
2021 let gas = (
2022 ObjectID::random(),
2023 SequenceNumber::new(),
2024 ObjectDigest::random(),
2025 );
2026
2027 let coin = (
2028 ObjectID::random(),
2029 SequenceNumber::new(),
2030 ObjectDigest::random(),
2031 );
2032
2033 let sender = SuiAddress::random_for_testing_only();
2034 let recipient = SuiAddress::random_for_testing_only();
2035
2036 let pt = pay_coin_pt(sender, vec![recipient], vec![10000], &[coin], &[], 0, &SUI)?;
2037 let gas_price = 10;
2038 let data = TransactionData::new_programmable(
2039 sender,
2040 vec![gas],
2041 pt,
2042 TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
2043 gas_price,
2044 );
2045
2046 let proto_tx: Transaction = data.clone().into();
2047 let ops = Operations::new(Operations::from_transaction(
2048 proto_tx
2049 .kind
2050 .ok_or_else(|| Error::DataError("Transaction missing kind".to_string()))?,
2051 sender,
2052 None,
2053 )?);
2054 ops.0
2055 .iter()
2056 .for_each(|op| assert_eq!(op.type_, OperationType::PayCoin));
2057 let metadata = ConstructionMetadata {
2058 sender,
2059 gas_coins: vec![gas],
2060 extra_gas_coins: vec![],
2061 objects: vec![coin],
2062 party_objects: vec![],
2063 total_coin_value: 0,
2064 gas_price,
2065 budget: TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
2066 currency: Some(SUI.clone()),
2067 address_balance_withdrawal: 0,
2068 epoch: None,
2069 chain_id: None,
2070 fss_object_count: None,
2071 redeem_token_amount: None,
2072 };
2073 let parsed_data = ops.into_internal()?.try_into_data(metadata)?;
2074 assert_eq!(data, parsed_data);
2075
2076 Ok(())
2077 }
2078
2079 #[test]
2080 fn test_parse_consolidate_all_staked_sui_to_fungible() {
2081 let sender = SuiAddress::random_for_testing_only();
2082 let validator = SuiAddress::random_for_testing_only();
2083
2084 let ops: Operations = serde_json::from_value(serde_json::json!([{
2085 "operation_identifier": {"index": 0},
2086 "type": "ConsolidateAllStakedSuiToFungible",
2087 "account": {"address": sender.to_string()},
2088 "metadata": {
2089 "ConsolidateAllStakedSuiToFungible": {
2090 "validator": validator.to_string()
2091 }
2092 }
2093 }]))
2094 .unwrap();
2095
2096 let internal = ops.into_internal().unwrap();
2097 match internal {
2098 InternalOperation::ConsolidateAllStakedSuiToFungible(op) => {
2099 assert_eq!(op.sender, sender);
2100 assert_eq!(op.validator, validator);
2101 }
2102 _ => panic!("Expected ConsolidateAllStakedSuiToFungible"),
2103 }
2104 }
2105
2106 #[test]
2107 fn test_parse_merge_and_redeem_fungible_staked_sui() {
2108 let sender = SuiAddress::random_for_testing_only();
2109 let validator = SuiAddress::random_for_testing_only();
2110
2111 let ops: Operations = serde_json::from_value(serde_json::json!([{
2112 "operation_identifier": {"index": 0},
2113 "type": "MergeAndRedeemFungibleStakedSui",
2114 "account": {"address": sender.to_string()},
2115 "metadata": {
2116 "MergeAndRedeemFungibleStakedSui": {
2117 "validator": validator.to_string(),
2118 "amount": "500000000000",
2119 "redeem_mode": "AtLeast"
2120 }
2121 }
2122 }]))
2123 .unwrap();
2124
2125 let internal = ops.into_internal().unwrap();
2126 match internal {
2127 InternalOperation::MergeAndRedeemFungibleStakedSui(op) => {
2128 assert_eq!(op.sender, sender);
2129 assert_eq!(op.validator, validator);
2130 assert_eq!(op.amount, Some(500000000000));
2131 assert_eq!(op.redeem_mode, RedeemMode::AtLeast);
2132 }
2133 _ => panic!("Expected MergeAndRedeemFungibleStakedSui"),
2134 }
2135 }
2136
2137 #[test]
2138 fn test_parse_merge_and_redeem_all_mode() {
2139 let sender = SuiAddress::random_for_testing_only();
2140 let validator = SuiAddress::random_for_testing_only();
2141
2142 let ops: Operations = serde_json::from_value(serde_json::json!([{
2143 "operation_identifier": {"index": 0},
2144 "type": "MergeAndRedeemFungibleStakedSui",
2145 "account": {"address": sender.to_string()},
2146 "metadata": {
2147 "MergeAndRedeemFungibleStakedSui": {
2148 "validator": validator.to_string(),
2149 "redeem_mode": "All"
2150 }
2151 }
2152 }]))
2153 .unwrap();
2154
2155 let internal = ops.into_internal().unwrap();
2156 match internal {
2157 InternalOperation::MergeAndRedeemFungibleStakedSui(op) => {
2158 assert_eq!(op.amount, None);
2159 assert_eq!(op.redeem_mode, RedeemMode::All);
2160 }
2161 _ => panic!("Expected MergeAndRedeemFungibleStakedSui"),
2162 }
2163 }
2164
2165 fn assert_consolidate_ops(
2170 ops: &[Operation],
2171 expected_sender: SuiAddress,
2172 expected_staked_sui: &[ObjectID],
2173 expected_fss: &[ObjectID],
2174 ) {
2175 assert_eq!(ops.len(), 1);
2176 let op = &ops[0];
2177 assert_eq!(op.type_, OperationType::ConsolidateAllStakedSuiToFungible);
2178 assert_eq!(
2179 op.account.as_ref().map(|a| a.address),
2180 Some(expected_sender)
2181 );
2182 assert!(op.amount.is_none());
2183 let Some(OperationMetadata::ConsolidateAllStakedSuiToFungible {
2184 validator,
2185 staked_sui_ids,
2186 fss_ids,
2187 }) = op.metadata.clone()
2188 else {
2189 panic!("wrong metadata variant: {:?}", op.metadata);
2190 };
2191 assert!(validator.is_none(), "validator must be None on parse");
2192 assert_eq!(staked_sui_ids, expected_staked_sui);
2193 assert_eq!(fss_ids, expected_fss);
2194 }
2195
2196 #[test]
2197 fn test_parse_consolidate_pure_merge_2_fss() {
2198 let sender = SuiAddress::random_for_testing_only();
2199 let fss_a = random_object_ref();
2200 let fss_b = random_object_ref();
2201 let pt = consolidate_to_fungible_pt(sender, vec![fss_a, fss_b], vec![]).expect("pt");
2202 let ops = parse_pt(sender, pt);
2203 assert_consolidate_ops(&ops, sender, &[], &[fss_a.0, fss_b.0]);
2204 }
2205
2206 #[test]
2207 fn test_parse_consolidate_pure_merge_3_fss() {
2208 let sender = SuiAddress::random_for_testing_only();
2209 let a = random_object_ref();
2210 let b = random_object_ref();
2211 let c = random_object_ref();
2212 let pt = consolidate_to_fungible_pt(sender, vec![a, b, c], vec![]).expect("pt");
2213 assert_consolidate_ops(&parse_pt(sender, pt), sender, &[], &[a.0, b.0, c.0]);
2214 }
2215
2216 #[test]
2217 fn test_parse_consolidate_pure_merge_5_fss() {
2218 let sender = SuiAddress::random_for_testing_only();
2219 let refs: Vec<_> = (0..5).map(|_| random_object_ref()).collect();
2220 let pt = consolidate_to_fungible_pt(sender, refs.clone(), vec![]).expect("pt");
2221 let expected: Vec<_> = refs.iter().map(|r| r.0).collect();
2222 assert_consolidate_ops(&parse_pt(sender, pt), sender, &[], &expected);
2223 }
2224
2225 #[test]
2226 fn test_parse_consolidate_single_convert_no_fss() {
2227 let sender = SuiAddress::random_for_testing_only();
2228 let staked = random_object_ref();
2229 let pt = consolidate_to_fungible_pt(sender, vec![], vec![staked]).expect("pt");
2230 assert_consolidate_ops(&parse_pt(sender, pt), sender, &[staked.0], &[]);
2231 }
2232
2233 #[test]
2234 fn test_parse_consolidate_multi_convert_no_fss() {
2235 let sender = SuiAddress::random_for_testing_only();
2236 let s1 = random_object_ref();
2237 let s2 = random_object_ref();
2238 let s3 = random_object_ref();
2239 let pt = consolidate_to_fungible_pt(sender, vec![], vec![s1, s2, s3]).expect("pt");
2240 assert_consolidate_ops(&parse_pt(sender, pt), sender, &[s1.0, s2.0, s3.0], &[]);
2241 }
2242
2243 #[test]
2244 fn test_parse_consolidate_single_stake_single_fss() {
2245 let sender = SuiAddress::random_for_testing_only();
2246 let fss = random_object_ref();
2247 let staked = random_object_ref();
2248 let pt = consolidate_to_fungible_pt(sender, vec![fss], vec![staked]).expect("pt");
2249 assert_consolidate_ops(&parse_pt(sender, pt), sender, &[staked.0], &[fss.0]);
2250 }
2251
2252 #[test]
2253 fn test_parse_consolidate_single_stake_multi_fss() {
2254 let sender = SuiAddress::random_for_testing_only();
2255 let f1 = random_object_ref();
2256 let f2 = random_object_ref();
2257 let staked = random_object_ref();
2258 let pt = consolidate_to_fungible_pt(sender, vec![f1, f2], vec![staked]).expect("pt");
2259 assert_consolidate_ops(&parse_pt(sender, pt), sender, &[staked.0], &[f1.0, f2.0]);
2260 }
2261
2262 #[test]
2263 fn test_parse_consolidate_multi_stake_single_fss() {
2264 let sender = SuiAddress::random_for_testing_only();
2265 let fss = random_object_ref();
2266 let s1 = random_object_ref();
2267 let s2 = random_object_ref();
2268 let pt = consolidate_to_fungible_pt(sender, vec![fss], vec![s1, s2]).expect("pt");
2269 assert_consolidate_ops(&parse_pt(sender, pt), sender, &[s1.0, s2.0], &[fss.0]);
2270 }
2271
2272 #[test]
2273 fn test_parse_consolidate_multi_stake_multi_fss() {
2274 let sender = SuiAddress::random_for_testing_only();
2275 let f1 = random_object_ref();
2276 let f2 = random_object_ref();
2277 let s1 = random_object_ref();
2278 let s2 = random_object_ref();
2279 let pt = consolidate_to_fungible_pt(sender, vec![f1, f2], vec![s1, s2]).expect("pt");
2280 assert_consolidate_ops(&parse_pt(sender, pt), sender, &[s1.0, s2.0], &[f1.0, f2.0]);
2281 }
2282
2283 #[test]
2284 fn test_parse_consolidate_large_mixed() {
2285 let sender = SuiAddress::random_for_testing_only();
2286 let fss: Vec<_> = (0..3).map(|_| random_object_ref()).collect();
2287 let staked: Vec<_> = (0..3).map(|_| random_object_ref()).collect();
2288 let pt = consolidate_to_fungible_pt(sender, fss.clone(), staked.clone()).expect("pt");
2289 let expected_s: Vec<_> = staked.iter().map(|r| r.0).collect();
2290 let expected_f: Vec<_> = fss.iter().map(|r| r.0).collect();
2291 assert_consolidate_ops(&parse_pt(sender, pt), sender, &expected_s, &expected_f);
2292 }
2293
2294 #[test]
2295 fn test_parse_consolidate_classification_correctness() {
2296 let sender = SuiAddress::random_for_testing_only();
2298 let f1 = random_object_ref();
2299 let f2 = random_object_ref();
2300 let s1 = random_object_ref();
2301 let s2 = random_object_ref();
2302 let pt = consolidate_to_fungible_pt(sender, vec![f1, f2], vec![s1, s2]).expect("pt");
2303 let ops = parse_pt(sender, pt);
2304 let Some(OperationMetadata::ConsolidateAllStakedSuiToFungible {
2305 staked_sui_ids,
2306 fss_ids,
2307 ..
2308 }) = ops[0].metadata.clone()
2309 else {
2310 panic!();
2311 };
2312 let staked_set: std::collections::HashSet<_> = staked_sui_ids.iter().collect();
2313 let fss_set: std::collections::HashSet<_> = fss_ids.iter().collect();
2314 assert!(
2315 staked_set.is_disjoint(&fss_set),
2316 "classification crossed categories"
2317 );
2318 }
2319
2320 fn assert_falls_through_to_generic(ops: &[Operation]) {
2325 assert_eq!(ops.len(), 1);
2326 assert_eq!(
2327 ops[0].type_,
2328 OperationType::ProgrammableTransaction,
2329 "expected fall-through to generic ProgrammableTransaction, got: {:?}",
2330 ops[0].type_
2331 );
2332 }
2333
2334 #[test]
2335 fn test_parse_falls_through_consolidate_with_merge_coins() {
2336 let sender = SuiAddress::random_for_testing_only();
2337 let fss_a = random_object_ref();
2338 let fss_b = random_object_ref();
2339 let coin_a = random_object_ref();
2340
2341 let mut builder = ProgrammableTransactionBuilder::new();
2342 let _sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
2343 let first = builder.obj(ObjectArg::ImmOrOwnedObject(fss_a)).unwrap();
2344 let other = builder.obj(ObjectArg::ImmOrOwnedObject(fss_b)).unwrap();
2345 builder.command(NativeCommand::move_call(
2346 SUI_SYSTEM_PACKAGE_ID,
2347 Identifier::new("staking_pool").unwrap(),
2348 Identifier::new("join_fungible_staked_sui").unwrap(),
2349 vec![],
2350 vec![first, other],
2351 ));
2352 let coin_target = builder.obj(ObjectArg::ImmOrOwnedObject(coin_a)).unwrap();
2354 builder.command(NativeCommand::MergeCoins(coin_target, vec![]));
2355
2356 let ops = parse_pt(sender, builder.finish());
2357 assert_falls_through_to_generic(&ops);
2358 }
2359
2360 #[test]
2361 fn test_parse_falls_through_consolidate_with_unrelated_movecall() {
2362 let sender = SuiAddress::random_for_testing_only();
2363 let fss_a = random_object_ref();
2364 let fss_b = random_object_ref();
2365
2366 let mut builder = ProgrammableTransactionBuilder::new();
2367 let _sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
2368 let first = builder.obj(ObjectArg::ImmOrOwnedObject(fss_a)).unwrap();
2369 let other = builder.obj(ObjectArg::ImmOrOwnedObject(fss_b)).unwrap();
2370 builder.command(NativeCommand::move_call(
2371 SUI_SYSTEM_PACKAGE_ID,
2372 Identifier::new("staking_pool").unwrap(),
2373 Identifier::new("join_fungible_staked_sui").unwrap(),
2374 vec![],
2375 vec![first, other],
2376 ));
2377 builder.command(NativeCommand::move_call(
2379 SUI_FRAMEWORK_PACKAGE_ID,
2380 Identifier::new("coin").unwrap(),
2381 Identifier::new("destroy_zero").unwrap(),
2382 vec![],
2383 vec![other],
2384 ));
2385
2386 let ops = parse_pt(sender, builder.finish());
2387 assert_falls_through_to_generic(&ops);
2388 }
2389
2390 #[test]
2391 fn test_parse_falls_through_convert_without_system_state() {
2392 let sender = SuiAddress::random_for_testing_only();
2394 let staked = random_object_ref();
2395 let other_obj = random_object_ref();
2396
2397 let mut builder = ProgrammableTransactionBuilder::new();
2398 let _not_system = builder.obj(ObjectArg::ImmOrOwnedObject(other_obj)).unwrap();
2400 let staked_arg = builder.obj(ObjectArg::ImmOrOwnedObject(staked)).unwrap();
2401 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
2402 let new_fss = builder.command(NativeCommand::move_call(
2403 SUI_SYSTEM_PACKAGE_ID,
2404 Identifier::new("sui_system").unwrap(),
2405 Identifier::new("convert_to_fungible_staked_sui").unwrap(),
2406 vec![],
2407 vec![sys, staked_arg],
2408 ));
2409 let sender_arg = builder.pure(sender).unwrap();
2410 builder.command(NativeCommand::TransferObjects(vec![new_fss], sender_arg));
2411
2412 let ops = parse_pt(sender, builder.finish());
2413 assert_falls_through_to_generic(&ops);
2414 }
2415
2416 #[test]
2417 fn test_parse_falls_through_extra_command_after_transfer() {
2418 let sender = SuiAddress::random_for_testing_only();
2420 let staked = random_object_ref();
2421 let other_obj = random_object_ref();
2422
2423 let mut builder = ProgrammableTransactionBuilder::new();
2424 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
2425 let staked_arg = builder.obj(ObjectArg::ImmOrOwnedObject(staked)).unwrap();
2426 let new_fss = builder.command(NativeCommand::move_call(
2427 SUI_SYSTEM_PACKAGE_ID,
2428 Identifier::new("sui_system").unwrap(),
2429 Identifier::new("convert_to_fungible_staked_sui").unwrap(),
2430 vec![],
2431 vec![sys, staked_arg],
2432 ));
2433 let sender_arg = builder.pure(sender).unwrap();
2434 builder.command(NativeCommand::TransferObjects(vec![new_fss], sender_arg));
2435 let extra = builder.obj(ObjectArg::ImmOrOwnedObject(other_obj)).unwrap();
2437 builder.command(NativeCommand::move_call(
2438 SUI_FRAMEWORK_PACKAGE_ID,
2439 Identifier::new("coin").unwrap(),
2440 Identifier::new("destroy_zero").unwrap(),
2441 vec![],
2442 vec![extra],
2443 ));
2444
2445 let ops = parse_pt(sender, builder.finish());
2446 assert_falls_through_to_generic(&ops);
2447 }
2448
2449 #[test]
2454 fn test_parse_empty_ptb() {
2455 let sender = SuiAddress::random_for_testing_only();
2456 let pt = ProgrammableTransactionBuilder::new().finish();
2457 let ops = parse_pt(sender, pt);
2458 assert_eq!(ops.len(), 1);
2460 assert_eq!(ops[0].type_, OperationType::ProgrammableTransaction);
2461 }
2462
2463 #[test]
2464 fn test_parse_only_merge_coins() {
2465 let sender = SuiAddress::random_for_testing_only();
2467 let coin_a = random_object_ref();
2468 let coin_b = random_object_ref();
2469 let mut builder = ProgrammableTransactionBuilder::new();
2470 let target = builder.obj(ObjectArg::ImmOrOwnedObject(coin_a)).unwrap();
2471 let source = builder.obj(ObjectArg::ImmOrOwnedObject(coin_b)).unwrap();
2472 builder.command(NativeCommand::MergeCoins(target, vec![source]));
2473 let ops = parse_pt(sender, builder.finish());
2474 assert_ne!(
2477 ops[0].type_,
2478 OperationType::ConsolidateAllStakedSuiToFungible
2479 );
2480 assert_ne!(ops[0].type_, OperationType::MergeAndRedeemFungibleStakedSui);
2481 }
2482
2483 #[test]
2491 fn test_meta_consolidate_old_input_deserializes() {
2492 let validator = SuiAddress::random_for_testing_only();
2493 let json = serde_json::json!({
2494 "ConsolidateAllStakedSuiToFungible": { "validator": validator.to_string() }
2495 });
2496 let meta: OperationMetadata = serde_json::from_value(json).unwrap();
2497 match meta {
2498 OperationMetadata::ConsolidateAllStakedSuiToFungible {
2499 validator: v,
2500 staked_sui_ids,
2501 fss_ids,
2502 } => {
2503 assert_eq!(v, Some(validator));
2504 assert!(staked_sui_ids.is_empty());
2505 assert!(fss_ids.is_empty());
2506 }
2507 _ => panic!("wrong variant"),
2508 }
2509 }
2510
2511 #[test]
2512 fn test_meta_consolidate_new_parse_output_serializes() {
2513 let id_a = ObjectID::random();
2514 let id_b = ObjectID::random();
2515 let meta = OperationMetadata::ConsolidateAllStakedSuiToFungible {
2516 validator: None,
2517 staked_sui_ids: vec![id_a],
2518 fss_ids: vec![id_b],
2519 };
2520 let json = serde_json::to_value(&meta).unwrap();
2521 let obj = json
2522 .as_object()
2523 .unwrap()
2524 .get("ConsolidateAllStakedSuiToFungible")
2525 .unwrap()
2526 .as_object()
2527 .unwrap();
2528 assert!(
2529 !obj.contains_key("validator"),
2530 "validator must be omitted when None"
2531 );
2532 assert_eq!(
2533 obj.get("staked_sui_ids").unwrap().as_array().unwrap().len(),
2534 1
2535 );
2536 assert_eq!(obj.get("fss_ids").unwrap().as_array().unwrap().len(), 1);
2537 }
2538
2539 #[test]
2544 fn test_write_consolidate_requires_validator() {
2545 let sender = SuiAddress::random_for_testing_only();
2546 let op = Operation {
2547 operation_identifier: Default::default(),
2548 type_: OperationType::ConsolidateAllStakedSuiToFungible,
2549 status: None,
2550 account: Some(sender.into()),
2551 amount: None,
2552 coin_change: None,
2553 metadata: Some(OperationMetadata::ConsolidateAllStakedSuiToFungible {
2554 validator: None,
2555 staked_sui_ids: vec![],
2556 fss_ids: vec![],
2557 }),
2558 };
2559 let err = Operations::new(vec![op])
2560 .into_internal()
2561 .expect_err("should fail without validator");
2562 let msg = format!("{err}");
2563 assert!(msg.contains("validator"), "unexpected error: {msg}");
2564 }
2565
2566 fn assert_merge_redeem_ops(
2571 ops: &[Operation],
2572 expected_sender: SuiAddress,
2573 expected_fss: &[ObjectID],
2574 expected_mode: Option<RedeemMode>,
2575 ) {
2576 assert_eq!(ops.len(), 1);
2577 let op = &ops[0];
2578 assert_eq!(op.type_, OperationType::MergeAndRedeemFungibleStakedSui);
2579 assert_eq!(
2580 op.account.as_ref().map(|a| a.address),
2581 Some(expected_sender)
2582 );
2583 assert!(op.amount.is_none());
2584 let Some(OperationMetadata::MergeAndRedeemFungibleStakedSui {
2585 validator,
2586 amount,
2587 redeem_mode,
2588 fss_ids,
2589 }) = op.metadata.clone()
2590 else {
2591 panic!("wrong metadata variant: {:?}", op.metadata);
2592 };
2593 assert!(validator.is_none(), "validator must be None on parse");
2594 assert!(
2595 amount.is_none(),
2596 "amount must be None on parse (not in PTB)"
2597 );
2598 assert_eq!(redeem_mode, expected_mode);
2599 assert_eq!(fss_ids, expected_fss);
2600 }
2601
2602 #[test]
2603 fn test_parse_merge_redeem_single_all() {
2604 let sender = SuiAddress::random_for_testing_only();
2605 let fss = random_object_ref();
2606 let pt = merge_and_redeem_fss_pt(sender, vec![fss], None).expect("pt");
2607 assert_merge_redeem_ops(
2608 &parse_pt(sender, pt),
2609 sender,
2610 &[fss.0],
2611 Some(RedeemMode::All),
2612 );
2613 }
2614
2615 #[test]
2616 fn test_parse_merge_redeem_single_partial() {
2617 let sender = SuiAddress::random_for_testing_only();
2618 let fss = random_object_ref();
2619 let pt = merge_and_redeem_fss_pt(sender, vec![fss], Some(500_000_000)).expect("pt");
2620 assert_merge_redeem_ops(&parse_pt(sender, pt), sender, &[fss.0], None);
2621 }
2622
2623 #[test]
2624 fn test_parse_merge_redeem_two_all() {
2625 let sender = SuiAddress::random_for_testing_only();
2626 let a = random_object_ref();
2627 let b = random_object_ref();
2628 let pt = merge_and_redeem_fss_pt(sender, vec![a, b], None).expect("pt");
2629 assert_merge_redeem_ops(
2630 &parse_pt(sender, pt),
2631 sender,
2632 &[a.0, b.0],
2633 Some(RedeemMode::All),
2634 );
2635 }
2636
2637 #[test]
2638 fn test_parse_merge_redeem_two_partial() {
2639 let sender = SuiAddress::random_for_testing_only();
2640 let a = random_object_ref();
2641 let b = random_object_ref();
2642 let pt = merge_and_redeem_fss_pt(sender, vec![a, b], Some(500_000_000)).expect("pt");
2643 assert_merge_redeem_ops(&parse_pt(sender, pt), sender, &[a.0, b.0], None);
2644 }
2645
2646 #[test]
2647 fn test_parse_merge_redeem_three_all() {
2648 let sender = SuiAddress::random_for_testing_only();
2649 let a = random_object_ref();
2650 let b = random_object_ref();
2651 let c = random_object_ref();
2652 let pt = merge_and_redeem_fss_pt(sender, vec![a, b, c], None).expect("pt");
2653 assert_merge_redeem_ops(
2654 &parse_pt(sender, pt),
2655 sender,
2656 &[a.0, b.0, c.0],
2657 Some(RedeemMode::All),
2658 );
2659 }
2660
2661 #[test]
2662 fn test_parse_merge_redeem_three_partial() {
2663 let sender = SuiAddress::random_for_testing_only();
2664 let a = random_object_ref();
2665 let b = random_object_ref();
2666 let c = random_object_ref();
2667 let pt = merge_and_redeem_fss_pt(sender, vec![a, b, c], Some(500_000_000)).expect("pt");
2668 assert_merge_redeem_ops(&parse_pt(sender, pt), sender, &[a.0, b.0, c.0], None);
2669 }
2670
2671 #[test]
2672 fn test_parse_merge_redeem_five_all() {
2673 let sender = SuiAddress::random_for_testing_only();
2674 let refs: Vec<_> = (0..5).map(|_| random_object_ref()).collect();
2675 let pt = merge_and_redeem_fss_pt(sender, refs.clone(), None).expect("pt");
2676 let expected: Vec<_> = refs.iter().map(|r| r.0).collect();
2677 assert_merge_redeem_ops(
2678 &parse_pt(sender, pt),
2679 sender,
2680 &expected,
2681 Some(RedeemMode::All),
2682 );
2683 }
2684
2685 #[test]
2686 fn test_parse_merge_redeem_fss_ids_order() {
2687 let sender = SuiAddress::random_for_testing_only();
2689 let a = random_object_ref();
2690 let b = random_object_ref();
2691 let c = random_object_ref();
2692 let pt = merge_and_redeem_fss_pt(sender, vec![a, b, c], None).expect("pt");
2693 let ops = parse_pt(sender, pt);
2694 let Some(OperationMetadata::MergeAndRedeemFungibleStakedSui { fss_ids, .. }) =
2695 ops[0].metadata.clone()
2696 else {
2697 panic!();
2698 };
2699 assert_eq!(fss_ids, vec![a.0, b.0, c.0]);
2700 }
2701
2702 #[test]
2703 fn test_parse_merge_redeem_sender_account() {
2704 let sender = SuiAddress::random_for_testing_only();
2705 let fss = random_object_ref();
2706 let pt = merge_and_redeem_fss_pt(sender, vec![fss], None).expect("pt");
2707 let ops = parse_pt(sender, pt);
2708 assert_eq!(ops[0].account.as_ref().unwrap().address, sender);
2709 }
2710
2711 #[test]
2712 fn test_parse_merge_redeem_no_amount_in_metadata() {
2713 let sender = SuiAddress::random_for_testing_only();
2714 let fss = random_object_ref();
2715 let pt = merge_and_redeem_fss_pt(sender, vec![fss], Some(500_000_000)).expect("pt");
2716 let ops = parse_pt(sender, pt);
2717 let Some(OperationMetadata::MergeAndRedeemFungibleStakedSui { amount, .. }) =
2718 ops[0].metadata.clone()
2719 else {
2720 panic!();
2721 };
2722 assert!(amount.is_none());
2723 }
2724
2725 #[test]
2726 fn test_parse_merge_redeem_no_validator_in_metadata() {
2727 let sender = SuiAddress::random_for_testing_only();
2728 let fss = random_object_ref();
2729 let pt = merge_and_redeem_fss_pt(sender, vec![fss], None).expect("pt");
2730 let ops = parse_pt(sender, pt);
2731 let Some(OperationMetadata::MergeAndRedeemFungibleStakedSui { validator, .. }) =
2732 ops[0].metadata.clone()
2733 else {
2734 panic!();
2735 };
2736 assert!(validator.is_none());
2737 }
2738
2739 fn build_redeem_ptb_with_type_arg(
2744 sender: SuiAddress,
2745 fss: ObjectRef,
2746 coin_type_arg: &str,
2747 ) -> ProgrammableTransaction {
2748 let mut builder = ProgrammableTransactionBuilder::new();
2749 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
2750 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
2751 let balance = builder.command(NativeCommand::move_call(
2752 SUI_SYSTEM_PACKAGE_ID,
2753 Identifier::new("sui_system").unwrap(),
2754 Identifier::new("redeem_fungible_staked_sui").unwrap(),
2755 vec![],
2756 vec![sys, fss_arg],
2757 ));
2758 let coin = builder.command(NativeCommand::move_call(
2759 SUI_FRAMEWORK_PACKAGE_ID,
2760 Identifier::new("coin").unwrap(),
2761 Identifier::new("from_balance").unwrap(),
2762 vec![sui_types::TypeTag::from_str(coin_type_arg).unwrap()],
2763 vec![balance],
2764 ));
2765 let sender_arg = builder.pure(sender).unwrap();
2766 builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
2767 builder.finish()
2768 }
2769
2770 #[test]
2771 fn test_parse_falls_through_redeem_wrong_type_arg() {
2772 let sender = SuiAddress::random_for_testing_only();
2773 let fss = random_object_ref();
2774 let pt = build_redeem_ptb_with_type_arg(sender, fss, "0x2::coin::Coin");
2776 let ops = parse_pt(sender, pt);
2777 assert_falls_through_to_generic(&ops);
2778 }
2779
2780 #[test]
2781 fn test_parse_falls_through_redeem_without_from_balance() {
2782 let sender = SuiAddress::random_for_testing_only();
2783 let fss = random_object_ref();
2784 let mut builder = ProgrammableTransactionBuilder::new();
2786 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
2787 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
2788 let balance = builder.command(NativeCommand::move_call(
2789 SUI_SYSTEM_PACKAGE_ID,
2790 Identifier::new("sui_system").unwrap(),
2791 Identifier::new("redeem_fungible_staked_sui").unwrap(),
2792 vec![],
2793 vec![sys, fss_arg],
2794 ));
2795 let sender_arg = builder.pure(sender).unwrap();
2796 builder.command(NativeCommand::TransferObjects(vec![balance], sender_arg));
2797 let ops = parse_pt(sender, builder.finish());
2798 assert_falls_through_to_generic(&ops);
2799 }
2800
2801 #[test]
2802 fn test_parse_falls_through_redeem_without_transfer() {
2803 let sender = SuiAddress::random_for_testing_only();
2804 let fss = random_object_ref();
2805 let mut builder = ProgrammableTransactionBuilder::new();
2806 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
2807 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
2808 let balance = builder.command(NativeCommand::move_call(
2809 SUI_SYSTEM_PACKAGE_ID,
2810 Identifier::new("sui_system").unwrap(),
2811 Identifier::new("redeem_fungible_staked_sui").unwrap(),
2812 vec![],
2813 vec![sys, fss_arg],
2814 ));
2815 builder.command(NativeCommand::move_call(
2816 SUI_FRAMEWORK_PACKAGE_ID,
2817 Identifier::new("coin").unwrap(),
2818 Identifier::new("from_balance").unwrap(),
2819 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
2820 vec![balance],
2821 ));
2822 let ops = parse_pt(sender, builder.finish());
2824 assert_falls_through_to_generic(&ops);
2825 }
2826
2827 #[test]
2828 fn test_parse_falls_through_redeem_transfer_wrong_recipient() {
2829 let sender = SuiAddress::random_for_testing_only();
2830 let other = SuiAddress::random_for_testing_only();
2831 let fss = random_object_ref();
2832 let mut builder = ProgrammableTransactionBuilder::new();
2833 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
2834 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
2835 let balance = builder.command(NativeCommand::move_call(
2836 SUI_SYSTEM_PACKAGE_ID,
2837 Identifier::new("sui_system").unwrap(),
2838 Identifier::new("redeem_fungible_staked_sui").unwrap(),
2839 vec![],
2840 vec![sys, fss_arg],
2841 ));
2842 let coin = builder.command(NativeCommand::move_call(
2843 SUI_FRAMEWORK_PACKAGE_ID,
2844 Identifier::new("coin").unwrap(),
2845 Identifier::new("from_balance").unwrap(),
2846 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
2847 vec![balance],
2848 ));
2849 let other_arg = builder.pure(other).unwrap();
2851 builder.command(NativeCommand::TransferObjects(vec![coin], other_arg));
2852 let ops = parse_pt(sender, builder.finish());
2853 assert_falls_through_to_generic(&ops);
2854 }
2855
2856 #[test]
2857 fn test_parse_falls_through_redeem_transfer_multiple_objects() {
2858 let sender = SuiAddress::random_for_testing_only();
2859 let fss = random_object_ref();
2860 let other_obj = random_object_ref();
2861 let mut builder = ProgrammableTransactionBuilder::new();
2862 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
2863 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
2864 let balance = builder.command(NativeCommand::move_call(
2865 SUI_SYSTEM_PACKAGE_ID,
2866 Identifier::new("sui_system").unwrap(),
2867 Identifier::new("redeem_fungible_staked_sui").unwrap(),
2868 vec![],
2869 vec![sys, fss_arg],
2870 ));
2871 let coin = builder.command(NativeCommand::move_call(
2872 SUI_FRAMEWORK_PACKAGE_ID,
2873 Identifier::new("coin").unwrap(),
2874 Identifier::new("from_balance").unwrap(),
2875 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
2876 vec![balance],
2877 ));
2878 let extra = builder.obj(ObjectArg::ImmOrOwnedObject(other_obj)).unwrap();
2880 let sender_arg = builder.pure(sender).unwrap();
2881 builder.command(NativeCommand::TransferObjects(
2882 vec![coin, extra],
2883 sender_arg,
2884 ));
2885 let ops = parse_pt(sender, builder.finish());
2886 assert_falls_through_to_generic(&ops);
2887 }
2888
2889 #[test]
2890 fn test_parse_falls_through_hybrid_convert_and_redeem() {
2891 let sender = SuiAddress::random_for_testing_only();
2895 let staked = random_object_ref();
2896 let fss = random_object_ref();
2897 let mut builder = ProgrammableTransactionBuilder::new();
2898 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
2899 let staked_arg = builder.obj(ObjectArg::ImmOrOwnedObject(staked)).unwrap();
2900 let _new_fss = builder.command(NativeCommand::move_call(
2901 SUI_SYSTEM_PACKAGE_ID,
2902 Identifier::new("sui_system").unwrap(),
2903 Identifier::new("convert_to_fungible_staked_sui").unwrap(),
2904 vec![],
2905 vec![sys, staked_arg],
2906 ));
2907 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
2908 let balance = builder.command(NativeCommand::move_call(
2909 SUI_SYSTEM_PACKAGE_ID,
2910 Identifier::new("sui_system").unwrap(),
2911 Identifier::new("redeem_fungible_staked_sui").unwrap(),
2912 vec![],
2913 vec![sys, fss_arg],
2914 ));
2915 let coin = builder.command(NativeCommand::move_call(
2916 SUI_FRAMEWORK_PACKAGE_ID,
2917 Identifier::new("coin").unwrap(),
2918 Identifier::new("from_balance").unwrap(),
2919 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
2920 vec![balance],
2921 ));
2922 let sender_arg = builder.pure(sender).unwrap();
2923 builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
2924 let ops = parse_pt(sender, builder.finish());
2925 assert_falls_through_to_generic(&ops);
2926 }
2927
2928 #[test]
2929 fn test_parse_falls_through_split_without_redeem() {
2930 let sender = SuiAddress::random_for_testing_only();
2931 let fss = random_object_ref();
2932 let mut builder = ProgrammableTransactionBuilder::new();
2933 let _sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
2934 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
2935 let split_amount = builder.pure(100u64).unwrap();
2936 builder.command(NativeCommand::move_call(
2937 SUI_SYSTEM_PACKAGE_ID,
2938 Identifier::new("staking_pool").unwrap(),
2939 Identifier::new("split_fungible_staked_sui").unwrap(),
2940 vec![],
2941 vec![fss_arg, split_amount],
2942 ));
2943 let ops = parse_pt(sender, builder.finish());
2945 assert_falls_through_to_generic(&ops);
2946 }
2947
2948 #[test]
2949 fn test_parse_falls_through_redeem_split_position_wrong() {
2950 let sender = SuiAddress::random_for_testing_only();
2952 let fss_a = random_object_ref();
2953 let fss_b = random_object_ref();
2954 let mut builder = ProgrammableTransactionBuilder::new();
2955 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
2956 let a_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss_a)).unwrap();
2957 let b_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss_b)).unwrap();
2958 let balance = builder.command(NativeCommand::move_call(
2959 SUI_SYSTEM_PACKAGE_ID,
2960 Identifier::new("sui_system").unwrap(),
2961 Identifier::new("redeem_fungible_staked_sui").unwrap(),
2962 vec![],
2963 vec![sys, a_arg],
2964 ));
2965 let split_amount = builder.pure(100u64).unwrap();
2967 builder.command(NativeCommand::move_call(
2968 SUI_SYSTEM_PACKAGE_ID,
2969 Identifier::new("staking_pool").unwrap(),
2970 Identifier::new("split_fungible_staked_sui").unwrap(),
2971 vec![],
2972 vec![b_arg, split_amount],
2973 ));
2974 let coin = builder.command(NativeCommand::move_call(
2975 SUI_FRAMEWORK_PACKAGE_ID,
2976 Identifier::new("coin").unwrap(),
2977 Identifier::new("from_balance").unwrap(),
2978 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
2979 vec![balance],
2980 ));
2981 let sender_arg = builder.pure(sender).unwrap();
2982 builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
2983 let ops = parse_pt(sender, builder.finish());
2984 assert_falls_through_to_generic(&ops);
2985 }
2986
2987 #[test]
2988 fn test_parse_falls_through_redeem_wrong_system_state_immutable() {
2989 let sender = SuiAddress::random_for_testing_only();
2995 let fss = random_object_ref();
2996 let mut builder = ProgrammableTransactionBuilder::new();
2997 let _sys = builder
2999 .obj(ObjectArg::SharedObject {
3000 id: SUI_SYSTEM_STATE_OBJECT_ID,
3001 initial_shared_version: sui_types::SUI_SYSTEM_STATE_OBJECT_SHARED_VERSION,
3002 mutability: sui_types::transaction::SharedObjectMutability::Immutable,
3003 })
3004 .unwrap();
3005 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3006 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3009 let balance = builder.command(NativeCommand::move_call(
3010 SUI_SYSTEM_PACKAGE_ID,
3011 Identifier::new("sui_system").unwrap(),
3012 Identifier::new("redeem_fungible_staked_sui").unwrap(),
3013 vec![],
3014 vec![sys, fss_arg],
3015 ));
3016 let coin = builder.command(NativeCommand::move_call(
3017 SUI_FRAMEWORK_PACKAGE_ID,
3018 Identifier::new("coin").unwrap(),
3019 Identifier::new("from_balance").unwrap(),
3020 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
3021 vec![balance],
3022 ));
3023 let sender_arg = builder.pure(sender).unwrap();
3024 builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
3025 let ops = parse_pt(sender, builder.finish());
3036 assert!(
3039 ops[0].type_ == OperationType::MergeAndRedeemFungibleStakedSui
3040 || ops[0].type_ == OperationType::ProgrammableTransaction,
3041 "unexpected op type: {:?}",
3042 ops[0].type_
3043 );
3044 }
3045
3046 #[test]
3054 fn test_parse_falls_through_convert_without_transfer() {
3055 let sender = SuiAddress::random_for_testing_only();
3056 let staked = random_object_ref();
3057 let mut builder = ProgrammableTransactionBuilder::new();
3058 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3059 let staked_arg = builder.obj(ObjectArg::ImmOrOwnedObject(staked)).unwrap();
3060 let _new_fss = builder.command(NativeCommand::move_call(
3061 SUI_SYSTEM_PACKAGE_ID,
3062 Identifier::new("sui_system").unwrap(),
3063 Identifier::new("convert_to_fungible_staked_sui").unwrap(),
3064 vec![],
3065 vec![sys, staked_arg],
3066 ));
3067 let ops = parse_pt(sender, builder.finish());
3069 assert_falls_through_to_generic(&ops);
3070 }
3071
3072 #[test]
3076 fn test_parse_falls_through_pure_merge_with_transfer() {
3077 let sender = SuiAddress::random_for_testing_only();
3078 let fss_a = random_object_ref();
3079 let fss_b = random_object_ref();
3080 let mut builder = ProgrammableTransactionBuilder::new();
3081 let _sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3082 let first = builder.obj(ObjectArg::ImmOrOwnedObject(fss_a)).unwrap();
3083 let other = builder.obj(ObjectArg::ImmOrOwnedObject(fss_b)).unwrap();
3084 let join_result = builder.command(NativeCommand::move_call(
3085 SUI_SYSTEM_PACKAGE_ID,
3086 Identifier::new("staking_pool").unwrap(),
3087 Identifier::new("join_fungible_staked_sui").unwrap(),
3088 vec![],
3089 vec![first, other],
3090 ));
3091 let sender_arg = builder.pure(sender).unwrap();
3093 builder.command(NativeCommand::TransferObjects(
3094 vec![join_result],
3095 sender_arg,
3096 ));
3097 let ops = parse_pt(sender, builder.finish());
3098 assert_falls_through_to_generic(&ops);
3099 }
3100
3101 #[test]
3104 fn test_parse_falls_through_split_amount_not_pure() {
3105 let sender = SuiAddress::random_for_testing_only();
3106 let fss = random_object_ref();
3107 let bogus_obj = random_object_ref();
3108 let mut builder = ProgrammableTransactionBuilder::new();
3109 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3110 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3111 let bogus_arg = builder.obj(ObjectArg::ImmOrOwnedObject(bogus_obj)).unwrap();
3113 let split_result = builder.command(NativeCommand::move_call(
3114 SUI_SYSTEM_PACKAGE_ID,
3115 Identifier::new("staking_pool").unwrap(),
3116 Identifier::new("split_fungible_staked_sui").unwrap(),
3117 vec![],
3118 vec![fss_arg, bogus_arg],
3119 ));
3120 let balance = builder.command(NativeCommand::move_call(
3121 SUI_SYSTEM_PACKAGE_ID,
3122 Identifier::new("sui_system").unwrap(),
3123 Identifier::new("redeem_fungible_staked_sui").unwrap(),
3124 vec![],
3125 vec![sys, split_result],
3126 ));
3127 let coin = builder.command(NativeCommand::move_call(
3128 SUI_FRAMEWORK_PACKAGE_ID,
3129 Identifier::new("coin").unwrap(),
3130 Identifier::new("from_balance").unwrap(),
3131 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
3132 vec![balance],
3133 ));
3134 let sender_arg = builder.pure(sender).unwrap();
3135 builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
3136 let ops = parse_pt(sender, builder.finish());
3137 assert_falls_through_to_generic(&ops);
3138 }
3139
3140 #[test]
3144 fn test_parse_falls_through_convert_wrong_system_state_arg() {
3145 let sender = SuiAddress::random_for_testing_only();
3146 let staked = random_object_ref();
3147 let mut builder = ProgrammableTransactionBuilder::new();
3148 let _sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3150 let bogus_arg = builder.pure(0u64).unwrap();
3153 let staked_arg = builder.obj(ObjectArg::ImmOrOwnedObject(staked)).unwrap();
3154 let new_fss = builder.command(NativeCommand::move_call(
3155 SUI_SYSTEM_PACKAGE_ID,
3156 Identifier::new("sui_system").unwrap(),
3157 Identifier::new("convert_to_fungible_staked_sui").unwrap(),
3158 vec![],
3159 vec![bogus_arg, staked_arg],
3161 ));
3162 let sender_arg = builder.pure(sender).unwrap();
3163 builder.command(NativeCommand::TransferObjects(vec![new_fss], sender_arg));
3164 let ops = parse_pt(sender, builder.finish());
3165 assert_falls_through_to_generic(&ops);
3166 }
3167
3168 #[test]
3173 fn test_parse_falls_through_consolidate_same_input_both_convert_and_join() {
3174 let sender = SuiAddress::random_for_testing_only();
3175 let shared_input = random_object_ref();
3176 let other_fss = random_object_ref();
3177 let mut builder = ProgrammableTransactionBuilder::new();
3178 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3179 let dual = builder
3181 .obj(ObjectArg::ImmOrOwnedObject(shared_input))
3182 .unwrap();
3183 let fss_b = builder.obj(ObjectArg::ImmOrOwnedObject(other_fss)).unwrap();
3184 builder.command(NativeCommand::move_call(
3186 SUI_SYSTEM_PACKAGE_ID,
3187 Identifier::new("staking_pool").unwrap(),
3188 Identifier::new("join_fungible_staked_sui").unwrap(),
3189 vec![],
3190 vec![dual, fss_b],
3191 ));
3192 let new_fss = builder.command(NativeCommand::move_call(
3194 SUI_SYSTEM_PACKAGE_ID,
3195 Identifier::new("sui_system").unwrap(),
3196 Identifier::new("convert_to_fungible_staked_sui").unwrap(),
3197 vec![],
3198 vec![sys, dual],
3199 ));
3200 let sender_arg = builder.pure(sender).unwrap();
3201 builder.command(NativeCommand::TransferObjects(vec![new_fss], sender_arg));
3202 let ops = parse_pt(sender, builder.finish());
3203 assert_falls_through_to_generic(&ops);
3204 }
3205
3206 #[test]
3211 fn test_meta_merge_redeem_old_input_all() {
3212 let v = SuiAddress::random_for_testing_only();
3213 let json = serde_json::json!({
3214 "MergeAndRedeemFungibleStakedSui": {
3215 "validator": v.to_string(),
3216 "redeem_mode": "All"
3217 }
3218 });
3219 let meta: OperationMetadata = serde_json::from_value(json).unwrap();
3220 match meta {
3221 OperationMetadata::MergeAndRedeemFungibleStakedSui {
3222 validator,
3223 amount,
3224 redeem_mode,
3225 fss_ids,
3226 } => {
3227 assert_eq!(validator, Some(v));
3228 assert!(amount.is_none());
3229 assert_eq!(redeem_mode, Some(RedeemMode::All));
3230 assert!(fss_ids.is_empty());
3231 }
3232 _ => panic!("wrong variant"),
3233 }
3234 }
3235
3236 #[test]
3237 fn test_meta_merge_redeem_old_input_atleast() {
3238 let v = SuiAddress::random_for_testing_only();
3239 let json = serde_json::json!({
3240 "MergeAndRedeemFungibleStakedSui": {
3241 "validator": v.to_string(),
3242 "amount": "500000000000",
3243 "redeem_mode": "AtLeast"
3244 }
3245 });
3246 let meta: OperationMetadata = serde_json::from_value(json).unwrap();
3247 match meta {
3248 OperationMetadata::MergeAndRedeemFungibleStakedSui {
3249 validator,
3250 amount,
3251 redeem_mode,
3252 fss_ids,
3253 } => {
3254 assert_eq!(validator, Some(v));
3255 assert_eq!(amount, Some("500000000000".to_string()));
3256 assert_eq!(redeem_mode, Some(RedeemMode::AtLeast));
3257 assert!(fss_ids.is_empty());
3258 }
3259 _ => panic!(),
3260 }
3261 }
3262
3263 #[test]
3264 fn test_meta_merge_redeem_new_parse_output() {
3265 let id = ObjectID::random();
3266 let meta = OperationMetadata::MergeAndRedeemFungibleStakedSui {
3267 validator: None,
3268 amount: None,
3269 redeem_mode: Some(RedeemMode::All),
3270 fss_ids: vec![id],
3271 };
3272 let json = serde_json::to_value(&meta).unwrap();
3273 let obj = json
3274 .as_object()
3275 .unwrap()
3276 .get("MergeAndRedeemFungibleStakedSui")
3277 .unwrap()
3278 .as_object()
3279 .unwrap();
3280 assert!(!obj.contains_key("validator"));
3281 assert!(!obj.contains_key("amount"));
3282 assert_eq!(obj.get("redeem_mode").unwrap(), "All");
3283 assert_eq!(obj.get("fss_ids").unwrap().as_array().unwrap().len(), 1);
3284 }
3285
3286 #[test]
3287 fn test_meta_merge_redeem_new_parse_output_partial() {
3288 let id = ObjectID::random();
3289 let meta = OperationMetadata::MergeAndRedeemFungibleStakedSui {
3290 validator: None,
3291 amount: None,
3292 redeem_mode: None,
3293 fss_ids: vec![id],
3294 };
3295 let json = serde_json::to_value(&meta).unwrap();
3296 let obj = json
3297 .as_object()
3298 .unwrap()
3299 .get("MergeAndRedeemFungibleStakedSui")
3300 .unwrap()
3301 .as_object()
3302 .unwrap();
3303 assert!(!obj.contains_key("validator"));
3304 assert!(!obj.contains_key("amount"));
3305 assert!(
3306 !obj.contains_key("redeem_mode"),
3307 "redeem_mode must be omitted in partial parse output"
3308 );
3309 assert_eq!(obj.get("fss_ids").unwrap().as_array().unwrap().len(), 1);
3310 }
3311
3312 #[test]
3317 fn test_write_merge_redeem_requires_validator_and_mode() {
3318 let sender = SuiAddress::random_for_testing_only();
3319
3320 let op = Operation {
3322 operation_identifier: Default::default(),
3323 type_: OperationType::MergeAndRedeemFungibleStakedSui,
3324 status: None,
3325 account: Some(sender.into()),
3326 amount: None,
3327 coin_change: None,
3328 metadata: Some(OperationMetadata::MergeAndRedeemFungibleStakedSui {
3329 validator: None,
3330 amount: None,
3331 redeem_mode: Some(RedeemMode::All),
3332 fss_ids: vec![],
3333 }),
3334 };
3335 let err = Operations::new(vec![op])
3336 .into_internal()
3337 .expect_err("should fail without validator");
3338 assert!(format!("{err}").contains("validator"));
3339
3340 let op = Operation {
3342 operation_identifier: Default::default(),
3343 type_: OperationType::MergeAndRedeemFungibleStakedSui,
3344 status: None,
3345 account: Some(sender.into()),
3346 amount: None,
3347 coin_change: None,
3348 metadata: Some(OperationMetadata::MergeAndRedeemFungibleStakedSui {
3349 validator: Some(SuiAddress::random_for_testing_only()),
3350 amount: None,
3351 redeem_mode: None,
3352 fss_ids: vec![],
3353 }),
3354 };
3355 let err = Operations::new(vec![op])
3356 .into_internal()
3357 .expect_err("should fail without redeem_mode");
3358 assert!(format!("{err}").contains("redeem_mode"));
3359 }
3360}