Skip to main content

chia_sdk_driver/clear_signing/
vault_transaction.rs

1use std::collections::{HashMap, HashSet};
2
3use chia_protocol::Bytes32;
4use chia_puzzle_types::cat::CatSolution;
5use chia_sdk_types::{Condition, Mod, puzzles::SingletonMember};
6use clvm_traits::{FromClvm, ToClvm, clvm_quote};
7use clvm_utils::tree_hash;
8use clvmr::NodePtr;
9use indexmap::{IndexMap, IndexSet};
10
11use crate::{
12    AssertedNotarizedPayment, AssertedPayment, ClawbackInfo, ClearSigningAsset, CustodyInfo,
13    DriverError, DropCoin, Facts, Issuance, IssuanceKind, LinkedOffer, P2ConditionsOrSingletonInfo,
14    P2SingletonInfo, ParsedAsset, ParsedChild, ParsedSpend, RevealedCoinSpend, Reveals, Spend,
15    SpendContext, TransferType, VaultMessage, VaultOutput, build_linked_offer,
16    get_extra_delta_message, mips_puzzle_hash, parse_asserted_requested_payments, parse_children,
17    parse_run_cat_tail, parse_spend, parse_vault_delegated_spend, split_asserted_payments,
18};
19
20/// The purpose of this is to provide sufficient information to verify what is happening to a vault and its assets
21/// as a result of a transaction at a glance. Information that is not verifiable should not be included or displayed.
22/// We can still allow transactions which are not fully verifiable, but a conservative summary should be provided.
23#[derive(Debug, Clone)]
24pub struct VaultTransaction {
25    /// If a new vault coin is created (i.e. the vault isn't melted), this will be set.
26    /// It's the new inner puzzle hash and amount of the vault singleton. If the puzzle hash is different, the custody
27    /// configuration has changed. If the amount is different, XCH is being added or removed from its value. The hash
28    /// can be validated against a [`MipsMemo`](crate::MipsMemo) so that you know what specifically is happening.
29    pub vault_child: Option<VaultOutput>,
30    /// Coins which were created as outputs of the vault singleton spend itself, for example to mint NFTs.
31    pub drop_coins: Vec<DropCoin>,
32    /// The spends (and their children) which were authorized by the vault.
33    pub spends: Vec<VerifiedSpend>,
34    /// CAT supply changes (mints or melts) authorized by spends in this transaction. Each issuance
35    /// records the coin id of the spend that emitted the `RunCatTail` condition, so callers can
36    /// match it back to the corresponding `VerifiedSpend` by coin id if they want to.
37    pub issuances: Vec<Issuance>,
38    /// Settlement payments which were both revealed and asserted by the transaction.
39    pub asserted_payments: Vec<AssertedNotarizedPayment>,
40    /// Individual asserted payment outputs whose puzzle hash matches one of the vault's known p2 puzzle hashes.
41    pub received_payments: Vec<AssertedPayment>,
42    /// Individual asserted payment outputs whose puzzle hash doesn't match one of the vault's known p2 puzzle hashes.
43    /// These are not necessarily paid by the vault, but the transaction requires them to happen.
44    pub external_payments: Vec<AssertedPayment>,
45    /// Per-asset value flow for verified spends and asserted payments.
46    pub asset_flows: Vec<AssetFlow>,
47    /// If this transaction creates one or more offer pre-split coins, this rolls them up into a
48    /// description of the future offer. Per-leg details (the individual pre-split amounts) live
49    /// in the children's transfer type field.
50    ///
51    /// [`None`] means the transaction does not link any offer pre-split coins.
52    pub linked_offer: Option<LinkedOffer>,
53    /// The amount of fees reserved by coin spends authorized by the vault.
54    pub reserved_fee: u64,
55    /// The known p2 puzzle hashes of the vault, based on revealed nonces (the first address is included by default).
56    pub p2_puzzle_hashes: Vec<Bytes32>,
57    /// The delegated puzzle hash that is being signed for.
58    pub delegated_puzzle_hash: Bytes32,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub struct AssetFlow {
63    pub asset: ClearSigningAsset,
64    pub input_amount: u64,
65    pub output_amount: u64,
66    pub issued_amount: u64,
67    pub melted_amount: u64,
68    pub received_amount: u64,
69    pub paid_amount: u64,
70    pub unaccounted_amount: u64,
71}
72
73#[derive(Debug, Clone)]
74pub struct VerifiedSpend {
75    pub asset: ParsedAsset,
76    pub clawback: Option<ClawbackInfo>,
77    pub custody: CustodyInfo,
78    pub children: Vec<ParsedChild>,
79    pub revoked: bool,
80}
81
82pub fn parse_vault_transaction(
83    mut reveals: Reveals,
84    ctx: &mut SpendContext,
85    launcher_id: Bytes32,
86    delegated_spend: Spend,
87) -> Result<VaultTransaction, DriverError> {
88    let mut facts = Facts::default();
89
90    let vault_spend = parse_vault_delegated_spend(&mut facts, ctx, delegated_spend)?;
91
92    let mut parsed_spends = HashMap::new();
93
94    for spend in reveals.coin_spends().copied().collect::<Vec<_>>() {
95        let parsed_spend = parse_spend(&mut reveals, ctx, &spend)?;
96
97        if let Some(
98            CustodyInfo::P2Singleton(P2SingletonInfo {
99                launcher_id: spend_launcher_id,
100                ..
101            })
102            | CustodyInfo::P2ConditionsOrSingleton(P2ConditionsOrSingletonInfo {
103                launcher_id: spend_launcher_id,
104                ..
105            }),
106        ) = &parsed_spend.custody
107            && spend_launcher_id != &launcher_id
108        {
109            continue;
110        }
111
112        parsed_spends.insert(spend.coin.coin_id(), parsed_spend);
113    }
114
115    let mut messages_by_coin: IndexMap<Bytes32, Vec<VaultMessage>> = IndexMap::new();
116    for message in vault_spend.messages {
117        messages_by_coin
118            .entry(message.spent_coin_id)
119            .or_default()
120            .push(message);
121    }
122
123    let mut verified_spends = Vec::new();
124    let mut issuances: Vec<Issuance> = Vec::new();
125
126    for (coin_id, messages) in messages_by_coin {
127        let spend = *reveals
128            .coin_spend(coin_id)
129            .ok_or(DriverError::MissingSpend)?;
130        let parsed_spend = parsed_spends
131            .remove(&coin_id)
132            .ok_or(DriverError::MissingSpend)?;
133
134        let Some(verified_spend) = verify_spend(
135            &mut reveals,
136            &mut facts,
137            ctx,
138            spend,
139            parsed_spend,
140            &messages,
141            &mut issuances,
142        )?
143        else {
144            return Err(DriverError::InvalidLinkedCustody);
145        };
146
147        verified_spends.push(verified_spend);
148    }
149
150    let mut stack: IndexSet<Bytes32> = verified_spends
151        .iter()
152        .flat_map(|spend| {
153            spend
154                .children
155                .iter()
156                .map(|child| child.asset.coin().coin_id())
157        })
158        .collect();
159
160    while let Some(coin_id) = stack.pop() {
161        if !facts.is_spend_asserted(coin_id) {
162            continue;
163        }
164
165        let Some(parsed_spend) = parsed_spends.remove(&coin_id) else {
166            continue;
167        };
168
169        let spend = *reveals
170            .coin_spend(coin_id)
171            .ok_or(DriverError::MissingSpend)?;
172
173        let Some(verified_spend) = verify_spend(
174            &mut reveals,
175            &mut facts,
176            ctx,
177            spend,
178            parsed_spend,
179            &[],
180            &mut issuances,
181        )?
182        else {
183            continue;
184        };
185
186        for child in &verified_spend.children {
187            stack.insert(child.asset.coin().coin_id());
188        }
189
190        verified_spends.push(verified_spend);
191    }
192
193    // If the transaction expires after the required expiration time of the spend,
194    // we can't guarantee that the transaction will expire when the spend expires,
195    // which is a security vulnerability.
196    if facts.required_expiration_time().is_some_and(|required| {
197        facts
198            .actual_expiration_time()
199            .is_none_or(|expiration| expiration > required)
200    }) {
201        return Err(DriverError::UnguaranteedClawBack);
202    }
203
204    let delegated_puzzle_hash = tree_hash(ctx, delegated_spend.puzzle).into();
205
206    let p2_puzzle_hashes = calculate_p2_puzzle_hashes(&reveals, launcher_id);
207    let p2_puzzle_hash_set = p2_puzzle_hashes.iter().copied().collect();
208    let reserved_fee = facts.reserved_fees().try_into()?;
209    let asserted_payments = parse_asserted_requested_payments(&reveals, &facts, ctx)?;
210    let split_payments = split_asserted_payments(
211        &asserted_payments,
212        &p2_puzzle_hash_set,
213        reveals.asset_info(),
214    );
215    let linked_offer = build_linked_offer(
216        &reveals,
217        ctx,
218        &verified_spends,
219        launcher_id,
220        &p2_puzzle_hash_set,
221    )?;
222
223    // Hydrate ephemerally spent bulletin children.
224    let mut bulletins = HashMap::new();
225
226    for spend in &verified_spends {
227        if let ParsedAsset::Bulletin(bulletin) = &spend.asset {
228            bulletins.insert(bulletin.coin.coin_id(), bulletin.clone());
229        }
230    }
231
232    for spend in &mut verified_spends {
233        for child in &mut spend.children {
234            if matches!(child.asset, ParsedAsset::Xch(_))
235                && let Some(bulletin) = bulletins.remove(&child.asset.coin().coin_id())
236            {
237                child.asset = ParsedAsset::Bulletin(bulletin);
238            }
239        }
240    }
241
242    let asset_flows = build_asset_flows(
243        &verified_spends,
244        &split_payments.received_payments,
245        &split_payments.external_payments,
246        &vault_spend.drop_coins,
247        &issuances,
248        reserved_fee,
249    );
250
251    Ok(VaultTransaction {
252        vault_child: vault_spend.child,
253        drop_coins: vault_spend.drop_coins,
254        spends: verified_spends,
255        issuances,
256        asserted_payments,
257        received_payments: split_payments.received_payments,
258        external_payments: split_payments.external_payments,
259        asset_flows,
260        linked_offer,
261        reserved_fee,
262        p2_puzzle_hashes,
263        delegated_puzzle_hash,
264    })
265}
266
267fn verify_spend(
268    reveals: &mut Reveals,
269    facts: &mut Facts,
270    allocator: &mut SpendContext,
271    spend: RevealedCoinSpend,
272    parsed_spend: ParsedSpend,
273    messages: &[VaultMessage],
274    issuances: &mut Vec<Issuance>,
275) -> Result<Option<VerifiedSpend>, DriverError> {
276    let Some(custody) = parsed_spend.custody else {
277        return Ok(None);
278    };
279
280    let conditions: &[Condition] = match &custody {
281        CustodyInfo::P2Singleton(P2SingletonInfo { conditions, .. })
282        | CustodyInfo::P2ConditionsOrSingleton(P2ConditionsOrSingletonInfo {
283            conditions, ..
284        })
285        | CustodyInfo::DelegatedConditions(conditions) => conditions,
286    };
287
288    if matches!(&custody, CustodyInfo::P2ConditionsOrSingleton(_))
289        && !conditions.contains(&Condition::assert_my_coin_id(spend.coin.coin_id()))
290    {
291        return Err(DriverError::MissingP2ConditionsOrSingletonAssertion);
292    }
293
294    if messages.is_empty() && custody.receives_message() {
295        return Err(DriverError::MissingVaultMessage);
296    }
297
298    let conditions_hash = if custody.receives_message() {
299        let delegated_puzzle = clvm_quote!(conditions).to_clvm(allocator)?;
300        Some(tree_hash(allocator, delegated_puzzle))
301    } else {
302        None
303    };
304
305    let run_cat_tail = if matches!(parsed_spend.asset, ParsedAsset::Cat(_)) {
306        parse_run_cat_tail(allocator, conditions)?
307    } else {
308        None
309    };
310
311    let issuance = if let Some(run_cat_tail) = run_cat_tail {
312        let cat_solution = CatSolution::<NodePtr>::from_clvm(allocator, spend.solution)?;
313
314        Some(Issuance {
315            coin_id: spend.coin.coin_id(),
316            asset_id: run_cat_tail.asset_id,
317            extra_delta: cat_solution.extra_delta,
318            kind: run_cat_tail.kind,
319        })
320    } else {
321        None
322    };
323
324    let mut tail_matched = false;
325    let mut custody_matched = false;
326
327    for message in messages {
328        if let Some(hash) = conditions_hash
329            && message.data.as_ref() == hash.as_ref()
330        {
331            if custody_matched {
332                return Err(DriverError::DuplicateVaultMessage);
333            }
334
335            custody_matched = true;
336        } else if let Some(issuance) = &issuance
337            && matches!(issuance.kind, IssuanceKind::EverythingWithSingleton { .. })
338            && message.data == get_extra_delta_message(issuance.extra_delta)
339        {
340            if tail_matched {
341                return Err(DriverError::DuplicateVaultMessage);
342            }
343
344            tail_matched = true;
345        } else {
346            return Err(DriverError::UnmatchedVaultMessage);
347        }
348    }
349
350    if custody.receives_message() && !custody_matched {
351        return Err(DriverError::WrongConditions);
352    }
353
354    if let Some(time) = parsed_spend.required_expiration_time {
355        facts.update_required_expiration_time(time);
356    }
357
358    let children = parse_children(
359        reveals,
360        facts,
361        allocator,
362        &parsed_spend.asset,
363        spend,
364        conditions,
365        parsed_spend.required_expiration_time.is_some(),
366    )?;
367
368    // Issuances are either:
369    // 1. Invalid, in which case the transaction will atomically fail because this spend is verified
370    // 2. Valid, in which case the information is correct
371    if let Some(issuance) = issuance {
372        issuances.push(issuance);
373    }
374
375    Ok(Some(VerifiedSpend {
376        asset: parsed_spend.asset,
377        clawback: parsed_spend.clawback,
378        custody,
379        children,
380        revoked: parsed_spend.revoked,
381    }))
382}
383
384#[derive(Debug, Clone)]
385struct AssetFlowTotals {
386    asset: ClearSigningAsset,
387    input_amount: u64,
388    output_amount: u64,
389    issued_amount: u64,
390    melted_amount: u64,
391    received_amount: u64,
392    paid_amount: u64,
393}
394
395fn build_asset_flows(
396    spends: &[VerifiedSpend],
397    received_payments: &[AssertedPayment],
398    external_payments: &[AssertedPayment],
399    drop_coins: &[DropCoin],
400    issuances: &[Issuance],
401    reserved_fee: u64,
402) -> Vec<AssetFlow> {
403    let child_coin_ids: HashSet<Bytes32> = spends
404        .iter()
405        .flat_map(|spend| spend.children.iter())
406        .map(|child| child.asset.coin().coin_id())
407        .collect();
408    let spend_by_coin_id: HashMap<Bytes32, &VerifiedSpend> = spends
409        .iter()
410        .map(|spend| (spend.asset.coin().coin_id(), spend))
411        .collect();
412    let xch_child_coin_ids: HashSet<Bytes32> = spends
413        .iter()
414        .filter(|spend| matches!(spend.asset, ParsedAsset::Xch(_) | ParsedAsset::Bulletin(_)))
415        .flat_map(|spend| spend.children.iter())
416        .map(|child| child.asset.coin().coin_id())
417        .collect();
418
419    let mut flows = IndexMap::<Option<Bytes32>, AssetFlowTotals>::new();
420
421    for spend in spends {
422        if !child_coin_ids.contains(&spend.asset.coin().coin_id()) {
423            asset_flow_mut(&mut flows, asset_from_parsed(&spend.asset)).input_amount +=
424                spend.asset.coin().amount;
425        }
426
427        for child in &spend.children {
428            if !spend_by_coin_id.contains_key(&child.asset.coin().coin_id())
429                && child.transfer_type != TransferType::Offered
430            {
431                asset_flow_mut(&mut flows, asset_from_parsed(&child.asset)).output_amount +=
432                    child.asset.coin().amount;
433            }
434        }
435    }
436
437    for asserted_payment in received_payments {
438        asset_flow_mut(&mut flows, asserted_payment.asset).received_amount +=
439            asserted_payment.payment.amount;
440    }
441
442    for asserted_payment in external_payments {
443        asset_flow_mut(&mut flows, asserted_payment.asset).paid_amount +=
444            asserted_payment.payment.amount;
445    }
446
447    for drop_coin in drop_coins {
448        asset_flow_mut(&mut flows, ClearSigningAsset::Xch).output_amount += drop_coin.amount;
449    }
450
451    for spend in spends {
452        if matches!(spend.asset, ParsedAsset::Nft(_))
453            && xch_child_coin_ids.contains(&spend.asset.coin().parent_coin_info)
454        {
455            asset_flow_mut(&mut flows, ClearSigningAsset::Xch).melted_amount +=
456                spend.asset.coin().amount;
457        }
458    }
459
460    for issuance in issuances {
461        let Some(spend) = spend_by_coin_id.get(&issuance.coin_id) else {
462            continue;
463        };
464
465        let ParsedAsset::Cat(cat) = &spend.asset else {
466            continue;
467        };
468
469        if cat.info.asset_id != issuance.asset_id {
470            continue;
471        }
472
473        let cat_asset = asset_from_parsed(&spend.asset);
474
475        if xch_child_coin_ids.contains(&issuance.coin_id) {
476            let amount = spend.asset.coin().amount;
477            asset_flow_mut(&mut flows, cat_asset).issued_amount += amount;
478            asset_flow_mut(&mut flows, ClearSigningAsset::Xch).melted_amount += amount;
479        }
480
481        if issuance.extra_delta > 0 {
482            let amount = u64::try_from(issuance.extra_delta).unwrap();
483            asset_flow_mut(&mut flows, cat_asset).issued_amount += amount;
484            asset_flow_mut(&mut flows, ClearSigningAsset::Xch).melted_amount += amount;
485        } else if issuance.extra_delta < 0 {
486            let amount = u64::try_from(-issuance.extra_delta).unwrap();
487            asset_flow_mut(&mut flows, cat_asset).melted_amount += amount;
488            asset_flow_mut(&mut flows, ClearSigningAsset::Xch).issued_amount += amount;
489        }
490    }
491
492    flows
493        .into_values()
494        .filter_map(|flow| {
495            let unaccounted_amount = flow
496                .input_amount
497                .saturating_add(flow.issued_amount)
498                .saturating_sub(flow.output_amount)
499                .saturating_sub(flow.melted_amount)
500                .saturating_sub(flow.paid_amount)
501                .saturating_sub(if matches!(flow.asset, ClearSigningAsset::Xch) {
502                    reserved_fee
503                } else {
504                    0
505                });
506
507            let include = flow.input_amount > 0
508                || flow.output_amount > 0
509                || flow.received_amount > 0
510                || flow.paid_amount > 0
511                || flow.issued_amount > 0
512                || flow.melted_amount > 0;
513
514            include.then_some((flow, unaccounted_amount))
515        })
516        .map(|(flow, unaccounted_amount)| AssetFlow {
517            asset: flow.asset,
518            input_amount: flow.input_amount,
519            output_amount: flow.output_amount,
520            issued_amount: flow.issued_amount,
521            melted_amount: flow.melted_amount,
522            received_amount: flow.received_amount,
523            paid_amount: flow.paid_amount,
524            unaccounted_amount,
525        })
526        .collect()
527}
528
529fn asset_flow_mut(
530    flows: &mut IndexMap<Option<Bytes32>, AssetFlowTotals>,
531    asset: ClearSigningAsset,
532) -> &mut AssetFlowTotals {
533    flows
534        .entry(asset_flow_key(asset))
535        .or_insert_with(|| AssetFlowTotals {
536            asset,
537            input_amount: 0,
538            output_amount: 0,
539            issued_amount: 0,
540            melted_amount: 0,
541            received_amount: 0,
542            paid_amount: 0,
543        })
544}
545
546fn asset_flow_key(asset: ClearSigningAsset) -> Option<Bytes32> {
547    match asset {
548        ClearSigningAsset::Xch => None,
549        ClearSigningAsset::Cat { asset_id, .. }
550        | ClearSigningAsset::Nft {
551            launcher_id: asset_id,
552            ..
553        } => Some(asset_id),
554    }
555}
556
557fn asset_from_parsed(asset: &ParsedAsset) -> ClearSigningAsset {
558    match asset {
559        ParsedAsset::Xch(_) | ParsedAsset::Bulletin(_) => ClearSigningAsset::Xch,
560        ParsedAsset::Cat(cat) => ClearSigningAsset::Cat {
561            asset_id: cat.info.asset_id,
562            hidden_puzzle_hash: cat.info.hidden_puzzle_hash,
563        },
564        ParsedAsset::Nft(nft) => ClearSigningAsset::Nft {
565            launcher_id: nft.info.launcher_id,
566            metadata: nft.info.metadata,
567            metadata_updater_puzzle_hash: nft.info.metadata_updater_puzzle_hash,
568            royalty_puzzle_hash: nft.info.royalty_puzzle_hash,
569            royalty_basis_points: nft.info.royalty_basis_points,
570        },
571    }
572}
573
574fn calculate_p2_puzzle_hashes(reveals: &Reveals, launcher_id: Bytes32) -> Vec<Bytes32> {
575    let mut p2_puzzle_hashes = Vec::new();
576
577    for nonce in reveals.vault_nonces() {
578        p2_puzzle_hashes.push(
579            mips_puzzle_hash(
580                nonce,
581                vec![],
582                SingletonMember::new(launcher_id).curry_tree_hash(),
583                true,
584            )
585            .into(),
586        );
587    }
588
589    p2_puzzle_hashes
590}
591
592pub fn iter_final_children(spends: &[VerifiedSpend]) -> impl Iterator<Item = &ParsedChild> {
593    let spent_coin_ids: HashSet<Bytes32> = spends
594        .iter()
595        .map(|spend| spend.asset.coin().coin_id())
596        .collect();
597
598    spends
599        .iter()
600        .flat_map(|spend| spend.children.iter())
601        .filter(move |child| !spent_coin_ids.contains(&child.asset.coin().coin_id()))
602}