diff --git a/Cargo.lock b/Cargo.lock index b382d0426c..5521af1fa3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,6 +97,70 @@ dependencies = [ "memchr", ] +[[package]] +name = "airdrop_bot_canister" +version = "0.1.0" +dependencies = [ + "bot_api", + "candid", + "candid_gen", + "serde", + "types", + "user_canister", + "user_index_canister", +] + +[[package]] +name = "airdrop_bot_canister_client" +version = "0.1.0" +dependencies = [ + "airdrop_bot_canister", + "candid", + "canister_client", + "ic-agent", + "types", +] + +[[package]] +name = "airdrop_bot_canister_impl" +version = "0.1.0" +dependencies = [ + "airdrop_bot_canister", + "candid", + "canister_api_macros", + "canister_logger", + "canister_state_macros", + "canister_tracing_macros", + "community_canister", + "community_canister_c2c_client", + "futures", + "http_request", + "ic-cdk 0.14.0", + "ic-cdk-timers", + "ic-ledger-types", + "ic-stable-structures 0.6.4", + "icrc-ledger-types", + "icrc_ledger_canister_c2c_client", + "local_user_index_canister", + "local_user_index_canister_c2c_client", + "msgpack", + "rand 0.8.5", + "serde", + "serde_bytes", + "serde_json", + "serializer", + "stable_memory", + "testing", + "time", + "tracing", + "types", + "user_canister", + "user_canister_c2c_client", + "user_index_canister", + "user_index_canister_c2c_client", + "utils 0.1.0", +] + [[package]] name = "allocator-api2" version = "0.2.18" @@ -1055,6 +1119,8 @@ version = "0.1.0" name = "canister_installer" version = "0.1.0" dependencies = [ + "airdrop_bot_canister", + "airdrop_bot_canister_client", "candid", "canister_agent_utils", "clap 4.5.4", @@ -1176,6 +1242,7 @@ dependencies = [ name = "canister_upgrader" version = "0.1.0" dependencies = [ + "airdrop_bot_canister", "candid", "canister_agent_utils", "clap 4.5.4", @@ -4709,6 +4776,7 @@ dependencies = [ name = "integration_tests" version = "0.1.0" dependencies = [ + "airdrop_bot_canister", "candid", "community_canister", "cycles_dispenser_canister", diff --git a/Cargo.toml b/Cargo.toml index 7540074b98..73b92aaa00 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,9 @@ members = [ "backend/tools/canister_installer", "backend/tools/canister_upgrade_proposal_builder", "backend/tools/canister_upgrader", + "backend/canisters/airdrop_bot/api", + "backend/canisters/airdrop_bot/client", + "backend/canisters/airdrop_bot/impl", "backend/canisters/community/api", "backend/canisters/community/c2c_client", "backend/canisters/community/impl", diff --git a/backend/canisters/airdrop_bot/CHANGELOG.md b/backend/canisters/airdrop_bot/CHANGELOG.md new file mode 100644 index 0000000000..909c771f52 --- /dev/null +++ b/backend/canisters/airdrop_bot/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +## [unreleased] + +### Added + +- Add Airdrop Bot ([#6088](https://github.com/open-chat-labs/open-chat/pull/6088)) diff --git a/backend/canisters/airdrop_bot/api/Cargo.toml b/backend/canisters/airdrop_bot/api/Cargo.toml new file mode 100644 index 0000000000..75e98b7954 --- /dev/null +++ b/backend/canisters/airdrop_bot/api/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "airdrop_bot_canister" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +bot_api = { path = "../../../bots/api" } +candid = { workspace = true } +candid_gen = { path = "../../../libraries/candid_gen" } +serde = { workspace = true } +user_canister = { path = "../../user/api" } +user_index_canister = { path = "../../user_index/api" } +types = { path = "../../../libraries/types" } diff --git a/backend/canisters/airdrop_bot/api/can.did b/backend/canisters/airdrop_bot/api/can.did new file mode 100644 index 0000000000..75aa0b8ec1 --- /dev/null +++ b/backend/canisters/airdrop_bot/api/can.did @@ -0,0 +1,37 @@ +import "../../../libraries/types/can.did"; + +type SetAvatarArgs = record { + avatar : opt Document; +}; + +type SetAvatarResponse = variant { + Success; + AvatarTooBig : FieldTooLongResult; +}; + +type SetAirdropArgs = record { + community_id : CommunityId; + channel_id : ChannelId; + start : TimestampMillis; + main_chat_fund : nat; + main_chit_band : nat32; + lottery_prizes : vec nat; + lottery_chit_band : nat32; +}; + +type CancelAirdropResponse = variant { + Success; +}; + +type SetAirdropResponse = variant { + Success; + ChannelUsed; + InThePast; + ClashesWithPrevious; +}; + +service : { + set_avatar : (SetAvatarArgs) -> (SetAvatarResponse); + set_airdrop : (SetAirdropArgs) -> (SetAirdropResponse); + cancel_airdrop : (EmptyArgs) -> (CancelAirdropResponse); +}; diff --git a/backend/canisters/airdrop_bot/api/src/lib.rs b/backend/canisters/airdrop_bot/api/src/lib.rs new file mode 100644 index 0000000000..9550e16025 --- /dev/null +++ b/backend/canisters/airdrop_bot/api/src/lib.rs @@ -0,0 +1,5 @@ +mod lifecycle; +mod updates; + +pub use lifecycle::*; +pub use updates::*; diff --git a/backend/canisters/airdrop_bot/api/src/lifecycle/init.rs b/backend/canisters/airdrop_bot/api/src/lifecycle/init.rs new file mode 100644 index 0000000000..9f78992a80 --- /dev/null +++ b/backend/canisters/airdrop_bot/api/src/lifecycle/init.rs @@ -0,0 +1,13 @@ +use candid::{CandidType, Principal}; +use serde::{Deserialize, Serialize}; +use types::{BuildVersion, CanisterId}; + +#[derive(CandidType, Serialize, Deserialize, Debug)] +pub struct Args { + pub user_index_canister_id: CanisterId, + pub local_user_index_canister_id: CanisterId, + pub chat_ledger_canister_id: CanisterId, + pub admins: Vec, + pub wasm_version: BuildVersion, + pub test_mode: bool, +} diff --git a/backend/canisters/airdrop_bot/api/src/lifecycle/mod.rs b/backend/canisters/airdrop_bot/api/src/lifecycle/mod.rs new file mode 100644 index 0000000000..70bd4f5a23 --- /dev/null +++ b/backend/canisters/airdrop_bot/api/src/lifecycle/mod.rs @@ -0,0 +1,2 @@ +pub mod init; +pub mod post_upgrade; diff --git a/backend/canisters/airdrop_bot/api/src/lifecycle/post_upgrade.rs b/backend/canisters/airdrop_bot/api/src/lifecycle/post_upgrade.rs new file mode 100644 index 0000000000..470a25ac40 --- /dev/null +++ b/backend/canisters/airdrop_bot/api/src/lifecycle/post_upgrade.rs @@ -0,0 +1,8 @@ +use candid::CandidType; +use serde::{Deserialize, Serialize}; +use types::BuildVersion; + +#[derive(CandidType, Serialize, Deserialize, Debug)] +pub struct Args { + pub wasm_version: BuildVersion, +} diff --git a/backend/canisters/airdrop_bot/api/src/main.rs b/backend/canisters/airdrop_bot/api/src/main.rs new file mode 100644 index 0000000000..5a2c6834a5 --- /dev/null +++ b/backend/canisters/airdrop_bot/api/src/main.rs @@ -0,0 +1,11 @@ +use candid_gen::generate_candid_method; + +#[allow(deprecated)] +fn main() { + generate_candid_method!(airdrop_bot, set_avatar, update); + generate_candid_method!(airdrop_bot, set_airdrop, update); + generate_candid_method!(airdrop_bot, cancel_airdrop, update); + + candid::export_service!(); + std::print!("{}", __export_service()); +} diff --git a/backend/canisters/airdrop_bot/api/src/updates/cancel_airdrop.rs b/backend/canisters/airdrop_bot/api/src/updates/cancel_airdrop.rs new file mode 100644 index 0000000000..bb6d57c4b4 --- /dev/null +++ b/backend/canisters/airdrop_bot/api/src/updates/cancel_airdrop.rs @@ -0,0 +1,10 @@ +use candid::CandidType; +use serde::{Deserialize, Serialize}; +use types::Empty; + +pub type Args = Empty; + +#[derive(CandidType, Serialize, Deserialize, Debug)] +pub enum Response { + Success, +} diff --git a/backend/canisters/airdrop_bot/api/src/updates/handle_direct_message.rs b/backend/canisters/airdrop_bot/api/src/updates/handle_direct_message.rs new file mode 100644 index 0000000000..924a492d2c --- /dev/null +++ b/backend/canisters/airdrop_bot/api/src/updates/handle_direct_message.rs @@ -0,0 +1 @@ +pub use bot_api::handle_direct_message::{Response::*, *}; diff --git a/backend/canisters/airdrop_bot/api/src/updates/mod.rs b/backend/canisters/airdrop_bot/api/src/updates/mod.rs new file mode 100644 index 0000000000..b1d2ec58e6 --- /dev/null +++ b/backend/canisters/airdrop_bot/api/src/updates/mod.rs @@ -0,0 +1,4 @@ +pub mod cancel_airdrop; +pub mod handle_direct_message; +pub mod set_airdrop; +pub mod set_avatar; diff --git a/backend/canisters/airdrop_bot/api/src/updates/set_airdrop.rs b/backend/canisters/airdrop_bot/api/src/updates/set_airdrop.rs new file mode 100644 index 0000000000..4a10ec8800 --- /dev/null +++ b/backend/canisters/airdrop_bot/api/src/updates/set_airdrop.rs @@ -0,0 +1,22 @@ +use candid::CandidType; +use serde::{Deserialize, Serialize}; +use types::{ChannelId, CommunityId, TimestampMillis}; + +#[derive(CandidType, Serialize, Deserialize, Debug)] +pub struct Args { + pub community_id: CommunityId, + pub channel_id: ChannelId, + pub start: TimestampMillis, + pub main_chat_fund: u128, + pub main_chit_band: u32, + pub lottery_prizes: Vec, + pub lottery_chit_band: u32, +} + +#[derive(CandidType, Serialize, Deserialize, Debug)] +pub enum Response { + Success, + ChannelUsed, + InThePast, + ClashesWithPrevious, +} diff --git a/backend/canisters/airdrop_bot/api/src/updates/set_avatar.rs b/backend/canisters/airdrop_bot/api/src/updates/set_avatar.rs new file mode 100644 index 0000000000..fca0abe187 --- /dev/null +++ b/backend/canisters/airdrop_bot/api/src/updates/set_avatar.rs @@ -0,0 +1,15 @@ +use candid::CandidType; +use serde::{Deserialize, Serialize}; +use std::fmt::Debug; +use types::{Document, FieldTooLongResult}; + +#[derive(CandidType, Serialize, Deserialize, Debug)] +pub struct Args { + pub avatar: Option, +} + +#[derive(CandidType, Serialize, Deserialize, Debug)] +pub enum Response { + Success, + AvatarTooBig(FieldTooLongResult), +} diff --git a/backend/canisters/airdrop_bot/canister_ids.json b/backend/canisters/airdrop_bot/canister_ids.json new file mode 100644 index 0000000000..97211263c7 --- /dev/null +++ b/backend/canisters/airdrop_bot/canister_ids.json @@ -0,0 +1,6 @@ +{ + "airdrop_bot": { + "ic": "wznbi-caaaa-aaaar-anvea-cai", + "ic_test": "uuw5d-uiaaa-aaaar-anzeq-cai" + } +} \ No newline at end of file diff --git a/backend/canisters/airdrop_bot/client/Cargo.toml b/backend/canisters/airdrop_bot/client/Cargo.toml new file mode 100644 index 0000000000..0ec6b12547 --- /dev/null +++ b/backend/canisters/airdrop_bot/client/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "airdrop_bot_canister_client" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +candid = { workspace = true } +canister_client = { path = "../../../libraries/canister_client" } +ic-agent = { workspace = true } +airdrop_bot_canister = { path = "../api" } +types = { path = "../../../libraries/types" } diff --git a/backend/canisters/airdrop_bot/client/src/lib.rs b/backend/canisters/airdrop_bot/client/src/lib.rs new file mode 100644 index 0000000000..1b5f9d6a01 --- /dev/null +++ b/backend/canisters/airdrop_bot/client/src/lib.rs @@ -0,0 +1,7 @@ +use airdrop_bot_canister::*; +use canister_client::generate_update_call; + +// Queries + +// Updates +generate_update_call!(set_avatar); diff --git a/backend/canisters/airdrop_bot/impl/Cargo.toml b/backend/canisters/airdrop_bot/impl/Cargo.toml new file mode 100644 index 0000000000..be53b18881 --- /dev/null +++ b/backend/canisters/airdrop_bot/impl/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "airdrop_bot_canister_impl" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +path = "src/lib.rs" +crate-type = ["cdylib"] + +[dependencies] +airdrop_bot_canister = { path = "../api" } +candid = { workspace = true } +canister_api_macros = { path = "../../../libraries/canister_api_macros" } +canister_logger = { path = "../../../libraries/canister_logger" } +canister_state_macros = { path = "../../../libraries/canister_state_macros" } +canister_tracing_macros = { path = "../../../libraries/canister_tracing_macros" } +community_canister_c2c_client = { path = "../../community/c2c_client" } +community_canister = { path = "../../community/api" } +futures = { workspace = true } +http_request = { path = "../../../libraries/http_request" } +ic-cdk = { workspace = true } +ic-cdk-timers = { workspace = true } +ic-ledger-types = { workspace = true } +ic-stable-structures = { workspace = true } +icrc_ledger_canister_c2c_client = { path = "../../../external_canisters/icrc_ledger/c2c_client" } +icrc-ledger-types = { workspace = true } +local_user_index_canister_c2c_client = { path = "../../local_user_index/c2c_client" } +local_user_index_canister = { path = "../../local_user_index/api" } +msgpack = { path = "../../../libraries/msgpack" } +rand = { workspace = true } +serde = { workspace = true } +serde_bytes = { workspace = true } +serde_json = { workspace = true } +serializer = { path = "../../../libraries/serializer" } +stable_memory = { path = "../../../libraries/stable_memory" } +testing = { path = "../../../libraries/testing" } +time = { workspace = true, features = ["macros", "formatting"] } +tracing = { workspace = true } +types = { path = "../../../libraries/types" } +user_canister_c2c_client = { path = "../../user/c2c_client" } +user_canister = { path = "../../user/api" } +user_index_canister_c2c_client = { path = "../../user_index/c2c_client" } +user_index_canister = { path = "../../user_index/api" } +utils = { path = "../../../libraries/utils" } diff --git a/backend/canisters/airdrop_bot/impl/src/guards.rs b/backend/canisters/airdrop_bot/impl/src/guards.rs new file mode 100644 index 0000000000..6ed2d758a3 --- /dev/null +++ b/backend/canisters/airdrop_bot/impl/src/guards.rs @@ -0,0 +1,9 @@ +use crate::read_state; + +pub fn caller_is_admin() -> Result<(), String> { + if read_state(|state| state.is_caller_admin()) { + Ok(()) + } else { + Err("Caller is not an admin".to_string()) + } +} diff --git a/backend/canisters/airdrop_bot/impl/src/jobs/execute_airdrop.rs b/backend/canisters/airdrop_bot/impl/src/jobs/execute_airdrop.rs new file mode 100644 index 0000000000..853846cc79 --- /dev/null +++ b/backend/canisters/airdrop_bot/impl/src/jobs/execute_airdrop.rs @@ -0,0 +1,201 @@ +use crate::model::airdrops::AirdropConfig; +use crate::model::pending_actions_queue::{Action, AirdropTransfer, AirdropType, LotteryAirdrop, MainAidrop}; +use crate::{mutate_state, read_state, RuntimeState}; +use ic_cdk_timers::TimerId; +use std::cell::Cell; +use std::iter::zip; +use std::time::Duration; +use tracing::{error, trace}; +use types::{AccessGate, CanisterId, OptionUpdate, UserId}; +use utils::time::MonthKey; + +use super::process_pending_actions; + +thread_local! { + static TIMER_ID: Cell> = Cell::default(); +} + +pub(crate) fn start_job_if_required(state: &RuntimeState) -> bool { + if TIMER_ID.get().is_none() { + start_airdrop_timer(state) + } else { + false + } +} + +pub(crate) fn start_airdrop_timer(state: &RuntimeState) -> bool { + clear_airdrop_timer(); + + if let Some(config) = state.data.airdrops.next() { + // Start the airdrop now if the start date is in the past + let delay = config.start.saturating_sub(state.env.now()); + let timer_id = ic_cdk_timers::set_timer(Duration::from_millis(delay), run); + TIMER_ID.set(Some(timer_id)); + true + } else { + false + } +} + +pub(crate) fn clear_airdrop_timer() { + if let Some(timer_id) = TIMER_ID.take() { + ic_cdk_timers::clear_timer(timer_id); + } +} + +fn run() { + trace!("'execute_airdrop' running"); + TIMER_ID.set(None); + + let (config, user_index_canister_id) = + read_state(|state| (state.data.airdrops.next().cloned(), state.data.user_index_canister_id)); + + if let Some(config) = config { + ic_cdk::spawn(prepare_airdrop(config, user_index_canister_id)); + } else { + trace!("No airdrop configured"); + }; +} + +async fn prepare_airdrop(config: AirdropConfig, user_index_canister_id: CanisterId) { + // Call the configured community canister to set the `locked` gate on the configured channel + match community_canister_c2c_client::update_channel( + config.community_id.into(), + &community_canister::update_channel::Args { + channel_id: config.channel_id, + name: None, + description: None, + rules: None, + avatar: OptionUpdate::NoChange, + permissions_v2: None, + events_ttl: OptionUpdate::NoChange, + gate: OptionUpdate::SetToSome(AccessGate::Locked), + public: None, + }, + ) + .await + { + Ok(community_canister::update_channel::Response::SuccessV2(_)) => (), + Ok(resp) => { + error!(?resp, "Failed to set `locked` gate"); + return; + } + Err(err) => { + error!("{err:?}"); + let timer_id = ic_cdk_timers::set_timer(Duration::from_millis(60_000), run); + TIMER_ID.set(Some(timer_id)); + return; + } + } + + // Call the configured community canister to fetch the particpants of the configured channel + let members = match community_canister_c2c_client::selected_channel_initial( + config.community_id.into(), + &community_canister::selected_channel_initial::Args { + channel_id: config.channel_id, + }, + ) + .await + { + Ok(community_canister::selected_channel_initial::Response::Success(success)) => success.members, + Ok(resp) => { + error!(?resp, "Failed to get channel members"); + return; + } + Err(err) => { + error!("{err:?}"); + let timer_id = ic_cdk_timers::set_timer(Duration::from_secs(60), run); + TIMER_ID.set(Some(timer_id)); + return; + } + }; + + // Call the user_index to get the particpants' CHIT balances for the given month + let mk = MonthKey::from_timestamp(config.start).previous(); + + let users: Vec = members.into_iter().map(|m| m.user_id).collect(); + + let balances = match user_index_canister_c2c_client::chit_balances( + user_index_canister_id, + &user_index_canister::chit_balances::Args { + users: users.clone(), + year: mk.year() as u16, + month: mk.month(), + }, + ) + .await + { + Ok(user_index_canister::chit_balances::Response::Success(result)) => result.balances, + Err(err) => { + error!("{err:?}"); + let timer_id = ic_cdk_timers::set_timer(Duration::from_secs(60), run); + TIMER_ID.set(Some(timer_id)); + return; + } + }; + + let user_balances = zip(users, balances).collect(); + + // Execute the airdrop + mutate_state(|state| execute_airdrop(user_balances, state)); +} + +fn execute_airdrop(particpants: Vec<(UserId, i32)>, state: &mut RuntimeState) { + let rng = state.env.rng(); + + if let Some(airdrop) = state.data.airdrops.execute(particpants, rng) { + // Add the CHAT transfer actions to the queue. When each transfer has succeeded + // the corresponding message action will be added to the queue. + + // Add some suspense to the lottery winning messages by sending them + // one at a time, from nth to 1st, spaced by a bunch of main airdrop messages. + + let mut lottery_winners = airdrop.outcome.lottery_winners.clone(); + + for (user_id, particpant) in airdrop.outcome.participants.iter() { + if state.data.pending_actions_queue.len() % 50 == 0 { + if let Some((user_id, prize)) = lottery_winners.pop() { + state + .data + .pending_actions_queue + .push(Action::Transfer(Box::new(AirdropTransfer { + recipient: user_id, + amount: prize.chat_won, + airdrop_type: AirdropType::Lottery(LotteryAirdrop { + position: lottery_winners.len(), + }), + }))) + } + } + + if let Some(prize) = &particpant.prize { + state + .data + .pending_actions_queue + .push(Action::Transfer(Box::new(AirdropTransfer { + recipient: *user_id, + amount: prize.chat_won, + airdrop_type: AirdropType::Main(MainAidrop { + chit: particpant.chit, + shares: particpant.shares, + }), + }))) + } + } + + while let Some((user_id, prize)) = lottery_winners.pop() { + state + .data + .pending_actions_queue + .push(Action::Transfer(Box::new(AirdropTransfer { + recipient: user_id, + amount: prize.chat_won, + airdrop_type: AirdropType::Lottery(LotteryAirdrop { + position: lottery_winners.len(), + }), + }))) + } + + process_pending_actions::start_job_if_required(state, None); + } +} diff --git a/backend/canisters/airdrop_bot/impl/src/jobs/mod.rs b/backend/canisters/airdrop_bot/impl/src/jobs/mod.rs new file mode 100644 index 0000000000..750b5b5d67 --- /dev/null +++ b/backend/canisters/airdrop_bot/impl/src/jobs/mod.rs @@ -0,0 +1,9 @@ +use crate::RuntimeState; + +pub mod execute_airdrop; +pub mod process_pending_actions; + +pub(crate) fn start(state: &RuntimeState) { + execute_airdrop::start_job_if_required(state); + process_pending_actions::start_job_if_required(state, None); +} diff --git a/backend/canisters/airdrop_bot/impl/src/jobs/process_pending_actions.rs b/backend/canisters/airdrop_bot/impl/src/jobs/process_pending_actions.rs new file mode 100644 index 0000000000..792018b13c --- /dev/null +++ b/backend/canisters/airdrop_bot/impl/src/jobs/process_pending_actions.rs @@ -0,0 +1,285 @@ +use crate::model::pending_actions_queue::{Action, AirdropMessage, AirdropTransfer, AirdropType, LotteryAirdrop, MainAidrop}; +use crate::{mutate_state, read_state, RuntimeState}; +use candid::Principal; +use ic_cdk_timers::TimerId; +use icrc_ledger_types::icrc1::account::Account; +use icrc_ledger_types::icrc1::transfer::{TransferArg, TransferError}; +use rand::Rng; +use std::cell::Cell; +use std::time::Duration; +use time::macros::format_description; +use tracing::{error, trace}; +use types::icrc1::{self}; +use types::{ + BotMessage, CanisterId, ChannelId, CommunityId, CompletedCryptoTransaction, CryptoContent, CryptoTransaction, + Cryptocurrency, MessageContentInitial, +}; +use utils::consts::{MEMO_CHIT_FOR_CHAT_AIRDROP, MEMO_CHIT_FOR_CHAT_LOTTERY}; + +use super::execute_airdrop::start_airdrop_timer; + +const MAX_BATCH_SIZE: usize = 5; + +thread_local! { + static TIMER_ID: Cell> = Cell::default(); +} + +pub(crate) fn start_job_if_required(state: &RuntimeState, after: Option) -> bool { + if TIMER_ID.get().is_none() && !state.data.pending_actions_queue.is_empty() { + let timer_id = ic_cdk_timers::set_timer_interval(after.unwrap_or_default(), run); + TIMER_ID.set(Some(timer_id)); + trace!("'process_pending_actions' job started"); + true + } else { + false + } +} + +fn run() { + let batch = mutate_state(next_batch); + if !batch.is_empty() { + ic_cdk::spawn(process_actions(batch)); + } else if let Some(timer_id) = TIMER_ID.take() { + ic_cdk_timers::clear_timer(timer_id); + trace!("'process_pending_actions' job stopped"); + } +} + +fn next_batch(state: &mut RuntimeState) -> Vec { + (0..MAX_BATCH_SIZE) + .map_while(|_| state.data.pending_actions_queue.pop()) + .collect() +} + +async fn process_actions(actions: Vec) { + let futures: Vec<_> = actions.into_iter().map(process_action).collect(); + + futures::future::join_all(futures).await; +} + +async fn process_action(action: Action) { + match action.clone() { + Action::JoinChannel(community_id, channel_id) => join_channel(community_id, channel_id).await, + Action::SendMessage(action) if matches!(action.airdrop_type, AirdropType::Lottery(_)) => { + handle_lottery_message_action(*action).await + } + Action::SendMessage(action) => handle_main_message_action(*action).await, + Action::Transfer(action) => handle_transfer_action(*action).await, + } +} + +async fn join_channel(community_id: CommunityId, channel_id: ChannelId) { + let local_user_index_canister_id = match community_canister_c2c_client::local_user_index( + community_id.into(), + &community_canister::local_user_index::Args {}, + ) + .await + { + Ok(community_canister::local_user_index::Response::Success(canister_id)) => canister_id, + Err(err) => { + error!("Failed to get local_user_index {err:?}"); + mutate_state(|state| { + state.enqueue_pending_action(Action::JoinChannel(community_id, channel_id), Some(Duration::from_secs(60))) + }); + return; + } + }; + + match local_user_index_canister_c2c_client::join_channel( + local_user_index_canister_id, + &local_user_index_canister::join_channel::Args { + community_id, + channel_id, + invite_code: None, + verified_credential_args: None, + }, + ) + .await + { + Ok(_) => (), + Err(err) => { + error!("Failed to join_channel {err:?}"); + mutate_state(|state| { + state.enqueue_pending_action(Action::JoinChannel(community_id, channel_id), Some(Duration::from_secs(60))) + }); + return; + } + } + + read_state(start_airdrop_timer); +} + +async fn handle_transfer_action(action: AirdropTransfer) { + let (this_canister_id, ledger_canister_id, now_nanos) = read_state(|state| { + ( + state.env.canister_id(), + state.data.chat_ledger_canister_id, + state.env.now_nanos(), + ) + }); + + let token = Cryptocurrency::CHAT; + let to = Account::from(Principal::from(action.recipient)); + let memo = match action.airdrop_type { + AirdropType::Main(_) => MEMO_CHIT_FOR_CHAT_AIRDROP, + AirdropType::Lottery(_) => MEMO_CHIT_FOR_CHAT_LOTTERY, + }; + + let args = TransferArg { + from_subaccount: None, + to, + fee: token.fee().map(|f| f.into()), + created_at_time: Some(now_nanos), + memo: Some(memo.to_vec().into()), + amount: action.amount.into(), + }; + + match icrc_ledger_canister_c2c_client::icrc1_transfer(ledger_canister_id, &args).await { + Ok(Ok(block_index)) => { + mutate_state(|state| { + let fee = token.fee().unwrap(); + let block_index = block_index.0.try_into().unwrap(); + + state.enqueue_pending_action( + Action::SendMessage(Box::new(AirdropMessage { + recipient: action.recipient, + transaction: CompletedCryptoTransaction::ICRC1(icrc1::CompletedCryptoTransaction { + ledger: ledger_canister_id, + token, + amount: action.amount, + fee, + from: Account::from(this_canister_id).into(), + to: to.into(), + memo: Some(memo.to_vec().into()), + created: now_nanos, + block_index, + }), + airdrop_type: action.airdrop_type.clone(), + })), + None, + ); + + match action.airdrop_type { + AirdropType::Lottery(LotteryAirdrop { position }) => { + state.data.airdrops.set_lottery_transaction(position, block_index) + } + AirdropType::Main(_) => state.data.airdrops.set_main_transaction(&action.recipient, block_index), + } + }); + } + Ok(Err(TransferError::InsufficientFunds { balance })) => { + error!(?args, ?balance, "Failed to transfer CHAT, insufficient funds"); + } + Ok(error) => { + error!(?args, ?error, "Failed to transfer CHAT"); + } + Err(error) => { + error!(?args, ?error, "Failed to transfer CHAT, retrying"); + mutate_state(|state| state.enqueue_pending_action(Action::Transfer(Box::new(action)), None)) + } + } +} + +async fn handle_main_message_action(action: AirdropMessage) { + let AirdropType::Main(MainAidrop { chit, shares }) = action.airdrop_type else { + return; + }; + + let Some((username, display_name, month)) = read_state(|state| { + state + .data + .airdrops + .current(state.env.now()) + .and_then(|c| { + let date = time::OffsetDateTime::from_unix_timestamp((c.start / 1000) as i64).unwrap(); + let format = format_description!("[month repr:long]"); + date.format(format).ok() + }) + .map(|m| (state.data.username.clone(), state.data.display_name.clone(), m)) + }) else { + return; + }; + + let args = user_canister::c2c_handle_bot_messages::Args { + bot_name: username, + bot_display_name: display_name, + messages: vec![BotMessage { + thread_root_message_id: None, + content: MessageContentInitial::Crypto(CryptoContent { + recipient: action.recipient, + transfer: CryptoTransaction::Completed(action.transaction.clone()), + caption: Some(format!( + "Congratulations! In {month} you earned {chit} CHIT giving you {shares} shares in the CHIT for CHAT airdrop." + )), + }), + message_id: None, + block_level_markdown: None, + }], + }; + + if user_canister_c2c_client::c2c_handle_bot_messages(CanisterId::from(action.recipient), &args) + .await + .is_err() + { + mutate_state(|state| state.enqueue_pending_action(Action::SendMessage(Box::new(action)), None)); + } +} + +async fn handle_lottery_message_action(action: AirdropMessage) { + let AirdropType::Lottery(LotteryAirdrop { position }): AirdropType = action.airdrop_type else { + return; + }; + + let Some((username, community_id, channel_id, message_id)) = mutate_state(|state| { + state.data.airdrops.current(state.env.now()).map(|c| { + ( + state.data.username.clone(), + c.community_id, + c.channel_id, + state.env.rng().gen(), + ) + }) + }) else { + return; + }; + + let position = match position { + 0 => "1st", + 1 => "2nd", + 2 => "3rd", + n => { + let n = n + 1; + &format!("{n}th") + } + }; + + let args = community_canister::send_message::Args { + channel_id, + thread_root_message_index: None, + message_id, + content: MessageContentInitial::Crypto(CryptoContent { + recipient: action.recipient, + transfer: CryptoTransaction::Completed(action.transaction.clone()), + caption: Some(format!( + "Congratulations! You have won {position} prize in the CHIT for CHAT airdrop lottery!" + )), + }), + sender_name: username, + sender_display_name: None, + replies_to: None, + mentioned: Vec::new(), + forwarding: false, + block_level_markdown: false, + community_rules_accepted: None, + channel_rules_accepted: None, + message_filter_failed: None, + new_achievement: false, + }; + + if community_canister_c2c_client::send_message(community_id.into(), &args) + .await + .is_err() + { + mutate_state(|state| state.enqueue_pending_action(Action::SendMessage(Box::new(action)), None)); + } +} diff --git a/backend/canisters/airdrop_bot/impl/src/lib.rs b/backend/canisters/airdrop_bot/impl/src/lib.rs new file mode 100644 index 0000000000..492554daae --- /dev/null +++ b/backend/canisters/airdrop_bot/impl/src/lib.rs @@ -0,0 +1,129 @@ +use crate::model::pending_actions_queue::{Action, PendingActionsQueue}; +use candid::Principal; +use canister_state_macros::canister_state; +use model::airdrops::{Airdrops, AirdropsMetrics}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::{cell::RefCell, time::Duration}; +use types::{BuildVersion, CanisterId, ChannelId, CommunityId, Cycles, Document, TimestampMillis, Timestamped}; +use utils::env::Environment; + +mod guards; +mod jobs; +mod lifecycle; +mod memory; +mod model; +mod queries; +mod updates; + +thread_local! { + static WASM_VERSION: RefCell> = RefCell::default(); +} + +canister_state!(RuntimeState); + +struct RuntimeState { + pub env: Box, + pub data: Data, +} + +impl RuntimeState { + pub fn new(env: Box, data: Data) -> RuntimeState { + RuntimeState { env, data } + } + + pub fn is_caller_admin(&self) -> bool { + let caller = self.env.caller(); + self.data.admins.contains(&caller) + } + + pub fn enqueue_pending_action(&mut self, action: Action, after: Option) { + self.data.pending_actions_queue.push(action); + jobs::process_pending_actions::start_job_if_required(self, after); + } + + pub fn metrics(&self) -> Metrics { + Metrics { + heap_memory_used: utils::memory::heap(), + stable_memory_used: utils::memory::stable(), + now: self.env.now(), + cycles_balance: self.env.cycles_balance(), + wasm_version: WASM_VERSION.with_borrow(|v| **v), + git_commit_id: utils::git::git_commit_id().to_string(), + username: self.data.username.clone(), + display_name: self.data.display_name.clone(), + initialized: self.data.initialized, + canister_ids: CanisterIds { + user_index: self.data.user_index_canister_id, + local_user_index: self.data.local_user_index_canister_id, + chat_ledger: self.data.chat_ledger_canister_id, + }, + airdrops: self.data.airdrops.metrics(), + } + } +} + +#[derive(Serialize, Deserialize)] +struct Data { + pub user_index_canister_id: CanisterId, + pub local_user_index_canister_id: CanisterId, + pub chat_ledger_canister_id: CanisterId, + pub admins: HashSet, + pub avatar: Timestamped>, + pub username: String, + pub display_name: Option, + pub airdrops: Airdrops, + pub channels_joined: HashSet<(CommunityId, ChannelId)>, + pub pending_actions_queue: PendingActionsQueue, + pub initialized: bool, + pub rng_seed: [u8; 32], + pub test_mode: bool, +} + +impl Data { + pub fn new( + user_index_canister_id: CanisterId, + local_user_index_canister_id: CanisterId, + chat_ledger_canister_id: CanisterId, + admins: HashSet, + test_mode: bool, + ) -> Data { + Data { + user_index_canister_id, + local_user_index_canister_id, + chat_ledger_canister_id, + admins, + avatar: Timestamped::default(), + username: "".to_string(), + display_name: None, + airdrops: Airdrops::default(), + channels_joined: HashSet::default(), + pending_actions_queue: PendingActionsQueue::default(), + initialized: false, + rng_seed: [0; 32], + test_mode, + } + } +} + +#[derive(Serialize, Debug)] +pub struct Metrics { + pub now: TimestampMillis, + pub heap_memory_used: u64, + pub stable_memory_used: u64, + pub cycles_balance: Cycles, + pub wasm_version: BuildVersion, + pub git_commit_id: String, + pub username: String, + pub display_name: Option, + pub initialized: bool, + pub canister_ids: CanisterIds, + pub airdrops: AirdropsMetrics, +} + +#[derive(Serialize, Debug)] +pub struct CanisterIds { + pub user_index: CanisterId, + pub local_user_index: CanisterId, + pub chat_ledger: CanisterId, +} diff --git a/backend/canisters/airdrop_bot/impl/src/lifecycle/init.rs b/backend/canisters/airdrop_bot/impl/src/lifecycle/init.rs new file mode 100644 index 0000000000..f306daee51 --- /dev/null +++ b/backend/canisters/airdrop_bot/impl/src/lifecycle/init.rs @@ -0,0 +1,26 @@ +use crate::lifecycle::{init_env, init_state}; +use crate::Data; +use airdrop_bot_canister::init::Args; +use canister_tracing_macros::trace; +use ic_cdk::init; +use tracing::info; + +#[init] +#[trace] +fn init(args: Args) { + canister_logger::init(args.test_mode); + + let env = init_env([0; 32]); + + let data = Data::new( + args.user_index_canister_id, + args.local_user_index_canister_id, + args.chat_ledger_canister_id, + args.admins.into_iter().collect(), + args.test_mode, + ); + + init_state(env, data, args.wasm_version); + + info!(version = %args.wasm_version, "Initialization complete"); +} diff --git a/backend/canisters/airdrop_bot/impl/src/lifecycle/mod.rs b/backend/canisters/airdrop_bot/impl/src/lifecycle/mod.rs new file mode 100644 index 0000000000..831c7067b9 --- /dev/null +++ b/backend/canisters/airdrop_bot/impl/src/lifecycle/mod.rs @@ -0,0 +1,43 @@ +use crate::{mutate_state, Data, RuntimeState, WASM_VERSION}; +use std::time::Duration; +use tracing::trace; +use types::{BuildVersion, Timestamped}; +use utils::canister::get_random_seed; +use utils::env::canister::CanisterEnv; +use utils::env::Environment; + +mod init; +mod post_upgrade; +mod pre_upgrade; + +fn init_env(rng_seed: [u8; 32]) -> Box { + let canister_env = if rng_seed == [0; 32] { + ic_cdk_timers::set_timer(Duration::ZERO, reseed_rng); + CanisterEnv::default() + } else { + CanisterEnv::new(rng_seed) + }; + Box::new(canister_env) +} + +fn init_state(env: Box, data: Data, wasm_version: BuildVersion) { + let now = env.now(); + let state = RuntimeState::new(env, data); + + crate::jobs::start(&state); + crate::init_state(state); + WASM_VERSION.set(Timestamped::new(wasm_version, now)); +} + +fn reseed_rng() { + ic_cdk::spawn(reseed_rng_inner()); + + async fn reseed_rng_inner() { + let seed = get_random_seed().await; + mutate_state(|state| { + state.data.rng_seed = seed; + state.env = Box::new(CanisterEnv::new(seed)) + }); + trace!("Successfully reseeded rng"); + } +} diff --git a/backend/canisters/airdrop_bot/impl/src/lifecycle/post_upgrade.rs b/backend/canisters/airdrop_bot/impl/src/lifecycle/post_upgrade.rs new file mode 100644 index 0000000000..76a9c8fd53 --- /dev/null +++ b/backend/canisters/airdrop_bot/impl/src/lifecycle/post_upgrade.rs @@ -0,0 +1,25 @@ +use crate::lifecycle::{init_env, init_state}; +use crate::memory::get_upgrades_memory; +use crate::Data; +use airdrop_bot_canister::post_upgrade::Args; +use canister_logger::LogEntry; +use canister_tracing_macros::trace; +use ic_cdk::post_upgrade; +use stable_memory::get_reader; +use tracing::info; + +#[post_upgrade] +#[trace] +fn post_upgrade(args: Args) { + let memory = get_upgrades_memory(); + let reader = get_reader(&memory); + + let (data, logs, traces): (Data, Vec, Vec) = serializer::deserialize(reader).unwrap(); + + canister_logger::init_with_logs(data.test_mode, logs, traces); + + let env = init_env(data.rng_seed); + init_state(env, data, args.wasm_version); + + info!(version = %args.wasm_version, "Post-upgrade complete"); +} diff --git a/backend/canisters/airdrop_bot/impl/src/lifecycle/pre_upgrade.rs b/backend/canisters/airdrop_bot/impl/src/lifecycle/pre_upgrade.rs new file mode 100644 index 0000000000..70dc57a395 --- /dev/null +++ b/backend/canisters/airdrop_bot/impl/src/lifecycle/pre_upgrade.rs @@ -0,0 +1,26 @@ +use crate::memory::get_upgrades_memory; +use crate::take_state; +use canister_tracing_macros::trace; +use ic_cdk::pre_upgrade; +use rand::Rng; +use stable_memory::get_writer; +use tracing::info; + +#[pre_upgrade] +#[trace] +fn pre_upgrade() { + info!("Pre-upgrade starting"); + + let mut state = take_state(); + state.data.rng_seed = state.env.rng().gen(); + + let logs = canister_logger::export_logs(); + let traces = canister_logger::export_traces(); + + let stable_state = (state.data, logs, traces); + + let mut memory = get_upgrades_memory(); + let writer = get_writer(&mut memory); + + serializer::serialize(stable_state, writer).unwrap(); +} diff --git a/backend/canisters/airdrop_bot/impl/src/memory.rs b/backend/canisters/airdrop_bot/impl/src/memory.rs new file mode 100644 index 0000000000..d3a1de0b7b --- /dev/null +++ b/backend/canisters/airdrop_bot/impl/src/memory.rs @@ -0,0 +1,21 @@ +use ic_stable_structures::{ + memory_manager::{MemoryId, MemoryManager, VirtualMemory}, + DefaultMemoryImpl, +}; + +const UPGRADES: MemoryId = MemoryId::new(0); + +pub type Memory = VirtualMemory; + +thread_local! { + static MEMORY_MANAGER: MemoryManager + = MemoryManager::init(DefaultMemoryImpl::default()); +} + +pub fn get_upgrades_memory() -> Memory { + get_memory(UPGRADES) +} + +fn get_memory(id: MemoryId) -> Memory { + MEMORY_MANAGER.with(|m| m.get(id)) +} diff --git a/backend/canisters/airdrop_bot/impl/src/model/airdrops.rs b/backend/canisters/airdrop_bot/impl/src/model/airdrops.rs new file mode 100644 index 0000000000..4a409d92e3 --- /dev/null +++ b/backend/canisters/airdrop_bot/impl/src/model/airdrops.rs @@ -0,0 +1,288 @@ +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use types::{ChannelId, CommunityId, TimestampMillis, UserId}; +use utils::time::MonthKey; + +#[derive(Serialize, Deserialize, Default)] +pub struct Airdrops { + past: Vec, + next: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct Airdrop { + pub config: AirdropConfig, + pub outcome: AirdropOutcome, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct AirdropConfig { + pub community_id: CommunityId, + pub channel_id: ChannelId, + pub start: TimestampMillis, + pub main_chat_fund: u128, + pub main_chit_band: u32, + pub lottery_prizes: Vec, + pub lottery_chit_band: u32, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct AirdropOutcome { + pub participants: HashMap, + pub lottery_winners: Vec<(UserId, Prize)>, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct Participant { + pub chit: u32, + pub shares: u32, + pub prize: Option, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct Prize { + pub chat_won: u128, + pub block_index: Option, +} + +pub enum SetNextResult { + Success, + ChannelUsed, + InThePast, + ClashesWithPrevious, +} + +#[derive(Serialize, Debug)] +pub struct AirdropsMetrics { + past: Vec, + next: Option, +} + +#[derive(Serialize, Debug)] +pub struct AirdropMetrics { + pub config: AirdropConfig, + pub outcome: AirdropOutcomeMetrics, +} + +#[derive(Serialize, Debug)] +pub struct AirdropOutcomeMetrics { + pub participants: u32, + pub lottery_winners: Vec<(UserId, Prize)>, +} + +impl Airdrops { + pub fn set_next(&mut self, config: AirdropConfig, now: TimestampMillis) -> SetNextResult { + if config.start < now { + return SetNextResult::InThePast; + } + + if self + .past + .iter() + .any(|a| a.config.community_id == config.community_id && a.config.channel_id == config.channel_id) + { + return SetNextResult::ChannelUsed; + } + + if let Some(previous) = self.past.last() { + if MonthKey::from_timestamp(previous.config.start) == MonthKey::from_timestamp(config.start) { + return SetNextResult::ClashesWithPrevious; + } + } + + self.next = Some(config); + + SetNextResult::Success + } + + pub fn cancel(&mut self) -> Option { + self.next.take() + } + + pub fn execute(&mut self, users: Vec<(UserId, i32)>, rng: &mut R) -> Option<&Airdrop> { + let config = self.next.take()?; + + let mut total_shares: u32 = 0; + let mut total_tickets: u32 = 0; + let mut user_shares: Vec<(UserId, u32, u32)> = Vec::new(); + let mut ticket_holders: Vec = Vec::new(); + + for (user_id, chit) in users { + let chit = chit as u32; + let shares = chit / config.main_chit_band; + let tickets = chit / config.lottery_chit_band; + + total_shares += shares; + total_tickets += tickets; + + user_shares.push((user_id, chit, shares)); + + for _n in 0..tickets { + ticket_holders.push(user_id); + } + } + + if total_tickets == 0 || total_shares == 0 { + return None; + } + + let fund = config.main_chat_fund; + let prizes = config.lottery_prizes.len(); + let outcome = AirdropOutcome { + participants: user_shares + .into_iter() + .map(|(u, chit, shares)| { + ( + u, + Participant { + chit, + shares, + prize: if shares > 0 { + Some(Prize { + chat_won: (fund * shares as u128) / total_shares as u128, + block_index: None, + }) + } else { + None + }, + }, + ) + }) + .collect(), + lottery_winners: (0..prizes) + .map(|i| { + let winning_ticket = (rng.next_u32() % total_tickets) as usize; + let winner = ticket_holders[winning_ticket]; + ( + winner, + Prize { + chat_won: config.lottery_prizes[i], + block_index: None, + }, + ) + }) + .collect(), + }; + + let airdrop = Airdrop { config, outcome }; + + self.past.push(airdrop); + + Some(self.past.last().as_ref().unwrap()) + } + + pub fn set_main_transaction(&mut self, user_id: &UserId, block_index: u64) -> bool { + if let Some(last) = self.past.last_mut() { + if let Some(participant) = last.outcome.participants.get_mut(user_id) { + if let Some(prize) = &mut participant.prize { + if prize.block_index.is_none() { + prize.block_index = Some(block_index); + return true; + } + } + } + } + + false + } + + pub fn set_lottery_transaction(&mut self, winning_index: usize, block_index: u64) -> bool { + if let Some(last) = self.past.last_mut() { + if let Some((_, prize)) = last.outcome.lottery_winners.get_mut(winning_index) { + if prize.block_index.is_none() { + prize.block_index = Some(block_index); + return true; + } + } + } + + false + } + + pub fn current(&self, now: TimestampMillis) -> Option<&AirdropConfig> { + if let Some(last) = self.past.last() { + if MonthKey::from_timestamp(now) == MonthKey::from_timestamp(last.config.start) { + return Some(&last.config); + } + } + + None + } + + pub fn metrics(&self) -> AirdropsMetrics { + AirdropsMetrics { + past: self + .past + .iter() + .map(|a| AirdropMetrics { + config: a.config.clone(), + outcome: AirdropOutcomeMetrics { + participants: a.outcome.participants.len() as u32, + lottery_winners: a.outcome.lottery_winners.clone(), + }, + }) + .collect(), + next: self.next.clone(), + } + } + + pub fn next(&self) -> Option<&AirdropConfig> { + self.next.as_ref() + } +} + +#[cfg(test)] +mod tests { + use testing::rng::random_principal; + use utils::env::{test::TestEnv, Environment}; + + use super::*; + + #[test] + fn execute_airdrop_expected() { + let mut env = TestEnv::default(); + let mut airdrops = setup(env.now); + let users = generate_random_users(); + + let airdrop = airdrops.execute(users, env.rng()).expect("Expected some airdrop"); + + println!("{:#?}", airdrop.outcome); + + assert_eq!(airdrop.outcome.lottery_winners.len(), 3); + assert_eq!( + airdrop + .outcome + .lottery_winners + .iter() + .map(|(_, p)| p.chat_won) + .collect::>(), + vec![12000_u128, 5000_u128, 3000_u128] + ) + } + + fn setup(now: TimestampMillis) -> Airdrops { + let mut airdrops = Airdrops::default(); + + airdrops.set_next( + AirdropConfig { + community_id: random_principal().into(), + channel_id: 1, + start: now + 1_000, + main_chat_fund: 80_000, + main_chit_band: 10_000, + lottery_prizes: vec![12_000, 5_000, 3_000], + lottery_chit_band: 50_000, + }, + now, + ); + + airdrops + } + + fn generate_random_users() -> Vec<(UserId, i32)> { + (0..1000) + .map(|_| (random_principal().into(), (rand::thread_rng().next_u32() % 110_000) as i32)) + .collect() + } +} diff --git a/backend/canisters/airdrop_bot/impl/src/model/mod.rs b/backend/canisters/airdrop_bot/impl/src/model/mod.rs new file mode 100644 index 0000000000..5bb2f8de1a --- /dev/null +++ b/backend/canisters/airdrop_bot/impl/src/model/mod.rs @@ -0,0 +1,2 @@ +pub mod airdrops; +pub mod pending_actions_queue; diff --git a/backend/canisters/airdrop_bot/impl/src/model/pending_actions_queue.rs b/backend/canisters/airdrop_bot/impl/src/model/pending_actions_queue.rs new file mode 100644 index 0000000000..7e4b9063f8 --- /dev/null +++ b/backend/canisters/airdrop_bot/impl/src/model/pending_actions_queue.rs @@ -0,0 +1,64 @@ +use serde::{Deserialize, Serialize}; +use std::collections::vec_deque::VecDeque; +use types::{ChannelId, CommunityId, CompletedCryptoTransaction, UserId}; + +#[derive(Serialize, Deserialize, Default)] +pub struct PendingActionsQueue { + queue: VecDeque, +} + +impl PendingActionsQueue { + pub fn push(&mut self, action: Action) { + self.queue.push_back(action); + } + + pub fn pop(&mut self) -> Option { + self.queue.pop_front() + } + + pub fn is_empty(&self) -> bool { + self.queue.is_empty() + } + + pub fn len(&self) -> usize { + self.queue.len() + } +} + +#[derive(Serialize, Deserialize, Clone)] +pub enum Action { + JoinChannel(CommunityId, ChannelId), + Transfer(Box), + SendMessage(Box), +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct AirdropTransfer { + pub recipient: UserId, + pub amount: u128, + pub airdrop_type: AirdropType, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct AirdropMessage { + pub recipient: UserId, + pub transaction: CompletedCryptoTransaction, + pub airdrop_type: AirdropType, +} + +#[derive(Serialize, Deserialize, Clone)] +pub enum AirdropType { + Main(MainAidrop), + Lottery(LotteryAirdrop), +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct MainAidrop { + pub chit: u32, + pub shares: u32, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct LotteryAirdrop { + pub position: usize, +} diff --git a/backend/canisters/airdrop_bot/impl/src/queries/http_request.rs b/backend/canisters/airdrop_bot/impl/src/queries/http_request.rs new file mode 100644 index 0000000000..601ab5612d --- /dev/null +++ b/backend/canisters/airdrop_bot/impl/src/queries/http_request.rs @@ -0,0 +1,38 @@ +use crate::{read_state, RuntimeState}; +use http_request::{build_json_response, encode_logs, extract_route, get_document, Route}; +use ic_cdk::query; +use types::{HttpRequest, HttpResponse, TimestampMillis}; + +#[query] +fn http_request(request: HttpRequest) -> HttpResponse { + fn get_avatar_impl(requested_avatar_id: Option, state: &RuntimeState) -> HttpResponse { + get_document(requested_avatar_id, &state.data.avatar, "avatar") + } + + fn get_logs_impl(since: Option) -> HttpResponse { + encode_logs(canister_logger::export_logs(), since.unwrap_or(0)) + } + + fn get_traces_impl(since: Option) -> HttpResponse { + encode_logs(canister_logger::export_traces(), since.unwrap_or(0)) + } + + fn get_metrics_impl(state: &RuntimeState) -> HttpResponse { + build_json_response(&state.metrics()) + } + + fn get_admins(state: &RuntimeState) -> HttpResponse { + let principals: Vec<_> = state.data.admins.iter().collect(); + + build_json_response(&principals) + } + + match extract_route(&request.url) { + Route::Avatar(requested_avatar_id) => read_state(|state| get_avatar_impl(requested_avatar_id, state)), + Route::Logs(since) => get_logs_impl(since), + Route::Traces(since) => get_traces_impl(since), + Route::Metrics => read_state(get_metrics_impl), + Route::Other(path, _) if path == "admins" => read_state(get_admins), + _ => HttpResponse::not_found(), + } +} diff --git a/backend/canisters/airdrop_bot/impl/src/queries/mod.rs b/backend/canisters/airdrop_bot/impl/src/queries/mod.rs new file mode 100644 index 0000000000..1cfa1ad736 --- /dev/null +++ b/backend/canisters/airdrop_bot/impl/src/queries/mod.rs @@ -0,0 +1 @@ +mod http_request; diff --git a/backend/canisters/airdrop_bot/impl/src/updates/cancel_airdrop.rs b/backend/canisters/airdrop_bot/impl/src/updates/cancel_airdrop.rs new file mode 100644 index 0000000000..16510439ca --- /dev/null +++ b/backend/canisters/airdrop_bot/impl/src/updates/cancel_airdrop.rs @@ -0,0 +1,20 @@ +use crate::guards::caller_is_admin; +use crate::jobs::execute_airdrop::clear_airdrop_timer; +use crate::{mutate_state, RuntimeState}; +use airdrop_bot_canister::cancel_airdrop::*; +use canister_tracing_macros::trace; +use ic_cdk::update; + +#[update(guard = "caller_is_admin")] +#[trace] +fn cancel_airdrop(_args: Args) -> Response { + mutate_state(cancel_airdrop_impl) +} + +fn cancel_airdrop_impl(state: &mut RuntimeState) -> Response { + if state.data.airdrops.cancel().is_some() { + clear_airdrop_timer(); + } + + Response::Success +} diff --git a/backend/canisters/airdrop_bot/impl/src/updates/handle_direct_message.rs b/backend/canisters/airdrop_bot/impl/src/updates/handle_direct_message.rs new file mode 100644 index 0000000000..3e11d66f6a --- /dev/null +++ b/backend/canisters/airdrop_bot/impl/src/updates/handle_direct_message.rs @@ -0,0 +1,25 @@ +use crate::{mutate_state, RuntimeState}; +use airdrop_bot_canister::handle_direct_message::*; +use canister_api_macros::update; +use canister_tracing_macros::trace; +use types::{BotMessage, MessageContentInitial, TextContent}; + +#[update(msgpack = true)] +#[trace] +fn handle_direct_message(_args: Args) -> Response { + mutate_state(handle_message) +} + +fn handle_message(state: &mut RuntimeState) -> Response { + let text = "Hi, I am the bot which conducts the CHIT for CHAT airdrops. For information about CHIT and the aridrops please read [this blog post](https://oc.app/blog/chit).".to_string(); + Success(SuccessResult { + bot_name: state.data.username.clone(), + bot_display_name: None, + messages: vec![BotMessage { + thread_root_message_id: None, + content: MessageContentInitial::Text(TextContent { text }), + message_id: None, + block_level_markdown: None, + }], + }) +} diff --git a/backend/canisters/airdrop_bot/impl/src/updates/mod.rs b/backend/canisters/airdrop_bot/impl/src/updates/mod.rs new file mode 100644 index 0000000000..b1d2ec58e6 --- /dev/null +++ b/backend/canisters/airdrop_bot/impl/src/updates/mod.rs @@ -0,0 +1,4 @@ +pub mod cancel_airdrop; +pub mod handle_direct_message; +pub mod set_airdrop; +pub mod set_avatar; diff --git a/backend/canisters/airdrop_bot/impl/src/updates/set_airdrop.rs b/backend/canisters/airdrop_bot/impl/src/updates/set_airdrop.rs new file mode 100644 index 0000000000..b86c612eeb --- /dev/null +++ b/backend/canisters/airdrop_bot/impl/src/updates/set_airdrop.rs @@ -0,0 +1,40 @@ +use crate::guards::caller_is_admin; +use crate::jobs::execute_airdrop::start_airdrop_timer; +use crate::model::airdrops::{AirdropConfig, SetNextResult}; +use crate::model::pending_actions_queue::Action; +use crate::{mutate_state, RuntimeState}; +use airdrop_bot_canister::set_airdrop::*; +use canister_tracing_macros::trace; +use ic_cdk::update; + +#[update(guard = "caller_is_admin")] +#[trace] +fn set_airdrop(args: Args) -> Response { + mutate_state(|state| set_airdrop_impl(args, state)) +} + +fn set_airdrop_impl(args: Args, state: &mut RuntimeState) -> Response { + let config = AirdropConfig { + community_id: args.community_id, + channel_id: args.channel_id, + start: args.start, + main_chat_fund: args.main_chat_fund, + main_chit_band: args.main_chit_band, + lottery_prizes: args.lottery_prizes, + lottery_chit_band: args.lottery_chit_band, + }; + + match state.data.airdrops.set_next(config, state.env.now()) { + SetNextResult::Success => { + if state.data.channels_joined.contains(&(args.community_id, args.channel_id)) { + start_airdrop_timer(state); + } else { + state.enqueue_pending_action(Action::JoinChannel(args.community_id, args.channel_id), None); + } + Response::Success + } + SetNextResult::ChannelUsed => Response::ChannelUsed, + SetNextResult::InThePast => Response::InThePast, + SetNextResult::ClashesWithPrevious => Response::ClashesWithPrevious, + } +} diff --git a/backend/canisters/airdrop_bot/impl/src/updates/set_avatar.rs b/backend/canisters/airdrop_bot/impl/src/updates/set_avatar.rs new file mode 100644 index 0000000000..1bcf1287d4 --- /dev/null +++ b/backend/canisters/airdrop_bot/impl/src/updates/set_avatar.rs @@ -0,0 +1,35 @@ +use crate::guards::caller_is_admin; +use crate::updates::set_avatar::Response::*; +use crate::{mutate_state, RuntimeState}; +use airdrop_bot_canister::set_avatar::*; +use canister_tracing_macros::trace; +use ic_cdk::update; +use types::{CanisterId, Timestamped}; +use utils::document_validation::validate_avatar; + +#[update(guard = "caller_is_admin")] +#[trace] +fn set_avatar(args: Args) -> Response { + mutate_state(|state| set_avatar_impl(args, state)) +} + +fn set_avatar_impl(args: Args, state: &mut RuntimeState) -> Response { + if let Err(error) = validate_avatar(args.avatar.as_ref()) { + return AvatarTooBig(error); + } + + let id = args.avatar.as_ref().map(|a| a.id); + let now = state.env.now(); + + state.data.avatar = Timestamped::new(args.avatar, now); + + let user_index_canister_id = state.data.user_index_canister_id; + ic_cdk::spawn(update_index_canister(user_index_canister_id, id)); + + Success +} + +async fn update_index_canister(user_index_canister_id: CanisterId, avatar_id: Option) { + let args = user_index_canister::c2c_set_avatar::Args { avatar_id }; + let _ = user_index_canister_c2c_client::c2c_set_avatar(user_index_canister_id, &args).await; +} diff --git a/backend/canisters/community/c2c_client/src/lib.rs b/backend/canisters/community/c2c_client/src/lib.rs index 8b8be595df..52fcb1c51a 100644 --- a/backend/canisters/community/c2c_client/src/lib.rs +++ b/backend/canisters/community/c2c_client/src/lib.rs @@ -8,6 +8,8 @@ generate_c2c_call!(c2c_events_by_index); generate_c2c_call!(c2c_events_window); generate_c2c_call!(c2c_summary); generate_c2c_call!(c2c_summary_updates); +generate_candid_c2c_call!(local_user_index); +generate_candid_c2c_call!(selected_channel_initial); // Updates generate_c2c_call!(c2c_create_proposals_channel); diff --git a/backend/canisters/local_user_index/c2c_client/src/lib.rs b/backend/canisters/local_user_index/c2c_client/src/lib.rs index 4a91eb44da..e9e798a809 100644 --- a/backend/canisters/local_user_index/c2c_client/src/lib.rs +++ b/backend/canisters/local_user_index/c2c_client/src/lib.rs @@ -14,7 +14,7 @@ generate_c2c_call!(chat_events); generate_c2c_call!(c2c_notify_low_balance); generate_c2c_call!(c2c_notify_user_index_events); generate_c2c_call!(c2c_upgrade_user_canister_wasm); - +generate_candid_c2c_call!(join_channel); generate_candid_c2c_call!(join_group); #[derive(Debug)] diff --git a/backend/canisters/user_index/CHANGELOG.md b/backend/canisters/user_index/CHANGELOG.md index 8132241fa4..e9a663094d 100644 --- a/backend/canisters/user_index/CHANGELOG.md +++ b/backend/canisters/user_index/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [unreleased] +### Added + +- Special case registration of airdrop bot ([#6088](https://github.com/open-chat-labs/open-chat/pull/6088)) + ### Changed - Set `is_oc_controlled_bot` to true when registering the ProposalsBot ([#6115](https://github.com/open-chat-labs/open-chat/pull/6115)) diff --git a/backend/canisters/user_index/api/src/lifecycle/init.rs b/backend/canisters/user_index/api/src/lifecycle/init.rs index 7d693b59e4..e069169381 100644 --- a/backend/canisters/user_index/api/src/lifecycle/init.rs +++ b/backend/canisters/user_index/api/src/lifecycle/init.rs @@ -11,6 +11,7 @@ pub struct Args { pub notifications_index_canister_id: CanisterId, pub identity_canister_id: CanisterId, pub proposals_bot_canister_id: CanisterId, + pub airdrop_bot_canister_id: CanisterId, pub cycles_dispenser_canister_id: CanisterId, pub storage_index_canister_id: CanisterId, pub escrow_canister_id: CanisterId, diff --git a/backend/canisters/user_index/c2c_client/src/lib.rs b/backend/canisters/user_index/c2c_client/src/lib.rs index ce8798ec7c..0ccc3d67b5 100644 --- a/backend/canisters/user_index/c2c_client/src/lib.rs +++ b/backend/canisters/user_index/c2c_client/src/lib.rs @@ -5,6 +5,7 @@ use user_index_canister::*; // Queries generate_c2c_call!(c2c_lookup_user); +generate_candid_c2c_call!(chit_balances); generate_candid_c2c_call!(platform_moderators_group); generate_c2c_call!(user); diff --git a/backend/canisters/user_index/impl/src/lib.rs b/backend/canisters/user_index/impl/src/lib.rs index c89b32a132..3cd24dff50 100644 --- a/backend/canisters/user_index/impl/src/lib.rs +++ b/backend/canisters/user_index/impl/src/lib.rs @@ -283,6 +283,8 @@ struct Data { pub notifications_index_canister_id: CanisterId, pub identity_canister_id: CanisterId, pub proposals_bot_canister_id: CanisterId, + #[serde(default = "init_airdrop_bot_canister_id")] + pub airdrop_bot_canister_id: CanisterId, pub canisters_requiring_upgrade: CanistersRequiringUpgrade, pub total_cycles_spent_on_canisters: Cycles, pub cycles_dispenser_canister_id: CanisterId, @@ -320,6 +322,10 @@ struct Data { pub identity_canister_user_sync_queue: VecDeque<(Principal, Option)>, } +fn init_airdrop_bot_canister_id() -> CanisterId { + Principal::from_text("62rh2-kiaaa-aaaaf-bmy5q-cai").unwrap() +} + impl Data { #[allow(clippy::too_many_arguments)] pub fn new( @@ -330,6 +336,7 @@ impl Data { notifications_index_canister_id: CanisterId, identity_canister_id: CanisterId, proposals_bot_canister_id: CanisterId, + airdrop_bot_canister_id: CanisterId, cycles_dispenser_canister_id: CanisterId, storage_index_canister_id: CanisterId, escrow_canister_id: CanisterId, @@ -351,6 +358,7 @@ impl Data { notifications_index_canister_id, identity_canister_id, proposals_bot_canister_id, + airdrop_bot_canister_id, cycles_dispenser_canister_id, canisters_requiring_upgrade: CanistersRequiringUpgrade::default(), total_cycles_spent_on_canisters: 0, @@ -400,6 +408,16 @@ impl Data { UserType::OcControlledBot, ); + // Register the AirdropBot + data.users.register( + airdrop_bot_canister_id, + airdrop_bot_canister_id.into(), + "AirdropBot".to_string(), + 0, + None, + UserType::OcControlledBot, + ); + data } @@ -464,6 +482,7 @@ impl Default for Data { notifications_index_canister_id: Principal::anonymous(), identity_canister_id: Principal::anonymous(), proposals_bot_canister_id: Principal::anonymous(), + airdrop_bot_canister_id: Principal::anonymous(), canisters_requiring_upgrade: CanistersRequiringUpgrade::default(), cycles_dispenser_canister_id: Principal::anonymous(), total_cycles_spent_on_canisters: 0, diff --git a/backend/canisters/user_index/impl/src/lifecycle/init.rs b/backend/canisters/user_index/impl/src/lifecycle/init.rs index ccf15f4bc6..23056e3a8e 100644 --- a/backend/canisters/user_index/impl/src/lifecycle/init.rs +++ b/backend/canisters/user_index/impl/src/lifecycle/init.rs @@ -22,6 +22,7 @@ fn init(args: Args) { args.notifications_index_canister_id, args.identity_canister_id, args.proposals_bot_canister_id, + args.airdrop_bot_canister_id, args.cycles_dispenser_canister_id, args.storage_index_canister_id, args.escrow_canister_id, diff --git a/backend/canisters/user_index/impl/src/lifecycle/post_upgrade.rs b/backend/canisters/user_index/impl/src/lifecycle/post_upgrade.rs index 2b05422dd1..76f7b75816 100644 --- a/backend/canisters/user_index/impl/src/lifecycle/post_upgrade.rs +++ b/backend/canisters/user_index/impl/src/lifecycle/post_upgrade.rs @@ -1,6 +1,7 @@ use crate::lifecycle::{init_env, init_state}; use crate::memory::get_upgrades_memory; use crate::{mutate_state, Data}; +use candid::Principal; use canister_logger::LogEntry; use canister_tracing_macros::trace; use ic_cdk::post_upgrade; @@ -26,6 +27,11 @@ fn post_upgrade(args: Args) { init_state(env, data, args.wasm_version); mutate_state(|state| { + // TODO: remove this one-time only code + if state.data.test_mode { + state.data.airdrop_bot_canister_id = Principal::from_text("6pwwx-laaaa-aaaaf-bmy6a-cai").unwrap(); + } + state.push_event_to_all_local_user_indexes( local_user_index_canister::Event::UserRegistered(UserRegistered { user_id: state.data.proposals_bot_canister_id.into(), @@ -37,6 +43,17 @@ fn post_upgrade(args: Args) { }), None, ); + state.push_event_to_all_local_user_indexes( + local_user_index_canister::Event::UserRegistered(UserRegistered { + user_id: state.data.airdrop_bot_canister_id.into(), + user_principal: state.data.airdrop_bot_canister_id, + username: "AirdropBot".to_string(), + is_bot: true, + user_type: UserType::OcControlledBot, + referred_by: None, + }), + None, + ); }); info!(version = %args.wasm_version, "Post-upgrade complete"); diff --git a/backend/integration_tests/Cargo.toml b/backend/integration_tests/Cargo.toml index b4f3e71ea9..5a5e5ef283 100644 --- a/backend/integration_tests/Cargo.toml +++ b/backend/integration_tests/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dev-dependencies] +airdrop_bot_canister = { path = "../canisters/airdrop_bot/api" } candid = { workspace = true } community_canister = { path = "../canisters/community/api" } cycles_dispenser_canister = { path = "../canisters/cycles_dispenser/api" } diff --git a/backend/integration_tests/src/airdrop_bot_tests.rs b/backend/integration_tests/src/airdrop_bot_tests.rs new file mode 100644 index 0000000000..b405d60fdd --- /dev/null +++ b/backend/integration_tests/src/airdrop_bot_tests.rs @@ -0,0 +1,160 @@ +use crate::env::ENV; +use crate::utils::{now_millis, tick_many}; +use crate::{client, TestEnv}; +use airdrop_bot_canister::set_airdrop; +use std::ops::Deref; +use std::time::Duration; +use types::{AccessGate, ChatEvent, CryptoContent, EventIndex, GroupRole, Message, MessageContent, UserId}; +use utils::time::MonthKey; + +#[test] +fn airdrop_end_to_end() { + let mut wrapper = ENV.deref().get(); + let TestEnv { + env, + canister_ids, + controller, + .. + } = wrapper.env(); + + // Setup the environment for the test... + // Create 1 owner and 5 other users + // Owner creates the airdrop community + // Join each other user to the community + // Owner creates a public airdrop channel gated by diamond - the 5 users will be added automatically + // Transfer 85,001 CHAT to the airdrop_bot canister + // Owner invites the airdrop_bot to the channel + // + let airdrop_bot_user_id: UserId = canister_ids.airdrop_bot.into(); + + let owner = client::register_diamond_user(env, canister_ids, *controller); + + let community_id = + client::user::happy_path::create_community(env, &owner, "CHIT for CHAT airdrops", true, vec!["General".to_string()]); + + let users: Vec<_> = (0..5) + .map(|_| client::register_diamond_user(env, canister_ids, *controller)) + .collect(); + + env.tick(); + + for user in users { + client::local_user_index::happy_path::join_community(env, user.principal, canister_ids.local_user_index, community_id); + } + + tick_many(env, 10); + + let channel_id = client::community::happy_path::create_gated_channel( + env, + owner.principal, + community_id, + true, + "July airdrop".to_string(), + AccessGate::DiamondMember, + ); + + client::ledger::happy_path::transfer( + env, + *controller, + canister_ids.chat_ledger, + canister_ids.airdrop_bot, + 8_500_100_000_000, + ); + + client::local_user_index::happy_path::invite_users_to_channel( + env, + &owner, + canister_ids.local_user_index, + community_id, + channel_id, + vec![airdrop_bot_user_id], + ); + + // Set the airdrop to start just after the beginning of the next month + // This will also join the airdrop_bot to the channel + // + let airdrop_month = MonthKey::from_timestamp(now_millis(env)); + let next_month = airdrop_month.next(); + let start_airdrop = next_month.start_timestamp() + 10000; + + let response = client::airdrop_bot::set_airdrop( + env, + *controller, + canister_ids.airdrop_bot, + &airdrop_bot_canister::set_airdrop::Args { + community_id, + channel_id, + start: start_airdrop, + main_chat_fund: 6_500_000_000_000, + main_chit_band: 500, + lottery_prizes: vec![1_200_000_000_000, 500_000_000_000, 300_000_000_000], + lottery_chit_band: 500, + }, + ); + + assert!(matches!(response, set_airdrop::Response::Success)); + + tick_many(env, 3); + + // Make the airdrop_bot user an owner of the channel + // + client::community::happy_path::change_channel_role( + env, + owner.principal, + community_id, + channel_id, + airdrop_bot_user_id, + GroupRole::Owner, + ); + + // Advance time to just after the airdrop is due + env.advance_time(Duration::from_millis(1000 + start_airdrop - now_millis(env))); + + tick_many(env, 10); + + // Assert the channel is now locked + // + let channel_summary = client::community::happy_path::channel_summary(env, &owner, community_id, channel_id); + assert_eq!(channel_summary.gate, Some(AccessGate::Locked)); + + // Assert the airdrop channel has messages with the correct prizes in reverse order + // + let response = + client::community::happy_path::events(env, &owner, community_id, channel_id, EventIndex::from(0), true, 10, 10); + + let contents: Vec = response + .events + .into_iter() + .filter_map(|e| if let ChatEvent::Message(message) = e.event { Some(*message) } else { None }) + .filter_map(|m| if let MessageContent::Crypto(content) = m.content { Some(content) } else { None }) + .collect(); + + assert_eq!(contents.len(), 3); + + assert_eq!(contents[0].transfer.units(), 300_000_000_000); + assert_eq!(contents[1].transfer.units(), 500_000_000_000); + assert_eq!(contents[2].transfer.units(), 1_200_000_000_000); + + // Assert the diamond user has been sent a DM from the Airdrop Bot for the expected amount of CHAT + // + let response = client::user::happy_path::events(env, &owner, airdrop_bot_user_id, EventIndex::from(0), true, 10, 20); + + let messages: Vec = response + .events + .into_iter() + .filter_map(|e| if let ChatEvent::Message(message) = e.event { Some(*message) } else { None }) + .collect(); + + assert_eq!(messages.len(), 1); + + let MessageContent::Crypto(content) = &messages[0].content else { + panic!("unexpected content: {messages:?}"); + }; + + // Owner should have 5000 CHIT from diamond achievement. + // Other 5 users should have 5500 CHIT each from joining community achievement + // Each share = ((5500 * 5 + 5000) / 500) = 1_000 + // Owner's shares = 5000/500 = 10 + // Expected CHAT = 10_000 + assert_eq!(content.transfer.units(), 1_000_000_000_000); +} diff --git a/backend/integration_tests/src/client/airdrop_bot.rs b/backend/integration_tests/src/client/airdrop_bot.rs new file mode 100644 index 0000000000..a84d1ac9c5 --- /dev/null +++ b/backend/integration_tests/src/client/airdrop_bot.rs @@ -0,0 +1,5 @@ +use crate::generate_update_call; +use airdrop_bot_canister::*; + +// Updates +generate_update_call!(set_airdrop); diff --git a/backend/integration_tests/src/client/community.rs b/backend/integration_tests/src/client/community.rs index 46ca7f8454..0e743b2672 100644 --- a/backend/integration_tests/src/client/community.rs +++ b/backend/integration_tests/src/client/community.rs @@ -16,6 +16,7 @@ generate_query_call!(summary_updates); generate_update_call!(add_reaction); generate_update_call!(block_user); generate_update_call!(cancel_invites); +generate_update_call!(change_channel_role); generate_update_call!(change_role); generate_update_call!(claim_prize); generate_update_call!(create_channel); @@ -43,7 +44,7 @@ pub mod happy_path { use testing::rng::random_message_id; use types::{ AccessGate, ChannelId, ChatId, CommunityCanisterChannelSummary, CommunityCanisterCommunitySummary, - CommunityCanisterCommunitySummaryUpdates, CommunityId, CommunityRole, EventIndex, EventsResponse, + CommunityCanisterCommunitySummaryUpdates, CommunityId, CommunityRole, EventIndex, EventsResponse, GroupRole, MessageContentInitial, MessageId, MessageIndex, Rules, TextContent, TimestampMillis, UserId, }; @@ -208,6 +209,31 @@ pub mod happy_path { } } + pub fn change_channel_role( + env: &mut PocketIc, + sender: Principal, + community_id: CommunityId, + channel_id: ChannelId, + user_id: UserId, + new_role: GroupRole, + ) { + let response = super::change_channel_role( + env, + sender, + community_id.into(), + &community_canister::change_channel_role::Args { + user_id, + new_role, + channel_id, + }, + ); + + match response { + community_canister::change_channel_role::Response::Success => {} + response => panic!("'change_channel_role' error: {response:?}"), + } + } + pub fn create_user_group( env: &mut PocketIc, sender: Principal, diff --git a/backend/integration_tests/src/client/mod.rs b/backend/integration_tests/src/client/mod.rs index f75ee12525..63ad4ca7c4 100644 --- a/backend/integration_tests/src/client/mod.rs +++ b/backend/integration_tests/src/client/mod.rs @@ -11,6 +11,7 @@ use types::{CanisterId, CanisterWasm, DiamondMembershipPlanDuration}; mod macros; +pub mod airdrop_bot; pub mod community; pub mod cycles_dispenser; pub mod escrow; diff --git a/backend/integration_tests/src/lib.rs b/backend/integration_tests/src/lib.rs index 881827df80..9128041e80 100644 --- a/backend/integration_tests/src/lib.rs +++ b/backend/integration_tests/src/lib.rs @@ -5,6 +5,7 @@ use candid::Principal; use pocket_ic::PocketIc; use types::{CanisterId, Cycles, UserId}; +mod airdrop_bot_tests; mod batched_summary_and_event_tests; mod change_group_role_tests; mod chit_tests; @@ -94,6 +95,7 @@ pub struct CanisterIds { pub identity: CanisterId, pub online_users: CanisterId, pub proposals_bot: CanisterId, + pub airdrop_bot: CanisterId, pub storage_index: CanisterId, pub cycles_dispenser: CanisterId, pub registry: CanisterId, diff --git a/backend/integration_tests/src/setup.rs b/backend/integration_tests/src/setup.rs index be5c7e4639..51b62d7dee 100644 --- a/backend/integration_tests/src/setup.rs +++ b/backend/integration_tests/src/setup.rs @@ -85,6 +85,7 @@ fn install_canisters(env: &mut PocketIc, controller: Principal) -> CanisterIds { let notifications_index_canister_id = create_canister(env, controller); let identity_canister_id = create_canister(env, controller); let online_users_canister_id = create_canister(env, controller); + let airdrop_bot_canister_id = create_canister(env, controller); let proposals_bot_canister_id = create_canister(env, controller); let storage_index_canister_id = create_canister(env, controller); let cycles_dispenser_canister_id = create_canister(env, controller); @@ -115,6 +116,7 @@ fn install_canisters(env: &mut PocketIc, controller: Principal) -> CanisterIds { let notifications_index_canister_wasm = wasms::NOTIFICATIONS_INDEX.clone(); let online_users_canister_wasm = wasms::ONLINE_USERS.clone(); let proposals_bot_canister_wasm = wasms::PROPOSALS_BOT.clone(); + let airdrop_bot_canister_wasm = wasms::AIRDROP_BOT.clone(); let registry_canister_wasm = wasms::REGISTRY.clone(); let sign_in_with_email_canister_wasm = wasms::SIGN_IN_WITH_EMAIL.clone(); let sns_wasm_canister_wasm = wasms::SNS_WASM.clone(); @@ -132,6 +134,7 @@ fn install_canisters(env: &mut PocketIc, controller: Principal) -> CanisterIds { notifications_index_canister_id, identity_canister_id, proposals_bot_canister_id, + airdrop_bot_canister_id, cycles_dispenser_canister_id, storage_index_canister_id, escrow_canister_id, @@ -261,6 +264,22 @@ fn install_canisters(env: &mut PocketIc, controller: Principal) -> CanisterIds { proposals_bot_init_args, ); + let airdrop_bot_init_args = airdrop_bot_canister::init::Args { + admins: vec![controller], + user_index_canister_id, + local_user_index_canister_id, + chat_ledger_canister_id, + wasm_version: BuildVersion::min(), + test_mode: true, + }; + install_canister( + env, + controller, + airdrop_bot_canister_id, + airdrop_bot_canister_wasm, + airdrop_bot_init_args, + ); + let storage_index_init_args = storage_index_canister::init::Args { governance_principals: vec![controller], user_controllers: vec![user_index_canister_id, group_index_canister_id], @@ -493,6 +512,7 @@ fn install_canisters(env: &mut PocketIc, controller: Principal) -> CanisterIds { identity: identity_canister_id, online_users: online_users_canister_id, proposals_bot: proposals_bot_canister_id, + airdrop_bot: airdrop_bot_canister_id, storage_index: storage_index_canister_id, cycles_dispenser: cycles_dispenser_canister_id, registry: registry_canister_id, diff --git a/backend/integration_tests/src/wasms.rs b/backend/integration_tests/src/wasms.rs index e5be9f95bf..ce38280b83 100644 --- a/backend/integration_tests/src/wasms.rs +++ b/backend/integration_tests/src/wasms.rs @@ -5,6 +5,7 @@ use std::io::Read; use types::{BuildVersion, CanisterWasm}; lazy_static! { + pub static ref AIRDROP_BOT: CanisterWasm = get_canister_wasm("airdrop_bot"); pub static ref COMMUNITY: CanisterWasm = get_canister_wasm("community"); pub static ref CYCLES_DISPENSER: CanisterWasm = get_canister_wasm("cycles_dispenser"); pub static ref CYCLES_MINTING_CANISTER: CanisterWasm = get_canister_wasm("cycles_minting_canister"); diff --git a/backend/libraries/canister_agent_utils/src/lib.rs b/backend/libraries/canister_agent_utils/src/lib.rs index 49d131e763..4b502b822d 100644 --- a/backend/libraries/canister_agent_utils/src/lib.rs +++ b/backend/libraries/canister_agent_utils/src/lib.rs @@ -13,6 +13,7 @@ use types::{BuildVersion, CanisterId, CanisterWasm}; #[derive(Clone, Debug)] pub enum CanisterName { + AirdropBot, Community, CyclesDispenser, Escrow, @@ -45,6 +46,7 @@ impl FromStr for CanisterName { fn from_str(s: &str) -> Result { match s { + "airdrop_bot" => Ok(CanisterName::AirdropBot), "community" => Ok(CanisterName::Community), "cycles_dispenser" => Ok(CanisterName::CyclesDispenser), "escrow" => Ok(CanisterName::Escrow), @@ -78,6 +80,7 @@ impl FromStr for CanisterName { impl Display for CanisterName { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { let name = match self { + CanisterName::AirdropBot => "airdrop_bot", CanisterName::Community => "community", CanisterName::CyclesDispenser => "cycles_dispenser", CanisterName::Escrow => "escrow", @@ -120,6 +123,7 @@ pub struct CanisterIds { pub identity: CanisterId, pub online_users: CanisterId, pub proposals_bot: CanisterId, + pub airdrop_bot: CanisterId, pub storage_index: CanisterId, pub cycles_dispenser: CanisterId, pub registry: CanisterId, diff --git a/backend/libraries/utils/src/consts.rs b/backend/libraries/utils/src/consts.rs index 9a5ca0bcd6..a6f3a98286 100644 --- a/backend/libraries/utils/src/consts.rs +++ b/backend/libraries/utils/src/consts.rs @@ -41,6 +41,8 @@ pub const MEMO_P2P_SWAP_CREATE: [u8; 8] = [0x4f, 0x43, 0x5f, 0x50, 0x32, 0x50, 0 pub const MEMO_P2P_SWAP_ACCEPT: [u8; 8] = [0x4f, 0x43, 0x5f, 0x50, 0x32, 0x50, 0x53, 0x41]; // OC_P2PSA pub const MEMO_TRANSLATION_PAYMENT: [u8; 7] = [0x4f, 0x43, 0x5f, 0x54, 0x52, 0x41, 0x4e]; // OC_TRAN pub const MEMO_GROUP_IMPORT_INTO_COMMUNITY: [u8; 6] = [0x4f, 0x43, 0x5f, 0x47, 0x32, 0x43]; // OC_G2C +pub const MEMO_CHIT_FOR_CHAT_AIRDROP: [u8; 6] = [0x4f, 0x43, 0x5f, 0x41, 0x49, 0x52]; // OC_AIR +pub const MEMO_CHIT_FOR_CHAT_LOTTERY: [u8; 6] = [0x4f, 0x43, 0x5f, 0x4C, 0x4F, 0x54]; // OC_LOT #[cfg(test)] mod tests { diff --git a/backend/tools/canister_installer/Cargo.toml b/backend/tools/canister_installer/Cargo.toml index 6c3297943a..e80785235a 100644 --- a/backend/tools/canister_installer/Cargo.toml +++ b/backend/tools/canister_installer/Cargo.toml @@ -6,6 +6,8 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +airdrop_bot_canister = { path = "../../canisters/airdrop_bot/api" } +airdrop_bot_canister_client = { path = "../../canisters/airdrop_bot/client" } candid = { workspace = true } canister_agent_utils = { path = "../../libraries/canister_agent_utils" } clap = { workspace = true, features = ["derive"] } diff --git a/backend/tools/canister_installer/src/lib.rs b/backend/tools/canister_installer/src/lib.rs index 6b6fa0065e..898e8c7119 100644 --- a/backend/tools/canister_installer/src/lib.rs +++ b/backend/tools/canister_installer/src/lib.rs @@ -33,6 +33,7 @@ async fn install_service_canisters_impl( set_controllers(management_canister, &canister_ids.identity, controllers.clone()), set_controllers(management_canister, &canister_ids.online_users, controllers.clone()), set_controllers(management_canister, &canister_ids.proposals_bot, controllers.clone()), + set_controllers(management_canister, &canister_ids.airdrop_bot, controllers.clone()), set_controllers(management_canister, &canister_ids.storage_index, controllers.clone()), set_controllers(management_canister, &canister_ids.cycles_dispenser, controllers.clone()), set_controllers(management_canister, &canister_ids.registry, controllers.clone()), @@ -74,6 +75,7 @@ async fn install_service_canisters_impl( notifications_index_canister_id: canister_ids.notifications_index, identity_canister_id: canister_ids.identity, proposals_bot_canister_id: canister_ids.proposals_bot, + airdrop_bot_canister_id: canister_ids.airdrop_bot, storage_index_canister_id: canister_ids.storage_index, cycles_dispenser_canister_id: canister_ids.cycles_dispenser, escrow_canister_id: canister_ids.escrow, @@ -165,6 +167,16 @@ async fn install_service_canisters_impl( test_mode, }; + let airdrop_bot_canister_wasm = get_canister_wasm(CanisterName::AirdropBot, version); + let airdrop_bot_init_args = airdrop_bot_canister::init::Args { + admins: vec![principal], + user_index_canister_id: canister_ids.user_index, + local_user_index_canister_id: canister_ids.local_user_index, + chat_ledger_canister_id: SNS_LEDGER_CANISTER_ID, + wasm_version: version, + test_mode, + }; + let storage_index_canister_wasm = get_canister_wasm(CanisterName::StorageIndex, version); let storage_index_init_args = storage_index_canister::init::Args { governance_principals: vec![principal], @@ -222,7 +234,7 @@ async fn install_service_canisters_impl( user_index_canister_id: canister_ids.user_index, cycles_dispenser_canister_id: canister_ids.cycles_dispenser, icp_ledger_canister_id: canister_ids.nns_ledger, - chat_ledger_canister_id: canister_ids.nns_ledger, // TODO This should be the CHAT ledger + chat_ledger_canister_id: SNS_LEDGER_CANISTER_ID, wasm_version: version, test_mode, }; @@ -401,7 +413,7 @@ async fn install_service_canisters_impl( ) .await; - futures::future::join3( + futures::future::join4( install_wasm( management_canister, &canister_ids.sign_in_with_email, @@ -420,6 +432,12 @@ async fn install_service_canisters_impl( &sign_in_with_solana_wasm.module, sign_in_with_solana_init_args, ), + install_wasm( + management_canister, + &canister_ids.airdrop_bot, + &airdrop_bot_canister_wasm.module, + airdrop_bot_init_args, + ), ) .await; diff --git a/backend/tools/canister_installer/src/main.rs b/backend/tools/canister_installer/src/main.rs index f321ec2cd6..6a476b1510 100644 --- a/backend/tools/canister_installer/src/main.rs +++ b/backend/tools/canister_installer/src/main.rs @@ -17,6 +17,7 @@ async fn main() { identity: opts.identity, online_users: opts.online_users, proposals_bot: opts.proposals_bot, + airdrop_bot: opts.airdrop_bot, storage_index: opts.storage_index, cycles_dispenser: opts.cycles_dispenser, registry: opts.registry, @@ -81,6 +82,9 @@ struct Opts { #[arg(long)] proposals_bot: CanisterId, + #[arg(long)] + airdrop_bot: CanisterId, + #[arg(long)] storage_index: CanisterId, diff --git a/backend/tools/canister_upgrader/Cargo.toml b/backend/tools/canister_upgrader/Cargo.toml index 33980f1ed8..cb1eb981ec 100644 --- a/backend/tools/canister_upgrader/Cargo.toml +++ b/backend/tools/canister_upgrader/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +airdrop_bot_canister = { path = "../../canisters/airdrop_bot/api" } candid = { workspace = true } canister_agent_utils = { path = "../../libraries/canister_agent_utils" } clap = { workspace = true, features = ["derive"] } diff --git a/backend/tools/canister_upgrader/src/lib.rs b/backend/tools/canister_upgrader/src/lib.rs index f8b4ddf108..e6b08c02df 100644 --- a/backend/tools/canister_upgrader/src/lib.rs +++ b/backend/tools/canister_upgrader/src/lib.rs @@ -140,6 +140,25 @@ pub async fn upgrade_proposals_bot_canister( println!("Proposals bot canister upgraded"); } +pub async fn upgrade_airdrop_bot_canister( + identity: Box, + url: String, + airdrop_bot_canister_id: CanisterId, + version: BuildVersion, +) { + upgrade_top_level_canister( + identity, + url, + airdrop_bot_canister_id, + version, + airdrop_bot_canister::post_upgrade::Args { wasm_version: version }, + CanisterName::AirdropBot, + ) + .await; + + println!("Airdrop bot canister upgraded"); +} + pub async fn upgrade_storage_index_canister( identity: Box, url: String, diff --git a/backend/tools/canister_upgrader/src/main.rs b/backend/tools/canister_upgrader/src/main.rs index a645095d95..32a326a7e4 100644 --- a/backend/tools/canister_upgrader/src/main.rs +++ b/backend/tools/canister_upgrader/src/main.rs @@ -10,6 +10,7 @@ async fn main() { let identity = get_dfx_identity(&opts.controller); match opts.canister_to_upgrade { + CanisterName::AirdropBot => upgrade_airdrop_bot_canister(identity, opts.url, opts.airdrop_bot, opts.version).await, CanisterName::Community => upgrade_community_canister(identity, opts.url, opts.group_index, opts.version).await, CanisterName::CyclesDispenser => { upgrade_cycles_dispenser_canister(identity, opts.url, opts.cycles_dispenser, opts.version).await @@ -83,6 +84,9 @@ struct Opts { #[arg(long)] proposals_bot: CanisterId, + #[arg(long)] + airdrop_bot: CanisterId, + #[arg(long)] storage_index: CanisterId, diff --git a/canister_ids.json b/canister_ids.json index 6d161ef485..79c5a7493c 100644 --- a/canister_ids.json +++ b/canister_ids.json @@ -4,6 +4,10 @@ "ic_test": "pfs7b-iqaaa-aaaaf-abs7q-cai", "web_test": "xp7uu-xyaaa-aaaaf-aoa6a-cai" }, + "airdrop_bot": { + "ic": "62rh2-kiaaa-aaaaf-bmy5q-cai", + "ic_test": "6pwwx-laaaa-aaaaf-bmy6a-cai" + }, "cycles_dispenser": { "ic": "gonut-hqaaa-aaaaf-aby7a-cai", "ic_test": "mq2tp-baaaa-aaaaf-aucva-cai" diff --git a/dfx.json b/dfx.json index 4b84dc00c0..e61aec09ee 100644 --- a/dfx.json +++ b/dfx.json @@ -67,6 +67,12 @@ "wasm": "wasms/proposals_bot.wasm.gz", "build": "./scripts/generate-wasm.sh proposals_bot" }, + "airdrop_bot": { + "type": "custom", + "candid": "backend/canisters/airdrop_bot/api/can.did", + "wasm": "wasms/airdrop_bot.wasm.gz", + "build": "./scripts/generate-wasm.sh airdrop_bot" + }, "user": { "type": "custom", "candid": "backend/canisters/user/api/can.did", diff --git a/frontend/app/src/components/landingpages/blog/posts.ts b/frontend/app/src/components/landingpages/blog/posts.ts index 49d28356a1..bb3192c699 100644 --- a/frontend/app/src/components/landingpages/blog/posts.ts +++ b/frontend/app/src/components/landingpages/blog/posts.ts @@ -22,7 +22,7 @@ export const postsBySlug: Record = { slug: "chit", title: "CHIT Rewards", author: "@Matt", - date: new Date(2024, 7, 9), + date: new Date(2024, 6, 9), component: Chit, }, signin: { diff --git a/scripts/deploy-local.sh b/scripts/deploy-local.sh index 5a44a87e3b..336a92317e 100755 --- a/scripts/deploy-local.sh +++ b/scripts/deploy-local.sh @@ -31,6 +31,7 @@ dfx --identity $IDENTITY canister create --no-wallet --with-cycles 1000000000000 dfx --identity $IDENTITY canister create --no-wallet --with-cycles 100000000000000 identity dfx --identity $IDENTITY canister create --no-wallet --with-cycles 100000000000000 online_users dfx --identity $IDENTITY canister create --no-wallet --with-cycles 100000000000000 proposals_bot +dfx --identity $IDENTITY canister create --no-wallet --with-cycles 100000000000000 airdrop_bot dfx --identity $IDENTITY canister create --no-wallet --with-cycles 1000000000000000 storage_index dfx --identity $IDENTITY canister create --no-wallet --with-cycles 1000000000000000 cycles_dispenser dfx --identity $IDENTITY canister create --no-wallet --with-cycles 100000000000000 registry diff --git a/scripts/deploy-testnet.sh b/scripts/deploy-testnet.sh index d878906782..a3efd02114 100755 --- a/scripts/deploy-testnet.sh +++ b/scripts/deploy-testnet.sh @@ -26,6 +26,7 @@ dfx --identity $IDENTITY canister create --provisional-create-canister-effective dfx --identity $IDENTITY canister create --provisional-create-canister-effective-canister-id jrlun-jiaaa-aaaab-aaaaa-cai --network $NETWORK --no-wallet --with-cycles 100000000000000 notifications dfx --identity $IDENTITY canister create --provisional-create-canister-effective-canister-id jrlun-jiaaa-aaaab-aaaaa-cai --network $NETWORK --no-wallet --with-cycles 100000000000000 identity dfx --identity $IDENTITY canister create --provisional-create-canister-effective-canister-id jrlun-jiaaa-aaaab-aaaaa-cai --network $NETWORK --no-wallet --with-cycles 100000000000000 online_users +dfx --identity $IDENTITY canister create --provisional-create-canister-effective-canister-id jrlun-jiaaa-aaaab-aaaaa-cai --network $NETWORK --no-wallet --with-cycles 100000000000000 airdrop_bot dfx --identity $IDENTITY canister create --provisional-create-canister-effective-canister-id jrlun-jiaaa-aaaab-aaaaa-cai --network $NETWORK --no-wallet --with-cycles 100000000000000 proposals_bot dfx --identity $IDENTITY canister create --provisional-create-canister-effective-canister-id jrlun-jiaaa-aaaab-aaaaa-cai --network $NETWORK --no-wallet --with-cycles 1000000000000000 storage_index dfx --identity $IDENTITY canister create --provisional-create-canister-effective-canister-id jrlun-jiaaa-aaaab-aaaaa-cai --network $NETWORK --no-wallet --with-cycles 1000000000000000 cycles_dispenser diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 63ca811305..a5f35fc77c 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -42,6 +42,7 @@ NOTIFICATIONS_CANISTER_ID=$(dfx canister --network $NETWORK id notifications) IDENTITY_CANISTER_ID=$(dfx canister --network $NETWORK id identity) ONLINE_USERS_CANISTER_ID=$(dfx canister --network $NETWORK id online_users) PROPOSALS_BOT_CANISTER_ID=$(dfx canister --network $NETWORK id proposals_bot) +AIRDROP_BOT_CANISTER_ID=$(dfx canister --network $NETWORK id airdrop_bot) STORAGE_INDEX_CANISTER_ID=$(dfx canister --network $NETWORK id storage_index) CYCLES_DISPENSER_CANISTER_ID=$(dfx canister --network $NETWORK id cycles_dispenser) REGISTRY_CANISTER_ID=$(dfx canister --network $NETWORK id registry) @@ -69,6 +70,7 @@ cargo run \ --identity $IDENTITY_CANISTER_ID \ --online-users $ONLINE_USERS_CANISTER_ID \ --proposals-bot $PROPOSALS_BOT_CANISTER_ID \ + --airdrop-bot $AIRDROP_BOT_CANISTER_ID \ --storage-index $STORAGE_INDEX_CANISTER_ID \ --cycles-dispenser $CYCLES_DISPENSER_CANISTER_ID \ --registry $REGISTRY_CANISTER_ID \ diff --git a/scripts/download-all-canister-wasms.sh b/scripts/download-all-canister-wasms.sh index d20e0e7844..aba4f922c1 100755 --- a/scripts/download-all-canister-wasms.sh +++ b/scripts/download-all-canister-wasms.sh @@ -13,6 +13,7 @@ fi echo "Downloading wasms" +./download-canister-wasm.sh airdrop_bot $WASM_SRC || exit 1 ./download-canister-wasm.sh community $WASM_SRC || exit 1 ./download-canister-wasm.sh cycles_dispenser $WASM_SRC || exit 1 ./download-canister-wasm.sh escrow $WASM_SRC || exit 1 diff --git a/scripts/generate-all-canister-wasms.sh b/scripts/generate-all-canister-wasms.sh index 2e146abd7e..683eb4367e 100755 --- a/scripts/generate-all-canister-wasms.sh +++ b/scripts/generate-all-canister-wasms.sh @@ -4,6 +4,7 @@ SCRIPT=$(readlink -f "$0") SCRIPT_DIR=$(dirname "$SCRIPT") cd $SCRIPT_DIR/.. +./scripts/generate-wasm.sh airdrop_bot ./scripts/generate-wasm.sh community ./scripts/generate-wasm.sh cycles_dispenser ./scripts/generate-wasm.sh escrow diff --git a/scripts/proposals/upgrade_airdrop_bot.sh b/scripts/proposals/upgrade_airdrop_bot.sh new file mode 100755 index 0000000000..24ed96319c --- /dev/null +++ b/scripts/proposals/upgrade_airdrop_bot.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +VERSION=$1 +CHANGELOG_PATH=$2 + +TITLE="Upgrade AirdropBot canister to $VERSION" +CHANGELOG=`cat $CHANGELOG_PATH` +FUNCTION_ID=3 +CANISTER_NAME=airdrop_bot + +# Set current directory to the scripts root +SCRIPT=$(readlink -f "$0") +SCRIPT_DIR=$(dirname "$SCRIPT") +cd $SCRIPT_DIR/.. + +# Submit the proposal +./make_upgrade_canister_proposal.sh $FUNCTION_ID $CANISTER_NAME "$VERSION" "$TITLE" "$CHANGELOG" diff --git a/scripts/upgrade-canister.sh b/scripts/upgrade-canister.sh index a9130594cb..f8abaa90a3 100755 --- a/scripts/upgrade-canister.sh +++ b/scripts/upgrade-canister.sh @@ -28,6 +28,7 @@ NOTIFICATIONS_INDEX_CANISTER_ID=$(dfx canister --network $NETWORK id notificatio IDENTITY_CANISTER_ID=$(dfx canister --network $NETWORK id identity) ONLINE_USERS_CANISTER_ID=$(dfx canister --network $NETWORK id online_users) PROPOSALS_BOT_CANISTER_ID=$(dfx canister --network $NETWORK id proposals_bot) +AIRDROP_BOT_CANISTER_ID=$(dfx canister --network $NETWORK id airdrop_bot) STORAGE_INDEX_CANISTER_ID=$(dfx canister --network $NETWORK id storage_index) CYCLES_DISPENSER_CANISTER_ID=$(dfx canister --network $NETWORK id cycles_dispenser) REGISTRY_CANISTER_ID=$(dfx canister --network $NETWORK id registry) @@ -49,6 +50,7 @@ cargo run \ --identity $IDENTITY_CANISTER_ID \ --online-users $ONLINE_USERS_CANISTER_ID \ --proposals-bot $PROPOSALS_BOT_CANISTER_ID \ + --airdrop-bot $AIRDROP_BOT_CANISTER_ID \ --storage-index $STORAGE_INDEX_CANISTER_ID \ --cycles-dispenser $CYCLES_DISPENSER_CANISTER_ID \ --registry $REGISTRY_CANISTER_ID \