1use chia_protocol::Bytes32;
2use chia_puzzle_types::singleton::SingletonArgs;
3use chia_puzzle_types::{nft::NftRoyaltyTransferPuzzleArgs, singleton::SingletonStruct};
4use chia_sdk_types::{
5 Conditions, MerkleProof, Mod, announcement_id,
6 puzzles::{
7 NONCE_WRAPPER_PUZZLE_HASH, NonceWrapperArgs, P2DelegatedBySingletonLayerArgs,
8 P2DelegatedBySingletonLayerSolution, RefreshNftInfo, RewardDistributorDlInfo,
9 RewardDistributorEntryPayoutInfo, RewardDistributorEntrySlotValue,
10 RewardDistributorRefreshNftsFromDlActionArgs,
11 RewardDistributorRefreshNftsFromDlActionSolution, RewardDistributorRefreshNftsTotals,
12 RewardDistributorSlotNonce, SlotAndNfts,
13 },
14};
15use clvm_traits::{clvm_quote, clvm_tuple};
16use clvm_utils::{CurriedProgram, ToTreeHash, TreeHash};
17use clvmr::NodePtr;
18
19use crate::{
20 DriverError, Layer, Nft, P2DelegatedBySingletonLayer, RewardDistributor,
21 RewardDistributorConstants, RewardDistributorCreatedAnnouncementPrefix,
22 RewardDistributorNftStakeEntry, RewardDistributorRefreshNftsFromDlActionLog,
23 RewardDistributorStateTransition, RewardDistributorType, SingletonAction, Slot, Spend,
24 SpendContext,
25};
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct RewardDistributorRefreshAction {
29 pub launcher_id: Bytes32,
30 pub max_second_offset: u64,
31 pub distributor_type: RewardDistributorType,
32 pub precision: u64,
33}
34
35impl ToTreeHash for RewardDistributorRefreshAction {
36 fn tree_hash(&self) -> TreeHash {
37 if let Ok(args) = Self::new_args(
38 self.launcher_id,
39 self.max_second_offset,
40 self.distributor_type,
41 self.precision,
42 ) {
43 args.curry_tree_hash()
44 } else {
45 TreeHash::new([0; 32])
46 }
47 }
48}
49
50impl SingletonAction<RewardDistributor> for RewardDistributorRefreshAction {
51 fn from_constants(constants: &RewardDistributorConstants) -> Self {
52 Self {
53 launcher_id: constants.launcher_id,
54 max_second_offset: constants.max_seconds_offset,
55 distributor_type: constants.reward_distributor_type,
56 precision: constants.precision,
57 }
58 }
59}
60
61impl RewardDistributorRefreshAction {
62 pub fn new_args(
63 launcher_id: Bytes32,
64 max_second_offset: u64,
65 distributor_type: RewardDistributorType,
66 precision: u64,
67 ) -> Result<RewardDistributorRefreshNftsFromDlActionArgs, DriverError> {
68 match distributor_type {
69 RewardDistributorType::CuratedNft {
70 store_launcher_id,
71 refreshable,
72 } => {
73 if !refreshable {
74 return Err(DriverError::Custom(
75 "Refresh action is only available in *refreshable* curated NFT mode"
76 .to_string(),
77 ));
78 }
79
80 Ok(RewardDistributorRefreshNftsFromDlActionArgs::new(
81 store_launcher_id,
82 Self::my_p2_puzzle_hash(launcher_id),
83 Slot::<()>::first_curry_hash(
84 launcher_id,
85 RewardDistributorSlotNonce::ENTRY.to_u64(),
86 )
87 .into(),
88 max_second_offset,
89 precision,
90 ))
91 }
92 _ => Err(DriverError::Custom(
93 "Refresh action is only available in curated NFT mode".to_string(),
94 )),
95 }
96 }
97
98 pub fn my_p2_puzzle_hash(launcher_id: Bytes32) -> Bytes32 {
99 P2DelegatedBySingletonLayerArgs::curry_tree_hash(
100 SingletonStruct::new(launcher_id).tree_hash().into(),
101 1,
102 )
103 .into()
104 }
105
106 fn construct_puzzle(&self, ctx: &mut SpendContext) -> Result<NodePtr, DriverError> {
107 let args = Self::new_args(
108 self.launcher_id,
109 self.max_second_offset,
110 self.distributor_type,
111 self.precision,
112 )?;
113
114 ctx.curry(args)
115 }
116
117 #[allow(clippy::cast_sign_loss)]
118 pub fn get_log(
119 ctx: &mut SpendContext,
120 solution: NodePtr,
121 changes: RewardDistributorStateTransition,
122 store_launcher_id: Bytes32,
123 ) -> Result<RewardDistributorRefreshNftsFromDlActionLog, DriverError> {
124 let params = ctx.extract::<RewardDistributorRefreshNftsFromDlActionSolution>(solution)?;
125
126 let spent_entry_slots = params
127 .slots_and_nfts
128 .iter()
129 .map(|e| e.existing_slot_value)
130 .collect();
131 let created_entry_slots = params
132 .slots_and_nfts
133 .iter()
134 .map(|e| {
135 Ok(RewardDistributorEntrySlotValue {
136 counter: e.existing_slot_value.counter + 1,
137 payout_puzzle_hash: e.existing_slot_value.payout_puzzle_hash,
138 initial_cumulative_payout: changes
139 .old_state
140 .round_reward_info
141 .cumulative_payout,
142 shares: u64::try_from(
143 i128::from(e.existing_slot_value.shares)
144 + i128::from(e.nfts_total_shares_delta),
145 )?,
146 })
147 })
148 .collect::<Result<Vec<_>, DriverError>>()?;
149 let nft_entries = params
150 .slots_and_nfts
151 .iter()
152 .flat_map(|slot| &slot.nfts)
153 .map(|nft| RewardDistributorNftStakeEntry {
154 launcher_id: nft.nft_launcher_id,
155 shares: nft.new_nft_shares,
156 })
157 .collect();
158
159 Ok(RewardDistributorRefreshNftsFromDlActionLog {
160 spent_entry_slots,
161 created_entry_slots,
162 nft_entries,
163 dl_root_hash: params.dl_root_hash,
164 dl_inner_puzzle_hash: params.dl_info.dl_inner_puzzle_hash,
165 dl_full_puzzle_hash: SingletonArgs::curry_tree_hash(
166 store_launcher_id,
167 params.dl_info.dl_inner_puzzle_hash.into(),
168 )
169 .into(),
170 changes,
171 })
172 }
173
174 #[allow(clippy::too_many_arguments)]
175 #[allow(clippy::cast_sign_loss)]
176 pub fn spend(
177 self,
178 ctx: &mut SpendContext,
179 distributor: &mut RewardDistributor,
180 slots: Vec<Slot<RewardDistributorEntrySlotValue>>,
181 nfts: &[&[Nft]],
182 nft_shares_delta: &[&[i64]],
183 nft_new_shares: &[&[u64]],
184 nft_inclusion_proofs: &[&[MerkleProof]],
185 dl_root_hash: Bytes32,
186 dl_metadata_rest_hash: Option<Bytes32>,
187 dl_metadata_updater_hash_hash: Bytes32,
188 dl_inner_puzzle_hash: Bytes32,
189 ) -> Result<(Conditions, Vec<Nft>), DriverError> {
190 let mut security_conditions = Conditions::new();
192 let mut slots_and_nfts = Vec::<SlotAndNfts>::new();
193 let mut created_nfts = Vec::<Nft>::new();
194
195 let my_inner_puzzle_hash: Bytes32 = distributor.info.inner_puzzle_hash().into();
196 let my_p2_puzzle_hash = Self::my_p2_puzzle_hash(self.launcher_id);
197 let my_p2_treehash: TreeHash = my_p2_puzzle_hash.into();
198 let my_singleton_struct_hash = SingletonStruct::new(self.launcher_id).tree_hash().into();
199
200 for (i, slot) in slots.into_iter().enumerate() {
201 let slot = distributor.actual_entry_slot_value(slot);
202 let mut nft_infos = Vec::<RefreshNftInfo>::new();
203 for (j, nft) in nfts[i].iter().enumerate() {
204 nft_infos.push(RefreshNftInfo {
206 nft_shares_delta: nft_shares_delta[i][j],
207 new_nft_shares: nft_new_shares[i][j],
208 nft_parent_id: nft.coin.parent_coin_info,
209 nft_launcher_id: nft.info.launcher_id,
210 nft_metadata_hash: nft.info.metadata.tree_hash().into(),
211 nft_metadata_updater_hash_hash: nft
212 .info
213 .metadata_updater_puzzle_hash
214 .tree_hash()
215 .into(),
216 nft_transfer_porgram_hash: NftRoyaltyTransferPuzzleArgs::curry_tree_hash(
217 nft.info.launcher_id,
218 nft.info.royalty_puzzle_hash,
219 nft.info.royalty_basis_points,
220 )
221 .into(),
222 nft_owner: nft.info.current_owner,
223 nft_inclusion_proof: nft_inclusion_proofs[i][j].clone(),
224 });
225
226 let new_nft_inner_puzzle_hash = CurriedProgram {
228 program: NONCE_WRAPPER_PUZZLE_HASH,
229 args: NonceWrapperArgs::<(Bytes32, u64), TreeHash> {
230 nonce: clvm_tuple!(
231 slot.info.value.payout_puzzle_hash,
232 nft_new_shares[i][j]
233 ),
234 inner_puzzle: my_p2_treehash,
235 },
236 }
237 .tree_hash()
238 .into();
239 let nft_p2 = P2DelegatedBySingletonLayer::new(my_singleton_struct_hash, 1);
240 let nft_inner_puzzle = nft_p2.construct_puzzle(ctx)?;
241 let old_nft_shares = u64::try_from(
242 i128::from(nft_new_shares[i][j]) - i128::from(nft_shares_delta[i][j]),
243 )?;
244 let nft_nonce: (Bytes32, u64) =
245 clvm_tuple!(slot.info.value.payout_puzzle_hash, old_nft_shares);
246 let nft_inner_puzzle = ctx.curry(NonceWrapperArgs::<(Bytes32, u64), NodePtr> {
247 nonce: nft_nonce,
248 inner_puzzle: nft_inner_puzzle,
249 })?;
250
251 let hint = ctx.hint(
252 (slot.info.value.payout_puzzle_hash, my_p2_puzzle_hash)
253 .tree_hash()
254 .into(),
255 )?;
256 let delegated_puzzle = ctx.alloc(&clvm_quote!(Conditions::new().create_coin(
257 new_nft_inner_puzzle_hash,
258 1,
259 hint,
260 )))?;
261 let nft_inner_solution = nft_p2.construct_solution(
262 ctx,
263 P2DelegatedBySingletonLayerSolution::<NodePtr, NodePtr> {
264 singleton_inner_puzzle_hash: my_inner_puzzle_hash,
265 delegated_puzzle,
266 delegated_solution: NodePtr::NIL,
267 },
268 )?;
269
270 created_nfts
271 .push(nft.spend(ctx, Spend::new(nft_inner_puzzle, nft_inner_solution))?);
272
273 security_conditions =
275 security_conditions.assert_puzzle_announcement(announcement_id(
276 distributor.coin.puzzle_hash,
277 RewardDistributorCreatedAnnouncementPrefix::refresh(nft.info.launcher_id),
278 ));
279 }
280
281 let payout_amount_precision = u128::from(slot.info.value.shares)
282 * (distributor
283 .pending_spend
284 .latest_state
285 .1
286 .round_reward_info
287 .cumulative_payout
288 - slot.info.value.initial_cumulative_payout);
289 let entry_payout_amount =
290 u64::try_from(payout_amount_precision / u128::from(self.precision))?;
291 let payout_rounding_error = payout_amount_precision % u128::from(self.precision);
292 slots_and_nfts.push(SlotAndNfts {
293 existing_slot_value: slot.info.value,
294 entry_payout_info: RewardDistributorEntryPayoutInfo {
295 payout_amount: entry_payout_amount,
296 payout_rounding_error,
297 },
298 nfts_total_shares_delta: nft_infos.iter().map(|e| e.nft_shares_delta).sum(),
299 nfts: nft_infos,
300 });
301 slot.spend(ctx, my_inner_puzzle_hash)?;
302 }
303
304 let action_solution = ctx.alloc(&RewardDistributorRefreshNftsFromDlActionSolution {
306 dl_root_hash,
307 dl_info: RewardDistributorDlInfo {
308 dl_metadata_rest_hash,
309 dl_metadata_updater_hash_hash,
310 dl_inner_puzzle_hash,
311 },
312 totals: RewardDistributorRefreshNftsTotals {
313 total_entry_payout_amount: slots_and_nfts
314 .iter()
315 .map(|e| e.entry_payout_info.payout_amount)
316 .sum(),
317 total_shares_delta: i128::from(
318 slots_and_nfts
319 .iter()
320 .map(|e| e.nfts_total_shares_delta)
321 .sum::<i64>(),
322 ),
323 total_payout_rounding_error: slots_and_nfts
324 .iter()
325 .map(|e| e.entry_payout_info.payout_rounding_error)
326 .sum(),
327 },
328 slots_and_nfts,
329 })?;
330 let action_puzzle = self.construct_puzzle(ctx)?;
331
332 distributor.insert_action_spend(ctx, Spend::new(action_puzzle, action_solution))?;
333
334 Ok((security_conditions, created_nfts))
335 }
336}
337
338#[cfg(test)]
339mod tests {
340 use chia_sdk_types::puzzles::{
341 RewardDistributorDlInfo, RewardDistributorEntryPayoutInfo,
342 RewardDistributorRefreshNftsFromDlActionSolution, RewardDistributorRefreshNftsTotals,
343 };
344
345 use super::*;
346 use crate::{
347 RewardDistributorState, RewardDistributorStateTransition, RoundRewardInfo, RoundTimeInfo,
348 };
349
350 fn id(byte: u8) -> Bytes32 {
351 Bytes32::new([byte; 32])
352 }
353
354 fn nft(launcher_id: Bytes32, shares_delta: i64, new_shares: u64) -> RefreshNftInfo {
355 RefreshNftInfo {
356 nft_shares_delta: shares_delta,
357 new_nft_shares: new_shares,
358 nft_parent_id: Bytes32::default(),
359 nft_launcher_id: launcher_id,
360 nft_metadata_hash: Bytes32::default(),
361 nft_metadata_updater_hash_hash: Bytes32::default(),
362 nft_transfer_porgram_hash: Bytes32::default(),
363 nft_owner: None,
364 nft_inclusion_proof: MerkleProof::new(0, vec![]),
365 }
366 }
367
368 fn slot(shares: u64, shares_delta: i64, nfts: Vec<RefreshNftInfo>) -> SlotAndNfts {
369 SlotAndNfts {
370 existing_slot_value: RewardDistributorEntrySlotValue {
371 counter: 0,
372 payout_puzzle_hash: Bytes32::default(),
373 initial_cumulative_payout: 0,
374 shares,
375 },
376 entry_payout_info: RewardDistributorEntryPayoutInfo {
377 payout_amount: 0,
378 payout_rounding_error: 0,
379 },
380 nfts_total_shares_delta: shares_delta,
381 nfts,
382 }
383 }
384
385 #[test]
386 fn refresh_log_maps_each_nft_to_its_new_shares_across_slot_groups() {
387 let first = id(1);
388 let second = id(2);
389 let third = id(3);
390 let mut ctx = SpendContext::new();
391 let solution = ctx
392 .alloc(&RewardDistributorRefreshNftsFromDlActionSolution {
393 dl_root_hash: id(4),
394 dl_info: RewardDistributorDlInfo {
395 dl_metadata_rest_hash: None,
396 dl_metadata_updater_hash_hash: id(5),
397 dl_inner_puzzle_hash: id(6),
398 },
399 totals: RewardDistributorRefreshNftsTotals {
400 total_entry_payout_amount: 0,
401 total_shares_delta: 1,
402 total_payout_rounding_error: 0,
403 },
404 slots_and_nfts: vec![
405 slot(12, -2, vec![nft(first, -4, 0), nft(second, 2, 2)]),
406 slot(7, 3, vec![nft(third, 3, 10)]),
407 ],
408 })
409 .unwrap();
410 let state = RewardDistributorState {
411 total_reserves: 0,
412 active_shares: 19,
413 round_reward_info: RoundRewardInfo {
414 cumulative_payout: 100,
415 remaining_rewards: 0,
416 },
417 round_time_info: RoundTimeInfo {
418 last_update: 0,
419 epoch_end: 0,
420 },
421 };
422
423 let log = RewardDistributorRefreshAction::get_log(
424 &mut ctx,
425 solution,
426 RewardDistributorStateTransition {
427 old_state: state,
428 new_state: RewardDistributorState {
429 active_shares: 20,
430 ..state
431 },
432 },
433 id(9),
434 )
435 .unwrap();
436
437 assert_eq!(
438 log.nft_entries,
439 vec![
440 RewardDistributorNftStakeEntry {
441 launcher_id: first,
442 shares: 0,
443 },
444 RewardDistributorNftStakeEntry {
445 launcher_id: second,
446 shares: 2,
447 },
448 RewardDistributorNftStakeEntry {
449 launcher_id: third,
450 shares: 10,
451 },
452 ]
453 );
454 }
455}