chia_sdk_driver/
clear_signing.rs

1use std::collections::HashSet;
2
3use chia_protocol::{Bytes32, Coin, CoinSpend};
4use chia_puzzle_types::{
5    Memos,
6    nft::NftMetadata,
7    offer::{NotarizedPayment, SettlementPaymentsSolution},
8};
9use chia_puzzles::SETTLEMENT_PAYMENT_HASH;
10use chia_sdk_types::{
11    Condition, MessageFlags, MessageSide, Mod, announcement_id, conditions::CreateCoin,
12    puzzles::SingletonMember, run_puzzle, tree_hash_notarized_payment,
13};
14use clvm_traits::{FromClvm, ToClvm};
15use clvm_utils::TreeHash;
16use clvmr::{Allocator, NodePtr};
17
18use crate::{
19    BURN_PUZZLE_HASH, Cat, ClawbackV2, DriverError, MetadataUpdate, Nft, Puzzle, Spend, UriKind,
20    mips_puzzle_hash,
21};
22
23/// Information about a vault that must be provided in order to securely parse a transaction.
24#[derive(Debug, Clone, Copy)]
25pub struct VaultSpendReveal {
26    /// The launcher id of the vault's singleton.
27    /// This is used to calculate the p2 puzzle hash.
28    pub launcher_id: Bytes32,
29    /// The inner puzzle hash of the vault singleton.
30    /// This is used to construct the puzzle hash we're signing for.
31    pub custody_hash: TreeHash,
32    /// The delegated puzzle we're signing and its solution.
33    /// Its output is the non-custody related conditions that the vault spend will output.
34    pub delegated_spend: Spend,
35}
36
37/// The purpose of this is to provide sufficient information to verify what is happening to a vault and its assets
38/// as a result of a transaction at a glance. Information that is not verifiable should not be included or displayed.
39/// We can still allow transactions which are not fully verifiable, but a conservative summary should be provided.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct VaultTransaction {
42    /// If a new vault coin is created (i.e. the vault isn't melted), this will be set.
43    /// It's the new inner puzzle hash of the vault singleton. If it's different, the custody configuration has changed.
44    /// It can be validated against a [`MipsMemo`](crate::MipsMemo) so that you know what specifically is happening.
45    pub new_custody_hash: Option<TreeHash>,
46    /// Fungible asset payments that are relevant to the vault and can be verified to exist if the signature is used.
47    pub payments: Vec<ParsedPayment>,
48    /// NFT transfers that are relevant to the vault and can be verified to exist if the signature is used.
49    pub nfts: Vec<ParsedNftTransfer>,
50    /// Coins which were created as outputs of the vault singleton spend itself, for example to mint NFTs.
51    pub drop_coins: Vec<DropCoin>,
52    /// Total fees (different between input and output amounts) paid by coin spends authorized by the vault.
53    /// If the transaction is signed, the fee is guaranteed to be at least this amount, unless it's not reserved.
54    /// The reason to include unreserved fees is to make it clear that the XCH is leaving the vault due to this transaction.
55    pub fee_paid: u64,
56    /// Total fees (different between input and output amounts) paid by all coin spends in the transaction combined.
57    /// Because the full coin spend list cannot be validated off-chain, this is not guaranteed to be accurate.
58    pub total_fee: u64,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct ParsedPayment {
63    /// The direction in which the asset is being transferred.
64    pub transfer_type: TransferType,
65    /// The asset id, if applicable. This may be [`None`] for XCH, or [`Some`] for a CAT.
66    pub asset_id: Option<Bytes32>,
67    /// The revocation hidden puzzle hash (if the asset is a revocable CAT).
68    pub hidden_puzzle_hash: Option<Bytes32>,
69    /// The custody p2 puzzle hash that the payment is being sent to (analogous to a decoded XCH or TXCH address).
70    pub p2_puzzle_hash: Bytes32,
71    /// The coin that will be created as a result of this payment being confirmed on-chain.
72    /// This includes the amount being paid to the p2 puzzle hash.
73    pub coin: Coin,
74    /// If applicable, the clawback information for the payment (including who can claw it back and for how long).
75    pub clawback: Option<ClawbackV2>,
76    /// The potentially human readable memo list after the hint and/or clawback memo is removed.
77    pub memos: Vec<String>,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct ParsedNftTransfer {
82    /// The direction in which the NFT is being transferred.
83    pub transfer_type: TransferType,
84    /// The launcher id of the NFT.
85    pub launcher_id: Bytes32,
86    /// The custody p2 puzzle hash that the NFT is being sent to (analogous to a decoded XCH or TXCH address).
87    pub p2_puzzle_hash: Bytes32,
88    /// The latest NFT coin that is confirmed to be created as a result of this transaction.
89    /// Unverifiable coin spends will be excluded.
90    pub coin: Coin,
91    /// If applicable, the clawback information for the NFT (including who can claw it back and for how long).
92    pub clawback: Option<ClawbackV2>,
93    /// The potentially human readable memo list after the hint and/or clawback memo is removed.
94    pub memos: Vec<String>,
95    /// URIs which are added to the NFT's metadata as part of coin spends which can be verified to exist.
96    pub new_uris: Vec<MetadataUpdate>,
97    /// The latest owner hash of the NFT from verified coin spends.
98    pub latest_owner: Option<Bytes32>,
99    /// Whether the NFT transfer includes unverifiable metadata updates.
100    pub includes_unverifiable_updates: bool,
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum TransferType {
105    /// These are payments that are output from coin spends which have been authorized by the vault.
106    /// Notably, this will not include payments that are being sent to the vault's p2 puzzle hash.
107    /// Thus, this excludes change coins and payments that are received from taken offers.
108    Sent,
109    /// Instead of being [`TransferType::Sent`], this is being sent to the burn address.
110    Burned,
111    /// Instead of being [`TransferType::Sent`], this is being sent to the settlement payments.
112    Offered,
113    /// These are payments to the vault's p2 puzzle hash that are output from offer settlement coins.
114    /// Change coins and non-offer payments are excluded, since their authenticity cannot be easily verified off-chain.
115    /// An offer payment is also excluded if its notarized payment announcement id is not asserted by a coin spend authorized by the vault.
116    Received,
117    /// When the coin spend originally comes from the vault, and ends up back in the vault, this is an update.
118    /// It's only used for singleton transactions.
119    Updated,
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub struct DropCoin {
124    pub puzzle_hash: Bytes32,
125    pub amount: u64,
126}
127
128impl VaultTransaction {
129    pub fn parse(
130        allocator: &mut Allocator,
131        vault: &VaultSpendReveal,
132        coin_spends: Vec<CoinSpend>,
133    ) -> Result<Self, DriverError> {
134        let our_p2_puzzle_hash = vault_p2_puzzle_hash(vault.launcher_id);
135
136        let all_spent_coin_ids = coin_spends.iter().map(|cs| cs.coin.coin_id()).collect();
137
138        let ParsedDelegatedSpend {
139            new_custody_hash,
140            our_spent_coin_ids,
141            puzzle_assertion_ids,
142            drop_coins,
143        } = parse_delegated_spend(allocator, vault.delegated_spend, &all_spent_coin_ids)?;
144
145        let ParsedConditions {
146            puzzle_assertion_ids,
147            all_created_coin_ids,
148        } = parse_our_conditions(
149            allocator,
150            &coin_spends,
151            &our_spent_coin_ids,
152            puzzle_assertion_ids,
153        )?;
154
155        let coin_spends = reorder_coin_spends(coin_spends);
156
157        let mut payments = Vec::new();
158        let mut nfts = Vec::new();
159        let mut our_input = 0;
160        let mut our_output = 0;
161        let mut total_input = 0;
162        let mut total_output = 0;
163
164        for coin_spend in coin_spends {
165            let coin_id = coin_spend.coin.coin_id();
166            let is_parent_ours = our_spent_coin_ids.contains(&coin_id);
167            let is_parent_ephemeral = all_created_coin_ids.contains(&coin_id);
168
169            total_input += coin_spend.coin.amount;
170
171            if is_parent_ours && !is_parent_ephemeral {
172                our_input += coin_spend.coin.amount;
173            }
174
175            let puzzle = coin_spend.puzzle_reveal.to_clvm(allocator)?;
176            let puzzle = Puzzle::parse(allocator, puzzle);
177            let solution = coin_spend.solution.to_clvm(allocator)?;
178
179            let output = run_puzzle(allocator, puzzle.ptr(), solution)?;
180            let conditions = Vec::<Condition>::from_clvm(allocator, output)?;
181
182            if let Some((cat, p2_puzzle, p2_solution)) =
183                Cat::parse(allocator, coin_spend.coin, puzzle, solution)?
184            {
185                let p2_output = run_puzzle(allocator, p2_puzzle.ptr(), p2_solution)?;
186
187                let mut p2_create_coins = Vec::<Condition>::from_clvm(allocator, p2_output)?
188                    .into_iter()
189                    .filter_map(Condition::into_create_coin)
190                    .collect::<Vec<_>>();
191
192                let children = Cat::parse_children(allocator, coin_spend.coin, puzzle, solution)?
193                    .unwrap_or_default();
194
195                let notarized_payments =
196                    if cat.info.p2_puzzle_hash == SETTLEMENT_PAYMENT_HASH.into() {
197                        SettlementPaymentsSolution::from_clvm(allocator, p2_solution)?
198                            .notarized_payments
199                    } else {
200                        Vec::new()
201                    };
202
203                for child in children {
204                    let child_coin_id = child.coin.coin_id();
205                    let create_coin = p2_create_coins.remove(0);
206                    let parsed_memos = parse_memos(allocator, create_coin, true);
207                    let is_child_ours = parsed_memos.p2_puzzle_hash == our_p2_puzzle_hash;
208                    let is_child_ephemeral = all_spent_coin_ids.contains(&child_coin_id);
209
210                    total_output += child.coin.amount;
211
212                    if is_parent_ours && !is_child_ephemeral {
213                        our_output += child.coin.amount;
214                    }
215
216                    // Skip ephemeral coins
217                    if our_spent_coin_ids.contains(&child_coin_id) {
218                        continue;
219                    }
220
221                    if let Some(transfer_type) = calculate_transfer_type(
222                        allocator,
223                        TransferTypeContext {
224                            puzzle_assertion_ids: &puzzle_assertion_ids,
225                            notarized_payments: &notarized_payments,
226                            create_coin: &create_coin,
227                            p2_puzzle_hash: parsed_memos.p2_puzzle_hash,
228                            full_puzzle_hash: cat.coin.puzzle_hash,
229                            is_parent_ours,
230                            is_child_ours,
231                        },
232                    ) {
233                        payments.push(ParsedPayment {
234                            transfer_type,
235                            asset_id: Some(child.info.asset_id),
236                            hidden_puzzle_hash: child.info.hidden_puzzle_hash,
237                            p2_puzzle_hash: parsed_memos.p2_puzzle_hash,
238                            coin: child.coin,
239                            clawback: parsed_memos.clawback,
240                            memos: parsed_memos.memos,
241                        });
242                    }
243                }
244
245                continue;
246            }
247
248            let mut is_singleton = false;
249
250            if let Some((nft, p2_puzzle, p2_solution)) =
251                Nft::parse(allocator, coin_spend.coin, puzzle, solution)?
252            {
253                is_singleton = true;
254
255                let p2_output = run_puzzle(allocator, p2_puzzle.ptr(), p2_solution)?;
256
257                let mut p2_create_coins = Vec::<Condition>::from_clvm(allocator, p2_output)?
258                    .into_iter()
259                    .filter_map(Condition::into_create_coin)
260                    .filter(|cc| cc.amount % 2 == 1)
261                    .collect::<Vec<_>>();
262
263                let child = Nft::parse_child(allocator, coin_spend.coin, puzzle, solution)?
264                    .ok_or(DriverError::MissingChild)?;
265
266                let notarized_payments =
267                    if nft.info.p2_puzzle_hash == SETTLEMENT_PAYMENT_HASH.into() {
268                        SettlementPaymentsSolution::from_clvm(allocator, p2_solution)?
269                            .notarized_payments
270                    } else {
271                        Vec::new()
272                    };
273
274                let child_coin_id = child.coin.coin_id();
275                let is_child_ephemeral = all_spent_coin_ids.contains(&child_coin_id);
276                let create_coin = p2_create_coins.remove(0);
277                let parsed_memos = parse_memos(allocator, create_coin, true);
278                let is_child_ours = parsed_memos.p2_puzzle_hash == our_p2_puzzle_hash;
279
280                total_output += child.coin.amount;
281
282                if is_parent_ours && !is_child_ephemeral {
283                    our_output += child.coin.amount;
284                }
285
286                // Skip ephemeral coins
287                if our_spent_coin_ids.contains(&child.coin.coin_id()) {
288                    continue;
289                }
290
291                if let Some(transfer_type) = calculate_transfer_type(
292                    allocator,
293                    TransferTypeContext {
294                        puzzle_assertion_ids: &puzzle_assertion_ids,
295                        notarized_payments: &notarized_payments,
296                        create_coin: &create_coin,
297                        p2_puzzle_hash: parsed_memos.p2_puzzle_hash,
298                        full_puzzle_hash: nft.coin.puzzle_hash,
299                        is_parent_ours,
300                        is_child_ours,
301                    },
302                ) {
303                    let mut includes_unverifiable_updates = false;
304
305                    let new_uris = if let Ok(old_metadata) =
306                        NftMetadata::from_clvm(allocator, nft.info.metadata.ptr())
307                        && let Ok(new_metadata) =
308                            NftMetadata::from_clvm(allocator, child.info.metadata.ptr())
309                    {
310                        let mut new_uris = Vec::new();
311
312                        for uri in new_metadata.data_uris {
313                            if !old_metadata.data_uris.contains(&uri) {
314                                new_uris.push(MetadataUpdate {
315                                    kind: UriKind::Data,
316                                    uri,
317                                });
318                            }
319                        }
320
321                        for uri in new_metadata.metadata_uris {
322                            if !old_metadata.metadata_uris.contains(&uri) {
323                                new_uris.push(MetadataUpdate {
324                                    kind: UriKind::Metadata,
325                                    uri,
326                                });
327                            }
328                        }
329
330                        for uri in new_metadata.license_uris {
331                            if !old_metadata.license_uris.contains(&uri) {
332                                new_uris.push(MetadataUpdate {
333                                    kind: UriKind::License,
334                                    uri,
335                                });
336                            }
337                        }
338
339                        new_uris
340                    } else {
341                        includes_unverifiable_updates |= nft.info.metadata != child.info.metadata;
342
343                        vec![]
344                    };
345
346                    nfts.push(ParsedNftTransfer {
347                        transfer_type,
348                        launcher_id: child.info.launcher_id,
349                        p2_puzzle_hash: parsed_memos.p2_puzzle_hash,
350                        coin: child.coin,
351                        clawback: parsed_memos.clawback,
352                        memos: parsed_memos.memos,
353                        new_uris,
354                        latest_owner: child.info.current_owner,
355                        includes_unverifiable_updates,
356                    });
357                }
358            }
359
360            let create_coins = conditions
361                .into_iter()
362                .filter_map(Condition::into_create_coin)
363                .collect::<Vec<_>>();
364
365            let notarized_payments =
366                if coin_spend.coin.puzzle_hash == SETTLEMENT_PAYMENT_HASH.into() {
367                    SettlementPaymentsSolution::from_clvm(allocator, solution)?.notarized_payments
368                } else {
369                    Vec::new()
370                };
371
372            for create_coin in create_coins {
373                let child_coin = Coin::new(
374                    coin_spend.coin.coin_id(),
375                    create_coin.puzzle_hash,
376                    create_coin.amount,
377                );
378
379                let child_coin_id = child_coin.coin_id();
380                let is_child_ephemeral = all_spent_coin_ids.contains(&child_coin_id);
381
382                // If this is a singleton, we've already emitted payments for the odd singleton output, so we can skip odd coins
383                if is_singleton && child_coin.amount % 2 == 1 {
384                    continue;
385                }
386
387                let parsed_memos = parse_memos(allocator, create_coin, false);
388                let is_child_ours = parsed_memos.p2_puzzle_hash == our_p2_puzzle_hash;
389
390                total_output += child_coin.amount;
391
392                if is_parent_ours && !is_child_ephemeral {
393                    our_output += child_coin.amount;
394                }
395
396                // Skip ephemeral coins
397                if our_spent_coin_ids.contains(&child_coin_id) {
398                    continue;
399                }
400
401                if let Some(transfer_type) = calculate_transfer_type(
402                    allocator,
403                    TransferTypeContext {
404                        puzzle_assertion_ids: &puzzle_assertion_ids,
405                        notarized_payments: &notarized_payments,
406                        create_coin: &create_coin,
407                        p2_puzzle_hash: parsed_memos.p2_puzzle_hash,
408                        full_puzzle_hash: coin_spend.coin.puzzle_hash,
409                        is_parent_ours,
410                        is_child_ours,
411                    },
412                ) {
413                    payments.push(ParsedPayment {
414                        transfer_type,
415                        asset_id: None,
416                        hidden_puzzle_hash: None,
417                        p2_puzzle_hash: parsed_memos.p2_puzzle_hash,
418                        coin: child_coin,
419                        clawback: parsed_memos.clawback,
420                        memos: parsed_memos.memos,
421                    });
422                }
423            }
424        }
425
426        Ok(Self {
427            new_custody_hash,
428            payments,
429            nfts,
430            drop_coins,
431            fee_paid: our_input.saturating_sub(our_output),
432            total_fee: total_input.saturating_sub(total_output),
433        })
434    }
435}
436
437fn vault_p2_puzzle_hash(launcher_id: Bytes32) -> Bytes32 {
438    mips_puzzle_hash(
439        0,
440        vec![],
441        SingletonMember::new(launcher_id).curry_tree_hash(),
442        true,
443    )
444    .into()
445}
446
447#[derive(Debug, Clone)]
448struct ParsedDelegatedSpend {
449    new_custody_hash: Option<TreeHash>,
450    our_spent_coin_ids: HashSet<Bytes32>,
451    puzzle_assertion_ids: HashSet<Bytes32>,
452    drop_coins: Vec<DropCoin>,
453}
454
455fn parse_delegated_spend(
456    allocator: &mut Allocator,
457    delegated_spend: Spend,
458    spent_coin_ids: &HashSet<Bytes32>,
459) -> Result<ParsedDelegatedSpend, DriverError> {
460    let vault_output = run_puzzle(allocator, delegated_spend.puzzle, delegated_spend.solution)?;
461    let vault_conditions = Vec::<Condition>::from_clvm(allocator, vault_output)?;
462
463    let mut new_custody_hash = None;
464    let mut our_spent_coin_ids = HashSet::new();
465    let mut puzzle_assertion_ids = HashSet::new();
466    let mut drop_coins = Vec::new();
467
468    for condition in vault_conditions {
469        match condition {
470            Condition::CreateCoin(condition) => {
471                if condition.amount % 2 == 1 {
472                    // The vault singleton is being recreated
473                    new_custody_hash = Some(condition.puzzle_hash.into());
474                } else {
475                    drop_coins.push(DropCoin {
476                        puzzle_hash: condition.puzzle_hash,
477                        amount: condition.amount,
478                    });
479                }
480            }
481            Condition::SendMessage(condition) => {
482                // If the receiver isn't a specific coin id, we prevent signing
483                let sender = MessageFlags::decode(condition.mode, MessageSide::Sender);
484                let receiver = MessageFlags::decode(condition.mode, MessageSide::Receiver);
485
486                if sender != MessageFlags::PUZZLE
487                    || receiver != MessageFlags::COIN
488                    || condition.data.len() != 1
489                {
490                    // TODO: Handle vault of vaults
491                    return Err(DriverError::MissingSpend);
492                }
493
494                // If we're authorizing a spend, it must be in the revealed coin spends
495                // We can't authorize the same spend twice
496                let coin_id = Bytes32::from_clvm(allocator, condition.data[0])?;
497
498                if !spent_coin_ids.contains(&coin_id) || !our_spent_coin_ids.insert(coin_id) {
499                    return Err(DriverError::MissingSpend);
500                }
501            }
502            Condition::AssertPuzzleAnnouncement(condition) => {
503                puzzle_assertion_ids.insert(condition.announcement_id);
504            }
505            _ => {}
506        }
507    }
508
509    Ok(ParsedDelegatedSpend {
510        new_custody_hash,
511        our_spent_coin_ids,
512        puzzle_assertion_ids,
513        drop_coins,
514    })
515}
516
517#[derive(Debug, Clone)]
518struct ParsedConditions {
519    puzzle_assertion_ids: HashSet<Bytes32>,
520    all_created_coin_ids: HashSet<Bytes32>,
521}
522
523fn parse_our_conditions(
524    allocator: &mut Allocator,
525    coin_spends: &[CoinSpend],
526    our_coin_ids: &HashSet<Bytes32>,
527    mut puzzle_assertion_ids: HashSet<Bytes32>,
528) -> Result<ParsedConditions, DriverError> {
529    let mut all_created_coin_ids = HashSet::new();
530
531    for coin_spend in coin_spends {
532        let coin_id = coin_spend.coin.coin_id();
533        let puzzle = coin_spend.puzzle_reveal.to_clvm(allocator)?;
534        let solution = coin_spend.solution.to_clvm(allocator)?;
535        let output = run_puzzle(allocator, puzzle, solution)?;
536        let conditions = Vec::<Condition>::from_clvm(allocator, output)?;
537
538        for condition in conditions {
539            match condition {
540                Condition::AssertPuzzleAnnouncement(condition) => {
541                    if our_coin_ids.contains(&coin_id) {
542                        puzzle_assertion_ids.insert(condition.announcement_id);
543                    }
544                }
545                Condition::CreateCoin(condition) => {
546                    all_created_coin_ids.insert(
547                        Coin::new(coin_id, condition.puzzle_hash, condition.amount).coin_id(),
548                    );
549                }
550                _ => {}
551            }
552        }
553    }
554
555    Ok(ParsedConditions {
556        puzzle_assertion_ids,
557        all_created_coin_ids,
558    })
559}
560
561/// The idea here is to order coin spends by the order in which they were created.
562/// This simplifies keeping track of the lineage and latest updates of singletons such as NFTs.
563/// Coins which weren't created in this transaction are first, followed by coins which they created, and so on.
564fn reorder_coin_spends(mut coin_spends: Vec<CoinSpend>) -> Vec<CoinSpend> {
565    let mut reordered_coin_spends = Vec::new();
566    let mut remaining_spent_coin_ids: HashSet<Bytes32> =
567        coin_spends.iter().map(|cs| cs.coin.coin_id()).collect();
568
569    while !coin_spends.is_empty() {
570        coin_spends.retain(|cs| {
571            if remaining_spent_coin_ids.contains(&cs.coin.parent_coin_info) {
572                true
573            } else {
574                remaining_spent_coin_ids.remove(&cs.coin.coin_id());
575                reordered_coin_spends.push(cs.clone());
576                false
577            }
578        });
579    }
580
581    reordered_coin_spends
582}
583
584#[derive(Debug, Clone)]
585struct ParsedMemos {
586    p2_puzzle_hash: Bytes32,
587    clawback: Option<ClawbackV2>,
588    memos: Vec<String>,
589}
590
591fn parse_memos(
592    allocator: &Allocator,
593    p2_create_coin: CreateCoin<NodePtr>,
594    requires_hint: bool,
595) -> ParsedMemos {
596    // If there is no memo list, there's nothing to parse and we can assume there's no clawback
597    let Memos::Some(memos) = p2_create_coin.memos else {
598        return ParsedMemos {
599            p2_puzzle_hash: p2_create_coin.puzzle_hash,
600            clawback: None,
601            memos: Vec::new(),
602        };
603    };
604
605    // If there is both a hint and a valid clawback memo that correctly calculates the puzzle hash,
606    // then we can parse the clawback and return the rest of the memos, if they are bytes.
607    if let Ok((hint, (clawback_memo, rest))) =
608        <(Bytes32, (NodePtr, NodePtr))>::from_clvm(allocator, memos)
609        && let Some(clawback) = ClawbackV2::from_memo(
610            allocator,
611            clawback_memo,
612            hint,
613            p2_create_coin.amount,
614            requires_hint,
615            p2_create_coin.puzzle_hash,
616        )
617    {
618        return ParsedMemos {
619            p2_puzzle_hash: clawback.receiver_puzzle_hash,
620            clawback: Some(clawback),
621            memos: parse_memo_list(allocator, rest),
622        };
623    }
624
625    // If we're parsing a CAT output, we can remove the hint from the memos if applicable.
626    if requires_hint && let Ok((_hint, rest)) = <(Bytes32, NodePtr)>::from_clvm(allocator, memos) {
627        return ParsedMemos {
628            p2_puzzle_hash: p2_create_coin.puzzle_hash,
629            clawback: None,
630            memos: parse_memo_list(allocator, rest),
631        };
632    }
633
634    // Otherwise, we assume there's no clawback and return the memos as is, if they are bytes.
635    ParsedMemos {
636        p2_puzzle_hash: p2_create_coin.puzzle_hash,
637        clawback: None,
638        memos: parse_memo_list(allocator, memos),
639    }
640}
641
642fn parse_memo_list(allocator: &Allocator, memos: NodePtr) -> Vec<String> {
643    let memos = Vec::<NodePtr>::from_clvm(allocator, memos).unwrap_or_default();
644
645    let mut result = Vec::new();
646
647    for memo in memos {
648        let Ok(memo) = String::from_clvm(allocator, memo) else {
649            continue;
650        };
651        result.push(memo);
652    }
653
654    result
655}
656
657#[derive(Debug, Clone, Copy)]
658struct TransferTypeContext<'a> {
659    puzzle_assertion_ids: &'a HashSet<Bytes32>,
660    notarized_payments: &'a Vec<NotarizedPayment>,
661    create_coin: &'a CreateCoin<NodePtr>,
662    p2_puzzle_hash: Bytes32,
663    full_puzzle_hash: Bytes32,
664    is_parent_ours: bool,
665    is_child_ours: bool,
666}
667
668fn calculate_transfer_type(
669    allocator: &Allocator,
670    context: TransferTypeContext<'_>,
671) -> Option<TransferType> {
672    let TransferTypeContext {
673        puzzle_assertion_ids,
674        notarized_payments,
675        create_coin,
676        p2_puzzle_hash,
677        full_puzzle_hash,
678        is_parent_ours,
679        is_child_ours,
680    } = context;
681
682    if is_parent_ours && !is_child_ours {
683        // We know that the coin spend is authorized by the delegated spend, and we don't own the child coin
684        // Therefore, it's a valid sent payment
685        if p2_puzzle_hash == BURN_PUZZLE_HASH {
686            Some(TransferType::Burned)
687        } else if p2_puzzle_hash == SETTLEMENT_PAYMENT_HASH.into() {
688            Some(TransferType::Offered)
689        } else {
690            Some(TransferType::Sent)
691        }
692    } else if !is_parent_ours
693        && is_child_ours
694        && let Some(notarized_payment) = notarized_payments.iter().find(|np| {
695            np.payments
696                .iter()
697                .any(|p| p.puzzle_hash == create_coin.puzzle_hash && p.amount == create_coin.amount)
698        })
699    {
700        let notarized_payment_hash = tree_hash_notarized_payment(allocator, notarized_payment);
701        let settlement_announcement_id = announcement_id(full_puzzle_hash, notarized_payment_hash);
702
703        // Since the parent spend isn't verifiable, we need to know that we've asserted the payment
704        // Otherwise, it may as well not exist since we could be being lied to by the coin spend provider
705        if puzzle_assertion_ids.contains(&settlement_announcement_id) {
706            Some(TransferType::Received)
707        } else {
708            None
709        }
710    } else if is_parent_ours && is_child_ours {
711        // For non-fungible assets that we sent to ourself, we can assume they are updated
712        Some(TransferType::Updated)
713    } else {
714        None
715    }
716}
717
718#[cfg(test)]
719mod tests {
720    use super::*;
721
722    use anyhow::Result;
723    use chia_puzzles::SINGLETON_LAUNCHER_HASH;
724    use chia_sdk_test::Simulator;
725    use chia_sdk_types::Conditions;
726    use rstest::rstest;
727
728    use crate::{Action, Id, SpendContext, Spends, TestVault};
729
730    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
731    enum AssetKind {
732        Xch,
733        Cat,
734        RevocableCat,
735    }
736
737    #[rstest]
738    fn test_clear_signing_sent(
739        #[values(AssetKind::Xch, AssetKind::Cat, AssetKind::RevocableCat)] asset_kind: AssetKind,
740        #[values(0, 100)] fee: u64,
741    ) -> Result<()> {
742        let mut sim = Simulator::new();
743        let mut ctx = SpendContext::new();
744
745        let alice = TestVault::mint(&mut sim, &mut ctx, 1000 + fee)?;
746        let bob = TestVault::mint(&mut sim, &mut ctx, 0)?;
747
748        let (id, asset_id) = if let AssetKind::Cat | AssetKind::RevocableCat = asset_kind {
749            let result = alice.spend(
750                &mut sim,
751                &mut ctx,
752                &[Action::single_issue_cat(
753                    if let AssetKind::RevocableCat = asset_kind {
754                        Some(Bytes32::default())
755                    } else {
756                        None
757                    },
758                    1000,
759                )],
760            )?;
761
762            let asset_id = result.outputs.cats[0][0].info.asset_id;
763            let id = Id::Existing(asset_id);
764            (id, Some(asset_id))
765        } else {
766            (Id::Xch, None)
767        };
768
769        let result = alice.spend(
770            &mut sim,
771            &mut ctx,
772            &[
773                Action::send(id, bob.puzzle_hash(), 1000, Memos::None),
774                Action::fee(fee),
775            ],
776        )?;
777
778        let reveal = VaultSpendReveal {
779            launcher_id: alice.launcher_id(),
780            custody_hash: alice.custody_hash(),
781            delegated_spend: result.delegated_spend,
782        };
783
784        let tx = VaultTransaction::parse(&mut ctx, &reveal, result.coin_spends)?;
785        assert_eq!(tx.new_custody_hash, Some(alice.custody_hash()));
786        assert_eq!(tx.payments.len(), 1);
787        assert_eq!(tx.fee_paid, fee);
788        assert_eq!(tx.total_fee, fee);
789
790        let payment = &tx.payments[0];
791        assert_eq!(payment.transfer_type, TransferType::Sent);
792        assert_eq!(payment.asset_id, asset_id);
793        assert_eq!(payment.p2_puzzle_hash, bob.puzzle_hash());
794        assert_eq!(payment.coin.amount, 1000);
795
796        Ok(())
797    }
798
799    #[rstest]
800    fn test_clear_signing_received(
801        #[values(AssetKind::Xch, AssetKind::Cat, AssetKind::RevocableCat)] asset_kind: AssetKind,
802        #[values(true, false)] disable_settlement_assertions: bool,
803        #[values(0, 100)] alice_fee: u64,
804        #[values(0, 100)] bob_fee: u64,
805    ) -> Result<()> {
806        let mut sim = Simulator::new();
807        let mut ctx = SpendContext::new();
808
809        let alice = TestVault::mint(&mut sim, &mut ctx, 1000 + alice_fee)?;
810        let bob = TestVault::mint(&mut sim, &mut ctx, bob_fee)?;
811
812        let (id, asset_id) = if let AssetKind::Cat | AssetKind::RevocableCat = asset_kind {
813            let result = alice.spend(
814                &mut sim,
815                &mut ctx,
816                &[Action::single_issue_cat(
817                    if let AssetKind::RevocableCat = asset_kind {
818                        Some(Bytes32::default())
819                    } else {
820                        None
821                    },
822                    1000,
823                )],
824            )?;
825
826            let asset_id = result.outputs.cats[0][0].info.asset_id;
827            let id = Id::Existing(asset_id);
828            (id, Some(asset_id))
829        } else {
830            (Id::Xch, None)
831        };
832
833        let result = alice.spend(
834            &mut sim,
835            &mut ctx,
836            &[
837                Action::send(id, SETTLEMENT_PAYMENT_HASH.into(), 1000, Memos::None),
838                Action::fee(alice_fee),
839            ],
840        )?;
841
842        let reveal = VaultSpendReveal {
843            launcher_id: bob.launcher_id(),
844            custody_hash: bob.custody_hash(),
845            delegated_spend: result.delegated_spend,
846        };
847
848        let tx = VaultTransaction::parse(&mut ctx, &reveal, result.coin_spends)?;
849
850        assert_eq!(tx.payments.len(), 1);
851        assert_eq!(tx.fee_paid, alice_fee);
852        assert_eq!(tx.total_fee, alice_fee);
853
854        let payment = &tx.payments[0];
855        assert_eq!(payment.transfer_type, TransferType::Offered);
856        assert_eq!(payment.asset_id, asset_id);
857        assert_eq!(payment.p2_puzzle_hash, SETTLEMENT_PAYMENT_HASH.into());
858        assert_eq!(payment.coin.amount, 1000);
859
860        let mut spends = Spends::new(bob.puzzle_hash());
861        if id == Id::Xch {
862            spends.add(result.outputs.xch[0]);
863        } else {
864            spends.add(result.outputs.cats[&id][0]);
865        }
866        spends.conditions.disable_settlement_assertions = disable_settlement_assertions;
867
868        let result = bob.custom_spend(
869            &mut sim,
870            &mut ctx,
871            &[Action::fee(bob_fee)],
872            spends,
873            Conditions::new(),
874        )?;
875
876        let reveal = VaultSpendReveal {
877            launcher_id: bob.launcher_id(),
878            custody_hash: bob.custody_hash(),
879            delegated_spend: result.delegated_spend,
880        };
881
882        let tx = VaultTransaction::parse(&mut ctx, &reveal, result.coin_spends)?;
883
884        if disable_settlement_assertions {
885            assert_eq!(tx.payments.len(), 0);
886            assert_eq!(tx.fee_paid, bob_fee);
887            assert_eq!(tx.total_fee, bob_fee);
888        } else {
889            assert_eq!(tx.payments.len(), 1);
890            assert_eq!(tx.fee_paid, bob_fee);
891            assert_eq!(tx.total_fee, bob_fee);
892
893            let payment = &tx.payments[0];
894            assert_eq!(payment.transfer_type, TransferType::Received);
895            assert_eq!(payment.asset_id, asset_id);
896            assert_eq!(payment.p2_puzzle_hash, bob.puzzle_hash());
897            assert_eq!(payment.coin.amount, 1000);
898        }
899
900        Ok(())
901    }
902
903    #[rstest]
904    fn test_clear_signing_nft_lifecycle() -> Result<()> {
905        let mut sim = Simulator::new();
906        let mut ctx = SpendContext::new();
907
908        let alice = TestVault::mint(&mut sim, &mut ctx, 1)?;
909        let bob = TestVault::mint(&mut sim, &mut ctx, 0)?;
910
911        let result = alice.spend(&mut sim, &mut ctx, &[Action::mint_empty_nft()])?;
912
913        let reveal = VaultSpendReveal {
914            launcher_id: alice.launcher_id(),
915            custody_hash: alice.custody_hash(),
916            delegated_spend: result.delegated_spend,
917        };
918
919        let tx = VaultTransaction::parse(&mut ctx, &reveal, result.coin_spends)?;
920
921        assert_eq!(tx.payments.len(), 1);
922        assert_eq!(tx.nfts.len(), 1);
923        assert_eq!(tx.fee_paid, 0);
924        assert_eq!(tx.total_fee, 0);
925
926        // Even though this is for an NFT mint, the launcher is tracked as a sent payment
927        let payment = &tx.payments[0];
928        assert_eq!(payment.transfer_type, TransferType::Sent);
929        assert_eq!(payment.p2_puzzle_hash, SINGLETON_LAUNCHER_HASH.into());
930        assert_eq!(payment.coin.amount, 0);
931
932        // The NFT should be included
933        let nft = &tx.nfts[0];
934        assert_eq!(nft.transfer_type, TransferType::Updated);
935        assert_eq!(nft.p2_puzzle_hash, alice.puzzle_hash());
936        assert!(!nft.includes_unverifiable_updates);
937
938        // Transfer the NFT to Bob
939        let nft_id = Id::Existing(nft.launcher_id);
940        let bob_hint = ctx.hint(bob.puzzle_hash())?;
941
942        let result = alice.spend(
943            &mut sim,
944            &mut ctx,
945            &[Action::send(nft_id, bob.puzzle_hash(), 1, bob_hint)],
946        )?;
947
948        let reveal = VaultSpendReveal {
949            launcher_id: alice.launcher_id(),
950            custody_hash: alice.custody_hash(),
951            delegated_spend: result.delegated_spend,
952        };
953
954        let tx = VaultTransaction::parse(&mut ctx, &reveal, result.coin_spends)?;
955
956        assert_eq!(tx.payments.len(), 0);
957        assert_eq!(tx.nfts.len(), 1);
958        assert_eq!(tx.fee_paid, 0);
959        assert_eq!(tx.total_fee, 0);
960
961        let nft = &tx.nfts[0];
962        assert_eq!(nft.transfer_type, TransferType::Sent);
963        assert_eq!(nft.p2_puzzle_hash, bob.puzzle_hash());
964        assert!(!nft.includes_unverifiable_updates);
965
966        Ok(())
967    }
968
969    #[rstest]
970    fn test_clear_signing_split(
971        #[values(AssetKind::Xch, AssetKind::Cat, AssetKind::RevocableCat)] asset_kind: AssetKind,
972        #[values(0, 100)] fee: u64,
973    ) -> Result<()> {
974        let mut sim = Simulator::new();
975        let mut ctx = SpendContext::new();
976
977        let alice = TestVault::mint(&mut sim, &mut ctx, 1000 + fee)?;
978
979        let (id, asset_id) = if let AssetKind::Cat | AssetKind::RevocableCat = asset_kind {
980            let result = alice.spend(
981                &mut sim,
982                &mut ctx,
983                &[Action::single_issue_cat(
984                    if let AssetKind::RevocableCat = asset_kind {
985                        Some(Bytes32::default())
986                    } else {
987                        None
988                    },
989                    1000,
990                )],
991            )?;
992
993            let asset_id = result.outputs.cats[0][0].info.asset_id;
994            let id = Id::Existing(asset_id);
995            (id, Some(asset_id))
996        } else {
997            (Id::Xch, None)
998        };
999
1000        let result = alice.spend(
1001            &mut sim,
1002            &mut ctx,
1003            &[
1004                Action::send(id, alice.puzzle_hash(), 250, Memos::None),
1005                Action::send(id, alice.puzzle_hash(), 250, Memos::None),
1006                Action::send(id, alice.puzzle_hash(), 250, Memos::None),
1007                Action::send(id, alice.puzzle_hash(), 250, Memos::None),
1008                Action::fee(fee),
1009            ],
1010        )?;
1011
1012        let reveal = VaultSpendReveal {
1013            launcher_id: alice.launcher_id(),
1014            custody_hash: alice.custody_hash(),
1015            delegated_spend: result.delegated_spend,
1016        };
1017
1018        let tx = VaultTransaction::parse(&mut ctx, &reveal, result.coin_spends)?;
1019        assert_eq!(tx.new_custody_hash, Some(alice.custody_hash()));
1020        assert_eq!(tx.payments.len(), 4);
1021        assert_eq!(tx.fee_paid, fee);
1022        assert_eq!(tx.total_fee, fee);
1023
1024        for payment in &tx.payments {
1025            assert_eq!(payment.transfer_type, TransferType::Updated);
1026            assert_eq!(payment.asset_id, asset_id);
1027            assert_eq!(payment.p2_puzzle_hash, alice.puzzle_hash());
1028            assert_eq!(payment.coin.amount, 250);
1029        }
1030
1031        let result = alice.spend(
1032            &mut sim,
1033            &mut ctx,
1034            &[Action::send(id, alice.puzzle_hash(), 1000, Memos::None)],
1035        )?;
1036
1037        let reveal = VaultSpendReveal {
1038            launcher_id: alice.launcher_id(),
1039            custody_hash: alice.custody_hash(),
1040            delegated_spend: result.delegated_spend,
1041        };
1042
1043        let tx = VaultTransaction::parse(&mut ctx, &reveal, result.coin_spends)?;
1044        assert_eq!(tx.new_custody_hash, Some(alice.custody_hash()));
1045        assert_eq!(tx.payments.len(), 1);
1046        assert_eq!(tx.fee_paid, 0);
1047        assert_eq!(tx.total_fee, 0);
1048
1049        let payment = &tx.payments[0];
1050        assert_eq!(payment.transfer_type, TransferType::Updated);
1051        assert_eq!(payment.asset_id, asset_id);
1052        assert_eq!(payment.p2_puzzle_hash, alice.puzzle_hash());
1053        assert_eq!(payment.coin.amount, 1000);
1054
1055        Ok(())
1056    }
1057}