collectxyz_nft_contract/
execute.rs

1use rsa::{hash::Hash, padding::PaddingScheme, PublicKey};
2use serde_json;
3use sha2::{Digest, Sha256};
4
5use collectxyz::nft::{
6    base64_token_image, full_token_id, numeric_token_id, Config, Coordinates, ExecuteMsg,
7    InstantiateMsg, MigrateMsg, XyzExtension, XyzTokenInfo,
8};
9use cosmwasm_std::{
10    Attribute, BankMsg, Binary, Coin, DepsMut, Empty, Env, MessageInfo, Order, Response, StdError,
11    StdResult, Storage,
12};
13use cw721::{ContractInfoResponse, Cw721ReceiveMsg};
14use cw721_base::{msg::ExecuteMsg as Cw721ExecuteMsg, Cw721Contract};
15
16use crate::error::ContractError;
17use crate::state::{load_captcha_public_key, save_captcha_public_key, tokens, CONFIG, OWNER};
18
19const XYZ: &str = "xyz";
20
21pub fn instantiate(deps: DepsMut, info: MessageInfo, msg: InstantiateMsg) -> StdResult<Response> {
22    let cw721_contract = Cw721Contract::<Coordinates, Empty>::default();
23
24    let contract_info = ContractInfoResponse {
25        name: XYZ.to_string(),
26        symbol: XYZ.to_string(),
27    };
28    cw721_contract
29        .contract_info
30        .save(deps.storage, &contract_info)?;
31
32    CONFIG.save(deps.storage, &msg.config)?;
33    OWNER.save(deps.storage, &info.sender.to_string())?;
34
35    save_captcha_public_key(deps.storage, &msg.captcha_public_key)?;
36
37    Ok(Response::default())
38}
39
40pub fn execute_mint(
41    deps: DepsMut,
42    env: Env,
43    info: MessageInfo,
44    coordinates: Coordinates,
45    captcha_signature: String,
46) -> Result<Response, ContractError> {
47    let cw721_contract = Cw721Contract::<Coordinates, Empty>::default();
48
49    let owner = OWNER.load(deps.storage)?;
50    let config = CONFIG.load(deps.storage)?;
51    let num_tokens = cw721_contract.token_count(deps.storage)?;
52
53    if num_tokens >= config.token_supply {
54        return Err(ContractError::SupplyExhausted {});
55    }
56
57    if info.sender != owner {
58        if !config.public_minting_enabled {
59            return Err(ContractError::Unauthorized {});
60        }
61
62        // check that mint fee is covered if sender isn't an owner
63        check_sufficient_funds(info.funds, config.mint_fee)?;
64
65        // check that wallet limit isn't exceeded if sender isn't an owner
66        check_wallet_limit(deps.storage, info.sender.clone(), config.wallet_limit)?;
67    }
68
69    // check that the coordinates are valid and available
70    check_coordinates(deps.storage, &coordinates)?;
71
72    // check that the recaptcha lambda signature is valid
73    check_captcha_signature(deps.storage, &coordinates, &captcha_signature)?;
74
75    // create the token
76    let num_tokens = 1 + num_tokens;
77    let token_id = format!("xyz #{}", &num_tokens);
78    let token = XyzTokenInfo {
79        owner: info.sender.clone(),
80        approvals: vec![],
81        name: token_id.clone(),
82        description: String::from("Explore the metaverse, starting with xyz."),
83        image: Some(base64_token_image(&coordinates)),
84        extension: XyzExtension {
85            coordinates,
86            prev_coordinates: None,
87            arrival: env.block.time,
88        },
89    };
90    tokens().update(deps.storage, &token_id, |old| match old {
91        Some(_) => Err(ContractError::Claimed {}),
92        None => Ok(token),
93    })?;
94
95    cw721_contract.increment_tokens(deps.storage)?;
96
97    Ok(Response::new()
98        .add_attribute("action", "mint")
99        .add_attribute("minter", info.sender)
100        .add_attribute("token_id", numeric_token_id(token_id)?))
101}
102
103fn check_sufficient_funds(funds: Vec<Coin>, required: Coin) -> Result<(), ContractError> {
104    if required.amount.u128() == 0 {
105        return Ok(());
106    }
107    let sent_sufficient_funds = funds.iter().any(|coin| {
108        // check if a given sent coin matches denom
109        // and has sufficient amount
110        coin.denom == required.denom && coin.amount.u128() >= required.amount.u128()
111    });
112    if sent_sufficient_funds {
113        Ok(())
114    } else {
115        Err(ContractError::Std(StdError::generic_err(
116            "insufficient funds sent",
117        )))
118    }
119}
120
121fn check_wallet_limit(
122    storage: &dyn Storage,
123    owner: cosmwasm_std::Addr,
124    limit: u32,
125) -> Result<(), ContractError> {
126    let num_wallet_tokens = tokens()
127        .idx
128        .owner
129        .prefix(owner)
130        .range(storage, None, None, Order::Ascending)
131        .count();
132
133    if num_wallet_tokens >= limit as usize {
134        Err(ContractError::WalletLimit {})
135    } else {
136        Ok(())
137    }
138}
139
140fn check_captcha_signature(
141    storage: &dyn Storage,
142    coordinates: &Coordinates,
143    captcha_signature: &str,
144) -> Result<(), ContractError> {
145    let key = load_captcha_public_key(storage).unwrap();
146
147    let signature_bytes = base64::decode(captcha_signature).unwrap();
148
149    let coords_json_bytes = serde_json::to_vec(coordinates).unwrap();
150    let mut hasher = Sha256::new();
151    hasher.update(&coords_json_bytes);
152    let digest = hasher.finalize();
153
154    if key
155        .verify(
156            PaddingScheme::PKCS1v15Sign {
157                hash: Some(Hash::SHA2_256),
158            },
159            &digest,
160            &signature_bytes,
161        )
162        .is_ok()
163    {
164        Ok(())
165    } else {
166        Err(ContractError::Unauthorized {})
167    }
168}
169
170fn check_coordinates(storage: &dyn Storage, coords: &Coordinates) -> Result<(), ContractError> {
171    let config = CONFIG.load(storage)?;
172    config.check_bounds(*coords).map_err(ContractError::Std)?;
173    match tokens().idx.coordinates.item(storage, coords.to_bytes())? {
174        Some(_) => Err(ContractError::Claimed {}),
175        None => Ok(()),
176    }
177}
178
179pub fn execute_move(
180    deps: DepsMut,
181    env: Env,
182    info: MessageInfo,
183    token_id: String,
184    coordinates: Coordinates,
185) -> Result<Response, ContractError> {
186    let owner = OWNER.load(deps.storage)?;
187    let config = CONFIG.load(deps.storage)?;
188    let token = tokens().load(deps.storage, &token_id)?;
189
190    // check that the sender owns the token
191    if token.owner != info.sender {
192        return Err(ContractError::Unauthorized {});
193    }
194
195    // check that a move isn't currently in progess
196    if !token.extension.has_arrived(env.block.time) {
197        return Err(ContractError::MoveInProgress {});
198    }
199
200    // check that a non-owner sent funds greater than the move fee
201    if owner != info.sender {
202        let move_fee = config.get_move_fee(token.extension.coordinates, coordinates);
203        check_sufficient_funds(info.funds, move_fee)?;
204    }
205
206    // check that move target is unoccupied and in bounds
207    check_coordinates(deps.storage, &coordinates)?;
208
209    // update token with new coordinates, prev coordinates, and arrival time
210    let mut new_token = token.clone();
211    new_token.image = Some(base64_token_image(&coordinates));
212    new_token.extension.coordinates = coordinates;
213    new_token.extension.prev_coordinates = Some(token.extension.coordinates);
214    let travel_time_nanos = config.get_move_nanos(token.extension.coordinates, coordinates);
215    new_token.extension.arrival = env.block.time.plus_nanos(travel_time_nanos);
216    tokens().replace(deps.storage, &token_id, Some(&new_token), Some(&token))?;
217
218    Ok(Response::default()
219        .add_attribute("action", "move")
220        .add_attribute("mover", info.sender)
221        .add_attribute("token_id", numeric_token_id(token_id)?))
222}
223
224pub fn execute_update_config(
225    deps: DepsMut,
226    info: MessageInfo,
227    config: Config,
228) -> Result<Response, ContractError> {
229    let owner = OWNER.load(deps.storage)?;
230    if info.sender != owner {
231        return Err(ContractError::Unauthorized {});
232    }
233    CONFIG.save(deps.storage, &config)?;
234    Ok(Response::new().add_attribute("action", "update_config"))
235}
236
237pub fn execute_update_captcha_public_key(
238    deps: DepsMut,
239    info: MessageInfo,
240    public_key: String,
241) -> Result<Response, ContractError> {
242    let owner = OWNER.load(deps.storage)?;
243
244    if info.sender != owner {
245        return Err(ContractError::Unauthorized {});
246    }
247
248    save_captcha_public_key(deps.storage, &public_key)?;
249
250    Ok(Response::new().add_attribute("action", "update_captcha_public_key"))
251}
252
253pub fn execute_withdraw(
254    deps: DepsMut,
255    _env: Env,
256    info: MessageInfo,
257    amount: Vec<Coin>,
258) -> Result<Response, ContractError> {
259    let owner = OWNER.load(deps.storage)?;
260    if info.sender != owner {
261        return Err(ContractError::Unauthorized {});
262    }
263
264    Ok(Response::new().add_message(BankMsg::Send {
265        amount,
266        to_address: owner,
267    }))
268}
269
270pub fn cw721_base_execute(
271    deps: DepsMut,
272    env: Env,
273    info: MessageInfo,
274    msg: ExecuteMsg,
275) -> Result<Response, ContractError> {
276    let cw721_contract = Cw721Contract::<XyzExtension, Empty>::default();
277    let cw721_msg: Cw721ExecuteMsg<XyzExtension> = msg.into();
278    let cw721_msg_full_token_id = match cw721_msg {
279        Cw721ExecuteMsg::Approve {
280            spender,
281            token_id,
282            expires,
283        } => Cw721ExecuteMsg::Approve {
284            spender,
285            expires,
286            token_id: full_token_id(token_id)?,
287        },
288        Cw721ExecuteMsg::Revoke { spender, token_id } => Cw721ExecuteMsg::Revoke {
289            spender,
290            token_id: full_token_id(token_id)?,
291        },
292        Cw721ExecuteMsg::TransferNft {
293            recipient,
294            token_id,
295        } => Cw721ExecuteMsg::TransferNft {
296            recipient,
297            token_id: full_token_id(token_id)?,
298        },
299        Cw721ExecuteMsg::SendNft {
300            contract,
301            token_id,
302            msg,
303        } => Cw721ExecuteMsg::SendNft {
304            contract,
305            msg,
306            token_id: full_token_id(token_id)?,
307        },
308        _ => cw721_msg,
309    };
310
311    let mut response = (match cw721_msg_full_token_id {
312        Cw721ExecuteMsg::SendNft {
313            contract,
314            token_id,
315            msg,
316        } => execute_send_nft(deps, env, info, contract, token_id, msg),
317        _ => cw721_contract
318            .execute(deps, env, info, cw721_msg_full_token_id)
319            .map_err(|err| err.into()),
320    })?;
321
322    response.attributes = response
323        .attributes
324        .iter()
325        .map(|attr| {
326            if attr.key == "token_id" {
327                Attribute::new(
328                    "token_id",
329                    numeric_token_id(attr.value.to_string()).unwrap(),
330                )
331            } else {
332                attr.clone()
333            }
334        })
335        .collect();
336    Ok(response)
337}
338
339pub fn execute_send_nft(
340    deps: DepsMut,
341    env: Env,
342    info: MessageInfo,
343    contract: String,
344    token_id: String,
345    msg: Binary,
346) -> Result<Response, ContractError> {
347    let cw721_contract = Cw721Contract::<XyzExtension, Empty>::default();
348    // Transfer token
349    cw721_contract._transfer_nft(deps, &env, &info, &contract, &token_id)?;
350
351    let send = Cw721ReceiveMsg {
352        sender: info.sender.to_string(),
353        token_id: numeric_token_id(token_id.clone())?,
354        msg,
355    };
356
357    // Send message
358    Ok(Response::new()
359        .add_message(send.into_cosmos_msg(contract.clone())?)
360        .add_attribute("action", "send_nft")
361        .add_attribute("sender", info.sender)
362        .add_attribute("recipient", contract)
363        .add_attribute("token_id", token_id))
364}
365
366pub fn migrate(_deps: DepsMut, _env: Env, _msg: MigrateMsg) -> StdResult<Response> {
367    Ok(Response::default().add_attribute("action", "migrate"))
368}
369
370#[cfg(test)]
371mod test {
372    use super::*;
373
374    use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info};
375    use cosmwasm_std::{to_binary, Addr, Timestamp};
376    use cw721::{Cw721ReceiveMsg, Expiration};
377    use cw721_base::state::Approval;
378
379    const ADDR1: &str = "addr1";
380    const ADDR2: &str = "addr2";
381
382    fn token_examples() -> Vec<XyzTokenInfo> {
383        vec![
384            XyzTokenInfo {
385                owner: Addr::unchecked(ADDR1),
386                approvals: vec![],
387                name: "xyz #1".to_string(),
388                description: "".to_string(),
389                image: None,
390                extension: XyzExtension {
391                    coordinates: Coordinates { x: 1, y: 1, z: 1 },
392                    arrival: Timestamp::from_nanos(0),
393                    prev_coordinates: None,
394                },
395            },
396            XyzTokenInfo {
397                owner: Addr::unchecked(ADDR2),
398                approvals: vec![],
399                name: "xyz #2".to_string(),
400                description: "".to_string(),
401                image: None,
402                extension: XyzExtension {
403                    coordinates: Coordinates { x: 2, y: 2, z: 2 },
404                    arrival: Timestamp::from_nanos(0),
405                    prev_coordinates: None,
406                },
407            },
408        ]
409    }
410
411    fn setup_storage(deps: DepsMut) {
412        for token in token_examples().iter() {
413            tokens().save(deps.storage, &token.name, token).unwrap();
414        }
415    }
416
417    fn numeric_id_error() -> ContractError {
418        ContractError::Std(StdError::generic_err("expected numeric token identifier"))
419    }
420
421    #[test]
422    fn cw721_transfer() {
423        let mut deps = mock_dependencies(&[]);
424        setup_storage(deps.as_mut());
425
426        // blocks full token identifiers
427        let err = cw721_base_execute(
428            deps.as_mut(),
429            mock_env(),
430            mock_info(ADDR1, &[]),
431            ExecuteMsg::TransferNft {
432                recipient: ADDR2.to_string(),
433                token_id: "xyz #1".to_string(),
434            },
435        )
436        .unwrap_err();
437        assert_eq!(err, numeric_id_error());
438
439        // transfer xyz #1
440        let res = cw721_base_execute(
441            deps.as_mut(),
442            mock_env(),
443            mock_info(ADDR1, &[]),
444            ExecuteMsg::TransferNft {
445                recipient: ADDR2.to_string(),
446                token_id: "1".to_string(),
447            },
448        )
449        .unwrap();
450
451        // ensure response event emits the transferred token_id
452        assert!(res
453            .attributes
454            .iter()
455            .any(|attr| attr.key == "token_id" && attr.value == "1"));
456
457        // check ownership was updated
458        let token = tokens().load(&deps.storage, "xyz #1").unwrap();
459        assert_eq!(token.name, "xyz #1");
460        assert_eq!(token.owner.to_string(), ADDR2.to_string());
461    }
462
463    #[test]
464    fn cw721_approve_revoke() {
465        let mut deps = mock_dependencies(&[]);
466        setup_storage(deps.as_mut());
467
468        // approve blocks full token identifiers
469        let err = cw721_base_execute(
470            deps.as_mut(),
471            mock_env(),
472            mock_info(ADDR1, &[]),
473            ExecuteMsg::Approve {
474                spender: ADDR2.to_string(),
475                token_id: "xyz #1".to_string(),
476                expires: None,
477            },
478        )
479        .unwrap_err();
480        assert_eq!(err, numeric_id_error());
481
482        // grant an approval
483        let res = cw721_base_execute(
484            deps.as_mut(),
485            mock_env(),
486            mock_info(ADDR1, &[]),
487            ExecuteMsg::Approve {
488                spender: ADDR2.to_string(),
489                token_id: "1".to_string(),
490                expires: None,
491            },
492        )
493        .unwrap();
494
495        // ensure response event emits the transferred token_id
496        assert!(res
497            .attributes
498            .iter()
499            .any(|attr| attr.key == "token_id" && attr.value == "1"));
500
501        // check approval was added
502        let token = tokens().load(&deps.storage, "xyz #1").unwrap();
503        assert_eq!(token.name, "xyz #1");
504        assert_eq!(
505            token.approvals,
506            vec![Approval {
507                spender: Addr::unchecked(ADDR2),
508                expires: Expiration::Never {}
509            }]
510        );
511
512        // revoke blocks full token identifiers
513        let err = cw721_base_execute(
514            deps.as_mut(),
515            mock_env(),
516            mock_info(ADDR1, &[]),
517            ExecuteMsg::Revoke {
518                spender: ADDR2.to_string(),
519                token_id: "xyz #1".to_string(),
520            },
521        )
522        .unwrap_err();
523        assert_eq!(err, numeric_id_error());
524
525        // revoke the approval
526        let res = cw721_base_execute(
527            deps.as_mut(),
528            mock_env(),
529            mock_info(ADDR1, &[]),
530            ExecuteMsg::Revoke {
531                spender: ADDR2.to_string(),
532                token_id: "1".to_string(),
533            },
534        )
535        .unwrap();
536
537        // ensure response event emits the transferred token_id
538        assert!(res
539            .attributes
540            .iter()
541            .any(|attr| attr.key == "token_id" && attr.value == "1"));
542
543        // check approval was revoked
544        let token = tokens().load(&deps.storage, "xyz #1").unwrap();
545        assert_eq!(token.name, "xyz #1");
546        assert_eq!(token.approvals, vec![]);
547    }
548
549    #[test]
550    fn cw721_send_nft() {
551        let mut deps = mock_dependencies(&[]);
552        setup_storage(deps.as_mut());
553
554        let token_id = "1".to_string();
555        let target = "another_contract".to_string();
556        let msg = to_binary("my msg").unwrap();
557
558        // blocks full token identifiers
559        let err = cw721_base_execute(
560            deps.as_mut(),
561            mock_env(),
562            mock_info(ADDR1, &[]),
563            ExecuteMsg::SendNft {
564                contract: target.clone(),
565                token_id: "xyz #1".to_string(),
566                msg: msg.clone(),
567            },
568        )
569        .unwrap_err();
570        assert_eq!(err, numeric_id_error());
571
572        // send a token to a contract
573        let res = cw721_base_execute(
574            deps.as_mut(),
575            mock_env(),
576            mock_info(ADDR1, &[]),
577            ExecuteMsg::SendNft {
578                contract: target.clone(),
579                token_id: token_id.clone(),
580                msg: msg.clone(),
581            },
582        )
583        .unwrap();
584
585        let payload = Cw721ReceiveMsg {
586            sender: ADDR1.to_string(),
587            token_id: token_id.clone(),
588            msg,
589        };
590        let expected = payload.into_cosmos_msg(target).unwrap();
591        assert_eq!(
592            res,
593            Response::new()
594                .add_message(expected)
595                .add_attribute("action", "send_nft")
596                .add_attribute("sender", ADDR1)
597                .add_attribute("recipient", "another_contract")
598                .add_attribute("token_id", token_id)
599        );
600    }
601}