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