provwasm_tutorial/
contract.rs

1use cosmwasm_std::{
2    coin, entry_point, to_binary, BankMsg, Binary, CosmosMsg, Decimal, Deps, DepsMut, Env,
3    MessageInfo, Response, StdError, StdResult,
4};
5use provwasm_std::{bind_name, NameBinding, ProvenanceMsg, ProvenanceQuery};
6use std::ops::Mul;
7
8use crate::error::ContractError;
9use crate::msg::{ExecuteMsg, InitMsg, MigrateMsg, QueryMsg};
10use crate::state::{config, config_read, State};
11
12/// Initialize the contract
13#[entry_point]
14pub fn instantiate(
15    deps: DepsMut<ProvenanceQuery>,
16    env: Env,
17    info: MessageInfo,
18    msg: InitMsg,
19) -> Result<Response<ProvenanceMsg>, StdError> {
20    // Ensure no funds were sent with the message
21    if !info.funds.is_empty() {
22        let err = "purchase funds are not allowed to be sent during init";
23        return Err(StdError::generic_err(err));
24    }
25
26    // Ensure there are limits on fees.
27    if msg.fee_percent.is_zero() || msg.fee_percent > Decimal::percent(25) {
28        return Err(StdError::generic_err(
29            "fee percent must be > 0.0 and <= 0.25",
30        ));
31    }
32
33    // Ensure the merchant address is not also the fee collection address
34    if msg.merchant_address == info.sender {
35        return Err(StdError::generic_err(
36            "merchant address can't be the fee collection address",
37        ));
38    }
39
40    // Create and save contract config state. The fee collection address represents the network
41    // (ie they get paid fees), thus they must be the message sender.
42    let merchant_address = deps.api.addr_validate(&msg.merchant_address)?;
43    config(deps.storage).save(&State {
44        purchase_denom: msg.purchase_denom,
45        merchant_address,
46        fee_collection_address: info.sender,
47        fee_percent: msg.fee_percent,
48    })?;
49
50    // Create a message that will bind a restricted name to the contract address.
51    let msg = bind_name(
52        &msg.contract_name,
53        env.contract.address,
54        NameBinding::Restricted,
55    )?;
56
57    Ok(Response::new()
58        .add_message(msg)
59        .add_attribute("action", "init"))
60}
61
62/// Query contract state.
63#[entry_point]
64pub fn query(
65    deps: Deps<ProvenanceQuery>,
66    _env: Env, // NOTE: A '_' prefix indicates a variable is unused (suppress linter warnings)
67    msg: QueryMsg,
68) -> StdResult<Binary> {
69    match msg {
70        QueryMsg::QueryRequest {} => {
71            let state = config_read(deps.storage).load()?;
72            let json = to_binary(&state)?;
73            Ok(json)
74        }
75    }
76}
77
78/// Handle purchase messages.
79#[entry_point]
80pub fn execute(
81    deps: DepsMut<ProvenanceQuery>,
82    env: Env,
83    info: MessageInfo,
84    msg: ExecuteMsg,
85) -> Result<Response<ProvenanceMsg>, ContractError> {
86    // BankMsg
87    match msg {
88        ExecuteMsg::Purchase { id } => try_purchase(deps, env, info, id),
89    }
90}
91
92/// Called when migrating a contract instance to a new code ID.
93#[entry_point]
94pub fn migrate(
95    _deps: DepsMut<ProvenanceQuery>,
96    _env: Env,
97    _msg: MigrateMsg,
98) -> Result<Response, ContractError> {
99    Ok(Response::default())
100}
101
102// Calculates transfers and fees, then dispatches messages to the bank module.
103fn try_purchase(
104    deps: DepsMut<ProvenanceQuery>,
105    env: Env,
106    info: MessageInfo,
107    id: String,
108) -> Result<Response<ProvenanceMsg>, ContractError> {
109    // Ensure funds were sent with the message
110    if info.funds.is_empty() {
111        let err = "no purchase funds sent";
112        return Err(ContractError::Std(StdError::generic_err(err)));
113    }
114
115    // Load state
116    let state = config_read(deps.storage).load()?;
117    let fee_pct = state.fee_percent;
118
119    // Ensure the funds have the required amount and denomination
120    for funds in info.funds.iter() {
121        if funds.amount.is_zero() || funds.denom != state.purchase_denom {
122            let err = format!("invalid purchase funds: {}{}", funds.amount, funds.denom);
123            return Err(ContractError::Std(StdError::generic_err(err)));
124        }
125    }
126
127    // Calculate amounts and create bank transfers to the merchant account
128    let transfers = CosmosMsg::Bank(BankMsg::Send {
129        to_address: state.merchant_address.to_string(),
130        amount: info
131            .funds
132            .iter()
133            .map(|sent| {
134                let fees = sent.amount.mul(fee_pct).u128();
135                coin(sent.amount.u128() - fees, sent.denom.clone())
136            })
137            .collect(),
138    });
139
140    // Calculate fees and create bank transfers to the fee collection account
141    let fees = CosmosMsg::Bank(BankMsg::Send {
142        to_address: state.fee_collection_address.to_string(),
143        amount: info
144            .funds
145            .iter()
146            .map(|sent| coin(sent.amount.mul(fee_pct).u128(), sent.denom.clone()))
147            .collect(),
148    });
149
150    // Return a response that will dispatch the transfers to the bank module and emit events.
151    Ok(Response::new()
152        .add_message(transfers)
153        .add_message(fees)
154        .add_attribute("action", "purchase")
155        .add_attribute("purchase_id", id)
156        .add_attribute("purchase_time", env.block.time.to_string()))
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use crate::msg::QueryResponse;
163    use cosmwasm_std::testing::{mock_env, mock_info};
164    use cosmwasm_std::{from_binary, Addr};
165    use provwasm_mocks::mock_dependencies;
166    use provwasm_std::{NameMsgParams, ProvenanceMsgParams};
167
168    #[test]
169    fn valid_init() {
170        // Create mocks
171        let mut deps = mock_dependencies(&[]);
172
173        // Create valid config state
174        let res = instantiate(
175            deps.as_mut(),
176            mock_env(),
177            mock_info("feebucket", &[]),
178            InitMsg {
179                contract_name: "tutorial.sc.pb".into(),
180                purchase_denom: "purchasecoin".into(),
181                merchant_address: "merchant".into(),
182                fee_percent: Decimal::percent(10),
183            },
184        )
185        .unwrap();
186
187        // Ensure a message was created to bind the name to the contract address.
188        assert_eq!(res.messages.len(), 1);
189        match &res.messages[0].msg {
190            CosmosMsg::Custom(msg) => match &msg.params {
191                ProvenanceMsgParams::Name(p) => match &p {
192                    NameMsgParams::BindName { name, .. } => assert_eq!(name, "tutorial.sc.pb"),
193                    _ => panic!("unexpected name params"),
194                },
195                _ => panic!("unexpected provenance params"),
196            },
197            _ => panic!("unexpected cosmos message"),
198        }
199    }
200
201    #[test]
202    fn invalid_merchant_init() {
203        // Create mocks
204        let mut deps = mock_dependencies(&[]);
205
206        // Create an invalid init message
207        let err = instantiate(
208            deps.as_mut(),
209            mock_env(),
210            mock_info("merchant", &[]),
211            InitMsg {
212                contract_name: "tutorial.sc.pb".into(),
213                purchase_denom: "purchasecoin".into(),
214                merchant_address: "merchant".into(),
215                fee_percent: Decimal::percent(10),
216            },
217        )
218        .unwrap_err();
219
220        // Ensure the expected error was returned.
221        match err {
222            StdError::GenericErr { msg, .. } => {
223                assert_eq!(msg, "merchant address can't be the fee collection address")
224            }
225            _ => panic!("unexpected init error"),
226        }
227    }
228
229    #[test]
230    fn invalid_fee_percent_init() {
231        // Create mocks
232        let mut deps = mock_dependencies(&[]);
233
234        // Create an invalid init message.
235        let err = instantiate(
236            deps.as_mut(),
237            mock_env(),
238            mock_info("feebucket", &[]),
239            InitMsg {
240                contract_name: "tutorial.sc.pb".into(),
241                purchase_denom: "purchasecoin".into(),
242                merchant_address: "merchant".into(),
243                fee_percent: Decimal::percent(37), // error: > 25%
244            },
245        )
246        .unwrap_err();
247
248        // Ensure the expected error was returned
249        match err {
250            StdError::GenericErr { msg, .. } => {
251                assert_eq!(msg, "fee percent must be > 0.0 and <= 0.25")
252            }
253            _ => panic!("unexpected init error"),
254        }
255    }
256
257    #[test]
258    fn query_test() {
259        // Create mocks
260        let mut deps = mock_dependencies(&[]);
261
262        // Create config state
263        instantiate(
264            deps.as_mut(),
265            mock_env(),
266            mock_info("feebucket", &[]),
267            InitMsg {
268                contract_name: "tutorial.sc.pb".into(),
269                purchase_denom: "purchasecoin".into(),
270                merchant_address: "merchant".into(),
271                fee_percent: Decimal::percent(10),
272            },
273        )
274        .unwrap(); // Panics on error
275
276        // Call the smart contract query function to get stored state.
277        let bin = query(deps.as_ref(), mock_env(), QueryMsg::QueryRequest {}).unwrap();
278        let resp: QueryResponse = from_binary(&bin).unwrap();
279
280        // Ensure the expected init fields were properly stored.
281        assert_eq!(resp.merchant_address, Addr::unchecked("merchant"));
282        assert_eq!(resp.purchase_denom, "purchasecoin");
283        assert_eq!(resp.fee_collection_address, Addr::unchecked("feebucket"));
284        assert_eq!(resp.fee_percent, Decimal::percent(10));
285    }
286
287    #[test]
288    fn handle_valid_purchase() {
289        // Create mocks
290        let mut deps = mock_dependencies(&[]);
291
292        // Create config state
293        instantiate(
294            deps.as_mut(),
295            mock_env(),
296            mock_info("feebucket", &[]),
297            InitMsg {
298                contract_name: "tutorial.sc.pb".into(),
299                purchase_denom: "purchasecoin".into(),
300                merchant_address: "merchant".into(),
301                fee_percent: Decimal::percent(10),
302            },
303        )
304        .unwrap();
305
306        // Send a valid purchase message of 100purchasecoin
307        let res = execute(
308            deps.as_mut(),
309            mock_env(),
310            mock_info("consumer", &[coin(100, "purchasecoin")]),
311            ExecuteMsg::Purchase {
312                id: "a7918172-ac09-43f6-bc4b-7ac2fbad17e9".into(),
313            },
314        )
315        .unwrap();
316
317        // Ensure we have the merchant transfer and fee collection bank messages
318        assert_eq!(res.messages.len(), 2);
319
320        // Ensure we got the proper bank transfer values.
321        // 10% fees on 100 purchasecoin => 90 purchasecoin for the merchant and 10 purchasecoin for the fee bucket.
322        let expected_transfer = coin(90, "purchasecoin");
323        let expected_fees = coin(10, "purchasecoin");
324        res.messages.into_iter().for_each(|msg| match msg.msg {
325            CosmosMsg::Bank(BankMsg::Send {
326                amount, to_address, ..
327            }) => {
328                assert_eq!(amount.len(), 1);
329                if to_address == "merchant" {
330                    assert_eq!(amount[0], expected_transfer)
331                } else if to_address == "feebucket" {
332                    assert_eq!(amount[0], expected_fees)
333                } else {
334                    panic!("unexpected to_address in bank message")
335                }
336            }
337            _ => panic!("unexpected message type"),
338        });
339
340        // Ensure we got the purchase ID event attribute value
341        let expected_purchase_id = "a7918172-ac09-43f6-bc4b-7ac2fbad17e9";
342        res.attributes.into_iter().for_each(|atr| {
343            if atr.key == "purchase_id" {
344                assert_eq!(atr.value, expected_purchase_id)
345            }
346        })
347    }
348
349    #[test]
350    fn handle_invalid_funds() {
351        // Create mocks
352        let mut deps = mock_dependencies(&[]);
353
354        // Create config state
355        instantiate(
356            deps.as_mut(),
357            mock_env(),
358            mock_info("feebucket", &[]),
359            InitMsg {
360                contract_name: "tutorial.sc.pb".into(),
361                purchase_denom: "purchasecoin".into(),
362                merchant_address: "merchant".into(),
363                fee_percent: Decimal::percent(10),
364            },
365        )
366        .unwrap();
367
368        // Don't send any funds
369        let err = execute(
370            deps.as_mut(),
371            mock_env(),
372            mock_info("consumer", &[]),
373            ExecuteMsg::Purchase {
374                id: "a7918172-ac09-43f6-bc4b-7ac2fbad17e9".into(),
375            },
376        )
377        .unwrap_err();
378
379        // Ensure the expected error was returned.
380        match err {
381            ContractError::Std(StdError::GenericErr { msg, .. }) => {
382                assert_eq!(msg, "no purchase funds sent")
383            }
384            _ => panic!("unexpected handle error"),
385        }
386
387        // Send zero amount for a valid denom
388        let err = execute(
389            deps.as_mut(),
390            mock_env(),
391            mock_info("consumer", &[coin(0, "purchasecoin")]),
392            ExecuteMsg::Purchase {
393                id: "a7918172-ac09-43f6-bc4b-7ac2fbad17e9".into(),
394            },
395        )
396        .unwrap_err();
397
398        // Ensure the expected error was returned.
399        match err {
400            ContractError::Std(StdError::GenericErr { msg, .. }) => {
401                assert_eq!(msg, "invalid purchase funds: 0purchasecoin")
402            }
403            _ => panic!("unexpected handle error"),
404        }
405
406        // Send invalid denom
407        let err = execute(
408            deps.as_mut(),
409            mock_env(),
410            mock_info("consumer", &[coin(100, "fakecoin")]),
411            ExecuteMsg::Purchase {
412                id: "a7918172-ac09-43f6-bc4b-7ac2fbad17e9".into(),
413            },
414        )
415        .unwrap_err();
416
417        // Ensure the expected error was returned.
418        match err {
419            ContractError::Std(StdError::GenericErr { msg, .. }) => {
420                assert_eq!(msg, "invalid purchase funds: 100fakecoin")
421            }
422            _ => panic!("unexpected handle error"),
423        }
424    }
425}