chia_sdk_driver/primitives/
streamed_asset.rs

1use crate::{CatLayer, DriverError, HashedPtr, Layer, Puzzle, Spend, SpendContext};
2use chia_consensus::make_aggsig_final_message::u64_to_bytes;
3use chia_protocol::{Bytes, Bytes32, Coin, CoinSpend};
4use chia_puzzle_types::{
5    CoinProof, LineageProof, Memos,
6    cat::{CatArgs, CatSolution},
7};
8use chia_sdk_types::{Condition, Conditions};
9use chia_sha2::Sha256;
10use clvm_traits::FromClvm;
11use clvm_utils::TreeHash;
12use clvmr::{Allocator, NodePtr, op_utils::u64_from_bytes};
13
14use crate::{StreamLayer, StreamPuzzleSolution};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct StreamingPuzzleInfo {
18    pub recipient: Bytes32,
19    pub clawback_ph: Option<Bytes32>,
20    pub end_time: u64,
21    pub last_payment_time: u64,
22}
23
24impl StreamingPuzzleInfo {
25    pub fn new(
26        recipient: Bytes32,
27        clawback_ph: Option<Bytes32>,
28        end_time: u64,
29        last_payment_time: u64,
30    ) -> Self {
31        Self {
32            recipient,
33            clawback_ph,
34            end_time,
35            last_payment_time,
36        }
37    }
38
39    pub fn amount_to_be_paid(&self, my_coin_amount: u64, payment_time: u64) -> u64 {
40        // LAST_PAYMENT_TIME + (to_pay * (END_TIME - LAST_PAYMENT_TIME) / my_amount) = payment_time
41        // to_pay = my_amount * (payment_time - LAST_PAYMENT_TIME) / (END_TIME - LAST_PAYMENT_TIME)
42        my_coin_amount * (payment_time - self.last_payment_time)
43            / (self.end_time - self.last_payment_time)
44    }
45
46    pub fn get_hint(recipient: Bytes32) -> Bytes32 {
47        let mut s = Sha256::new();
48        s.update(b"s");
49        s.update(recipient.as_slice());
50        s.finalize().into()
51    }
52
53    pub fn get_launch_hints(&self) -> Vec<Bytes> {
54        let hint: Bytes = self.recipient.into();
55        let clawback_ph: Bytes = if let Some(clawback_ph) = self.clawback_ph {
56            clawback_ph.into()
57        } else {
58            Bytes::new(vec![])
59        };
60        let second_memo = u64_to_bytes(self.last_payment_time);
61        let third_memo = u64_to_bytes(self.end_time);
62
63        vec![hint, clawback_ph, second_memo.into(), third_memo.into()]
64    }
65
66    #[must_use]
67    pub fn with_last_payment_time(self, last_payment_time: u64) -> Self {
68        Self {
69            last_payment_time,
70            ..self
71        }
72    }
73
74    pub fn parse(allocator: &Allocator, puzzle: Puzzle) -> Result<Option<Self>, DriverError> {
75        let Some(layer) = StreamLayer::parse_puzzle(allocator, puzzle)? else {
76            return Ok(None);
77        };
78
79        Ok(Some(Self::from_layer(layer)))
80    }
81
82    pub fn into_layer(self) -> StreamLayer {
83        StreamLayer::new(
84            self.recipient,
85            self.clawback_ph,
86            self.end_time,
87            self.last_payment_time,
88        )
89    }
90
91    pub fn from_layer(layer: StreamLayer) -> Self {
92        Self {
93            recipient: layer.recipient,
94            clawback_ph: layer.clawback_ph,
95            end_time: layer.end_time,
96            last_payment_time: layer.last_payment_time,
97        }
98    }
99
100    pub fn inner_puzzle_hash(&self) -> TreeHash {
101        self.into_layer().puzzle_hash()
102    }
103
104    pub fn from_memos(memos: &[Bytes]) -> Result<Option<Self>, DriverError> {
105        if memos.len() < 4 || memos.len() > 5 {
106            return Ok(None);
107        }
108
109        let (recipient, clawback_ph, last_payment_time, end_time): (
110            Bytes32,
111            Option<Bytes32>,
112            u64,
113            u64,
114        ) = if memos.len() == 4 {
115            let Ok(recipient_b64): Result<Bytes32, _> = memos[0].clone().try_into() else {
116                return Ok(None);
117            };
118            let clawback_ph_b64: Option<Bytes32> = if memos[1].is_empty() {
119                None
120            } else {
121                let b32: Result<Bytes32, _> = memos[1].clone().try_into();
122                if let Ok(b32) = b32 {
123                    Some(b32)
124                } else {
125                    return Ok(None);
126                }
127            };
128            (
129                recipient_b64,
130                clawback_ph_b64,
131                u64_from_bytes(&memos[2]),
132                u64_from_bytes(&memos[3]),
133            )
134        } else {
135            let Ok(recipient_b64): Result<Bytes32, _> = memos[1].clone().try_into() else {
136                return Ok(None);
137            };
138            let clawback_ph_b64: Option<Bytes32> = if memos[2].is_empty() {
139                None
140            } else {
141                let b32: Result<Bytes32, _> = memos[2].clone().try_into();
142                if let Ok(b32) = b32 {
143                    Some(b32)
144                } else {
145                    return Ok(None);
146                }
147            };
148            (
149                recipient_b64,
150                clawback_ph_b64,
151                u64_from_bytes(&memos[3]),
152                u64_from_bytes(&memos[4]),
153            )
154        };
155
156        Ok(Some(Self::new(
157            recipient,
158            clawback_ph,
159            end_time,
160            last_payment_time,
161        )))
162    }
163}
164
165#[derive(Debug, Clone, PartialEq, Eq)]
166#[must_use]
167pub struct StreamedAsset {
168    pub coin: Coin,
169    pub asset_id: Option<Bytes32>,
170    pub proof: Option<LineageProof>,
171    pub info: StreamingPuzzleInfo,
172}
173
174impl StreamedAsset {
175    pub fn cat(
176        coin: Coin,
177        asset_id: Bytes32,
178        proof: LineageProof,
179        info: StreamingPuzzleInfo,
180    ) -> Self {
181        Self {
182            coin,
183            asset_id: Some(asset_id),
184            proof: Some(proof),
185            info,
186        }
187    }
188
189    pub fn xch(coin: Coin, info: StreamingPuzzleInfo) -> Self {
190        Self {
191            coin,
192            asset_id: None,
193            proof: None,
194            info,
195        }
196    }
197
198    pub fn construct_puzzle(&self, ctx: &mut SpendContext) -> Result<NodePtr, DriverError> {
199        let inner_layer = self.info.into_layer();
200        if let Some(asset_id) = self.asset_id {
201            CatLayer::new(asset_id, inner_layer).construct_puzzle(ctx)
202        } else {
203            inner_layer.construct_puzzle(ctx)
204        }
205    }
206
207    pub fn construct_solution(
208        &self,
209        ctx: &mut SpendContext,
210        payment_time: u64,
211        clawback: bool,
212    ) -> Result<NodePtr, DriverError> {
213        let inner_layer = self.info.into_layer();
214        let inner_solution = StreamPuzzleSolution {
215            my_amount: self.coin.amount,
216            payment_time,
217            to_pay: self.info.amount_to_be_paid(self.coin.amount, payment_time),
218            clawback,
219        };
220
221        if let Some(asset_id) = self.asset_id {
222            CatLayer::new(asset_id, inner_layer).construct_solution(
223                ctx,
224                CatSolution {
225                    inner_puzzle_solution: inner_solution,
226                    lineage_proof: Some(self.proof.ok_or(DriverError::Custom(
227                        "Missing lineage proof for CAT steam".to_string(),
228                    ))?),
229                    prev_coin_id: self.coin.coin_id(),
230                    this_coin_info: self.coin,
231                    next_coin_proof: CoinProof {
232                        parent_coin_info: self.coin.parent_coin_info,
233                        inner_puzzle_hash: self.info.inner_puzzle_hash().into(),
234                        amount: self.coin.amount,
235                    },
236                    prev_subtotal: 0,
237                    extra_delta: 0,
238                },
239            )
240        } else {
241            inner_layer.construct_solution(ctx, inner_solution)
242        }
243    }
244
245    pub fn spend(
246        &self,
247        ctx: &mut SpendContext,
248        payment_time: u64,
249        clawback: bool,
250    ) -> Result<(), DriverError> {
251        let puzzle = self.construct_puzzle(ctx)?;
252        let solution = self.construct_solution(ctx, payment_time, clawback)?;
253
254        ctx.spend(self.coin, Spend::new(puzzle, solution))
255    }
256
257    // if clawback, 3rd arg = last paid amount
258    pub fn from_parent_spend(
259        ctx: &mut SpendContext,
260        coin_spend: &CoinSpend,
261    ) -> Result<(Option<Self>, bool, u64), DriverError> {
262        let parent_coin = coin_spend.coin;
263        let parent_puzzle_ptr = ctx.alloc(&coin_spend.puzzle_reveal)?;
264        let parent_puzzle = Puzzle::from_clvm(ctx, parent_puzzle_ptr)?;
265        let parent_solution = ctx.alloc(&coin_spend.solution)?;
266
267        if let Some((asset_id, proof, streaming_layer, streaming_solution)) =
268            if let Ok(Some(layers)) = CatLayer::<StreamLayer>::parse_puzzle(ctx, parent_puzzle) {
269                // parent is CAT streaming coin
270
271                Some((
272                    Some(layers.asset_id),
273                    Some(LineageProof {
274                        parent_parent_coin_info: parent_coin.parent_coin_info,
275                        parent_inner_puzzle_hash: layers.inner_puzzle.puzzle_hash().into(),
276                        parent_amount: parent_coin.amount,
277                    }),
278                    layers.inner_puzzle,
279                    ctx.extract::<CatSolution<StreamPuzzleSolution>>(parent_solution)?
280                        .inner_puzzle_solution,
281                ))
282            } else if let Ok(Some(layer)) = StreamLayer::parse_puzzle(ctx, parent_puzzle) {
283                Some((
284                    None,
285                    None,
286                    layer,
287                    ctx.extract::<StreamPuzzleSolution>(parent_solution)?,
288                ))
289            } else {
290                None
291            }
292        {
293            if streaming_solution.clawback {
294                return Ok((None, true, streaming_solution.to_pay));
295            }
296
297            let new_amount = parent_coin.amount - streaming_solution.to_pay;
298
299            let new_inner_layer = StreamLayer::new(
300                streaming_layer.recipient,
301                streaming_layer.clawback_ph,
302                streaming_layer.end_time,
303                streaming_solution.payment_time,
304            );
305            let new_puzzle_hash = if let Some(asset_id) = asset_id {
306                CatArgs::curry_tree_hash(asset_id, new_inner_layer.puzzle_hash())
307            } else {
308                new_inner_layer.puzzle_hash()
309            };
310
311            return Ok((
312                Some(Self {
313                    coin: Coin::new(parent_coin.coin_id(), new_puzzle_hash.into(), new_amount),
314                    asset_id,
315                    proof,
316                    // last payment time should've been updated by the spend
317                    info: StreamingPuzzleInfo::from_layer(streaming_layer)
318                        .with_last_payment_time(streaming_solution.payment_time),
319                }),
320                false,
321                0,
322            ));
323        }
324
325        // if parent is not CAT/XCH streaming coin,
326        // check if parent created eve streaming asset
327        let parent_puzzle_ptr = parent_puzzle.ptr();
328        let output = ctx.run(parent_puzzle_ptr, parent_solution)?;
329        let conds = ctx.extract::<Conditions<NodePtr>>(output)?;
330
331        let (asset_id, proof) = if let Ok(Some(parent_layer)) =
332            CatLayer::<HashedPtr>::parse_puzzle(ctx, parent_puzzle)
333        {
334            (
335                Some(parent_layer.asset_id),
336                Some(LineageProof {
337                    parent_parent_coin_info: parent_coin.parent_coin_info,
338                    parent_inner_puzzle_hash: parent_layer.inner_puzzle.tree_hash().into(),
339                    parent_amount: parent_coin.amount,
340                }),
341            )
342        } else {
343            (None, None)
344        };
345
346        for cond in conds {
347            let Condition::CreateCoin(cc) = cond else {
348                continue;
349            };
350
351            let Memos::Some(memos) = cc.memos else {
352                continue;
353            };
354
355            let memos = ctx.extract::<Vec<Bytes>>(memos)?;
356            let Some(candidate_info) = StreamingPuzzleInfo::from_memos(&memos)? else {
357                continue;
358            };
359            let candidate_inner_puzzle_hash = candidate_info.inner_puzzle_hash();
360            let candidate_puzzle_hash = if let Some(asset_id) = asset_id {
361                CatArgs::curry_tree_hash(asset_id, candidate_inner_puzzle_hash)
362            } else {
363                candidate_inner_puzzle_hash
364            };
365
366            if cc.puzzle_hash != candidate_puzzle_hash.into() {
367                continue;
368            }
369
370            return Ok((
371                Some(Self {
372                    coin: Coin::new(
373                        parent_coin.coin_id(),
374                        candidate_puzzle_hash.into(),
375                        cc.amount,
376                    ),
377                    asset_id,
378                    proof,
379                    info: candidate_info,
380                }),
381                false,
382                0,
383            ));
384        }
385
386        Ok((None, false, 0))
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use std::slice;
393
394    use chia_protocol::Bytes;
395    use chia_sdk_test::{Benchmark, Simulator};
396    use clvm_utils::tree_hash;
397    use clvmr::serde::node_from_bytes;
398    use rstest::rstest;
399
400    use crate::{
401        Cat, CatSpend, FungibleAsset, STREAM_PUZZLE, STREAM_PUZZLE_HASH, SpendWithConditions,
402        StandardLayer,
403    };
404
405    use super::*;
406
407    #[test]
408    fn test_puzzle_hash() {
409        let mut allocator = Allocator::new();
410
411        let ptr = node_from_bytes(&mut allocator, &STREAM_PUZZLE).unwrap();
412        assert_eq!(tree_hash(&allocator, ptr), STREAM_PUZZLE_HASH);
413    }
414
415    #[rstest]
416    fn test_streamed_asset(#[values(true, false)] xch_stream: bool) -> anyhow::Result<()> {
417        let mut ctx = SpendContext::new();
418        let mut sim = Simulator::new();
419        let mut benchmark = Benchmark::new(format!(
420            "Streamed {}",
421            if xch_stream { "XCH" } else { "CAT" }
422        ));
423
424        let claim_intervals = [1000, 2000, 500, 1000, 10];
425        let clawback_offset = 1234;
426        let total_claim_time = claim_intervals.iter().sum::<u64>() + clawback_offset;
427
428        // Create asset (XCH/CAT) & launch streaming coin
429        let user_bls = sim.bls(0);
430        let minter_bls = sim.bls(1000);
431
432        let clawback_puzzle_ptr = ctx.alloc(&1)?;
433        let clawback_ph = ctx.tree_hash(clawback_puzzle_ptr);
434        let streaming_inner_puzzle = StreamLayer::new(
435            user_bls.puzzle_hash,
436            Some(clawback_ph.into()),
437            total_claim_time + 1000,
438            1000,
439        );
440        let streaming_inner_puzzle_hash: Bytes32 = streaming_inner_puzzle.puzzle_hash().into();
441
442        let launch_hints =
443            ctx.alloc(&StreamingPuzzleInfo::from_layer(streaming_inner_puzzle).get_launch_hints())?;
444        let create_inner_spend = StandardLayer::new(minter_bls.pk).spend_with_conditions(
445            &mut ctx,
446            Conditions::new().create_coin(
447                streaming_inner_puzzle_hash,
448                minter_bls.coin.amount,
449                Memos::Some(launch_hints),
450            ),
451        )?;
452
453        let (expected_coin, expected_asset_id, expected_lp) = if xch_stream {
454            ctx.spend(minter_bls.coin, create_inner_spend)?;
455
456            (
457                minter_bls
458                    .coin
459                    .make_child(streaming_inner_puzzle_hash, minter_bls.coin.amount),
460                None,
461                None,
462            )
463        } else {
464            let (issue_cat, cats) = Cat::issue_with_coin(
465                &mut ctx,
466                minter_bls.coin.coin_id(),
467                minter_bls.coin.amount,
468                Conditions::new().create_coin(
469                    minter_bls.puzzle_hash,
470                    minter_bls.coin.amount,
471                    Memos::None,
472                ),
473            )?;
474            StandardLayer::new(minter_bls.pk).spend(&mut ctx, minter_bls.coin, issue_cat)?;
475            sim.spend_coins(ctx.take(), slice::from_ref(&minter_bls.sk))?;
476
477            let cats = Cat::spend_all(&mut ctx, &[CatSpend::new(cats[0], create_inner_spend)])?;
478
479            (
480                cats[0].coin,
481                Some(cats[0].info.asset_id),
482                cats[0].lineage_proof,
483            )
484        };
485
486        let spends = ctx.take();
487        let launch_spend = spends.last().unwrap().clone();
488        benchmark.add_spends(
489            &mut ctx,
490            &mut sim,
491            spends,
492            "create",
493            slice::from_ref(&minter_bls.sk),
494        )?;
495        sim.set_next_timestamp(1000 + claim_intervals[0])?;
496
497        // spend streaming CAT
498        let mut streamed_asset = StreamedAsset::from_parent_spend(&mut ctx, &launch_spend)?
499            .0
500            .unwrap();
501        assert_eq!(
502            streamed_asset,
503            StreamedAsset {
504                coin: expected_coin,
505                asset_id: expected_asset_id,
506                proof: expected_lp,
507                info: StreamingPuzzleInfo::new(
508                    user_bls.puzzle_hash,
509                    Some(clawback_ph.into()),
510                    total_claim_time + 1000,
511                    1000,
512                ),
513            },
514        );
515
516        let mut claim_time = sim.next_timestamp();
517        for (i, _interval) in claim_intervals.iter().enumerate() {
518            /* Payment is always based on last block's timestamp */
519            if i < claim_intervals.len() - 1 {
520                sim.pass_time(claim_intervals[i + 1]);
521            }
522
523            // to claim the payment, user needs to send a message to the streaming CAT
524            let user_coin = sim.new_coin(user_bls.puzzle_hash, 0);
525            let message_to_send: Bytes = Bytes::new(u64_to_bytes(claim_time));
526            let coin_id_ptr = ctx.alloc(&streamed_asset.coin.coin_id())?;
527            StandardLayer::new(user_bls.pk).spend(
528                &mut ctx,
529                user_coin,
530                Conditions::new().send_message(23, message_to_send, vec![coin_id_ptr]),
531            )?;
532
533            streamed_asset.spend(&mut ctx, claim_time, false)?;
534
535            let spends = ctx.take();
536            let streamed_asset_spend = spends.last().unwrap().clone();
537            benchmark.add_spends(
538                &mut ctx,
539                &mut sim,
540                spends,
541                "claim",
542                slice::from_ref(&user_bls.sk),
543            )?;
544
545            // set up for next iteration
546            if i < claim_intervals.len() - 1 {
547                claim_time += claim_intervals[i + 1];
548            }
549            let (Some(new_streamed_asset), clawback, _) =
550                StreamedAsset::from_parent_spend(&mut ctx, &streamed_asset_spend)?
551            else {
552                panic!("Failed to parse new streamed asset");
553            };
554
555            assert!(!clawback);
556            streamed_asset = new_streamed_asset;
557        }
558
559        // Test clawback
560        assert!(streamed_asset.coin.amount > 0);
561        let clawback_msg_coin = sim.new_coin(clawback_ph.into(), 0);
562        let claim_time = sim.next_timestamp() + 1;
563        let message_to_send: Bytes = Bytes::new(u64_to_bytes(claim_time));
564        let coin_id_ptr = ctx.alloc(&streamed_asset.coin.coin_id())?;
565        let solution =
566            ctx.alloc(&Conditions::new().send_message(23, message_to_send, vec![coin_id_ptr]))?;
567        ctx.spend(clawback_msg_coin, Spend::new(clawback_puzzle_ptr, solution))?;
568
569        streamed_asset.spend(&mut ctx, claim_time, true)?;
570
571        let spends = ctx.take();
572        let streamed_asset_spend = spends.last().unwrap().clone();
573        benchmark.add_spends(
574            &mut ctx,
575            &mut sim,
576            spends,
577            "clawback",
578            slice::from_ref(&user_bls.sk),
579        )?;
580
581        let (new_streamed_asset, clawback, _paid_amount_if_clawback) =
582            StreamedAsset::from_parent_spend(&mut ctx, &streamed_asset_spend)?;
583
584        assert!(clawback);
585        assert!(new_streamed_asset.is_none());
586
587        benchmark.print_summary(Some(&format!(
588            "streamed-{}.costs",
589            if xch_stream { "xch" } else { "cat" }
590        )));
591
592        Ok(())
593    }
594}