1use std::collections::{BTreeMap, 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::Transaction as ProtoTransaction;
24use sui_rpc::proto::sui::rpc::v2::TransactionKind;
25use sui_rpc::proto::sui::rpc::v2::argument::ArgumentKind;
26use sui_rpc::proto::sui::rpc::v2::command::Command;
27use sui_rpc::proto::sui::rpc::v2::input::InputKind;
28use sui_rpc::proto::sui::rpc::v2::transaction_kind::Data as TransactionKindData;
29use sui_rpc::proto::sui::rpc::v2::transaction_kind::Kind::ProgrammableTransaction as ProgrammableTransactionKind;
30use sui_types::base_types::{ObjectID, SequenceNumber, SuiAddress};
31use sui_types::gas_coin::GasCoin;
32use sui_types::governance::{ADD_STAKE_FUN_NAME, WITHDRAW_STAKE_FUN_NAME};
33use sui_types::sui_system_state::SUI_SYSTEM_MODULE_NAME;
34use sui_types::{
35 SUI_FRAMEWORK_PACKAGE_ID, SUI_SYSTEM_ADDRESS, SUI_SYSTEM_PACKAGE_ID, SUI_SYSTEM_STATE_OBJECT_ID,
36};
37
38#[cfg(test)]
39use crate::types::RedeemPlan;
40use crate::types::internal_operation::{
41 ConsolidateAllStakedSuiToFungible, MergeAndRedeemFungibleStakedSui, PayCoin, PaySui, Stake,
42 WithdrawStake,
43};
44use crate::types::{
45 AccountIdentifier, Amount, AuxData, CoinAction, CoinChange, CoinID, CoinIdentifier, Currency,
46 InternalOperation, OperationIdentifier, OperationStatus, OperationType, RedeemMode,
47};
48use crate::{CoinMetadataCache, Error, SUI};
49
50#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
51pub struct Operations(Vec<Operation>);
52
53#[derive(Clone, Debug)]
58pub(crate) enum PaymentCurrency {
59 Sui,
61 NonSui(Currency),
63 Unresolvable,
67}
68
69#[derive(Debug)]
71struct TxCurrencies {
72 by_coin_type: BTreeMap<String, Currency>,
75 payment: PaymentCurrency,
77}
78
79async fn resolve_tx_currencies(
97 balance_changes: &[BalanceChange],
98 cache: &CoinMetadataCache,
99) -> Result<TxCurrencies, Error> {
100 let mut currencies: BTreeMap<String, Currency> = BTreeMap::new();
101 let mut any_unresolvable = false;
102 for balance_change in balance_changes {
103 let coin_type = balance_change.coin_type();
104 if coin_type == SUI.metadata.coin_type {
109 currencies.insert(coin_type.to_string(), SUI.clone());
110 continue;
111 }
112 let Ok(struct_tag) = StructTag::from_str(coin_type) else {
119 tracing::debug!(coin_type, "coin type is not a struct type; generic_op");
120 any_unresolvable = true;
121 continue;
122 };
123 let type_tag = sui_types::TypeTag::Struct(Box::new(struct_tag));
124 match cache.get_currency(&type_tag).await {
133 Ok(currency) if !currency.symbol.is_empty() => {
134 currencies.insert(coin_type.to_string(), currency);
135 }
136 Ok(_) | Err(Error::MissingMetadata) => {
137 tracing::debug!(coin_type, "non-SUI coin metadata unresolved; generic_op");
138 any_unresolvable = true;
139 }
140 Err(Error::SuiRpcError(status)) if status.code() == tonic::Code::NotFound => {
141 tracing::debug!(coin_type, "non-SUI coin metadata not found; generic_op");
142 any_unresolvable = true;
143 }
144 Err(e) => {
149 return Err(Error::CoinMetadataUnavailable(format!(
150 "resolving coin metadata for {coin_type}: {e}"
151 )));
152 }
153 }
154 }
155
156 let non_sui: Vec<&Currency> = currencies
157 .values()
158 .filter(|c| c.metadata.coin_type != SUI.metadata.coin_type)
159 .collect();
160 let payment = if any_unresolvable {
161 PaymentCurrency::Unresolvable
162 } else {
163 match non_sui.as_slice() {
164 [] => PaymentCurrency::Sui,
165 [c] => PaymentCurrency::NonSui((*c).clone()),
166 many => {
167 tracing::debug!(
170 non_sui_count = many.len(),
171 "multiple non-SUI currencies in balance changes; emitting \
172 generic_op rather than guessing PayCoin label"
173 );
174 PaymentCurrency::Unresolvable
175 }
176 }
177 };
178 Ok(TxCurrencies {
179 by_coin_type: currencies,
180 payment,
181 })
182}
183
184impl FromIterator<Operation> for Operations {
185 fn from_iter<T: IntoIterator<Item = Operation>>(iter: T) -> Self {
186 Operations::new(iter.into_iter().collect())
187 }
188}
189
190impl FromIterator<Vec<Operation>> for Operations {
191 fn from_iter<T: IntoIterator<Item = Vec<Operation>>>(iter: T) -> Self {
192 iter.into_iter().flatten().collect()
193 }
194}
195
196impl IntoIterator for Operations {
197 type Item = Operation;
198 type IntoIter = vec::IntoIter<Operation>;
199 fn into_iter(self) -> Self::IntoIter {
200 self.0.into_iter()
201 }
202}
203
204impl Operations {
205 pub fn new(mut ops: Vec<Operation>) -> Self {
206 for (index, op) in ops.iter_mut().enumerate() {
207 op.operation_identifier = (index as u64).into()
208 }
209 Self(ops)
210 }
211
212 pub fn contains(&self, other: &Operations) -> bool {
213 for (i, other_op) in other.0.iter().enumerate() {
214 if let Some(op) = self.0.get(i) {
215 if op != other_op {
216 return false;
217 }
218 } else {
219 return false;
220 }
221 }
222 true
223 }
224
225 pub fn set_status(mut self, status: Option<OperationStatus>) -> Self {
226 for op in &mut self.0 {
227 op.status = status
228 }
229 self
230 }
231
232 pub fn type_(&self) -> Option<OperationType> {
233 self.0.first().map(|op| op.type_)
234 }
235
236 pub fn into_internal(self) -> Result<InternalOperation, Error> {
238 let type_ = self
239 .type_()
240 .ok_or_else(|| Error::MissingInput("Operation type".into()))?;
241 match type_ {
242 OperationType::PaySui => self.pay_sui_ops_to_internal(),
243 OperationType::PayCoin => self.pay_coin_ops_to_internal(),
244 OperationType::Stake => self.stake_ops_to_internal(),
245 OperationType::WithdrawStake => self.withdraw_stake_ops_to_internal(),
246 OperationType::ConsolidateAllStakedSuiToFungible => {
247 self.consolidate_to_fungible_ops_to_internal()
248 }
249 OperationType::MergeAndRedeemFungibleStakedSui => {
250 self.merge_and_redeem_fss_ops_to_internal()
251 }
252 op => Err(Error::UnsupportedOperation(op)),
253 }
254 }
255
256 fn pay_sui_ops_to_internal(self) -> Result<InternalOperation, Error> {
257 let mut recipients = vec![];
258 let mut amounts = vec![];
259 let mut sender = None;
260 for op in self {
261 if let (Some(amount), Some(account)) = (op.amount.clone(), op.account.clone()) {
262 if amount.value.is_negative() {
263 sender = Some(account.address)
264 } else {
265 recipients.push(account.address);
266 let amount = amount.value.abs();
267 if amount > u64::MAX as i128 {
268 return Err(Error::InvalidInput(
269 "Input amount exceed u64::MAX".to_string(),
270 ));
271 }
272 amounts.push(amount as u64)
273 }
274 }
275 }
276 let sender = sender.ok_or_else(|| Error::MissingInput("Sender address".to_string()))?;
277 Ok(InternalOperation::PaySui(PaySui {
278 sender,
279 recipients,
280 amounts,
281 }))
282 }
283
284 fn pay_coin_ops_to_internal(self) -> Result<InternalOperation, Error> {
285 let mut recipients = vec![];
286 let mut amounts = vec![];
287 let mut sender = None;
288 let mut currency = None;
289 for op in self {
290 if let (Some(amount), Some(account)) = (op.amount.clone(), op.account.clone()) {
291 currency = currency.or(Some(amount.currency));
292 if amount.value.is_negative() {
293 sender = Some(account.address)
294 } else {
295 recipients.push(account.address);
296 let amount = amount.value.abs();
297 if amount > u64::MAX as i128 {
298 return Err(Error::InvalidInput(
299 "Input amount exceed u64::MAX".to_string(),
300 ));
301 }
302 amounts.push(amount as u64)
303 }
304 }
305 }
306 let sender = sender.ok_or_else(|| Error::MissingInput("Sender address".to_string()))?;
307 let currency = currency.ok_or_else(|| Error::MissingInput("Currency".to_string()))?;
308 Ok(InternalOperation::PayCoin(PayCoin {
309 sender,
310 recipients,
311 amounts,
312 currency,
313 }))
314 }
315
316 fn stake_ops_to_internal(self) -> Result<InternalOperation, Error> {
317 let mut ops = self
318 .0
319 .into_iter()
320 .filter(|op| op.type_ == OperationType::Stake)
321 .collect::<Vec<_>>();
322 if ops.len() != 1 {
323 return Err(Error::MalformedOperationError(
324 "Delegation should only have one operation.".into(),
325 ));
326 }
327 let op = ops.pop().unwrap();
329 let sender = op
330 .account
331 .ok_or_else(|| Error::MissingInput("Sender address".to_string()))?
332 .address;
333 let metadata = op
334 .metadata
335 .ok_or_else(|| Error::MissingInput("Stake metadata".to_string()))?;
336
337 let amount = if let Some(amount) = op.amount {
339 if amount.value.is_positive() {
340 return Err(Error::MalformedOperationError(
341 "Stake amount should be negative.".into(),
342 ));
343 }
344 Some(amount.value.unsigned_abs() as u64)
345 } else {
346 None
347 };
348
349 let OperationMetadata::Stake { validator } = metadata else {
350 return Err(Error::InvalidInput(
351 "Cannot find delegation info from metadata.".into(),
352 ));
353 };
354
355 Ok(InternalOperation::Stake(Stake {
356 sender,
357 validator,
358 amount,
359 }))
360 }
361
362 fn withdraw_stake_ops_to_internal(self) -> Result<InternalOperation, Error> {
363 let mut ops = self
364 .0
365 .into_iter()
366 .filter(|op| op.type_ == OperationType::WithdrawStake)
367 .collect::<Vec<_>>();
368 if ops.len() != 1 {
369 return Err(Error::MalformedOperationError(
370 "Delegation should only have one operation.".into(),
371 ));
372 }
373 let op = ops.pop().unwrap();
375 let sender = op
376 .account
377 .ok_or_else(|| Error::MissingInput("Sender address".to_string()))?
378 .address;
379
380 let stake_ids = if let Some(metadata) = op.metadata {
381 let OperationMetadata::WithdrawStake { stake_ids } = metadata else {
382 return Err(Error::InvalidInput(
383 "Cannot find withdraw stake info from metadata.".into(),
384 ));
385 };
386 stake_ids
387 } else {
388 vec![]
389 };
390
391 Ok(InternalOperation::WithdrawStake(WithdrawStake {
392 sender,
393 stake_ids,
394 }))
395 }
396
397 fn consolidate_to_fungible_ops_to_internal(self) -> Result<InternalOperation, Error> {
398 let mut ops = self
399 .0
400 .into_iter()
401 .filter(|op| op.type_ == OperationType::ConsolidateAllStakedSuiToFungible)
402 .collect::<Vec<_>>();
403 if ops.len() != 1 {
404 return Err(Error::MalformedOperationError(
405 "ConsolidateAllStakedSuiToFungible should only have one operation.".into(),
406 ));
407 }
408 let op = ops.pop().unwrap();
409 let sender = op
410 .account
411 .ok_or_else(|| Error::MissingInput("Sender address".to_string()))?
412 .address;
413 let metadata = op.metadata.ok_or_else(|| {
414 Error::MissingInput("ConsolidateAllStakedSuiToFungible metadata".to_string())
415 })?;
416 let OperationMetadata::ConsolidateAllStakedSuiToFungible { validator, .. } = metadata
417 else {
418 return Err(Error::InvalidInput(
419 "Cannot find validator from ConsolidateAllStakedSuiToFungible metadata.".into(),
420 ));
421 };
422 let validator = validator.ok_or_else(|| {
423 Error::MissingInput("validator required for ConsolidateAllStakedSuiToFungible".into())
424 })?;
425 Ok(InternalOperation::ConsolidateAllStakedSuiToFungible(
426 ConsolidateAllStakedSuiToFungible { sender, validator },
427 ))
428 }
429
430 fn merge_and_redeem_fss_ops_to_internal(self) -> Result<InternalOperation, Error> {
431 let mut ops = self
432 .0
433 .into_iter()
434 .filter(|op| op.type_ == OperationType::MergeAndRedeemFungibleStakedSui)
435 .collect::<Vec<_>>();
436 if ops.len() != 1 {
437 return Err(Error::MalformedOperationError(
438 "MergeAndRedeemFungibleStakedSui should only have one operation.".into(),
439 ));
440 }
441 let op = ops.pop().unwrap();
442 let sender = op
443 .account
444 .ok_or_else(|| Error::MissingInput("Sender address".to_string()))?
445 .address;
446 let metadata = op.metadata.ok_or_else(|| {
447 Error::MissingInput("MergeAndRedeemFungibleStakedSui metadata".to_string())
448 })?;
449 let OperationMetadata::MergeAndRedeemFungibleStakedSui {
450 validator,
451 amount,
452 redeem_mode,
453 ..
454 } = metadata
455 else {
456 return Err(Error::InvalidInput(
457 "Cannot find MergeAndRedeemFungibleStakedSui info from metadata.".into(),
458 ));
459 };
460 let validator = validator.ok_or_else(|| {
461 Error::MissingInput("validator required for MergeAndRedeemFungibleStakedSui".into())
462 })?;
463 let redeem_mode = redeem_mode.ok_or_else(|| {
464 Error::MissingInput("redeem_mode required for MergeAndRedeemFungibleStakedSui".into())
465 })?;
466 let amount = match &redeem_mode {
467 RedeemMode::All => None,
468 _ => {
469 let amount_str = amount.ok_or_else(|| {
470 Error::MissingInput("amount required for AtLeast/AtMost mode".to_string())
471 })?;
472 let parsed = amount_str
473 .parse::<u64>()
474 .map_err(|e| Error::InvalidInput(format!("Invalid amount: {}", e)))?;
475 if parsed == 0 {
476 return Err(Error::InvalidInput(
477 "amount must be at least 1 MIST".to_string(),
478 ));
479 }
480 Some(parsed)
481 }
482 };
483 Ok(InternalOperation::MergeAndRedeemFungibleStakedSui(
484 MergeAndRedeemFungibleStakedSui {
485 sender,
486 validator,
487 amount,
488 redeem_mode,
489 },
490 ))
491 }
492
493 pub(crate) fn from_transaction(
494 tx: TransactionKind,
495 sender: SuiAddress,
496 status: Option<OperationStatus>,
497 currency: PaymentCurrency,
498 ) -> Result<Vec<Operation>, Error> {
499 let TransactionKind { data, kind, .. } = tx;
500 Ok(match data {
501 Some(TransactionKindData::ProgrammableTransaction(pt))
502 if status != Some(OperationStatus::Failure) =>
503 {
504 Self::parse_programmable_transaction(sender, status, pt, currency)?
505 }
506 data => {
507 let mut tx = TransactionKind::default();
508 tx.data = data;
509 tx.kind = kind;
510 vec![Operation::generic_op(status, sender, tx)]
511 }
512 })
513 }
514
515 fn parse_programmable_transaction(
516 sender: SuiAddress,
517 status: Option<OperationStatus>,
518 pt: ProgrammableTransaction,
519 currency: PaymentCurrency,
520 ) -> Result<Vec<Operation>, Error> {
521 #[derive(Debug)]
522 enum KnownValue {
523 GasCoin(u64),
524 }
525 fn resolve_result(
526 known_results: &[Vec<KnownValue>],
527 i: u32,
528 j: u32,
529 ) -> Option<&KnownValue> {
530 known_results
531 .get(i as usize)
532 .and_then(|inner| inner.get(j as usize))
533 }
534 fn split_coins(
535 inputs: &[Input],
536 known_results: &[Vec<KnownValue>],
537 coin: &Argument,
538 amounts: &[Argument],
539 ) -> Option<Vec<KnownValue>> {
540 match coin.kind() {
541 ArgumentKind::Gas => (),
542 ArgumentKind::Result => {
543 let i = coin.result?;
544 let subresult_idx = coin.subresult.unwrap_or(0);
545 let KnownValue::GasCoin(_) = resolve_result(known_results, i, subresult_idx)?;
546 }
547 ArgumentKind::Input => (),
549 _ => return None,
550 };
551
552 let amounts = amounts
553 .iter()
554 .map(|amount| {
555 let value: u64 = match amount.kind() {
556 ArgumentKind::Input => {
557 let input_idx = amount.input() as usize;
558 let input = inputs.get(input_idx)?;
559 match input.kind() {
560 InputKind::Pure => {
561 let bytes = input.pure();
562 bcs::from_bytes(bytes).ok()?
563 }
564 _ => return None,
565 }
566 }
567 _ => return None,
568 };
569 Some(KnownValue::GasCoin(value))
570 })
571 .collect::<Option<_>>()?;
572 Some(amounts)
573 }
574 fn transfer_object(
575 aggregated_recipients: &mut HashMap<SuiAddress, u64>,
576 inputs: &[Input],
577 known_results: &[Vec<KnownValue>],
578 objs: &[Argument],
579 recipient: &Argument,
580 ) -> Option<Vec<KnownValue>> {
581 let addr = match recipient.kind() {
582 ArgumentKind::Input => {
583 let input_idx = recipient.input() as usize;
584 let input = inputs.get(input_idx)?;
585 match input.kind() {
586 InputKind::Pure => {
587 let bytes = input.pure();
588 bcs::from_bytes::<SuiAddress>(bytes).ok()?
589 }
590 _ => return None,
591 }
592 }
593 _ => return None,
594 };
595 for obj in objs {
596 let i = match obj.kind() {
597 ArgumentKind::Result => obj.result(),
598 _ => return None,
599 };
600
601 let subresult_idx = obj.subresult.unwrap_or(0);
602 let KnownValue::GasCoin(value) = resolve_result(known_results, i, subresult_idx)?;
603
604 let aggregate = aggregated_recipients.entry(addr).or_default();
605 *aggregate += value;
606 }
607 Some(vec![])
608 }
609 fn into_balance_passthrough(
610 known_results: &[Vec<KnownValue>],
611 call: &MoveCall,
612 ) -> Option<Vec<KnownValue>> {
613 let args = &call.arguments;
614 if let Some(coin_arg) = args.first() {
615 match coin_arg.kind() {
616 ArgumentKind::Result => {
617 let cmd_idx = coin_arg.result?;
618 let sub_idx = coin_arg.subresult.unwrap_or(0);
619 let KnownValue::GasCoin(val) =
620 resolve_result(known_results, cmd_idx, sub_idx)?;
621 Some(vec![KnownValue::GasCoin(*val)])
622 }
623 _ => Some(vec![KnownValue::GasCoin(0)]),
626 }
627 } else {
628 Some(vec![KnownValue::GasCoin(0)])
629 }
630 }
631 fn send_funds_transfer(
632 aggregated_recipients: &mut HashMap<SuiAddress, u64>,
633 inputs: &[Input],
634 known_results: &[Vec<KnownValue>],
635 call: &MoveCall,
636 sender: SuiAddress,
637 ) -> Option<Vec<KnownValue>> {
638 let args = &call.arguments;
639 if args.len() < 2 {
640 return Some(vec![]);
641 }
642 let balance_arg = &args[0];
643 let recipient_arg = &args[1];
644
645 let amount = match balance_arg.kind() {
647 ArgumentKind::Result => {
648 let cmd_idx = balance_arg.result?;
649 let sub_idx = balance_arg.subresult.unwrap_or(0);
650 let KnownValue::GasCoin(val) = resolve_result(known_results, cmd_idx, sub_idx)?;
651 *val
652 }
653 _ => return Some(vec![]),
654 };
655
656 let addr = match recipient_arg.kind() {
658 ArgumentKind::Input => {
659 let input_idx = recipient_arg.input() as usize;
660 let input = inputs.get(input_idx)?;
661 if input.kind() == InputKind::Pure {
662 bcs::from_bytes::<SuiAddress>(input.pure()).ok()?
663 } else {
664 return Some(vec![]);
665 }
666 }
667 _ => return Some(vec![]),
668 };
669
670 if addr != sender {
672 *aggregated_recipients.entry(addr).or_insert(0) += amount;
673 }
674 Some(vec![])
675 }
676 fn stake_call(
677 inputs: &[Input],
678 known_results: &[Vec<KnownValue>],
679 call: &MoveCall,
680 ) -> Result<Option<(Option<u64>, SuiAddress)>, Error> {
681 let arguments = &call.arguments;
682 let (amount, validator) = match &arguments[..] {
683 [system_state_arg, coin, validator] => {
684 let amount = match coin.kind() {
685 ArgumentKind::Result => {
686 let i = coin
687 .result
688 .ok_or_else(|| anyhow!("Result argument missing index"))?;
689 let KnownValue::GasCoin(value) = resolve_result(known_results, i, 0)
690 .ok_or_else(|| {
691 anyhow!("Cannot resolve Gas coin value at Result({i})")
692 })?;
693 value
694 }
695 _ => return Ok(None),
696 };
697 let system_state_idx = match system_state_arg.kind() {
698 ArgumentKind::Input => system_state_arg.input(),
699 _ => return Ok(None),
700 };
701 let (some_amount, validator) = match validator.kind() {
702 ArgumentKind::Input => {
705 let i = validator.input();
706 let validator_addr = match inputs.get(i as usize) {
707 Some(input) if input.kind() == InputKind::Pure => {
708 bcs::from_bytes::<SuiAddress>(input.pure()).ok()
709 }
710 _ => None,
711 };
712 (i < system_state_idx, Ok(validator_addr))
713 }
714 _ => return Ok(None),
715 };
716 (some_amount.then_some(*amount), validator)
717 }
718 _ => Err(anyhow!(
719 "Error encountered when extracting arguments from move call, expecting 3 elements, got {}",
720 arguments.len()
721 ))?,
722 };
723 validator.map(|v| v.map(|v| (amount, v)))
724 }
725
726 fn unstake_call(inputs: &[Input], call: &MoveCall) -> Result<Option<ObjectID>, Error> {
727 let arguments = &call.arguments;
728 let id = match &arguments[..] {
729 [system_state_arg, stake_id] => match stake_id.kind() {
730 ArgumentKind::Input => {
731 let i = stake_id.input();
732 let id = match inputs.get(i as usize) {
733 Some(input) if input.kind() == InputKind::ImmutableOrOwned => input
734 .object_id
735 .as_ref()
736 .and_then(|oid| ObjectID::from_str(oid).ok()),
737 _ => None,
738 }
739 .ok_or_else(|| anyhow!("Cannot find stake id from input args."))?;
740 let system_state_idx = match system_state_arg.kind() {
743 ArgumentKind::Input => system_state_arg.input(),
744 _ => return Ok(None),
745 };
746 let some_id = system_state_idx < i;
747 some_id.then_some(id)
748 }
749 _ => None,
750 },
751 _ => Err(anyhow!(
752 "Error encountered when extracting arguments from move call, expecting 2 elements, got {}",
753 arguments.len()
754 ))?,
755 };
756 Ok(id)
757 }
758 let inputs = &pt.inputs;
759 let commands = &pt.commands;
760 let mut known_results: Vec<Vec<KnownValue>> = vec![];
761 let mut aggregated_recipients: HashMap<SuiAddress, u64> = HashMap::new();
762 let mut needs_generic = false;
763 let mut operations = vec![];
764 let mut stake_ids = vec![];
765
766 let has_redeem_fss = commands.iter().any(|c| {
771 matches!(
772 &c.command,
773 Some(Command::MoveCall(m)) if Self::is_redeem_fss_call(m)
774 )
775 });
776 let has_convert_fss = commands.iter().any(|c| {
777 matches!(
778 &c.command,
779 Some(Command::MoveCall(m)) if Self::is_convert_to_fss_call(m)
780 )
781 });
782 let has_join_fss = commands.iter().any(|c| {
783 matches!(
784 &c.command,
785 Some(Command::MoveCall(m)) if Self::is_join_fss_call(m)
786 )
787 });
788 if has_redeem_fss
789 && let Some(ops) = Self::parse_merge_and_redeem(sender, inputs, commands, status)
790 {
791 return Ok(ops);
792 }
793 if !has_redeem_fss
794 && (has_convert_fss || has_join_fss)
795 && let Some(ops) = Self::parse_consolidate(sender, inputs, commands, status)
796 {
797 return Ok(ops);
798 }
799 for command in commands {
803 let result = match &command.command {
804 Some(Command::SplitCoins(split)) => {
805 let coin = split.coin();
806 split_coins(inputs, &known_results, coin, &split.amounts)
807 }
808 Some(Command::TransferObjects(transfer)) => {
809 let addr = transfer.address();
810 transfer_object(
811 &mut aggregated_recipients,
812 inputs,
813 &known_results,
814 &transfer.objects,
815 addr,
816 )
817 }
818 Some(Command::MoveCall(m)) if Self::is_stake_call(m) => {
819 stake_call(inputs, &known_results, m)?.map(|(amount, validator)| {
820 let amount = amount.map(|amount| Amount::new(-(amount as i128), None));
821 operations.push(Operation {
822 operation_identifier: Default::default(),
823 type_: OperationType::Stake,
824 status,
825 account: Some(sender.into()),
826 amount,
827 coin_change: None,
828 metadata: Some(OperationMetadata::Stake { validator }),
829 });
830 vec![]
831 })
832 }
833 Some(Command::MoveCall(m)) if Self::is_unstake_call(m) => {
834 let stake_id = unstake_call(inputs, m)?;
835 stake_ids.push(stake_id);
836 Some(vec![])
837 }
838 Some(Command::MergeCoins(_)) => {
839 Some(vec![])
841 }
842 Some(Command::MoveCall(m)) if Self::is_coin_redeem_funds_call(m) => {
845 Some(vec![KnownValue::GasCoin(0)])
846 }
847 Some(Command::MoveCall(m)) if Self::is_coin_into_balance_call(m) => {
848 into_balance_passthrough(&known_results, m)
849 }
850 Some(Command::MoveCall(m))
851 if Self::is_balance_send_funds_call(m) || Self::is_coin_send_funds_call(m) =>
852 {
853 send_funds_transfer(
854 &mut aggregated_recipients,
855 inputs,
856 &known_results,
857 m,
858 sender,
859 )
860 }
861 Some(Command::MoveCall(m))
862 if Self::is_coin_destroy_zero_call(m) || Self::is_balance_join_call(m) =>
863 {
864 Some(vec![])
865 }
866 _ => None,
867 };
868 if let Some(result) = result {
869 known_results.push(result)
870 } else {
871 needs_generic = true;
872 break;
873 }
874 }
875
876 aggregated_recipients.retain(|recipient, amount| !(*recipient == sender && *amount == 0));
883
884 if !needs_generic
885 && !matches!(currency, PaymentCurrency::Unresolvable)
886 && !aggregated_recipients.is_empty()
887 {
888 let total_paid: u64 = aggregated_recipients.values().copied().sum();
889 operations.extend(
890 aggregated_recipients
891 .into_iter()
892 .map(|(recipient, amount)| {
893 match ¤cy {
894 PaymentCurrency::NonSui(c) => Operation::pay_coin(
895 status,
896 recipient,
897 amount.into(),
898 Some(c.clone()),
899 ),
900 _ => Operation::pay_sui(status, recipient, amount.into()),
902 }
903 }),
904 );
905 match ¤cy {
906 PaymentCurrency::NonSui(c) => operations.push(Operation::pay_coin(
907 status,
908 sender,
909 -(total_paid as i128),
910 Some(c.clone()),
911 )),
912 _ => operations.push(Operation::pay_sui(status, sender, -(total_paid as i128))),
913 }
914 } else if !stake_ids.is_empty() {
915 let stake_ids = stake_ids.into_iter().flatten().collect::<Vec<_>>();
916 let metadata = stake_ids
917 .is_empty()
918 .not()
919 .then_some(OperationMetadata::WithdrawStake { stake_ids });
920 operations.push(Operation {
921 operation_identifier: Default::default(),
922 type_: OperationType::WithdrawStake,
923 status,
924 account: Some(sender.into()),
925 amount: None,
926 coin_change: None,
927 metadata,
928 });
929 } else if operations.is_empty() {
930 let tx_kind = TransactionKind::default()
931 .with_kind(ProgrammableTransactionKind)
932 .with_programmable_transaction(pt);
933 operations.push(Operation::generic_op(status, sender, tx_kind))
934 }
935 Ok(operations)
936 }
937
938 fn parse_consolidate(
947 sender: SuiAddress,
948 inputs: &[Input],
949 commands: &[sui_rpc::proto::sui::rpc::v2::Command],
950 status: Option<OperationStatus>,
951 ) -> Option<Vec<Operation>> {
952 use std::collections::BTreeSet;
953
954 if !Self::first_input_is_sui_system_state(inputs) {
955 return None;
956 }
957
958 let mut staked_sui_indices: Vec<u32> = Vec::new();
959 let mut fss_indices: Vec<u32> = Vec::new();
960 let mut staked_seen: BTreeSet<u32> = BTreeSet::new();
961 let mut fss_seen: BTreeSet<u32> = BTreeSet::new();
962 let mut saw_transfer = false;
963
964 for (idx, command) in commands.iter().enumerate() {
965 if saw_transfer {
966 return None;
967 }
968 match &command.command {
969 Some(Command::MoveCall(m)) if Self::is_convert_to_fss_call(m) => {
970 if m.arguments.len() != 2 {
971 return None;
972 }
973 if m.arguments[0].kind() != ArgumentKind::Input || m.arguments[0].input() != 0 {
976 return None;
977 }
978 let staked_arg = &m.arguments[1];
979 if staked_arg.kind() != ArgumentKind::Input {
980 return None;
981 }
982 let i = staked_arg.input();
983 if fss_seen.contains(&i) {
984 return None;
985 }
986 if staked_seen.insert(i) {
987 staked_sui_indices.push(i);
988 }
989 }
990 Some(Command::MoveCall(m)) if Self::is_join_fss_call(m) => {
991 if m.arguments.len() != 2 {
992 return None;
993 }
994 for arg in &m.arguments {
995 match arg.kind() {
996 ArgumentKind::Input => {
997 let i = arg.input();
998 if staked_seen.contains(&i) {
999 return None;
1000 }
1001 if fss_seen.insert(i) {
1002 fss_indices.push(i);
1003 }
1004 }
1005 ArgumentKind::Result => {}
1006 _ => return None,
1007 }
1008 }
1009 }
1010 Some(Command::TransferObjects(transfer)) => {
1011 if transfer.objects.len() != 1 {
1012 return None;
1013 }
1014 if transfer.objects[0].kind() != ArgumentKind::Result {
1015 return None;
1016 }
1017 let addr_arg = transfer.address();
1018 if addr_arg.kind() != ArgumentKind::Input {
1019 return None;
1020 }
1021 let recipient = inputs.get(addr_arg.input() as usize).and_then(|inp| {
1022 if inp.kind() == InputKind::Pure {
1023 bcs::from_bytes::<SuiAddress>(inp.pure()).ok()
1024 } else {
1025 None
1026 }
1027 })?;
1028 if recipient != sender {
1029 return None;
1030 }
1031 if idx + 1 != commands.len() {
1032 return None;
1033 }
1034 saw_transfer = true;
1035 }
1036 _ => return None,
1037 }
1038 }
1039
1040 if staked_sui_indices.is_empty() && fss_indices.is_empty() {
1041 return None;
1042 }
1043
1044 let expect_transfer = !staked_sui_indices.is_empty() && fss_indices.is_empty();
1050 if expect_transfer != saw_transfer {
1051 return None;
1052 }
1053
1054 let staked_sui_ids = Self::input_indices_to_object_ids(inputs, &staked_sui_indices)?;
1055 let fss_ids = Self::input_indices_to_object_ids(inputs, &fss_indices)?;
1056
1057 Some(vec![Operation {
1058 operation_identifier: Default::default(),
1059 type_: OperationType::ConsolidateAllStakedSuiToFungible,
1060 status,
1061 account: Some(sender.into()),
1062 amount: None,
1063 coin_change: None,
1064 metadata: Some(OperationMetadata::ConsolidateAllStakedSuiToFungible {
1065 validator: None,
1066 staked_sui_ids,
1067 fss_ids,
1068 }),
1069 }])
1070 }
1071
1072 fn parse_merge_and_redeem(
1100 sender: SuiAddress,
1101 inputs: &[Input],
1102 commands: &[sui_rpc::proto::sui::rpc::v2::Command],
1103 status: Option<OperationStatus>,
1104 ) -> Option<Vec<Operation>> {
1105 use std::collections::BTreeSet;
1106
1107 if !Self::first_input_is_sui_system_state(inputs) {
1108 return None;
1109 }
1110
1111 #[derive(PartialEq, Eq)]
1112 enum Phase {
1113 Joins,
1114 AfterSplit,
1115 AfterRedeem,
1116 AfterBalanceSplit,
1117 AfterBalanceJoin,
1118 AfterFromBalance,
1119 Done,
1120 }
1121
1122 let mut phase = Phase::Joins;
1123 let mut fss_indices: Vec<u32> = Vec::new();
1124 let mut fss_seen: BTreeSet<u32> = BTreeSet::new();
1125 let mut has_split_fss = false;
1126 let mut has_balance_guard = false;
1127 let mut min_sui_recovered: Option<u64> = None;
1128 let mut redeem_cmd_idx: Option<u32> = None;
1133 let mut balance_split_cmd_idx: Option<u32> = None;
1134 let mut coin_from_balance_cmd_idx: Option<u32> = None;
1135
1136 for (idx, command) in commands.iter().enumerate() {
1137 if phase == Phase::Done {
1138 return None;
1139 }
1140 match &command.command {
1141 Some(Command::MoveCall(m)) if Self::is_join_fss_call(m) => {
1142 if phase != Phase::Joins {
1143 return None;
1144 }
1145 if m.arguments.len() != 2 {
1146 return None;
1147 }
1148 for arg in &m.arguments {
1149 match arg.kind() {
1150 ArgumentKind::Input => {
1151 let i = arg.input();
1152 if fss_seen.insert(i) {
1153 fss_indices.push(i);
1154 }
1155 }
1156 ArgumentKind::Result => {}
1157 _ => return None,
1158 }
1159 }
1160 }
1161 Some(Command::MoveCall(m)) if Self::is_split_fss_call(m) => {
1162 if phase != Phase::Joins {
1163 return None;
1164 }
1165 if m.arguments.len() != 2 {
1166 return None;
1167 }
1168 let first = &m.arguments[0];
1169 match first.kind() {
1170 ArgumentKind::Input => {
1171 let i = first.input();
1172 if fss_seen.insert(i) {
1173 fss_indices.push(i);
1174 }
1175 }
1176 ArgumentKind::Result => {}
1177 _ => return None,
1178 }
1179 if m.arguments[1].kind() != ArgumentKind::Input {
1180 return None;
1181 }
1182 let amount_idx = m.arguments[1].input() as usize;
1183 if inputs.get(amount_idx).map(|i| i.kind()) != Some(InputKind::Pure) {
1184 return None;
1185 }
1186 has_split_fss = true;
1187 phase = Phase::AfterSplit;
1188 }
1189 Some(Command::MoveCall(m)) if Self::is_redeem_fss_call(m) => {
1190 if phase != Phase::Joins && phase != Phase::AfterSplit {
1191 return None;
1192 }
1193 if m.arguments.len() != 2 {
1194 return None;
1195 }
1196 if m.arguments[0].kind() != ArgumentKind::Input || m.arguments[0].input() != 0 {
1197 return None;
1198 }
1199 let fss_arg = &m.arguments[1];
1200 match fss_arg.kind() {
1201 ArgumentKind::Input => {
1202 let i = fss_arg.input();
1203 if fss_seen.insert(i) {
1204 fss_indices.push(i);
1205 }
1206 }
1207 ArgumentKind::Result => {}
1208 _ => return None,
1209 }
1210 redeem_cmd_idx = Some(idx as u32);
1211 phase = Phase::AfterRedeem;
1212 }
1213 Some(Command::MoveCall(m)) if Self::is_balance_split_sui_call(m) => {
1214 if phase != Phase::AfterRedeem {
1215 return None;
1216 }
1217 if m.arguments.len() != 2 {
1218 return None;
1219 }
1220 if !Self::is_result_of(&m.arguments[0], redeem_cmd_idx) {
1222 return None;
1223 }
1224 if m.arguments[1].kind() != ArgumentKind::Input {
1226 return None;
1227 }
1228 let amount_idx = m.arguments[1].input() as usize;
1229 let pure_input = inputs.get(amount_idx)?;
1230 if pure_input.kind() != InputKind::Pure {
1231 return None;
1232 }
1233 let min_sui = bcs::from_bytes::<u64>(pure_input.pure()).ok()?;
1236 min_sui_recovered = Some(min_sui);
1237 balance_split_cmd_idx = Some(idx as u32);
1238 phase = Phase::AfterBalanceSplit;
1239 }
1240 Some(Command::MoveCall(m)) if Self::is_balance_join_sui_call(m) => {
1241 if phase != Phase::AfterBalanceSplit {
1242 return None;
1243 }
1244 if m.arguments.len() != 2 {
1245 return None;
1246 }
1247 if !Self::is_result_of(&m.arguments[0], redeem_cmd_idx) {
1252 return None;
1253 }
1254 if !Self::is_result_of(&m.arguments[1], balance_split_cmd_idx) {
1255 return None;
1256 }
1257 has_balance_guard = true;
1258 phase = Phase::AfterBalanceJoin;
1259 }
1260 Some(Command::MoveCall(m)) if Self::is_coin_from_balance_sui_call(m) => {
1261 if phase != Phase::AfterRedeem && phase != Phase::AfterBalanceJoin {
1262 return None;
1263 }
1264 if m.arguments.len() != 1 {
1265 return None;
1266 }
1267 if !Self::is_result_of(&m.arguments[0], redeem_cmd_idx) {
1270 return None;
1271 }
1272 coin_from_balance_cmd_idx = Some(idx as u32);
1273 phase = Phase::AfterFromBalance;
1274 }
1275 Some(Command::TransferObjects(transfer)) => {
1276 if phase != Phase::AfterFromBalance {
1277 return None;
1278 }
1279 if transfer.objects.len() != 1 {
1280 return None;
1281 }
1282 if !Self::is_result_of(&transfer.objects[0], coin_from_balance_cmd_idx) {
1288 return None;
1289 }
1290 let addr_arg = transfer.address();
1291 if addr_arg.kind() != ArgumentKind::Input {
1292 return None;
1293 }
1294 let recipient = inputs.get(addr_arg.input() as usize).and_then(|inp| {
1295 if inp.kind() == InputKind::Pure {
1296 bcs::from_bytes::<SuiAddress>(inp.pure()).ok()
1297 } else {
1298 None
1299 }
1300 })?;
1301 if recipient != sender {
1302 return None;
1303 }
1304 if idx + 1 != commands.len() {
1305 return None;
1306 }
1307 phase = Phase::Done;
1308 }
1309 _ => return None,
1310 }
1311 }
1312
1313 if phase != Phase::Done {
1314 return None;
1315 }
1316 if fss_indices.is_empty() {
1317 return None;
1318 }
1319
1320 let fss_ids = Self::input_indices_to_object_ids(inputs, &fss_indices)?;
1321 let (redeem_mode, amount) = match (has_split_fss, has_balance_guard) {
1337 (false, false) => (Some(RedeemMode::All), None),
1338 (true, true) | (false, true) => (
1339 Some(RedeemMode::AtLeast),
1340 min_sui_recovered.map(|v| v.to_string()),
1341 ),
1342 (true, false) => (None, None),
1343 };
1344
1345 Some(vec![Operation {
1346 operation_identifier: Default::default(),
1347 type_: OperationType::MergeAndRedeemFungibleStakedSui,
1348 status,
1349 account: Some(sender.into()),
1350 amount: None,
1351 coin_change: None,
1352 metadata: Some(OperationMetadata::MergeAndRedeemFungibleStakedSui {
1353 validator: None,
1354 amount,
1355 redeem_mode,
1356 fss_ids,
1357 }),
1358 }])
1359 }
1360
1361 fn first_input_is_sui_system_state(inputs: &[Input]) -> bool {
1368 let Some(first) = inputs.first() else {
1369 return false;
1370 };
1371 if first.kind() != InputKind::Shared {
1372 return false;
1373 }
1374 let Some(oid_str) = first.object_id.as_ref() else {
1375 return false;
1376 };
1377 let Ok(oid) = ObjectID::from_str(oid_str) else {
1378 return false;
1379 };
1380 oid == SUI_SYSTEM_STATE_OBJECT_ID
1381 }
1382
1383 fn is_result_of(arg: &Argument, expected_idx: Option<u32>) -> bool {
1396 let Some(expected) = expected_idx else {
1397 return false;
1398 };
1399 arg.kind() == ArgumentKind::Result
1400 && arg.result() == expected
1401 && arg.subresult_opt().is_none()
1402 }
1403
1404 fn input_indices_to_object_ids(inputs: &[Input], indices: &[u32]) -> Option<Vec<ObjectID>> {
1407 indices
1408 .iter()
1409 .map(|&i| {
1410 let inp = inputs.get(i as usize)?;
1411 if inp.kind() != InputKind::ImmutableOrOwned {
1412 return None;
1413 }
1414 ObjectID::from_str(inp.object_id.as_ref()?).ok()
1415 })
1416 .collect()
1417 }
1418
1419 fn is_stake_call(tx: &MoveCall) -> bool {
1420 let package_id = match ObjectID::from_str(tx.package()) {
1421 Ok(id) => id,
1422 Err(e) => {
1423 warn!(
1424 package = tx.package(),
1425 error = %e,
1426 "Failed to parse package ID for MoveCall"
1427 );
1428 return false;
1429 }
1430 };
1431
1432 package_id == SUI_SYSTEM_PACKAGE_ID
1433 && tx.module() == SUI_SYSTEM_MODULE_NAME.as_str()
1434 && tx.function() == ADD_STAKE_FUN_NAME.as_str()
1435 }
1436
1437 fn is_unstake_call(tx: &MoveCall) -> bool {
1438 let package_id = match ObjectID::from_str(tx.package()) {
1439 Ok(id) => id,
1440 Err(e) => {
1441 warn!(
1442 package = tx.package(),
1443 error = %e,
1444 "Failed to parse package ID for MoveCall"
1445 );
1446 return false;
1447 }
1448 };
1449
1450 package_id == SUI_SYSTEM_PACKAGE_ID
1451 && tx.module() == SUI_SYSTEM_MODULE_NAME.as_str()
1452 && (tx.function() == WITHDRAW_STAKE_FUN_NAME.as_str()
1453 || tx.function() == "request_withdraw_stake_non_entry")
1454 }
1455
1456 fn is_convert_to_fss_call(tx: &MoveCall) -> bool {
1459 let package_id = match ObjectID::from_str(tx.package()) {
1460 Ok(id) => id,
1461 Err(e) => {
1462 warn!(
1463 package = tx.package(),
1464 error = %e,
1465 "Failed to parse package ID for MoveCall"
1466 );
1467 return false;
1468 }
1469 };
1470 package_id == SUI_SYSTEM_PACKAGE_ID
1471 && tx.module() == SUI_SYSTEM_MODULE_NAME.as_str()
1472 && tx.function() == "convert_to_fungible_staked_sui"
1473 }
1474
1475 fn is_join_fss_call(tx: &MoveCall) -> bool {
1479 let package_id = match ObjectID::from_str(tx.package()) {
1480 Ok(id) => id,
1481 Err(e) => {
1482 warn!(
1483 package = tx.package(),
1484 error = %e,
1485 "Failed to parse package ID for MoveCall"
1486 );
1487 return false;
1488 }
1489 };
1490 package_id == SUI_SYSTEM_PACKAGE_ID
1491 && tx.module() == "staking_pool"
1492 && tx.function() == "join_fungible_staked_sui"
1493 }
1494
1495 fn is_redeem_fss_call(tx: &MoveCall) -> bool {
1498 let package_id = match ObjectID::from_str(tx.package()) {
1499 Ok(id) => id,
1500 Err(e) => {
1501 warn!(
1502 package = tx.package(),
1503 error = %e,
1504 "Failed to parse package ID for MoveCall"
1505 );
1506 return false;
1507 }
1508 };
1509 package_id == SUI_SYSTEM_PACKAGE_ID
1510 && tx.module() == SUI_SYSTEM_MODULE_NAME.as_str()
1511 && tx.function() == "redeem_fungible_staked_sui"
1512 }
1513
1514 fn is_split_fss_call(tx: &MoveCall) -> bool {
1517 let package_id = match ObjectID::from_str(tx.package()) {
1518 Ok(id) => id,
1519 Err(e) => {
1520 warn!(
1521 package = tx.package(),
1522 error = %e,
1523 "Failed to parse package ID for MoveCall"
1524 );
1525 return false;
1526 }
1527 };
1528 package_id == SUI_SYSTEM_PACKAGE_ID
1529 && tx.module() == "staking_pool"
1530 && tx.function() == "split_fungible_staked_sui"
1531 }
1532
1533 fn is_coin_from_balance_sui_call(tx: &MoveCall) -> bool {
1537 let Ok(package_id) = ObjectID::from_str(tx.package()) else {
1538 return false;
1539 };
1540 if package_id != SUI_FRAMEWORK_PACKAGE_ID {
1541 return false;
1542 }
1543 if tx.module() != "coin" || tx.function() != "from_balance" {
1544 return false;
1545 }
1546 if tx.type_arguments.len() != 1 {
1547 return false;
1548 }
1549 let Ok(parsed) = sui_types::TypeTag::from_str(&tx.type_arguments[0]) else {
1553 return false;
1554 };
1555 let Ok(expected) = sui_types::TypeTag::from_str("0x2::sui::SUI") else {
1556 return false;
1557 };
1558 parsed == expected
1559 }
1560
1561 fn is_balance_split_sui_call(tx: &MoveCall) -> bool {
1564 Self::is_balance_op_sui_call(tx, "split")
1565 }
1566
1567 fn is_balance_join_sui_call(tx: &MoveCall) -> bool {
1570 Self::is_balance_op_sui_call(tx, "join")
1571 }
1572
1573 fn is_balance_op_sui_call(tx: &MoveCall, function: &str) -> bool {
1574 let Ok(package_id) = ObjectID::from_str(tx.package()) else {
1575 return false;
1576 };
1577 if package_id != SUI_FRAMEWORK_PACKAGE_ID {
1578 return false;
1579 }
1580 if tx.module() != "balance" || tx.function() != function {
1581 return false;
1582 }
1583 if tx.type_arguments.len() != 1 {
1584 return false;
1585 }
1586 let Ok(parsed) = sui_types::TypeTag::from_str(&tx.type_arguments[0]) else {
1587 return false;
1588 };
1589 let Ok(expected) = sui_types::TypeTag::from_str("0x2::sui::SUI") else {
1590 return false;
1591 };
1592 parsed == expected
1593 }
1594
1595 fn is_coin_redeem_funds_call(tx: &MoveCall) -> bool {
1597 let package_id = match ObjectID::from_str(tx.package()) {
1598 Ok(id) => id,
1599 Err(_) => return false,
1600 };
1601 package_id == SUI_FRAMEWORK_PACKAGE_ID
1602 && tx.module() == "coin"
1603 && tx.function() == "redeem_funds"
1604 }
1605
1606 fn is_coin_into_balance_call(tx: &MoveCall) -> bool {
1607 let package_id = match ObjectID::from_str(tx.package()) {
1608 Ok(id) => id,
1609 Err(_) => return false,
1610 };
1611 package_id == SUI_FRAMEWORK_PACKAGE_ID
1612 && tx.module() == "coin"
1613 && tx.function() == "into_balance"
1614 }
1615
1616 fn is_balance_send_funds_call(tx: &MoveCall) -> bool {
1617 let package_id = match ObjectID::from_str(tx.package()) {
1618 Ok(id) => id,
1619 Err(_) => return false,
1620 };
1621 package_id == SUI_FRAMEWORK_PACKAGE_ID
1622 && tx.module() == "balance"
1623 && tx.function() == "send_funds"
1624 }
1625
1626 fn is_coin_send_funds_call(tx: &MoveCall) -> bool {
1627 let package_id = match ObjectID::from_str(tx.package()) {
1628 Ok(id) => id,
1629 Err(_) => return false,
1630 };
1631 package_id == SUI_FRAMEWORK_PACKAGE_ID
1632 && tx.module() == "coin"
1633 && tx.function() == "send_funds"
1634 }
1635
1636 fn is_coin_destroy_zero_call(tx: &MoveCall) -> bool {
1637 let package_id = match ObjectID::from_str(tx.package()) {
1638 Ok(id) => id,
1639 Err(_) => return false,
1640 };
1641 package_id == SUI_FRAMEWORK_PACKAGE_ID
1642 && tx.module() == "coin"
1643 && tx.function() == "destroy_zero"
1644 }
1645
1646 fn is_balance_join_call(tx: &MoveCall) -> bool {
1647 let package_id = match ObjectID::from_str(tx.package()) {
1648 Ok(id) => id,
1649 Err(_) => return false,
1650 };
1651 package_id == SUI_FRAMEWORK_PACKAGE_ID
1652 && tx.module() == "balance"
1653 && tx.function() == "join"
1654 }
1655
1656 fn process_balance_change(
1657 gas_owner: SuiAddress,
1658 gas_used: i128,
1659 balance_changes: &[(BalanceChange, Currency)],
1660 status: Option<OperationStatus>,
1661 balances: HashMap<(SuiAddress, Currency), i128>,
1662 ) -> impl Iterator<Item = Operation> {
1663 let mut balances =
1664 balance_changes
1665 .iter()
1666 .fold(balances, |mut balances, (balance_change, ccy)| {
1667 if let (Some(addr_str), Some(amount_str)) =
1668 (&balance_change.address, &balance_change.amount)
1669 && let (Ok(owner), Ok(amount)) =
1670 (SuiAddress::from_str(addr_str), i128::from_str(amount_str))
1671 {
1672 *balances.entry((owner, ccy.clone())).or_default() += amount;
1673 }
1674 balances
1675 });
1676 *balances.entry((gas_owner, SUI.clone())).or_default() -= gas_used;
1678
1679 let balance_change = balances.into_iter().filter(|(_, amount)| *amount != 0).map(
1680 move |((addr, currency), amount)| {
1681 Operation::balance_change(status, addr, amount, currency)
1682 },
1683 );
1684
1685 let gas = if gas_used != 0 {
1686 vec![Operation::gas(gas_owner, gas_used)]
1687 } else {
1688 vec![]
1690 };
1691 balance_change.chain(gas)
1692 }
1693
1694 fn is_gascoin_transfer(tx: &TransactionKind) -> bool {
1696 if let Some(TransactionKindData::ProgrammableTransaction(pt)) = &tx.data {
1697 return pt.commands.iter().any(|command| {
1698 if let Some(Command::TransferObjects(transfer)) = &command.command {
1699 transfer
1700 .objects
1701 .iter()
1702 .any(|arg| arg.kind() == ArgumentKind::Gas)
1703 } else {
1704 false
1705 }
1706 });
1707 }
1708 false
1709 }
1710
1711 fn add_missing_gas_owner(operations: &mut Vec<Operation>, gas_owner: SuiAddress) {
1714 if !operations.iter().any(|operation| {
1715 if let Some(amount) = &operation.amount
1716 && let Some(account) = &operation.account
1717 && account.address == gas_owner
1718 && amount.currency == *SUI
1719 {
1720 return true;
1721 }
1722 false
1723 }) {
1724 operations.push(Operation::balance_change(
1725 Some(OperationStatus::Success),
1726 gas_owner,
1727 0,
1728 SUI.clone(),
1729 ));
1730 }
1731 }
1732
1733 fn validate_operations(
1736 initial_balance_changes: &[(BalanceChange, Currency)],
1737 new_operations: &[Operation],
1738 ) -> Result<(), anyhow::Error> {
1739 let balances: HashMap<(SuiAddress, Currency), i128> = HashMap::new();
1740 let mut initial_balances =
1741 initial_balance_changes
1742 .iter()
1743 .fold(balances, |mut balances, (balance_change, ccy)| {
1744 if let (Some(addr_str), Some(amount_str)) =
1745 (&balance_change.address, &balance_change.amount)
1746 && let (Ok(owner), Ok(amount)) =
1747 (SuiAddress::from_str(addr_str), i128::from_str(amount_str))
1748 {
1749 *balances.entry((owner, ccy.clone())).or_default() += amount;
1750 }
1751 balances
1752 });
1753
1754 let mut new_balances = HashMap::new();
1755 for op in new_operations {
1756 if let Some(Amount {
1757 currency, value, ..
1758 }) = &op.amount
1759 {
1760 if let Some(account) = &op.account {
1761 let balance_change = new_balances
1762 .remove(&(account.address, currency.clone()))
1763 .unwrap_or(0)
1764 + value;
1765 new_balances.insert((account.address, currency.clone()), balance_change);
1766 } else {
1767 return Err(anyhow!("Missing account for a balance-change"));
1768 }
1769 }
1770 }
1771
1772 for ((address, currency), amount_expected) in new_balances {
1773 let new_amount = initial_balances.remove(&(address, currency)).unwrap_or(0);
1774 if new_amount != amount_expected {
1775 return Err(anyhow!(
1776 "Expected {} balance-change for {} but got {}",
1777 amount_expected,
1778 address,
1779 new_amount
1780 ));
1781 }
1782 }
1783 if !initial_balances.is_empty() {
1784 return Err(anyhow!(
1785 "Expected every item in initial_balances to be mapped"
1786 ));
1787 }
1788 Ok(())
1789 }
1790
1791 fn process_gascoin_transfer(
1796 coin_change_operations: &mut impl Iterator<Item = Operation>,
1797 is_gascoin_transfer: bool,
1798 prev_gas_owner: SuiAddress,
1799 new_gas_owner: SuiAddress,
1800 gas_used: i128,
1801 initial_balance_changes: &[(BalanceChange, Currency)],
1802 ) -> Result<Vec<Operation>, anyhow::Error> {
1803 let mut operations = vec![];
1804 if is_gascoin_transfer && prev_gas_owner != new_gas_owner {
1805 operations = coin_change_operations.collect();
1806 Self::add_missing_gas_owner(&mut operations, prev_gas_owner);
1807 Self::add_missing_gas_owner(&mut operations, new_gas_owner);
1808 for operation in &mut operations {
1809 match operation.type_ {
1810 OperationType::Gas => {
1811 operation.account = Some(prev_gas_owner.into())
1814 }
1815 OperationType::SuiBalanceChange => {
1816 let account = operation
1817 .account
1818 .as_ref()
1819 .ok_or_else(|| anyhow!("Missing account for a balance-change"))?;
1820 let amount = operation
1821 .amount
1822 .as_mut()
1823 .ok_or_else(|| anyhow!("Missing amount for a balance-change"))?;
1824 if account.address == prev_gas_owner && amount.currency == *SUI {
1826 amount.value -= gas_used;
1827 } else if account.address == new_gas_owner && amount.currency == *SUI {
1828 amount.value += gas_used;
1829 }
1830 }
1831 _ => {
1832 return Err(anyhow!(
1833 "Discarding unsupported operation type {:?}",
1834 operation.type_
1835 ));
1836 }
1837 }
1838 }
1839 Self::validate_operations(initial_balance_changes, &operations)?;
1840 }
1841 Ok(operations)
1842 }
1843}
1844
1845impl Operations {
1846 pub async fn try_from_executed_transaction(
1847 executed_tx: ExecutedTransaction,
1848 cache: &CoinMetadataCache,
1849 ) -> Result<Self, Error> {
1850 let ExecutedTransaction {
1851 transaction,
1852 effects,
1853 events,
1854 balance_changes,
1855 ..
1856 } = executed_tx;
1857
1858 let transaction = transaction.ok_or_else(|| {
1859 Error::DataError("ExecutedTransaction missing transaction".to_string())
1860 })?;
1861 let effects = effects
1862 .ok_or_else(|| Error::DataError("ExecutedTransaction missing effects".to_string()))?;
1863
1864 let sender = SuiAddress::from_str(transaction.sender())?;
1865
1866 let gas_output_owner = effects.gas_object().output_owner().address();
1871 let gas_owner = if !gas_output_owner.is_empty() {
1872 SuiAddress::from_str(gas_output_owner)?
1873 } else if sender == SuiAddress::ZERO {
1874 sender
1876 } else {
1877 SuiAddress::from_str(transaction.gas_payment().owner())?
1881 };
1882
1883 let gas_summary = effects.gas_used();
1884 let gas_used = gas_summary.storage_rebate_opt().unwrap_or(0) as i128
1885 - gas_summary.storage_cost_opt().unwrap_or(0) as i128
1886 - gas_summary.computation_cost_opt().unwrap_or(0) as i128;
1887
1888 let status = Some(effects.status().into());
1889
1890 let prev_gas_owner = SuiAddress::from_str(transaction.gas_payment().owner())?;
1891
1892 let tx_kind = transaction
1893 .kind
1894 .ok_or_else(|| Error::DataError("Transaction missing kind".to_string()))?;
1895 let is_gascoin_transfer = Self::is_gascoin_transfer(&tx_kind);
1896
1897 let TxCurrencies {
1901 by_coin_type: currencies,
1902 payment,
1903 } = resolve_tx_currencies(&balance_changes, cache).await?;
1904 let ops = Self::new(Self::from_transaction(tx_kind, sender, status, payment)?);
1905 let ops = ops.into_iter();
1906
1907 let mut accounted_balances =
1910 ops.as_ref()
1911 .iter()
1912 .fold(HashMap::new(), |mut balances, op| {
1913 if let (Some(acc), Some(amount), Some(OperationStatus::Success)) =
1914 (&op.account, &op.amount, &op.status)
1915 {
1916 *balances
1917 .entry((acc.address, amount.clone().currency))
1918 .or_default() -= amount.value;
1919 }
1920 balances
1921 });
1922
1923 let mut principal_amounts = 0;
1924 let mut reward_amounts = 0;
1925
1926 let events = events.as_ref().map(|e| e.events.as_slice()).unwrap_or(&[]);
1928 for event in events {
1929 let event_type = event.event_type();
1930 if let Ok(type_tag) = StructTag::from_str(event_type)
1931 && is_unstake_event(&type_tag)
1932 && let Some(json) = &event.json
1933 && let Some(Kind::StructValue(struct_val)) = &json.kind
1934 {
1935 if let Some(principal_field) = struct_val.fields.get("principal_amount")
1936 && let Some(Kind::StringValue(s)) = &principal_field.kind
1937 && let Ok(amount) = i128::from_str(s)
1938 {
1939 principal_amounts += amount;
1940 }
1941 if let Some(reward_field) = struct_val.fields.get("reward_amount")
1942 && let Some(Kind::StringValue(s)) = &reward_field.kind
1943 && let Ok(amount) = i128::from_str(s)
1944 {
1945 reward_amounts += amount;
1946 }
1947 }
1948 }
1949 let staking_balance = if principal_amounts != 0 {
1950 *accounted_balances.entry((sender, SUI.clone())).or_default() -= principal_amounts;
1951 *accounted_balances.entry((sender, SUI.clone())).or_default() -= reward_amounts;
1952 vec![
1953 Operation::stake_principle(status, sender, principal_amounts),
1954 Operation::stake_reward(status, sender, reward_amounts),
1955 ]
1956 } else {
1957 vec![]
1958 };
1959
1960 let balance_changes_with_currency: Vec<_> = balance_changes
1963 .iter()
1964 .filter_map(|bc| {
1965 currencies
1966 .get(bc.coin_type())
1967 .map(|c| (bc.clone(), c.clone()))
1968 })
1969 .collect();
1970
1971 let mut coin_change_operations = Self::process_balance_change(
1973 gas_owner,
1974 gas_used,
1975 &balance_changes_with_currency,
1976 status,
1977 accounted_balances.clone(),
1978 );
1979
1980 let gascoin_transfer_operations = Self::process_gascoin_transfer(
1983 &mut coin_change_operations,
1984 is_gascoin_transfer,
1985 prev_gas_owner,
1986 gas_owner,
1987 gas_used,
1988 &balance_changes_with_currency,
1989 )?;
1990
1991 let ops: Operations = ops
1992 .into_iter()
1993 .chain(coin_change_operations)
1994 .chain(gascoin_transfer_operations)
1995 .chain(staking_balance)
1996 .collect();
1997
1998 let mutually_cancelling_balances: HashMap<_, _> = ops
2003 .clone()
2004 .into_iter()
2005 .fold(
2006 HashMap::new(),
2007 |mut balances: HashMap<(SuiAddress, Currency), i128>, op| {
2008 if let (Some(acc), Some(amount), Some(OperationStatus::Success)) =
2009 (&op.account, &op.amount, &op.status)
2010 && op.type_ != OperationType::Gas
2011 {
2012 *balances
2013 .entry((acc.address, amount.clone().currency))
2014 .or_default() += amount.value;
2015 }
2016 balances
2017 },
2018 )
2019 .into_iter()
2020 .filter(|balance| {
2021 let (_, amount) = balance;
2022 *amount == 0
2023 })
2024 .collect();
2025
2026 let ops: Operations = ops
2027 .into_iter()
2028 .filter(|op| {
2029 if let (Some(acc), Some(amount)) = (&op.account, &op.amount) {
2030 return op.type_ == OperationType::Gas
2031 || !mutually_cancelling_balances
2032 .contains_key(&(acc.address, amount.clone().currency));
2033 }
2034 true
2035 })
2036 .collect();
2037 Ok(ops)
2038 }
2039}
2040
2041fn is_unstake_event(tag: &StructTag) -> bool {
2042 tag.address == SUI_SYSTEM_ADDRESS
2043 && tag.module.as_ident_str() == ident_str!("validator")
2044 && tag.name.as_ident_str() == ident_str!("UnstakingRequestEvent")
2045}
2046
2047#[derive(Deserialize, Serialize, Clone, Debug)]
2048pub struct Operation {
2049 operation_identifier: OperationIdentifier,
2050 #[serde(rename = "type")]
2051 pub type_: OperationType,
2052 #[serde(default, skip_serializing_if = "Option::is_none")]
2053 pub status: Option<OperationStatus>,
2054 #[serde(default, skip_serializing_if = "Option::is_none")]
2055 pub account: Option<AccountIdentifier>,
2056 #[serde(default, skip_serializing_if = "Option::is_none")]
2057 pub amount: Option<Amount>,
2058 #[serde(default, skip_serializing_if = "Option::is_none")]
2059 pub coin_change: Option<CoinChange>,
2060 #[serde(default, skip_serializing_if = "Option::is_none")]
2061 pub metadata: Option<OperationMetadata>,
2062}
2063
2064impl PartialEq for Operation {
2065 fn eq(&self, other: &Self) -> bool {
2066 self.operation_identifier == other.operation_identifier
2067 && self.type_ == other.type_
2068 && self.account == other.account
2069 && self.amount == other.amount
2070 && self.coin_change == other.coin_change
2071 && self.metadata == other.metadata
2072 }
2073}
2074
2075#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
2076pub enum OperationMetadata {
2077 GenericTransaction(TransactionKind),
2078 Stake {
2079 validator: SuiAddress,
2080 },
2081 WithdrawStake {
2082 stake_ids: Vec<ObjectID>,
2083 },
2084 ConsolidateAllStakedSuiToFungible {
2085 #[serde(default, skip_serializing_if = "Option::is_none")]
2086 validator: Option<SuiAddress>,
2087 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2088 staked_sui_ids: Vec<ObjectID>,
2089 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2090 fss_ids: Vec<ObjectID>,
2091 },
2092 MergeAndRedeemFungibleStakedSui {
2093 #[serde(default, skip_serializing_if = "Option::is_none")]
2094 validator: Option<SuiAddress>,
2095 #[serde(default, skip_serializing_if = "Option::is_none")]
2096 amount: Option<String>,
2097 #[serde(default, skip_serializing_if = "Option::is_none")]
2098 redeem_mode: Option<RedeemMode>,
2099 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2100 fss_ids: Vec<ObjectID>,
2101 },
2102}
2103
2104impl Operation {
2105 fn generic_op(
2106 status: Option<OperationStatus>,
2107 sender: SuiAddress,
2108 tx: TransactionKind,
2109 ) -> Self {
2110 Operation {
2111 operation_identifier: Default::default(),
2112 type_: (&tx).into(),
2113 status,
2114 account: Some(sender.into()),
2115 amount: None,
2116 coin_change: None,
2117 metadata: Some(OperationMetadata::GenericTransaction(tx)),
2118 }
2119 }
2120
2121 pub fn genesis(index: u64, sender: SuiAddress, coin: GasCoin) -> Self {
2122 Operation {
2123 operation_identifier: index.into(),
2124 type_: OperationType::Genesis,
2125 status: Some(OperationStatus::Success),
2126 account: Some(sender.into()),
2127 amount: Some(Amount::new(coin.value().into(), None)),
2128 coin_change: Some(CoinChange {
2129 coin_identifier: CoinIdentifier {
2130 identifier: CoinID {
2131 id: *coin.id(),
2132 version: SequenceNumber::new(),
2133 },
2134 },
2135 coin_action: CoinAction::CoinCreated,
2136 }),
2137 metadata: None,
2138 }
2139 }
2140
2141 fn pay_sui(status: Option<OperationStatus>, address: SuiAddress, amount: i128) -> Self {
2142 Operation {
2143 operation_identifier: Default::default(),
2144 type_: OperationType::PaySui,
2145 status,
2146 account: Some(address.into()),
2147 amount: Some(Amount::new(amount, None)),
2148 coin_change: None,
2149 metadata: None,
2150 }
2151 }
2152
2153 fn pay_coin(
2154 status: Option<OperationStatus>,
2155 address: SuiAddress,
2156 amount: i128,
2157 currency: Option<Currency>,
2158 ) -> Self {
2159 Operation {
2160 operation_identifier: Default::default(),
2161 type_: OperationType::PayCoin,
2162 status,
2163 account: Some(address.into()),
2164 amount: Some(Amount::new(amount, currency)),
2165 coin_change: None,
2166 metadata: None,
2167 }
2168 }
2169
2170 fn balance_change(
2171 status: Option<OperationStatus>,
2172 addr: SuiAddress,
2173 amount: i128,
2174 currency: Currency,
2175 ) -> Self {
2176 Self {
2177 operation_identifier: Default::default(),
2178 type_: OperationType::SuiBalanceChange,
2179 status,
2180 account: Some(addr.into()),
2181 amount: Some(Amount::new(amount, Some(currency))),
2182 coin_change: None,
2183 metadata: None,
2184 }
2185 }
2186 fn gas(addr: SuiAddress, amount: i128) -> Self {
2187 Self {
2188 operation_identifier: Default::default(),
2189 type_: OperationType::Gas,
2190 status: Some(OperationStatus::Success),
2191 account: Some(addr.into()),
2192 amount: Some(Amount::new(amount, None)),
2193 coin_change: None,
2194 metadata: None,
2195 }
2196 }
2197 fn stake_reward(status: Option<OperationStatus>, addr: SuiAddress, amount: i128) -> Self {
2198 Self {
2199 operation_identifier: Default::default(),
2200 type_: OperationType::StakeReward,
2201 status,
2202 account: Some(addr.into()),
2203 amount: Some(Amount::new(amount, None)),
2204 coin_change: None,
2205 metadata: None,
2206 }
2207 }
2208 fn stake_principle(status: Option<OperationStatus>, addr: SuiAddress, amount: i128) -> Self {
2209 Self {
2210 operation_identifier: Default::default(),
2211 type_: OperationType::StakePrinciple,
2212 status,
2213 account: Some(addr.into()),
2214 amount: Some(Amount::new(amount, None)),
2215 coin_change: None,
2216 metadata: None,
2217 }
2218 }
2219}
2220
2221pub fn reconstruct_operations(
2240 proto: &ProtoTransaction,
2241 aux: &AuxData,
2242 status: Option<OperationStatus>,
2243) -> Result<Operations, Error> {
2244 let sender = SuiAddress::from_str(proto.sender())
2245 .map_err(|e| Error::DataError(format!("invalid transaction sender: {e}")))?;
2246 let tx_kind = proto
2247 .kind
2248 .clone()
2249 .ok_or_else(|| Error::DataError("Transaction missing kind".to_string()))?;
2250
2251 let payment_currency = match aux {
2254 AuxData::PayCoin { currency } => PaymentCurrency::NonSui(currency.clone()),
2255 _ => PaymentCurrency::Sui,
2256 };
2257 let mut ops = Operations::from_transaction(tx_kind, sender, status, payment_currency)?;
2258
2259 apply_aux(&mut ops, aux)?;
2261 Ok(Operations::new(ops))
2262}
2263
2264fn apply_aux(ops: &mut [Operation], aux: &AuxData) -> Result<(), Error> {
2267 match aux {
2268 AuxData::None => {}
2269 AuxData::PayCoin { .. } => {
2270 let is_payment = ops
2274 .iter()
2275 .all(|op| matches!(op.type_, OperationType::PayCoin | OperationType::PaySui));
2276 if ops.is_empty() || !is_payment {
2277 return Err(Error::DataError(
2278 "envelope inconsistency: PayCoin aux data over a non-payment transaction"
2279 .to_string(),
2280 ));
2281 }
2282 }
2283 AuxData::Consolidate { validator } => {
2284 let op = single_op(ops, OperationType::ConsolidateAllStakedSuiToFungible)?;
2285 match &mut op.metadata {
2286 Some(OperationMetadata::ConsolidateAllStakedSuiToFungible {
2287 validator: v, ..
2288 }) => {
2289 *v = Some(*validator);
2290 }
2291 _ => {
2292 return Err(Error::DataError(
2293 "envelope inconsistency: Consolidate aux data but parsed op lacks \
2294 Consolidate metadata"
2295 .to_string(),
2296 ));
2297 }
2298 }
2299 }
2300 AuxData::MergeAndRedeem {
2301 validator,
2302 redeem_mode,
2303 amount,
2304 } => {
2305 match redeem_mode {
2310 RedeemMode::All if amount.is_some() => {
2311 return Err(Error::DataError(
2312 "MergeAndRedeem All must carry no amount".to_string(),
2313 ));
2314 }
2315 RedeemMode::AtLeast | RedeemMode::AtMost if !matches!(amount, Some(a) if *a > 0) => {
2316 return Err(Error::DataError(format!(
2317 "MergeAndRedeem {redeem_mode:?} must carry a positive amount"
2318 )));
2319 }
2320 _ => {}
2321 }
2322 let op = single_op(ops, OperationType::MergeAndRedeemFungibleStakedSui)?;
2323 match &mut op.metadata {
2324 Some(OperationMetadata::MergeAndRedeemFungibleStakedSui {
2325 validator: v,
2326 amount: a,
2327 redeem_mode: m,
2328 ..
2329 }) => {
2330 *v = Some(*validator);
2334 *m = Some(redeem_mode.clone());
2335 *a = amount.map(|amount| amount.to_string());
2336 }
2337 _ => {
2338 return Err(Error::DataError(
2339 "envelope inconsistency: MergeAndRedeem aux data but parsed op lacks \
2340 MergeAndRedeem metadata"
2341 .to_string(),
2342 ));
2343 }
2344 }
2345 }
2346 }
2347 Ok(())
2348}
2349
2350fn single_op(ops: &mut [Operation], expected: OperationType) -> Result<&mut Operation, Error> {
2353 match ops {
2354 [op] if op.type_ == expected => Ok(op),
2355 _ => Err(Error::DataError(format!(
2356 "envelope inconsistency: aux data expects a single {expected:?} operation, \
2357 but the transaction parsed to a different shape"
2358 ))),
2359 }
2360}
2361
2362#[cfg(test)]
2363mod tests {
2364 use super::*;
2365 use crate::types::ConstructionMetadata;
2366 use crate::types::internal_operation::{consolidate_to_fungible_pt, merge_and_redeem_fss_pt};
2367 use sui_rpc::proto::sui::rpc::v2::Transaction;
2368 use sui_types::Identifier;
2369 use sui_types::base_types::{ObjectDigest, ObjectID, ObjectRef, SequenceNumber, SuiAddress};
2370 use sui_types::programmable_transaction_builder::ProgrammableTransactionBuilder;
2371 use sui_types::transaction::{
2372 CallArg, Command as NativeCommand, ObjectArg, ProgrammableTransaction,
2373 TEST_ONLY_GAS_UNIT_FOR_TRANSFER, TransactionData,
2374 };
2375
2376 fn random_object_ref() -> ObjectRef {
2377 (
2378 ObjectID::random(),
2379 SequenceNumber::from(1),
2380 ObjectDigest::random(),
2381 )
2382 }
2383
2384 fn parse_pt(sender: SuiAddress, pt: ProgrammableTransaction) -> Vec<Operation> {
2387 let gas = random_object_ref();
2388 let gas_price = 10;
2389 let data = TransactionData::new_programmable(
2390 sender,
2391 vec![gas],
2392 pt,
2393 TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
2394 gas_price,
2395 );
2396 let proto_tx: Transaction = data.into();
2397 let tx_kind = proto_tx.kind.expect("tx missing kind");
2398 Operations::from_transaction(tx_kind, sender, None, PaymentCurrency::Sui)
2399 .expect("parse failed")
2400 }
2401
2402 #[tokio::test]
2403 async fn test_operation_data_parsing_pay_sui() -> Result<(), anyhow::Error> {
2404 let gas = (
2405 ObjectID::random(),
2406 SequenceNumber::new(),
2407 ObjectDigest::random(),
2408 );
2409
2410 let sender = SuiAddress::random_for_testing_only();
2411
2412 let pt = {
2413 let mut builder = ProgrammableTransactionBuilder::new();
2414 builder
2415 .pay_sui(vec![SuiAddress::random_for_testing_only()], vec![10000])
2416 .unwrap();
2417 builder.finish()
2418 };
2419 let gas_price = 10;
2420 let data = TransactionData::new_programmable(
2421 sender,
2422 vec![gas],
2423 pt,
2424 TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
2425 gas_price,
2426 );
2427
2428 let proto_tx: Transaction = data.clone().into();
2429 let ops = Operations::new(Operations::from_transaction(
2430 proto_tx
2431 .kind
2432 .ok_or_else(|| Error::DataError("Transaction missing kind".to_string()))?,
2433 sender,
2434 None,
2435 PaymentCurrency::Sui,
2436 )?);
2437 ops.0
2438 .iter()
2439 .for_each(|op| assert_eq!(op.type_, OperationType::PaySui));
2440 let metadata = ConstructionMetadata {
2441 sender,
2442 gas_coins: vec![gas],
2443 extra_gas_coins: vec![],
2444 objects: vec![],
2445 party_objects: vec![],
2446 total_coin_value: 0,
2447 gas_price,
2448 budget: TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
2449 currency: None,
2450 address_balance_withdrawal: 0,
2451 epoch: None,
2452 chain_id: None,
2453 nonce: None,
2454 fss_object_count: None,
2455 redeem_token_amount: None,
2456 redeem_plan: None,
2457 bind_epoch: None,
2458 };
2459 let parsed_data = ops.into_internal()?.try_into_data(metadata)?;
2460 assert_eq!(data, parsed_data);
2461
2462 Ok(())
2463 }
2464
2465 #[test]
2470 fn test_stake_parse_round_trip() -> Result<(), anyhow::Error> {
2471 use sui_types::transaction::TEST_ONLY_GAS_UNIT_FOR_STAKING;
2472
2473 let sender = SuiAddress::random_for_testing_only();
2474 let validator = SuiAddress::random_for_testing_only();
2475 let gas = random_object_ref();
2476 let gas_price = 10;
2477
2478 let ops: Operations = serde_json::from_value(serde_json::json!([{
2479 "operation_identifier": {"index": 0},
2480 "type": "Stake",
2481 "account": {"address": sender.to_string()},
2482 "amount": {"value": "-100000", "currency": {"symbol": "SUI", "decimals": 9}},
2483 "metadata": {"Stake": {"validator": validator.to_string()}}
2484 }]))?;
2485
2486 let metadata = ConstructionMetadata {
2487 sender,
2488 gas_coins: vec![gas],
2489 extra_gas_coins: vec![],
2490 objects: vec![],
2491 party_objects: vec![],
2492 total_coin_value: 0,
2493 gas_price,
2494 budget: gas_price * TEST_ONLY_GAS_UNIT_FOR_STAKING,
2495 currency: None,
2496 address_balance_withdrawal: 0,
2497 epoch: None,
2498 chain_id: None,
2499 nonce: None,
2500 fss_object_count: None,
2501 redeem_token_amount: None,
2502 redeem_plan: None,
2503 bind_epoch: None,
2504 };
2505 let parsed_data = ops.clone().into_internal()?.try_into_data(metadata)?;
2506
2507 let proto_tx: Transaction = parsed_data.clone().into();
2508 let parsed_ops = Operations::new(Operations::from_transaction(
2509 proto_tx
2510 .kind
2511 .ok_or_else(|| Error::DataError("Transaction missing kind".to_string()))?,
2512 sender,
2513 None,
2514 PaymentCurrency::Sui,
2515 )?);
2516
2517 assert_eq!(ops, parsed_ops, "expected {ops:#?}, got: {parsed_ops:#?}");
2518 Ok(())
2519 }
2520
2521 fn parse_payment_pt(payment: PaymentCurrency) -> Result<Vec<Operation>, anyhow::Error> {
2524 use crate::SUI;
2525 use crate::types::internal_operation::pay_coin_pt;
2526
2527 let gas = (
2528 ObjectID::random(),
2529 SequenceNumber::new(),
2530 ObjectDigest::random(),
2531 );
2532 let coin = (
2533 ObjectID::random(),
2534 SequenceNumber::new(),
2535 ObjectDigest::random(),
2536 );
2537 let sender = SuiAddress::random_for_testing_only();
2538 let recipient = SuiAddress::random_for_testing_only();
2539 let pt = pay_coin_pt(sender, vec![recipient], vec![10_000], &[coin], &[], 0, &SUI)?;
2540 let gas_price = 10;
2541 let data = TransactionData::new_programmable(
2542 sender,
2543 vec![gas],
2544 pt,
2545 TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
2546 gas_price,
2547 );
2548 let proto_tx: Transaction = data.into();
2549 let tx_kind = proto_tx.kind.unwrap();
2550 Ok(Operations::from_transaction(
2551 tx_kind, sender, None, payment,
2552 )?)
2553 }
2554
2555 #[test]
2560 fn test_parse_unresolvable_emits_generic_op() -> Result<(), anyhow::Error> {
2561 let ops = parse_payment_pt(PaymentCurrency::Unresolvable)?;
2562 assert!(
2563 !ops.iter().any(|op| op.type_ == OperationType::PaySui),
2564 "Unresolvable must not silently fall back to PaySui: {ops:?}"
2565 );
2566 assert!(
2567 !ops.iter().any(|op| op.type_ == OperationType::PayCoin),
2568 "Unresolvable must not produce PayCoin (we don't know the currency): {ops:?}"
2569 );
2570 assert!(
2571 ops.iter()
2572 .any(|op| matches!(op.metadata, Some(OperationMetadata::GenericTransaction(_)))),
2573 "Unresolvable must fall through to generic_op: {ops:?}"
2574 );
2575 Ok(())
2576 }
2577
2578 #[test]
2581 fn test_parse_nonsui_emits_pay_coin() -> Result<(), anyhow::Error> {
2582 use crate::types::CurrencyMetadata;
2583
2584 let usdc = Currency {
2585 symbol: "USDC".to_string(),
2586 decimals: 6,
2587 metadata: CurrencyMetadata {
2588 coin_type: "0xaaa::usdc::USDC".to_string(),
2589 },
2590 };
2591 let ops = parse_payment_pt(PaymentCurrency::NonSui(usdc.clone()))?;
2592 assert!(
2593 !ops.iter().any(|op| op.type_ == OperationType::PaySui),
2594 "NonSui must not produce PaySui: {ops:?}"
2595 );
2596 let pay_coins: Vec<_> = ops
2597 .iter()
2598 .filter(|op| op.type_ == OperationType::PayCoin)
2599 .collect();
2600 assert!(
2601 !pay_coins.is_empty(),
2602 "NonSui must produce PayCoin: {ops:?}"
2603 );
2604 for op in pay_coins {
2605 assert_eq!(
2606 op.amount.as_ref().map(|a| &a.currency),
2607 Some(&usdc),
2608 "PayCoin op must carry the NonSui currency: {op:?}"
2609 );
2610 }
2611 Ok(())
2612 }
2613
2614 fn unreachable_cache() -> CoinMetadataCache {
2617 use std::num::NonZeroUsize;
2618 use sui_rpc::client::Client;
2619 CoinMetadataCache::new(
2620 Client::new("http://127.0.0.1:1").unwrap(),
2621 NonZeroUsize::new(1).unwrap(),
2622 )
2623 }
2624
2625 fn balance_change(coin_type: &str) -> BalanceChange {
2626 let mut bc = BalanceChange::default();
2627 bc.coin_type = Some(coin_type.to_string());
2628 bc
2629 }
2630
2631 #[tokio::test]
2635 async fn test_resolve_sui_needs_no_lookup() {
2636 let cache = unreachable_cache();
2637 let resolved = resolve_tx_currencies(&[balance_change(&SUI.metadata.coin_type)], &cache)
2638 .await
2639 .expect("SUI must resolve without an RPC");
2640 assert!(matches!(resolved.payment, PaymentCurrency::Sui));
2641 assert_eq!(
2642 resolved.by_coin_type.get(&SUI.metadata.coin_type),
2643 Some(&*SUI)
2644 );
2645 }
2646
2647 #[tokio::test]
2653 async fn test_resolve_non_struct_coin_type_degrades() {
2654 let cache = unreachable_cache();
2655 let resolved = resolve_tx_currencies(&[balance_change("u64")], &cache)
2656 .await
2657 .expect("a non-struct coin type must not fail the block");
2658 assert!(
2659 matches!(resolved.payment, PaymentCurrency::Unresolvable),
2660 "a non-struct coin type must fall through to generic_op: {:?}",
2661 resolved.payment
2662 );
2663 assert!(
2664 resolved.by_coin_type.is_empty(),
2665 "a non-struct coin type must not be reported as a currency: {:?}",
2666 resolved.by_coin_type
2667 );
2668 }
2669
2670 #[tokio::test]
2674 async fn test_resolve_transient_non_sui_is_retriable() {
2675 let cache = unreachable_cache();
2676 let err = resolve_tx_currencies(&[balance_change("0xaaa::usdc::USDC")], &cache)
2677 .await
2678 .expect_err("a transient non-SUI lookup failure must surface as an error");
2679 assert!(
2680 matches!(err, Error::CoinMetadataUnavailable(_)),
2681 "transient failure must map to CoinMetadataUnavailable: {err:?}"
2682 );
2683 let json = serde_json::to_value(&err).expect("error serializes");
2685 assert_eq!(
2686 json.get("retriable"),
2687 Some(&serde_json::Value::Bool(true)),
2688 "CoinMetadataUnavailable must serialize as retriable: {json}"
2689 );
2690 }
2691
2692 #[test]
2697 fn test_pay_coin_pt_has_no_currency_bearer() -> Result<(), anyhow::Error> {
2698 use crate::SUI;
2699 use crate::types::internal_operation::pay_coin_pt;
2700
2701 let sender = SuiAddress::random_for_testing_only();
2702 let recipient = SuiAddress::random_for_testing_only();
2703 let coin = (
2704 ObjectID::random(),
2705 SequenceNumber::new(),
2706 ObjectDigest::random(),
2707 );
2708
2709 let pt = pay_coin_pt(sender, vec![recipient], vec![10_000], &[coin], &[], 0, &SUI)?;
2710
2711 for input in &pt.inputs {
2712 if let CallArg::Pure(bytes) = input
2713 && let Ok(s) = bcs::from_bytes::<String>(bytes)
2714 && serde_json::from_str::<Currency>(&s).is_ok()
2715 {
2716 panic!(
2717 "pay_coin_pt produced a Pure input that decodes as a Currency JSON string: {:?}",
2718 s
2719 );
2720 }
2721 }
2722 Ok(())
2723 }
2724
2725 #[tokio::test]
2734 async fn test_try_from_executed_transaction_deleted_gas_coin() -> Result<(), anyhow::Error> {
2735 use std::num::NonZeroUsize;
2736 use sui_rpc::client::Client;
2737 use sui_rpc::proto::sui::rpc::v2::changed_object::OutputObjectState;
2738 use sui_rpc::proto::sui::rpc::v2::{
2739 ChangedObject, ExecutedTransaction, ExecutionStatus, GasCostSummary, TransactionEffects,
2740 };
2741
2742 let sender = SuiAddress::random_for_testing_only();
2743 let recipient = SuiAddress::random_for_testing_only();
2744
2745 let pt = {
2746 let mut builder = ProgrammableTransactionBuilder::new();
2747 builder.pay_sui(vec![recipient], vec![1000]).unwrap();
2748 builder.finish()
2749 };
2750 let gas_price = 10;
2751 let data = TransactionData::new_programmable(
2752 sender,
2753 vec![random_object_ref()],
2754 pt,
2755 TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
2756 gas_price,
2757 );
2758 let transaction: Transaction = data.into();
2759
2760 let mut gas_object = ChangedObject::default();
2763 gas_object.object_id = Some(ObjectID::random().to_string());
2764 gas_object.output_state = Some(OutputObjectState::DoesNotExist as i32);
2765 gas_object.output_owner = None;
2766
2767 let mut status = ExecutionStatus::default();
2768 status.success = Some(true);
2769
2770 let mut gas_used = GasCostSummary::default();
2771 gas_used.computation_cost = Some(1000);
2772 gas_used.storage_cost = Some(0);
2773 gas_used.storage_rebate = Some(0);
2774 gas_used.non_refundable_storage_fee = Some(0);
2775
2776 let mut effects = TransactionEffects::default();
2777 effects.status = Some(status);
2778 effects.gas_used = Some(gas_used);
2779 effects.gas_object = Some(gas_object);
2780
2781 let mut executed_tx = ExecutedTransaction::default();
2782 executed_tx.transaction = Some(transaction);
2783 executed_tx.effects = Some(effects);
2784 executed_tx.events = None;
2785 executed_tx.balance_changes = vec![];
2786
2787 let cache = CoinMetadataCache::new(
2790 Client::new("http://127.0.0.1:1").unwrap(),
2791 NonZeroUsize::new(1).unwrap(),
2792 );
2793
2794 let ops = Operations::try_from_executed_transaction(executed_tx, &cache).await?;
2795
2796 let gas_op = ops
2797 .0
2798 .iter()
2799 .find(|op| op.type_ == OperationType::Gas)
2800 .expect("expected a Gas operation");
2801 assert_eq!(gas_op.account.as_ref().map(|a| a.address), Some(sender));
2802
2803 Ok(())
2804 }
2805
2806 #[test]
2807 fn test_parse_consolidate_all_staked_sui_to_fungible() {
2808 let sender = SuiAddress::random_for_testing_only();
2809 let validator = SuiAddress::random_for_testing_only();
2810
2811 let ops: Operations = serde_json::from_value(serde_json::json!([{
2812 "operation_identifier": {"index": 0},
2813 "type": "ConsolidateAllStakedSuiToFungible",
2814 "account": {"address": sender.to_string()},
2815 "metadata": {
2816 "ConsolidateAllStakedSuiToFungible": {
2817 "validator": validator.to_string()
2818 }
2819 }
2820 }]))
2821 .unwrap();
2822
2823 let internal = ops.into_internal().unwrap();
2824 match internal {
2825 InternalOperation::ConsolidateAllStakedSuiToFungible(op) => {
2826 assert_eq!(op.sender, sender);
2827 assert_eq!(op.validator, validator);
2828 }
2829 _ => panic!("Expected ConsolidateAllStakedSuiToFungible"),
2830 }
2831 }
2832
2833 #[test]
2834 fn test_parse_merge_and_redeem_fungible_staked_sui() {
2835 let sender = SuiAddress::random_for_testing_only();
2836 let validator = SuiAddress::random_for_testing_only();
2837
2838 let ops: Operations = serde_json::from_value(serde_json::json!([{
2839 "operation_identifier": {"index": 0},
2840 "type": "MergeAndRedeemFungibleStakedSui",
2841 "account": {"address": sender.to_string()},
2842 "metadata": {
2843 "MergeAndRedeemFungibleStakedSui": {
2844 "validator": validator.to_string(),
2845 "amount": "500000000000",
2846 "redeem_mode": "AtLeast"
2847 }
2848 }
2849 }]))
2850 .unwrap();
2851
2852 let internal = ops.into_internal().unwrap();
2853 match internal {
2854 InternalOperation::MergeAndRedeemFungibleStakedSui(op) => {
2855 assert_eq!(op.sender, sender);
2856 assert_eq!(op.validator, validator);
2857 assert_eq!(op.amount, Some(500000000000));
2858 assert_eq!(op.redeem_mode, RedeemMode::AtLeast);
2859 }
2860 _ => panic!("Expected MergeAndRedeemFungibleStakedSui"),
2861 }
2862 }
2863
2864 #[test]
2865 fn test_parse_merge_and_redeem_all_mode() {
2866 let sender = SuiAddress::random_for_testing_only();
2867 let validator = SuiAddress::random_for_testing_only();
2868
2869 let ops: Operations = serde_json::from_value(serde_json::json!([{
2870 "operation_identifier": {"index": 0},
2871 "type": "MergeAndRedeemFungibleStakedSui",
2872 "account": {"address": sender.to_string()},
2873 "metadata": {
2874 "MergeAndRedeemFungibleStakedSui": {
2875 "validator": validator.to_string(),
2876 "redeem_mode": "All"
2877 }
2878 }
2879 }]))
2880 .unwrap();
2881
2882 let internal = ops.into_internal().unwrap();
2883 match internal {
2884 InternalOperation::MergeAndRedeemFungibleStakedSui(op) => {
2885 assert_eq!(op.amount, None);
2886 assert_eq!(op.redeem_mode, RedeemMode::All);
2887 }
2888 _ => panic!("Expected MergeAndRedeemFungibleStakedSui"),
2889 }
2890 }
2891
2892 fn assert_consolidate_ops(
2897 ops: &[Operation],
2898 expected_sender: SuiAddress,
2899 expected_staked_sui: &[ObjectID],
2900 expected_fss: &[ObjectID],
2901 ) {
2902 assert_eq!(ops.len(), 1);
2903 let op = &ops[0];
2904 assert_eq!(op.type_, OperationType::ConsolidateAllStakedSuiToFungible);
2905 assert_eq!(
2906 op.account.as_ref().map(|a| a.address),
2907 Some(expected_sender)
2908 );
2909 assert!(op.amount.is_none());
2910 let Some(OperationMetadata::ConsolidateAllStakedSuiToFungible {
2911 validator,
2912 staked_sui_ids,
2913 fss_ids,
2914 }) = op.metadata.clone()
2915 else {
2916 panic!("wrong metadata variant: {:?}", op.metadata);
2917 };
2918 assert!(validator.is_none(), "validator must be None on parse");
2919 assert_eq!(staked_sui_ids, expected_staked_sui);
2920 assert_eq!(fss_ids, expected_fss);
2921 }
2922
2923 #[test]
2924 fn test_parse_consolidate_pure_merge_2_fss() {
2925 let sender = SuiAddress::random_for_testing_only();
2926 let fss_a = random_object_ref();
2927 let fss_b = random_object_ref();
2928 let pt = consolidate_to_fungible_pt(sender, vec![fss_a, fss_b], vec![]).expect("pt");
2929 let ops = parse_pt(sender, pt);
2930 assert_consolidate_ops(&ops, sender, &[], &[fss_a.0, fss_b.0]);
2931 }
2932
2933 #[test]
2934 fn test_parse_consolidate_pure_merge_3_fss() {
2935 let sender = SuiAddress::random_for_testing_only();
2936 let a = random_object_ref();
2937 let b = random_object_ref();
2938 let c = random_object_ref();
2939 let pt = consolidate_to_fungible_pt(sender, vec![a, b, c], vec![]).expect("pt");
2940 assert_consolidate_ops(&parse_pt(sender, pt), sender, &[], &[a.0, b.0, c.0]);
2941 }
2942
2943 #[test]
2944 fn test_parse_consolidate_pure_merge_5_fss() {
2945 let sender = SuiAddress::random_for_testing_only();
2946 let refs: Vec<_> = (0..5).map(|_| random_object_ref()).collect();
2947 let pt = consolidate_to_fungible_pt(sender, refs.clone(), vec![]).expect("pt");
2948 let expected: Vec<_> = refs.iter().map(|r| r.0).collect();
2949 assert_consolidate_ops(&parse_pt(sender, pt), sender, &[], &expected);
2950 }
2951
2952 #[test]
2953 fn test_parse_consolidate_single_convert_no_fss() {
2954 let sender = SuiAddress::random_for_testing_only();
2955 let staked = random_object_ref();
2956 let pt = consolidate_to_fungible_pt(sender, vec![], vec![staked]).expect("pt");
2957 assert_consolidate_ops(&parse_pt(sender, pt), sender, &[staked.0], &[]);
2958 }
2959
2960 #[test]
2961 fn test_parse_consolidate_multi_convert_no_fss() {
2962 let sender = SuiAddress::random_for_testing_only();
2963 let s1 = random_object_ref();
2964 let s2 = random_object_ref();
2965 let s3 = random_object_ref();
2966 let pt = consolidate_to_fungible_pt(sender, vec![], vec![s1, s2, s3]).expect("pt");
2967 assert_consolidate_ops(&parse_pt(sender, pt), sender, &[s1.0, s2.0, s3.0], &[]);
2968 }
2969
2970 #[test]
2971 fn test_parse_consolidate_single_stake_single_fss() {
2972 let sender = SuiAddress::random_for_testing_only();
2973 let fss = random_object_ref();
2974 let staked = random_object_ref();
2975 let pt = consolidate_to_fungible_pt(sender, vec![fss], vec![staked]).expect("pt");
2976 assert_consolidate_ops(&parse_pt(sender, pt), sender, &[staked.0], &[fss.0]);
2977 }
2978
2979 #[test]
2980 fn test_parse_consolidate_single_stake_multi_fss() {
2981 let sender = SuiAddress::random_for_testing_only();
2982 let f1 = random_object_ref();
2983 let f2 = random_object_ref();
2984 let staked = random_object_ref();
2985 let pt = consolidate_to_fungible_pt(sender, vec![f1, f2], vec![staked]).expect("pt");
2986 assert_consolidate_ops(&parse_pt(sender, pt), sender, &[staked.0], &[f1.0, f2.0]);
2987 }
2988
2989 #[test]
2990 fn test_parse_consolidate_multi_stake_single_fss() {
2991 let sender = SuiAddress::random_for_testing_only();
2992 let fss = random_object_ref();
2993 let s1 = random_object_ref();
2994 let s2 = random_object_ref();
2995 let pt = consolidate_to_fungible_pt(sender, vec![fss], vec![s1, s2]).expect("pt");
2996 assert_consolidate_ops(&parse_pt(sender, pt), sender, &[s1.0, s2.0], &[fss.0]);
2997 }
2998
2999 #[test]
3000 fn test_parse_consolidate_multi_stake_multi_fss() {
3001 let sender = SuiAddress::random_for_testing_only();
3002 let f1 = random_object_ref();
3003 let f2 = random_object_ref();
3004 let s1 = random_object_ref();
3005 let s2 = random_object_ref();
3006 let pt = consolidate_to_fungible_pt(sender, vec![f1, f2], vec![s1, s2]).expect("pt");
3007 assert_consolidate_ops(&parse_pt(sender, pt), sender, &[s1.0, s2.0], &[f1.0, f2.0]);
3008 }
3009
3010 #[test]
3011 fn test_parse_consolidate_large_mixed() {
3012 let sender = SuiAddress::random_for_testing_only();
3013 let fss: Vec<_> = (0..3).map(|_| random_object_ref()).collect();
3014 let staked: Vec<_> = (0..3).map(|_| random_object_ref()).collect();
3015 let pt = consolidate_to_fungible_pt(sender, fss.clone(), staked.clone()).expect("pt");
3016 let expected_s: Vec<_> = staked.iter().map(|r| r.0).collect();
3017 let expected_f: Vec<_> = fss.iter().map(|r| r.0).collect();
3018 assert_consolidate_ops(&parse_pt(sender, pt), sender, &expected_s, &expected_f);
3019 }
3020
3021 #[test]
3022 fn test_parse_consolidate_classification_correctness() {
3023 let sender = SuiAddress::random_for_testing_only();
3025 let f1 = random_object_ref();
3026 let f2 = random_object_ref();
3027 let s1 = random_object_ref();
3028 let s2 = random_object_ref();
3029 let pt = consolidate_to_fungible_pt(sender, vec![f1, f2], vec![s1, s2]).expect("pt");
3030 let ops = parse_pt(sender, pt);
3031 let Some(OperationMetadata::ConsolidateAllStakedSuiToFungible {
3032 staked_sui_ids,
3033 fss_ids,
3034 ..
3035 }) = ops[0].metadata.clone()
3036 else {
3037 panic!();
3038 };
3039 let staked_set: std::collections::HashSet<_> = staked_sui_ids.iter().collect();
3040 let fss_set: std::collections::HashSet<_> = fss_ids.iter().collect();
3041 assert!(
3042 staked_set.is_disjoint(&fss_set),
3043 "classification crossed categories"
3044 );
3045 }
3046
3047 fn assert_falls_through_to_generic(ops: &[Operation]) {
3052 assert_eq!(ops.len(), 1);
3053 assert_eq!(
3054 ops[0].type_,
3055 OperationType::ProgrammableTransaction,
3056 "expected fall-through to generic ProgrammableTransaction, got: {:?}",
3057 ops[0].type_
3058 );
3059 }
3060
3061 #[test]
3062 fn test_parse_falls_through_consolidate_with_merge_coins() {
3063 let sender = SuiAddress::random_for_testing_only();
3064 let fss_a = random_object_ref();
3065 let fss_b = random_object_ref();
3066 let coin_a = random_object_ref();
3067
3068 let mut builder = ProgrammableTransactionBuilder::new();
3069 let _sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3070 let first = builder.obj(ObjectArg::ImmOrOwnedObject(fss_a)).unwrap();
3071 let other = builder.obj(ObjectArg::ImmOrOwnedObject(fss_b)).unwrap();
3072 builder.command(NativeCommand::move_call(
3073 SUI_SYSTEM_PACKAGE_ID,
3074 Identifier::new("staking_pool").unwrap(),
3075 Identifier::new("join_fungible_staked_sui").unwrap(),
3076 vec![],
3077 vec![first, other],
3078 ));
3079 let coin_target = builder.obj(ObjectArg::ImmOrOwnedObject(coin_a)).unwrap();
3081 builder.command(NativeCommand::MergeCoins(coin_target, vec![]));
3082
3083 let ops = parse_pt(sender, builder.finish());
3084 assert_falls_through_to_generic(&ops);
3085 }
3086
3087 #[test]
3088 fn test_parse_falls_through_consolidate_with_unrelated_movecall() {
3089 let sender = SuiAddress::random_for_testing_only();
3090 let fss_a = random_object_ref();
3091 let fss_b = random_object_ref();
3092
3093 let mut builder = ProgrammableTransactionBuilder::new();
3094 let _sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3095 let first = builder.obj(ObjectArg::ImmOrOwnedObject(fss_a)).unwrap();
3096 let other = builder.obj(ObjectArg::ImmOrOwnedObject(fss_b)).unwrap();
3097 builder.command(NativeCommand::move_call(
3098 SUI_SYSTEM_PACKAGE_ID,
3099 Identifier::new("staking_pool").unwrap(),
3100 Identifier::new("join_fungible_staked_sui").unwrap(),
3101 vec![],
3102 vec![first, other],
3103 ));
3104 builder.command(NativeCommand::move_call(
3106 SUI_FRAMEWORK_PACKAGE_ID,
3107 Identifier::new("coin").unwrap(),
3108 Identifier::new("destroy_zero").unwrap(),
3109 vec![],
3110 vec![other],
3111 ));
3112
3113 let ops = parse_pt(sender, builder.finish());
3114 assert_falls_through_to_generic(&ops);
3115 }
3116
3117 #[test]
3118 fn test_parse_falls_through_convert_without_system_state() {
3119 let sender = SuiAddress::random_for_testing_only();
3121 let staked = random_object_ref();
3122 let other_obj = random_object_ref();
3123
3124 let mut builder = ProgrammableTransactionBuilder::new();
3125 let _not_system = builder.obj(ObjectArg::ImmOrOwnedObject(other_obj)).unwrap();
3127 let staked_arg = builder.obj(ObjectArg::ImmOrOwnedObject(staked)).unwrap();
3128 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3129 let new_fss = builder.command(NativeCommand::move_call(
3130 SUI_SYSTEM_PACKAGE_ID,
3131 Identifier::new("sui_system").unwrap(),
3132 Identifier::new("convert_to_fungible_staked_sui").unwrap(),
3133 vec![],
3134 vec![sys, staked_arg],
3135 ));
3136 let sender_arg = builder.pure(sender).unwrap();
3137 builder.command(NativeCommand::TransferObjects(vec![new_fss], sender_arg));
3138
3139 let ops = parse_pt(sender, builder.finish());
3140 assert_falls_through_to_generic(&ops);
3141 }
3142
3143 #[test]
3144 fn test_parse_falls_through_extra_command_after_transfer() {
3145 let sender = SuiAddress::random_for_testing_only();
3147 let staked = random_object_ref();
3148 let other_obj = random_object_ref();
3149
3150 let mut builder = ProgrammableTransactionBuilder::new();
3151 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3152 let staked_arg = builder.obj(ObjectArg::ImmOrOwnedObject(staked)).unwrap();
3153 let new_fss = builder.command(NativeCommand::move_call(
3154 SUI_SYSTEM_PACKAGE_ID,
3155 Identifier::new("sui_system").unwrap(),
3156 Identifier::new("convert_to_fungible_staked_sui").unwrap(),
3157 vec![],
3158 vec![sys, staked_arg],
3159 ));
3160 let sender_arg = builder.pure(sender).unwrap();
3161 builder.command(NativeCommand::TransferObjects(vec![new_fss], sender_arg));
3162 let extra = builder.obj(ObjectArg::ImmOrOwnedObject(other_obj)).unwrap();
3164 builder.command(NativeCommand::move_call(
3165 SUI_FRAMEWORK_PACKAGE_ID,
3166 Identifier::new("coin").unwrap(),
3167 Identifier::new("destroy_zero").unwrap(),
3168 vec![],
3169 vec![extra],
3170 ));
3171
3172 let ops = parse_pt(sender, builder.finish());
3173 assert_falls_through_to_generic(&ops);
3174 }
3175
3176 #[test]
3181 fn test_parse_empty_ptb() {
3182 let sender = SuiAddress::random_for_testing_only();
3183 let pt = ProgrammableTransactionBuilder::new().finish();
3184 let ops = parse_pt(sender, pt);
3185 assert_eq!(ops.len(), 1);
3187 assert_eq!(ops[0].type_, OperationType::ProgrammableTransaction);
3188 }
3189
3190 #[test]
3191 fn test_parse_only_merge_coins() {
3192 let sender = SuiAddress::random_for_testing_only();
3194 let coin_a = random_object_ref();
3195 let coin_b = random_object_ref();
3196 let mut builder = ProgrammableTransactionBuilder::new();
3197 let target = builder.obj(ObjectArg::ImmOrOwnedObject(coin_a)).unwrap();
3198 let source = builder.obj(ObjectArg::ImmOrOwnedObject(coin_b)).unwrap();
3199 builder.command(NativeCommand::MergeCoins(target, vec![source]));
3200 let ops = parse_pt(sender, builder.finish());
3201 assert_ne!(
3204 ops[0].type_,
3205 OperationType::ConsolidateAllStakedSuiToFungible
3206 );
3207 assert_ne!(ops[0].type_, OperationType::MergeAndRedeemFungibleStakedSui);
3208 }
3209
3210 #[test]
3218 fn test_meta_consolidate_old_input_deserializes() {
3219 let validator = SuiAddress::random_for_testing_only();
3220 let json = serde_json::json!({
3221 "ConsolidateAllStakedSuiToFungible": { "validator": validator.to_string() }
3222 });
3223 let meta: OperationMetadata = serde_json::from_value(json).unwrap();
3224 match meta {
3225 OperationMetadata::ConsolidateAllStakedSuiToFungible {
3226 validator: v,
3227 staked_sui_ids,
3228 fss_ids,
3229 } => {
3230 assert_eq!(v, Some(validator));
3231 assert!(staked_sui_ids.is_empty());
3232 assert!(fss_ids.is_empty());
3233 }
3234 _ => panic!("wrong variant"),
3235 }
3236 }
3237
3238 #[test]
3239 fn test_meta_consolidate_new_parse_output_serializes() {
3240 let id_a = ObjectID::random();
3241 let id_b = ObjectID::random();
3242 let meta = OperationMetadata::ConsolidateAllStakedSuiToFungible {
3243 validator: None,
3244 staked_sui_ids: vec![id_a],
3245 fss_ids: vec![id_b],
3246 };
3247 let json = serde_json::to_value(&meta).unwrap();
3248 let obj = json
3249 .as_object()
3250 .unwrap()
3251 .get("ConsolidateAllStakedSuiToFungible")
3252 .unwrap()
3253 .as_object()
3254 .unwrap();
3255 assert!(
3256 !obj.contains_key("validator"),
3257 "validator must be omitted when None"
3258 );
3259 assert_eq!(
3260 obj.get("staked_sui_ids").unwrap().as_array().unwrap().len(),
3261 1
3262 );
3263 assert_eq!(obj.get("fss_ids").unwrap().as_array().unwrap().len(), 1);
3264 }
3265
3266 #[test]
3271 fn test_write_consolidate_requires_validator() {
3272 let sender = SuiAddress::random_for_testing_only();
3273 let op = Operation {
3274 operation_identifier: Default::default(),
3275 type_: OperationType::ConsolidateAllStakedSuiToFungible,
3276 status: None,
3277 account: Some(sender.into()),
3278 amount: None,
3279 coin_change: None,
3280 metadata: Some(OperationMetadata::ConsolidateAllStakedSuiToFungible {
3281 validator: None,
3282 staked_sui_ids: vec![],
3283 fss_ids: vec![],
3284 }),
3285 };
3286 let err = Operations::new(vec![op])
3287 .into_internal()
3288 .expect_err("should fail without validator");
3289 let msg = format!("{err}");
3290 assert!(msg.contains("validator"), "unexpected error: {msg}");
3291 }
3292
3293 fn assert_merge_redeem_ops(
3298 ops: &[Operation],
3299 expected_sender: SuiAddress,
3300 expected_fss: &[ObjectID],
3301 expected_mode: Option<RedeemMode>,
3302 ) {
3303 assert_merge_redeem_ops_with_amount(
3304 ops,
3305 expected_sender,
3306 expected_fss,
3307 expected_mode,
3308 None,
3309 );
3310 }
3311
3312 fn assert_merge_redeem_ops_with_amount(
3313 ops: &[Operation],
3314 expected_sender: SuiAddress,
3315 expected_fss: &[ObjectID],
3316 expected_mode: Option<RedeemMode>,
3317 expected_amount: Option<&str>,
3318 ) {
3319 assert_eq!(ops.len(), 1);
3320 let op = &ops[0];
3321 assert_eq!(op.type_, OperationType::MergeAndRedeemFungibleStakedSui);
3322 assert_eq!(
3323 op.account.as_ref().map(|a| a.address),
3324 Some(expected_sender)
3325 );
3326 assert!(op.amount.is_none());
3327 let Some(OperationMetadata::MergeAndRedeemFungibleStakedSui {
3328 validator,
3329 amount,
3330 redeem_mode,
3331 fss_ids,
3332 }) = op.metadata.clone()
3333 else {
3334 panic!("wrong metadata variant: {:?}", op.metadata);
3335 };
3336 assert!(validator.is_none(), "validator must be None on parse");
3337 assert_eq!(
3338 amount.as_deref(),
3339 expected_amount,
3340 "metadata.amount mismatch"
3341 );
3342 assert_eq!(redeem_mode, expected_mode);
3343 assert_eq!(fss_ids, expected_fss);
3344 }
3345
3346 #[test]
3347 fn test_parse_merge_redeem_single_all() {
3348 let sender = SuiAddress::random_for_testing_only();
3349 let fss = random_object_ref();
3350 let pt = merge_and_redeem_fss_pt(sender, vec![fss], &RedeemPlan::All).expect("pt");
3351 assert_merge_redeem_ops(
3352 &parse_pt(sender, pt),
3353 sender,
3354 &[fss.0],
3355 Some(RedeemMode::All),
3356 );
3357 }
3358
3359 #[test]
3360 fn test_parse_merge_redeem_single_partial() {
3361 let sender = SuiAddress::random_for_testing_only();
3362 let fss = random_object_ref();
3363 let pt = merge_and_redeem_fss_pt(
3364 sender,
3365 vec![fss],
3366 &RedeemPlan::AtMost {
3367 token_amount: Some(500_000_000),
3368 max_sui: 0,
3369 },
3370 )
3371 .expect("pt");
3372 assert_merge_redeem_ops(&parse_pt(sender, pt), sender, &[fss.0], None);
3373 }
3374
3375 #[test]
3376 fn test_parse_merge_redeem_atleast_with_balance_guard() {
3377 let sender = SuiAddress::random_for_testing_only();
3378 let fss = random_object_ref();
3379 let pt = merge_and_redeem_fss_pt(
3380 sender,
3381 vec![fss],
3382 &RedeemPlan::AtLeast {
3383 token_amount: Some(500_000_000),
3384 min_sui: 1_000_000,
3385 },
3386 )
3387 .expect("pt");
3388 assert_merge_redeem_ops_with_amount(
3389 &parse_pt(sender, pt),
3390 sender,
3391 &[fss.0],
3392 Some(RedeemMode::AtLeast),
3393 Some("1000000"),
3394 );
3395 }
3396
3397 #[test]
3398 fn test_parse_merge_redeem_atleast_three_fss() {
3399 let sender = SuiAddress::random_for_testing_only();
3400 let a = random_object_ref();
3401 let b = random_object_ref();
3402 let c = random_object_ref();
3403 let pt = merge_and_redeem_fss_pt(
3404 sender,
3405 vec![a, b, c],
3406 &RedeemPlan::AtLeast {
3407 token_amount: Some(500_000_000),
3408 min_sui: 1_000_000,
3409 },
3410 )
3411 .expect("pt");
3412 assert_merge_redeem_ops_with_amount(
3413 &parse_pt(sender, pt),
3414 sender,
3415 &[a.0, b.0, c.0],
3416 Some(RedeemMode::AtLeast),
3417 Some("1000000"),
3418 );
3419 }
3420
3421 #[test]
3422 fn test_parse_merge_redeem_full_atleast_no_split() {
3423 let sender = SuiAddress::random_for_testing_only();
3428 let fss = random_object_ref();
3429 let pt = merge_and_redeem_fss_pt(
3430 sender,
3431 vec![fss],
3432 &RedeemPlan::AtLeast {
3433 token_amount: None,
3434 min_sui: 1_000_000,
3435 },
3436 )
3437 .expect("pt");
3438 assert_merge_redeem_ops_with_amount(
3439 &parse_pt(sender, pt),
3440 sender,
3441 &[fss.0],
3442 Some(RedeemMode::AtLeast),
3443 Some("1000000"),
3444 );
3445 }
3446
3447 #[test]
3448 fn test_parse_merge_redeem_two_all() {
3449 let sender = SuiAddress::random_for_testing_only();
3450 let a = random_object_ref();
3451 let b = random_object_ref();
3452 let pt = merge_and_redeem_fss_pt(sender, vec![a, b], &RedeemPlan::All).expect("pt");
3453 assert_merge_redeem_ops(
3454 &parse_pt(sender, pt),
3455 sender,
3456 &[a.0, b.0],
3457 Some(RedeemMode::All),
3458 );
3459 }
3460
3461 #[test]
3462 fn test_parse_merge_redeem_two_partial() {
3463 let sender = SuiAddress::random_for_testing_only();
3464 let a = random_object_ref();
3465 let b = random_object_ref();
3466 let pt = merge_and_redeem_fss_pt(
3467 sender,
3468 vec![a, b],
3469 &RedeemPlan::AtMost {
3470 token_amount: Some(500_000_000),
3471 max_sui: 0,
3472 },
3473 )
3474 .expect("pt");
3475 assert_merge_redeem_ops(&parse_pt(sender, pt), sender, &[a.0, b.0], None);
3476 }
3477
3478 #[test]
3479 fn test_parse_merge_redeem_three_all() {
3480 let sender = SuiAddress::random_for_testing_only();
3481 let a = random_object_ref();
3482 let b = random_object_ref();
3483 let c = random_object_ref();
3484 let pt = merge_and_redeem_fss_pt(sender, vec![a, b, c], &RedeemPlan::All).expect("pt");
3485 assert_merge_redeem_ops(
3486 &parse_pt(sender, pt),
3487 sender,
3488 &[a.0, b.0, c.0],
3489 Some(RedeemMode::All),
3490 );
3491 }
3492
3493 #[test]
3494 fn test_parse_merge_redeem_three_partial() {
3495 let sender = SuiAddress::random_for_testing_only();
3496 let a = random_object_ref();
3497 let b = random_object_ref();
3498 let c = random_object_ref();
3499 let pt = merge_and_redeem_fss_pt(
3500 sender,
3501 vec![a, b, c],
3502 &RedeemPlan::AtMost {
3503 token_amount: Some(500_000_000),
3504 max_sui: 0,
3505 },
3506 )
3507 .expect("pt");
3508 assert_merge_redeem_ops(&parse_pt(sender, pt), sender, &[a.0, b.0, c.0], None);
3509 }
3510
3511 #[test]
3512 fn test_parse_merge_redeem_five_all() {
3513 let sender = SuiAddress::random_for_testing_only();
3514 let refs: Vec<_> = (0..5).map(|_| random_object_ref()).collect();
3515 let pt = merge_and_redeem_fss_pt(sender, refs.clone(), &RedeemPlan::All).expect("pt");
3516 let expected: Vec<_> = refs.iter().map(|r| r.0).collect();
3517 assert_merge_redeem_ops(
3518 &parse_pt(sender, pt),
3519 sender,
3520 &expected,
3521 Some(RedeemMode::All),
3522 );
3523 }
3524
3525 #[test]
3526 fn test_parse_merge_redeem_fss_ids_order() {
3527 let sender = SuiAddress::random_for_testing_only();
3529 let a = random_object_ref();
3530 let b = random_object_ref();
3531 let c = random_object_ref();
3532 let pt = merge_and_redeem_fss_pt(sender, vec![a, b, c], &RedeemPlan::All).expect("pt");
3533 let ops = parse_pt(sender, pt);
3534 let Some(OperationMetadata::MergeAndRedeemFungibleStakedSui { fss_ids, .. }) =
3535 ops[0].metadata.clone()
3536 else {
3537 panic!();
3538 };
3539 assert_eq!(fss_ids, vec![a.0, b.0, c.0]);
3540 }
3541
3542 #[test]
3543 fn test_parse_merge_redeem_sender_account() {
3544 let sender = SuiAddress::random_for_testing_only();
3545 let fss = random_object_ref();
3546 let pt = merge_and_redeem_fss_pt(sender, vec![fss], &RedeemPlan::All).expect("pt");
3547 let ops = parse_pt(sender, pt);
3548 assert_eq!(ops[0].account.as_ref().unwrap().address, sender);
3549 }
3550
3551 #[test]
3552 fn test_parse_merge_redeem_no_amount_in_metadata() {
3553 let sender = SuiAddress::random_for_testing_only();
3554 let fss = random_object_ref();
3555 let pt = merge_and_redeem_fss_pt(
3556 sender,
3557 vec![fss],
3558 &RedeemPlan::AtMost {
3559 token_amount: Some(500_000_000),
3560 max_sui: 0,
3561 },
3562 )
3563 .expect("pt");
3564 let ops = parse_pt(sender, pt);
3565 let Some(OperationMetadata::MergeAndRedeemFungibleStakedSui { amount, .. }) =
3566 ops[0].metadata.clone()
3567 else {
3568 panic!();
3569 };
3570 assert!(amount.is_none());
3571 }
3572
3573 #[test]
3574 fn test_parse_merge_redeem_no_validator_in_metadata() {
3575 let sender = SuiAddress::random_for_testing_only();
3576 let fss = random_object_ref();
3577 let pt = merge_and_redeem_fss_pt(sender, vec![fss], &RedeemPlan::All).expect("pt");
3578 let ops = parse_pt(sender, pt);
3579 let Some(OperationMetadata::MergeAndRedeemFungibleStakedSui { validator, .. }) =
3580 ops[0].metadata.clone()
3581 else {
3582 panic!();
3583 };
3584 assert!(validator.is_none());
3585 }
3586
3587 fn build_redeem_ptb_with_type_arg(
3592 sender: SuiAddress,
3593 fss: ObjectRef,
3594 coin_type_arg: &str,
3595 ) -> ProgrammableTransaction {
3596 let mut builder = ProgrammableTransactionBuilder::new();
3597 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3598 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3599 let balance = builder.command(NativeCommand::move_call(
3600 SUI_SYSTEM_PACKAGE_ID,
3601 Identifier::new("sui_system").unwrap(),
3602 Identifier::new("redeem_fungible_staked_sui").unwrap(),
3603 vec![],
3604 vec![sys, fss_arg],
3605 ));
3606 let coin = builder.command(NativeCommand::move_call(
3607 SUI_FRAMEWORK_PACKAGE_ID,
3608 Identifier::new("coin").unwrap(),
3609 Identifier::new("from_balance").unwrap(),
3610 vec![sui_types::TypeTag::from_str(coin_type_arg).unwrap()],
3611 vec![balance],
3612 ));
3613 let sender_arg = builder.pure(sender).unwrap();
3614 builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
3615 builder.finish()
3616 }
3617
3618 #[test]
3619 fn test_parse_falls_through_redeem_wrong_type_arg() {
3620 let sender = SuiAddress::random_for_testing_only();
3621 let fss = random_object_ref();
3622 let pt = build_redeem_ptb_with_type_arg(sender, fss, "0x2::coin::Coin");
3624 let ops = parse_pt(sender, pt);
3625 assert_falls_through_to_generic(&ops);
3626 }
3627
3628 #[test]
3629 fn test_parse_falls_through_redeem_without_from_balance() {
3630 let sender = SuiAddress::random_for_testing_only();
3631 let fss = random_object_ref();
3632 let mut builder = ProgrammableTransactionBuilder::new();
3634 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3635 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3636 let balance = builder.command(NativeCommand::move_call(
3637 SUI_SYSTEM_PACKAGE_ID,
3638 Identifier::new("sui_system").unwrap(),
3639 Identifier::new("redeem_fungible_staked_sui").unwrap(),
3640 vec![],
3641 vec![sys, fss_arg],
3642 ));
3643 let sender_arg = builder.pure(sender).unwrap();
3644 builder.command(NativeCommand::TransferObjects(vec![balance], sender_arg));
3645 let ops = parse_pt(sender, builder.finish());
3646 assert_falls_through_to_generic(&ops);
3647 }
3648
3649 #[test]
3650 fn test_parse_falls_through_redeem_without_transfer() {
3651 let sender = SuiAddress::random_for_testing_only();
3652 let fss = random_object_ref();
3653 let mut builder = ProgrammableTransactionBuilder::new();
3654 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3655 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3656 let balance = builder.command(NativeCommand::move_call(
3657 SUI_SYSTEM_PACKAGE_ID,
3658 Identifier::new("sui_system").unwrap(),
3659 Identifier::new("redeem_fungible_staked_sui").unwrap(),
3660 vec![],
3661 vec![sys, fss_arg],
3662 ));
3663 builder.command(NativeCommand::move_call(
3664 SUI_FRAMEWORK_PACKAGE_ID,
3665 Identifier::new("coin").unwrap(),
3666 Identifier::new("from_balance").unwrap(),
3667 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
3668 vec![balance],
3669 ));
3670 let ops = parse_pt(sender, builder.finish());
3672 assert_falls_through_to_generic(&ops);
3673 }
3674
3675 #[test]
3676 fn test_parse_falls_through_redeem_transfer_wrong_recipient() {
3677 let sender = SuiAddress::random_for_testing_only();
3678 let other = SuiAddress::random_for_testing_only();
3679 let fss = random_object_ref();
3680 let mut builder = ProgrammableTransactionBuilder::new();
3681 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3682 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3683 let balance = builder.command(NativeCommand::move_call(
3684 SUI_SYSTEM_PACKAGE_ID,
3685 Identifier::new("sui_system").unwrap(),
3686 Identifier::new("redeem_fungible_staked_sui").unwrap(),
3687 vec![],
3688 vec![sys, fss_arg],
3689 ));
3690 let coin = builder.command(NativeCommand::move_call(
3691 SUI_FRAMEWORK_PACKAGE_ID,
3692 Identifier::new("coin").unwrap(),
3693 Identifier::new("from_balance").unwrap(),
3694 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
3695 vec![balance],
3696 ));
3697 let other_arg = builder.pure(other).unwrap();
3699 builder.command(NativeCommand::TransferObjects(vec![coin], other_arg));
3700 let ops = parse_pt(sender, builder.finish());
3701 assert_falls_through_to_generic(&ops);
3702 }
3703
3704 #[test]
3705 fn test_parse_falls_through_redeem_transfer_multiple_objects() {
3706 let sender = SuiAddress::random_for_testing_only();
3707 let fss = random_object_ref();
3708 let other_obj = random_object_ref();
3709 let mut builder = ProgrammableTransactionBuilder::new();
3710 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3711 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3712 let balance = builder.command(NativeCommand::move_call(
3713 SUI_SYSTEM_PACKAGE_ID,
3714 Identifier::new("sui_system").unwrap(),
3715 Identifier::new("redeem_fungible_staked_sui").unwrap(),
3716 vec![],
3717 vec![sys, fss_arg],
3718 ));
3719 let coin = builder.command(NativeCommand::move_call(
3720 SUI_FRAMEWORK_PACKAGE_ID,
3721 Identifier::new("coin").unwrap(),
3722 Identifier::new("from_balance").unwrap(),
3723 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
3724 vec![balance],
3725 ));
3726 let extra = builder.obj(ObjectArg::ImmOrOwnedObject(other_obj)).unwrap();
3728 let sender_arg = builder.pure(sender).unwrap();
3729 builder.command(NativeCommand::TransferObjects(
3730 vec![coin, extra],
3731 sender_arg,
3732 ));
3733 let ops = parse_pt(sender, builder.finish());
3734 assert_falls_through_to_generic(&ops);
3735 }
3736
3737 #[test]
3738 fn test_parse_falls_through_hybrid_convert_and_redeem() {
3739 let sender = SuiAddress::random_for_testing_only();
3743 let staked = random_object_ref();
3744 let fss = random_object_ref();
3745 let mut builder = ProgrammableTransactionBuilder::new();
3746 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3747 let staked_arg = builder.obj(ObjectArg::ImmOrOwnedObject(staked)).unwrap();
3748 let _new_fss = builder.command(NativeCommand::move_call(
3749 SUI_SYSTEM_PACKAGE_ID,
3750 Identifier::new("sui_system").unwrap(),
3751 Identifier::new("convert_to_fungible_staked_sui").unwrap(),
3752 vec![],
3753 vec![sys, staked_arg],
3754 ));
3755 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3756 let balance = builder.command(NativeCommand::move_call(
3757 SUI_SYSTEM_PACKAGE_ID,
3758 Identifier::new("sui_system").unwrap(),
3759 Identifier::new("redeem_fungible_staked_sui").unwrap(),
3760 vec![],
3761 vec![sys, fss_arg],
3762 ));
3763 let coin = builder.command(NativeCommand::move_call(
3764 SUI_FRAMEWORK_PACKAGE_ID,
3765 Identifier::new("coin").unwrap(),
3766 Identifier::new("from_balance").unwrap(),
3767 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
3768 vec![balance],
3769 ));
3770 let sender_arg = builder.pure(sender).unwrap();
3771 builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
3772 let ops = parse_pt(sender, builder.finish());
3773 assert_falls_through_to_generic(&ops);
3774 }
3775
3776 #[test]
3777 fn test_parse_falls_through_split_without_redeem() {
3778 let sender = SuiAddress::random_for_testing_only();
3779 let fss = random_object_ref();
3780 let mut builder = ProgrammableTransactionBuilder::new();
3781 let _sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3782 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3783 let split_amount = builder.pure(100u64).unwrap();
3784 builder.command(NativeCommand::move_call(
3785 SUI_SYSTEM_PACKAGE_ID,
3786 Identifier::new("staking_pool").unwrap(),
3787 Identifier::new("split_fungible_staked_sui").unwrap(),
3788 vec![],
3789 vec![fss_arg, split_amount],
3790 ));
3791 let ops = parse_pt(sender, builder.finish());
3793 assert_falls_through_to_generic(&ops);
3794 }
3795
3796 #[test]
3797 fn test_parse_falls_through_redeem_split_position_wrong() {
3798 let sender = SuiAddress::random_for_testing_only();
3800 let fss_a = random_object_ref();
3801 let fss_b = random_object_ref();
3802 let mut builder = ProgrammableTransactionBuilder::new();
3803 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3804 let a_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss_a)).unwrap();
3805 let b_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss_b)).unwrap();
3806 let balance = builder.command(NativeCommand::move_call(
3807 SUI_SYSTEM_PACKAGE_ID,
3808 Identifier::new("sui_system").unwrap(),
3809 Identifier::new("redeem_fungible_staked_sui").unwrap(),
3810 vec![],
3811 vec![sys, a_arg],
3812 ));
3813 let split_amount = builder.pure(100u64).unwrap();
3815 builder.command(NativeCommand::move_call(
3816 SUI_SYSTEM_PACKAGE_ID,
3817 Identifier::new("staking_pool").unwrap(),
3818 Identifier::new("split_fungible_staked_sui").unwrap(),
3819 vec![],
3820 vec![b_arg, split_amount],
3821 ));
3822 let coin = builder.command(NativeCommand::move_call(
3823 SUI_FRAMEWORK_PACKAGE_ID,
3824 Identifier::new("coin").unwrap(),
3825 Identifier::new("from_balance").unwrap(),
3826 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
3827 vec![balance],
3828 ));
3829 let sender_arg = builder.pure(sender).unwrap();
3830 builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
3831 let ops = parse_pt(sender, builder.finish());
3832 assert_falls_through_to_generic(&ops);
3833 }
3834
3835 #[test]
3836 fn test_parse_falls_through_redeem_wrong_system_state_immutable() {
3837 let sender = SuiAddress::random_for_testing_only();
3843 let fss = random_object_ref();
3844 let mut builder = ProgrammableTransactionBuilder::new();
3845 let _sys = builder
3847 .obj(ObjectArg::SharedObject {
3848 id: SUI_SYSTEM_STATE_OBJECT_ID,
3849 initial_shared_version: sui_types::SUI_SYSTEM_STATE_OBJECT_SHARED_VERSION,
3850 mutability: sui_types::transaction::SharedObjectMutability::Immutable,
3851 })
3852 .unwrap();
3853 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3854 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3857 let balance = builder.command(NativeCommand::move_call(
3858 SUI_SYSTEM_PACKAGE_ID,
3859 Identifier::new("sui_system").unwrap(),
3860 Identifier::new("redeem_fungible_staked_sui").unwrap(),
3861 vec![],
3862 vec![sys, fss_arg],
3863 ));
3864 let coin = builder.command(NativeCommand::move_call(
3865 SUI_FRAMEWORK_PACKAGE_ID,
3866 Identifier::new("coin").unwrap(),
3867 Identifier::new("from_balance").unwrap(),
3868 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
3869 vec![balance],
3870 ));
3871 let sender_arg = builder.pure(sender).unwrap();
3872 builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
3873 let ops = parse_pt(sender, builder.finish());
3884 assert!(
3887 ops[0].type_ == OperationType::MergeAndRedeemFungibleStakedSui
3888 || ops[0].type_ == OperationType::ProgrammableTransaction,
3889 "unexpected op type: {:?}",
3890 ops[0].type_
3891 );
3892 }
3893
3894 #[test]
3902 fn test_parse_falls_through_convert_without_transfer() {
3903 let sender = SuiAddress::random_for_testing_only();
3904 let staked = random_object_ref();
3905 let mut builder = ProgrammableTransactionBuilder::new();
3906 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3907 let staked_arg = builder.obj(ObjectArg::ImmOrOwnedObject(staked)).unwrap();
3908 let _new_fss = builder.command(NativeCommand::move_call(
3909 SUI_SYSTEM_PACKAGE_ID,
3910 Identifier::new("sui_system").unwrap(),
3911 Identifier::new("convert_to_fungible_staked_sui").unwrap(),
3912 vec![],
3913 vec![sys, staked_arg],
3914 ));
3915 let ops = parse_pt(sender, builder.finish());
3917 assert_falls_through_to_generic(&ops);
3918 }
3919
3920 #[test]
3924 fn test_parse_falls_through_pure_merge_with_transfer() {
3925 let sender = SuiAddress::random_for_testing_only();
3926 let fss_a = random_object_ref();
3927 let fss_b = random_object_ref();
3928 let mut builder = ProgrammableTransactionBuilder::new();
3929 let _sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3930 let first = builder.obj(ObjectArg::ImmOrOwnedObject(fss_a)).unwrap();
3931 let other = builder.obj(ObjectArg::ImmOrOwnedObject(fss_b)).unwrap();
3932 let join_result = builder.command(NativeCommand::move_call(
3933 SUI_SYSTEM_PACKAGE_ID,
3934 Identifier::new("staking_pool").unwrap(),
3935 Identifier::new("join_fungible_staked_sui").unwrap(),
3936 vec![],
3937 vec![first, other],
3938 ));
3939 let sender_arg = builder.pure(sender).unwrap();
3941 builder.command(NativeCommand::TransferObjects(
3942 vec![join_result],
3943 sender_arg,
3944 ));
3945 let ops = parse_pt(sender, builder.finish());
3946 assert_falls_through_to_generic(&ops);
3947 }
3948
3949 #[test]
3952 fn test_parse_falls_through_split_amount_not_pure() {
3953 let sender = SuiAddress::random_for_testing_only();
3954 let fss = random_object_ref();
3955 let bogus_obj = random_object_ref();
3956 let mut builder = ProgrammableTransactionBuilder::new();
3957 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3958 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3959 let bogus_arg = builder.obj(ObjectArg::ImmOrOwnedObject(bogus_obj)).unwrap();
3961 let split_result = builder.command(NativeCommand::move_call(
3962 SUI_SYSTEM_PACKAGE_ID,
3963 Identifier::new("staking_pool").unwrap(),
3964 Identifier::new("split_fungible_staked_sui").unwrap(),
3965 vec![],
3966 vec![fss_arg, bogus_arg],
3967 ));
3968 let balance = builder.command(NativeCommand::move_call(
3969 SUI_SYSTEM_PACKAGE_ID,
3970 Identifier::new("sui_system").unwrap(),
3971 Identifier::new("redeem_fungible_staked_sui").unwrap(),
3972 vec![],
3973 vec![sys, split_result],
3974 ));
3975 let coin = builder.command(NativeCommand::move_call(
3976 SUI_FRAMEWORK_PACKAGE_ID,
3977 Identifier::new("coin").unwrap(),
3978 Identifier::new("from_balance").unwrap(),
3979 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
3980 vec![balance],
3981 ));
3982 let sender_arg = builder.pure(sender).unwrap();
3983 builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
3984 let ops = parse_pt(sender, builder.finish());
3985 assert_falls_through_to_generic(&ops);
3986 }
3987
3988 #[test]
3992 fn test_parse_falls_through_convert_wrong_system_state_arg() {
3993 let sender = SuiAddress::random_for_testing_only();
3994 let staked = random_object_ref();
3995 let mut builder = ProgrammableTransactionBuilder::new();
3996 let _sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3998 let bogus_arg = builder.pure(0u64).unwrap();
4001 let staked_arg = builder.obj(ObjectArg::ImmOrOwnedObject(staked)).unwrap();
4002 let new_fss = builder.command(NativeCommand::move_call(
4003 SUI_SYSTEM_PACKAGE_ID,
4004 Identifier::new("sui_system").unwrap(),
4005 Identifier::new("convert_to_fungible_staked_sui").unwrap(),
4006 vec![],
4007 vec![bogus_arg, staked_arg],
4009 ));
4010 let sender_arg = builder.pure(sender).unwrap();
4011 builder.command(NativeCommand::TransferObjects(vec![new_fss], sender_arg));
4012 let ops = parse_pt(sender, builder.finish());
4013 assert_falls_through_to_generic(&ops);
4014 }
4015
4016 #[test]
4021 fn test_parse_falls_through_consolidate_same_input_both_convert_and_join() {
4022 let sender = SuiAddress::random_for_testing_only();
4023 let shared_input = random_object_ref();
4024 let other_fss = random_object_ref();
4025 let mut builder = ProgrammableTransactionBuilder::new();
4026 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
4027 let dual = builder
4029 .obj(ObjectArg::ImmOrOwnedObject(shared_input))
4030 .unwrap();
4031 let fss_b = builder.obj(ObjectArg::ImmOrOwnedObject(other_fss)).unwrap();
4032 builder.command(NativeCommand::move_call(
4034 SUI_SYSTEM_PACKAGE_ID,
4035 Identifier::new("staking_pool").unwrap(),
4036 Identifier::new("join_fungible_staked_sui").unwrap(),
4037 vec![],
4038 vec![dual, fss_b],
4039 ));
4040 let new_fss = builder.command(NativeCommand::move_call(
4042 SUI_SYSTEM_PACKAGE_ID,
4043 Identifier::new("sui_system").unwrap(),
4044 Identifier::new("convert_to_fungible_staked_sui").unwrap(),
4045 vec![],
4046 vec![sys, dual],
4047 ));
4048 let sender_arg = builder.pure(sender).unwrap();
4049 builder.command(NativeCommand::TransferObjects(vec![new_fss], sender_arg));
4050 let ops = parse_pt(sender, builder.finish());
4051 assert_falls_through_to_generic(&ops);
4052 }
4053
4054 fn build_malformed_atleast_ptb(
4076 sender: SuiAddress,
4077 fss: ObjectRef,
4078 wire_split_to_redeem: bool,
4079 wire_join_to_redeem: bool,
4080 wire_join_arg1_to_split: bool,
4081 wire_from_balance_to_redeem: bool,
4082 ) -> ProgrammableTransaction {
4083 use sui_types::transaction::Argument;
4084 let mut builder = ProgrammableTransactionBuilder::new();
4085 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
4086 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
4087 let split_amt = builder.pure(100u64).unwrap();
4088 let split_fss = builder.command(NativeCommand::move_call(
4090 SUI_SYSTEM_PACKAGE_ID,
4091 Identifier::new("staking_pool").unwrap(),
4092 Identifier::new("split_fungible_staked_sui").unwrap(),
4093 vec![],
4094 vec![fss_arg, split_amt],
4095 ));
4096 let redeem_balance = builder.command(NativeCommand::move_call(
4097 SUI_SYSTEM_PACKAGE_ID,
4098 Identifier::new("sui_system").unwrap(),
4099 Identifier::new("redeem_fungible_staked_sui").unwrap(),
4100 vec![],
4101 vec![sys, split_fss],
4102 ));
4103 let zero_balance = builder.command(NativeCommand::move_call(
4106 SUI_FRAMEWORK_PACKAGE_ID,
4107 Identifier::new("balance").unwrap(),
4108 Identifier::new("zero").unwrap(),
4109 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4110 vec![],
4111 ));
4112 let min_arg = builder.pure(0u64).unwrap();
4113 let split_arg0 = if wire_split_to_redeem {
4114 redeem_balance
4115 } else {
4116 zero_balance
4117 };
4118 let split_result = builder.command(NativeCommand::move_call(
4119 SUI_FRAMEWORK_PACKAGE_ID,
4120 Identifier::new("balance").unwrap(),
4121 Identifier::new("split").unwrap(),
4122 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4123 vec![split_arg0, min_arg],
4124 ));
4125 let join_arg0 = if wire_join_to_redeem {
4126 redeem_balance
4127 } else {
4128 zero_balance
4129 };
4130 let join_arg1 = if wire_join_arg1_to_split {
4131 split_result
4132 } else {
4133 builder.command(NativeCommand::move_call(
4136 SUI_FRAMEWORK_PACKAGE_ID,
4137 Identifier::new("balance").unwrap(),
4138 Identifier::new("zero").unwrap(),
4139 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4140 vec![],
4141 ))
4142 };
4143 builder.command(NativeCommand::move_call(
4144 SUI_FRAMEWORK_PACKAGE_ID,
4145 Identifier::new("balance").unwrap(),
4146 Identifier::new("join").unwrap(),
4147 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4148 vec![join_arg0, join_arg1],
4149 ));
4150 let from_balance_arg = if wire_from_balance_to_redeem {
4151 redeem_balance
4152 } else {
4153 zero_balance
4154 };
4155 let coin = builder.command(NativeCommand::move_call(
4156 SUI_FRAMEWORK_PACKAGE_ID,
4157 Identifier::new("coin").unwrap(),
4158 Identifier::new("from_balance").unwrap(),
4159 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4160 vec![from_balance_arg],
4161 ));
4162 let sender_arg = builder.pure(sender).unwrap();
4163 builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
4164 let _ = Argument::GasCoin; builder.finish()
4166 }
4167
4168 #[test]
4169 fn test_parse_falls_through_atleast_split_arg_not_redeem_result() {
4170 let sender = SuiAddress::random_for_testing_only();
4171 let fss = random_object_ref();
4172 let pt = build_malformed_atleast_ptb(sender, fss, false, true, true, true);
4174 assert_falls_through_to_generic(&parse_pt(sender, pt));
4175 }
4176
4177 #[test]
4178 fn test_parse_falls_through_atleast_join_arg0_not_redeem_result() {
4179 let sender = SuiAddress::random_for_testing_only();
4180 let fss = random_object_ref();
4181 let pt = build_malformed_atleast_ptb(sender, fss, true, false, true, true);
4183 assert_falls_through_to_generic(&parse_pt(sender, pt));
4184 }
4185
4186 #[test]
4187 fn test_parse_falls_through_atleast_join_arg1_not_split_result() {
4188 let sender = SuiAddress::random_for_testing_only();
4189 let fss = random_object_ref();
4190 let pt = build_malformed_atleast_ptb(sender, fss, true, true, false, true);
4192 assert_falls_through_to_generic(&parse_pt(sender, pt));
4193 }
4194
4195 #[test]
4196 fn test_parse_falls_through_atleast_from_balance_arg_not_redeem_result() {
4197 let sender = SuiAddress::random_for_testing_only();
4198 let fss = random_object_ref();
4199 let pt = build_malformed_atleast_ptb(sender, fss, true, true, true, false);
4201 assert_falls_through_to_generic(&parse_pt(sender, pt));
4202 }
4203
4204 #[test]
4210 fn test_parse_falls_through_atleast_split_arg_is_nested_result() {
4211 use sui_types::transaction::Argument;
4212 let sender = SuiAddress::random_for_testing_only();
4213 let fss = random_object_ref();
4214 let mut builder = ProgrammableTransactionBuilder::new();
4215 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
4216 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
4217 let split_amt = builder.pure(100u64).unwrap();
4218 let split_fss = builder.command(NativeCommand::move_call(
4219 SUI_SYSTEM_PACKAGE_ID,
4220 Identifier::new("staking_pool").unwrap(),
4221 Identifier::new("split_fungible_staked_sui").unwrap(),
4222 vec![],
4223 vec![fss_arg, split_amt],
4224 ));
4225 let _redeem = builder.command(NativeCommand::move_call(
4226 SUI_SYSTEM_PACKAGE_ID,
4227 Identifier::new("sui_system").unwrap(),
4228 Identifier::new("redeem_fungible_staked_sui").unwrap(),
4229 vec![],
4230 vec![sys, split_fss],
4231 ));
4232 let nested = Argument::NestedResult(1, 0);
4236 let min_arg = builder.pure(0u64).unwrap();
4237 let split_balance = builder.command(NativeCommand::move_call(
4238 SUI_FRAMEWORK_PACKAGE_ID,
4239 Identifier::new("balance").unwrap(),
4240 Identifier::new("split").unwrap(),
4241 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4242 vec![nested, min_arg],
4243 ));
4244 builder.command(NativeCommand::move_call(
4245 SUI_FRAMEWORK_PACKAGE_ID,
4246 Identifier::new("balance").unwrap(),
4247 Identifier::new("join").unwrap(),
4248 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4249 vec![nested, split_balance],
4250 ));
4251 let coin = builder.command(NativeCommand::move_call(
4252 SUI_FRAMEWORK_PACKAGE_ID,
4253 Identifier::new("coin").unwrap(),
4254 Identifier::new("from_balance").unwrap(),
4255 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4256 vec![nested],
4257 ));
4258 let sender_arg = builder.pure(sender).unwrap();
4259 builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
4260 assert_falls_through_to_generic(&parse_pt(sender, builder.finish()));
4261 }
4262
4263 #[test]
4267 fn test_parse_falls_through_transfer_not_from_balance_result() {
4268 let sender = SuiAddress::random_for_testing_only();
4269 let fss = random_object_ref();
4270 let mut builder = ProgrammableTransactionBuilder::new();
4271 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
4272 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
4273 let redeem = builder.command(NativeCommand::move_call(
4274 SUI_SYSTEM_PACKAGE_ID,
4275 Identifier::new("sui_system").unwrap(),
4276 Identifier::new("redeem_fungible_staked_sui").unwrap(),
4277 vec![],
4278 vec![sys, fss_arg],
4279 ));
4280 let _from_balance = builder.command(NativeCommand::move_call(
4281 SUI_FRAMEWORK_PACKAGE_ID,
4282 Identifier::new("coin").unwrap(),
4283 Identifier::new("from_balance").unwrap(),
4284 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4285 vec![redeem],
4286 ));
4287 let other_coin = builder.command(NativeCommand::move_call(
4291 SUI_FRAMEWORK_PACKAGE_ID,
4292 Identifier::new("coin").unwrap(),
4293 Identifier::new("zero").unwrap(),
4294 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4295 vec![],
4296 ));
4297 let sender_arg = builder.pure(sender).unwrap();
4298 builder.command(NativeCommand::TransferObjects(vec![other_coin], sender_arg));
4299 assert_falls_through_to_generic(&parse_pt(sender, builder.finish()));
4300 }
4301
4302 #[test]
4307 fn test_meta_merge_redeem_old_input_all() {
4308 let v = SuiAddress::random_for_testing_only();
4309 let json = serde_json::json!({
4310 "MergeAndRedeemFungibleStakedSui": {
4311 "validator": v.to_string(),
4312 "redeem_mode": "All"
4313 }
4314 });
4315 let meta: OperationMetadata = serde_json::from_value(json).unwrap();
4316 match meta {
4317 OperationMetadata::MergeAndRedeemFungibleStakedSui {
4318 validator,
4319 amount,
4320 redeem_mode,
4321 fss_ids,
4322 } => {
4323 assert_eq!(validator, Some(v));
4324 assert!(amount.is_none());
4325 assert_eq!(redeem_mode, Some(RedeemMode::All));
4326 assert!(fss_ids.is_empty());
4327 }
4328 _ => panic!("wrong variant"),
4329 }
4330 }
4331
4332 #[test]
4333 fn test_meta_merge_redeem_old_input_atleast() {
4334 let v = SuiAddress::random_for_testing_only();
4335 let json = serde_json::json!({
4336 "MergeAndRedeemFungibleStakedSui": {
4337 "validator": v.to_string(),
4338 "amount": "500000000000",
4339 "redeem_mode": "AtLeast"
4340 }
4341 });
4342 let meta: OperationMetadata = serde_json::from_value(json).unwrap();
4343 match meta {
4344 OperationMetadata::MergeAndRedeemFungibleStakedSui {
4345 validator,
4346 amount,
4347 redeem_mode,
4348 fss_ids,
4349 } => {
4350 assert_eq!(validator, Some(v));
4351 assert_eq!(amount, Some("500000000000".to_string()));
4352 assert_eq!(redeem_mode, Some(RedeemMode::AtLeast));
4353 assert!(fss_ids.is_empty());
4354 }
4355 _ => panic!(),
4356 }
4357 }
4358
4359 #[test]
4360 fn test_meta_merge_redeem_new_parse_output() {
4361 let id = ObjectID::random();
4362 let meta = OperationMetadata::MergeAndRedeemFungibleStakedSui {
4363 validator: None,
4364 amount: None,
4365 redeem_mode: Some(RedeemMode::All),
4366 fss_ids: vec![id],
4367 };
4368 let json = serde_json::to_value(&meta).unwrap();
4369 let obj = json
4370 .as_object()
4371 .unwrap()
4372 .get("MergeAndRedeemFungibleStakedSui")
4373 .unwrap()
4374 .as_object()
4375 .unwrap();
4376 assert!(!obj.contains_key("validator"));
4377 assert!(!obj.contains_key("amount"));
4378 assert_eq!(obj.get("redeem_mode").unwrap(), "All");
4379 assert_eq!(obj.get("fss_ids").unwrap().as_array().unwrap().len(), 1);
4380 }
4381
4382 #[test]
4383 fn test_meta_merge_redeem_new_parse_output_partial() {
4384 let id = ObjectID::random();
4385 let meta = OperationMetadata::MergeAndRedeemFungibleStakedSui {
4386 validator: None,
4387 amount: None,
4388 redeem_mode: None,
4389 fss_ids: vec![id],
4390 };
4391 let json = serde_json::to_value(&meta).unwrap();
4392 let obj = json
4393 .as_object()
4394 .unwrap()
4395 .get("MergeAndRedeemFungibleStakedSui")
4396 .unwrap()
4397 .as_object()
4398 .unwrap();
4399 assert!(!obj.contains_key("validator"));
4400 assert!(!obj.contains_key("amount"));
4401 assert!(
4402 !obj.contains_key("redeem_mode"),
4403 "redeem_mode must be omitted in partial parse output"
4404 );
4405 assert_eq!(obj.get("fss_ids").unwrap().as_array().unwrap().len(), 1);
4406 }
4407
4408 #[test]
4413 fn test_write_merge_redeem_requires_validator_and_mode() {
4414 let sender = SuiAddress::random_for_testing_only();
4415
4416 let op = Operation {
4418 operation_identifier: Default::default(),
4419 type_: OperationType::MergeAndRedeemFungibleStakedSui,
4420 status: None,
4421 account: Some(sender.into()),
4422 amount: None,
4423 coin_change: None,
4424 metadata: Some(OperationMetadata::MergeAndRedeemFungibleStakedSui {
4425 validator: None,
4426 amount: None,
4427 redeem_mode: Some(RedeemMode::All),
4428 fss_ids: vec![],
4429 }),
4430 };
4431 let err = Operations::new(vec![op])
4432 .into_internal()
4433 .expect_err("should fail without validator");
4434 assert!(format!("{err}").contains("validator"));
4435
4436 let op = Operation {
4438 operation_identifier: Default::default(),
4439 type_: OperationType::MergeAndRedeemFungibleStakedSui,
4440 status: None,
4441 account: Some(sender.into()),
4442 amount: None,
4443 coin_change: None,
4444 metadata: Some(OperationMetadata::MergeAndRedeemFungibleStakedSui {
4445 validator: Some(SuiAddress::random_for_testing_only()),
4446 amount: None,
4447 redeem_mode: None,
4448 fss_ids: vec![],
4449 }),
4450 };
4451 let err = Operations::new(vec![op])
4452 .into_internal()
4453 .expect_err("should fail without redeem_mode");
4454 assert!(format!("{err}").contains("redeem_mode"));
4455 }
4456
4457 use crate::types::CurrencyMetadata;
4460 use crate::types::internal_operation::pay_coin_pt;
4461
4462 fn sample_currency() -> Currency {
4463 Currency {
4464 symbol: "USDC".to_string(),
4465 decimals: 6,
4466 metadata: CurrencyMetadata {
4467 coin_type: "0x5::usdc::USDC".to_string(),
4468 },
4469 }
4470 }
4471
4472 fn data_with_pt(sender: SuiAddress, pt: ProgrammableTransaction) -> TransactionData {
4473 let gas_price = 1000;
4474 TransactionData::new_programmable(
4475 sender,
4476 vec![random_object_ref()],
4477 pt,
4478 TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
4479 gas_price,
4480 )
4481 }
4482
4483 fn proto_clean(data: &TransactionData) -> Transaction {
4486 use crate::types::transaction_envelope::{decode_inner_proto, encode_inner_proto};
4487 decode_inner_proto(&encode_inner_proto(data)).unwrap()
4488 }
4489
4490 #[test]
4492 fn test_reconstruct_pay_coin_currency() {
4493 let sender = SuiAddress::random_for_testing_only();
4494 let recipient = SuiAddress::random_for_testing_only();
4495 let coin = random_object_ref();
4496 let currency = sample_currency();
4497 let aux = AuxData::PayCoin {
4498 currency: currency.clone(),
4499 };
4500 let pt = pay_coin_pt(
4501 sender,
4502 vec![recipient],
4503 vec![10_000],
4504 &[coin],
4505 &[],
4506 0,
4507 ¤cy,
4508 )
4509 .unwrap();
4510 let proto = proto_clean(&data_with_pt(sender, pt));
4511
4512 let ops = reconstruct_operations(&proto, &aux, None).expect("reconstruct ok");
4513 assert!(ops.0.iter().any(|op| op.type_ == OperationType::PayCoin));
4514 let recip_amount = ops
4515 .0
4516 .iter()
4517 .find(|o| o.account.as_ref().map(|a| a.address) == Some(recipient))
4518 .and_then(|o| o.amount.clone())
4519 .expect("recipient op");
4520 assert_eq!(
4521 recip_amount.currency.metadata.coin_type,
4522 currency.metadata.coin_type
4523 );
4524 }
4525
4526 #[test]
4530 fn test_reconstruct_family_mismatch_rejected() {
4531 let sender = SuiAddress::random_for_testing_only();
4532 let pay_aux = AuxData::PayCoin {
4533 currency: sample_currency(),
4534 };
4535 let pt = consolidate_to_fungible_pt(
4536 sender,
4537 vec![random_object_ref()],
4538 vec![random_object_ref()],
4539 )
4540 .unwrap();
4541 let proto = proto_clean(&data_with_pt(sender, pt));
4542 let err = reconstruct_operations(&proto, &pay_aux, None)
4543 .expect_err("family mismatch must be rejected");
4544 assert!(format!("{err:?}").contains("non-payment"));
4545 }
4546
4547 #[test]
4549 fn test_reconstruct_consolidate_validator_decorated() {
4550 let sender = SuiAddress::random_for_testing_only();
4551 let validator = SuiAddress::random_for_testing_only();
4552 let aux = AuxData::Consolidate { validator };
4553 let pt = consolidate_to_fungible_pt(
4554 sender,
4555 vec![random_object_ref()],
4556 vec![random_object_ref()],
4557 )
4558 .unwrap();
4559 let proto = proto_clean(&data_with_pt(sender, pt));
4560 let ops = reconstruct_operations(&proto, &aux, None).unwrap();
4561 let Some(OperationMetadata::ConsolidateAllStakedSuiToFungible { validator: v, .. }) =
4562 ops.0[0].metadata.clone()
4563 else {
4564 panic!("expected Consolidate metadata");
4565 };
4566 assert_eq!(v, Some(validator));
4567 }
4568
4569 #[test]
4573 fn test_reconstruct_merge_redeem_atmost_decorated() {
4574 let sender = SuiAddress::random_for_testing_only();
4575 let validator = SuiAddress::random_for_testing_only();
4576 let aux = AuxData::MergeAndRedeem {
4577 validator,
4578 redeem_mode: RedeemMode::AtMost,
4579 amount: Some(1_000_000),
4580 };
4581 let plan = RedeemPlan::AtMost {
4582 token_amount: Some(500_000_000),
4583 max_sui: 0,
4584 };
4585 let pt = merge_and_redeem_fss_pt(sender, vec![random_object_ref()], &plan).unwrap();
4586 let proto = proto_clean(&data_with_pt(sender, pt));
4587 let ops = reconstruct_operations(&proto, &aux, None).unwrap();
4588 let Some(OperationMetadata::MergeAndRedeemFungibleStakedSui {
4589 validator: v,
4590 amount,
4591 redeem_mode,
4592 ..
4593 }) = ops.0[0].metadata.clone()
4594 else {
4595 panic!("expected MergeAndRedeem metadata");
4596 };
4597 assert_eq!(v, Some(validator));
4598 assert_eq!(redeem_mode, Some(RedeemMode::AtMost));
4599 assert_eq!(amount, Some("1000000".to_string()));
4600 }
4601
4602 #[test]
4604 fn test_reconstruct_pay_sui_none_ok() {
4605 let sender = SuiAddress::random_for_testing_only();
4606 let recipient = SuiAddress::random_for_testing_only();
4607 let pt = {
4608 let mut b = ProgrammableTransactionBuilder::new();
4609 b.pay_sui(vec![recipient], vec![10_000]).unwrap();
4610 b.finish()
4611 };
4612 let proto = proto_clean(&data_with_pt(sender, pt));
4613 let ops = reconstruct_operations(&proto, &AuxData::None, None)
4614 .expect("PaySui reconstructs with no aux data");
4615 assert!(ops.0.iter().any(|op| op.type_ == OperationType::PaySui));
4616 }
4617}