Skip to main content

mantra_claimdrop_std/
msg.rs

1use std::fmt::{Display, Formatter};
2
3use cosmwasm_schema::{cw_serde, QueryResponses};
4use cosmwasm_std::{ensure, Coin, Decimal, Timestamp, Uint128};
5use cw_ownable::{cw_ownable_execute, cw_ownable_query};
6
7use crate::error::ContractError;
8
9/// Maximum length for campaign name
10const MAX_NAME_LENGTH: usize = 200;
11/// Maximum length for campaign description
12const MAX_DESCRIPTION_LENGTH: usize = 2000;
13/// Maximum length for campaign type
14const MAX_TYPE_LENGTH: usize = 200;
15
16#[cw_serde]
17pub struct InstantiateMsg {
18    /// Owner of the contract. If not set, it is the sender of the Instantiate message.
19    pub owner: Option<String>,
20    /// Optinal action in case the contract is instantiated via the claimdrop factory
21    pub action: Option<CampaignAction>,
22}
23
24#[cw_ownable_execute]
25#[cw_serde]
26pub enum ExecuteMsg {
27    /// Manages campaigns based on the action, defined by [CampaignAction].
28    ManageCampaign { action: CampaignAction },
29    /// Claims rewards from a campaign
30    Claim {
31        /// The receiver address of the claimed rewards. If not set, the sender of the message will be the receiver.
32        /// This is useful for allowing a contract to do the claim operation on behalf of a user.
33        receiver: Option<String>,
34        /// The amount to claim. If not set, all available tokens will be claimed.
35        amount: Option<Uint128>,
36    },
37    /// Adds a batch of addresses and their allocations. This can only be done before the campaign has started.
38    AddAllocations {
39        /// Vector of (address, amount) pairs
40        allocations: Vec<(String, Uint128)>,
41    },
42    /// Replaces an address in the allocation list. This can only be done before the campaign has started.
43    ReplaceAddress {
44        /// The old address to replace
45        old_address: String,
46        /// The new address to use
47        new_address: String,
48    },
49    /// Removes an address in the allocation list. This can only be done before the campaign has started.
50    RemoveAddress {
51        /// The address to remove
52        address: String,
53    },
54    /// Blacklists or unblacklists an address. This can be done at any time.
55    BlacklistAddress {
56        /// The address to blacklist/unblacklist
57        address: String,
58        /// Whether to blacklist or unblacklist
59        blacklist: bool,
60    },
61    /// Manages authorized wallets that can perform admin actions. Only the owner can manage authorized wallets.
62    ManageAuthorizedWallets {
63        /// Vector of addresses to authorize/unauthorize
64        addresses: Vec<String>,
65        /// Whether to authorize or unauthorize the addresses
66        authorized: bool,
67    },
68    /// Sweep non-reward tokens from the contract (owner only)
69    /// This allows retrieving any tokens accidentally sent to the contract
70    /// that are not the campaign's reward denom
71    Sweep {
72        /// The denomination of the token to sweep
73        denom: String,
74        /// Optional amount to sweep. If not provided, sweeps entire balance
75        amount: Option<Uint128>,
76    },
77}
78
79#[cw_ownable_query]
80#[cw_serde]
81#[derive(QueryResponses)]
82pub enum QueryMsg {
83    #[returns(CampaignResponse)]
84    /// Get the airdrop campaign
85    Campaign {},
86    #[returns(RewardsResponse)]
87    /// Get the rewards for a specific campaign and receiver address.
88    Rewards {
89        /// The address to get the rewards for.
90        receiver: String,
91    },
92    #[returns(ClaimedResponse)]
93    /// Get the total amount of tokens claimed on the campaign.
94    Claimed {
95        /// If provided, it will return the tokens claimed by the specified address.
96        address: Option<String>,
97        /// The address to start querying from. Used for paginating results.
98        start_from: Option<String>,
99        /// The maximum number of items to return. If not set, the default value is used. Used for paginating results.
100        limit: Option<u16>,
101    },
102    #[returns(AllocationsResponse)]
103    /// Get the allocation for an address
104    Allocations {
105        /// The address to get the allocation for, if provided
106        address: Option<String>,
107        /// The address to start querying from. Used for paginating results.
108        start_after: Option<String>,
109        /// The maximum number of items to return. If not set, the default value is used. Used for paginating results.
110        limit: Option<u16>,
111    },
112    #[returns(BlacklistResponse)]
113    /// Check if an address is blacklisted
114    IsBlacklisted {
115        /// The address to check
116        address: String,
117    },
118    #[returns(AuthorizedResponse)]
119    /// Check if an address is authorized (owner or authorized wallet)
120    IsAuthorized {
121        /// The address to check
122        address: String,
123    },
124    #[returns(AuthorizedWalletsResponse)]
125    /// Get authorized wallets with pagination
126    AuthorizedWallets {
127        /// The address to start querying from. Used for paginating results.
128        start_after: Option<String>,
129        /// The maximum number of items to return. Used for paginating results.
130        limit: Option<u32>,
131    },
132}
133
134#[cw_serde]
135pub struct MigrateMsg {}
136
137pub type CampaignResponse = Campaign;
138
139/// Response to the Rewards query.
140#[cw_serde]
141pub struct RewardsResponse {
142    /// The tokens that have been claimed by the address.
143    pub claimed: Vec<Coin>,
144    /// The total amount of tokens that is pending to be claimed by the address.
145    pub pending: Vec<Coin>,
146    /// The tokens that are available to be claimed by the address.
147    pub available_to_claim: Vec<Coin>,
148}
149
150/// Response to the Claimed query.
151#[cw_serde]
152pub struct ClaimedResponse {
153    /// Contains a vector with a tuple with (address, coin) that have been claimed
154    pub claimed: Vec<(String, Coin)>,
155}
156
157/// Response to the Allocation query.
158#[cw_serde]
159pub struct AllocationsResponse {
160    /// A vector with a tuple with (address, coin) that have been allocated.
161    pub allocations: Vec<(String, Coin)>,
162}
163
164/// Response to the Blacklist query.
165#[cw_serde]
166pub struct BlacklistResponse {
167    /// Whether the address is blacklisted
168    pub is_blacklisted: bool,
169}
170
171/// Response to the IsAuthorized query.
172#[cw_serde]
173pub struct AuthorizedResponse {
174    /// Whether the address is authorized (owner or authorized wallet)
175    pub is_authorized: bool,
176}
177
178/// Response to the AuthorizedWallets query.
179#[cw_serde]
180pub struct AuthorizedWalletsResponse {
181    /// List of authorized wallet addresses
182    pub wallets: Vec<String>,
183}
184
185/// The campaign action that can be executed with the [ExecuteMsg::ManageCampaign] message.
186#[cw_serde]
187pub enum CampaignAction {
188    /// Creates a new campaign
189    CreateCampaign {
190        /// The parameters to create a campaign with
191        params: Box<CampaignParams>,
192    },
193    /// Closes the campaign
194    CloseCampaign {},
195}
196
197/// Represents a campaign.
198#[cw_serde]
199pub struct Campaign {
200    /// The campaign name
201    pub name: String,
202    /// The campaign description
203    pub description: String,
204    /// Campaign type. Value used by front ends.
205    #[serde(rename = "type")]
206    pub ty: String,
207    /// The total amount of the reward asset that is intended to be allocated to the campaign
208    pub total_reward: Coin,
209    /// The amount of the reward asset that has been claimed
210    pub claimed: Coin,
211    /// The ways the reward is distributed, which are defined by the [DistributionType].
212    /// The sum of the percentages must be 100.
213    pub distribution_type: Vec<DistributionType>,
214    /// The campaign start time (unix timestamp), in seconds
215    pub start_time: u64,
216    /// The campaign end time (unix timestamp), in seconds
217    pub end_time: u64,
218    /// The timestamp at which the campaign was closed, in seconds
219    pub closed: Option<u64>,
220}
221
222impl Display for Campaign {
223    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
224        write!(
225            f,
226            "Campaign {{ name: {}, description: {}, type: {}, total_reward: {}, claimed: {}, distribution_type: {:?}, start_time: {}, end_time: {}, closed: {:?} }}",
227            self.name,
228            self.description,
229            self.ty,
230            self.total_reward,
231            self.claimed,
232            self.distribution_type,
233            self.start_time,
234            self.end_time,
235            self.closed
236        )
237    }
238}
239
240impl Campaign {
241    /// Creates a new campaign from the given parameters
242    pub fn from_params(params: CampaignParams) -> Self {
243        let reward_denom = params.total_reward.denom.clone();
244
245        Campaign {
246            name: params.name,
247            description: params.description,
248            ty: params.ty,
249            total_reward: params.total_reward,
250            claimed: Coin {
251                denom: reward_denom,
252                amount: Uint128::zero(),
253            },
254            distribution_type: params.distribution_type,
255            start_time: params.start_time,
256            end_time: params.end_time,
257            closed: None,
258        }
259    }
260
261    /// Checks if the campaign has started
262    pub fn has_started(&self, current_time: &Timestamp) -> bool {
263        current_time.seconds() >= self.start_time
264    }
265
266    /// Checks if the campaign has ended
267    pub fn has_ended(&self, current_time: &Timestamp) -> bool {
268        current_time.seconds() >= self.end_time
269    }
270}
271
272/// Represents the parameters to create a campaign with.
273#[cw_serde]
274pub struct CampaignParams {
275    /// The campaign name
276    pub name: String,
277    /// The campaign description
278    pub description: String,
279    /// Campaign type. Value used by front ends.
280    #[serde(rename = "type")]
281    pub ty: String,
282    /// The total amount of the reward asset that is intended to be allocated to the campaign
283    pub total_reward: Coin,
284    /// The ways the reward is distributed, which are defined by the [DistributionType].
285    /// The sum of the percentages must be 100.
286    pub distribution_type: Vec<DistributionType>,
287    /// The campaign start time (unix timestamp), in seconds
288    pub start_time: u64,
289    /// The campaign end timestamp (unix timestamp), in seconds
290    pub end_time: u64,
291    /// An optional label to be used for the instantiated claimdrop contract
292    pub contract_label: String,
293}
294
295impl CampaignParams {
296    /// Validates the campaign name and description
297    pub fn validate_campaign_name_description(&self) -> Result<(), ContractError> {
298        ensure!(
299            !self.name.is_empty(),
300            ContractError::InvalidCampaignParam {
301                param: "name".to_string(),
302                reason: "cannot be empty".to_string(),
303            }
304        );
305
306        ensure!(
307            self.name.len() <= MAX_NAME_LENGTH,
308            ContractError::InvalidCampaignParam {
309                param: "name".to_string(),
310                reason: format!("cannot be longer than {} characters", MAX_NAME_LENGTH),
311            }
312        );
313
314        ensure!(
315            !self.description.is_empty(),
316            ContractError::InvalidCampaignParam {
317                param: "description".to_string(),
318                reason: "cannot be empty".to_string(),
319            }
320        );
321
322        ensure!(
323            self.description.len() <= MAX_DESCRIPTION_LENGTH,
324            ContractError::InvalidCampaignParam {
325                param: "description".to_string(),
326                reason: format!(
327                    "cannot be longer than {} characters",
328                    MAX_DESCRIPTION_LENGTH
329                ),
330            }
331        );
332
333        Ok(())
334    }
335
336    /// Validates the campaign type
337    pub fn validate_campaign_type(&self) -> Result<(), ContractError> {
338        ensure!(
339            !self.ty.is_empty(),
340            ContractError::InvalidCampaignParam {
341                param: "type".to_string(),
342                reason: "cannot be empty".to_string(),
343            }
344        );
345
346        ensure!(
347            self.ty.len() <= MAX_TYPE_LENGTH,
348            ContractError::InvalidCampaignParam {
349                param: "type".to_string(),
350                reason: format!("cannot be longer than {} characters", MAX_TYPE_LENGTH),
351            }
352        );
353
354        Ok(())
355    }
356
357    /// Validates the start and end times of a campaign
358    pub fn validate_campaign_times(&self, current_time: Timestamp) -> Result<(), ContractError> {
359        ensure!(
360            self.start_time < self.end_time,
361            ContractError::InvalidCampaignParam {
362                param: "start_time".to_string(),
363                reason: "cannot be greater or equal than end_time".to_string(),
364            }
365        );
366        ensure!(
367            self.start_time >= current_time.seconds(),
368            ContractError::InvalidCampaignParam {
369                param: "start_time".to_string(),
370                reason: "cannot be less than the current time".to_string(),
371            }
372        );
373
374        Ok(())
375    }
376
377    /// Ensures the distribution type parameters are correct
378    pub fn validate_campaign_distribution(&self) -> Result<(), ContractError> {
379        let mut total_percentage = Decimal::zero();
380
381        ensure!(
382            !self.distribution_type.is_empty() && self.distribution_type.len() <= 2,
383            ContractError::InvalidCampaignParam {
384                param: "distribution_type".to_string(),
385                reason: "invalid number of distribution types, should be at least 1, maximum 2"
386                    .to_string(),
387            }
388        );
389
390        for dist in self.distribution_type.iter() {
391            let (percentage, start_time, end_time, cliff_duration) = match dist {
392                DistributionType::LinearVesting {
393                    percentage,
394                    start_time,
395                    end_time,
396                    cliff_duration,
397                } => (percentage, start_time, Some(end_time), cliff_duration),
398                DistributionType::LumpSum {
399                    percentage,
400                    start_time,
401                } => (percentage, start_time, None, &None),
402            };
403
404            ensure!(
405                percentage != Decimal::zero(),
406                ContractError::ZeroDistributionPercentage
407            );
408
409            total_percentage = total_percentage.checked_add(*percentage)?;
410
411            ensure!(
412                *start_time >= self.start_time,
413                ContractError::InvalidStartDistributionTime {
414                    start_time: *start_time,
415                    campaign_start_time: self.start_time,
416                }
417            );
418
419            // validate the end time. Applies for the linear vesting distribution type only
420            if let Some(end_time) = end_time {
421                ensure!(
422                    end_time > start_time,
423                    ContractError::InvalidDistributionTimes {
424                        start_time: *start_time,
425                        end_time: *end_time,
426                    }
427                );
428
429                ensure!(
430                    *end_time <= self.end_time,
431                    ContractError::InvalidEndDistributionTime {
432                        end_time: *end_time,
433                        campaign_end_time: self.end_time,
434                    }
435                );
436            }
437
438            // validate the cliff duration
439            if let Some(cliff_duration) = cliff_duration {
440                ensure!(
441                    *cliff_duration > 0u64,
442                    ContractError::InvalidCampaignParam {
443                        param: "cliff_duration".to_string(),
444                        reason: "cannot be zero".to_string(),
445                    }
446                );
447
448                ensure!(
449                    // it is safe to unwrap because this cliff validation only applies for linear vesting,
450                    // which contains an end_time
451                    *cliff_duration < end_time.unwrap() - start_time,
452                    ContractError::InvalidCampaignParam {
453                        param: "cliff_duration".to_string(),
454                        reason: "cannot be greater or equal than the distribution duration"
455                            .to_string(),
456                    }
457                );
458            }
459        }
460
461        ensure!(
462            total_percentage == Decimal::percent(100),
463            ContractError::InvalidDistributionPercentage {
464                expected: Decimal::percent(100),
465                actual: total_percentage,
466            }
467        );
468
469        Ok(())
470    }
471
472    /// Validates the total reward amount
473    pub fn validate_rewards(&self) -> Result<(), ContractError> {
474        ensure!(
475            self.total_reward.amount > Uint128::zero(),
476            ContractError::InvalidCampaignParam {
477                param: "total_reward".to_string(),
478                reason: "cannot be zero".to_string()
479            }
480        );
481
482        Ok(())
483    }
484}
485
486#[cw_serde]
487pub enum DistributionType {
488    /// The distribution is done in a linear vesting schedule
489    LinearVesting {
490        /// The percentage of the total reward to be distributed with a linear vesting schedule
491        percentage: Decimal,
492        /// The unix timestamp when this distribution type starts, in seconds
493        start_time: u64,
494        /// The unix timestamp when this distribution type ends, in seconds
495        end_time: u64,
496        /// The duration of the cliff, in seconds
497        cliff_duration: Option<u64>,
498    },
499    /// The distribution is done in a single lump sum, i.e. no vesting period
500    LumpSum {
501        percentage: Decimal,
502        /// The unix timestamp when this distribution type starts, in seconds
503        start_time: u64,
504    },
505}
506
507impl DistributionType {
508    pub fn has_started(&self, current_time: &Timestamp) -> bool {
509        let start_time = match self {
510            DistributionType::LinearVesting { start_time, .. } => start_time,
511            DistributionType::LumpSum { start_time, .. } => start_time,
512        };
513
514        current_time.seconds() >= *start_time
515    }
516}