Skip to main content

hiero_sdk/
transfer_transaction.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use std::collections::HashMap;
4use std::ops::Not;
5
6use hiero_sdk_proto::services;
7use hiero_sdk_proto::services::crypto_service_client::CryptoServiceClient;
8use tonic::transport::Channel;
9
10use crate::hooks::{
11    FungibleHookCall,
12    FungibleHookType,
13    NftHookCall,
14    NftHookType,
15};
16use crate::ledger_id::RefLedgerId;
17use crate::protobuf::FromProtobuf;
18use crate::transaction::{
19    AnyTransactionData,
20    ChunkInfo,
21    ToSchedulableTransactionDataProtobuf,
22    ToTransactionDataProtobuf,
23    TransactionData,
24    TransactionExecute,
25};
26use crate::{
27    AccountId,
28    BoxGrpcFuture,
29    Error,
30    Hbar,
31    NftId,
32    ToProtobuf,
33    TokenId,
34    TokenNftTransfer,
35    Transaction,
36    ValidateChecksums,
37};
38
39/// Transfers cryptocurrency among two or more accounts by making the desired adjustments to their
40/// balances.
41///
42/// Each transfer list can specify up to 10 adjustments. Each negative amount is withdrawn
43/// from the corresponding account (a sender), and each positive one is added to the corresponding
44/// account (a receiver). The amounts list must sum to zero.
45///
46/// All transfers are in the lowest denomination, for `Hbar` that is tinybars (although `Hbar` handles this itself).
47///
48/// As an example: for a fungible token with `3` decimals (and let's say the symbol is `ƒ`), transferring `1` _always_ transfers `0.001 ƒ`.
49pub type TransferTransaction = Transaction<TransferTransactionData>;
50
51#[derive(Debug, Clone, Default)]
52#[cfg_attr(test, derive(Eq, PartialEq))]
53pub struct TransferTransactionData {
54    transfers: Vec<Transfer>,
55    token_transfers: Vec<TokenTransfer>,
56}
57
58#[derive(Debug, Clone)]
59#[cfg_attr(test, derive(Eq, PartialEq))]
60pub(crate) struct Transfer {
61    /// The account involved in the transfer.
62    pub account_id: AccountId,
63
64    /// The value of the transfer.
65    pub amount: i64,
66
67    /// If this is an approved transfer.
68    pub is_approval: bool,
69
70    /// Optional fungible hook call for this transfer.
71    pub hook_call: Option<FungibleHookCall>,
72}
73
74#[derive(Debug, Clone)]
75#[cfg_attr(test, derive(Eq, PartialEq))]
76pub(crate) struct TokenTransfer {
77    pub token_id: TokenId,
78
79    pub transfers: Vec<Transfer>,
80
81    pub nft_transfers: Vec<TokenNftTransfer>,
82
83    pub expected_decimals: Option<u32>,
84}
85
86impl TransferTransaction {
87    fn _hbar_transfer(
88        &mut self,
89        account_id: AccountId,
90        amount: Hbar,
91        approved: bool,
92        hook_call: Option<FungibleHookCall>,
93    ) -> &mut Self {
94        self.data_mut().transfers.push(Transfer {
95            account_id,
96            amount: amount.to_tinybars(),
97            is_approval: approved,
98            hook_call,
99        });
100
101        self
102    }
103
104    /// Add a non-approved hbar transfer to the transaction.
105    pub fn hbar_transfer(&mut self, account_id: AccountId, amount: Hbar) -> &mut Self {
106        self._hbar_transfer(account_id, amount, false, None)
107    }
108
109    /// Add an approved hbar transfer to the transaction.
110    pub fn approved_hbar_transfer(&mut self, account_id: AccountId, amount: Hbar) -> &mut Self {
111        self._hbar_transfer(account_id, amount, true, None)
112    }
113
114    /// Returns all transfers associated with this transaction.
115    pub fn get_hbar_transfers(&self) -> HashMap<AccountId, Hbar> {
116        self.data()
117            .transfers
118            .iter()
119            .map(|it| (it.account_id, Hbar::from_tinybars(it.amount)))
120            .collect()
121    }
122
123    fn _token_transfer(
124        &mut self,
125        token_id: TokenId,
126        account_id: AccountId,
127        amount: i64,
128        approved: bool,
129        expected_decimals: Option<u32>,
130        hook_call: Option<FungibleHookCall>,
131    ) -> &mut Self {
132        let transfer = Transfer { account_id, amount, is_approval: approved, hook_call };
133        let data = self.data_mut();
134
135        // First, try to find an existing TokenTransfer with matching token_id
136        if let Some(tt) = data.token_transfers.iter_mut().find(|tt| tt.token_id == token_id) {
137            // Within that TokenTransfer, look for a transfer with matching account_id and is_approval
138            if let Some(existing_transfer) = tt
139                .transfers
140                .iter_mut()
141                .find(|t| t.account_id == account_id && t.is_approval == approved)
142            {
143                // Found matching transfer - add to the existing amount
144                existing_transfer.amount += amount;
145                tt.expected_decimals = expected_decimals;
146            } else {
147                // Found TokenTransfer but no matching account - push new transfer
148                tt.transfers.push(transfer.clone());
149                tt.expected_decimals = expected_decimals;
150            }
151        } else {
152            // No TokenTransfer found for this token_id - create a new one
153            data.token_transfers.push(TokenTransfer {
154                token_id,
155                expected_decimals,
156                nft_transfers: Vec::new(),
157                transfers: vec![transfer],
158            });
159        }
160
161        self
162    }
163
164    /// Add a non-approved token transfer to the transaction.
165    ///
166    /// `amount` is in the lowest denomination for the token (if the token has `2` decimals this would be `0.01` tokens).
167    pub fn token_transfer(
168        &mut self,
169        token_id: TokenId,
170        account_id: AccountId,
171        amount: i64,
172    ) -> &mut Self {
173        self._token_transfer(token_id, account_id, amount, false, None, None)
174    }
175
176    /// Add an approved token transfer to the transaction.
177    ///
178    /// `amount` is in the lowest denomination for the token (if the token has `2` decimals this would be `0.01` tokens).
179    pub fn approved_token_transfer(
180        &mut self,
181        token_id: TokenId,
182        account_id: AccountId,
183        amount: i64,
184    ) -> &mut Self {
185        self._token_transfer(token_id, account_id, amount, true, None, None)
186    }
187
188    // todo: make the examples into code, just not sure how to do that.
189    /// Add a non-approved token transfer to the transaction, ensuring that the token has `expected_decimals` decimals.
190    ///
191    /// `amount` is _still_ in the lowest denomination, however,
192    /// you will get an error if the token has a different amount of decimals than `expected_decimals`.
193    pub fn token_transfer_with_decimals(
194        &mut self,
195        token_id: TokenId,
196        account_id: AccountId,
197        amount: i64,
198        expected_decimals: u32,
199    ) -> &mut Self {
200        self._token_transfer(token_id, account_id, amount, false, Some(expected_decimals), None)
201    }
202
203    /// Add an approved token transfer, ensuring that the token has `expected_decimals` decimals.
204    ///
205    /// `amount` is _still_ in the lowest denomination, however,
206    /// you will get an error if the token has a different amount of decimals than `expected_decimals`.
207    pub fn approved_token_transfer_with_decimals(
208        &mut self,
209        token_id: TokenId,
210        account_id: AccountId,
211        amount: i64,
212        expected_decimals: u32,
213    ) -> &mut Self {
214        self._token_transfer(token_id, account_id, amount, true, Some(expected_decimals), None)
215    }
216
217    /// Returns all the token transfers associated associated with this transaction.
218    pub fn get_token_transfers(&self) -> HashMap<TokenId, HashMap<AccountId, i64>> {
219        use std::collections::hash_map::Entry;
220
221        // note: using fold instead of nested collects on the off chance a token is in here twice.
222        self.data().token_transfers.iter().fold(
223            HashMap::with_capacity(self.data().token_transfers.len()),
224            |mut map, transfer| {
225                let iter = transfer.transfers.iter().map(|it| (it.account_id, it.amount));
226                match map.entry(transfer.token_id) {
227                    Entry::Occupied(mut it) => it.get_mut().extend(iter),
228                    Entry::Vacant(it) => {
229                        it.insert(iter.collect());
230                    }
231                }
232
233                map
234            },
235        )
236    }
237
238    /// Returns the decimals associated with each token.
239    pub fn get_token_decimals(&self) -> HashMap<TokenId, u32> {
240        self.data()
241            .token_transfers
242            .iter()
243            .filter_map(|it| it.expected_decimals.map(|decimals| (it.token_id, decimals)))
244            .collect()
245    }
246
247    fn _nft_transfer(
248        &mut self,
249        nft_id: NftId,
250        sender_account_id: AccountId,
251        receiver_account_id: AccountId,
252        approved: bool,
253        sender_hook_call: Option<NftHookCall>,
254        receiver_hook_call: Option<NftHookCall>,
255    ) -> &mut Self {
256        let NftId { token_id, serial } = nft_id;
257        let transfer = TokenNftTransfer {
258            token_id,
259            serial,
260            sender: sender_account_id,
261            receiver: receiver_account_id,
262            is_approved: approved,
263            sender_hook_call,
264            receiver_hook_call,
265        };
266
267        let data = self.data_mut();
268
269        if let Some(tt) = data.token_transfers.iter_mut().find(|tt| tt.token_id == token_id) {
270            tt.nft_transfers.push(transfer);
271        } else {
272            data.token_transfers.push(TokenTransfer {
273                token_id,
274                expected_decimals: None,
275                transfers: Vec::new(),
276                nft_transfers: vec![transfer],
277            });
278        }
279
280        self
281    }
282
283    /// Add an approved nft transfer to the transaction.
284    pub fn approved_nft_transfer(
285        &mut self,
286        nft_id: impl Into<NftId>,
287        sender_account_id: AccountId,
288        receiver_account_id: AccountId,
289    ) -> &mut Self {
290        self._nft_transfer(nft_id.into(), sender_account_id, receiver_account_id, true, None, None)
291    }
292
293    /// Add a non-approved nft transfer to the transaction.
294    pub fn nft_transfer(
295        &mut self,
296        nft_id: impl Into<NftId>,
297        sender_account_id: AccountId,
298        receiver_account_id: AccountId,
299    ) -> &mut Self {
300        self._nft_transfer(nft_id.into(), sender_account_id, receiver_account_id, false, None, None)
301    }
302
303    /// Returns all the NFT transfers associated with this transaction.
304    pub fn get_nft_transfers(&self) -> HashMap<TokenId, Vec<TokenNftTransfer>> {
305        self.data()
306            .token_transfers
307            .iter()
308            .map(|it| (it.token_id, it.nft_transfers.clone()))
309            .collect()
310    }
311
312    /// Add a hbar transfer with a fungible hook call.
313    pub fn add_hbar_transfer_with_hook(
314        &mut self,
315        account_id: AccountId,
316        amount: Hbar,
317        hook_call: FungibleHookCall,
318    ) -> &mut Self {
319        self._hbar_transfer(account_id, amount, false, Some(hook_call))
320    }
321
322    /// Add a token transfer with a fungible hook call.
323    pub fn add_token_transfer_with_hook(
324        &mut self,
325        token_id: TokenId,
326        account_id: AccountId,
327        amount: i64,
328        hook_call: FungibleHookCall,
329    ) -> &mut Self {
330        self._token_transfer(token_id, account_id, amount, false, None, Some(hook_call))
331    }
332
333    /// Add an NFT transfer with optional sender and/or receiver hook calls.
334    pub fn add_nft_transfer_with_hook(
335        &mut self,
336        nft_id: impl Into<NftId>,
337        sender: AccountId,
338        receiver: AccountId,
339        sender_hook_call: Option<NftHookCall>,
340        receiver_hook_call: Option<NftHookCall>,
341    ) -> &mut Self {
342        self._nft_transfer(
343            nft_id.into(),
344            sender,
345            receiver,
346            false,
347            sender_hook_call,
348            receiver_hook_call,
349        )
350    }
351}
352
353impl TransactionExecute for TransferTransactionData {
354    // noinspection DuplicatedCode
355    fn execute(
356        &self,
357        channel: Channel,
358        request: services::Transaction,
359    ) -> BoxGrpcFuture<'_, services::TransactionResponse> {
360        Box::pin(async { CryptoServiceClient::new(channel).crypto_transfer(request).await })
361    }
362}
363
364impl TransactionData for TransferTransactionData {}
365
366impl ValidateChecksums for TransferTransactionData {
367    fn validate_checksums(&self, ledger_id: &RefLedgerId) -> Result<(), Error> {
368        for transfer in &self.transfers {
369            transfer.account_id.validate_checksums(ledger_id)?;
370        }
371        for token_transfer in &self.token_transfers {
372            token_transfer.token_id.validate_checksums(ledger_id)?;
373            for transfer in &token_transfer.transfers {
374                transfer.account_id.validate_checksums(ledger_id)?;
375            }
376            for nft_transfer in &token_transfer.nft_transfers {
377                nft_transfer.sender.validate_checksums(ledger_id)?;
378                nft_transfer.receiver.validate_checksums(ledger_id)?;
379            }
380        }
381        Ok(())
382    }
383}
384
385impl FromProtobuf<services::AccountAmount> for Transfer {
386    fn from_protobuf(pb: services::AccountAmount) -> crate::Result<Self> {
387        let hook_call = match pb.hook_call {
388            Some(services::account_amount::HookCall::PreTxAllowanceHook(hook)) => {
389                Some(FungibleHookCall::from_protobuf_with_type(
390                    hook,
391                    FungibleHookType::PreTxAllowanceHook,
392                )?)
393            }
394            Some(services::account_amount::HookCall::PrePostTxAllowanceHook(hook)) => {
395                Some(FungibleHookCall::from_protobuf_with_type(
396                    hook,
397                    FungibleHookType::PrePostTxAllowanceHook,
398                )?)
399            }
400            None => None,
401        };
402
403        Ok(Self {
404            account_id: AccountId::from_protobuf(pb_getf!(pb, account_id)?)?,
405            amount: pb.amount,
406            is_approval: pb.is_approval,
407            hook_call,
408        })
409    }
410}
411
412impl ToProtobuf for Transfer {
413    type Protobuf = services::AccountAmount;
414
415    fn to_protobuf(&self) -> Self::Protobuf {
416        let hook_call = self.hook_call.as_ref().map(|hook| match hook.hook_type {
417            FungibleHookType::PreTxAllowanceHook => {
418                services::account_amount::HookCall::PreTxAllowanceHook(hook.to_protobuf())
419            }
420            FungibleHookType::PrePostTxAllowanceHook => {
421                services::account_amount::HookCall::PrePostTxAllowanceHook(hook.to_protobuf())
422            }
423        });
424
425        services::AccountAmount {
426            account_id: Some(self.account_id.to_protobuf()),
427            amount: self.amount,
428            is_approval: self.is_approval,
429            hook_call,
430        }
431    }
432}
433
434impl FromProtobuf<services::TokenTransferList> for TokenTransfer {
435    fn from_protobuf(pb: services::TokenTransferList) -> crate::Result<Self> {
436        let token_id = TokenId::from_protobuf(pb_getf!(pb, token)?)?;
437
438        Ok(Self {
439            token_id,
440            transfers: Vec::from_protobuf(pb.transfers)?,
441            nft_transfers: pb
442                .nft_transfers
443                .into_iter()
444                .map(|pb| TokenNftTransfer::from_protobuf(pb, token_id))
445                .collect::<Result<Vec<_>, _>>()?,
446            expected_decimals: pb.expected_decimals,
447        })
448    }
449}
450
451impl ToProtobuf for TokenTransfer {
452    type Protobuf = services::TokenTransferList;
453
454    fn to_protobuf(&self) -> Self::Protobuf {
455        let transfers = self.transfers.to_protobuf();
456        let nft_transfers = self.nft_transfers.to_protobuf();
457
458        services::TokenTransferList {
459            token: Some(self.token_id.to_protobuf()),
460            transfers,
461            nft_transfers,
462            expected_decimals: self.expected_decimals,
463        }
464    }
465}
466
467impl ToProtobuf for TokenNftTransfer {
468    type Protobuf = services::NftTransfer;
469
470    fn to_protobuf(&self) -> Self::Protobuf {
471        // Serialize sender hook call to oneof union
472        let sender_allowance_hook_call = self.sender_hook_call.as_ref().map(|hook| {
473            match hook.hook_type {
474                NftHookType::PreHookSender => {
475                    services::nft_transfer::SenderAllowanceHookCall::PreTxSenderAllowanceHook(
476                        hook.to_protobuf(),
477                    )
478                }
479                NftHookType::PrePostHookSender => {
480                    services::nft_transfer::SenderAllowanceHookCall::PrePostTxSenderAllowanceHook(
481                        hook.to_protobuf(),
482                    )
483                }
484                _ => {
485                    // This shouldn't happen as sender_hook_call should only have sender types
486                    services::nft_transfer::SenderAllowanceHookCall::PreTxSenderAllowanceHook(
487                        hook.to_protobuf(),
488                    )
489                }
490            }
491        });
492
493        // Serialize receiver hook call to oneof union
494        let receiver_allowance_hook_call = self.receiver_hook_call.as_ref().map(|hook| {
495            match hook.hook_type {
496                NftHookType::PreHookReceiver => {
497                    services::nft_transfer::ReceiverAllowanceHookCall::PreTxReceiverAllowanceHook(
498                        hook.to_protobuf(),
499                    )
500                }
501                NftHookType::PrePostHookReceiver => {
502                    services::nft_transfer::ReceiverAllowanceHookCall::PrePostTxReceiverAllowanceHook(
503                        hook.to_protobuf(),
504                    )
505                }
506                _ => {
507                    // This shouldn't happen as receiver_hook_call should only have receiver types
508                    services::nft_transfer::ReceiverAllowanceHookCall::PreTxReceiverAllowanceHook(
509                        hook.to_protobuf(),
510                    )
511                }
512            }
513        });
514
515        services::NftTransfer {
516            sender_account_id: Some(self.sender.to_protobuf()),
517            receiver_account_id: Some(self.receiver.to_protobuf()),
518            serial_number: self.serial as i64,
519            is_approval: self.is_approved,
520            sender_allowance_hook_call,
521            receiver_allowance_hook_call,
522        }
523    }
524}
525
526impl ToTransactionDataProtobuf for TransferTransactionData {
527    fn to_transaction_data_protobuf(
528        &self,
529        chunk_info: &ChunkInfo,
530    ) -> services::transaction_body::Data {
531        let _ = chunk_info.assert_single_transaction();
532
533        services::transaction_body::Data::CryptoTransfer(self.to_protobuf())
534    }
535}
536
537impl ToSchedulableTransactionDataProtobuf for TransferTransactionData {
538    fn to_schedulable_transaction_data_protobuf(
539        &self,
540    ) -> services::schedulable_transaction_body::Data {
541        services::schedulable_transaction_body::Data::CryptoTransfer(self.to_protobuf())
542    }
543}
544
545impl From<TransferTransactionData> for AnyTransactionData {
546    fn from(transaction: TransferTransactionData) -> Self {
547        Self::Transfer(transaction)
548    }
549}
550
551impl FromProtobuf<services::CryptoTransferTransactionBody> for TransferTransactionData {
552    fn from_protobuf(pb: services::CryptoTransferTransactionBody) -> crate::Result<Self> {
553        let transfers = pb.transfers.map(|it| it.account_amounts);
554        let transfers = Option::from_protobuf(transfers)?.unwrap_or_default();
555
556        Ok(Self { transfers, token_transfers: Vec::from_protobuf(pb.token_transfers)? })
557    }
558}
559
560impl ToProtobuf for TransferTransactionData {
561    type Protobuf = services::CryptoTransferTransactionBody;
562
563    fn to_protobuf(&self) -> Self::Protobuf {
564        let transfers = self
565            .transfers
566            .is_empty()
567            .not()
568            .then(|| services::TransferList { account_amounts: self.transfers.to_protobuf() });
569
570        let token_transfers = self.token_transfers.to_protobuf();
571
572        services::CryptoTransferTransactionBody { transfers, token_transfers }
573    }
574}
575
576#[cfg(test)]
577mod tests {
578    use expect_test::expect;
579
580    use crate::transaction::test_helpers::{
581        check_body,
582        transaction_body,
583    };
584    use crate::{
585        AccountId,
586        AnyTransaction,
587        Hbar,
588        TokenId,
589        TransferTransaction,
590    };
591
592    fn make_transaction() -> TransferTransaction {
593        let mut tx = TransferTransaction::new_for_tests();
594
595        tx.hbar_transfer(AccountId::new(0, 0, 5008), Hbar::from_tinybars(400))
596            .hbar_transfer(AccountId::new(0, 0, 5006), Hbar::from_tinybars(800).negated())
597            .approved_hbar_transfer(AccountId::new(0, 0, 5007), Hbar::from_tinybars(400))
598            .token_transfer(TokenId::new(0, 0, 5), AccountId::new(0, 0, 5008), 400)
599            .token_transfer_with_decimals(
600                TokenId::new(0, 0, 5),
601                AccountId::new(0, 0, 5006),
602                -800,
603                3,
604            )
605            .token_transfer_with_decimals(TokenId::new(0, 0, 5), AccountId::new(0, 0, 5007), 400, 3)
606            .token_transfer(TokenId::new(0, 0, 4), AccountId::new(0, 0, 5008), 1)
607            .approved_token_transfer(TokenId::new(0, 0, 4), AccountId::new(0, 0, 5006), -1)
608            .nft_transfer(
609                TokenId::new(0, 0, 3).nft(2),
610                AccountId::new(0, 0, 5008),
611                AccountId::new(0, 0, 5007),
612            )
613            .approved_nft_transfer(
614                TokenId::new(0, 0, 3).nft(1),
615                AccountId::new(0, 0, 5008),
616                AccountId::new(0, 0, 5007),
617            )
618            .nft_transfer(
619                TokenId::new(0, 0, 3).nft(3),
620                AccountId::new(0, 0, 5008),
621                AccountId::new(0, 0, 5006),
622            )
623            .nft_transfer(
624                TokenId::new(0, 0, 3).nft(4),
625                AccountId::new(0, 0, 5007),
626                AccountId::new(0, 0, 5006),
627            )
628            .nft_transfer(
629                TokenId::new(0, 0, 2).nft(4),
630                AccountId::new(0, 0, 5007),
631                AccountId::new(0, 0, 5006),
632            )
633            .freeze()
634            .unwrap();
635
636        tx
637    }
638
639    #[test]
640    fn serialize() {
641        let tx = make_transaction();
642
643        let tx = transaction_body(tx);
644
645        let tx = check_body(tx);
646
647        expect![[r#"
648            CryptoTransfer(
649                CryptoTransferTransactionBody {
650                    transfers: Some(
651                        TransferList {
652                            account_amounts: [
653                                AccountAmount {
654                                    account_id: Some(
655                                        AccountId {
656                                            shard_num: 0,
657                                            realm_num: 0,
658                                            account: Some(
659                                                AccountNum(
660                                                    5008,
661                                                ),
662                                            ),
663                                        },
664                                    ),
665                                    amount: 400,
666                                    is_approval: false,
667                                    hook_call: None,
668                                },
669                                AccountAmount {
670                                    account_id: Some(
671                                        AccountId {
672                                            shard_num: 0,
673                                            realm_num: 0,
674                                            account: Some(
675                                                AccountNum(
676                                                    5006,
677                                                ),
678                                            ),
679                                        },
680                                    ),
681                                    amount: -800,
682                                    is_approval: false,
683                                    hook_call: None,
684                                },
685                                AccountAmount {
686                                    account_id: Some(
687                                        AccountId {
688                                            shard_num: 0,
689                                            realm_num: 0,
690                                            account: Some(
691                                                AccountNum(
692                                                    5007,
693                                                ),
694                                            ),
695                                        },
696                                    ),
697                                    amount: 400,
698                                    is_approval: true,
699                                    hook_call: None,
700                                },
701                            ],
702                        },
703                    ),
704                    token_transfers: [
705                        TokenTransferList {
706                            token: Some(
707                                TokenId {
708                                    shard_num: 0,
709                                    realm_num: 0,
710                                    token_num: 5,
711                                },
712                            ),
713                            transfers: [
714                                AccountAmount {
715                                    account_id: Some(
716                                        AccountId {
717                                            shard_num: 0,
718                                            realm_num: 0,
719                                            account: Some(
720                                                AccountNum(
721                                                    5008,
722                                                ),
723                                            ),
724                                        },
725                                    ),
726                                    amount: 400,
727                                    is_approval: false,
728                                    hook_call: None,
729                                },
730                                AccountAmount {
731                                    account_id: Some(
732                                        AccountId {
733                                            shard_num: 0,
734                                            realm_num: 0,
735                                            account: Some(
736                                                AccountNum(
737                                                    5006,
738                                                ),
739                                            ),
740                                        },
741                                    ),
742                                    amount: -800,
743                                    is_approval: false,
744                                    hook_call: None,
745                                },
746                                AccountAmount {
747                                    account_id: Some(
748                                        AccountId {
749                                            shard_num: 0,
750                                            realm_num: 0,
751                                            account: Some(
752                                                AccountNum(
753                                                    5007,
754                                                ),
755                                            ),
756                                        },
757                                    ),
758                                    amount: 400,
759                                    is_approval: false,
760                                    hook_call: None,
761                                },
762                            ],
763                            nft_transfers: [],
764                            expected_decimals: Some(
765                                3,
766                            ),
767                        },
768                        TokenTransferList {
769                            token: Some(
770                                TokenId {
771                                    shard_num: 0,
772                                    realm_num: 0,
773                                    token_num: 4,
774                                },
775                            ),
776                            transfers: [
777                                AccountAmount {
778                                    account_id: Some(
779                                        AccountId {
780                                            shard_num: 0,
781                                            realm_num: 0,
782                                            account: Some(
783                                                AccountNum(
784                                                    5008,
785                                                ),
786                                            ),
787                                        },
788                                    ),
789                                    amount: 1,
790                                    is_approval: false,
791                                    hook_call: None,
792                                },
793                                AccountAmount {
794                                    account_id: Some(
795                                        AccountId {
796                                            shard_num: 0,
797                                            realm_num: 0,
798                                            account: Some(
799                                                AccountNum(
800                                                    5006,
801                                                ),
802                                            ),
803                                        },
804                                    ),
805                                    amount: -1,
806                                    is_approval: true,
807                                    hook_call: None,
808                                },
809                            ],
810                            nft_transfers: [],
811                            expected_decimals: None,
812                        },
813                        TokenTransferList {
814                            token: Some(
815                                TokenId {
816                                    shard_num: 0,
817                                    realm_num: 0,
818                                    token_num: 3,
819                                },
820                            ),
821                            transfers: [],
822                            nft_transfers: [
823                                NftTransfer {
824                                    sender_account_id: Some(
825                                        AccountId {
826                                            shard_num: 0,
827                                            realm_num: 0,
828                                            account: Some(
829                                                AccountNum(
830                                                    5008,
831                                                ),
832                                            ),
833                                        },
834                                    ),
835                                    receiver_account_id: Some(
836                                        AccountId {
837                                            shard_num: 0,
838                                            realm_num: 0,
839                                            account: Some(
840                                                AccountNum(
841                                                    5007,
842                                                ),
843                                            ),
844                                        },
845                                    ),
846                                    serial_number: 2,
847                                    is_approval: false,
848                                    sender_allowance_hook_call: None,
849                                    receiver_allowance_hook_call: None,
850                                },
851                                NftTransfer {
852                                    sender_account_id: Some(
853                                        AccountId {
854                                            shard_num: 0,
855                                            realm_num: 0,
856                                            account: Some(
857                                                AccountNum(
858                                                    5008,
859                                                ),
860                                            ),
861                                        },
862                                    ),
863                                    receiver_account_id: Some(
864                                        AccountId {
865                                            shard_num: 0,
866                                            realm_num: 0,
867                                            account: Some(
868                                                AccountNum(
869                                                    5007,
870                                                ),
871                                            ),
872                                        },
873                                    ),
874                                    serial_number: 1,
875                                    is_approval: true,
876                                    sender_allowance_hook_call: None,
877                                    receiver_allowance_hook_call: None,
878                                },
879                                NftTransfer {
880                                    sender_account_id: Some(
881                                        AccountId {
882                                            shard_num: 0,
883                                            realm_num: 0,
884                                            account: Some(
885                                                AccountNum(
886                                                    5008,
887                                                ),
888                                            ),
889                                        },
890                                    ),
891                                    receiver_account_id: Some(
892                                        AccountId {
893                                            shard_num: 0,
894                                            realm_num: 0,
895                                            account: Some(
896                                                AccountNum(
897                                                    5006,
898                                                ),
899                                            ),
900                                        },
901                                    ),
902                                    serial_number: 3,
903                                    is_approval: false,
904                                    sender_allowance_hook_call: None,
905                                    receiver_allowance_hook_call: None,
906                                },
907                                NftTransfer {
908                                    sender_account_id: Some(
909                                        AccountId {
910                                            shard_num: 0,
911                                            realm_num: 0,
912                                            account: Some(
913                                                AccountNum(
914                                                    5007,
915                                                ),
916                                            ),
917                                        },
918                                    ),
919                                    receiver_account_id: Some(
920                                        AccountId {
921                                            shard_num: 0,
922                                            realm_num: 0,
923                                            account: Some(
924                                                AccountNum(
925                                                    5006,
926                                                ),
927                                            ),
928                                        },
929                                    ),
930                                    serial_number: 4,
931                                    is_approval: false,
932                                    sender_allowance_hook_call: None,
933                                    receiver_allowance_hook_call: None,
934                                },
935                            ],
936                            expected_decimals: None,
937                        },
938                        TokenTransferList {
939                            token: Some(
940                                TokenId {
941                                    shard_num: 0,
942                                    realm_num: 0,
943                                    token_num: 2,
944                                },
945                            ),
946                            transfers: [],
947                            nft_transfers: [
948                                NftTransfer {
949                                    sender_account_id: Some(
950                                        AccountId {
951                                            shard_num: 0,
952                                            realm_num: 0,
953                                            account: Some(
954                                                AccountNum(
955                                                    5007,
956                                                ),
957                                            ),
958                                        },
959                                    ),
960                                    receiver_account_id: Some(
961                                        AccountId {
962                                            shard_num: 0,
963                                            realm_num: 0,
964                                            account: Some(
965                                                AccountNum(
966                                                    5006,
967                                                ),
968                                            ),
969                                        },
970                                    ),
971                                    serial_number: 4,
972                                    is_approval: false,
973                                    sender_allowance_hook_call: None,
974                                    receiver_allowance_hook_call: None,
975                                },
976                            ],
977                            expected_decimals: None,
978                        },
979                    ],
980                },
981            )
982        "#]]
983        .assert_debug_eq(&tx)
984    }
985
986    #[test]
987    fn to_from_bytes() {
988        let tx = make_transaction();
989
990        let tx2 = AnyTransaction::from_bytes(&tx.to_bytes().unwrap()).unwrap();
991
992        let tx = transaction_body(tx);
993
994        let tx2 = transaction_body(tx2);
995
996        assert_eq!(tx, tx2);
997    }
998
999    #[test]
1000    fn get_decimals() {
1001        let mut tx = TransferTransaction::new();
1002        const TOKEN: TokenId = TokenId::new(0, 0, 5);
1003
1004        assert_eq!(tx.get_token_decimals().get(&TOKEN), None);
1005
1006        tx.token_transfer(TOKEN, AccountId::new(0, 0, 8), 100);
1007        assert_eq!(tx.get_token_decimals().get(&TOKEN), None);
1008
1009        tx.token_transfer_with_decimals(TOKEN, AccountId::new(0, 0, 7), -100, 5);
1010        assert_eq!(tx.get_token_decimals().get(&TOKEN), Some(&5));
1011    }
1012
1013    #[test]
1014    fn token_transfer_aggregation_same_token_and_account() {
1015        // Test that multiple transfers for the same token + account aggregate amounts
1016        let mut tx = TransferTransaction::new();
1017        const TOKEN: TokenId = TokenId::new(0, 0, 5);
1018        const ACCOUNT: AccountId = AccountId::new(0, 0, 100);
1019
1020        // Add multiple transfers for the same token and account
1021        tx.token_transfer(TOKEN, ACCOUNT, 100);
1022        tx.token_transfer(TOKEN, ACCOUNT, 200);
1023        tx.token_transfer(TOKEN, ACCOUNT, 300);
1024
1025        let transfers = tx.get_token_transfers();
1026        let account_transfers = transfers.get(&TOKEN).unwrap();
1027
1028        // Should have only one entry with aggregated amount
1029        assert_eq!(account_transfers.len(), 1);
1030        assert_eq!(account_transfers.get(&ACCOUNT), Some(&600));
1031    }
1032
1033    #[test]
1034    fn token_transfer_aggregation_different_accounts() {
1035        // Test that different accounts create separate entries
1036        let mut tx = TransferTransaction::new();
1037        const TOKEN: TokenId = TokenId::new(0, 0, 5);
1038        const ACCOUNT1: AccountId = AccountId::new(0, 0, 100);
1039        const ACCOUNT2: AccountId = AccountId::new(0, 0, 200);
1040
1041        tx.token_transfer(TOKEN, ACCOUNT1, 100);
1042        tx.token_transfer(TOKEN, ACCOUNT2, 200);
1043        tx.token_transfer(TOKEN, ACCOUNT1, 300);
1044
1045        let transfers = tx.get_token_transfers();
1046        let account_transfers = transfers.get(&TOKEN).unwrap();
1047
1048        // Should have two entries, one for each account
1049        assert_eq!(account_transfers.len(), 2);
1050        assert_eq!(account_transfers.get(&ACCOUNT1), Some(&400)); // 100 + 300
1051        assert_eq!(account_transfers.get(&ACCOUNT2), Some(&200));
1052    }
1053
1054    #[test]
1055    fn token_transfer_aggregation_different_tokens() {
1056        // Test that different tokens create separate entries
1057        let mut tx = TransferTransaction::new();
1058        const TOKEN1: TokenId = TokenId::new(0, 0, 5);
1059        const TOKEN2: TokenId = TokenId::new(0, 0, 6);
1060        const ACCOUNT: AccountId = AccountId::new(0, 0, 100);
1061
1062        tx.token_transfer(TOKEN1, ACCOUNT, 100);
1063        tx.token_transfer(TOKEN2, ACCOUNT, 200);
1064        tx.token_transfer(TOKEN1, ACCOUNT, 300);
1065
1066        let transfers = tx.get_token_transfers();
1067
1068        // Should have entries for both tokens
1069        assert_eq!(transfers.len(), 2);
1070        assert_eq!(transfers.get(&TOKEN1).unwrap().get(&ACCOUNT), Some(&400)); // 100 + 300
1071        assert_eq!(transfers.get(&TOKEN2).unwrap().get(&ACCOUNT), Some(&200));
1072    }
1073
1074    #[test]
1075    fn token_transfer_aggregation_approved_vs_non_approved() {
1076        // Test that approved and non-approved transfers are kept separate
1077        let mut tx = TransferTransaction::new();
1078        const TOKEN: TokenId = TokenId::new(0, 0, 5);
1079        const ACCOUNT: AccountId = AccountId::new(0, 0, 100);
1080
1081        tx.token_transfer(TOKEN, ACCOUNT, 100);
1082        tx.approved_token_transfer(TOKEN, ACCOUNT, 200);
1083        tx.token_transfer(TOKEN, ACCOUNT, 300);
1084        tx.approved_token_transfer(TOKEN, ACCOUNT, 400);
1085
1086        // Access internal data to verify both approved and non-approved transfers exist
1087        let data = tx.data();
1088        let token_transfer = data.token_transfers.iter().find(|tt| tt.token_id == TOKEN).unwrap();
1089
1090        // Should have 2 transfer entries: one non-approved (400) and one approved (600)
1091        assert_eq!(token_transfer.transfers.len(), 2);
1092
1093        let non_approved = token_transfer
1094            .transfers
1095            .iter()
1096            .find(|t| !t.is_approval && t.account_id == ACCOUNT)
1097            .unwrap();
1098        assert_eq!(non_approved.amount, 400); // 100 + 300
1099
1100        let approved = token_transfer
1101            .transfers
1102            .iter()
1103            .find(|t| t.is_approval && t.account_id == ACCOUNT)
1104            .unwrap();
1105        assert_eq!(approved.amount, 600); // 200 + 400
1106    }
1107
1108    #[test]
1109    fn token_transfer_with_decimals_aggregation() {
1110        // Test that transfers with decimals also aggregate properly
1111        let mut tx = TransferTransaction::new();
1112        const TOKEN: TokenId = TokenId::new(0, 0, 5);
1113        const ACCOUNT: AccountId = AccountId::new(0, 0, 100);
1114
1115        tx.token_transfer_with_decimals(TOKEN, ACCOUNT, 100, 3);
1116        tx.token_transfer_with_decimals(TOKEN, ACCOUNT, 200, 3);
1117        tx.token_transfer_with_decimals(TOKEN, ACCOUNT, 300, 3);
1118
1119        let transfers = tx.get_token_transfers();
1120        let account_transfers = transfers.get(&TOKEN).unwrap();
1121
1122        // Should have only one entry with aggregated amount
1123        assert_eq!(account_transfers.len(), 1);
1124        assert_eq!(account_transfers.get(&ACCOUNT), Some(&600));
1125
1126        // Decimals should be set
1127        assert_eq!(tx.get_token_decimals().get(&TOKEN), Some(&3));
1128    }
1129
1130    #[test]
1131    fn token_transfer_negative_amounts_aggregate() {
1132        // Test that negative amounts (withdrawals) also aggregate correctly
1133        let mut tx = TransferTransaction::new();
1134        const TOKEN: TokenId = TokenId::new(0, 0, 5);
1135        const SENDER: AccountId = AccountId::new(0, 0, 100);
1136        const RECEIVER: AccountId = AccountId::new(0, 0, 200);
1137
1138        tx.token_transfer(TOKEN, SENDER, -100);
1139        tx.token_transfer(TOKEN, SENDER, -200);
1140        tx.token_transfer(TOKEN, RECEIVER, 150);
1141        tx.token_transfer(TOKEN, RECEIVER, 150);
1142
1143        let transfers = tx.get_token_transfers();
1144        let account_transfers = transfers.get(&TOKEN).unwrap();
1145
1146        assert_eq!(account_transfers.get(&SENDER), Some(&-300)); // -100 + -200
1147        assert_eq!(account_transfers.get(&RECEIVER), Some(&300)); // 150 + 150
1148    }
1149}