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 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 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 expiration.is_expired(block) {
80 self.0.remove(storage, (addr, token_id));
81 Ok(())
82 } else {
83 Err(NftClaimError::NotReady {
85 token_id: token_id.to_string(),
86 })
87 }
88 }
89 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_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 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 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 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 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 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 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 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 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}