Skip to main content

hiero_sdk/token/
token_airdrop_transaction.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use std::collections::HashMap;
4
5use hiero_sdk_proto::services;
6use hiero_sdk_proto::services::token_service_client::TokenServiceClient;
7use tonic::transport::Channel;
8
9use super::{
10    NftId,
11    TokenId,
12    TokenNftTransfer,
13};
14use crate::hooks::FungibleHookCall;
15use crate::ledger_id::RefLedgerId;
16use crate::protobuf::{
17    FromProtobuf,
18    ToProtobuf,
19};
20use crate::transaction::{
21    AnyTransactionData,
22    ChunkInfo,
23    ToSchedulableTransactionDataProtobuf,
24    ToTransactionDataProtobuf,
25    TransactionData,
26    TransactionExecute,
27};
28use crate::transfer_transaction::{
29    TokenTransfer,
30    Transfer,
31};
32use crate::{
33    AccountId,
34    BoxGrpcFuture,
35    Error,
36    Transaction,
37    ValidateChecksums,
38};
39
40///
41/// Airdrop one or more tokens to one or more accounts.
42///
43///  ### Effects
44///  This distributes tokens from the balance of one or more sending account(s) to the balance
45///  of one or more recipient accounts. Accounts MAY receive the tokens in one of four ways.
46///
47///   - An account already associated to the token to be distributed SHALL receive the
48///     airdropped tokens immediately to the recipient account balance.<br/>
49///     The fee for this transfer SHALL include the transfer, the airdrop fee, and any custom fees.
50///   - An account with available automatic association slots SHALL be automatically
51///     associated to the token, and SHALL immediately receive the airdropped tokens to the
52///     recipient account balance.<br/>
53///     The fee for this transfer SHALL include the transfer, the association, the cost to renew
54///     that association once, the airdrop fee, and any custom fees.
55///   - An account with "receiver signature required" set SHALL have a "Pending Airdrop" created
56///     and must claim that airdrop with a `claimAirdrop` transaction.<br/>
57///     The fee for this transfer SHALL include the transfer, the association, the cost to renew
58///     that association once, the airdrop fee, and any custom fees. If the pending airdrop is not
59///     claimed immediately, the `sender` SHALL pay the cost to renew the token association, and
60///     the cost to maintain the pending airdrop, until the pending airdrop is claimed or cancelled.
61///   - An account with no available automatic association slots SHALL have a "Pending Airdrop"
62///     created and must claim that airdrop with a `claimAirdrop` transaction.<br/>
63///     The fee for this transfer SHALL include the transfer, the association, the cost to renew
64///     that association once, the airdrop fee, and any custom fees. If the pending airdrop is not
65///     claimed immediately, the `sender` SHALL pay the cost to renew the token association, and
66///     the cost to maintain the pending airdrop, until the pending airdrop is claimed or cancelled.
67///
68///  If an airdrop would create a pending airdrop for a fungible/common token, and a pending airdrop
69///  for the same sender, receiver, and token already exists, the existing pending airdrop
70///  SHALL be updated to add the new amount to the existing airdrop, rather than creating a new
71///  pending airdrop.
72///
73///  Any airdrop that completes immediately SHALL be irreversible. Any airdrop that results in a
74///  "Pending Airdrop" MAY be canceled via a `cancelAirdrop` transaction.
75///
76///  All transfer fees (including custom fees and royalties), as well as the rent cost for the
77///  first auto-renewal period for any automatic-association slot occupied by the airdropped
78///  tokens, SHALL be charged to the account paying for this transaction.
79///
80///  ### Record Stream Effects
81///  - Each successful transfer SHALL be recorded in `token_transfer_list` for the transaction record.
82///  - Each successful transfer that consumes an automatic association slot SHALL populate the
83///    `automatic_association` field for the record.
84///  - Each pending transfer _created_ SHALL be added to the `pending_airdrops` field for the record.
85///  - Each pending transfer _updated_ SHALL be added to the `pending_airdrops` field for the record.
86///
87pub type TokenAirdropTransaction = Transaction<TokenAirdropTransactionData>;
88
89#[derive(Debug, Clone, Default)]
90pub struct TokenAirdropTransactionData {
91    /// A list of token transfers representing one or more airdrops.
92    token_transfers: Vec<TokenTransfer>,
93}
94
95impl TokenAirdropTransaction {
96    /// Add a non-approved token transfer.
97    pub fn token_transfer(
98        &mut self,
99        token_id: TokenId,
100        account_id: AccountId,
101        value: i64,
102    ) -> &mut Self {
103        self._token_transfer(token_id, account_id, value, false, None)
104    }
105
106    /// Return a non-approved token transfer.
107    pub fn get_token_transfers(&self) -> HashMap<TokenId, HashMap<AccountId, i64>> {
108        use std::collections::hash_map::Entry;
109
110        // note: using fold instead of nested collects on the off chance a token is in here twice.
111        self.data().token_transfers.iter().fold(
112            HashMap::with_capacity(self.data().token_transfers.len()),
113            |mut map, transfer| {
114                let iter = transfer.transfers.iter().map(|it| (it.account_id, it.amount));
115                match map.entry(transfer.token_id) {
116                    Entry::Occupied(mut it) => it.get_mut().extend(iter),
117                    Entry::Vacant(it) => {
118                        it.insert(iter.collect());
119                    }
120                }
121
122                map
123            },
124        )
125    }
126
127    /// Add a non-approved nft transfer.
128    pub fn nft_transfer(
129        &mut self,
130        nft_id: NftId,
131        sender: AccountId,
132        receiver: AccountId,
133    ) -> &mut Self {
134        self._nft_transfer(nft_id, sender, receiver, false);
135        self
136    }
137
138    /// Extract the of token nft transfers.
139    pub fn get_nft_transfers(&self) -> HashMap<TokenId, Vec<TokenNftTransfer>> {
140        self.data().token_transfers.iter().map(|t| (t.token_id, t.nft_transfers.clone())).collect()
141    }
142
143    /// Add a non-approved token transfer with decimals.
144    pub fn token_transfer_with_decimals(
145        &mut self,
146        token_id: TokenId,
147        account_id: AccountId,
148        amount: i64,
149        decimals: u32,
150    ) -> &mut Self {
151        self._token_transfer_with_decimals(token_id, account_id, amount, false, Some(decimals));
152        self
153    }
154
155    /// Extract the list of token id decimals.
156    pub fn get_token_ids_with_decimals(&self) -> HashMap<TokenId, Option<u32>> {
157        self.data().token_transfers.iter().map(|t| (t.token_id, t.expected_decimals)).collect()
158    }
159
160    /// Add an approved token transfer to the transaction.
161    pub fn approved_token_transfer(
162        &mut self,
163        token_id: TokenId,
164        account_id: AccountId,
165        amount: i64,
166    ) -> &mut Self {
167        self._token_transfer(token_id, account_id, amount, true, None);
168        self
169    }
170
171    /// Add an approved nft transfer.
172    pub fn approved_nft_transfer(
173        &mut self,
174        nft_id: NftId,
175        sender: AccountId,
176        receiver: AccountId,
177    ) -> &mut Self {
178        self._nft_transfer(nft_id, sender, receiver, true);
179        self
180    }
181
182    /// Add an approved token transfer with decimals.
183    pub fn approved_token_transfer_with_decimals(
184        &mut self,
185        token_id: TokenId,
186        account_id: AccountId,
187        amount: i64,
188        decimals: u32,
189    ) -> &mut Self {
190        self._token_transfer_with_decimals(token_id, account_id, amount, true, Some(decimals));
191        self
192    }
193
194    fn _token_transfer(
195        &mut self,
196        token_id: TokenId,
197        account_id: AccountId,
198        amount: i64,
199        is_approved: bool,
200        hook_call: Option<FungibleHookCall>,
201    ) -> &mut Self {
202        let transfer = Transfer { account_id, amount, is_approval: is_approved, hook_call };
203        let data = self.data_mut();
204
205        if let Some(tt) = data.token_transfers.iter_mut().find(|tt| tt.token_id == token_id) {
206            if let Some(tt) = tt
207                .transfers
208                .iter_mut()
209                .find(|t| t.account_id == account_id && t.is_approval == is_approved)
210            {
211                tt.amount += amount;
212            } else {
213                tt.transfers.push(transfer);
214            }
215        } else {
216            data.token_transfers.push(TokenTransfer {
217                token_id,
218                expected_decimals: None,
219                nft_transfers: Vec::new(),
220                transfers: vec![transfer],
221            });
222        }
223        self
224    }
225
226    fn _token_transfer_with_decimals(
227        &mut self,
228        token_id: TokenId,
229        account_id: AccountId,
230        amount: i64,
231        approved: bool,
232        expected_decimals: Option<u32>,
233    ) -> &mut Self {
234        let transfer = Transfer { account_id, amount, is_approval: approved, hook_call: None };
235        let data = self.data_mut();
236
237        if let Some(tt) = data.token_transfers.iter_mut().find(|tt| tt.token_id == token_id) {
238            if tt.expected_decimals.is_some() && tt.expected_decimals != expected_decimals {
239                panic!("expected decimals mismatch");
240            }
241
242            tt.expected_decimals = expected_decimals;
243
244            if let Some(tt) = tt
245                .transfers
246                .iter_mut()
247                .find(|t| t.account_id == account_id && t.is_approval == approved)
248            {
249                tt.amount += amount;
250            } else {
251                tt.transfers.push(transfer);
252            }
253        } else {
254            data.token_transfers.push(TokenTransfer {
255                token_id,
256                expected_decimals,
257                nft_transfers: Vec::new(),
258                transfers: vec![transfer],
259            });
260        }
261        self
262    }
263
264    fn _nft_transfer(
265        &mut self,
266        nft_id: NftId,
267        sender: AccountId,
268        receiver: AccountId,
269        is_approved: bool,
270    ) -> &mut Self {
271        let NftId { token_id, serial } = nft_id;
272        let transfer = TokenNftTransfer {
273            token_id,
274            serial,
275            sender,
276            receiver,
277            is_approved,
278            sender_hook_call: None,
279            receiver_hook_call: None,
280        };
281
282        let data = self.data_mut();
283
284        if let Some(tt) = data.token_transfers.iter_mut().find(|tt| tt.token_id == token_id) {
285            tt.nft_transfers.push(transfer);
286        } else {
287            data.token_transfers.push(TokenTransfer {
288                token_id,
289                expected_decimals: None,
290                transfers: Vec::new(),
291                nft_transfers: vec![transfer],
292            });
293        }
294
295        self
296    }
297}
298
299impl TransactionData for TokenAirdropTransactionData {}
300
301impl TransactionExecute for TokenAirdropTransactionData {
302    fn execute(
303        &self,
304        channel: Channel,
305        request: services::Transaction,
306    ) -> BoxGrpcFuture<'_, services::TransactionResponse> {
307        Box::pin(async { TokenServiceClient::new(channel).airdrop_tokens(request).await })
308    }
309}
310
311impl ValidateChecksums for TokenAirdropTransactionData {
312    fn validate_checksums(&self, ledger_id: &RefLedgerId) -> Result<(), Error> {
313        for token_transfer in &self.token_transfers {
314            token_transfer.token_id.validate_checksums(ledger_id)?;
315            for transfer in &token_transfer.transfers {
316                transfer.account_id.validate_checksums(ledger_id)?;
317            }
318            for nft_transfer in &token_transfer.nft_transfers {
319                nft_transfer.sender.validate_checksums(ledger_id)?;
320                nft_transfer.receiver.validate_checksums(ledger_id)?;
321            }
322        }
323        Ok(())
324    }
325}
326
327impl ToTransactionDataProtobuf for TokenAirdropTransactionData {
328    fn to_transaction_data_protobuf(
329        &self,
330        chunk_info: &ChunkInfo,
331    ) -> services::transaction_body::Data {
332        let _ = chunk_info.assert_single_transaction();
333
334        services::transaction_body::Data::TokenAirdrop(self.to_protobuf())
335    }
336}
337
338impl ToSchedulableTransactionDataProtobuf for TokenAirdropTransactionData {
339    fn to_schedulable_transaction_data_protobuf(
340        &self,
341    ) -> services::schedulable_transaction_body::Data {
342        services::schedulable_transaction_body::Data::TokenAirdrop(self.to_protobuf())
343    }
344}
345
346impl From<TokenAirdropTransactionData> for AnyTransactionData {
347    fn from(transaction: TokenAirdropTransactionData) -> Self {
348        Self::TokenAirdrop(transaction)
349    }
350}
351
352impl ToProtobuf for TokenAirdropTransactionData {
353    type Protobuf = services::TokenAirdropTransactionBody;
354
355    fn to_protobuf(&self) -> Self::Protobuf {
356        let mut token_transfers = self.token_transfers.clone();
357
358        // Sort token transfers by token ID
359        token_transfers.sort_by(|a, b| {
360            a.token_id
361                .shard
362                .cmp(&b.token_id.shard)
363                .then(a.token_id.realm.cmp(&b.token_id.realm))
364                .then(a.token_id.num.cmp(&b.token_id.num))
365        });
366
367        // Sort transfers within each TokenTransfer
368        for tt in &mut token_transfers {
369            tt.transfers.sort_by(|a, b| {
370                a.account_id
371                    .shard
372                    .cmp(&b.account_id.shard)
373                    .then_with(|| a.account_id.realm.cmp(&b.account_id.realm))
374                    .then_with(|| a.account_id.num.cmp(&b.account_id.num))
375                    .then_with(|| a.is_approval.cmp(&b.is_approval))
376            });
377
378            tt.nft_transfers.sort_by(|a, b| a.serial.cmp(&b.serial));
379        }
380
381        services::TokenAirdropTransactionBody {
382            token_transfers: token_transfers
383                .into_iter()
384                .map(|tt| services::TokenTransferList {
385                    token: Some(tt.token_id.to_protobuf()),
386                    transfers: tt.transfers.into_iter().map(|t| t.to_protobuf()).collect(),
387                    nft_transfers: tt
388                        .nft_transfers
389                        .into_iter()
390                        .map(|nt| nt.to_protobuf())
391                        .collect(),
392                    expected_decimals: tt.expected_decimals.map(|d| d as u32),
393                })
394                .collect(),
395        }
396    }
397}
398
399impl FromProtobuf<services::TokenAirdropTransactionBody> for TokenAirdropTransactionData {
400    fn from_protobuf(pb: services::TokenAirdropTransactionBody) -> crate::Result<Self>
401    where
402        Self: Sized,
403    {
404        Ok(Self {
405            token_transfers: pb
406                .token_transfers
407                .into_iter()
408                .map(|t| TokenTransfer::from_protobuf(t))
409                .collect::<crate::Result<_>>()?,
410        })
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use std::str::FromStr;
417
418    use expect_test::expect_file;
419    use hiero_sdk_proto::services::{
420        self,
421        AccountAmount,
422        NftTransfer,
423        TokenTransferList,
424    };
425
426    use crate::protobuf::{
427        FromProtobuf,
428        ToProtobuf,
429    };
430    use crate::token::TokenAirdropTransactionData;
431    use crate::transaction::test_helpers::{
432        check_body,
433        transaction_body,
434        unused_private_key,
435        TEST_ACCOUNT_ID,
436        TEST_TOKEN_ID,
437    };
438    use crate::{
439        AccountId,
440        AnyTransaction,
441        TokenAirdropTransaction,
442        TokenId,
443    };
444
445    fn make_transaction() -> TokenAirdropTransaction {
446        let mut tx = TokenAirdropTransaction::new_for_tests();
447
448        tx.token_transfer(TokenId::new(0, 0, 5005), AccountId::new(0, 0, 5006), 400)
449            .token_transfer_with_decimals(
450                TokenId::new(0, 0, 5),
451                AccountId::new(0, 0, 5005),
452                -800,
453                3,
454            )
455            .token_transfer_with_decimals(
456                TokenId::new(0, 0, 5),
457                AccountId::new(0, 0, 5007),
458                -400,
459                3,
460            )
461            .token_transfer(TokenId::new(0, 0, 4), AccountId::new(0, 0, 5008), 1)
462            .token_transfer(TokenId::new(0, 0, 4), AccountId::new(0, 0, 5006), -1)
463            .nft_transfer(
464                TokenId::new(0, 0, 3).nft(2),
465                AccountId::new(0, 0, 5008),
466                AccountId::new(0, 0, 5007),
467            )
468            .nft_transfer(
469                TokenId::new(0, 0, 3).nft(1),
470                AccountId::new(0, 0, 5008),
471                AccountId::new(0, 0, 5007),
472            )
473            .nft_transfer(
474                TokenId::new(0, 0, 3).nft(3),
475                AccountId::new(0, 0, 5008),
476                AccountId::new(0, 0, 5006),
477            )
478            .nft_transfer(
479                TokenId::new(0, 0, 3).nft(4),
480                AccountId::new(0, 0, 5007),
481                AccountId::new(0, 0, 5006),
482            )
483            .nft_transfer(
484                TokenId::new(0, 0, 2).nft(4),
485                AccountId::new(0, 0, 5007),
486                AccountId::new(0, 0, 5006),
487            )
488            .approved_token_transfer(TokenId::new(0, 0, 4), AccountId::new(0, 0, 5006), 123)
489            .approved_nft_transfer(
490                TokenId::new(0, 0, 4).nft(4),
491                AccountId::new(0, 0, 5005),
492                AccountId::new(0, 0, 5006),
493            )
494            .freeze()
495            .unwrap()
496            .sign(unused_private_key());
497        tx
498    }
499
500    #[test]
501    fn serialize() {
502        let tx = make_transaction();
503
504        let tx = transaction_body(tx);
505
506        let tx = check_body(tx);
507
508        expect_file!["./snapshots/token_airdrop_transaction/serialize.txt"].assert_debug_eq(&tx);
509    }
510
511    #[test]
512    fn to_from_bytes() {
513        let tx = make_transaction();
514
515        let tx2 = AnyTransaction::from_bytes(&tx.to_bytes().unwrap()).unwrap();
516
517        let tx = transaction_body(tx);
518        let tx2 = transaction_body(tx2);
519
520        println!("tx: {:?}", tx);
521        println!("tx2: {:?}", tx2);
522
523        assert_eq!(tx, tx2)
524    }
525
526    #[test]
527    fn from_proto_body() {
528        let tx = services::TokenAirdropTransactionBody {
529            token_transfers: vec![TokenTransferList {
530                token: Some(TEST_TOKEN_ID.to_protobuf()),
531                transfers: vec![
532                    AccountAmount {
533                        account_id: Some(AccountId::from_str("0.0.5008").unwrap().to_protobuf()),
534                        amount: 200,
535                        is_approval: false,
536                        hook_call: None,
537                    },
538                    AccountAmount {
539                        account_id: Some(AccountId::from_str("0.0.5009").unwrap().to_protobuf()),
540                        amount: -100,
541                        is_approval: false,
542                        hook_call: None,
543                    },
544                    AccountAmount {
545                        account_id: Some(AccountId::from_str("0.0.5010").unwrap().to_protobuf()),
546                        amount: 40,
547                        is_approval: false,
548                        hook_call: None,
549                    },
550                    AccountAmount {
551                        account_id: Some(AccountId::from_str("0.0.5011").unwrap().to_protobuf()),
552                        amount: 20,
553                        is_approval: false,
554                        hook_call: None,
555                    },
556                ],
557                nft_transfers: vec![NftTransfer {
558                    sender_account_id: Some(AccountId::from_str("0.0.5010").unwrap().to_protobuf()),
559                    receiver_account_id: Some(
560                        AccountId::from_str("0.0.5011").unwrap().to_protobuf(),
561                    ),
562                    serial_number: 1,
563                    is_approval: true,
564                    sender_allowance_hook_call: None,
565                    receiver_allowance_hook_call: None,
566                }],
567                expected_decimals: Some(3),
568            }],
569        };
570
571        let data = TokenAirdropTransactionData::from_protobuf(tx).unwrap();
572
573        let ft_transfers =
574            data.token_transfers.iter().flat_map(|t| &t.transfers).collect::<Vec<_>>();
575        let nft_transfers =
576            data.token_transfers.iter().flat_map(|t| &t.nft_transfers).collect::<Vec<_>>();
577
578        assert_eq!(ft_transfers.len(), 4);
579        assert_eq!(nft_transfers.len(), 1);
580    }
581
582    #[test]
583    fn get_set_token_transfers() {
584        let token_id = TokenId::new(0, 0, 123);
585        let account_id = AccountId::new(0, 0, 456);
586        let value = 1000;
587        let mut tx = TokenAirdropTransaction::new();
588        tx.token_transfer(token_id, account_id, value);
589
590        let token_transfers = tx.get_token_transfers();
591
592        assert!(token_transfers.contains_key(&token_id));
593        assert_eq!(token_transfers.len(), 1);
594        assert_eq!(value, *token_transfers.get(&token_id).unwrap().get(&account_id).unwrap());
595    }
596
597    #[test]
598    #[should_panic]
599    fn get_set_token_transfers_frozen_panic() {
600        make_transaction().token_transfer(TEST_TOKEN_ID, TEST_ACCOUNT_ID, 142);
601    }
602
603    #[test]
604    fn get_set_nft_transfer() {
605        let (nft_id, sender, receiver) =
606            (TEST_TOKEN_ID.nft(1), TEST_ACCOUNT_ID, AccountId::new(0, 0, 5011));
607        let mut tx = TokenAirdropTransaction::new();
608        tx.nft_transfer(nft_id, sender, receiver);
609        let nft_transfers = tx.get_nft_transfers();
610
611        assert!(nft_transfers.contains_key(&nft_id.token_id));
612        assert_eq!(1, nft_transfers.get(&nft_id.token_id).unwrap().len());
613        assert_eq!(sender, nft_transfers.get(&nft_id.token_id).unwrap()[0].sender);
614        assert_eq!(receiver, nft_transfers.get(&nft_id.token_id).unwrap()[0].receiver);
615    }
616
617    #[test]
618    #[should_panic]
619    fn get_set_nft_transfer_frozen_panic() {
620        make_transaction().nft_transfer(
621            TEST_TOKEN_ID.nft(1),
622            TEST_ACCOUNT_ID,
623            AccountId::new(0, 0, 156),
624        );
625    }
626
627    #[test]
628    fn get_set_approved_nft_transfer() {
629        let (nft_id, sender, receiver) =
630            (TEST_TOKEN_ID.nft(1), TEST_ACCOUNT_ID, AccountId::new(0, 0, 123));
631        let mut tx = TokenAirdropTransaction::new();
632        tx.approved_nft_transfer(nft_id, sender, receiver);
633        let nft_transfers = tx.get_nft_transfers();
634
635        assert!(nft_transfers.contains_key(&nft_id.token_id));
636        assert_eq!(nft_transfers.get(&nft_id.token_id).unwrap().len(), 1);
637        assert_eq!(sender, nft_transfers.get(&nft_id.token_id).unwrap()[0].sender);
638        assert_eq!(receiver, nft_transfers.get(&nft_id.token_id).unwrap()[0].receiver);
639    }
640
641    #[test]
642    fn get_set_approved_token_transfer() {
643        let (token_id, account_id, value) =
644            (TokenId::new(0, 0, 1420), AccountId::new(0, 0, 415), 1000);
645        let mut tx = TokenAirdropTransaction::new();
646        tx.approved_token_transfer(token_id, account_id, value);
647
648        let token_transfers = tx.get_token_transfers();
649
650        assert!(token_transfers.contains_key(&token_id));
651        assert_eq!(token_transfers.len(), 1);
652        assert_eq!(value, *token_transfers.get(&token_id).unwrap().get(&account_id).unwrap());
653    }
654
655    #[test]
656    fn get_set_token_id_decimals() {
657        let (nft_id, sender, receiver) =
658            (TEST_TOKEN_ID.nft(1), TEST_ACCOUNT_ID, AccountId::new(0, 0, 123));
659        let mut tx = TokenAirdropTransaction::new();
660        tx.approved_nft_transfer(nft_id, sender, receiver);
661        let nft_transfers = tx.get_nft_transfers();
662
663        assert!(nft_transfers.contains_key(&nft_id.token_id));
664        assert_eq!(nft_transfers.get(&nft_id.token_id).unwrap().len(), 1);
665        assert_eq!(sender, nft_transfers.get(&nft_id.token_id).unwrap()[0].sender);
666        assert_eq!(receiver, nft_transfers.get(&nft_id.token_id).unwrap()[0].receiver);
667    }
668}