cw721_controllers/
nft_claim.rs

1use cosmwasm_schema::cw_serde;
2use cosmwasm_std::{Addr, BlockInfo, CustomQuery, Deps, Order, StdError, StdResult, Storage};
3use cw_storage_plus::{Bound, Map};
4use cw_utils::Expiration;
5use thiserror::Error;
6
7#[derive(Error, Debug, PartialEq)]
8pub enum NftClaimError {
9    #[error(transparent)]
10    Std(#[from] StdError),
11
12    #[error("NFT claim not found for {token_id}")]
13    NotFound { token_id: String },
14
15    #[error("NFT with ID {token_id} is not ready to be claimed")]
16    NotReady { token_id: String },
17}
18
19#[cw_serde]
20pub struct NftClaim {
21    pub token_id: String,
22    pub release_at: Expiration,
23}
24
25impl NftClaim {
26    pub fn new(token_id: String, release_at: Expiration) -> Self {
27        NftClaim {
28            token_id,
29            release_at,
30        }
31    }
32}
33
34pub struct NftClaims<'a>(Map<'a, (&'a Addr, &'a String), Expiration>);
35
36impl<'a> NftClaims<'a> {
37    pub const fn new(storage_key: &'a str) -> Self {
38        NftClaims(Map::new(storage_key))
39    }
40
41    /// Creates a number of NFT claims simultaneously for a given
42    /// address.
43    ///
44    /// # Invariants
45    ///
46    /// - token_ids must be deduplicated
47    /// - token_ids must not contain any IDs which are currently in
48    ///   the claims queue for ADDR. This can be ensured by requiring
49    ///   that claims are completed before the tokens may be restaked.
50    pub fn create_nft_claims(
51        &self,
52        storage: &mut dyn Storage,
53        addr: &Addr,
54        token_ids: Vec<String>,
55        release_at: Expiration,
56    ) -> StdResult<()> {
57        token_ids
58            .into_iter()
59            .map(|token_id| self.0.save(storage, (addr, &token_id), &release_at))
60            .collect::<StdResult<Vec<_>>>()?;
61        Ok(())
62    }
63
64    /// This iterates over all claims for the given IDs, removing them if they
65    /// are all mature and erroring if any is not.
66    pub fn claim_nfts(
67        &self,
68        storage: &mut dyn Storage,
69        addr: &Addr,
70        token_ids: &[String],
71        block: &BlockInfo,
72    ) -> Result<(), NftClaimError> {
73        token_ids
74            .iter()
75            .map(|token_id| -> Result<(), NftClaimError> {
76                match self.0.may_load(storage, (addr, token_id)) {
77                    Ok(Some(expiration)) => {
78                        // if claim is expired, remove it and continue
79                        if expiration.is_expired(block) {
80                            self.0.remove(storage, (addr, token_id));
81                            Ok(())
82                        } else {
83                            // if claim is not expired, error
84                            Err(NftClaimError::NotReady {
85                                token_id: token_id.to_string(),
86                            })
87                        }
88                    }
89                    // if claim is not found, error
90                    Ok(None) => Err(NftClaimError::NotFound {
91                        token_id: token_id.clone(),
92                    }),
93                    Err(e) => Err(e.into()),
94                }
95            })
96            .collect::<Result<Vec<_>, NftClaimError>>()
97            .map(|_| ())
98    }
99
100    pub fn query_claims<Q: CustomQuery>(
101        &self,
102        deps: Deps<Q>,
103        address: &Addr,
104        start_after: Option<&String>,
105        limit: Option<u32>,
106    ) -> StdResult<Vec<NftClaim>> {
107        let limit = limit.map(|l| l as usize).unwrap_or(usize::MAX);
108        let start = start_after.map(Bound::<&String>::exclusive);
109
110        self.0
111            .prefix(address)
112            .range(deps.storage, start, None, Order::Ascending)
113            .take(limit)
114            .map(|item| {
115                item.map(|(token_id, release_at)| NftClaim {
116                    token_id,
117                    release_at,
118                })
119            })
120            .collect()
121    }
122}
123
124#[cfg(test)]
125mod test {
126    use cosmwasm_std::{
127        testing::{mock_dependencies, mock_env},
128        Order,
129    };
130
131    use super::*;
132    const TEST_BAYC_TOKEN_ID: &str = "BAYC";
133    const TEST_CRYPTO_PUNKS_TOKEN_ID: &str = "CRYPTOPUNKS";
134    const TEST_EXPIRATION: Expiration = Expiration::AtHeight(10);
135
136    #[test]
137    fn can_create_claim() {
138        let claim = NftClaim::new(TEST_BAYC_TOKEN_ID.to_string(), TEST_EXPIRATION);
139        assert_eq!(claim.token_id, TEST_BAYC_TOKEN_ID.to_string());
140        assert_eq!(claim.release_at, TEST_EXPIRATION);
141    }
142
143    #[test]
144    fn can_create_claims() {
145        let deps = mock_dependencies();
146        let claims = NftClaims::new("claims");
147        // Assert that claims creates a map and there are no keys in the map.
148        assert_eq!(
149            claims
150                .0
151                .range_raw(&deps.storage, None, None, Order::Ascending)
152                .collect::<StdResult<Vec<_>>>()
153                .unwrap()
154                .len(),
155            0
156        );
157    }
158
159    #[test]
160    fn check_create_claim_updates_map() {
161        let mut deps = mock_dependencies();
162        let claims = NftClaims::new("claims");
163
164        claims
165            .create_nft_claims(
166                deps.as_mut().storage,
167                &Addr::unchecked("addr"),
168                vec![TEST_BAYC_TOKEN_ID.into()],
169                TEST_EXPIRATION,
170            )
171            .unwrap();
172
173        // Assert that claims creates a map and there is one claim for the address.
174        let saved_claims = claims
175            .0
176            .prefix(&Addr::unchecked("addr"))
177            .range(deps.as_mut().storage, None, None, Order::Ascending)
178            .collect::<StdResult<Vec<_>>>()
179            .unwrap();
180        assert_eq!(saved_claims.len(), 1);
181        assert_eq!(saved_claims[0].0, TEST_BAYC_TOKEN_ID.to_string());
182        assert_eq!(saved_claims[0].1, TEST_EXPIRATION);
183
184        // Adding another claim to same address, make sure that both claims are saved.
185        claims
186            .create_nft_claims(
187                deps.as_mut().storage,
188                &Addr::unchecked("addr"),
189                vec![TEST_CRYPTO_PUNKS_TOKEN_ID.into()],
190                TEST_EXPIRATION,
191            )
192            .unwrap();
193
194        // Assert that both claims exist for the address.
195        let saved_claims = claims
196            .0
197            .prefix(&Addr::unchecked("addr"))
198            .range(deps.as_mut().storage, None, None, Order::Ascending)
199            .collect::<StdResult<Vec<_>>>()
200            .unwrap();
201        assert_eq!(saved_claims.len(), 2);
202        assert_eq!(saved_claims[0].0, TEST_BAYC_TOKEN_ID.to_string());
203        assert_eq!(saved_claims[0].1, TEST_EXPIRATION);
204        assert_eq!(saved_claims[1].0, TEST_CRYPTO_PUNKS_TOKEN_ID.to_string());
205        assert_eq!(saved_claims[1].1, TEST_EXPIRATION);
206
207        // Adding another claim to different address, make sure that other address only has one claim.
208        claims
209            .create_nft_claims(
210                deps.as_mut().storage,
211                &Addr::unchecked("addr2"),
212                vec![TEST_CRYPTO_PUNKS_TOKEN_ID.to_string()],
213                TEST_EXPIRATION,
214            )
215            .unwrap();
216
217        // Assert that both claims exist for the address.
218        let saved_claims = claims
219            .0
220            .prefix(&Addr::unchecked("addr"))
221            .range(deps.as_mut().storage, None, None, Order::Ascending)
222            .collect::<StdResult<Vec<_>>>()
223            .unwrap();
224
225        let saved_claims_addr2 = claims
226            .0
227            .prefix(&Addr::unchecked("addr2"))
228            .range(deps.as_mut().storage, None, None, Order::Ascending)
229            .collect::<StdResult<Vec<_>>>()
230            .unwrap();
231        assert_eq!(saved_claims.len(), 2);
232        assert_eq!(saved_claims_addr2.len(), 1);
233    }
234
235    #[test]
236    fn test_claim_tokens_with_no_claims() {
237        let mut deps = mock_dependencies();
238        let claims = NftClaims::new("claims");
239
240        let env = mock_env();
241        let error = claims
242            .claim_nfts(
243                deps.as_mut().storage,
244                &Addr::unchecked("addr"),
245                &["404".to_string()],
246                &env.block,
247            )
248            .unwrap_err();
249        assert_eq!(
250            error,
251            NftClaimError::NotFound {
252                token_id: "404".to_string()
253            }
254        );
255
256        claims
257            .claim_nfts(
258                deps.as_mut().storage,
259                &Addr::unchecked("addr"),
260                &[],
261                &mock_env().block,
262            )
263            .unwrap();
264        let saved_claims = claims
265            .0
266            .prefix(&Addr::unchecked("addr"))
267            .range_raw(deps.as_mut().storage, None, None, Order::Ascending)
268            .collect::<StdResult<Vec<_>>>()
269            .unwrap();
270
271        assert_eq!(saved_claims.len(), 0);
272    }
273
274    #[test]
275    fn test_claim_tokens_with_no_released_claims() {
276        let mut deps = mock_dependencies();
277        let claims = NftClaims::new("claims");
278
279        claims
280            .create_nft_claims(
281                deps.as_mut().storage,
282                &Addr::unchecked("addr"),
283                vec![TEST_CRYPTO_PUNKS_TOKEN_ID.to_string()],
284                Expiration::AtHeight(10),
285            )
286            .unwrap();
287
288        claims
289            .create_nft_claims(
290                deps.as_mut().storage,
291                &Addr::unchecked("addr"),
292                vec![TEST_BAYC_TOKEN_ID.to_string()],
293                Expiration::AtHeight(100),
294            )
295            .unwrap();
296
297        let mut env = mock_env();
298        env.block.height = 0;
299        // the address has two claims however they are both not expired
300        let error = claims
301            .claim_nfts(
302                deps.as_mut().storage,
303                &Addr::unchecked("addr"),
304                &[
305                    TEST_CRYPTO_PUNKS_TOKEN_ID.to_string(),
306                    TEST_BAYC_TOKEN_ID.to_string(),
307                ],
308                &env.block,
309            )
310            .unwrap_err();
311        assert_eq!(
312            error,
313            NftClaimError::NotReady {
314                token_id: TEST_CRYPTO_PUNKS_TOKEN_ID.to_string()
315            }
316        );
317
318        let saved_claims = claims
319            .0
320            .prefix(&Addr::unchecked("addr"))
321            .range(deps.as_mut().storage, None, None, Order::Ascending)
322            .collect::<StdResult<Vec<_>>>()
323            .unwrap();
324
325        assert_eq!(saved_claims.len(), 2);
326        assert_eq!(saved_claims[0].0, TEST_BAYC_TOKEN_ID.to_string());
327        assert_eq!(saved_claims[0].1, Expiration::AtHeight(100));
328        assert_eq!(saved_claims[1].0, TEST_CRYPTO_PUNKS_TOKEN_ID.to_string());
329        assert_eq!(saved_claims[1].1, Expiration::AtHeight(10));
330    }
331
332    #[test]
333    fn test_claim_tokens_with_one_released_claim() {
334        let mut deps = mock_dependencies();
335        let claims = NftClaims::new("claims");
336
337        claims
338            .create_nft_claims(
339                deps.as_mut().storage,
340                &Addr::unchecked("addr"),
341                vec![TEST_BAYC_TOKEN_ID.to_string()],
342                Expiration::AtHeight(10),
343            )
344            .unwrap();
345
346        claims
347            .create_nft_claims(
348                deps.as_mut().storage,
349                &Addr::unchecked("addr"),
350                vec![TEST_CRYPTO_PUNKS_TOKEN_ID.to_string()],
351                Expiration::AtHeight(100),
352            )
353            .unwrap();
354
355        let mut env = mock_env();
356        env.block.height = 20;
357        // the address has two claims and the first one can be released
358        claims
359            .claim_nfts(
360                deps.as_mut().storage,
361                &Addr::unchecked("addr"),
362                &[TEST_BAYC_TOKEN_ID.to_string()],
363                &env.block,
364            )
365            .unwrap();
366
367        let saved_claims = claims
368            .0
369            .prefix(&Addr::unchecked("addr"))
370            .range(deps.as_mut().storage, None, None, Order::Ascending)
371            .collect::<StdResult<Vec<_>>>()
372            .unwrap();
373
374        assert_eq!(saved_claims.len(), 1);
375        assert_eq!(saved_claims[0].0, TEST_CRYPTO_PUNKS_TOKEN_ID.to_string());
376        assert_eq!(saved_claims[0].1, Expiration::AtHeight(100));
377    }
378
379    #[test]
380    fn test_claim_tokens_with_all_released_claims() {
381        let mut deps = mock_dependencies();
382        let claims = NftClaims::new("claims");
383
384        claims
385            .create_nft_claims(
386                deps.as_mut().storage,
387                &Addr::unchecked("addr"),
388                vec![TEST_BAYC_TOKEN_ID.to_string()],
389                Expiration::AtHeight(10),
390            )
391            .unwrap();
392
393        claims
394            .create_nft_claims(
395                deps.as_mut().storage,
396                &Addr::unchecked("addr"),
397                vec![TEST_CRYPTO_PUNKS_TOKEN_ID.to_string()],
398                Expiration::AtHeight(100),
399            )
400            .unwrap();
401
402        let mut env = mock_env();
403        env.block.height = 1000;
404        // the address has two claims and both can be released
405        claims
406            .claim_nfts(
407                deps.as_mut().storage,
408                &Addr::unchecked("addr"),
409                &[
410                    TEST_BAYC_TOKEN_ID.to_string(),
411                    TEST_CRYPTO_PUNKS_TOKEN_ID.to_string(),
412                ],
413                &env.block,
414            )
415            .unwrap();
416
417        let saved_claims = claims
418            .0
419            .prefix(&Addr::unchecked("addr"))
420            .range(deps.as_mut().storage, None, None, Order::Ascending)
421            .collect::<StdResult<Vec<_>>>()
422            .unwrap();
423
424        assert_eq!(saved_claims.len(), 0);
425    }
426
427    #[test]
428    fn test_query_claims_returns_correct_claims() {
429        let mut deps = mock_dependencies();
430        let claims = NftClaims::new("claims");
431
432        claims
433            .create_nft_claims(
434                deps.as_mut().storage,
435                &Addr::unchecked("addr"),
436                vec![TEST_CRYPTO_PUNKS_TOKEN_ID.to_string()],
437                Expiration::AtHeight(10),
438            )
439            .unwrap();
440
441        let queried_claims = claims
442            .query_claims(deps.as_ref(), &Addr::unchecked("addr"), None, None)
443            .unwrap();
444        let saved_claims = claims
445            .0
446            .prefix(&Addr::unchecked("addr"))
447            .range(deps.as_mut().storage, None, None, Order::Ascending)
448            .map(|item| item.map(|(token_id, v)| NftClaim::new(token_id, v)))
449            .collect::<StdResult<Vec<_>>>()
450            .unwrap();
451
452        assert_eq!(queried_claims, saved_claims);
453    }
454
455    #[test]
456    fn test_query_claims_returns_correct_claims_paginated() {
457        let mut deps = mock_dependencies();
458        let claims = NftClaims::new("claims");
459
460        claims
461            .create_nft_claims(
462                deps.as_mut().storage,
463                &Addr::unchecked("addr"),
464                vec![
465                    TEST_BAYC_TOKEN_ID.to_string(),
466                    TEST_CRYPTO_PUNKS_TOKEN_ID.to_string(),
467                ],
468                Expiration::AtHeight(10),
469            )
470            .unwrap();
471
472        let queried_claims = claims
473            .query_claims(deps.as_ref(), &Addr::unchecked("addr"), None, None)
474            .unwrap();
475        assert_eq!(
476            queried_claims,
477            vec![
478                NftClaim::new(TEST_BAYC_TOKEN_ID.to_string(), Expiration::AtHeight(10)),
479                NftClaim::new(
480                    TEST_CRYPTO_PUNKS_TOKEN_ID.to_string(),
481                    Expiration::AtHeight(10)
482                ),
483            ]
484        );
485
486        let queried_claims = claims
487            .query_claims(deps.as_ref(), &Addr::unchecked("addr"), None, Some(1))
488            .unwrap();
489        assert_eq!(
490            queried_claims,
491            vec![NftClaim::new(
492                TEST_BAYC_TOKEN_ID.to_string(),
493                Expiration::AtHeight(10)
494            ),]
495        );
496
497        let queried_claims = claims
498            .query_claims(
499                deps.as_ref(),
500                &Addr::unchecked("addr"),
501                Some(&TEST_BAYC_TOKEN_ID.to_string()),
502                None,
503            )
504            .unwrap();
505        assert_eq!(
506            queried_claims,
507            vec![NftClaim::new(
508                TEST_CRYPTO_PUNKS_TOKEN_ID.to_string(),
509                Expiration::AtHeight(10)
510            )]
511        );
512
513        let queried_claims = claims
514            .query_claims(
515                deps.as_ref(),
516                &Addr::unchecked("addr"),
517                Some(&TEST_CRYPTO_PUNKS_TOKEN_ID.to_string()),
518                None,
519            )
520            .unwrap();
521        assert_eq!(queried_claims.len(), 0);
522    }
523
524    #[test]
525    fn test_query_claims_returns_empty_for_non_existent_user() {
526        let mut deps = mock_dependencies();
527        let claims = NftClaims::new("claims");
528
529        claims
530            .create_nft_claims(
531                deps.as_mut().storage,
532                &Addr::unchecked("addr"),
533                vec![TEST_CRYPTO_PUNKS_TOKEN_ID.to_string()],
534                Expiration::AtHeight(10),
535            )
536            .unwrap();
537
538        let queried_claims = claims
539            .query_claims(deps.as_ref(), &Addr::unchecked("addr2"), None, None)
540            .unwrap();
541
542        assert_eq!(queried_claims.len(), 0);
543    }
544}