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 };
2462 let parsed_data = ops.into_internal()?.try_into_data(metadata)?;
2463 assert_eq!(data, parsed_data);
2464
2465 Ok(())
2466 }
2467
2468 #[test]
2473 fn test_stake_parse_round_trip() -> Result<(), anyhow::Error> {
2474 use sui_types::transaction::TEST_ONLY_GAS_UNIT_FOR_STAKING;
2475
2476 let sender = SuiAddress::random_for_testing_only();
2477 let validator = SuiAddress::random_for_testing_only();
2478 let gas = random_object_ref();
2479 let gas_price = 10;
2480
2481 let ops: Operations = serde_json::from_value(serde_json::json!([{
2482 "operation_identifier": {"index": 0},
2483 "type": "Stake",
2484 "account": {"address": sender.to_string()},
2485 "amount": {"value": "-100000", "currency": {"symbol": "SUI", "decimals": 9}},
2486 "metadata": {"Stake": {"validator": validator.to_string()}}
2487 }]))?;
2488
2489 let metadata = ConstructionMetadata {
2490 sender,
2491 gas_coins: vec![gas],
2492 extra_gas_coins: vec![],
2493 objects: vec![],
2494 party_objects: vec![],
2495 total_coin_value: 0,
2496 gas_price,
2497 budget: gas_price * TEST_ONLY_GAS_UNIT_FOR_STAKING,
2498 currency: None,
2499 address_balance_withdrawal: 0,
2500 epoch: None,
2501 chain_id: None,
2502 nonce: None,
2503 fss_object_count: None,
2504 redeem_token_amount: None,
2505 redeem_plan: None,
2506 bind_epoch: None,
2507 };
2508 let parsed_data = ops.clone().into_internal()?.try_into_data(metadata)?;
2509
2510 let proto_tx: Transaction = parsed_data.clone().into();
2511 let parsed_ops = Operations::new(Operations::from_transaction(
2512 proto_tx
2513 .kind
2514 .ok_or_else(|| Error::DataError("Transaction missing kind".to_string()))?,
2515 sender,
2516 None,
2517 PaymentCurrency::Sui,
2518 )?);
2519
2520 assert_eq!(ops, parsed_ops, "expected {ops:#?}, got: {parsed_ops:#?}");
2521 Ok(())
2522 }
2523
2524 fn parse_payment_pt(payment: PaymentCurrency) -> Result<Vec<Operation>, anyhow::Error> {
2527 use crate::SUI;
2528 use crate::types::internal_operation::pay_coin_pt;
2529
2530 let gas = (
2531 ObjectID::random(),
2532 SequenceNumber::new(),
2533 ObjectDigest::random(),
2534 );
2535 let coin = (
2536 ObjectID::random(),
2537 SequenceNumber::new(),
2538 ObjectDigest::random(),
2539 );
2540 let sender = SuiAddress::random_for_testing_only();
2541 let recipient = SuiAddress::random_for_testing_only();
2542 let pt = pay_coin_pt(sender, vec![recipient], vec![10_000], &[coin], &[], 0, &SUI)?;
2543 let gas_price = 10;
2544 let data = TransactionData::new_programmable(
2545 sender,
2546 vec![gas],
2547 pt,
2548 TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
2549 gas_price,
2550 );
2551 let proto_tx: Transaction = data.into();
2552 let tx_kind = proto_tx.kind.unwrap();
2553 Ok(Operations::from_transaction(
2554 tx_kind, sender, None, payment,
2555 )?)
2556 }
2557
2558 #[test]
2563 fn test_parse_unresolvable_emits_generic_op() -> Result<(), anyhow::Error> {
2564 let ops = parse_payment_pt(PaymentCurrency::Unresolvable)?;
2565 assert!(
2566 !ops.iter().any(|op| op.type_ == OperationType::PaySui),
2567 "Unresolvable must not silently fall back to PaySui: {ops:?}"
2568 );
2569 assert!(
2570 !ops.iter().any(|op| op.type_ == OperationType::PayCoin),
2571 "Unresolvable must not produce PayCoin (we don't know the currency): {ops:?}"
2572 );
2573 assert!(
2574 ops.iter()
2575 .any(|op| matches!(op.metadata, Some(OperationMetadata::GenericTransaction(_)))),
2576 "Unresolvable must fall through to generic_op: {ops:?}"
2577 );
2578 Ok(())
2579 }
2580
2581 #[test]
2584 fn test_parse_nonsui_emits_pay_coin() -> Result<(), anyhow::Error> {
2585 use crate::types::CurrencyMetadata;
2586
2587 let usdc = Currency {
2588 symbol: "USDC".to_string(),
2589 decimals: 6,
2590 metadata: CurrencyMetadata {
2591 coin_type: "0xaaa::usdc::USDC".to_string(),
2592 },
2593 };
2594 let ops = parse_payment_pt(PaymentCurrency::NonSui(usdc.clone()))?;
2595 assert!(
2596 !ops.iter().any(|op| op.type_ == OperationType::PaySui),
2597 "NonSui must not produce PaySui: {ops:?}"
2598 );
2599 let pay_coins: Vec<_> = ops
2600 .iter()
2601 .filter(|op| op.type_ == OperationType::PayCoin)
2602 .collect();
2603 assert!(
2604 !pay_coins.is_empty(),
2605 "NonSui must produce PayCoin: {ops:?}"
2606 );
2607 for op in pay_coins {
2608 assert_eq!(
2609 op.amount.as_ref().map(|a| &a.currency),
2610 Some(&usdc),
2611 "PayCoin op must carry the NonSui currency: {op:?}"
2612 );
2613 }
2614 Ok(())
2615 }
2616
2617 fn unreachable_cache() -> CoinMetadataCache {
2620 use std::num::NonZeroUsize;
2621 use sui_rpc::client::Client;
2622 CoinMetadataCache::new(
2623 Client::new("http://127.0.0.1:1").unwrap(),
2624 NonZeroUsize::new(1).unwrap(),
2625 )
2626 }
2627
2628 fn balance_change(coin_type: &str) -> BalanceChange {
2629 let mut bc = BalanceChange::default();
2630 bc.coin_type = Some(coin_type.to_string());
2631 bc
2632 }
2633
2634 #[tokio::test]
2638 async fn test_resolve_sui_needs_no_lookup() {
2639 let cache = unreachable_cache();
2640 let resolved = resolve_tx_currencies(&[balance_change(&SUI.metadata.coin_type)], &cache)
2641 .await
2642 .expect("SUI must resolve without an RPC");
2643 assert!(matches!(resolved.payment, PaymentCurrency::Sui));
2644 assert_eq!(
2645 resolved.by_coin_type.get(&SUI.metadata.coin_type),
2646 Some(&*SUI)
2647 );
2648 }
2649
2650 #[tokio::test]
2656 async fn test_resolve_non_struct_coin_type_degrades() {
2657 let cache = unreachable_cache();
2658 let resolved = resolve_tx_currencies(&[balance_change("u64")], &cache)
2659 .await
2660 .expect("a non-struct coin type must not fail the block");
2661 assert!(
2662 matches!(resolved.payment, PaymentCurrency::Unresolvable),
2663 "a non-struct coin type must fall through to generic_op: {:?}",
2664 resolved.payment
2665 );
2666 assert!(
2667 resolved.by_coin_type.is_empty(),
2668 "a non-struct coin type must not be reported as a currency: {:?}",
2669 resolved.by_coin_type
2670 );
2671 }
2672
2673 #[tokio::test]
2677 async fn test_resolve_transient_non_sui_is_retriable() {
2678 let cache = unreachable_cache();
2679 let err = resolve_tx_currencies(&[balance_change("0xaaa::usdc::USDC")], &cache)
2680 .await
2681 .expect_err("a transient non-SUI lookup failure must surface as an error");
2682 assert!(
2683 matches!(err, Error::CoinMetadataUnavailable(_)),
2684 "transient failure must map to CoinMetadataUnavailable: {err:?}"
2685 );
2686 let json = serde_json::to_value(&err).expect("error serializes");
2688 assert_eq!(
2689 json.get("retriable"),
2690 Some(&serde_json::Value::Bool(true)),
2691 "CoinMetadataUnavailable must serialize as retriable: {json}"
2692 );
2693 }
2694
2695 #[test]
2700 fn test_pay_coin_pt_has_no_currency_bearer() -> Result<(), anyhow::Error> {
2701 use crate::SUI;
2702 use crate::types::internal_operation::pay_coin_pt;
2703
2704 let sender = SuiAddress::random_for_testing_only();
2705 let recipient = SuiAddress::random_for_testing_only();
2706 let coin = (
2707 ObjectID::random(),
2708 SequenceNumber::new(),
2709 ObjectDigest::random(),
2710 );
2711
2712 let pt = pay_coin_pt(sender, vec![recipient], vec![10_000], &[coin], &[], 0, &SUI)?;
2713
2714 for input in &pt.inputs {
2715 if let CallArg::Pure(bytes) = input
2716 && let Ok(s) = bcs::from_bytes::<String>(bytes)
2717 && serde_json::from_str::<Currency>(&s).is_ok()
2718 {
2719 panic!(
2720 "pay_coin_pt produced a Pure input that decodes as a Currency JSON string: {:?}",
2721 s
2722 );
2723 }
2724 }
2725 Ok(())
2726 }
2727
2728 #[tokio::test]
2737 async fn test_try_from_executed_transaction_deleted_gas_coin() -> Result<(), anyhow::Error> {
2738 use std::num::NonZeroUsize;
2739 use sui_rpc::client::Client;
2740 use sui_rpc::proto::sui::rpc::v2::changed_object::OutputObjectState;
2741 use sui_rpc::proto::sui::rpc::v2::{
2742 ChangedObject, ExecutedTransaction, ExecutionStatus, GasCostSummary, TransactionEffects,
2743 };
2744
2745 let sender = SuiAddress::random_for_testing_only();
2746 let recipient = SuiAddress::random_for_testing_only();
2747
2748 let pt = {
2749 let mut builder = ProgrammableTransactionBuilder::new();
2750 builder.pay_sui(vec![recipient], vec![1000]).unwrap();
2751 builder.finish()
2752 };
2753 let gas_price = 10;
2754 let data = TransactionData::new_programmable(
2755 sender,
2756 vec![random_object_ref()],
2757 pt,
2758 TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
2759 gas_price,
2760 );
2761 let transaction: Transaction = data.into();
2762
2763 let mut gas_object = ChangedObject::default();
2766 gas_object.object_id = Some(ObjectID::random().to_string());
2767 gas_object.output_state = Some(OutputObjectState::DoesNotExist as i32);
2768 gas_object.output_owner = None;
2769
2770 let mut status = ExecutionStatus::default();
2771 status.success = Some(true);
2772
2773 let mut gas_used = GasCostSummary::default();
2774 gas_used.computation_cost = Some(1000);
2775 gas_used.storage_cost = Some(0);
2776 gas_used.storage_rebate = Some(0);
2777 gas_used.non_refundable_storage_fee = Some(0);
2778
2779 let mut effects = TransactionEffects::default();
2780 effects.status = Some(status);
2781 effects.gas_used = Some(gas_used);
2782 effects.gas_object = Some(gas_object);
2783
2784 let mut executed_tx = ExecutedTransaction::default();
2785 executed_tx.transaction = Some(transaction);
2786 executed_tx.effects = Some(effects);
2787 executed_tx.events = None;
2788 executed_tx.balance_changes = vec![];
2789
2790 let cache = CoinMetadataCache::new(
2793 Client::new("http://127.0.0.1:1").unwrap(),
2794 NonZeroUsize::new(1).unwrap(),
2795 );
2796
2797 let ops = Operations::try_from_executed_transaction(executed_tx, &cache).await?;
2798
2799 let gas_op = ops
2800 .0
2801 .iter()
2802 .find(|op| op.type_ == OperationType::Gas)
2803 .expect("expected a Gas operation");
2804 assert_eq!(gas_op.account.as_ref().map(|a| a.address), Some(sender));
2805
2806 Ok(())
2807 }
2808
2809 #[tokio::test]
2815 async fn test_try_from_executed_transaction_unstake_with_gas_coin_transfer()
2816 -> Result<(), anyhow::Error> {
2817 use std::num::NonZeroUsize;
2818 use sui_rpc::client::Client;
2819 use sui_rpc::proto::sui::rpc::v2::owner::OwnerKind;
2820 use sui_rpc::proto::sui::rpc::v2::{
2821 ChangedObject, Event, ExecutedTransaction, ExecutionStatus, GasCostSummary, Owner,
2822 TransactionEffects, TransactionEvents,
2823 };
2824 use sui_types::transaction::Argument as NativeArgument;
2825
2826 let sender = SuiAddress::random_for_testing_only();
2827 let recipient = SuiAddress::random_for_testing_only();
2828 let principal: i128 = 1_000_000_000_000;
2829 let reward: i128 = 10_000_000_000;
2830 let gas_coin_balance: i128 = 5_000_000_000;
2831 let computation_cost: i128 = 1_000;
2832
2833 let pt = {
2834 let mut builder = ProgrammableTransactionBuilder::new();
2835 let system = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
2836 let staked_sui = builder
2837 .obj(ObjectArg::ImmOrOwnedObject(random_object_ref()))
2838 .unwrap();
2839 let balance = builder.command(NativeCommand::move_call(
2840 SUI_SYSTEM_PACKAGE_ID,
2841 Identifier::new("sui_system").unwrap(),
2842 Identifier::new("request_withdraw_stake_non_entry").unwrap(),
2843 vec![],
2844 vec![system, staked_sui],
2845 ));
2846 let coin = builder.command(NativeCommand::move_call(
2847 SUI_FRAMEWORK_PACKAGE_ID,
2848 Identifier::new("coin").unwrap(),
2849 Identifier::new("from_balance").unwrap(),
2850 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
2851 vec![balance],
2852 ));
2853 let recipient_arg = builder.pure(recipient).unwrap();
2854 builder.command(NativeCommand::TransferObjects(vec![coin], recipient_arg));
2855 builder.command(NativeCommand::TransferObjects(
2856 vec![NativeArgument::GasCoin],
2857 recipient_arg,
2858 ));
2859 builder.finish()
2860 };
2861 let gas_price = 10;
2862 let data = TransactionData::new_programmable(
2863 sender,
2864 vec![random_object_ref()],
2865 pt,
2866 TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
2867 gas_price,
2868 );
2869 let transaction: Transaction = data.into();
2870
2871 let mut gas_output_owner = Owner::default();
2873 gas_output_owner.kind = Some(OwnerKind::Address as i32);
2874 gas_output_owner.address = Some(recipient.to_string());
2875 let mut gas_object = ChangedObject::default();
2876 gas_object.object_id = Some(ObjectID::random().to_string());
2877 gas_object.output_owner = Some(gas_output_owner);
2878
2879 let mut status = ExecutionStatus::default();
2880 status.success = Some(true);
2881
2882 let mut gas_used = GasCostSummary::default();
2883 gas_used.computation_cost = Some(computation_cost as u64);
2884 gas_used.storage_cost = Some(0);
2885 gas_used.storage_rebate = Some(0);
2886 gas_used.non_refundable_storage_fee = Some(0);
2887
2888 let mut effects = TransactionEffects::default();
2889 effects.status = Some(status);
2890 effects.gas_used = Some(gas_used);
2891 effects.gas_object = Some(gas_object);
2892
2893 let unstake_fields = [("principal_amount", principal), ("reward_amount", reward)]
2894 .into_iter()
2895 .map(|(name, amount)| {
2896 (
2897 name.to_string(),
2898 prost_types::Value {
2899 kind: Some(Kind::StringValue(amount.to_string())),
2900 },
2901 )
2902 })
2903 .collect();
2904 let mut unstake_event = Event::default();
2905 unstake_event.event_type = Some("0x3::validator::UnstakingRequestEvent".to_string());
2906 unstake_event.json = Some(Box::new(prost_types::Value {
2907 kind: Some(Kind::StructValue(prost_types::Struct {
2908 fields: unstake_fields,
2909 })),
2910 }));
2911 let mut events = TransactionEvents::default();
2912 events.events = vec![unstake_event];
2913
2914 let sui_balance_change = |address: SuiAddress, amount: i128| {
2915 let mut balance_change = BalanceChange::default();
2916 balance_change.address = Some(address.to_string());
2917 balance_change.coin_type = Some(SUI.metadata.coin_type.clone());
2918 balance_change.amount = Some(amount.to_string());
2919 balance_change
2920 };
2921 let sender_change = -gas_coin_balance;
2923 let recipient_change = principal + reward + gas_coin_balance - computation_cost;
2924
2925 let mut executed_tx = ExecutedTransaction::default();
2926 executed_tx.transaction = Some(transaction);
2927 executed_tx.effects = Some(effects);
2928 executed_tx.events = Some(events);
2929 executed_tx.balance_changes = vec![
2930 sui_balance_change(sender, sender_change),
2931 sui_balance_change(recipient, recipient_change),
2932 ];
2933
2934 let cache = CoinMetadataCache::new(
2937 Client::new("http://127.0.0.1:1").unwrap(),
2938 NonZeroUsize::new(1).unwrap(),
2939 );
2940
2941 let ops = Operations::try_from_executed_transaction(executed_tx, &cache).await?;
2942
2943 let gas_op = ops
2944 .0
2945 .iter()
2946 .find(|op| op.type_ == OperationType::Gas)
2947 .expect("expected a Gas operation");
2948 assert_eq!(gas_op.account.as_ref().map(|a| a.address), Some(sender));
2949
2950 let mut net_by_address: HashMap<SuiAddress, i128> = HashMap::new();
2953 for op in &ops.0 {
2954 if let (Some(account), Some(amount)) = (&op.account, &op.amount) {
2955 *net_by_address.entry(account.address).or_default() += amount.value;
2956 }
2957 }
2958 assert_eq!(
2959 net_by_address,
2960 HashMap::from([(sender, sender_change), (recipient, recipient_change)]),
2961 "operations: {:?}",
2962 ops.0
2963 );
2964
2965 Ok(())
2966 }
2967
2968 #[test]
2969 fn test_parse_consolidate_all_staked_sui_to_fungible() {
2970 let sender = SuiAddress::random_for_testing_only();
2971 let validator = SuiAddress::random_for_testing_only();
2972
2973 let ops: Operations = serde_json::from_value(serde_json::json!([{
2974 "operation_identifier": {"index": 0},
2975 "type": "ConsolidateAllStakedSuiToFungible",
2976 "account": {"address": sender.to_string()},
2977 "metadata": {
2978 "ConsolidateAllStakedSuiToFungible": {
2979 "validator": validator.to_string()
2980 }
2981 }
2982 }]))
2983 .unwrap();
2984
2985 let internal = ops.into_internal().unwrap();
2986 match internal {
2987 InternalOperation::ConsolidateAllStakedSuiToFungible(op) => {
2988 assert_eq!(op.sender, sender);
2989 assert_eq!(op.validator, validator);
2990 }
2991 _ => panic!("Expected ConsolidateAllStakedSuiToFungible"),
2992 }
2993 }
2994
2995 #[test]
2996 fn test_parse_merge_and_redeem_fungible_staked_sui() {
2997 let sender = SuiAddress::random_for_testing_only();
2998 let validator = SuiAddress::random_for_testing_only();
2999
3000 let ops: Operations = serde_json::from_value(serde_json::json!([{
3001 "operation_identifier": {"index": 0},
3002 "type": "MergeAndRedeemFungibleStakedSui",
3003 "account": {"address": sender.to_string()},
3004 "metadata": {
3005 "MergeAndRedeemFungibleStakedSui": {
3006 "validator": validator.to_string(),
3007 "amount": "500000000000",
3008 "redeem_mode": "AtLeast"
3009 }
3010 }
3011 }]))
3012 .unwrap();
3013
3014 let internal = ops.into_internal().unwrap();
3015 match internal {
3016 InternalOperation::MergeAndRedeemFungibleStakedSui(op) => {
3017 assert_eq!(op.sender, sender);
3018 assert_eq!(op.validator, validator);
3019 assert_eq!(op.amount, Some(500000000000));
3020 assert_eq!(op.redeem_mode, RedeemMode::AtLeast);
3021 }
3022 _ => panic!("Expected MergeAndRedeemFungibleStakedSui"),
3023 }
3024 }
3025
3026 #[test]
3027 fn test_parse_merge_and_redeem_all_mode() {
3028 let sender = SuiAddress::random_for_testing_only();
3029 let validator = SuiAddress::random_for_testing_only();
3030
3031 let ops: Operations = serde_json::from_value(serde_json::json!([{
3032 "operation_identifier": {"index": 0},
3033 "type": "MergeAndRedeemFungibleStakedSui",
3034 "account": {"address": sender.to_string()},
3035 "metadata": {
3036 "MergeAndRedeemFungibleStakedSui": {
3037 "validator": validator.to_string(),
3038 "redeem_mode": "All"
3039 }
3040 }
3041 }]))
3042 .unwrap();
3043
3044 let internal = ops.into_internal().unwrap();
3045 match internal {
3046 InternalOperation::MergeAndRedeemFungibleStakedSui(op) => {
3047 assert_eq!(op.amount, None);
3048 assert_eq!(op.redeem_mode, RedeemMode::All);
3049 }
3050 _ => panic!("Expected MergeAndRedeemFungibleStakedSui"),
3051 }
3052 }
3053
3054 fn assert_consolidate_ops(
3059 ops: &[Operation],
3060 expected_sender: SuiAddress,
3061 expected_staked_sui: &[ObjectID],
3062 expected_fss: &[ObjectID],
3063 ) {
3064 assert_eq!(ops.len(), 1);
3065 let op = &ops[0];
3066 assert_eq!(op.type_, OperationType::ConsolidateAllStakedSuiToFungible);
3067 assert_eq!(
3068 op.account.as_ref().map(|a| a.address),
3069 Some(expected_sender)
3070 );
3071 assert!(op.amount.is_none());
3072 let Some(OperationMetadata::ConsolidateAllStakedSuiToFungible {
3073 validator,
3074 staked_sui_ids,
3075 fss_ids,
3076 }) = op.metadata.clone()
3077 else {
3078 panic!("wrong metadata variant: {:?}", op.metadata);
3079 };
3080 assert!(validator.is_none(), "validator must be None on parse");
3081 assert_eq!(staked_sui_ids, expected_staked_sui);
3082 assert_eq!(fss_ids, expected_fss);
3083 }
3084
3085 #[test]
3086 fn test_parse_consolidate_pure_merge_2_fss() {
3087 let sender = SuiAddress::random_for_testing_only();
3088 let fss_a = random_object_ref();
3089 let fss_b = random_object_ref();
3090 let pt = consolidate_to_fungible_pt(sender, vec![fss_a, fss_b], vec![]).expect("pt");
3091 let ops = parse_pt(sender, pt);
3092 assert_consolidate_ops(&ops, sender, &[], &[fss_a.0, fss_b.0]);
3093 }
3094
3095 #[test]
3096 fn test_parse_consolidate_pure_merge_3_fss() {
3097 let sender = SuiAddress::random_for_testing_only();
3098 let a = random_object_ref();
3099 let b = random_object_ref();
3100 let c = random_object_ref();
3101 let pt = consolidate_to_fungible_pt(sender, vec![a, b, c], vec![]).expect("pt");
3102 assert_consolidate_ops(&parse_pt(sender, pt), sender, &[], &[a.0, b.0, c.0]);
3103 }
3104
3105 #[test]
3106 fn test_parse_consolidate_pure_merge_5_fss() {
3107 let sender = SuiAddress::random_for_testing_only();
3108 let refs: Vec<_> = (0..5).map(|_| random_object_ref()).collect();
3109 let pt = consolidate_to_fungible_pt(sender, refs.clone(), vec![]).expect("pt");
3110 let expected: Vec<_> = refs.iter().map(|r| r.0).collect();
3111 assert_consolidate_ops(&parse_pt(sender, pt), sender, &[], &expected);
3112 }
3113
3114 #[test]
3115 fn test_parse_consolidate_single_convert_no_fss() {
3116 let sender = SuiAddress::random_for_testing_only();
3117 let staked = random_object_ref();
3118 let pt = consolidate_to_fungible_pt(sender, vec![], vec![staked]).expect("pt");
3119 assert_consolidate_ops(&parse_pt(sender, pt), sender, &[staked.0], &[]);
3120 }
3121
3122 #[test]
3123 fn test_parse_consolidate_multi_convert_no_fss() {
3124 let sender = SuiAddress::random_for_testing_only();
3125 let s1 = random_object_ref();
3126 let s2 = random_object_ref();
3127 let s3 = random_object_ref();
3128 let pt = consolidate_to_fungible_pt(sender, vec![], vec![s1, s2, s3]).expect("pt");
3129 assert_consolidate_ops(&parse_pt(sender, pt), sender, &[s1.0, s2.0, s3.0], &[]);
3130 }
3131
3132 #[test]
3133 fn test_parse_consolidate_single_stake_single_fss() {
3134 let sender = SuiAddress::random_for_testing_only();
3135 let fss = random_object_ref();
3136 let staked = random_object_ref();
3137 let pt = consolidate_to_fungible_pt(sender, vec![fss], vec![staked]).expect("pt");
3138 assert_consolidate_ops(&parse_pt(sender, pt), sender, &[staked.0], &[fss.0]);
3139 }
3140
3141 #[test]
3142 fn test_parse_consolidate_single_stake_multi_fss() {
3143 let sender = SuiAddress::random_for_testing_only();
3144 let f1 = random_object_ref();
3145 let f2 = random_object_ref();
3146 let staked = random_object_ref();
3147 let pt = consolidate_to_fungible_pt(sender, vec![f1, f2], vec![staked]).expect("pt");
3148 assert_consolidate_ops(&parse_pt(sender, pt), sender, &[staked.0], &[f1.0, f2.0]);
3149 }
3150
3151 #[test]
3152 fn test_parse_consolidate_multi_stake_single_fss() {
3153 let sender = SuiAddress::random_for_testing_only();
3154 let fss = random_object_ref();
3155 let s1 = random_object_ref();
3156 let s2 = random_object_ref();
3157 let pt = consolidate_to_fungible_pt(sender, vec![fss], vec![s1, s2]).expect("pt");
3158 assert_consolidate_ops(&parse_pt(sender, pt), sender, &[s1.0, s2.0], &[fss.0]);
3159 }
3160
3161 #[test]
3162 fn test_parse_consolidate_multi_stake_multi_fss() {
3163 let sender = SuiAddress::random_for_testing_only();
3164 let f1 = random_object_ref();
3165 let f2 = random_object_ref();
3166 let s1 = random_object_ref();
3167 let s2 = random_object_ref();
3168 let pt = consolidate_to_fungible_pt(sender, vec![f1, f2], vec![s1, s2]).expect("pt");
3169 assert_consolidate_ops(&parse_pt(sender, pt), sender, &[s1.0, s2.0], &[f1.0, f2.0]);
3170 }
3171
3172 #[test]
3173 fn test_parse_consolidate_large_mixed() {
3174 let sender = SuiAddress::random_for_testing_only();
3175 let fss: Vec<_> = (0..3).map(|_| random_object_ref()).collect();
3176 let staked: Vec<_> = (0..3).map(|_| random_object_ref()).collect();
3177 let pt = consolidate_to_fungible_pt(sender, fss.clone(), staked.clone()).expect("pt");
3178 let expected_s: Vec<_> = staked.iter().map(|r| r.0).collect();
3179 let expected_f: Vec<_> = fss.iter().map(|r| r.0).collect();
3180 assert_consolidate_ops(&parse_pt(sender, pt), sender, &expected_s, &expected_f);
3181 }
3182
3183 #[test]
3184 fn test_parse_consolidate_classification_correctness() {
3185 let sender = SuiAddress::random_for_testing_only();
3187 let f1 = random_object_ref();
3188 let f2 = random_object_ref();
3189 let s1 = random_object_ref();
3190 let s2 = random_object_ref();
3191 let pt = consolidate_to_fungible_pt(sender, vec![f1, f2], vec![s1, s2]).expect("pt");
3192 let ops = parse_pt(sender, pt);
3193 let Some(OperationMetadata::ConsolidateAllStakedSuiToFungible {
3194 staked_sui_ids,
3195 fss_ids,
3196 ..
3197 }) = ops[0].metadata.clone()
3198 else {
3199 panic!();
3200 };
3201 let staked_set: std::collections::HashSet<_> = staked_sui_ids.iter().collect();
3202 let fss_set: std::collections::HashSet<_> = fss_ids.iter().collect();
3203 assert!(
3204 staked_set.is_disjoint(&fss_set),
3205 "classification crossed categories"
3206 );
3207 }
3208
3209 fn assert_falls_through_to_generic(ops: &[Operation]) {
3214 assert_eq!(ops.len(), 1);
3215 assert_eq!(
3216 ops[0].type_,
3217 OperationType::ProgrammableTransaction,
3218 "expected fall-through to generic ProgrammableTransaction, got: {:?}",
3219 ops[0].type_
3220 );
3221 }
3222
3223 #[test]
3224 fn test_parse_falls_through_consolidate_with_merge_coins() {
3225 let sender = SuiAddress::random_for_testing_only();
3226 let fss_a = random_object_ref();
3227 let fss_b = random_object_ref();
3228 let coin_a = random_object_ref();
3229
3230 let mut builder = ProgrammableTransactionBuilder::new();
3231 let _sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3232 let first = builder.obj(ObjectArg::ImmOrOwnedObject(fss_a)).unwrap();
3233 let other = builder.obj(ObjectArg::ImmOrOwnedObject(fss_b)).unwrap();
3234 builder.command(NativeCommand::move_call(
3235 SUI_SYSTEM_PACKAGE_ID,
3236 Identifier::new("staking_pool").unwrap(),
3237 Identifier::new("join_fungible_staked_sui").unwrap(),
3238 vec![],
3239 vec![first, other],
3240 ));
3241 let coin_target = builder.obj(ObjectArg::ImmOrOwnedObject(coin_a)).unwrap();
3243 builder.command(NativeCommand::MergeCoins(coin_target, vec![]));
3244
3245 let ops = parse_pt(sender, builder.finish());
3246 assert_falls_through_to_generic(&ops);
3247 }
3248
3249 #[test]
3250 fn test_parse_falls_through_consolidate_with_unrelated_movecall() {
3251 let sender = SuiAddress::random_for_testing_only();
3252 let fss_a = random_object_ref();
3253 let fss_b = random_object_ref();
3254
3255 let mut builder = ProgrammableTransactionBuilder::new();
3256 let _sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3257 let first = builder.obj(ObjectArg::ImmOrOwnedObject(fss_a)).unwrap();
3258 let other = builder.obj(ObjectArg::ImmOrOwnedObject(fss_b)).unwrap();
3259 builder.command(NativeCommand::move_call(
3260 SUI_SYSTEM_PACKAGE_ID,
3261 Identifier::new("staking_pool").unwrap(),
3262 Identifier::new("join_fungible_staked_sui").unwrap(),
3263 vec![],
3264 vec![first, other],
3265 ));
3266 builder.command(NativeCommand::move_call(
3268 SUI_FRAMEWORK_PACKAGE_ID,
3269 Identifier::new("coin").unwrap(),
3270 Identifier::new("destroy_zero").unwrap(),
3271 vec![],
3272 vec![other],
3273 ));
3274
3275 let ops = parse_pt(sender, builder.finish());
3276 assert_falls_through_to_generic(&ops);
3277 }
3278
3279 #[test]
3280 fn test_parse_falls_through_convert_without_system_state() {
3281 let sender = SuiAddress::random_for_testing_only();
3283 let staked = random_object_ref();
3284 let other_obj = random_object_ref();
3285
3286 let mut builder = ProgrammableTransactionBuilder::new();
3287 let _not_system = builder.obj(ObjectArg::ImmOrOwnedObject(other_obj)).unwrap();
3289 let staked_arg = builder.obj(ObjectArg::ImmOrOwnedObject(staked)).unwrap();
3290 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3291 let new_fss = builder.command(NativeCommand::move_call(
3292 SUI_SYSTEM_PACKAGE_ID,
3293 Identifier::new("sui_system").unwrap(),
3294 Identifier::new("convert_to_fungible_staked_sui").unwrap(),
3295 vec![],
3296 vec![sys, staked_arg],
3297 ));
3298 let sender_arg = builder.pure(sender).unwrap();
3299 builder.command(NativeCommand::TransferObjects(vec![new_fss], sender_arg));
3300
3301 let ops = parse_pt(sender, builder.finish());
3302 assert_falls_through_to_generic(&ops);
3303 }
3304
3305 #[test]
3306 fn test_parse_falls_through_extra_command_after_transfer() {
3307 let sender = SuiAddress::random_for_testing_only();
3309 let staked = random_object_ref();
3310 let other_obj = random_object_ref();
3311
3312 let mut builder = ProgrammableTransactionBuilder::new();
3313 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3314 let staked_arg = builder.obj(ObjectArg::ImmOrOwnedObject(staked)).unwrap();
3315 let new_fss = builder.command(NativeCommand::move_call(
3316 SUI_SYSTEM_PACKAGE_ID,
3317 Identifier::new("sui_system").unwrap(),
3318 Identifier::new("convert_to_fungible_staked_sui").unwrap(),
3319 vec![],
3320 vec![sys, staked_arg],
3321 ));
3322 let sender_arg = builder.pure(sender).unwrap();
3323 builder.command(NativeCommand::TransferObjects(vec![new_fss], sender_arg));
3324 let extra = builder.obj(ObjectArg::ImmOrOwnedObject(other_obj)).unwrap();
3326 builder.command(NativeCommand::move_call(
3327 SUI_FRAMEWORK_PACKAGE_ID,
3328 Identifier::new("coin").unwrap(),
3329 Identifier::new("destroy_zero").unwrap(),
3330 vec![],
3331 vec![extra],
3332 ));
3333
3334 let ops = parse_pt(sender, builder.finish());
3335 assert_falls_through_to_generic(&ops);
3336 }
3337
3338 #[test]
3343 fn test_parse_empty_ptb() {
3344 let sender = SuiAddress::random_for_testing_only();
3345 let pt = ProgrammableTransactionBuilder::new().finish();
3346 let ops = parse_pt(sender, pt);
3347 assert_eq!(ops.len(), 1);
3349 assert_eq!(ops[0].type_, OperationType::ProgrammableTransaction);
3350 }
3351
3352 #[test]
3353 fn test_parse_only_merge_coins() {
3354 let sender = SuiAddress::random_for_testing_only();
3356 let coin_a = random_object_ref();
3357 let coin_b = random_object_ref();
3358 let mut builder = ProgrammableTransactionBuilder::new();
3359 let target = builder.obj(ObjectArg::ImmOrOwnedObject(coin_a)).unwrap();
3360 let source = builder.obj(ObjectArg::ImmOrOwnedObject(coin_b)).unwrap();
3361 builder.command(NativeCommand::MergeCoins(target, vec![source]));
3362 let ops = parse_pt(sender, builder.finish());
3363 assert_ne!(
3366 ops[0].type_,
3367 OperationType::ConsolidateAllStakedSuiToFungible
3368 );
3369 assert_ne!(ops[0].type_, OperationType::MergeAndRedeemFungibleStakedSui);
3370 }
3371
3372 #[test]
3380 fn test_meta_consolidate_old_input_deserializes() {
3381 let validator = SuiAddress::random_for_testing_only();
3382 let json = serde_json::json!({
3383 "ConsolidateAllStakedSuiToFungible": { "validator": validator.to_string() }
3384 });
3385 let meta: OperationMetadata = serde_json::from_value(json).unwrap();
3386 match meta {
3387 OperationMetadata::ConsolidateAllStakedSuiToFungible {
3388 validator: v,
3389 staked_sui_ids,
3390 fss_ids,
3391 } => {
3392 assert_eq!(v, Some(validator));
3393 assert!(staked_sui_ids.is_empty());
3394 assert!(fss_ids.is_empty());
3395 }
3396 _ => panic!("wrong variant"),
3397 }
3398 }
3399
3400 #[test]
3401 fn test_meta_consolidate_new_parse_output_serializes() {
3402 let id_a = ObjectID::random();
3403 let id_b = ObjectID::random();
3404 let meta = OperationMetadata::ConsolidateAllStakedSuiToFungible {
3405 validator: None,
3406 staked_sui_ids: vec![id_a],
3407 fss_ids: vec![id_b],
3408 };
3409 let json = serde_json::to_value(&meta).unwrap();
3410 let obj = json
3411 .as_object()
3412 .unwrap()
3413 .get("ConsolidateAllStakedSuiToFungible")
3414 .unwrap()
3415 .as_object()
3416 .unwrap();
3417 assert!(
3418 !obj.contains_key("validator"),
3419 "validator must be omitted when None"
3420 );
3421 assert_eq!(
3422 obj.get("staked_sui_ids").unwrap().as_array().unwrap().len(),
3423 1
3424 );
3425 assert_eq!(obj.get("fss_ids").unwrap().as_array().unwrap().len(), 1);
3426 }
3427
3428 #[test]
3433 fn test_write_consolidate_requires_validator() {
3434 let sender = SuiAddress::random_for_testing_only();
3435 let op = Operation {
3436 operation_identifier: Default::default(),
3437 type_: OperationType::ConsolidateAllStakedSuiToFungible,
3438 status: None,
3439 account: Some(sender.into()),
3440 amount: None,
3441 coin_change: None,
3442 metadata: Some(OperationMetadata::ConsolidateAllStakedSuiToFungible {
3443 validator: None,
3444 staked_sui_ids: vec![],
3445 fss_ids: vec![],
3446 }),
3447 };
3448 let err = Operations::new(vec![op])
3449 .into_internal()
3450 .expect_err("should fail without validator");
3451 let msg = format!("{err}");
3452 assert!(msg.contains("validator"), "unexpected error: {msg}");
3453 }
3454
3455 fn assert_merge_redeem_ops(
3460 ops: &[Operation],
3461 expected_sender: SuiAddress,
3462 expected_fss: &[ObjectID],
3463 expected_mode: Option<RedeemMode>,
3464 ) {
3465 assert_merge_redeem_ops_with_amount(
3466 ops,
3467 expected_sender,
3468 expected_fss,
3469 expected_mode,
3470 None,
3471 );
3472 }
3473
3474 fn assert_merge_redeem_ops_with_amount(
3475 ops: &[Operation],
3476 expected_sender: SuiAddress,
3477 expected_fss: &[ObjectID],
3478 expected_mode: Option<RedeemMode>,
3479 expected_amount: Option<&str>,
3480 ) {
3481 assert_eq!(ops.len(), 1);
3482 let op = &ops[0];
3483 assert_eq!(op.type_, OperationType::MergeAndRedeemFungibleStakedSui);
3484 assert_eq!(
3485 op.account.as_ref().map(|a| a.address),
3486 Some(expected_sender)
3487 );
3488 assert!(op.amount.is_none());
3489 let Some(OperationMetadata::MergeAndRedeemFungibleStakedSui {
3490 validator,
3491 amount,
3492 redeem_mode,
3493 fss_ids,
3494 }) = op.metadata.clone()
3495 else {
3496 panic!("wrong metadata variant: {:?}", op.metadata);
3497 };
3498 assert!(validator.is_none(), "validator must be None on parse");
3499 assert_eq!(
3500 amount.as_deref(),
3501 expected_amount,
3502 "metadata.amount mismatch"
3503 );
3504 assert_eq!(redeem_mode, expected_mode);
3505 assert_eq!(fss_ids, expected_fss);
3506 }
3507
3508 #[test]
3509 fn test_parse_merge_redeem_single_all() {
3510 let sender = SuiAddress::random_for_testing_only();
3511 let fss = random_object_ref();
3512 let pt = merge_and_redeem_fss_pt(sender, vec![fss], &RedeemPlan::All).expect("pt");
3513 assert_merge_redeem_ops(
3514 &parse_pt(sender, pt),
3515 sender,
3516 &[fss.0],
3517 Some(RedeemMode::All),
3518 );
3519 }
3520
3521 #[test]
3522 fn test_parse_merge_redeem_single_partial() {
3523 let sender = SuiAddress::random_for_testing_only();
3524 let fss = random_object_ref();
3525 let pt = merge_and_redeem_fss_pt(
3526 sender,
3527 vec![fss],
3528 &RedeemPlan::AtMost {
3529 token_amount: Some(500_000_000),
3530 max_sui: 0,
3531 },
3532 )
3533 .expect("pt");
3534 assert_merge_redeem_ops(&parse_pt(sender, pt), sender, &[fss.0], None);
3535 }
3536
3537 #[test]
3538 fn test_parse_merge_redeem_atleast_with_balance_guard() {
3539 let sender = SuiAddress::random_for_testing_only();
3540 let fss = random_object_ref();
3541 let pt = merge_and_redeem_fss_pt(
3542 sender,
3543 vec![fss],
3544 &RedeemPlan::AtLeast {
3545 token_amount: Some(500_000_000),
3546 min_sui: 1_000_000,
3547 },
3548 )
3549 .expect("pt");
3550 assert_merge_redeem_ops_with_amount(
3551 &parse_pt(sender, pt),
3552 sender,
3553 &[fss.0],
3554 Some(RedeemMode::AtLeast),
3555 Some("1000000"),
3556 );
3557 }
3558
3559 #[test]
3560 fn test_parse_merge_redeem_atleast_three_fss() {
3561 let sender = SuiAddress::random_for_testing_only();
3562 let a = random_object_ref();
3563 let b = random_object_ref();
3564 let c = random_object_ref();
3565 let pt = merge_and_redeem_fss_pt(
3566 sender,
3567 vec![a, b, c],
3568 &RedeemPlan::AtLeast {
3569 token_amount: Some(500_000_000),
3570 min_sui: 1_000_000,
3571 },
3572 )
3573 .expect("pt");
3574 assert_merge_redeem_ops_with_amount(
3575 &parse_pt(sender, pt),
3576 sender,
3577 &[a.0, b.0, c.0],
3578 Some(RedeemMode::AtLeast),
3579 Some("1000000"),
3580 );
3581 }
3582
3583 #[test]
3584 fn test_parse_merge_redeem_full_atleast_no_split() {
3585 let sender = SuiAddress::random_for_testing_only();
3590 let fss = random_object_ref();
3591 let pt = merge_and_redeem_fss_pt(
3592 sender,
3593 vec![fss],
3594 &RedeemPlan::AtLeast {
3595 token_amount: None,
3596 min_sui: 1_000_000,
3597 },
3598 )
3599 .expect("pt");
3600 assert_merge_redeem_ops_with_amount(
3601 &parse_pt(sender, pt),
3602 sender,
3603 &[fss.0],
3604 Some(RedeemMode::AtLeast),
3605 Some("1000000"),
3606 );
3607 }
3608
3609 #[test]
3610 fn test_parse_merge_redeem_two_all() {
3611 let sender = SuiAddress::random_for_testing_only();
3612 let a = random_object_ref();
3613 let b = random_object_ref();
3614 let pt = merge_and_redeem_fss_pt(sender, vec![a, b], &RedeemPlan::All).expect("pt");
3615 assert_merge_redeem_ops(
3616 &parse_pt(sender, pt),
3617 sender,
3618 &[a.0, b.0],
3619 Some(RedeemMode::All),
3620 );
3621 }
3622
3623 #[test]
3624 fn test_parse_merge_redeem_two_partial() {
3625 let sender = SuiAddress::random_for_testing_only();
3626 let a = random_object_ref();
3627 let b = random_object_ref();
3628 let pt = merge_and_redeem_fss_pt(
3629 sender,
3630 vec![a, b],
3631 &RedeemPlan::AtMost {
3632 token_amount: Some(500_000_000),
3633 max_sui: 0,
3634 },
3635 )
3636 .expect("pt");
3637 assert_merge_redeem_ops(&parse_pt(sender, pt), sender, &[a.0, b.0], None);
3638 }
3639
3640 #[test]
3641 fn test_parse_merge_redeem_three_all() {
3642 let sender = SuiAddress::random_for_testing_only();
3643 let a = random_object_ref();
3644 let b = random_object_ref();
3645 let c = random_object_ref();
3646 let pt = merge_and_redeem_fss_pt(sender, vec![a, b, c], &RedeemPlan::All).expect("pt");
3647 assert_merge_redeem_ops(
3648 &parse_pt(sender, pt),
3649 sender,
3650 &[a.0, b.0, c.0],
3651 Some(RedeemMode::All),
3652 );
3653 }
3654
3655 #[test]
3656 fn test_parse_merge_redeem_three_partial() {
3657 let sender = SuiAddress::random_for_testing_only();
3658 let a = random_object_ref();
3659 let b = random_object_ref();
3660 let c = random_object_ref();
3661 let pt = merge_and_redeem_fss_pt(
3662 sender,
3663 vec![a, b, c],
3664 &RedeemPlan::AtMost {
3665 token_amount: Some(500_000_000),
3666 max_sui: 0,
3667 },
3668 )
3669 .expect("pt");
3670 assert_merge_redeem_ops(&parse_pt(sender, pt), sender, &[a.0, b.0, c.0], None);
3671 }
3672
3673 #[test]
3674 fn test_parse_merge_redeem_five_all() {
3675 let sender = SuiAddress::random_for_testing_only();
3676 let refs: Vec<_> = (0..5).map(|_| random_object_ref()).collect();
3677 let pt = merge_and_redeem_fss_pt(sender, refs.clone(), &RedeemPlan::All).expect("pt");
3678 let expected: Vec<_> = refs.iter().map(|r| r.0).collect();
3679 assert_merge_redeem_ops(
3680 &parse_pt(sender, pt),
3681 sender,
3682 &expected,
3683 Some(RedeemMode::All),
3684 );
3685 }
3686
3687 #[test]
3688 fn test_parse_merge_redeem_fss_ids_order() {
3689 let sender = SuiAddress::random_for_testing_only();
3691 let a = random_object_ref();
3692 let b = random_object_ref();
3693 let c = random_object_ref();
3694 let pt = merge_and_redeem_fss_pt(sender, vec![a, b, c], &RedeemPlan::All).expect("pt");
3695 let ops = parse_pt(sender, pt);
3696 let Some(OperationMetadata::MergeAndRedeemFungibleStakedSui { fss_ids, .. }) =
3697 ops[0].metadata.clone()
3698 else {
3699 panic!();
3700 };
3701 assert_eq!(fss_ids, vec![a.0, b.0, c.0]);
3702 }
3703
3704 #[test]
3705 fn test_parse_merge_redeem_sender_account() {
3706 let sender = SuiAddress::random_for_testing_only();
3707 let fss = random_object_ref();
3708 let pt = merge_and_redeem_fss_pt(sender, vec![fss], &RedeemPlan::All).expect("pt");
3709 let ops = parse_pt(sender, pt);
3710 assert_eq!(ops[0].account.as_ref().unwrap().address, sender);
3711 }
3712
3713 #[test]
3714 fn test_parse_merge_redeem_no_amount_in_metadata() {
3715 let sender = SuiAddress::random_for_testing_only();
3716 let fss = random_object_ref();
3717 let pt = merge_and_redeem_fss_pt(
3718 sender,
3719 vec![fss],
3720 &RedeemPlan::AtMost {
3721 token_amount: Some(500_000_000),
3722 max_sui: 0,
3723 },
3724 )
3725 .expect("pt");
3726 let ops = parse_pt(sender, pt);
3727 let Some(OperationMetadata::MergeAndRedeemFungibleStakedSui { amount, .. }) =
3728 ops[0].metadata.clone()
3729 else {
3730 panic!();
3731 };
3732 assert!(amount.is_none());
3733 }
3734
3735 #[test]
3736 fn test_parse_merge_redeem_no_validator_in_metadata() {
3737 let sender = SuiAddress::random_for_testing_only();
3738 let fss = random_object_ref();
3739 let pt = merge_and_redeem_fss_pt(sender, vec![fss], &RedeemPlan::All).expect("pt");
3740 let ops = parse_pt(sender, pt);
3741 let Some(OperationMetadata::MergeAndRedeemFungibleStakedSui { validator, .. }) =
3742 ops[0].metadata.clone()
3743 else {
3744 panic!();
3745 };
3746 assert!(validator.is_none());
3747 }
3748
3749 fn build_redeem_ptb_with_type_arg(
3754 sender: SuiAddress,
3755 fss: ObjectRef,
3756 coin_type_arg: &str,
3757 ) -> ProgrammableTransaction {
3758 let mut builder = ProgrammableTransactionBuilder::new();
3759 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3760 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3761 let balance = builder.command(NativeCommand::move_call(
3762 SUI_SYSTEM_PACKAGE_ID,
3763 Identifier::new("sui_system").unwrap(),
3764 Identifier::new("redeem_fungible_staked_sui").unwrap(),
3765 vec![],
3766 vec![sys, fss_arg],
3767 ));
3768 let coin = builder.command(NativeCommand::move_call(
3769 SUI_FRAMEWORK_PACKAGE_ID,
3770 Identifier::new("coin").unwrap(),
3771 Identifier::new("from_balance").unwrap(),
3772 vec![sui_types::TypeTag::from_str(coin_type_arg).unwrap()],
3773 vec![balance],
3774 ));
3775 let sender_arg = builder.pure(sender).unwrap();
3776 builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
3777 builder.finish()
3778 }
3779
3780 #[test]
3781 fn test_parse_falls_through_redeem_wrong_type_arg() {
3782 let sender = SuiAddress::random_for_testing_only();
3783 let fss = random_object_ref();
3784 let pt = build_redeem_ptb_with_type_arg(sender, fss, "0x2::coin::Coin");
3786 let ops = parse_pt(sender, pt);
3787 assert_falls_through_to_generic(&ops);
3788 }
3789
3790 #[test]
3791 fn test_parse_falls_through_redeem_without_from_balance() {
3792 let sender = SuiAddress::random_for_testing_only();
3793 let fss = random_object_ref();
3794 let mut builder = ProgrammableTransactionBuilder::new();
3796 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3797 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3798 let balance = builder.command(NativeCommand::move_call(
3799 SUI_SYSTEM_PACKAGE_ID,
3800 Identifier::new("sui_system").unwrap(),
3801 Identifier::new("redeem_fungible_staked_sui").unwrap(),
3802 vec![],
3803 vec![sys, fss_arg],
3804 ));
3805 let sender_arg = builder.pure(sender).unwrap();
3806 builder.command(NativeCommand::TransferObjects(vec![balance], sender_arg));
3807 let ops = parse_pt(sender, builder.finish());
3808 assert_falls_through_to_generic(&ops);
3809 }
3810
3811 #[test]
3812 fn test_parse_falls_through_redeem_without_transfer() {
3813 let sender = SuiAddress::random_for_testing_only();
3814 let fss = random_object_ref();
3815 let mut builder = ProgrammableTransactionBuilder::new();
3816 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3817 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3818 let balance = builder.command(NativeCommand::move_call(
3819 SUI_SYSTEM_PACKAGE_ID,
3820 Identifier::new("sui_system").unwrap(),
3821 Identifier::new("redeem_fungible_staked_sui").unwrap(),
3822 vec![],
3823 vec![sys, fss_arg],
3824 ));
3825 builder.command(NativeCommand::move_call(
3826 SUI_FRAMEWORK_PACKAGE_ID,
3827 Identifier::new("coin").unwrap(),
3828 Identifier::new("from_balance").unwrap(),
3829 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
3830 vec![balance],
3831 ));
3832 let ops = parse_pt(sender, builder.finish());
3834 assert_falls_through_to_generic(&ops);
3835 }
3836
3837 #[test]
3838 fn test_parse_falls_through_redeem_transfer_wrong_recipient() {
3839 let sender = SuiAddress::random_for_testing_only();
3840 let other = SuiAddress::random_for_testing_only();
3841 let fss = random_object_ref();
3842 let mut builder = ProgrammableTransactionBuilder::new();
3843 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3844 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3845 let balance = builder.command(NativeCommand::move_call(
3846 SUI_SYSTEM_PACKAGE_ID,
3847 Identifier::new("sui_system").unwrap(),
3848 Identifier::new("redeem_fungible_staked_sui").unwrap(),
3849 vec![],
3850 vec![sys, fss_arg],
3851 ));
3852 let coin = builder.command(NativeCommand::move_call(
3853 SUI_FRAMEWORK_PACKAGE_ID,
3854 Identifier::new("coin").unwrap(),
3855 Identifier::new("from_balance").unwrap(),
3856 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
3857 vec![balance],
3858 ));
3859 let other_arg = builder.pure(other).unwrap();
3861 builder.command(NativeCommand::TransferObjects(vec![coin], other_arg));
3862 let ops = parse_pt(sender, builder.finish());
3863 assert_falls_through_to_generic(&ops);
3864 }
3865
3866 #[test]
3867 fn test_parse_falls_through_redeem_transfer_multiple_objects() {
3868 let sender = SuiAddress::random_for_testing_only();
3869 let fss = random_object_ref();
3870 let other_obj = random_object_ref();
3871 let mut builder = ProgrammableTransactionBuilder::new();
3872 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3873 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3874 let balance = builder.command(NativeCommand::move_call(
3875 SUI_SYSTEM_PACKAGE_ID,
3876 Identifier::new("sui_system").unwrap(),
3877 Identifier::new("redeem_fungible_staked_sui").unwrap(),
3878 vec![],
3879 vec![sys, fss_arg],
3880 ));
3881 let coin = builder.command(NativeCommand::move_call(
3882 SUI_FRAMEWORK_PACKAGE_ID,
3883 Identifier::new("coin").unwrap(),
3884 Identifier::new("from_balance").unwrap(),
3885 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
3886 vec![balance],
3887 ));
3888 let extra = builder.obj(ObjectArg::ImmOrOwnedObject(other_obj)).unwrap();
3890 let sender_arg = builder.pure(sender).unwrap();
3891 builder.command(NativeCommand::TransferObjects(
3892 vec![coin, extra],
3893 sender_arg,
3894 ));
3895 let ops = parse_pt(sender, builder.finish());
3896 assert_falls_through_to_generic(&ops);
3897 }
3898
3899 #[test]
3900 fn test_parse_falls_through_hybrid_convert_and_redeem() {
3901 let sender = SuiAddress::random_for_testing_only();
3905 let staked = random_object_ref();
3906 let fss = random_object_ref();
3907 let mut builder = ProgrammableTransactionBuilder::new();
3908 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3909 let staked_arg = builder.obj(ObjectArg::ImmOrOwnedObject(staked)).unwrap();
3910 let _new_fss = builder.command(NativeCommand::move_call(
3911 SUI_SYSTEM_PACKAGE_ID,
3912 Identifier::new("sui_system").unwrap(),
3913 Identifier::new("convert_to_fungible_staked_sui").unwrap(),
3914 vec![],
3915 vec![sys, staked_arg],
3916 ));
3917 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3918 let balance = builder.command(NativeCommand::move_call(
3919 SUI_SYSTEM_PACKAGE_ID,
3920 Identifier::new("sui_system").unwrap(),
3921 Identifier::new("redeem_fungible_staked_sui").unwrap(),
3922 vec![],
3923 vec![sys, fss_arg],
3924 ));
3925 let coin = builder.command(NativeCommand::move_call(
3926 SUI_FRAMEWORK_PACKAGE_ID,
3927 Identifier::new("coin").unwrap(),
3928 Identifier::new("from_balance").unwrap(),
3929 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
3930 vec![balance],
3931 ));
3932 let sender_arg = builder.pure(sender).unwrap();
3933 builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
3934 let ops = parse_pt(sender, builder.finish());
3935 assert_falls_through_to_generic(&ops);
3936 }
3937
3938 #[test]
3939 fn test_parse_falls_through_split_without_redeem() {
3940 let sender = SuiAddress::random_for_testing_only();
3941 let fss = random_object_ref();
3942 let mut builder = ProgrammableTransactionBuilder::new();
3943 let _sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3944 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
3945 let split_amount = builder.pure(100u64).unwrap();
3946 builder.command(NativeCommand::move_call(
3947 SUI_SYSTEM_PACKAGE_ID,
3948 Identifier::new("staking_pool").unwrap(),
3949 Identifier::new("split_fungible_staked_sui").unwrap(),
3950 vec![],
3951 vec![fss_arg, split_amount],
3952 ));
3953 let ops = parse_pt(sender, builder.finish());
3955 assert_falls_through_to_generic(&ops);
3956 }
3957
3958 #[test]
3959 fn test_parse_falls_through_redeem_split_position_wrong() {
3960 let sender = SuiAddress::random_for_testing_only();
3962 let fss_a = random_object_ref();
3963 let fss_b = random_object_ref();
3964 let mut builder = ProgrammableTransactionBuilder::new();
3965 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
3966 let a_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss_a)).unwrap();
3967 let b_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss_b)).unwrap();
3968 let balance = builder.command(NativeCommand::move_call(
3969 SUI_SYSTEM_PACKAGE_ID,
3970 Identifier::new("sui_system").unwrap(),
3971 Identifier::new("redeem_fungible_staked_sui").unwrap(),
3972 vec![],
3973 vec![sys, a_arg],
3974 ));
3975 let split_amount = builder.pure(100u64).unwrap();
3977 builder.command(NativeCommand::move_call(
3978 SUI_SYSTEM_PACKAGE_ID,
3979 Identifier::new("staking_pool").unwrap(),
3980 Identifier::new("split_fungible_staked_sui").unwrap(),
3981 vec![],
3982 vec![b_arg, split_amount],
3983 ));
3984 let coin = builder.command(NativeCommand::move_call(
3985 SUI_FRAMEWORK_PACKAGE_ID,
3986 Identifier::new("coin").unwrap(),
3987 Identifier::new("from_balance").unwrap(),
3988 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
3989 vec![balance],
3990 ));
3991 let sender_arg = builder.pure(sender).unwrap();
3992 builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
3993 let ops = parse_pt(sender, builder.finish());
3994 assert_falls_through_to_generic(&ops);
3995 }
3996
3997 #[test]
3998 fn test_parse_falls_through_redeem_wrong_system_state_immutable() {
3999 let sender = SuiAddress::random_for_testing_only();
4005 let fss = random_object_ref();
4006 let mut builder = ProgrammableTransactionBuilder::new();
4007 let _sys = builder
4009 .obj(ObjectArg::SharedObject {
4010 id: SUI_SYSTEM_STATE_OBJECT_ID,
4011 initial_shared_version: sui_types::SUI_SYSTEM_STATE_OBJECT_SHARED_VERSION,
4012 mutability: sui_types::transaction::SharedObjectMutability::Immutable,
4013 })
4014 .unwrap();
4015 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
4016 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
4019 let balance = builder.command(NativeCommand::move_call(
4020 SUI_SYSTEM_PACKAGE_ID,
4021 Identifier::new("sui_system").unwrap(),
4022 Identifier::new("redeem_fungible_staked_sui").unwrap(),
4023 vec![],
4024 vec![sys, fss_arg],
4025 ));
4026 let coin = builder.command(NativeCommand::move_call(
4027 SUI_FRAMEWORK_PACKAGE_ID,
4028 Identifier::new("coin").unwrap(),
4029 Identifier::new("from_balance").unwrap(),
4030 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4031 vec![balance],
4032 ));
4033 let sender_arg = builder.pure(sender).unwrap();
4034 builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
4035 let ops = parse_pt(sender, builder.finish());
4046 assert!(
4049 ops[0].type_ == OperationType::MergeAndRedeemFungibleStakedSui
4050 || ops[0].type_ == OperationType::ProgrammableTransaction,
4051 "unexpected op type: {:?}",
4052 ops[0].type_
4053 );
4054 }
4055
4056 #[test]
4064 fn test_parse_falls_through_convert_without_transfer() {
4065 let sender = SuiAddress::random_for_testing_only();
4066 let staked = random_object_ref();
4067 let mut builder = ProgrammableTransactionBuilder::new();
4068 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
4069 let staked_arg = builder.obj(ObjectArg::ImmOrOwnedObject(staked)).unwrap();
4070 let _new_fss = builder.command(NativeCommand::move_call(
4071 SUI_SYSTEM_PACKAGE_ID,
4072 Identifier::new("sui_system").unwrap(),
4073 Identifier::new("convert_to_fungible_staked_sui").unwrap(),
4074 vec![],
4075 vec![sys, staked_arg],
4076 ));
4077 let ops = parse_pt(sender, builder.finish());
4079 assert_falls_through_to_generic(&ops);
4080 }
4081
4082 #[test]
4086 fn test_parse_falls_through_pure_merge_with_transfer() {
4087 let sender = SuiAddress::random_for_testing_only();
4088 let fss_a = random_object_ref();
4089 let fss_b = random_object_ref();
4090 let mut builder = ProgrammableTransactionBuilder::new();
4091 let _sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
4092 let first = builder.obj(ObjectArg::ImmOrOwnedObject(fss_a)).unwrap();
4093 let other = builder.obj(ObjectArg::ImmOrOwnedObject(fss_b)).unwrap();
4094 let join_result = builder.command(NativeCommand::move_call(
4095 SUI_SYSTEM_PACKAGE_ID,
4096 Identifier::new("staking_pool").unwrap(),
4097 Identifier::new("join_fungible_staked_sui").unwrap(),
4098 vec![],
4099 vec![first, other],
4100 ));
4101 let sender_arg = builder.pure(sender).unwrap();
4103 builder.command(NativeCommand::TransferObjects(
4104 vec![join_result],
4105 sender_arg,
4106 ));
4107 let ops = parse_pt(sender, builder.finish());
4108 assert_falls_through_to_generic(&ops);
4109 }
4110
4111 #[test]
4114 fn test_parse_falls_through_split_amount_not_pure() {
4115 let sender = SuiAddress::random_for_testing_only();
4116 let fss = random_object_ref();
4117 let bogus_obj = random_object_ref();
4118 let mut builder = ProgrammableTransactionBuilder::new();
4119 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
4120 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
4121 let bogus_arg = builder.obj(ObjectArg::ImmOrOwnedObject(bogus_obj)).unwrap();
4123 let split_result = builder.command(NativeCommand::move_call(
4124 SUI_SYSTEM_PACKAGE_ID,
4125 Identifier::new("staking_pool").unwrap(),
4126 Identifier::new("split_fungible_staked_sui").unwrap(),
4127 vec![],
4128 vec![fss_arg, bogus_arg],
4129 ));
4130 let balance = builder.command(NativeCommand::move_call(
4131 SUI_SYSTEM_PACKAGE_ID,
4132 Identifier::new("sui_system").unwrap(),
4133 Identifier::new("redeem_fungible_staked_sui").unwrap(),
4134 vec![],
4135 vec![sys, split_result],
4136 ));
4137 let coin = builder.command(NativeCommand::move_call(
4138 SUI_FRAMEWORK_PACKAGE_ID,
4139 Identifier::new("coin").unwrap(),
4140 Identifier::new("from_balance").unwrap(),
4141 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4142 vec![balance],
4143 ));
4144 let sender_arg = builder.pure(sender).unwrap();
4145 builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
4146 let ops = parse_pt(sender, builder.finish());
4147 assert_falls_through_to_generic(&ops);
4148 }
4149
4150 #[test]
4154 fn test_parse_falls_through_convert_wrong_system_state_arg() {
4155 let sender = SuiAddress::random_for_testing_only();
4156 let staked = random_object_ref();
4157 let mut builder = ProgrammableTransactionBuilder::new();
4158 let _sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
4160 let bogus_arg = builder.pure(0u64).unwrap();
4163 let staked_arg = builder.obj(ObjectArg::ImmOrOwnedObject(staked)).unwrap();
4164 let new_fss = builder.command(NativeCommand::move_call(
4165 SUI_SYSTEM_PACKAGE_ID,
4166 Identifier::new("sui_system").unwrap(),
4167 Identifier::new("convert_to_fungible_staked_sui").unwrap(),
4168 vec![],
4169 vec![bogus_arg, staked_arg],
4171 ));
4172 let sender_arg = builder.pure(sender).unwrap();
4173 builder.command(NativeCommand::TransferObjects(vec![new_fss], sender_arg));
4174 let ops = parse_pt(sender, builder.finish());
4175 assert_falls_through_to_generic(&ops);
4176 }
4177
4178 #[test]
4183 fn test_parse_falls_through_consolidate_same_input_both_convert_and_join() {
4184 let sender = SuiAddress::random_for_testing_only();
4185 let shared_input = random_object_ref();
4186 let other_fss = random_object_ref();
4187 let mut builder = ProgrammableTransactionBuilder::new();
4188 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
4189 let dual = builder
4191 .obj(ObjectArg::ImmOrOwnedObject(shared_input))
4192 .unwrap();
4193 let fss_b = builder.obj(ObjectArg::ImmOrOwnedObject(other_fss)).unwrap();
4194 builder.command(NativeCommand::move_call(
4196 SUI_SYSTEM_PACKAGE_ID,
4197 Identifier::new("staking_pool").unwrap(),
4198 Identifier::new("join_fungible_staked_sui").unwrap(),
4199 vec![],
4200 vec![dual, fss_b],
4201 ));
4202 let new_fss = builder.command(NativeCommand::move_call(
4204 SUI_SYSTEM_PACKAGE_ID,
4205 Identifier::new("sui_system").unwrap(),
4206 Identifier::new("convert_to_fungible_staked_sui").unwrap(),
4207 vec![],
4208 vec![sys, dual],
4209 ));
4210 let sender_arg = builder.pure(sender).unwrap();
4211 builder.command(NativeCommand::TransferObjects(vec![new_fss], sender_arg));
4212 let ops = parse_pt(sender, builder.finish());
4213 assert_falls_through_to_generic(&ops);
4214 }
4215
4216 fn build_malformed_atleast_ptb(
4238 sender: SuiAddress,
4239 fss: ObjectRef,
4240 wire_split_to_redeem: bool,
4241 wire_join_to_redeem: bool,
4242 wire_join_arg1_to_split: bool,
4243 wire_from_balance_to_redeem: bool,
4244 ) -> ProgrammableTransaction {
4245 use sui_types::transaction::Argument;
4246 let mut builder = ProgrammableTransactionBuilder::new();
4247 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
4248 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
4249 let split_amt = builder.pure(100u64).unwrap();
4250 let split_fss = builder.command(NativeCommand::move_call(
4252 SUI_SYSTEM_PACKAGE_ID,
4253 Identifier::new("staking_pool").unwrap(),
4254 Identifier::new("split_fungible_staked_sui").unwrap(),
4255 vec![],
4256 vec![fss_arg, split_amt],
4257 ));
4258 let redeem_balance = builder.command(NativeCommand::move_call(
4259 SUI_SYSTEM_PACKAGE_ID,
4260 Identifier::new("sui_system").unwrap(),
4261 Identifier::new("redeem_fungible_staked_sui").unwrap(),
4262 vec![],
4263 vec![sys, split_fss],
4264 ));
4265 let zero_balance = builder.command(NativeCommand::move_call(
4268 SUI_FRAMEWORK_PACKAGE_ID,
4269 Identifier::new("balance").unwrap(),
4270 Identifier::new("zero").unwrap(),
4271 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4272 vec![],
4273 ));
4274 let min_arg = builder.pure(0u64).unwrap();
4275 let split_arg0 = if wire_split_to_redeem {
4276 redeem_balance
4277 } else {
4278 zero_balance
4279 };
4280 let split_result = builder.command(NativeCommand::move_call(
4281 SUI_FRAMEWORK_PACKAGE_ID,
4282 Identifier::new("balance").unwrap(),
4283 Identifier::new("split").unwrap(),
4284 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4285 vec![split_arg0, min_arg],
4286 ));
4287 let join_arg0 = if wire_join_to_redeem {
4288 redeem_balance
4289 } else {
4290 zero_balance
4291 };
4292 let join_arg1 = if wire_join_arg1_to_split {
4293 split_result
4294 } else {
4295 builder.command(NativeCommand::move_call(
4298 SUI_FRAMEWORK_PACKAGE_ID,
4299 Identifier::new("balance").unwrap(),
4300 Identifier::new("zero").unwrap(),
4301 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4302 vec![],
4303 ))
4304 };
4305 builder.command(NativeCommand::move_call(
4306 SUI_FRAMEWORK_PACKAGE_ID,
4307 Identifier::new("balance").unwrap(),
4308 Identifier::new("join").unwrap(),
4309 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4310 vec![join_arg0, join_arg1],
4311 ));
4312 let from_balance_arg = if wire_from_balance_to_redeem {
4313 redeem_balance
4314 } else {
4315 zero_balance
4316 };
4317 let coin = builder.command(NativeCommand::move_call(
4318 SUI_FRAMEWORK_PACKAGE_ID,
4319 Identifier::new("coin").unwrap(),
4320 Identifier::new("from_balance").unwrap(),
4321 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4322 vec![from_balance_arg],
4323 ));
4324 let sender_arg = builder.pure(sender).unwrap();
4325 builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
4326 let _ = Argument::GasCoin; builder.finish()
4328 }
4329
4330 #[test]
4331 fn test_parse_falls_through_atleast_split_arg_not_redeem_result() {
4332 let sender = SuiAddress::random_for_testing_only();
4333 let fss = random_object_ref();
4334 let pt = build_malformed_atleast_ptb(sender, fss, false, true, true, true);
4336 assert_falls_through_to_generic(&parse_pt(sender, pt));
4337 }
4338
4339 #[test]
4340 fn test_parse_falls_through_atleast_join_arg0_not_redeem_result() {
4341 let sender = SuiAddress::random_for_testing_only();
4342 let fss = random_object_ref();
4343 let pt = build_malformed_atleast_ptb(sender, fss, true, false, true, true);
4345 assert_falls_through_to_generic(&parse_pt(sender, pt));
4346 }
4347
4348 #[test]
4349 fn test_parse_falls_through_atleast_join_arg1_not_split_result() {
4350 let sender = SuiAddress::random_for_testing_only();
4351 let fss = random_object_ref();
4352 let pt = build_malformed_atleast_ptb(sender, fss, true, true, false, true);
4354 assert_falls_through_to_generic(&parse_pt(sender, pt));
4355 }
4356
4357 #[test]
4358 fn test_parse_falls_through_atleast_from_balance_arg_not_redeem_result() {
4359 let sender = SuiAddress::random_for_testing_only();
4360 let fss = random_object_ref();
4361 let pt = build_malformed_atleast_ptb(sender, fss, true, true, true, false);
4363 assert_falls_through_to_generic(&parse_pt(sender, pt));
4364 }
4365
4366 #[test]
4372 fn test_parse_falls_through_atleast_split_arg_is_nested_result() {
4373 use sui_types::transaction::Argument;
4374 let sender = SuiAddress::random_for_testing_only();
4375 let fss = random_object_ref();
4376 let mut builder = ProgrammableTransactionBuilder::new();
4377 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
4378 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
4379 let split_amt = builder.pure(100u64).unwrap();
4380 let split_fss = builder.command(NativeCommand::move_call(
4381 SUI_SYSTEM_PACKAGE_ID,
4382 Identifier::new("staking_pool").unwrap(),
4383 Identifier::new("split_fungible_staked_sui").unwrap(),
4384 vec![],
4385 vec![fss_arg, split_amt],
4386 ));
4387 let _redeem = builder.command(NativeCommand::move_call(
4388 SUI_SYSTEM_PACKAGE_ID,
4389 Identifier::new("sui_system").unwrap(),
4390 Identifier::new("redeem_fungible_staked_sui").unwrap(),
4391 vec![],
4392 vec![sys, split_fss],
4393 ));
4394 let nested = Argument::NestedResult(1, 0);
4398 let min_arg = builder.pure(0u64).unwrap();
4399 let split_balance = builder.command(NativeCommand::move_call(
4400 SUI_FRAMEWORK_PACKAGE_ID,
4401 Identifier::new("balance").unwrap(),
4402 Identifier::new("split").unwrap(),
4403 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4404 vec![nested, min_arg],
4405 ));
4406 builder.command(NativeCommand::move_call(
4407 SUI_FRAMEWORK_PACKAGE_ID,
4408 Identifier::new("balance").unwrap(),
4409 Identifier::new("join").unwrap(),
4410 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4411 vec![nested, split_balance],
4412 ));
4413 let coin = builder.command(NativeCommand::move_call(
4414 SUI_FRAMEWORK_PACKAGE_ID,
4415 Identifier::new("coin").unwrap(),
4416 Identifier::new("from_balance").unwrap(),
4417 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4418 vec![nested],
4419 ));
4420 let sender_arg = builder.pure(sender).unwrap();
4421 builder.command(NativeCommand::TransferObjects(vec![coin], sender_arg));
4422 assert_falls_through_to_generic(&parse_pt(sender, builder.finish()));
4423 }
4424
4425 #[test]
4429 fn test_parse_falls_through_transfer_not_from_balance_result() {
4430 let sender = SuiAddress::random_for_testing_only();
4431 let fss = random_object_ref();
4432 let mut builder = ProgrammableTransactionBuilder::new();
4433 let sys = builder.input(CallArg::SUI_SYSTEM_MUT).unwrap();
4434 let fss_arg = builder.obj(ObjectArg::ImmOrOwnedObject(fss)).unwrap();
4435 let redeem = builder.command(NativeCommand::move_call(
4436 SUI_SYSTEM_PACKAGE_ID,
4437 Identifier::new("sui_system").unwrap(),
4438 Identifier::new("redeem_fungible_staked_sui").unwrap(),
4439 vec![],
4440 vec![sys, fss_arg],
4441 ));
4442 let _from_balance = builder.command(NativeCommand::move_call(
4443 SUI_FRAMEWORK_PACKAGE_ID,
4444 Identifier::new("coin").unwrap(),
4445 Identifier::new("from_balance").unwrap(),
4446 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4447 vec![redeem],
4448 ));
4449 let other_coin = builder.command(NativeCommand::move_call(
4453 SUI_FRAMEWORK_PACKAGE_ID,
4454 Identifier::new("coin").unwrap(),
4455 Identifier::new("zero").unwrap(),
4456 vec![sui_types::TypeTag::from_str("0x2::sui::SUI").unwrap()],
4457 vec![],
4458 ));
4459 let sender_arg = builder.pure(sender).unwrap();
4460 builder.command(NativeCommand::TransferObjects(vec![other_coin], sender_arg));
4461 assert_falls_through_to_generic(&parse_pt(sender, builder.finish()));
4462 }
4463
4464 #[test]
4469 fn test_meta_merge_redeem_old_input_all() {
4470 let v = SuiAddress::random_for_testing_only();
4471 let json = serde_json::json!({
4472 "MergeAndRedeemFungibleStakedSui": {
4473 "validator": v.to_string(),
4474 "redeem_mode": "All"
4475 }
4476 });
4477 let meta: OperationMetadata = serde_json::from_value(json).unwrap();
4478 match meta {
4479 OperationMetadata::MergeAndRedeemFungibleStakedSui {
4480 validator,
4481 amount,
4482 redeem_mode,
4483 fss_ids,
4484 } => {
4485 assert_eq!(validator, Some(v));
4486 assert!(amount.is_none());
4487 assert_eq!(redeem_mode, Some(RedeemMode::All));
4488 assert!(fss_ids.is_empty());
4489 }
4490 _ => panic!("wrong variant"),
4491 }
4492 }
4493
4494 #[test]
4495 fn test_meta_merge_redeem_old_input_atleast() {
4496 let v = SuiAddress::random_for_testing_only();
4497 let json = serde_json::json!({
4498 "MergeAndRedeemFungibleStakedSui": {
4499 "validator": v.to_string(),
4500 "amount": "500000000000",
4501 "redeem_mode": "AtLeast"
4502 }
4503 });
4504 let meta: OperationMetadata = serde_json::from_value(json).unwrap();
4505 match meta {
4506 OperationMetadata::MergeAndRedeemFungibleStakedSui {
4507 validator,
4508 amount,
4509 redeem_mode,
4510 fss_ids,
4511 } => {
4512 assert_eq!(validator, Some(v));
4513 assert_eq!(amount, Some("500000000000".to_string()));
4514 assert_eq!(redeem_mode, Some(RedeemMode::AtLeast));
4515 assert!(fss_ids.is_empty());
4516 }
4517 _ => panic!(),
4518 }
4519 }
4520
4521 #[test]
4522 fn test_meta_merge_redeem_new_parse_output() {
4523 let id = ObjectID::random();
4524 let meta = OperationMetadata::MergeAndRedeemFungibleStakedSui {
4525 validator: None,
4526 amount: None,
4527 redeem_mode: Some(RedeemMode::All),
4528 fss_ids: vec![id],
4529 };
4530 let json = serde_json::to_value(&meta).unwrap();
4531 let obj = json
4532 .as_object()
4533 .unwrap()
4534 .get("MergeAndRedeemFungibleStakedSui")
4535 .unwrap()
4536 .as_object()
4537 .unwrap();
4538 assert!(!obj.contains_key("validator"));
4539 assert!(!obj.contains_key("amount"));
4540 assert_eq!(obj.get("redeem_mode").unwrap(), "All");
4541 assert_eq!(obj.get("fss_ids").unwrap().as_array().unwrap().len(), 1);
4542 }
4543
4544 #[test]
4545 fn test_meta_merge_redeem_new_parse_output_partial() {
4546 let id = ObjectID::random();
4547 let meta = OperationMetadata::MergeAndRedeemFungibleStakedSui {
4548 validator: None,
4549 amount: None,
4550 redeem_mode: None,
4551 fss_ids: vec![id],
4552 };
4553 let json = serde_json::to_value(&meta).unwrap();
4554 let obj = json
4555 .as_object()
4556 .unwrap()
4557 .get("MergeAndRedeemFungibleStakedSui")
4558 .unwrap()
4559 .as_object()
4560 .unwrap();
4561 assert!(!obj.contains_key("validator"));
4562 assert!(!obj.contains_key("amount"));
4563 assert!(
4564 !obj.contains_key("redeem_mode"),
4565 "redeem_mode must be omitted in partial parse output"
4566 );
4567 assert_eq!(obj.get("fss_ids").unwrap().as_array().unwrap().len(), 1);
4568 }
4569
4570 #[test]
4575 fn test_write_merge_redeem_requires_validator_and_mode() {
4576 let sender = SuiAddress::random_for_testing_only();
4577
4578 let op = Operation {
4580 operation_identifier: Default::default(),
4581 type_: OperationType::MergeAndRedeemFungibleStakedSui,
4582 status: None,
4583 account: Some(sender.into()),
4584 amount: None,
4585 coin_change: None,
4586 metadata: Some(OperationMetadata::MergeAndRedeemFungibleStakedSui {
4587 validator: None,
4588 amount: None,
4589 redeem_mode: Some(RedeemMode::All),
4590 fss_ids: vec![],
4591 }),
4592 };
4593 let err = Operations::new(vec![op])
4594 .into_internal()
4595 .expect_err("should fail without validator");
4596 assert!(format!("{err}").contains("validator"));
4597
4598 let op = Operation {
4600 operation_identifier: Default::default(),
4601 type_: OperationType::MergeAndRedeemFungibleStakedSui,
4602 status: None,
4603 account: Some(sender.into()),
4604 amount: None,
4605 coin_change: None,
4606 metadata: Some(OperationMetadata::MergeAndRedeemFungibleStakedSui {
4607 validator: Some(SuiAddress::random_for_testing_only()),
4608 amount: None,
4609 redeem_mode: None,
4610 fss_ids: vec![],
4611 }),
4612 };
4613 let err = Operations::new(vec![op])
4614 .into_internal()
4615 .expect_err("should fail without redeem_mode");
4616 assert!(format!("{err}").contains("redeem_mode"));
4617 }
4618
4619 use crate::types::CurrencyMetadata;
4622 use crate::types::internal_operation::pay_coin_pt;
4623
4624 fn sample_currency() -> Currency {
4625 Currency {
4626 symbol: "USDC".to_string(),
4627 decimals: 6,
4628 metadata: CurrencyMetadata {
4629 coin_type: "0x5::usdc::USDC".to_string(),
4630 },
4631 }
4632 }
4633
4634 fn data_with_pt(sender: SuiAddress, pt: ProgrammableTransaction) -> TransactionData {
4635 let gas_price = 1000;
4636 TransactionData::new_programmable(
4637 sender,
4638 vec![random_object_ref()],
4639 pt,
4640 TEST_ONLY_GAS_UNIT_FOR_TRANSFER * gas_price,
4641 gas_price,
4642 )
4643 }
4644
4645 fn proto_clean(data: &TransactionData) -> Transaction {
4648 use crate::types::transaction_envelope::{decode_inner_proto, encode_inner_proto};
4649 decode_inner_proto(&encode_inner_proto(data)).unwrap()
4650 }
4651
4652 #[test]
4654 fn test_reconstruct_pay_coin_currency() {
4655 let sender = SuiAddress::random_for_testing_only();
4656 let recipient = SuiAddress::random_for_testing_only();
4657 let coin = random_object_ref();
4658 let currency = sample_currency();
4659 let aux = AuxData::PayCoin {
4660 currency: currency.clone(),
4661 };
4662 let pt = pay_coin_pt(
4663 sender,
4664 vec![recipient],
4665 vec![10_000],
4666 &[coin],
4667 &[],
4668 0,
4669 ¤cy,
4670 )
4671 .unwrap();
4672 let proto = proto_clean(&data_with_pt(sender, pt));
4673
4674 let ops = reconstruct_operations(&proto, &aux, None).expect("reconstruct ok");
4675 assert!(ops.0.iter().any(|op| op.type_ == OperationType::PayCoin));
4676 let recip_amount = ops
4677 .0
4678 .iter()
4679 .find(|o| o.account.as_ref().map(|a| a.address) == Some(recipient))
4680 .and_then(|o| o.amount.clone())
4681 .expect("recipient op");
4682 assert_eq!(
4683 recip_amount.currency.metadata.coin_type,
4684 currency.metadata.coin_type
4685 );
4686 }
4687
4688 #[test]
4692 fn test_reconstruct_family_mismatch_rejected() {
4693 let sender = SuiAddress::random_for_testing_only();
4694 let pay_aux = AuxData::PayCoin {
4695 currency: sample_currency(),
4696 };
4697 let pt = consolidate_to_fungible_pt(
4698 sender,
4699 vec![random_object_ref()],
4700 vec![random_object_ref()],
4701 )
4702 .unwrap();
4703 let proto = proto_clean(&data_with_pt(sender, pt));
4704 let err = reconstruct_operations(&proto, &pay_aux, None)
4705 .expect_err("family mismatch must be rejected");
4706 assert!(format!("{err:?}").contains("non-payment"));
4707 }
4708
4709 #[test]
4711 fn test_reconstruct_consolidate_validator_decorated() {
4712 let sender = SuiAddress::random_for_testing_only();
4713 let validator = SuiAddress::random_for_testing_only();
4714 let aux = AuxData::Consolidate { validator };
4715 let pt = consolidate_to_fungible_pt(
4716 sender,
4717 vec![random_object_ref()],
4718 vec![random_object_ref()],
4719 )
4720 .unwrap();
4721 let proto = proto_clean(&data_with_pt(sender, pt));
4722 let ops = reconstruct_operations(&proto, &aux, None).unwrap();
4723 let Some(OperationMetadata::ConsolidateAllStakedSuiToFungible { validator: v, .. }) =
4724 ops.0[0].metadata.clone()
4725 else {
4726 panic!("expected Consolidate metadata");
4727 };
4728 assert_eq!(v, Some(validator));
4729 }
4730
4731 #[test]
4735 fn test_reconstruct_merge_redeem_atmost_decorated() {
4736 let sender = SuiAddress::random_for_testing_only();
4737 let validator = SuiAddress::random_for_testing_only();
4738 let aux = AuxData::MergeAndRedeem {
4739 validator,
4740 redeem_mode: RedeemMode::AtMost,
4741 amount: Some(1_000_000),
4742 };
4743 let plan = RedeemPlan::AtMost {
4744 token_amount: Some(500_000_000),
4745 max_sui: 0,
4746 };
4747 let pt = merge_and_redeem_fss_pt(sender, vec![random_object_ref()], &plan).unwrap();
4748 let proto = proto_clean(&data_with_pt(sender, pt));
4749 let ops = reconstruct_operations(&proto, &aux, None).unwrap();
4750 let Some(OperationMetadata::MergeAndRedeemFungibleStakedSui {
4751 validator: v,
4752 amount,
4753 redeem_mode,
4754 ..
4755 }) = ops.0[0].metadata.clone()
4756 else {
4757 panic!("expected MergeAndRedeem metadata");
4758 };
4759 assert_eq!(v, Some(validator));
4760 assert_eq!(redeem_mode, Some(RedeemMode::AtMost));
4761 assert_eq!(amount, Some("1000000".to_string()));
4762 }
4763
4764 #[test]
4766 fn test_reconstruct_pay_sui_none_ok() {
4767 let sender = SuiAddress::random_for_testing_only();
4768 let recipient = SuiAddress::random_for_testing_only();
4769 let pt = {
4770 let mut b = ProgrammableTransactionBuilder::new();
4771 b.pay_sui(vec![recipient], vec![10_000]).unwrap();
4772 b.finish()
4773 };
4774 let proto = proto_clean(&data_with_pt(sender, pt));
4775 let ops = reconstruct_operations(&proto, &AuxData::None, None)
4776 .expect("PaySui reconstructs with no aux data");
4777 assert!(ops.0.iter().any(|op| op.type_ == OperationType::PaySui));
4778 }
4779}