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(
1737 initial_balance_changes: &[(BalanceChange, Currency)],
1738 accounted_balances: &HashMap<(SuiAddress, Currency), i128>,
1739 new_operations: &[Operation],
1740 ) -> Result<(), anyhow::Error> {
1741 let mut expected_balances = initial_balance_changes.iter().fold(
1742 accounted_balances.clone(),
1743 |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 expected_balances.retain(|_, amount| *amount != 0);
1756
1757 let mut new_balances: HashMap<(SuiAddress, Currency), i128> = HashMap::new();
1758 for op in new_operations {
1759 if let Some(Amount {
1760 currency, value, ..
1761 }) = &op.amount
1762 {
1763 let account = op
1764 .account
1765 .as_ref()
1766 .ok_or_else(|| anyhow!("Missing account for a balance-change"))?;
1767 *new_balances
1768 .entry((account.address, currency.clone()))
1769 .or_default() += value;
1770 }
1771 }
1772
1773 for ((address, currency), new_amount) in new_balances {
1774 let expected_amount = expected_balances.remove(&(address, currency)).unwrap_or(0);
1775 if new_amount != expected_amount {
1776 return Err(anyhow!(
1777 "Expected {} balance-change for {} but got {}",
1778 expected_amount,
1779 address,
1780 new_amount
1781 ));
1782 }
1783 }
1784 if !expected_balances.is_empty() {
1785 return Err(anyhow!(
1786 "Expected every item in initial_balances to be mapped"
1787 ));
1788 }
1789 Ok(())
1790 }
1791
1792 fn process_gascoin_transfer(
1797 coin_change_operations: &mut impl Iterator<Item = Operation>,
1798 is_gascoin_transfer: bool,
1799 prev_gas_owner: SuiAddress,
1800 new_gas_owner: SuiAddress,
1801 gas_used: i128,
1802 initial_balance_changes: &[(BalanceChange, Currency)],
1803 accounted_balances: &HashMap<(SuiAddress, Currency), i128>,
1804 ) -> Result<Vec<Operation>, anyhow::Error> {
1805 let mut operations = vec![];
1806 if is_gascoin_transfer && prev_gas_owner != new_gas_owner {
1807 operations = coin_change_operations.collect();
1808 Self::add_missing_gas_owner(&mut operations, prev_gas_owner);
1809 Self::add_missing_gas_owner(&mut operations, new_gas_owner);
1810 for operation in &mut operations {
1811 match operation.type_ {
1812 OperationType::Gas => {
1813 operation.account = Some(prev_gas_owner.into())
1816 }
1817 OperationType::SuiBalanceChange => {
1818 let account = operation
1819 .account
1820 .as_ref()
1821 .ok_or_else(|| anyhow!("Missing account for a balance-change"))?;
1822 let amount = operation
1823 .amount
1824 .as_mut()
1825 .ok_or_else(|| anyhow!("Missing amount for a balance-change"))?;
1826 if account.address == prev_gas_owner && amount.currency == *SUI {
1828 amount.value -= gas_used;
1829 } else if account.address == new_gas_owner && amount.currency == *SUI {
1830 amount.value += gas_used;
1831 }
1832 }
1833 _ => {
1834 return Err(anyhow!(
1835 "Discarding unsupported operation type {:?}",
1836 operation.type_
1837 ));
1838 }
1839 }
1840 }
1841 Self::validate_operations(initial_balance_changes, accounted_balances, &operations)?;
1842 }
1843 Ok(operations)
1844 }
1845}
1846
1847impl Operations {
1848 pub async fn try_from_executed_transaction(
1849 executed_tx: ExecutedTransaction,
1850 cache: &CoinMetadataCache,
1851 ) -> Result<Self, Error> {
1852 let ExecutedTransaction {
1853 transaction,
1854 effects,
1855 events,
1856 balance_changes,
1857 ..
1858 } = executed_tx;
1859
1860 let transaction = transaction.ok_or_else(|| {
1861 Error::DataError("ExecutedTransaction missing transaction".to_string())
1862 })?;
1863 let effects = effects
1864 .ok_or_else(|| Error::DataError("ExecutedTransaction missing effects".to_string()))?;
1865
1866 let sender = SuiAddress::from_str(transaction.sender())?;
1867
1868 let gas_output_owner = effects.gas_object().output_owner().address();
1873 let gas_owner = if !gas_output_owner.is_empty() {
1874 SuiAddress::from_str(gas_output_owner)?
1875 } else if sender == SuiAddress::ZERO {
1876 sender
1878 } else {
1879 SuiAddress::from_str(transaction.gas_payment().owner())?
1883 };
1884
1885 let gas_summary = effects.gas_used();
1886 let gas_used = gas_summary.storage_rebate_opt().unwrap_or(0) as i128
1887 - gas_summary.storage_cost_opt().unwrap_or(0) as i128
1888 - gas_summary.computation_cost_opt().unwrap_or(0) as i128;
1889
1890 let status = Some(effects.status().into());
1891
1892 let prev_gas_owner = SuiAddress::from_str(transaction.gas_payment().owner())?;
1893
1894 let tx_kind = transaction
1895 .kind
1896 .ok_or_else(|| Error::DataError("Transaction missing kind".to_string()))?;
1897 let is_gascoin_transfer = Self::is_gascoin_transfer(&tx_kind);
1898
1899 let TxCurrencies {
1903 by_coin_type: currencies,
1904 payment,
1905 } = resolve_tx_currencies(&balance_changes, cache).await?;
1906 let ops = Self::new(Self::from_transaction(tx_kind, sender, status, payment)?);
1907 let ops = ops.into_iter();
1908
1909 let mut accounted_balances =
1912 ops.as_ref()
1913 .iter()
1914 .fold(HashMap::new(), |mut balances, op| {
1915 if let (Some(acc), Some(amount), Some(OperationStatus::Success)) =
1916 (&op.account, &op.amount, &op.status)
1917 {
1918 *balances
1919 .entry((acc.address, amount.clone().currency))
1920 .or_default() -= amount.value;
1921 }
1922 balances
1923 });
1924
1925 let mut principal_amounts = 0;
1926 let mut reward_amounts = 0;
1927
1928 let events = events.as_ref().map(|e| e.events.as_slice()).unwrap_or(&[]);
1930 for event in events {
1931 let event_type = event.event_type();
1932 if let Ok(type_tag) = StructTag::from_str(event_type)
1933 && is_unstake_event(&type_tag)
1934 && let Some(json) = &event.json
1935 && let Some(Kind::StructValue(struct_val)) = &json.kind
1936 {
1937 if let Some(principal_field) = struct_val.fields.get("principal_amount")
1938 && let Some(Kind::StringValue(s)) = &principal_field.kind
1939 && let Ok(amount) = i128::from_str(s)
1940 {
1941 principal_amounts += amount;
1942 }
1943 if let Some(reward_field) = struct_val.fields.get("reward_amount")
1944 && let Some(Kind::StringValue(s)) = &reward_field.kind
1945 && let Ok(amount) = i128::from_str(s)
1946 {
1947 reward_amounts += amount;
1948 }
1949 }
1950 }
1951 let staking_balance = if principal_amounts != 0 {
1952 *accounted_balances.entry((sender, SUI.clone())).or_default() -= principal_amounts;
1953 *accounted_balances.entry((sender, SUI.clone())).or_default() -= reward_amounts;
1954 vec![
1955 Operation::stake_principle(status, sender, principal_amounts),
1956 Operation::stake_reward(status, sender, reward_amounts),
1957 ]
1958 } else {
1959 vec![]
1960 };
1961
1962 let balance_changes_with_currency: Vec<_> = balance_changes
1965 .iter()
1966 .filter_map(|bc| {
1967 currencies
1968 .get(bc.coin_type())
1969 .map(|c| (bc.clone(), c.clone()))
1970 })
1971 .collect();
1972
1973 let mut coin_change_operations = Self::process_balance_change(
1975 gas_owner,
1976 gas_used,
1977 &balance_changes_with_currency,
1978 status,
1979 accounted_balances.clone(),
1980 );
1981
1982 let gascoin_transfer_operations = Self::process_gascoin_transfer(
1985 &mut coin_change_operations,
1986 is_gascoin_transfer,
1987 prev_gas_owner,
1988 gas_owner,
1989 gas_used,
1990 &balance_changes_with_currency,
1991 &accounted_balances,
1992 )?;
1993
1994 let ops: Operations = ops
1995 .into_iter()
1996 .chain(coin_change_operations)
1997 .chain(gascoin_transfer_operations)
1998 .chain(staking_balance)
1999 .collect();
2000
2001 let mutually_cancelling_balances: HashMap<_, _> = ops
2006 .clone()
2007 .into_iter()
2008 .fold(
2009 HashMap::new(),
2010 |mut balances: HashMap<(SuiAddress, Currency), i128>, op| {
2011 if let (Some(acc), Some(amount), Some(OperationStatus::Success)) =
2012 (&op.account, &op.amount, &op.status)
2013 && op.type_ != OperationType::Gas
2014 {
2015 *balances
2016 .entry((acc.address, amount.clone().currency))
2017 .or_default() += amount.value;
2018 }
2019 balances
2020 },
2021 )
2022 .into_iter()
2023 .filter(|balance| {
2024 let (_, amount) = balance;
2025 *amount == 0
2026 })
2027 .collect();
2028
2029 let ops: Operations = ops
2030 .into_iter()
2031 .filter(|op| {
2032 if let (Some(acc), Some(amount)) = (&op.account, &op.amount) {
2033 return op.type_ == OperationType::Gas
2034 || !mutually_cancelling_balances
2035 .contains_key(&(acc.address, amount.clone().currency));
2036 }
2037 true
2038 })
2039 .collect();
2040 Ok(ops)
2041 }
2042}
2043
2044fn is_unstake_event(tag: &StructTag) -> bool {
2045 tag.address == SUI_SYSTEM_ADDRESS
2046 && tag.module.as_ident_str() == ident_str!("validator")
2047 && tag.name.as_ident_str() == ident_str!("UnstakingRequestEvent")
2048}
2049
2050#[derive(Deserialize, Serialize, Clone, Debug)]
2051pub struct Operation {
2052 operation_identifier: OperationIdentifier,
2053 #[serde(rename = "type")]
2054 pub type_: OperationType,
2055 #[serde(default, skip_serializing_if = "Option::is_none")]
2056 pub status: Option<OperationStatus>,
2057 #[serde(default, skip_serializing_if = "Option::is_none")]
2058 pub account: Option<AccountIdentifier>,
2059 #[serde(default, skip_serializing_if = "Option::is_none")]
2060 pub amount: Option<Amount>,
2061 #[serde(default, skip_serializing_if = "Option::is_none")]
2062 pub coin_change: Option<CoinChange>,
2063 #[serde(default, skip_serializing_if = "Option::is_none")]
2064 pub metadata: Option<OperationMetadata>,
2065}
2066
2067impl PartialEq for Operation {
2068 fn eq(&self, other: &Self) -> bool {
2069 self.operation_identifier == other.operation_identifier
2070 && self.type_ == other.type_
2071 && self.account == other.account
2072 && self.amount == other.amount
2073 && self.coin_change == other.coin_change
2074 && self.metadata == other.metadata
2075 }
2076}
2077
2078#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
2079pub enum OperationMetadata {
2080 GenericTransaction(TransactionKind),
2081 Stake {
2082 validator: SuiAddress,
2083 },
2084 WithdrawStake {
2085 stake_ids: Vec<ObjectID>,
2086 },
2087 ConsolidateAllStakedSuiToFungible {
2088 #[serde(default, skip_serializing_if = "Option::is_none")]
2089 validator: Option<SuiAddress>,
2090 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2091 staked_sui_ids: Vec<ObjectID>,
2092 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2093 fss_ids: Vec<ObjectID>,
2094 },
2095 MergeAndRedeemFungibleStakedSui {
2096 #[serde(default, skip_serializing_if = "Option::is_none")]
2097 validator: Option<SuiAddress>,
2098 #[serde(default, skip_serializing_if = "Option::is_none")]
2099 amount: Option<String>,
2100 #[serde(default, skip_serializing_if = "Option::is_none")]
2101 redeem_mode: Option<RedeemMode>,
2102 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2103 fss_ids: Vec<ObjectID>,
2104 },
2105}
2106
2107impl Operation {
2108 fn generic_op(
2109 status: Option<OperationStatus>,
2110 sender: SuiAddress,
2111 tx: TransactionKind,
2112 ) -> Self {
2113 Operation {
2114 operation_identifier: Default::default(),
2115 type_: (&tx).into(),
2116 status,
2117 account: Some(sender.into()),
2118 amount: None,
2119 coin_change: None,
2120 metadata: Some(OperationMetadata::GenericTransaction(tx)),
2121 }
2122 }
2123
2124 pub fn genesis(index: u64, sender: SuiAddress, coin: GasCoin) -> Self {
2125 Operation {
2126 operation_identifier: index.into(),
2127 type_: OperationType::Genesis,
2128 status: Some(OperationStatus::Success),
2129 account: Some(sender.into()),
2130 amount: Some(Amount::new(coin.value().into(), None)),
2131 coin_change: Some(CoinChange {
2132 coin_identifier: CoinIdentifier {
2133 identifier: CoinID {
2134 id: *coin.id(),
2135 version: SequenceNumber::new(),
2136 },
2137 },
2138 coin_action: CoinAction::CoinCreated,
2139 }),
2140 metadata: None,
2141 }
2142 }
2143
2144 fn pay_sui(status: Option<OperationStatus>, address: SuiAddress, amount: i128) -> Self {
2145 Operation {
2146 operation_identifier: Default::default(),
2147 type_: OperationType::PaySui,
2148 status,
2149 account: Some(address.into()),
2150 amount: Some(Amount::new(amount, None)),
2151 coin_change: None,
2152 metadata: None,
2153 }
2154 }
2155
2156 fn pay_coin(
2157 status: Option<OperationStatus>,
2158 address: SuiAddress,
2159 amount: i128,
2160 currency: Option<Currency>,
2161 ) -> Self {
2162 Operation {
2163 operation_identifier: Default::default(),
2164 type_: OperationType::PayCoin,
2165 status,
2166 account: Some(address.into()),
2167 amount: Some(Amount::new(amount, currency)),
2168 coin_change: None,
2169 metadata: None,
2170 }
2171 }
2172
2173 fn balance_change(
2174 status: Option<OperationStatus>,
2175 addr: SuiAddress,
2176 amount: i128,
2177 currency: Currency,
2178 ) -> Self {
2179 Self {
2180 operation_identifier: Default::default(),
2181 type_: OperationType::SuiBalanceChange,
2182 status,
2183 account: Some(addr.into()),
2184 amount: Some(Amount::new(amount, Some(currency))),
2185 coin_change: None,
2186 metadata: None,
2187 }
2188 }
2189 fn gas(addr: SuiAddress, amount: i128) -> Self {
2190 Self {
2191 operation_identifier: Default::default(),
2192 type_: OperationType::Gas,
2193 status: Some(OperationStatus::Success),
2194 account: Some(addr.into()),
2195 amount: Some(Amount::new(amount, None)),
2196 coin_change: None,
2197 metadata: None,
2198 }
2199 }
2200 fn stake_reward(status: Option<OperationStatus>, addr: SuiAddress, amount: i128) -> Self {
2201 Self {
2202 operation_identifier: Default::default(),
2203 type_: OperationType::StakeReward,
2204 status,
2205 account: Some(addr.into()),
2206 amount: Some(Amount::new(amount, None)),
2207 coin_change: None,
2208 metadata: None,
2209 }
2210 }
2211 fn stake_principle(status: Option<OperationStatus>, addr: SuiAddress, amount: i128) -> Self {
2212 Self {
2213 operation_identifier: Default::default(),
2214 type_: OperationType::StakePrinciple,
2215 status,
2216 account: Some(addr.into()),
2217 amount: Some(Amount::new(amount, None)),
2218 coin_change: None,
2219 metadata: None,
2220 }
2221 }
2222}
2223
2224pub fn reconstruct_operations(
2243 proto: &ProtoTransaction,
2244 aux: &AuxData,
2245 status: Option<OperationStatus>,
2246) -> Result<Operations, Error> {
2247 let sender = SuiAddress::from_str(proto.sender())
2248 .map_err(|e| Error::DataError(format!("invalid transaction sender: {e}")))?;
2249 let tx_kind = proto
2250 .kind
2251 .clone()
2252 .ok_or_else(|| Error::DataError("Transaction missing kind".to_string()))?;
2253
2254 let payment_currency = match aux {
2257 AuxData::PayCoin { currency } => PaymentCurrency::NonSui(currency.clone()),
2258 _ => PaymentCurrency::Sui,
2259 };
2260 let mut ops = Operations::from_transaction(tx_kind, sender, status, payment_currency)?;
2261
2262 apply_aux(&mut ops, aux)?;
2264 Ok(Operations::new(ops))
2265}
2266
2267fn apply_aux(ops: &mut [Operation], aux: &AuxData) -> Result<(), Error> {
2270 match aux {
2271 AuxData::None => {}
2272 AuxData::PayCoin { .. } => {
2273 let is_payment = ops
2277 .iter()
2278 .all(|op| matches!(op.type_, OperationType::PayCoin | OperationType::PaySui));
2279 if ops.is_empty() || !is_payment {
2280 return Err(Error::DataError(
2281 "envelope inconsistency: PayCoin aux data over a non-payment transaction"
2282 .to_string(),
2283 ));
2284 }
2285 }
2286 AuxData::Consolidate { validator } => {
2287 let op = single_op(ops, OperationType::ConsolidateAllStakedSuiToFungible)?;
2288 match &mut op.metadata {
2289 Some(OperationMetadata::ConsolidateAllStakedSuiToFungible {
2290 validator: v, ..
2291 }) => {
2292 *v = Some(*validator);
2293 }
2294 _ => {
2295 return Err(Error::DataError(
2296 "envelope inconsistency: Consolidate aux data but parsed op lacks \
2297 Consolidate metadata"
2298 .to_string(),
2299 ));
2300 }
2301 }
2302 }
2303 AuxData::MergeAndRedeem {
2304 validator,
2305 redeem_mode,
2306 amount,
2307 } => {
2308 match redeem_mode {
2313 RedeemMode::All if amount.is_some() => {
2314 return Err(Error::DataError(
2315 "MergeAndRedeem All must carry no amount".to_string(),
2316 ));
2317 }
2318 RedeemMode::AtLeast | RedeemMode::AtMost if !matches!(amount, Some(a) if *a > 0) => {
2319 return Err(Error::DataError(format!(
2320 "MergeAndRedeem {redeem_mode:?} must carry a positive amount"
2321 )));
2322 }
2323 _ => {}
2324 }
2325 let op = single_op(ops, OperationType::MergeAndRedeemFungibleStakedSui)?;
2326 match &mut op.metadata {
2327 Some(OperationMetadata::MergeAndRedeemFungibleStakedSui {
2328 validator: v,
2329 amount: a,
2330 redeem_mode: m,
2331 ..
2332 }) => {
2333 *v = Some(*validator);
2337 *m = Some(redeem_mode.clone());
2338 *a = amount.map(|amount| amount.to_string());
2339 }
2340 _ => {
2341 return Err(Error::DataError(
2342 "envelope inconsistency: MergeAndRedeem aux data but parsed op lacks \
2343 MergeAndRedeem metadata"
2344 .to_string(),
2345 ));
2346 }
2347 }
2348 }
2349 }
2350 Ok(())
2351}
2352
2353fn single_op(ops: &mut [Operation], expected: OperationType) -> Result<&mut Operation, Error> {
2356 match ops {
2357 [op] if op.type_ == expected => Ok(op),
2358 _ => Err(Error::DataError(format!(
2359 "envelope inconsistency: aux data expects a single {expected:?} operation, \
2360 but the transaction parsed to a different shape"
2361 ))),
2362 }
2363}
2364
2365#[cfg(test)]
2366mod tests {
2367 use super::*;
2368 use crate::types::ConstructionMetadata;
2369 use crate::types::internal_operation::{consolidate_to_fungible_pt, merge_and_redeem_fss_pt};
2370 use sui_rpc::proto::sui::rpc::v2::Transaction;
2371 use sui_types::Identifier;
2372 use sui_types::base_types::{ObjectDigest, ObjectID, ObjectRef, SequenceNumber, SuiAddress};
2373 use sui_types::programmable_transaction_builder::ProgrammableTransactionBuilder;
2374 use sui_types::transaction::{
2375 CallArg, Command as NativeCommand, ObjectArg, ProgrammableTransaction,
2376 TEST_ONLY_GAS_UNIT_FOR_TRANSFER, TransactionData,
2377 };
2378
2379 fn random_object_ref() -> ObjectRef {
2380 (
2381 ObjectID::random(),
2382 SequenceNumber::from(1),
2383 ObjectDigest::random(),
2384 )
2385 }
2386
2387 fn parse_pt(sender: SuiAddress, pt: ProgrammableTransaction) -> Vec<Operation> {
2390 let gas = random_object_ref();
2391 let gas_price = 10;
2392 let data = TransactionData::new_programmable(
2393 sender,
2394 vec![gas],
2395 pt,
2396 TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
2397 gas_price,
2398 );
2399 let proto_tx: Transaction = data.into();
2400 let tx_kind = proto_tx.kind.expect("tx missing kind");
2401 Operations::from_transaction(tx_kind, sender, None, PaymentCurrency::Sui)
2402 .expect("parse failed")
2403 }
2404
2405 #[tokio::test]
2406 async fn test_operation_data_parsing_pay_sui() -> Result<(), anyhow::Error> {
2407 let gas = (
2408 ObjectID::random(),
2409 SequenceNumber::new(),
2410 ObjectDigest::random(),
2411 );
2412
2413 let sender = SuiAddress::random_for_testing_only();
2414
2415 let pt = {
2416 let mut builder = ProgrammableTransactionBuilder::new();
2417 builder
2418 .pay_sui(vec![SuiAddress::random_for_testing_only()], vec![10000])
2419 .unwrap();
2420 builder.finish()
2421 };
2422 let gas_price = 10;
2423 let data = TransactionData::new_programmable(
2424 sender,
2425 vec![gas],
2426 pt,
2427 TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
2428 gas_price,
2429 );
2430
2431 let proto_tx: Transaction = data.clone().into();
2432 let ops = Operations::new(Operations::from_transaction(
2433 proto_tx
2434 .kind
2435 .ok_or_else(|| Error::DataError("Transaction missing kind".to_string()))?,
2436 sender,
2437 None,
2438 PaymentCurrency::Sui,
2439 )?);
2440 ops.0
2441 .iter()
2442 .for_each(|op| assert_eq!(op.type_, OperationType::PaySui));
2443 let metadata = ConstructionMetadata {
2444 sender,
2445 gas_coins: vec![gas],
2446 extra_gas_coins: vec![],
2447 objects: vec![],
2448 party_objects: vec![],
2449 total_coin_value: 0,
2450 gas_price,
2451 budget: TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
2452 currency: None,
2453 address_balance_withdrawal: 0,
2454 epoch: None,
2455 chain_id: None,
2456 nonce: None,
2457 fss_object_count: None,
2458 redeem_token_amount: None,
2459 redeem_plan: None,
2460 bind_epoch: None,
2461 estimate_only: false,
2462 };
2463 let parsed_data = ops.into_internal()?.try_into_data(metadata)?;
2464 assert_eq!(data, parsed_data);
2465
2466 Ok(())
2467 }
2468
2469 #[test]
2474 fn test_stake_parse_round_trip() -> Result<(), anyhow::Error> {
2475 use sui_types::transaction::TEST_ONLY_GAS_UNIT_FOR_STAKING;
2476
2477 let sender = SuiAddress::random_for_testing_only();
2478 let validator = SuiAddress::random_for_testing_only();
2479 let gas = random_object_ref();
2480 let gas_price = 10;
2481
2482 let ops: Operations = serde_json::from_value(serde_json::json!([{
2483 "operation_identifier": {"index": 0},
2484 "type": "Stake",
2485 "account": {"address": sender.to_string()},
2486 "amount": {"value": "-100000", "currency": {"symbol": "SUI", "decimals": 9}},
2487 "metadata": {"Stake": {"validator": validator.to_string()}}
2488 }]))?;
2489
2490 let metadata = ConstructionMetadata {
2491 sender,
2492 gas_coins: vec![gas],
2493 extra_gas_coins: vec![],
2494 objects: vec![],
2495 party_objects: vec![],
2496 total_coin_value: 0,
2497 gas_price,
2498 budget: gas_price * TEST_ONLY_GAS_UNIT_FOR_STAKING,
2499 currency: None,
2500 address_balance_withdrawal: 0,
2501 epoch: None,
2502 chain_id: None,
2503 nonce: None,
2504 fss_object_count: None,
2505 redeem_token_amount: None,
2506 redeem_plan: None,
2507 bind_epoch: None,
2508 estimate_only: false,
2509 };
2510 let parsed_data = ops.clone().into_internal()?.try_into_data(metadata)?;
2511
2512 let proto_tx: Transaction = parsed_data.clone().into();
2513 let parsed_ops = Operations::new(Operations::from_transaction(
2514 proto_tx
2515 .kind
2516 .ok_or_else(|| Error::DataError("Transaction missing kind".to_string()))?,
2517 sender,
2518 None,
2519 PaymentCurrency::Sui,
2520 )?);
2521
2522 assert_eq!(ops, parsed_ops, "expected {ops:#?}, got: {parsed_ops:#?}");
2523 Ok(())
2524 }
2525
2526 fn parse_payment_pt(payment: PaymentCurrency) -> Result<Vec<Operation>, anyhow::Error> {
2529 use crate::SUI;
2530 use crate::types::internal_operation::pay_coin_pt;
2531
2532 let gas = (
2533 ObjectID::random(),
2534 SequenceNumber::new(),
2535 ObjectDigest::random(),
2536 );
2537 let coin = (
2538 ObjectID::random(),
2539 SequenceNumber::new(),
2540 ObjectDigest::random(),
2541 );
2542 let sender = SuiAddress::random_for_testing_only();
2543 let recipient = SuiAddress::random_for_testing_only();
2544 let pt = pay_coin_pt(sender, vec![recipient], vec![10_000], &[coin], &[], 0, &SUI)?;
2545 let gas_price = 10;
2546 let data = TransactionData::new_programmable(
2547 sender,
2548 vec![gas],
2549 pt,
2550 TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
2551 gas_price,
2552 );
2553 let proto_tx: Transaction = data.into();
2554 let tx_kind = proto_tx.kind.unwrap();
2555 Ok(Operations::from_transaction(
2556 tx_kind, sender, None, payment,
2557 )?)
2558 }
2559
2560 #[test]
2565 fn test_parse_unresolvable_emits_generic_op() -> Result<(), anyhow::Error> {
2566 let ops = parse_payment_pt(PaymentCurrency::Unresolvable)?;
2567 assert!(
2568 !ops.iter().any(|op| op.type_ == OperationType::PaySui),
2569 "Unresolvable must not silently fall back to PaySui: {ops:?}"
2570 );
2571 assert!(
2572 !ops.iter().any(|op| op.type_ == OperationType::PayCoin),
2573 "Unresolvable must not produce PayCoin (we don't know the currency): {ops:?}"
2574 );
2575 assert!(
2576 ops.iter()
2577 .any(|op| matches!(op.metadata, Some(OperationMetadata::GenericTransaction(_)))),
2578 "Unresolvable must fall through to generic_op: {ops:?}"
2579 );
2580 Ok(())
2581 }
2582
2583 #[test]
2586 fn test_parse_nonsui_emits_pay_coin() -> Result<(), anyhow::Error> {
2587 use crate::types::CurrencyMetadata;
2588
2589 let usdc = Currency {
2590 symbol: "USDC".to_string(),
2591 decimals: 6,
2592 metadata: CurrencyMetadata {
2593 coin_type: "0xaaa::usdc::USDC".to_string(),
2594 },
2595 };
2596 let ops = parse_payment_pt(PaymentCurrency::NonSui(usdc.clone()))?;
2597 assert!(
2598 !ops.iter().any(|op| op.type_ == OperationType::PaySui),
2599 "NonSui must not produce PaySui: {ops:?}"
2600 );
2601 let pay_coins: Vec<_> = ops
2602 .iter()
2603 .filter(|op| op.type_ == OperationType::PayCoin)
2604 .collect();
2605 assert!(
2606 !pay_coins.is_empty(),
2607 "NonSui must produce PayCoin: {ops:?}"
2608 );
2609 for op in pay_coins {
2610 assert_eq!(
2611 op.amount.as_ref().map(|a| &a.currency),
2612 Some(&usdc),
2613 "PayCoin op must carry the NonSui currency: {op:?}"
2614 );
2615 }
2616 Ok(())
2617 }
2618
2619 fn unreachable_cache() -> CoinMetadataCache {
2622 use std::num::NonZeroUsize;
2623 use sui_rpc::client::Client;
2624 CoinMetadataCache::new(
2625 Client::new("http://127.0.0.1:1").unwrap(),
2626 NonZeroUsize::new(1).unwrap(),
2627 )
2628 }
2629
2630 fn balance_change(coin_type: &str) -> BalanceChange {
2631 let mut bc = BalanceChange::default();
2632 bc.coin_type = Some(coin_type.to_string());
2633 bc
2634 }
2635
2636 #[tokio::test]
2640 async fn test_resolve_sui_needs_no_lookup() {
2641 let cache = unreachable_cache();
2642 let resolved = resolve_tx_currencies(&[balance_change(&SUI.metadata.coin_type)], &cache)
2643 .await
2644 .expect("SUI must resolve without an RPC");
2645 assert!(matches!(resolved.payment, PaymentCurrency::Sui));
2646 assert_eq!(
2647 resolved.by_coin_type.get(&SUI.metadata.coin_type),
2648 Some(&*SUI)
2649 );
2650 }
2651
2652 #[tokio::test]
2658 async fn test_resolve_non_struct_coin_type_degrades() {
2659 let cache = unreachable_cache();
2660 let resolved = resolve_tx_currencies(&[balance_change("u64")], &cache)
2661 .await
2662 .expect("a non-struct coin type must not fail the block");
2663 assert!(
2664 matches!(resolved.payment, PaymentCurrency::Unresolvable),
2665 "a non-struct coin type must fall through to generic_op: {:?}",
2666 resolved.payment
2667 );
2668 assert!(
2669 resolved.by_coin_type.is_empty(),
2670 "a non-struct coin type must not be reported as a currency: {:?}",
2671 resolved.by_coin_type
2672 );
2673 }
2674
2675 #[tokio::test]
2679 async fn test_resolve_transient_non_sui_is_retriable() {
2680 let cache = unreachable_cache();
2681 let err = resolve_tx_currencies(&[balance_change("0xaaa::usdc::USDC")], &cache)
2682 .await
2683 .expect_err("a transient non-SUI lookup failure must surface as an error");
2684 assert!(
2685 matches!(err, Error::CoinMetadataUnavailable(_)),
2686 "transient failure must map to CoinMetadataUnavailable: {err:?}"
2687 );
2688 let json = serde_json::to_value(&err).expect("error serializes");
2690 assert_eq!(
2691 json.get("retriable"),
2692 Some(&serde_json::Value::Bool(true)),
2693 "CoinMetadataUnavailable must serialize as retriable: {json}"
2694 );
2695 }
2696
2697 #[test]
2702 fn test_pay_coin_pt_has_no_currency_bearer() -> Result<(), anyhow::Error> {
2703 use crate::SUI;
2704 use crate::types::internal_operation::pay_coin_pt;
2705
2706 let sender = SuiAddress::random_for_testing_only();
2707 let recipient = SuiAddress::random_for_testing_only();
2708 let coin = (
2709 ObjectID::random(),
2710 SequenceNumber::new(),
2711 ObjectDigest::random(),
2712 );
2713
2714 let pt = pay_coin_pt(sender, vec![recipient], vec![10_000], &[coin], &[], 0, &SUI)?;
2715
2716 for input in &pt.inputs {
2717 if let CallArg::Pure(bytes) = input
2718 && let Ok(s) = bcs::from_bytes::<String>(bytes)
2719 && serde_json::from_str::<Currency>(&s).is_ok()
2720 {
2721 panic!(
2722 "pay_coin_pt produced a Pure input that decodes as a Currency JSON string: {:?}",
2723 s
2724 );
2725 }
2726 }
2727 Ok(())
2728 }
2729
2730 #[tokio::test]
2739 async fn test_try_from_executed_transaction_deleted_gas_coin() -> Result<(), anyhow::Error> {
2740 use std::num::NonZeroUsize;
2741 use sui_rpc::client::Client;
2742 use sui_rpc::proto::sui::rpc::v2::changed_object::OutputObjectState;
2743 use sui_rpc::proto::sui::rpc::v2::{
2744 ChangedObject, ExecutedTransaction, ExecutionStatus, GasCostSummary, TransactionEffects,
2745 };
2746
2747 let sender = SuiAddress::random_for_testing_only();
2748 let recipient = SuiAddress::random_for_testing_only();
2749
2750 let pt = {
2751 let mut builder = ProgrammableTransactionBuilder::new();
2752 builder.pay_sui(vec![recipient], vec![1000]).unwrap();
2753 builder.finish()
2754 };
2755 let gas_price = 10;
2756 let data = TransactionData::new_programmable(
2757 sender,
2758 vec![random_object_ref()],
2759 pt,
2760 TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
2761 gas_price,
2762 );
2763 let transaction: Transaction = data.into();
2764
2765 let mut gas_object = ChangedObject::default();
2768 gas_object.object_id = Some(ObjectID::random().to_string());
2769 gas_object.output_state = Some(OutputObjectState::DoesNotExist as i32);
2770 gas_object.output_owner = None;
2771
2772 let mut status = ExecutionStatus::default();
2773 status.success = Some(true);
2774
2775 let mut gas_used = GasCostSummary::default();
2776 gas_used.computation_cost = Some(1000);
2777 gas_used.storage_cost = Some(0);
2778 gas_used.storage_rebate = Some(0);
2779 gas_used.non_refundable_storage_fee = Some(0);
2780
2781 let mut effects = TransactionEffects::default();
2782 effects.status = Some(status);
2783 effects.gas_used = Some(gas_used);
2784 effects.gas_object = Some(gas_object);
2785
2786 let mut executed_tx = ExecutedTransaction::default();
2787 executed_tx.transaction = Some(transaction);
2788 executed_tx.effects = Some(effects);
2789 executed_tx.events = None;
2790 executed_tx.balance_changes = vec![];
2791
2792 let cache = CoinMetadataCache::new(
2795 Client::new("http://127.0.0.1:1").unwrap(),
2796 NonZeroUsize::new(1).unwrap(),
2797 );
2798
2799 let ops = Operations::try_from_executed_transaction(executed_tx, &cache).await?;
2800
2801 let gas_op = ops
2802 .0
2803 .iter()
2804 .find(|op| op.type_ == OperationType::Gas)
2805 .expect("expected a Gas operation");
2806 assert_eq!(gas_op.account.as_ref().map(|a| a.address), Some(sender));
2807
2808 Ok(())
2809 }
2810
2811 #[tokio::test]
2817 async fn test_try_from_executed_transaction_unstake_with_gas_coin_transfer()
2818 -> Result<(), anyhow::Error> {
2819 use std::num::NonZeroUsize;
2820 use sui_rpc::client::Client;
2821 use sui_rpc::proto::sui::rpc::v2::owner::OwnerKind;
2822 use sui_rpc::proto::sui::rpc::v2::{
2823 ChangedObject, Event, ExecutedTransaction, ExecutionStatus, GasCostSummary, Owner,
2824 TransactionEffects, TransactionEvents,
2825 };
2826 use sui_types::transaction::Argument as NativeArgument;
2827
2828 let sender = SuiAddress::random_for_testing_only();
2829 let recipient = SuiAddress::random_for_testing_only();
2830 let principal: i128 = 1_000_000_000_000;
2831 let reward: i128 = 10_000_000_000;
2832 let gas_coin_balance: i128 = 5_000_000_000;
2833 let computation_cost: i128 = 1_000;
2834
2835 let pt = {
2836 let mut builder = ProgrammableTransactionBuilder::new();
2837 let system = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
2838 let staked_sui = builder
2839 .obj(ObjectArg::ImmOrOwnedObject(random_object_ref()))
2840 .unwrap();
2841 let balance = builder.command(NativeCommand::move_call(
2842 SUI_SYSTEM_PACKAGE_ID,
2843 Identifier::new("sui_system").unwrap(),
2844 Identifier::new("request_withdraw_stake_non_entry").unwrap(),
2845 vec![],
2846 vec![system, staked_sui],
2847 ));
2848 let coin = builder.command(NativeCommand::move_call(
2849 SUI_FRAMEWORK_PACKAGE_ID,
2850 Identifier::new("coin").unwrap(),
2851 Identifier::new("from_balance").unwrap(),
2852 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
2853 vec![balance],
2854 ));
2855 let recipient_arg = builder.pure(recipient).unwrap();
2856 builder.command(NativeCommand::TransferObjects(vec![coin], recipient_arg));
2857 builder.command(NativeCommand::TransferObjects(
2858 vec![NativeArgument::GasCoin],
2859 recipient_arg,
2860 ));
2861 builder.finish()
2862 };
2863 let gas_price = 10;
2864 let data = TransactionData::new_programmable(
2865 sender,
2866 vec![random_object_ref()],
2867 pt,
2868 TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
2869 gas_price,
2870 );
2871 let transaction: Transaction = data.into();
2872
2873 let mut gas_output_owner = Owner::default();
2875 gas_output_owner.kind = Some(OwnerKind::Address as i32);
2876 gas_output_owner.address = Some(recipient.to_string());
2877 let mut gas_object = ChangedObject::default();
2878 gas_object.object_id = Some(ObjectID::random().to_string());
2879 gas_object.output_owner = Some(gas_output_owner);
2880
2881 let mut status = ExecutionStatus::default();
2882 status.success = Some(true);
2883
2884 let mut gas_used = GasCostSummary::default();
2885 gas_used.computation_cost = Some(computation_cost as u64);
2886 gas_used.storage_cost = Some(0);
2887 gas_used.storage_rebate = Some(0);
2888 gas_used.non_refundable_storage_fee = Some(0);
2889
2890 let mut effects = TransactionEffects::default();
2891 effects.status = Some(status);
2892 effects.gas_used = Some(gas_used);
2893 effects.gas_object = Some(gas_object);
2894
2895 let unstake_fields = [("principal_amount", principal), ("reward_amount", reward)]
2896 .into_iter()
2897 .map(|(name, amount)| {
2898 (
2899 name.to_string(),
2900 prost_types::Value {
2901 kind: Some(Kind::StringValue(amount.to_string())),
2902 },
2903 )
2904 })
2905 .collect();
2906 let mut unstake_event = Event::default();
2907 unstake_event.event_type = Some("0x3::validator::UnstakingRequestEvent".to_string());
2908 unstake_event.json = Some(Box::new(prost_types::Value {
2909 kind: Some(Kind::StructValue(prost_types::Struct {
2910 fields: unstake_fields,
2911 })),
2912 }));
2913 let mut events = TransactionEvents::default();
2914 events.events = vec![unstake_event];
2915
2916 let sui_balance_change = |address: SuiAddress, amount: i128| {
2917 let mut balance_change = BalanceChange::default();
2918 balance_change.address = Some(address.to_string());
2919 balance_change.coin_type = Some(SUI.metadata.coin_type.clone());
2920 balance_change.amount = Some(amount.to_string());
2921 balance_change
2922 };
2923 let sender_change = -gas_coin_balance;
2925 let recipient_change = principal + reward + gas_coin_balance - computation_cost;
2926
2927 let mut executed_tx = ExecutedTransaction::default();
2928 executed_tx.transaction = Some(transaction);
2929 executed_tx.effects = Some(effects);
2930 executed_tx.events = Some(events);
2931 executed_tx.balance_changes = vec![
2932 sui_balance_change(sender, sender_change),
2933 sui_balance_change(recipient, recipient_change),
2934 ];
2935
2936 let cache = CoinMetadataCache::new(
2939 Client::new("http://127.0.0.1:1").unwrap(),
2940 NonZeroUsize::new(1).unwrap(),
2941 );
2942
2943 let ops = Operations::try_from_executed_transaction(executed_tx, &cache).await?;
2944
2945 let gas_op = ops
2946 .0
2947 .iter()
2948 .find(|op| op.type_ == OperationType::Gas)
2949 .expect("expected a Gas operation");
2950 assert_eq!(gas_op.account.as_ref().map(|a| a.address), Some(sender));
2951
2952 let mut net_by_address: HashMap<SuiAddress, i128> = HashMap::new();
2955 for op in &ops.0 {
2956 if let (Some(account), Some(amount)) = (&op.account, &op.amount) {
2957 *net_by_address.entry(account.address).or_default() += amount.value;
2958 }
2959 }
2960 assert_eq!(
2961 net_by_address,
2962 HashMap::from([(sender, sender_change), (recipient, recipient_change)]),
2963 "operations: {:?}",
2964 ops.0
2965 );
2966
2967 Ok(())
2968 }
2969
2970 #[test]
2971 fn test_parse_consolidate_all_staked_sui_to_fungible() {
2972 let sender = SuiAddress::random_for_testing_only();
2973 let validator = SuiAddress::random_for_testing_only();
2974
2975 let ops: Operations = serde_json::from_value(serde_json::json!([{
2976 "operation_identifier": {"index": 0},
2977 "type": "ConsolidateAllStakedSuiToFungible",
2978 "account": {"address": sender.to_string()},
2979 "metadata": {
2980 "ConsolidateAllStakedSuiToFungible": {
2981 "validator": validator.to_string()
2982 }
2983 }
2984 }]))
2985 .unwrap();
2986
2987 let internal = ops.into_internal().unwrap();
2988 match internal {
2989 InternalOperation::ConsolidateAllStakedSuiToFungible(op) => {
2990 assert_eq!(op.sender, sender);
2991 assert_eq!(op.validator, validator);
2992 }
2993 _ => panic!("Expected ConsolidateAllStakedSuiToFungible"),
2994 }
2995 }
2996
2997 #[test]
2998 fn test_parse_merge_and_redeem_fungible_staked_sui() {
2999 let sender = SuiAddress::random_for_testing_only();
3000 let validator = SuiAddress::random_for_testing_only();
3001
3002 let ops: Operations = serde_json::from_value(serde_json::json!([{
3003 "operation_identifier": {"index": 0},
3004 "type": "MergeAndRedeemFungibleStakedSui",
3005 "account": {"address": sender.to_string()},
3006 "metadata": {
3007 "MergeAndRedeemFungibleStakedSui": {
3008 "validator": validator.to_string(),
3009 "amount": "500000000000",
3010 "redeem_mode": "AtLeast"
3011 }
3012 }
3013 }]))
3014 .unwrap();
3015
3016 let internal = ops.into_internal().unwrap();
3017 match internal {
3018 InternalOperation::MergeAndRedeemFungibleStakedSui(op) => {
3019 assert_eq!(op.sender, sender);
3020 assert_eq!(op.validator, validator);
3021 assert_eq!(op.amount, Some(500000000000));
3022 assert_eq!(op.redeem_mode, RedeemMode::AtLeast);
3023 }
3024 _ => panic!("Expected MergeAndRedeemFungibleStakedSui"),
3025 }
3026 }
3027
3028 #[test]
3029 fn test_parse_merge_and_redeem_all_mode() {
3030 let sender = SuiAddress::random_for_testing_only();
3031 let validator = SuiAddress::random_for_testing_only();
3032
3033 let ops: Operations = serde_json::from_value(serde_json::json!([{
3034 "operation_identifier": {"index": 0},
3035 "type": "MergeAndRedeemFungibleStakedSui",
3036 "account": {"address": sender.to_string()},
3037 "metadata": {
3038 "MergeAndRedeemFungibleStakedSui": {
3039 "validator": validator.to_string(),
3040 "redeem_mode": "All"
3041 }
3042 }
3043 }]))
3044 .unwrap();
3045
3046 let internal = ops.into_internal().unwrap();
3047 match internal {
3048 InternalOperation::MergeAndRedeemFungibleStakedSui(op) => {
3049 assert_eq!(op.amount, None);
3050 assert_eq!(op.redeem_mode, RedeemMode::All);
3051 }
3052 _ => panic!("Expected MergeAndRedeemFungibleStakedSui"),
3053 }
3054 }
3055
3056 fn assert_consolidate_ops(
3061 ops: &[Operation],
3062 expected_sender: SuiAddress,
3063 expected_staked_sui: &[ObjectID],
3064 expected_fss: &[ObjectID],
3065 ) {
3066 assert_eq!(ops.len(), 1);
3067 let op = &ops[0];
3068 assert_eq!(op.type_, OperationType::ConsolidateAllStakedSuiToFungible);
3069 assert_eq!(
3070 op.account.as_ref().map(|a| a.address),
3071 Some(expected_sender)
3072 );
3073 assert!(op.amount.is_none());
3074 let Some(OperationMetadata::ConsolidateAllStakedSuiToFungible {
3075 validator,
3076 staked_sui_ids,
3077 fss_ids,
3078 }) = op.metadata.clone()
3079 else {
3080 panic!("wrong metadata variant: {:?}", op.metadata);
3081 };
3082 assert!(validator.is_none(), "validator must be None on parse");
3083 assert_eq!(staked_sui_ids, expected_staked_sui);
3084 assert_eq!(fss_ids, expected_fss);
3085 }
3086
3087 #[test]
3088 fn test_parse_consolidate_pure_merge_2_fss() {
3089 let sender = SuiAddress::random_for_testing_only();
3090 let fss_a = random_object_ref();
3091 let fss_b = random_object_ref();
3092 let pt = consolidate_to_fungible_pt(sender, vec![fss_a, fss_b], vec![]).expect("pt");
3093 let ops = parse_pt(sender, pt);
3094 assert_consolidate_ops(&ops, sender, &[], &[fss_a.0, fss_b.0]);
3095 }
3096
3097 #[test]
3098 fn test_parse_consolidate_pure_merge_3_fss() {
3099 let sender = SuiAddress::random_for_testing_only();
3100 let a = random_object_ref();
3101 let b = random_object_ref();
3102 let c = random_object_ref();
3103 let pt = consolidate_to_fungible_pt(sender, vec![a, b, c], vec![]).expect("pt");
3104 assert_consolidate_ops(&parse_pt(sender, pt), sender, &[], &[a.0, b.0, c.0]);
3105 }
3106
3107 #[test]
3108 fn test_parse_consolidate_pure_merge_5_fss() {
3109 let sender = SuiAddress::random_for_testing_only();
3110 let refs: Vec<_> = (0..5).map(|_| random_object_ref()).collect();
3111 let pt = consolidate_to_fungible_pt(sender, refs.clone(), vec![]).expect("pt");
3112 let expected: Vec<_> = refs.iter().map(|r| r.0).collect();
3113 assert_consolidate_ops(&parse_pt(sender, pt), sender, &[], &expected);
3114 }
3115
3116 #[test]
3117 fn test_parse_consolidate_single_convert_no_fss() {
3118 let sender = SuiAddress::random_for_testing_only();
3119 let staked = random_object_ref();
3120 let pt = consolidate_to_fungible_pt(sender, vec![], vec![staked]).expect("pt");
3121 assert_consolidate_ops(&parse_pt(sender, pt), sender, &[staked.0], &[]);
3122 }
3123
3124 #[test]
3125 fn test_parse_consolidate_multi_convert_no_fss() {
3126 let sender = SuiAddress::random_for_testing_only();
3127 let s1 = random_object_ref();
3128 let s2 = random_object_ref();
3129 let s3 = random_object_ref();
3130 let pt = consolidate_to_fungible_pt(sender, vec![], vec![s1, s2, s3]).expect("pt");
3131 assert_consolidate_ops(&parse_pt(sender, pt), sender, &[s1.0, s2.0, s3.0], &[]);
3132 }
3133
3134 #[test]
3135 fn test_parse_consolidate_single_stake_single_fss() {
3136 let sender = SuiAddress::random_for_testing_only();
3137 let fss = random_object_ref();
3138 let staked = random_object_ref();
3139 let pt = consolidate_to_fungible_pt(sender, vec![fss], vec![staked]).expect("pt");
3140 assert_consolidate_ops(&parse_pt(sender, pt), sender, &[staked.0], &[fss.0]);
3141 }
3142
3143 #[test]
3144 fn test_parse_consolidate_single_stake_multi_fss() {
3145 let sender = SuiAddress::random_for_testing_only();
3146 let f1 = random_object_ref();
3147 let f2 = random_object_ref();
3148 let staked = random_object_ref();
3149 let pt = consolidate_to_fungible_pt(sender, vec![f1, f2], vec![staked]).expect("pt");
3150 assert_consolidate_ops(&parse_pt(sender, pt), sender, &[staked.0], &[f1.0, f2.0]);
3151 }
3152
3153 #[test]
3154 fn test_parse_consolidate_multi_stake_single_fss() {
3155 let sender = SuiAddress::random_for_testing_only();
3156 let fss = random_object_ref();
3157 let s1 = random_object_ref();
3158 let s2 = random_object_ref();
3159 let pt = consolidate_to_fungible_pt(sender, vec![fss], vec![s1, s2]).expect("pt");
3160 assert_consolidate_ops(&parse_pt(sender, pt), sender, &[s1.0, s2.0], &[fss.0]);
3161 }
3162
3163 #[test]
3164 fn test_parse_consolidate_multi_stake_multi_fss() {
3165 let sender = SuiAddress::random_for_testing_only();
3166 let f1 = random_object_ref();
3167 let f2 = random_object_ref();
3168 let s1 = random_object_ref();
3169 let s2 = random_object_ref();
3170 let pt = consolidate_to_fungible_pt(sender, vec![f1, f2], vec![s1, s2]).expect("pt");
3171 assert_consolidate_ops(&parse_pt(sender, pt), sender, &[s1.0, s2.0], &[f1.0, f2.0]);
3172 }
3173
3174 #[test]
3175 fn test_parse_consolidate_large_mixed() {
3176 let sender = SuiAddress::random_for_testing_only();
3177 let fss: Vec<_> = (0..3).map(|_| random_object_ref()).collect();
3178 let staked: Vec<_> = (0..3).map(|_| random_object_ref()).collect();
3179 let pt = consolidate_to_fungible_pt(sender, fss.clone(), staked.clone()).expect("pt");
3180 let expected_s: Vec<_> = staked.iter().map(|r| r.0).collect();
3181 let expected_f: Vec<_> = fss.iter().map(|r| r.0).collect();
3182 assert_consolidate_ops(&parse_pt(sender, pt), sender, &expected_s, &expected_f);
3183 }
3184
3185 #[test]
3186 fn test_parse_consolidate_classification_correctness() {
3187 let sender = SuiAddress::random_for_testing_only();
3189 let f1 = random_object_ref();
3190 let f2 = random_object_ref();
3191 let s1 = random_object_ref();
3192 let s2 = random_object_ref();
3193 let pt = consolidate_to_fungible_pt(sender, vec![f1, f2], vec![s1, s2]).expect("pt");
3194 let ops = parse_pt(sender, pt);
3195 let Some(OperationMetadata::ConsolidateAllStakedSuiToFungible {
3196 staked_sui_ids,
3197 fss_ids,
3198 ..
3199 }) = ops[0].metadata.clone()
3200 else {
3201 panic!();
3202 };
3203 let staked_set: std::collections::HashSet<_> = staked_sui_ids.iter().collect();
3204 let fss_set: std::collections::HashSet<_> = fss_ids.iter().collect();
3205 assert!(
3206 staked_set.is_disjoint(&fss_set),
3207 "classification crossed categories"
3208 );
3209 }
3210
3211 fn assert_falls_through_to_generic(ops: &[Operation]) {
3216 assert_eq!(ops.len(), 1);
3217 assert_eq!(
3218 ops[0].type_,
3219 OperationType::ProgrammableTransaction,
3220 "expected fall-through to generic ProgrammableTransaction, got: {:?}",
3221 ops[0].type_
3222 );
3223 }
3224
3225 #[test]
3226 fn test_parse_falls_through_consolidate_with_merge_coins() {
3227 let sender = SuiAddress::random_for_testing_only();
3228 let fss_a = random_object_ref();
3229 let fss_b = random_object_ref();
3230 let coin_a = random_object_ref();
3231
3232 let mut builder = ProgrammableTransactionBuilder::new();
3233 let _sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3234 let first = builder.obj(ObjectArg::ImmOrOwnedObject(fss_a)).unwrap();
3235 let other = builder.obj(ObjectArg::ImmOrOwnedObject(fss_b)).unwrap();
3236 builder.command(NativeCommand::move_call(
3237 SUI_SYSTEM_PACKAGE_ID,
3238 Identifier::new("staking_pool").unwrap(),
3239 Identifier::new("join_fungible_staked_sui").unwrap(),
3240 vec![],
3241 vec![first, other],
3242 ));
3243 let coin_target = builder.obj(ObjectArg::ImmOrOwnedObject(coin_a)).unwrap();
3245 builder.command(NativeCommand::MergeCoins(coin_target, vec![]));
3246
3247 let ops = parse_pt(sender, builder.finish());
3248 assert_falls_through_to_generic(&ops);
3249 }
3250
3251 #[test]
3252 fn test_parse_falls_through_consolidate_with_unrelated_movecall() {
3253 let sender = SuiAddress::random_for_testing_only();
3254 let fss_a = random_object_ref();
3255 let fss_b = random_object_ref();
3256
3257 let mut builder = ProgrammableTransactionBuilder::new();
3258 let _sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3259 let first = builder.obj(ObjectArg::ImmOrOwnedObject(fss_a)).unwrap();
3260 let other = builder.obj(ObjectArg::ImmOrOwnedObject(fss_b)).unwrap();
3261 builder.command(NativeCommand::move_call(
3262 SUI_SYSTEM_PACKAGE_ID,
3263 Identifier::new("staking_pool").unwrap(),
3264 Identifier::new("join_fungible_staked_sui").unwrap(),
3265 vec![],
3266 vec![first, other],
3267 ));
3268 builder.command(NativeCommand::move_call(
3270 SUI_FRAMEWORK_PACKAGE_ID,
3271 Identifier::new("coin").unwrap(),
3272 Identifier::new("destroy_zero").unwrap(),
3273 vec![],
3274 vec![other],
3275 ));
3276
3277 let ops = parse_pt(sender, builder.finish());
3278 assert_falls_through_to_generic(&ops);
3279 }
3280
3281 #[test]
3282 fn test_parse_falls_through_convert_without_system_state() {
3283 let sender = SuiAddress::random_for_testing_only();
3285 let staked = random_object_ref();
3286 let other_obj = random_object_ref();
3287
3288 let mut builder = ProgrammableTransactionBuilder::new();
3289 let _not_system = builder.obj(ObjectArg::ImmOrOwnedObject(other_obj)).unwrap();
3291 let staked_arg = builder.obj(ObjectArg::ImmOrOwnedObject(staked)).unwrap();
3292 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3293 let new_fss = builder.command(NativeCommand::move_call(
3294 SUI_SYSTEM_PACKAGE_ID,
3295 Identifier::new("sui_system").unwrap(),
3296 Identifier::new("convert_to_fungible_staked_sui").unwrap(),
3297 vec![],
3298 vec![sys, staked_arg],
3299 ));
3300 let sender_arg = builder.pure(sender).unwrap();
3301 builder.command(NativeCommand::TransferObjects(vec![new_fss], sender_arg));
3302
3303 let ops = parse_pt(sender, builder.finish());
3304 assert_falls_through_to_generic(&ops);
3305 }
3306
3307 #[test]
3308 fn test_parse_falls_through_extra_command_after_transfer() {
3309 let sender = SuiAddress::random_for_testing_only();
3311 let staked = random_object_ref();
3312 let other_obj = random_object_ref();
3313
3314 let mut builder = ProgrammableTransactionBuilder::new();
3315 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3316 let staked_arg = builder.obj(ObjectArg::ImmOrOwnedObject(staked)).unwrap();
3317 let new_fss = builder.command(NativeCommand::move_call(
3318 SUI_SYSTEM_PACKAGE_ID,
3319 Identifier::new("sui_system").unwrap(),
3320 Identifier::new("convert_to_fungible_staked_sui").unwrap(),
3321 vec![],
3322 vec![sys, staked_arg],
3323 ));
3324 let sender_arg = builder.pure(sender).unwrap();
3325 builder.command(NativeCommand::TransferObjects(vec![new_fss], sender_arg));
3326 let extra = builder.obj(ObjectArg::ImmOrOwnedObject(other_obj)).unwrap();
3328 builder.command(NativeCommand::move_call(
3329 SUI_FRAMEWORK_PACKAGE_ID,
3330 Identifier::new("coin").unwrap(),
3331 Identifier::new("destroy_zero").unwrap(),
3332 vec![],
3333 vec![extra],
3334 ));
3335
3336 let ops = parse_pt(sender, builder.finish());
3337 assert_falls_through_to_generic(&ops);
3338 }
3339
3340 #[test]
3345 fn test_parse_empty_ptb() {
3346 let sender = SuiAddress::random_for_testing_only();
3347 let pt = ProgrammableTransactionBuilder::new().finish();
3348 let ops = parse_pt(sender, pt);
3349 assert_eq!(ops.len(), 1);
3351 assert_eq!(ops[0].type_, OperationType::ProgrammableTransaction);
3352 }
3353
3354 #[test]
3355 fn test_parse_only_merge_coins() {
3356 let sender = SuiAddress::random_for_testing_only();
3358 let coin_a = random_object_ref();
3359 let coin_b = random_object_ref();
3360 let mut builder = ProgrammableTransactionBuilder::new();
3361 let target = builder.obj(ObjectArg::ImmOrOwnedObject(coin_a)).unwrap();
3362 let source = builder.obj(ObjectArg::ImmOrOwnedObject(coin_b)).unwrap();
3363 builder.command(NativeCommand::MergeCoins(target, vec![source]));
3364 let ops = parse_pt(sender, builder.finish());
3365 assert_ne!(
3368 ops[0].type_,
3369 OperationType::ConsolidateAllStakedSuiToFungible
3370 );
3371 assert_ne!(ops[0].type_, OperationType::MergeAndRedeemFungibleStakedSui);
3372 }
3373
3374 #[test]
3382 fn test_meta_consolidate_old_input_deserializes() {
3383 let validator = SuiAddress::random_for_testing_only();
3384 let json = serde_json::json!({
3385 "ConsolidateAllStakedSuiToFungible": { "validator": validator.to_string() }
3386 });
3387 let meta: OperationMetadata = serde_json::from_value(json).unwrap();
3388 match meta {
3389 OperationMetadata::ConsolidateAllStakedSuiToFungible {
3390 validator: v,
3391 staked_sui_ids,
3392 fss_ids,
3393 } => {
3394 assert_eq!(v, Some(validator));
3395 assert!(staked_sui_ids.is_empty());
3396 assert!(fss_ids.is_empty());
3397 }
3398 _ => panic!("wrong variant"),
3399 }
3400 }
3401
3402 #[test]
3403 fn test_meta_consolidate_new_parse_output_serializes() {
3404 let id_a = ObjectID::random();
3405 let id_b = ObjectID::random();
3406 let meta = OperationMetadata::ConsolidateAllStakedSuiToFungible {
3407 validator: None,
3408 staked_sui_ids: vec![id_a],
3409 fss_ids: vec![id_b],
3410 };
3411 let json = serde_json::to_value(&meta).unwrap();
3412 let obj = json
3413 .as_object()
3414 .unwrap()
3415 .get("ConsolidateAllStakedSuiToFungible")
3416 .unwrap()
3417 .as_object()
3418 .unwrap();
3419 assert!(
3420 !obj.contains_key("validator"),
3421 "validator must be omitted when None"
3422 );
3423 assert_eq!(
3424 obj.get("staked_sui_ids").unwrap().as_array().unwrap().len(),
3425 1
3426 );
3427 assert_eq!(obj.get("fss_ids").unwrap().as_array().unwrap().len(), 1);
3428 }
3429
3430 #[test]
3435 fn test_write_consolidate_requires_validator() {
3436 let sender = SuiAddress::random_for_testing_only();
3437 let op = Operation {
3438 operation_identifier: Default::default(),
3439 type_: OperationType::ConsolidateAllStakedSuiToFungible,
3440 status: None,
3441 account: Some(sender.into()),
3442 amount: None,
3443 coin_change: None,
3444 metadata: Some(OperationMetadata::ConsolidateAllStakedSuiToFungible {
3445 validator: None,
3446 staked_sui_ids: vec![],
3447 fss_ids: vec![],
3448 }),
3449 };
3450 let err = Operations::new(vec![op])
3451 .into_internal()
3452 .expect_err("should fail without validator");
3453 let msg = format!("{err}");
3454 assert!(msg.contains("validator"), "unexpected error: {msg}");
3455 }
3456
3457 fn assert_merge_redeem_ops(
3462 ops: &[Operation],
3463 expected_sender: SuiAddress,
3464 expected_fss: &[ObjectID],
3465 expected_mode: Option<RedeemMode>,
3466 ) {
3467 assert_merge_redeem_ops_with_amount(
3468 ops,
3469 expected_sender,
3470 expected_fss,
3471 expected_mode,
3472 None,
3473 );
3474 }
3475
3476 fn assert_merge_redeem_ops_with_amount(
3477 ops: &[Operation],
3478 expected_sender: SuiAddress,
3479 expected_fss: &[ObjectID],
3480 expected_mode: Option<RedeemMode>,
3481 expected_amount: Option<&str>,
3482 ) {
3483 assert_eq!(ops.len(), 1);
3484 let op = &ops[0];
3485 assert_eq!(op.type_, OperationType::MergeAndRedeemFungibleStakedSui);
3486 assert_eq!(
3487 op.account.as_ref().map(|a| a.address),
3488 Some(expected_sender)
3489 );
3490 assert!(op.amount.is_none());
3491 let Some(OperationMetadata::MergeAndRedeemFungibleStakedSui {
3492 validator,
3493 amount,
3494 redeem_mode,
3495 fss_ids,
3496 }) = op.metadata.clone()
3497 else {
3498 panic!("wrong metadata variant: {:?}", op.metadata);
3499 };
3500 assert!(validator.is_none(), "validator must be None on parse");
3501 assert_eq!(
3502 amount.as_deref(),
3503 expected_amount,
3504 "metadata.amount mismatch"
3505 );
3506 assert_eq!(redeem_mode, expected_mode);
3507 assert_eq!(fss_ids, expected_fss);
3508 }
3509
3510 #[test]
3511 fn test_parse_merge_redeem_single_all() {
3512 let sender = SuiAddress::random_for_testing_only();
3513 let fss = random_object_ref();
3514 let pt = merge_and_redeem_fss_pt(sender, vec![fss], &RedeemPlan::All).expect("pt");
3515 assert_merge_redeem_ops(
3516 &parse_pt(sender, pt),
3517 sender,
3518 &[fss.0],
3519 Some(RedeemMode::All),
3520 );
3521 }
3522
3523 #[test]
3524 fn test_parse_merge_redeem_single_partial() {
3525 let sender = SuiAddress::random_for_testing_only();
3526 let fss = random_object_ref();
3527 let pt = merge_and_redeem_fss_pt(
3528 sender,
3529 vec![fss],
3530 &RedeemPlan::AtMost {
3531 token_amount: Some(500_000_000),
3532 max_sui: 0,
3533 },
3534 )
3535 .expect("pt");
3536 assert_merge_redeem_ops(&parse_pt(sender, pt), sender, &[fss.0], None);
3537 }
3538
3539 #[test]
3540 fn test_parse_merge_redeem_atleast_with_balance_guard() {
3541 let sender = SuiAddress::random_for_testing_only();
3542 let fss = random_object_ref();
3543 let pt = merge_and_redeem_fss_pt(
3544 sender,
3545 vec![fss],
3546 &RedeemPlan::AtLeast {
3547 token_amount: Some(500_000_000),
3548 min_sui: 1_000_000,
3549 },
3550 )
3551 .expect("pt");
3552 assert_merge_redeem_ops_with_amount(
3553 &parse_pt(sender, pt),
3554 sender,
3555 &[fss.0],
3556 Some(RedeemMode::AtLeast),
3557 Some("1000000"),
3558 );
3559 }
3560
3561 #[test]
3562 fn test_parse_merge_redeem_atleast_three_fss() {
3563 let sender = SuiAddress::random_for_testing_only();
3564 let a = random_object_ref();
3565 let b = random_object_ref();
3566 let c = random_object_ref();
3567 let pt = merge_and_redeem_fss_pt(
3568 sender,
3569 vec![a, b, c],
3570 &RedeemPlan::AtLeast {
3571 token_amount: Some(500_000_000),
3572 min_sui: 1_000_000,
3573 },
3574 )
3575 .expect("pt");
3576 assert_merge_redeem_ops_with_amount(
3577 &parse_pt(sender, pt),
3578 sender,
3579 &[a.0, b.0, c.0],
3580 Some(RedeemMode::AtLeast),
3581 Some("1000000"),
3582 );
3583 }
3584
3585 #[test]
3586 fn test_parse_merge_redeem_full_atleast_no_split() {
3587 let sender = SuiAddress::random_for_testing_only();
3592 let fss = random_object_ref();
3593 let pt = merge_and_redeem_fss_pt(
3594 sender,
3595 vec![fss],
3596 &RedeemPlan::AtLeast {
3597 token_amount: None,
3598 min_sui: 1_000_000,
3599 },
3600 )
3601 .expect("pt");
3602 assert_merge_redeem_ops_with_amount(
3603 &parse_pt(sender, pt),
3604 sender,
3605 &[fss.0],
3606 Some(RedeemMode::AtLeast),
3607 Some("1000000"),
3608 );
3609 }
3610
3611 #[test]
3612 fn test_parse_merge_redeem_two_all() {
3613 let sender = SuiAddress::random_for_testing_only();
3614 let a = random_object_ref();
3615 let b = random_object_ref();
3616 let pt = merge_and_redeem_fss_pt(sender, vec![a, b], &RedeemPlan::All).expect("pt");
3617 assert_merge_redeem_ops(
3618 &parse_pt(sender, pt),
3619 sender,
3620 &[a.0, b.0],
3621 Some(RedeemMode::All),
3622 );
3623 }
3624
3625 #[test]
3626 fn test_parse_merge_redeem_two_partial() {
3627 let sender = SuiAddress::random_for_testing_only();
3628 let a = random_object_ref();
3629 let b = random_object_ref();
3630 let pt = merge_and_redeem_fss_pt(
3631 sender,
3632 vec![a, b],
3633 &RedeemPlan::AtMost {
3634 token_amount: Some(500_000_000),
3635 max_sui: 0,
3636 },
3637 )
3638 .expect("pt");
3639 assert_merge_redeem_ops(&parse_pt(sender, pt), sender, &[a.0, b.0], None);
3640 }
3641
3642 #[test]
3643 fn test_parse_merge_redeem_three_all() {
3644 let sender = SuiAddress::random_for_testing_only();
3645 let a = random_object_ref();
3646 let b = random_object_ref();
3647 let c = random_object_ref();
3648 let pt = merge_and_redeem_fss_pt(sender, vec![a, b, c], &RedeemPlan::All).expect("pt");
3649 assert_merge_redeem_ops(
3650 &parse_pt(sender, pt),
3651 sender,
3652 &[a.0, b.0, c.0],
3653 Some(RedeemMode::All),
3654 );
3655 }
3656
3657 #[test]
3658 fn test_parse_merge_redeem_three_partial() {
3659 let sender = SuiAddress::random_for_testing_only();
3660 let a = random_object_ref();
3661 let b = random_object_ref();
3662 let c = random_object_ref();
3663 let pt = merge_and_redeem_fss_pt(
3664 sender,
3665 vec![a, b, c],
3666 &RedeemPlan::AtMost {
3667 token_amount: Some(500_000_000),
3668 max_sui: 0,
3669 },
3670 )
3671 .expect("pt");
3672 assert_merge_redeem_ops(&parse_pt(sender, pt), sender, &[a.0, b.0, c.0], None);
3673 }
3674
3675 #[test]
3676 fn test_parse_merge_redeem_five_all() {
3677 let sender = SuiAddress::random_for_testing_only();
3678 let refs: Vec<_> = (0..5).map(|_| random_object_ref()).collect();
3679 let pt = merge_and_redeem_fss_pt(sender, refs.clone(), &RedeemPlan::All).expect("pt");
3680 let expected: Vec<_> = refs.iter().map(|r| r.0).collect();
3681 assert_merge_redeem_ops(
3682 &parse_pt(sender, pt),
3683 sender,
3684 &expected,
3685 Some(RedeemMode::All),
3686 );
3687 }
3688
3689 #[test]
3690 fn test_parse_merge_redeem_fss_ids_order() {
3691 let sender = SuiAddress::random_for_testing_only();
3693 let a = random_object_ref();
3694 let b = random_object_ref();
3695 let c = random_object_ref();
3696 let pt = merge_and_redeem_fss_pt(sender, vec![a, b, c], &RedeemPlan::All).expect("pt");
3697 let ops = parse_pt(sender, pt);
3698 let Some(OperationMetadata::MergeAndRedeemFungibleStakedSui { fss_ids, .. }) =
3699 ops[0].metadata.clone()
3700 else {
3701 panic!();
3702 };
3703 assert_eq!(fss_ids, vec![a.0, b.0, c.0]);
3704 }
3705
3706 #[test]
3707 fn test_parse_merge_redeem_sender_account() {
3708 let sender = SuiAddress::random_for_testing_only();
3709 let fss = random_object_ref();
3710 let pt = merge_and_redeem_fss_pt(sender, vec![fss], &RedeemPlan::All).expect("pt");
3711 let ops = parse_pt(sender, pt);
3712 assert_eq!(ops[0].account.as_ref().unwrap().address, sender);
3713 }
3714
3715 #[test]
3716 fn test_parse_merge_redeem_no_amount_in_metadata() {
3717 let sender = SuiAddress::random_for_testing_only();
3718 let fss = random_object_ref();
3719 let pt = merge_and_redeem_fss_pt(
3720 sender,
3721 vec![fss],
3722 &RedeemPlan::AtMost {
3723 token_amount: Some(500_000_000),
3724 max_sui: 0,
3725 },
3726 )
3727 .expect("pt");
3728 let ops = parse_pt(sender, pt);
3729 let Some(OperationMetadata::MergeAndRedeemFungibleStakedSui { amount, .. }) =
3730 ops[0].metadata.clone()
3731 else {
3732 panic!();
3733 };
3734 assert!(amount.is_none());
3735 }
3736
3737 #[test]
3738 fn test_parse_merge_redeem_no_validator_in_metadata() {
3739 let sender = SuiAddress::random_for_testing_only();
3740 let fss = random_object_ref();
3741 let pt = merge_and_redeem_fss_pt(sender, vec![fss], &RedeemPlan::All).expect("pt");
3742 let ops = parse_pt(sender, pt);
3743 let Some(OperationMetadata::MergeAndRedeemFungibleStakedSui { validator, .. }) =
3744 ops[0].metadata.clone()
3745 else {
3746 panic!();
3747 };
3748 assert!(validator.is_none());
3749 }
3750
3751 fn build_redeem_ptb_with_type_arg(
3756 sender: SuiAddress,
3757 fss: ObjectRef,
3758 coin_type_arg: &str,
3759 ) -> ProgrammableTransaction {
3760 let mut builder = ProgrammableTransactionBuilder::new();
3761 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3762 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3763 let balance = builder.command(NativeCommand::move_call(
3764 SUI_SYSTEM_PACKAGE_ID,
3765 Identifier::new("sui_system").unwrap(),
3766 Identifier::new("redeem_fungible_staked_sui").unwrap(),
3767 vec![],
3768 vec![sys, fss_arg],
3769 ));
3770 let coin = builder.command(NativeCommand::move_call(
3771 SUI_FRAMEWORK_PACKAGE_ID,
3772 Identifier::new("coin").unwrap(),
3773 Identifier::new("from_balance").unwrap(),
3774 vec![sui_types::TypeTag::from_str(coin_type_arg).unwrap()],
3775 vec![balance],
3776 ));
3777 let sender_arg = builder.pure(sender).unwrap();
3778 builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
3779 builder.finish()
3780 }
3781
3782 #[test]
3783 fn test_parse_falls_through_redeem_wrong_type_arg() {
3784 let sender = SuiAddress::random_for_testing_only();
3785 let fss = random_object_ref();
3786 let pt = build_redeem_ptb_with_type_arg(sender, fss, "0x2::coin::Coin");
3788 let ops = parse_pt(sender, pt);
3789 assert_falls_through_to_generic(&ops);
3790 }
3791
3792 #[test]
3793 fn test_parse_falls_through_redeem_without_from_balance() {
3794 let sender = SuiAddress::random_for_testing_only();
3795 let fss = random_object_ref();
3796 let mut builder = ProgrammableTransactionBuilder::new();
3798 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3799 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3800 let balance = builder.command(NativeCommand::move_call(
3801 SUI_SYSTEM_PACKAGE_ID,
3802 Identifier::new("sui_system").unwrap(),
3803 Identifier::new("redeem_fungible_staked_sui").unwrap(),
3804 vec![],
3805 vec![sys, fss_arg],
3806 ));
3807 let sender_arg = builder.pure(sender).unwrap();
3808 builder.command(NativeCommand::TransferObjects(vec![balance], sender_arg));
3809 let ops = parse_pt(sender, builder.finish());
3810 assert_falls_through_to_generic(&ops);
3811 }
3812
3813 #[test]
3814 fn test_parse_falls_through_redeem_without_transfer() {
3815 let sender = SuiAddress::random_for_testing_only();
3816 let fss = random_object_ref();
3817 let mut builder = ProgrammableTransactionBuilder::new();
3818 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3819 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3820 let balance = builder.command(NativeCommand::move_call(
3821 SUI_SYSTEM_PACKAGE_ID,
3822 Identifier::new("sui_system").unwrap(),
3823 Identifier::new("redeem_fungible_staked_sui").unwrap(),
3824 vec![],
3825 vec![sys, fss_arg],
3826 ));
3827 builder.command(NativeCommand::move_call(
3828 SUI_FRAMEWORK_PACKAGE_ID,
3829 Identifier::new("coin").unwrap(),
3830 Identifier::new("from_balance").unwrap(),
3831 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
3832 vec![balance],
3833 ));
3834 let ops = parse_pt(sender, builder.finish());
3836 assert_falls_through_to_generic(&ops);
3837 }
3838
3839 #[test]
3840 fn test_parse_falls_through_redeem_transfer_wrong_recipient() {
3841 let sender = SuiAddress::random_for_testing_only();
3842 let other = SuiAddress::random_for_testing_only();
3843 let fss = random_object_ref();
3844 let mut builder = ProgrammableTransactionBuilder::new();
3845 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3846 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3847 let balance = builder.command(NativeCommand::move_call(
3848 SUI_SYSTEM_PACKAGE_ID,
3849 Identifier::new("sui_system").unwrap(),
3850 Identifier::new("redeem_fungible_staked_sui").unwrap(),
3851 vec![],
3852 vec![sys, fss_arg],
3853 ));
3854 let coin = builder.command(NativeCommand::move_call(
3855 SUI_FRAMEWORK_PACKAGE_ID,
3856 Identifier::new("coin").unwrap(),
3857 Identifier::new("from_balance").unwrap(),
3858 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
3859 vec![balance],
3860 ));
3861 let other_arg = builder.pure(other).unwrap();
3863 builder.command(NativeCommand::TransferObjects(vec![coin], other_arg));
3864 let ops = parse_pt(sender, builder.finish());
3865 assert_falls_through_to_generic(&ops);
3866 }
3867
3868 #[test]
3869 fn test_parse_falls_through_redeem_transfer_multiple_objects() {
3870 let sender = SuiAddress::random_for_testing_only();
3871 let fss = random_object_ref();
3872 let other_obj = random_object_ref();
3873 let mut builder = ProgrammableTransactionBuilder::new();
3874 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3875 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3876 let balance = builder.command(NativeCommand::move_call(
3877 SUI_SYSTEM_PACKAGE_ID,
3878 Identifier::new("sui_system").unwrap(),
3879 Identifier::new("redeem_fungible_staked_sui").unwrap(),
3880 vec![],
3881 vec![sys, fss_arg],
3882 ));
3883 let coin = builder.command(NativeCommand::move_call(
3884 SUI_FRAMEWORK_PACKAGE_ID,
3885 Identifier::new("coin").unwrap(),
3886 Identifier::new("from_balance").unwrap(),
3887 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
3888 vec![balance],
3889 ));
3890 let extra = builder.obj(ObjectArg::ImmOrOwnedObject(other_obj)).unwrap();
3892 let sender_arg = builder.pure(sender).unwrap();
3893 builder.command(NativeCommand::TransferObjects(
3894 vec![coin, extra],
3895 sender_arg,
3896 ));
3897 let ops = parse_pt(sender, builder.finish());
3898 assert_falls_through_to_generic(&ops);
3899 }
3900
3901 #[test]
3902 fn test_parse_falls_through_hybrid_convert_and_redeem() {
3903 let sender = SuiAddress::random_for_testing_only();
3907 let staked = random_object_ref();
3908 let fss = random_object_ref();
3909 let mut builder = ProgrammableTransactionBuilder::new();
3910 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3911 let staked_arg = builder.obj(ObjectArg::ImmOrOwnedObject(staked)).unwrap();
3912 let _new_fss = builder.command(NativeCommand::move_call(
3913 SUI_SYSTEM_PACKAGE_ID,
3914 Identifier::new("sui_system").unwrap(),
3915 Identifier::new("convert_to_fungible_staked_sui").unwrap(),
3916 vec![],
3917 vec![sys, staked_arg],
3918 ));
3919 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3920 let balance = builder.command(NativeCommand::move_call(
3921 SUI_SYSTEM_PACKAGE_ID,
3922 Identifier::new("sui_system").unwrap(),
3923 Identifier::new("redeem_fungible_staked_sui").unwrap(),
3924 vec![],
3925 vec![sys, fss_arg],
3926 ));
3927 let coin = builder.command(NativeCommand::move_call(
3928 SUI_FRAMEWORK_PACKAGE_ID,
3929 Identifier::new("coin").unwrap(),
3930 Identifier::new("from_balance").unwrap(),
3931 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
3932 vec![balance],
3933 ));
3934 let sender_arg = builder.pure(sender).unwrap();
3935 builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
3936 let ops = parse_pt(sender, builder.finish());
3937 assert_falls_through_to_generic(&ops);
3938 }
3939
3940 #[test]
3941 fn test_parse_falls_through_split_without_redeem() {
3942 let sender = SuiAddress::random_for_testing_only();
3943 let fss = random_object_ref();
3944 let mut builder = ProgrammableTransactionBuilder::new();
3945 let _sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3946 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3947 let split_amount = builder.pure(100u64).unwrap();
3948 builder.command(NativeCommand::move_call(
3949 SUI_SYSTEM_PACKAGE_ID,
3950 Identifier::new("staking_pool").unwrap(),
3951 Identifier::new("split_fungible_staked_sui").unwrap(),
3952 vec![],
3953 vec![fss_arg, split_amount],
3954 ));
3955 let ops = parse_pt(sender, builder.finish());
3957 assert_falls_through_to_generic(&ops);
3958 }
3959
3960 #[test]
3961 fn test_parse_falls_through_redeem_split_position_wrong() {
3962 let sender = SuiAddress::random_for_testing_only();
3964 let fss_a = random_object_ref();
3965 let fss_b = random_object_ref();
3966 let mut builder = ProgrammableTransactionBuilder::new();
3967 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3968 let a_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss_a)).unwrap();
3969 let b_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss_b)).unwrap();
3970 let balance = builder.command(NativeCommand::move_call(
3971 SUI_SYSTEM_PACKAGE_ID,
3972 Identifier::new("sui_system").unwrap(),
3973 Identifier::new("redeem_fungible_staked_sui").unwrap(),
3974 vec![],
3975 vec![sys, a_arg],
3976 ));
3977 let split_amount = builder.pure(100u64).unwrap();
3979 builder.command(NativeCommand::move_call(
3980 SUI_SYSTEM_PACKAGE_ID,
3981 Identifier::new("staking_pool").unwrap(),
3982 Identifier::new("split_fungible_staked_sui").unwrap(),
3983 vec![],
3984 vec![b_arg, split_amount],
3985 ));
3986 let coin = builder.command(NativeCommand::move_call(
3987 SUI_FRAMEWORK_PACKAGE_ID,
3988 Identifier::new("coin").unwrap(),
3989 Identifier::new("from_balance").unwrap(),
3990 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
3991 vec![balance],
3992 ));
3993 let sender_arg = builder.pure(sender).unwrap();
3994 builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
3995 let ops = parse_pt(sender, builder.finish());
3996 assert_falls_through_to_generic(&ops);
3997 }
3998
3999 #[test]
4000 fn test_parse_falls_through_redeem_wrong_system_state_immutable() {
4001 let sender = SuiAddress::random_for_testing_only();
4007 let fss = random_object_ref();
4008 let mut builder = ProgrammableTransactionBuilder::new();
4009 let _sys = builder
4011 .obj(ObjectArg::SharedObject {
4012 id: SUI_SYSTEM_STATE_OBJECT_ID,
4013 initial_shared_version: sui_types::SUI_SYSTEM_STATE_OBJECT_SHARED_VERSION,
4014 mutability: sui_types::transaction::SharedObjectMutability::Immutable,
4015 })
4016 .unwrap();
4017 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
4018 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
4021 let balance = builder.command(NativeCommand::move_call(
4022 SUI_SYSTEM_PACKAGE_ID,
4023 Identifier::new("sui_system").unwrap(),
4024 Identifier::new("redeem_fungible_staked_sui").unwrap(),
4025 vec![],
4026 vec![sys, fss_arg],
4027 ));
4028 let coin = builder.command(NativeCommand::move_call(
4029 SUI_FRAMEWORK_PACKAGE_ID,
4030 Identifier::new("coin").unwrap(),
4031 Identifier::new("from_balance").unwrap(),
4032 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4033 vec![balance],
4034 ));
4035 let sender_arg = builder.pure(sender).unwrap();
4036 builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
4037 let ops = parse_pt(sender, builder.finish());
4048 assert!(
4051 ops[0].type_ == OperationType::MergeAndRedeemFungibleStakedSui
4052 || ops[0].type_ == OperationType::ProgrammableTransaction,
4053 "unexpected op type: {:?}",
4054 ops[0].type_
4055 );
4056 }
4057
4058 #[test]
4066 fn test_parse_falls_through_convert_without_transfer() {
4067 let sender = SuiAddress::random_for_testing_only();
4068 let staked = random_object_ref();
4069 let mut builder = ProgrammableTransactionBuilder::new();
4070 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
4071 let staked_arg = builder.obj(ObjectArg::ImmOrOwnedObject(staked)).unwrap();
4072 let _new_fss = builder.command(NativeCommand::move_call(
4073 SUI_SYSTEM_PACKAGE_ID,
4074 Identifier::new("sui_system").unwrap(),
4075 Identifier::new("convert_to_fungible_staked_sui").unwrap(),
4076 vec![],
4077 vec![sys, staked_arg],
4078 ));
4079 let ops = parse_pt(sender, builder.finish());
4081 assert_falls_through_to_generic(&ops);
4082 }
4083
4084 #[test]
4088 fn test_parse_falls_through_pure_merge_with_transfer() {
4089 let sender = SuiAddress::random_for_testing_only();
4090 let fss_a = random_object_ref();
4091 let fss_b = random_object_ref();
4092 let mut builder = ProgrammableTransactionBuilder::new();
4093 let _sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
4094 let first = builder.obj(ObjectArg::ImmOrOwnedObject(fss_a)).unwrap();
4095 let other = builder.obj(ObjectArg::ImmOrOwnedObject(fss_b)).unwrap();
4096 let join_result = builder.command(NativeCommand::move_call(
4097 SUI_SYSTEM_PACKAGE_ID,
4098 Identifier::new("staking_pool").unwrap(),
4099 Identifier::new("join_fungible_staked_sui").unwrap(),
4100 vec![],
4101 vec![first, other],
4102 ));
4103 let sender_arg = builder.pure(sender).unwrap();
4105 builder.command(NativeCommand::TransferObjects(
4106 vec![join_result],
4107 sender_arg,
4108 ));
4109 let ops = parse_pt(sender, builder.finish());
4110 assert_falls_through_to_generic(&ops);
4111 }
4112
4113 #[test]
4116 fn test_parse_falls_through_split_amount_not_pure() {
4117 let sender = SuiAddress::random_for_testing_only();
4118 let fss = random_object_ref();
4119 let bogus_obj = random_object_ref();
4120 let mut builder = ProgrammableTransactionBuilder::new();
4121 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
4122 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
4123 let bogus_arg = builder.obj(ObjectArg::ImmOrOwnedObject(bogus_obj)).unwrap();
4125 let split_result = builder.command(NativeCommand::move_call(
4126 SUI_SYSTEM_PACKAGE_ID,
4127 Identifier::new("staking_pool").unwrap(),
4128 Identifier::new("split_fungible_staked_sui").unwrap(),
4129 vec![],
4130 vec![fss_arg, bogus_arg],
4131 ));
4132 let balance = builder.command(NativeCommand::move_call(
4133 SUI_SYSTEM_PACKAGE_ID,
4134 Identifier::new("sui_system").unwrap(),
4135 Identifier::new("redeem_fungible_staked_sui").unwrap(),
4136 vec![],
4137 vec![sys, split_result],
4138 ));
4139 let coin = builder.command(NativeCommand::move_call(
4140 SUI_FRAMEWORK_PACKAGE_ID,
4141 Identifier::new("coin").unwrap(),
4142 Identifier::new("from_balance").unwrap(),
4143 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4144 vec![balance],
4145 ));
4146 let sender_arg = builder.pure(sender).unwrap();
4147 builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
4148 let ops = parse_pt(sender, builder.finish());
4149 assert_falls_through_to_generic(&ops);
4150 }
4151
4152 #[test]
4156 fn test_parse_falls_through_convert_wrong_system_state_arg() {
4157 let sender = SuiAddress::random_for_testing_only();
4158 let staked = random_object_ref();
4159 let mut builder = ProgrammableTransactionBuilder::new();
4160 let _sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
4162 let bogus_arg = builder.pure(0u64).unwrap();
4165 let staked_arg = builder.obj(ObjectArg::ImmOrOwnedObject(staked)).unwrap();
4166 let new_fss = builder.command(NativeCommand::move_call(
4167 SUI_SYSTEM_PACKAGE_ID,
4168 Identifier::new("sui_system").unwrap(),
4169 Identifier::new("convert_to_fungible_staked_sui").unwrap(),
4170 vec![],
4171 vec![bogus_arg, staked_arg],
4173 ));
4174 let sender_arg = builder.pure(sender).unwrap();
4175 builder.command(NativeCommand::TransferObjects(vec![new_fss], sender_arg));
4176 let ops = parse_pt(sender, builder.finish());
4177 assert_falls_through_to_generic(&ops);
4178 }
4179
4180 #[test]
4185 fn test_parse_falls_through_consolidate_same_input_both_convert_and_join() {
4186 let sender = SuiAddress::random_for_testing_only();
4187 let shared_input = random_object_ref();
4188 let other_fss = random_object_ref();
4189 let mut builder = ProgrammableTransactionBuilder::new();
4190 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
4191 let dual = builder
4193 .obj(ObjectArg::ImmOrOwnedObject(shared_input))
4194 .unwrap();
4195 let fss_b = builder.obj(ObjectArg::ImmOrOwnedObject(other_fss)).unwrap();
4196 builder.command(NativeCommand::move_call(
4198 SUI_SYSTEM_PACKAGE_ID,
4199 Identifier::new("staking_pool").unwrap(),
4200 Identifier::new("join_fungible_staked_sui").unwrap(),
4201 vec![],
4202 vec![dual, fss_b],
4203 ));
4204 let new_fss = builder.command(NativeCommand::move_call(
4206 SUI_SYSTEM_PACKAGE_ID,
4207 Identifier::new("sui_system").unwrap(),
4208 Identifier::new("convert_to_fungible_staked_sui").unwrap(),
4209 vec![],
4210 vec![sys, dual],
4211 ));
4212 let sender_arg = builder.pure(sender).unwrap();
4213 builder.command(NativeCommand::TransferObjects(vec![new_fss], sender_arg));
4214 let ops = parse_pt(sender, builder.finish());
4215 assert_falls_through_to_generic(&ops);
4216 }
4217
4218 fn build_malformed_atleast_ptb(
4240 sender: SuiAddress,
4241 fss: ObjectRef,
4242 wire_split_to_redeem: bool,
4243 wire_join_to_redeem: bool,
4244 wire_join_arg1_to_split: bool,
4245 wire_from_balance_to_redeem: bool,
4246 ) -> ProgrammableTransaction {
4247 use sui_types::transaction::Argument;
4248 let mut builder = ProgrammableTransactionBuilder::new();
4249 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
4250 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
4251 let split_amt = builder.pure(100u64).unwrap();
4252 let split_fss = builder.command(NativeCommand::move_call(
4254 SUI_SYSTEM_PACKAGE_ID,
4255 Identifier::new("staking_pool").unwrap(),
4256 Identifier::new("split_fungible_staked_sui").unwrap(),
4257 vec![],
4258 vec![fss_arg, split_amt],
4259 ));
4260 let redeem_balance = builder.command(NativeCommand::move_call(
4261 SUI_SYSTEM_PACKAGE_ID,
4262 Identifier::new("sui_system").unwrap(),
4263 Identifier::new("redeem_fungible_staked_sui").unwrap(),
4264 vec![],
4265 vec![sys, split_fss],
4266 ));
4267 let zero_balance = builder.command(NativeCommand::move_call(
4270 SUI_FRAMEWORK_PACKAGE_ID,
4271 Identifier::new("balance").unwrap(),
4272 Identifier::new("zero").unwrap(),
4273 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4274 vec![],
4275 ));
4276 let min_arg = builder.pure(0u64).unwrap();
4277 let split_arg0 = if wire_split_to_redeem {
4278 redeem_balance
4279 } else {
4280 zero_balance
4281 };
4282 let split_result = builder.command(NativeCommand::move_call(
4283 SUI_FRAMEWORK_PACKAGE_ID,
4284 Identifier::new("balance").unwrap(),
4285 Identifier::new("split").unwrap(),
4286 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4287 vec![split_arg0, min_arg],
4288 ));
4289 let join_arg0 = if wire_join_to_redeem {
4290 redeem_balance
4291 } else {
4292 zero_balance
4293 };
4294 let join_arg1 = if wire_join_arg1_to_split {
4295 split_result
4296 } else {
4297 builder.command(NativeCommand::move_call(
4300 SUI_FRAMEWORK_PACKAGE_ID,
4301 Identifier::new("balance").unwrap(),
4302 Identifier::new("zero").unwrap(),
4303 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4304 vec![],
4305 ))
4306 };
4307 builder.command(NativeCommand::move_call(
4308 SUI_FRAMEWORK_PACKAGE_ID,
4309 Identifier::new("balance").unwrap(),
4310 Identifier::new("join").unwrap(),
4311 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4312 vec![join_arg0, join_arg1],
4313 ));
4314 let from_balance_arg = if wire_from_balance_to_redeem {
4315 redeem_balance
4316 } else {
4317 zero_balance
4318 };
4319 let coin = builder.command(NativeCommand::move_call(
4320 SUI_FRAMEWORK_PACKAGE_ID,
4321 Identifier::new("coin").unwrap(),
4322 Identifier::new("from_balance").unwrap(),
4323 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4324 vec![from_balance_arg],
4325 ));
4326 let sender_arg = builder.pure(sender).unwrap();
4327 builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
4328 let _ = Argument::GasCoin; builder.finish()
4330 }
4331
4332 #[test]
4333 fn test_parse_falls_through_atleast_split_arg_not_redeem_result() {
4334 let sender = SuiAddress::random_for_testing_only();
4335 let fss = random_object_ref();
4336 let pt = build_malformed_atleast_ptb(sender, fss, false, true, true, true);
4338 assert_falls_through_to_generic(&parse_pt(sender, pt));
4339 }
4340
4341 #[test]
4342 fn test_parse_falls_through_atleast_join_arg0_not_redeem_result() {
4343 let sender = SuiAddress::random_for_testing_only();
4344 let fss = random_object_ref();
4345 let pt = build_malformed_atleast_ptb(sender, fss, true, false, true, true);
4347 assert_falls_through_to_generic(&parse_pt(sender, pt));
4348 }
4349
4350 #[test]
4351 fn test_parse_falls_through_atleast_join_arg1_not_split_result() {
4352 let sender = SuiAddress::random_for_testing_only();
4353 let fss = random_object_ref();
4354 let pt = build_malformed_atleast_ptb(sender, fss, true, true, false, true);
4356 assert_falls_through_to_generic(&parse_pt(sender, pt));
4357 }
4358
4359 #[test]
4360 fn test_parse_falls_through_atleast_from_balance_arg_not_redeem_result() {
4361 let sender = SuiAddress::random_for_testing_only();
4362 let fss = random_object_ref();
4363 let pt = build_malformed_atleast_ptb(sender, fss, true, true, true, false);
4365 assert_falls_through_to_generic(&parse_pt(sender, pt));
4366 }
4367
4368 #[test]
4374 fn test_parse_falls_through_atleast_split_arg_is_nested_result() {
4375 use sui_types::transaction::Argument;
4376 let sender = SuiAddress::random_for_testing_only();
4377 let fss = random_object_ref();
4378 let mut builder = ProgrammableTransactionBuilder::new();
4379 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
4380 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
4381 let split_amt = builder.pure(100u64).unwrap();
4382 let split_fss = builder.command(NativeCommand::move_call(
4383 SUI_SYSTEM_PACKAGE_ID,
4384 Identifier::new("staking_pool").unwrap(),
4385 Identifier::new("split_fungible_staked_sui").unwrap(),
4386 vec![],
4387 vec![fss_arg, split_amt],
4388 ));
4389 let _redeem = builder.command(NativeCommand::move_call(
4390 SUI_SYSTEM_PACKAGE_ID,
4391 Identifier::new("sui_system").unwrap(),
4392 Identifier::new("redeem_fungible_staked_sui").unwrap(),
4393 vec![],
4394 vec![sys, split_fss],
4395 ));
4396 let nested = Argument::NestedResult(1, 0);
4400 let min_arg = builder.pure(0u64).unwrap();
4401 let split_balance = builder.command(NativeCommand::move_call(
4402 SUI_FRAMEWORK_PACKAGE_ID,
4403 Identifier::new("balance").unwrap(),
4404 Identifier::new("split").unwrap(),
4405 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4406 vec![nested, min_arg],
4407 ));
4408 builder.command(NativeCommand::move_call(
4409 SUI_FRAMEWORK_PACKAGE_ID,
4410 Identifier::new("balance").unwrap(),
4411 Identifier::new("join").unwrap(),
4412 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4413 vec![nested, split_balance],
4414 ));
4415 let coin = builder.command(NativeCommand::move_call(
4416 SUI_FRAMEWORK_PACKAGE_ID,
4417 Identifier::new("coin").unwrap(),
4418 Identifier::new("from_balance").unwrap(),
4419 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4420 vec![nested],
4421 ));
4422 let sender_arg = builder.pure(sender).unwrap();
4423 builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
4424 assert_falls_through_to_generic(&parse_pt(sender, builder.finish()));
4425 }
4426
4427 #[test]
4431 fn test_parse_falls_through_transfer_not_from_balance_result() {
4432 let sender = SuiAddress::random_for_testing_only();
4433 let fss = random_object_ref();
4434 let mut builder = ProgrammableTransactionBuilder::new();
4435 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
4436 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
4437 let redeem = builder.command(NativeCommand::move_call(
4438 SUI_SYSTEM_PACKAGE_ID,
4439 Identifier::new("sui_system").unwrap(),
4440 Identifier::new("redeem_fungible_staked_sui").unwrap(),
4441 vec![],
4442 vec![sys, fss_arg],
4443 ));
4444 let _from_balance = builder.command(NativeCommand::move_call(
4445 SUI_FRAMEWORK_PACKAGE_ID,
4446 Identifier::new("coin").unwrap(),
4447 Identifier::new("from_balance").unwrap(),
4448 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4449 vec![redeem],
4450 ));
4451 let other_coin = builder.command(NativeCommand::move_call(
4455 SUI_FRAMEWORK_PACKAGE_ID,
4456 Identifier::new("coin").unwrap(),
4457 Identifier::new("zero").unwrap(),
4458 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4459 vec![],
4460 ));
4461 let sender_arg = builder.pure(sender).unwrap();
4462 builder.command(NativeCommand::TransferObjects(vec![other_coin], sender_arg));
4463 assert_falls_through_to_generic(&parse_pt(sender, builder.finish()));
4464 }
4465
4466 #[test]
4471 fn test_meta_merge_redeem_old_input_all() {
4472 let v = SuiAddress::random_for_testing_only();
4473 let json = serde_json::json!({
4474 "MergeAndRedeemFungibleStakedSui": {
4475 "validator": v.to_string(),
4476 "redeem_mode": "All"
4477 }
4478 });
4479 let meta: OperationMetadata = serde_json::from_value(json).unwrap();
4480 match meta {
4481 OperationMetadata::MergeAndRedeemFungibleStakedSui {
4482 validator,
4483 amount,
4484 redeem_mode,
4485 fss_ids,
4486 } => {
4487 assert_eq!(validator, Some(v));
4488 assert!(amount.is_none());
4489 assert_eq!(redeem_mode, Some(RedeemMode::All));
4490 assert!(fss_ids.is_empty());
4491 }
4492 _ => panic!("wrong variant"),
4493 }
4494 }
4495
4496 #[test]
4497 fn test_meta_merge_redeem_old_input_atleast() {
4498 let v = SuiAddress::random_for_testing_only();
4499 let json = serde_json::json!({
4500 "MergeAndRedeemFungibleStakedSui": {
4501 "validator": v.to_string(),
4502 "amount": "500000000000",
4503 "redeem_mode": "AtLeast"
4504 }
4505 });
4506 let meta: OperationMetadata = serde_json::from_value(json).unwrap();
4507 match meta {
4508 OperationMetadata::MergeAndRedeemFungibleStakedSui {
4509 validator,
4510 amount,
4511 redeem_mode,
4512 fss_ids,
4513 } => {
4514 assert_eq!(validator, Some(v));
4515 assert_eq!(amount, Some("500000000000".to_string()));
4516 assert_eq!(redeem_mode, Some(RedeemMode::AtLeast));
4517 assert!(fss_ids.is_empty());
4518 }
4519 _ => panic!(),
4520 }
4521 }
4522
4523 #[test]
4524 fn test_meta_merge_redeem_new_parse_output() {
4525 let id = ObjectID::random();
4526 let meta = OperationMetadata::MergeAndRedeemFungibleStakedSui {
4527 validator: None,
4528 amount: None,
4529 redeem_mode: Some(RedeemMode::All),
4530 fss_ids: vec![id],
4531 };
4532 let json = serde_json::to_value(&meta).unwrap();
4533 let obj = json
4534 .as_object()
4535 .unwrap()
4536 .get("MergeAndRedeemFungibleStakedSui")
4537 .unwrap()
4538 .as_object()
4539 .unwrap();
4540 assert!(!obj.contains_key("validator"));
4541 assert!(!obj.contains_key("amount"));
4542 assert_eq!(obj.get("redeem_mode").unwrap(), "All");
4543 assert_eq!(obj.get("fss_ids").unwrap().as_array().unwrap().len(), 1);
4544 }
4545
4546 #[test]
4547 fn test_meta_merge_redeem_new_parse_output_partial() {
4548 let id = ObjectID::random();
4549 let meta = OperationMetadata::MergeAndRedeemFungibleStakedSui {
4550 validator: None,
4551 amount: None,
4552 redeem_mode: None,
4553 fss_ids: vec![id],
4554 };
4555 let json = serde_json::to_value(&meta).unwrap();
4556 let obj = json
4557 .as_object()
4558 .unwrap()
4559 .get("MergeAndRedeemFungibleStakedSui")
4560 .unwrap()
4561 .as_object()
4562 .unwrap();
4563 assert!(!obj.contains_key("validator"));
4564 assert!(!obj.contains_key("amount"));
4565 assert!(
4566 !obj.contains_key("redeem_mode"),
4567 "redeem_mode must be omitted in partial parse output"
4568 );
4569 assert_eq!(obj.get("fss_ids").unwrap().as_array().unwrap().len(), 1);
4570 }
4571
4572 #[test]
4577 fn test_write_merge_redeem_requires_validator_and_mode() {
4578 let sender = SuiAddress::random_for_testing_only();
4579
4580 let op = Operation {
4582 operation_identifier: Default::default(),
4583 type_: OperationType::MergeAndRedeemFungibleStakedSui,
4584 status: None,
4585 account: Some(sender.into()),
4586 amount: None,
4587 coin_change: None,
4588 metadata: Some(OperationMetadata::MergeAndRedeemFungibleStakedSui {
4589 validator: None,
4590 amount: None,
4591 redeem_mode: Some(RedeemMode::All),
4592 fss_ids: vec![],
4593 }),
4594 };
4595 let err = Operations::new(vec![op])
4596 .into_internal()
4597 .expect_err("should fail without validator");
4598 assert!(format!("{err}").contains("validator"));
4599
4600 let op = Operation {
4602 operation_identifier: Default::default(),
4603 type_: OperationType::MergeAndRedeemFungibleStakedSui,
4604 status: None,
4605 account: Some(sender.into()),
4606 amount: None,
4607 coin_change: None,
4608 metadata: Some(OperationMetadata::MergeAndRedeemFungibleStakedSui {
4609 validator: Some(SuiAddress::random_for_testing_only()),
4610 amount: None,
4611 redeem_mode: None,
4612 fss_ids: vec![],
4613 }),
4614 };
4615 let err = Operations::new(vec![op])
4616 .into_internal()
4617 .expect_err("should fail without redeem_mode");
4618 assert!(format!("{err}").contains("redeem_mode"));
4619 }
4620
4621 use crate::types::CurrencyMetadata;
4624 use crate::types::internal_operation::pay_coin_pt;
4625
4626 fn sample_currency() -> Currency {
4627 Currency {
4628 symbol: "USDC".to_string(),
4629 decimals: 6,
4630 metadata: CurrencyMetadata {
4631 coin_type: "0x5::usdc::USDC".to_string(),
4632 },
4633 }
4634 }
4635
4636 fn data_with_pt(sender: SuiAddress, pt: ProgrammableTransaction) -> TransactionData {
4637 let gas_price = 1000;
4638 TransactionData::new_programmable(
4639 sender,
4640 vec![random_object_ref()],
4641 pt,
4642 TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
4643 gas_price,
4644 )
4645 }
4646
4647 fn proto_clean(data: &TransactionData) -> Transaction {
4650 use crate::types::transaction_envelope::{decode_inner_proto, encode_inner_proto};
4651 decode_inner_proto(&encode_inner_proto(data)).unwrap()
4652 }
4653
4654 #[test]
4656 fn test_reconstruct_pay_coin_currency() {
4657 let sender = SuiAddress::random_for_testing_only();
4658 let recipient = SuiAddress::random_for_testing_only();
4659 let coin = random_object_ref();
4660 let currency = sample_currency();
4661 let aux = AuxData::PayCoin {
4662 currency: currency.clone(),
4663 };
4664 let pt = pay_coin_pt(
4665 sender,
4666 vec![recipient],
4667 vec![10_000],
4668 &[coin],
4669 &[],
4670 0,
4671 ¤cy,
4672 )
4673 .unwrap();
4674 let proto = proto_clean(&data_with_pt(sender, pt));
4675
4676 let ops = reconstruct_operations(&proto, &aux, None).expect("reconstruct ok");
4677 assert!(ops.0.iter().any(|op| op.type_ == OperationType::PayCoin));
4678 let recip_amount = ops
4679 .0
4680 .iter()
4681 .find(|o| o.account.as_ref().map(|a| a.address) == Some(recipient))
4682 .and_then(|o| o.amount.clone())
4683 .expect("recipient op");
4684 assert_eq!(
4685 recip_amount.currency.metadata.coin_type,
4686 currency.metadata.coin_type
4687 );
4688 }
4689
4690 #[test]
4694 fn test_reconstruct_family_mismatch_rejected() {
4695 let sender = SuiAddress::random_for_testing_only();
4696 let pay_aux = AuxData::PayCoin {
4697 currency: sample_currency(),
4698 };
4699 let pt = consolidate_to_fungible_pt(
4700 sender,
4701 vec![random_object_ref()],
4702 vec![random_object_ref()],
4703 )
4704 .unwrap();
4705 let proto = proto_clean(&data_with_pt(sender, pt));
4706 let err = reconstruct_operations(&proto, &pay_aux, None)
4707 .expect_err("family mismatch must be rejected");
4708 assert!(format!("{err:?}").contains("non-payment"));
4709 }
4710
4711 #[test]
4713 fn test_reconstruct_consolidate_validator_decorated() {
4714 let sender = SuiAddress::random_for_testing_only();
4715 let validator = SuiAddress::random_for_testing_only();
4716 let aux = AuxData::Consolidate { validator };
4717 let pt = consolidate_to_fungible_pt(
4718 sender,
4719 vec![random_object_ref()],
4720 vec![random_object_ref()],
4721 )
4722 .unwrap();
4723 let proto = proto_clean(&data_with_pt(sender, pt));
4724 let ops = reconstruct_operations(&proto, &aux, None).unwrap();
4725 let Some(OperationMetadata::ConsolidateAllStakedSuiToFungible { validator: v, .. }) =
4726 ops.0[0].metadata.clone()
4727 else {
4728 panic!("expected Consolidate metadata");
4729 };
4730 assert_eq!(v, Some(validator));
4731 }
4732
4733 #[test]
4737 fn test_reconstruct_merge_redeem_atmost_decorated() {
4738 let sender = SuiAddress::random_for_testing_only();
4739 let validator = SuiAddress::random_for_testing_only();
4740 let aux = AuxData::MergeAndRedeem {
4741 validator,
4742 redeem_mode: RedeemMode::AtMost,
4743 amount: Some(1_000_000),
4744 };
4745 let plan = RedeemPlan::AtMost {
4746 token_amount: Some(500_000_000),
4747 max_sui: 0,
4748 };
4749 let pt = merge_and_redeem_fss_pt(sender, vec![random_object_ref()], &plan).unwrap();
4750 let proto = proto_clean(&data_with_pt(sender, pt));
4751 let ops = reconstruct_operations(&proto, &aux, None).unwrap();
4752 let Some(OperationMetadata::MergeAndRedeemFungibleStakedSui {
4753 validator: v,
4754 amount,
4755 redeem_mode,
4756 ..
4757 }) = ops.0[0].metadata.clone()
4758 else {
4759 panic!("expected MergeAndRedeem metadata");
4760 };
4761 assert_eq!(v, Some(validator));
4762 assert_eq!(redeem_mode, Some(RedeemMode::AtMost));
4763 assert_eq!(amount, Some("1000000".to_string()));
4764 }
4765
4766 #[test]
4768 fn test_reconstruct_pay_sui_none_ok() {
4769 let sender = SuiAddress::random_for_testing_only();
4770 let recipient = SuiAddress::random_for_testing_only();
4771 let pt = {
4772 let mut b = ProgrammableTransactionBuilder::new();
4773 b.pay_sui(vec![recipient], vec![10_000]).unwrap();
4774 b.finish()
4775 };
4776 let proto = proto_clean(&data_with_pt(sender, pt));
4777 let ops = reconstruct_operations(&proto, &AuxData::None, None)
4778 .expect("PaySui reconstructs with no aux data");
4779 assert!(ops.0.iter().any(|op| op.type_ == OperationType::PaySui));
4780 }
4781}