Учебник: Как создать токен с рекуррентными платежами на интернет-компьютере с помощью библиотеки ic-cron

Этот туториал посвящен разработке канистр на платформе Internet Computer (Dfinity). Завершив его, вы:

  1. Будете знать некоторые передовые технологии разработки канистр (смарт-контрактов) на платформе Internet Computer с использованием языка программирования Rust.
  2. Создадите свой собственный токен-канистратор.
  3. Будете использовать библиотеку ic-cron, чтобы добавить механику рекуррентных платежей в эту канистру токенов.

Этот учебник предназначен для опытных разработчиков, которые уже понимают основы разработки канистр Rust на IC.

Прежде чем углубляться в него, рекомендуется еще раз восстановить основы. Некоторые хорошие отправные точки: официальный сайт, посвященный разработке канистр на IC; форум разработчиков IC, где вы можете найти ответ практически на любой технический вопрос.

Мотивация

Существует множество стандартов токенов. Одним из самых популярных стандартов токенов на Ethereum является знаменитый ERC20. Он был настолько успешен, что многие другие люди основывают свои стандарты токенов на ERC20 (включая другие сети, например, DIP20). Справедливо будет сказать, что токены, похожие на ERC20, наиболее распространены в дикой природе в любой сети.

Несмотря на то, что этот стандарт так хорош и прост в использовании, он довольно тривиален с точки зрения функциональности. Токены должны заменить деньги, но есть некоторые вещи, которые вы можете делать с деньгами, но не можете делать с токенами. Например, используя web2 banking (классические деньги), вы можете легко «подписаться» на какой-либо сервис, делая автоматические периодические платежи в его пользу. Такая схема называется «повторяющиеся платежи», а в web3 такого пока нет.

В этом уроке я покажу вам, как расширить ваш токен, чтобы добавить в него функциональность периодических платежей с помощью библиотеки ic-cron.

Полный исходный код этого туториала можно найти здесь:

https://github.com/seniorjoinu/ic-cron-recurrent-payments-example

Приступим.

Инициализация проекта

Для того чтобы продолжить, убедитесь, что на вашей машине установлены эти инструменты:

  • dfx 0.9.0
  • rust 1.54+ и инструментарий wasm32-unknown-unknown
  • ic-cdk-optimizer

Вы можете использовать любую схему файловой системы для этого проекта, но я буду использовать эту:

- src
  - actor.rs              // canister API description
  - common                
     - mod.rs
         - currency_token.rs  // token internals
     - guards.rs          // guard canister functions
     - types.rs           // related types
- cargo.toml
- dfx.json
- build.sh         // canister buildscript
- can.did          // canister candid interface
Вход в полноэкранный режим Выход из полноэкранного режима

Прежде всего, мы должны установить зависимости для проекта, используя файл cargo.toml:

// cargo.toml

[package]
name = "ic-cron-recurrent-payments-example"
version = "0.1.0"
edition = "2018"

[lib]
crate-type = ["cdylib"]
path = "src/actor.rs"

[dependencies]
ic-cdk = "0.3.3"
ic-cdk-macros = "0.3.3"
serde = "1.0"
ic-cron = "0.5.1"
Войти в полноэкранный режим Выйти из полноэкранного режима

Затем нам нужно поместить этот скрипт в файл build.sh, который соберет и оптимизирует для нас wasm-модуль:

# build.sh

#!/usr/bin/env bash

cargo build --target wasm32-unknown-unknown --release --package ic-cron-recurrent-payments-example && 
 ic-cdk-optimizer ./target/wasm32-unknown-unknown/release/ic_cron_recurrent_payments_example.wasm -o ./target/wasm32-unknown-unknown/release/ic-cron-recurrent-payments-example-opt.wasm
Войти в полноэкранный режим Выйти из полноэкранного режима

И после этого мы заполним файл dfx.json, чтобы описать контейнер, который мы собираемся построить:

// dfx.json

{
  "canisters": {
    "ic-cron-recurrent-payments-example": {
      "build": "./build.sh",
      "candid": "./can.did",
      "wasm": "./target/wasm32-unknown-unknown/release/ic-cron-recurrent-payments-example-opt.wasm",
      "type": "custom"
    }
  },
  "defaults": {
    "build": {
      "packtool": ""
    }
  },
  "dfx": "0.9.0",
  "networks": {
    "local": {
      "bind": "127.0.0.1:8000",
      "type": "ephemeral"
    }
  },
  "version": 1
}
Войти в полноэкранный режим Выход из полноэкранного режима

Внутреннее устройство токена

Чтобы лучше понять логику повторяющихся платежей, давайте максимально упростим другие части токена. Используя эту канистру токенов, пользователи должны иметь возможность:

  • чеканить новые токены;
  • переводить токены на другой счет;
  • сжигать токены;
  • проверять баланс любого счета;
  • проверить общее количество токенов;
  • проверка информации о токене (название, тикер/символ, децималы).

Также мы добавим следующие новые функциональные возможности:

  • периодическая чеканка токенов;
  • периодическая передача токенов;
  • возможность проверить текущие повторяющиеся задания пользователя и отменить их.

Основные функции токенов

Важно сказать, что мы собираемся определить эти базовые функции (состояние токена и некоторые функции для изменения этого состояния) без использования каких-либо API Internet Computer. Это позволит нам протестировать этот код, используя только стандартный фреймворк тестирования Rust.

Мы добавим вызовы API Internet Computer (например, функцию caller()) позже в файле с именем actor.rs.

Состояние токена

Прежде всего, давайте опишем тип данных для всех данных, которые будут храниться в контейнере:

// src/common/currency_token.rs

pub struct CurrencyToken {
    pub balances: HashMap<Principal, u64>,
    pub total_supply: u64,
    pub info: TokenInfo,
    pub controllers: Controllers,
    pub recurrent_mint_tasks: HashSet<TaskId>,
    pub recurrent_transfer_tasks: HashMap<Principal, HashSet<TaskId>>,
}

// src/common/types.rs

#[derive(Clone, CandidType, Deserialize)]
pub struct TokenInfo {
    pub name: String,
    pub symbol: String,
    pub decimals: u8,
}

pub type Controllers = Vec<Principal>;
pub type TaskId = u64;
Вход в полноэкранный режим Выход из полноэкранного режима

Для хранения остатков токенов для каждого аккаунта мы будем использовать свойство balances. Обратите внимание, что оно представляет собой хешмап, ключом которого является Principal пользователя.

В свойстве total_supply мы будем хранить общее количество токенов в обращении (общее количество отчеканенных токенов минус общее количество сожженных токенов).

В свойстве info мы будем хранить основную информацию о нашем токене — TokenInfo, которая представляет собой имя, тикер/символ и количество десятичных дробей.

Внутри свойства controllers мы будем хранить список Principal администраторов токенов — пользователей, которые могут майнить новые токены.

Также, чтобы проиндексировать фоновые задачи ic-cron для эффективного доступа, нам понадобится пара дополнительных свойств: recurrent_mint_tasks и recurrent_transfer_tasks. Нам нужны эти новые индексы, потому что планировщик задач ic-cron хранит все свои фоновые задачи в одной коллекции, оптимизированной для быстрого планирования. Там нет других индексов, поэтому нам нужно добавить эти новые.

Майнинг токенов

Давайте определим метод для майнинга новых токенов:

// src/common/currency_token.rs

impl CurrencyToken {

    ...

    pub fn mint(&mut self, to: Principal, qty: u64) -> Result<(), Error> {
        if qty == 0 {
            return Err(Error::ZeroQuantity);
        }

        let prev_balance = self.balance_of(&to);
        let new_balance = prev_balance + qty;

        self.total_supply += qty;
        self.balances.insert(to, new_balance);

        Ok(())
    }

    ...

}
Войти в полноэкранный режим Выйти из полноэкранного режима

Этот метод принимает в качестве аргументов идентификатор аккаунта, на который нужно майнить токены, и количество токенов, которое нужно майнить. Внутри этого метода мы просто увеличиваем текущий баланс пользователя на заданную сумму, а затем обновляем значение общего запаса.

Метод возвращает (), если операция прошла успешно, или возвращает Error, если значение аргумента qty равно 0. Тип Error определяется следующим образом:

// src/common/types.rs

#[derive(Debug)]
pub enum Error {
    InsufficientBalance,
    ZeroQuantity,
    AccessDenied,
    ForbiddenOperation,
} 
Вход в полноэкранный режим Выход из полноэкранного режима

Сжигание токена

Метод сжигания жетонов работает прямо противоположным образом, вычитая количество жетонов из баланса счета и обновляя счетчик общего запаса:

// src/common/currency_token.rs

impl CurrencyToken {

    ...

    pub fn burn(&mut self, from: Principal, qty: u64) -> Result<(), Error> {
        if qty == 0 {
            return Err(Error::ZeroQuantity);
        }

        let prev_balance = self.balance_of(&from);

        if prev_balance < qty {
            return Err(Error::InsufficientBalance);
        }

        let new_balance = prev_balance - qty;

        if new_balance == 0 {
            self.balances.remove(&from);
        } else {
            self.balances.insert(from, new_balance);
        }

        self.total_supply -= qty;

        Ok(())
    }

    ...

}
Вход в полноэкранный режим Выйти из полноэкранного режима

Этот метод принимает в качестве аргументов идентификатор счета и количество жетонов для сжигания. Он возвращает (), если операция прошла успешно, или Error, если значение аргумента qty равно 0 или больше, чем баланс счета.

Обратите внимание, что мы используем определенный шаблон для написания кода: 1) проверить валидность аргументов; 2) вернуть любую возможную ошибку; 3) изменить состояние маркера.

Всегда полезно следовать этому шаблону, чтобы предотвратить будущие ошибки, включая ошибки, которые вводят уязвимость атаки re-entrancy.

Передача токена

Теперь давайте определим функцию для передачи токенов между аккаунтами. Эта функция будет уменьшать количество токенов с одного аккаунта и добавлять их на другой:

// src/common/currency_token.rs

impl CurrencyToken {

    ...

    pub fn transfer(&mut self, from: Principal, to: Principal, qty: u64) -> Result<(), Error> {
        if qty == 0 {
            return Err(Error::ZeroQuantity);
        }

        let prev_from_balance = self.balance_of(&from);
        let prev_to_balance = self.balance_of(&to);

        if prev_from_balance < qty {
            return Err(Error::InsufficientBalance);
        }

        let new_from_balance = prev_from_balance - qty;
        let new_to_balance = prev_to_balance + qty;

        if new_from_balance == 0 {
            self.balances.remove(&from);
        } else {
            self.balances.insert(from, new_from_balance);
        }

        self.balances.insert(to, new_to_balance);

        Ok(())
    }

    ...

}
Вход в полноэкранный режим Выйти из полноэкранного режима

Этот метод принимает в качестве аргументов идентификаторы аккаунтов и количество токенов для передачи. В случае успеха он возвращает (), в случае если количество переводимых токенов равно 0 или превышает текущий баланс отправителя, метод возвращает Error.

Получение баланса

Функция получения баланса довольно проста, поэтому мы не будем обсуждать ее подробно:

// src/common/currency_token.rs

impl CurrencyToken {

    ...

    pub fn balance_of(&self, account_owner: &Principal) -> u64 {
        match self.balances.get(account_owner) {
            None => 0,
            Some(b) => *b,
        }
    }

    ...

} 
Вход в полноэкранный режим Выход из полноэкранного режима

Управление повторяющимися задачами

Как было сказано ранее, мы должны поддерживать собственные индексы фоновых задач для эффективного доступа к ним. Позже вы увидите, почему, но сейчас вполне нормально иметь проблемы с пониманием этого хода. Будет проще сначала начать с повторяющихся задач mint:

// src/common/currency_token.rs

impl CurrencyToken {

    ... 

    pub fn register_recurrent_mint_task(&mut self, task_id: TaskId) {
        self.recurrent_mint_tasks.insert(task_id);
    }

    pub fn unregister_recurrent_mint_task(&mut self, task_id: TaskId) -> bool {
        self.recurrent_mint_tasks.remove(&task_id)
    }

    pub fn get_recurrent_mint_tasks(&self) -> Vec<TaskId> {
        self.recurrent_mint_tasks.iter().cloned().collect()
    }

    ...

}
Войти в полноэкранный режим Выйти из полноэкранного режима

Итак, все, что мы хотим, это вести список всех повторяющихся задач майнинга по их идентификаторам и иметь возможность вернуть этот список в любой момент, когда он нам понадобится.

С повторяющимися задачами по переводу средств немного сложнее, потому что нам нужно вести такой список задач для каждого счета отдельно. Для этого мы будем использовать связку, в которой для каждого аккаунта будет свой список повторяющихся задач трансфера:

// src/common/currency_token.rs

impl CurrencyToken {

    ...

    pub fn register_recurrent_transfer_task(&mut self, from: Principal, task_id: TaskId) {
        match self.recurrent_transfer_tasks.entry(from) {
            Entry::Occupied(mut entry) => {
                entry.get_mut().insert(task_id);
            }
            Entry::Vacant(entry) => {
                let mut s = HashSet::new();
                s.insert(task_id);

                entry.insert(s);
            }
        };
    }

    pub fn unregister_recurrent_transfer_task(&mut self, from: Principal, task_id: TaskId) -> bool {
        match self.recurrent_transfer_tasks.get_mut(&from) {
            Some(tasks) => tasks.remove(&task_id),
            None => false,
        }
    }

    pub fn get_recurrent_transfer_tasks(&self, from: Principal) -> Vec<TaskId> {
        self.recurrent_transfer_tasks
            .get(&from)
            .map(|t| t.iter().cloned().collect::<Vec<_>>())
            .unwrap_or_default()
    }

    ...

}
Войти в полноэкранный режим Выйти из полноэкранного режима

Базовое тестирование функциональности

Это все, что нам потребовалось сделать для реализации внутренних функций токенов. Теперь давайте воспользуемся стандартной структурой тестирования Rust, чтобы проверить, все ли мы сделали правильно.

Прежде всего, для простоты тестирования нам понадобится пара вспомогательных функций:

// src/common/currency_token.rs

#[cfg(test)]
mod tests {

    ...

    pub fn random_principal_test() -> Principal {
        Principal::from_slice(
            &SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .unwrap()
                .as_nanos()
                .to_be_bytes(),
        )
    }

    fn create_currency_token() -> (CurrencyToken, Principal) {
        let controller = random_principal_test();
        let token = CurrencyToken {
            balances: HashMap::new(),
            total_supply: 0,
            info: TokenInfo {
                name: String::from("test"),
                symbol: String::from("TST"),
                decimals: 8,
            },
            controllers: vec![controller],
            recurrent_mint_tasks: HashSet::new(),
            recurrent_transfer_tasks: HashMap::new(),
        };

        (token, controller)
    }

    ...

}
Вход в полноэкранный режим Выход из полноэкранного режима

Функция random_principal_test() создает уникальный идентификатор учетной записи Principal, используя текущее системное время в качестве затравки. Функция create_currency_token() создает объект токена по умолчанию, заполненный тестовыми данными.

// src/common/currency_token.rs

#[cfg(test)]
mod tests {

    ...

    #[test]
    fn minting_works_right() {
        let (mut token, controller) = create_currency_token();
        let user_1 = random_principal_test();

        token.mint(user_1, 100).ok().unwrap();

        assert_eq!(token.total_supply, 100);
        assert_eq!(token.balances.len(), 1);
        assert_eq!(token.balances.get(&user_1).unwrap().clone(), 100);

        token.mint(controller, 200).ok().unwrap();

        assert_eq!(token.total_supply, 300);
        assert_eq!(token.balances.len(), 2);
        assert_eq!(token.balances.get(&user_1).unwrap().clone(), 100);
        assert_eq!(token.balances.get(&controller).unwrap().clone(), 200);
    }

    #[test]
    fn burning_works_fine() {
        let (mut token, _) = create_currency_token();
        let user_1 = random_principal_test();

        token.mint(user_1, 100).ok().unwrap();

        token.burn(user_1, 90).ok().unwrap();

        assert_eq!(token.balances.len(), 1);
        assert_eq!(token.balances.get(&user_1).unwrap().clone(), 10);
        assert_eq!(token.total_supply, 10);

        token.burn(user_1, 20).err().unwrap();

        token.burn(user_1, 10).ok().unwrap();

        assert!(token.balances.is_empty());
        assert!(token.balances.get(&user_1).is_none());
        assert_eq!(token.total_supply, 0);

        token.burn(user_1, 20).err().unwrap();
    }

    #[test]
    fn transfer_works_fine() {
        let (mut token, controller) = create_currency_token();
        let user_1 = random_principal_test();
        let user_2 = random_principal_test();

        token.mint(user_1, 1000).ok().unwrap();

        token.transfer(user_1, user_2, 100).ok().unwrap();

        assert_eq!(token.balances.len(), 2);
        assert_eq!(token.balances.get(&user_1).unwrap().clone(), 900);
        assert_eq!(token.balances.get(&user_2).unwrap().clone(), 100);
        assert_eq!(token.total_supply, 1000);

        token.transfer(user_1, user_2, 1000).err().unwrap();

        token.transfer(controller, user_2, 100).err().unwrap();

        token.transfer(user_2, user_1, 100).ok().unwrap();

        assert_eq!(token.balances.len(), 1);
        assert_eq!(token.balances.get(&user_1).unwrap().clone(), 1000);
        assert!(token.balances.get(&user_2).is_none());
        assert_eq!(token.total_supply, 1000);

        token.transfer(user_2, user_1, 1).err().unwrap();

        token.transfer(user_2, user_1, 0).err().unwrap();
    } 

    ...

}
Вход в полноэкранный режим Выход из полноэкранного режима

Мы не будем останавливаться на этих тестовых примерах слишком долго. В каждом из них есть ряд проверок, которые позволяют убедиться, что состояние остается таким, каким оно должно оставаться, независимо от того, какую функцию мы вызываем. Но тесты очень важны, потому что они дают нам уверенность в нашем коде. Никогда не пропускайте тесты.

API для канистры токенов

Мы почти на финишной прямой. Все, что осталось сделать, это:

  • добавить функцию инициализации состояния канистры;
  • добавить функции управления токенами, используя внутренние функции, которые мы написали ранее;
  • добавить рекуррентную механику в эти функции управления токенами.

Инициализация состояния

Итак, нам нужна функция, которая будет заполнять состояние канистры некоторыми значениями по умолчанию в момент создания канистры:

// src/actor.rs

static mut STATE: Option<CurrencyToken> = None;

pub fn get_token() -> &'static mut CurrencyToken {
    unsafe { STATE.as_mut().unwrap() }
}

#[init]
fn init(controller: Principal, info: TokenInfo) {
    let token = CurrencyToken {
        balances: HashMap::new(),
        total_supply: 0,
        info,
        controllers: vec![controller],
        recurrent_mint_tasks: HashSet::new(),
        recurrent_transfer_tasks: HashMap::new(),
    };

    unsafe {
        STATE = Some(token);
    }
}
Войти в полноэкранный режим Выход из полноэкранного режима

Функция init() принимает в качестве аргумента Principal контроллера — пользователя admin, который сможет майнить новые токены. А в качестве аргумента она принимает информацию о токене TokenInfo. Также здесь есть служебная функция get_token(), которая возвращает безопасную ссылку на состояние токена.

Управление токенами

Давайте начнем с майнинга токенов:

// src/actor.rs

#[update(guard = "controller_guard")]
fn mint(to: Principal, qty: u64, scheduling_interval: Option<SchedulingInterval>) {
    match scheduling_interval {
        Some(interval) => {
            let task_id = cron_enqueue(
                CronTaskKind::RecurrentMint(RecurrentMintTask { to, qty }),
                interval,
            )
            .expect("Mint scheduling failed");

            get_token().register_recurrent_mint_task(task_id);
        }
        None => {
            get_token().mint(to, qty).expect("Minting failed");
        }
    }
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Обратите внимание, что макрос update, аннотирующий функцию, также содержит функцию guard с именем controller_guard(), которая автоматически проверит, является ли вызывающий контроллером токена, и перейдет к функции mint() только если это так. В противном случае пользователю будет выдан ответ с ошибкой.

Эта функция guard довольно проста, и вы можете проверить ее в репозитории Github в файле src/common/guards.rs, если захотите.

Эта функция может выполнять как повторяющиеся минты, так и обычные. Чтобы это произошло, нам нужно добавить дополнительный аргумент к этой функции, чтобы пользователь мог передать функции некоторые параметры фонового задания — scheduling_interval. Тип SchedulingInterval является частью библиотеки ic-cron и определяется следующим образом:

#[derive(Clone, Copy, CandidType, Deserialize)]
pub struct SchedulingInterval {
    pub delay_nano: u64,
    pub interval_nano: u64,
    pub iterations: Iterations,
}

#[derive(Clone, Copy, CandidType, Deserialize)]
pub enum Iterations {
    Infinite,
    Exact(u64),
}
Вход в полноэкранный режим Выход из полноэкранного режима

Этот аргумент позволяет пользователю определить настройки времени повторной чеканки и количество повторений.

Если значение аргумента scheduling_interval равно None, то функция будет выполнять обычную процедуру майнинга, вызывая метод состояния CurrencyToken::mint(), иначе она запланирует новую фоновую задачу для планировщика задач с помощью функции cron_enqueue(), после чего зарегистрирует эту новую фоновую задачу в нашем индексе задач повторяющегося майнинга.

Функция cron_enqueue(), как и любая другая функциональность из библиотеки ic-cron, доступна только после использования макроса implement_cron!(). Поэтому убедитесь, что вы поместили его куда-нибудь в ваш файл actor.rs.

Повторяющиеся задачи майнинга, которые мы передаем в функцию cron_enqueue(), определяются следующим образом:

// src/common/types.rs

#[derive(CandidType, Deserialize, Debug)]
pub struct RecurrentMintTask {
    pub to: Principal,
    pub qty: u64,
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Функция передачи токенов определяется аналогичным образом:

// src/actor.rs

#[update]
fn transfer(to: Principal, qty: u64, scheduling_interval: Option<SchedulingInterval>) {
    let from = caller();

    match scheduling_interval {
        Some(interval) => {
            let task_id = cron_enqueue(
                CronTaskKind::RecurrentTransfer(RecurrentTransferTask { from, to, qty }),
                interval,
            )
            .expect("Transfer scheduling failed");

            get_token().register_recurrent_transfer_task(from, task_id);
        }
        None => {
            get_token()
                .transfer(from, to, qty)
                .expect("Transfer failed");
        }
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Если аргумент scheduling_interval равен None, то эта функция выполняет обычную передачу токена, вызывая метод CurrencyToken::transfer() состояния. Если же внутри этого аргумента есть какая-либо полезная нагрузка, то функция планирует задачу рекуррентной передачи в планировщике задач и сохраняет эту задачу в индекс.

Повторяющиеся задачи передачи определяются таким образом:

#[derive(CandidType, Deserialize, Debug)]
pub struct RecurrentTransferTask {
    pub from: Principal,
    pub to: Principal,
    pub qty: u64,
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Эти данные — все, что нужно нашей канистре для последующего вызова метода CurrencyToken::transfer().

Для того чтобы различать типы фоновых задач, мы будем использовать этот enum:

#[derive(CandidType, Deserialize, Debug)]
pub enum CronTaskKind {
    RecurrentTransfer(RecurrentTransferTask),
    RecurrentMint(RecurrentMintTask),
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Не волнуйтесь, если к этому моменту вы все еще чувствуете себя сбитым с толку — мы почти достигли того момента, когда все станет понятно.

Функция сжигания токенов намного проще, поскольку в ней нет повторяющихся задач:

// src/actor.rs

#[update]
fn burn(qty: u64) {
    get_token().burn(caller(), qty).expect("Burning failed");
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Здесь и в предыдущей функции мы передаем caller() в качестве аргумента from, поскольку эти действия может выполнять только владелец аккаунта.

Получатели данных также довольно просты:

// src/actor.rs

#[query]
fn get_balance_of(account_owner: Principal) -> u64 {
    get_token().balance_of(&account_owner)
}

#[query]
fn get_total_supply() -> u64 {
    get_token().total_supply
}

#[query]
fn get_info() -> TokenInfo {
    get_token().info.clone()
}
Войти в полноэкранный режим Выход из полноэкранного режима

Повторяющиеся задачи

Теперь перейдем к самой интересной части этого руководства. Внутри функций mint() и transfer() мы запланировали несколько задач для планировщика задач. Мы хотим, чтобы пользователи токена могли получить список своих активных задач и иметь возможность отменить их.

Функции recurrent mint tasks getter и cancel определены таким образом:

// src/actor.rs

#[update]
pub fn cancel_recurrent_mint_task(task_id: TaskId) -> bool {
    cron_dequeue(task_id).expect("Task id not found");
    get_token().unregister_recurrent_mint_task(task_id)
}

#[query(guard = "controller_guard")]
pub fn get_recurrent_mint_tasks() -> Vec<RecurrentMintTaskExt> {
    get_token()
        .get_recurrent_mint_tasks()
        .into_iter()
        .map(|task_id| {
            let task = get_cron_state().get_task_by_id(&task_id).unwrap();
            let kind: CronTaskKind = task
                .get_payload()
                .expect("Unable to decode a recurrent mint task");

            match kind {
                CronTaskKind::RecurrentTransfer(_) => trap("Invalid task kind"),
                CronTaskKind::RecurrentMint(mint_task) => RecurrentMintTaskExt {
                    task_id: task.id,
                    to: mint_task.to,
                    qty: mint_task.qty,
                    scheduled_at: task.scheduled_at,
                    rescheduled_at: task.rescheduled_at,
                    scheduling_interval: task.scheduling_interval,
                },
            }
        })
        .collect()
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Отмена задачи проста: мы просто удаляем задачу из планировщика задач с помощью функции cron_dequeue(), а затем удаляем эту же задачу из индекса. Если все прошло хорошо, вызывающая сторона увидит ответ true.

Однако с перечислением повторяющихся задач дело обстоит немного сложнее. Функция CurrencyToken::get_recurrent_mint_tasks() возвращает только идентификаторы задач, а не их данные (что гораздо полезнее для конечного пользователя). Чтобы изменить это, нам нужно определить новый тип задачи, который будет содержать все данные задачи, которые мы хотим показать пользователю:

// src/common/types.rs

#[derive(CandidType, Deserialize)]
pub struct RecurrentMintTaskExt {
    pub task_id: TaskId,
    pub to: Principal,
    pub qty: u64,
    pub scheduled_at: u64,
    pub rescheduled_at: Option<u64>,
    pub scheduling_interval: SchedulingInterval,
} 
Войти в полноэкранный режим Выход из полноэкранного режима

Помимо идентификатора задачи этот тип также содержит эту информацию:

Итак, мы просто отображаем список TaskId в этот новый тип, используя Iterator::map().

То же самое нужно сделать для того, чтобы работали повторяющиеся переходы:

// src/actor.rs

#[update]
pub fn cancel_my_recurrent_transfer_task(task_id: TaskId) -> bool {
    cron_dequeue(task_id).expect("Task id not found");
    get_token().unregister_recurrent_transfer_task(caller(), task_id)
}

#[query]
pub fn get_my_recurrent_transfer_tasks() -> Vec<RecurrentTransferTaskExt> {
    get_token()
        .get_recurrent_transfer_tasks(caller())
        .into_iter()
        .map(|task_id| {
            let task = get_cron_state().get_task_by_id(&task_id).unwrap();
            let kind: CronTaskKind = task
                .get_payload()
                .expect("Unable to decode a recurrent transfer task");

            match kind {
                CronTaskKind::RecurrentMint(_) => trap("Invalid task kind"),
                CronTaskKind::RecurrentTransfer(transfer_task) => RecurrentTransferTaskExt {
                    task_id: task.id,
                    from: transfer_task.from,
                    to: transfer_task.to,
                    qty: transfer_task.qty,
                    scheduled_at: task.scheduled_at,
                    rescheduled_at: task.rescheduled_at,
                    scheduling_interval: task.scheduling_interval,
                },
            }
        })
        .collect()
} 
Войдите в полноэкранный режим Выход из полноэкранного режима

Для этих фоновых задач мы используем следующий тип:

// src/common/types.rs

#[derive(CandidType, Deserialize)]
pub struct RecurrentTransferTaskExt {
    pub task_id: TaskId,
    pub from: Principal,
    pub to: Principal,
    pub qty: u64,
    pub scheduled_at: u64,
    pub rescheduled_at: Option<u64>,
    pub scheduling_interval: SchedulingInterval,
}
Войти в полноэкранный режим Выход из полноэкранного режима

Он отличается от RecurrentMintTaskExt только одним дополнительным полем — from, которое определяет аккаунт отправителя.

Наконец. Настал момент, когда все должно собраться воедино и начать обретать смысл.

Теперь нам нужно описать, как именно мы будем выполнять эти повторяющиеся задачи. Это должно быть сделано внутри специальной системной функции, аннотированной функцией heartbeat:

// src/actor.rs

#[heartbeat]
pub fn tick() {
    let token = get_token();

    for task in cron_ready_tasks() {
        let kind: CronTaskKind = task.get_payload().expect("Unable to decode task payload");

        match kind {
            CronTaskKind::RecurrentMint(mint_task) => {
                token
                    .mint(mint_task.to, mint_task.qty)
                    .expect("Unable to perform scheduled mint");

                if let Iterations::Exact(n) = task.scheduling_interval.iterations {
                    if n == 1 {
                        token.unregister_recurrent_mint_task(task.id);
                    }
                };
            }
            CronTaskKind::RecurrentTransfer(transfer_task) => {
                token
                    .transfer(transfer_task.from, transfer_task.to, transfer_task.qty)
                    .expect("Unable to perform scheduled transfer");

                if let Iterations::Exact(n) = task.scheduling_interval.iterations {
                    if n == 1 {
                        token.unregister_recurrent_transfer_task(transfer_task.from, task.id);
                    }
                };
            }
        }
    }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

В этой функции мы перебираем список всех задач, готовых к выполнению прямо сейчас (предоставленный функцией cron_ready_tasks()). Для каждой из этих задач мы определяем ее тип, обозначаемый перечислением CronTaskKind, а затем в зависимости от типа задачи выполняем один из двух следующих методов: CurrencyToken::mint() или CurrencyToken::transfer().

Обратите внимание, что внутри этой функции также необходимо проверить, сколько исполнений осталось для задачи. Если осталось только одно выполнение, мы должны вручную удалить его из индекса повторяющихся задач.

К сожалению, библиотека ic-cron не поддерживает выполнение обратного вызова при определенных событиях, таких как окончание выполнения задачи. Поэтому нам приходится вручную следить за наступлением этого события и реагировать на него императивным способом.

Описание интерфейса Candid

Вот и все! Мы закончили часть кодирования. Осталось только описать интерфейс нашего контейнера в файле .did, чтобы можно было общаться с ним из консоли:

// can.did

type TaskId = nat64;

type Iterations = variant {
    Infinite;
    Exact : nat64;
};

type SchedulingInterval = record {
    delay_nano : nat64;
    interval_nano : nat64;
    iterations : Iterations;
};

type RecurrentTransferTaskExt = record {
    task_id : TaskId;
    from : principal;
    to : principal;
    qty : nat64;
    scheduled_at : nat64;
    rescheduled_at : opt nat64;
    scheduling_interval : SchedulingInterval;
};

type RecurrentMintTaskExt = record {
    task_id : TaskId;
    to : principal;
    qty : nat64;
    scheduled_at : nat64;
    rescheduled_at : opt nat64;
    scheduling_interval : SchedulingInterval;
};

type TokenInfo = record {
    name : text;
    symbol : text;
    decimals : nat8;
};

service : (principal, TokenInfo) -> {
    "mint" : (principal, nat64, opt SchedulingInterval) -> ();
    "transfer" : (principal, nat64, opt SchedulingInterval) -> ();
    "burn" : (nat64) -> ();
    "get_balance_of" : (principal) -> (nat64) query;
    "get_total_supply" : () -> (nat64) query;
    "get_info" : () -> (TokenInfo) query;

    "cancel_recurrent_mint_task" : (TaskId) -> (bool);
    "get_recurrent_mint_tasks" : () -> (vec RecurrentMintTaskExt) query;

    "cancel_my_recurrent_transfer_task" : (TaskId) -> (bool);
    "get_my_recurrent_transfer_tasks" : () -> (vec RecurrentTransferTaskExt) query;
}
Вход в полноэкранный режим Выход из полноэкранного режима

Взаимодействие с токеном

Теперь давайте, наконец, воспользуемся консолью, чтобы проверить, работает он или нет. Запустим среду разработки, чтобы развернуть в ней наш контейнер:

$ dfx start --clean
Вход в полноэкранный режим Выйти из полноэкранного режима

Затем в новом окне консоли:

$ dfx deploy --argument '(principal "<your-principal>", record { name = "Test token"; symbol = "TST"; decimals = 2 : nat8 })'
...
Deployed canisters
Войти в полноэкранный режим Выйти из полноэкранного режима

Вместо <your-principal> нужно вставить свой собственный Principal, который можно получить командой dfx identity get-principal.

Итак, давайте чеканить токены:

$ dfx canister call ic-cron-recurrent-payments-example mint '(principal "<your-principal>", 100000 : nat64, null )'
()
Войти в полноэкранный режим Выход из полноэкранного режима

Проверка баланса:

$ dfx canister call ic-cron-recurrent-payments-example get_balance_of '(principal "<your-principal>")'
(100_000 : nat64)
Вход в полноэкранный режим Выход из полноэкранного режима

Отлично, процедура майнинга обычных токенов работает, как и ожидалось. Давайте попробуем повторную:

$ dfx canister call ic-cron-recurrent-payments-example mint '(principal "<your-principal>", 10_00 : nat64, opt record { delay_nano = 0 : nat64; interval_nano = 10_000_000_000 : nat64; iterations = variant { Exact = 5 : nat64 } } )'
()
Войти в полноэкранный режим Выйти из полноэкранного режима

Проверка баланса:

$ dfx canister call ic-cron-recurrent-payments-example get_balance_of '(principal "<your-principal>")'
(102_000 : nat64)
Войти в полноэкранный режим Выход из полноэкранного режима

Еще одна проверка баланса через минуту или около того:

$ dfx canister call ic-cron-recurrent-payments-example get_balance_of '(principal "<your-principal>")'
(105_000 : nat64)
Войти в полноэкранный режим Выход из полноэкранного режима

Баланс больше не будет расти, потому что мы установили счетчик итераций на 5, что означает, что мы получили 5000 токенов всего 5 порциями по 1000 токенов каждая с интервалом ~10 секунд. Это заняло всего ~40 секунд, потому что первая порция была получена сразу после создания задачи (delay_nano было 0).

Итак, похоже, что рекуррентный майнинг токенов работает нормально. Давайте теперь проверим периодическую передачу токенов:

$ dfx canister call ic-cron-recurrent-payments-example transfer '(principal "aaaaa-aa", 1_00 : nat64, opt record { delay_nano = 0 : nat64; interval_nano = 10_000_000_000 : nat64; iterations = vari
ant { Infinite } } )'
()
Войдите в полноэкранный режим Выйти из полноэкранного режима

Внимание! Обратите внимание, что мы передаем токены на счет aaaaaa-aa. Это принцип так называемого мани менеджмента. Никогда не переводите реальные деньги на этот счет — вы никогда не получите их обратно.

Давайте проверим наш баланс:

$ dfx canister call ic-cron-recurrent-payments-example get_balance_of '(principal "<your-principal>")'
(104_900 : nat64)
Войти в полноэкранный режим Выйти из полноэкранного режима

Проверяем снова через некоторое время:

$ dfx canister call ic-cron-recurrent-payments-example get_balance_of '(principal "<your-principal>")'
(104_600 : nat64)
Войти в полноэкранный режим Выход из полноэкранного режима

Похоже, что это сработало. Нам начисляется 100 жетонов каждые 10 секунд, как мы и хотели. Давайте посмотрим список наших повторяющихся задач по переводу:

$ dfx canister call ic-cron-recurrent-payments-example get_my_recurrent_transfer_tasks '()'
(
  vec {
    record {
      to = principal "aaaaa-aa";
      qty = 100 : nat64;
      task_id = 1 : nat64;
      from = principal "<your-principal>";
      scheduled_at = 1_645_660_528_445_056_278 : nat64;
      rescheduled_at = opt (1_645_660_528_445_056_278 : nat64);
      scheduling_interval = record {
        interval_nano = 10_000_000_000 : nat64;
        iterations = variant { Infinite };
        delay_nano = 0 : nat64;
      };
    };
  },
)
Войти в полноэкранный режим Выйти из полноэкранного режима

Как мы видим из ответа, в настоящее время у нас есть только одна повторяющаяся задача передачи, выполняющая свою работу. Давайте отменим ее:

$ dfx canister call ic-cron-recurrent-payments-example cancel_my_recurrent_transfer_task '(1 : nat64)'
(true)
Войти в полноэкранный режим Выйти из полноэкранного режима

Снова проверяем список повторяющихся задач переноса:

$ dfx canister call ic-cron-recurrent-payments-example get_my_recurrent_transfer_tasks '()'
(vec {})
Войти в полноэкранный режим Выйти из полноэкранного режима

Хорошо, задача была удалена из списка. Давайте проверим, что с нас больше не взимается плата:

$ dfx canister call ic-cron-recurrent-payments-example get_balance_of '(principal "<your-principal>")'
(104_100 : nat64)
Войти в полноэкранный режим Выйти из полноэкранного режима

И через некоторое время все остается по-прежнему:

$ dfx canister call ic-cron-recurrent-payments-example get_balance_of '(principal "<your-principal>")'
(104_100 : nat64)
Войти в полноэкранный режим Выход из полноэкранного режима

Ух ты! Это работает!

Послесловие

Как вы могли убедиться, с помощью Интернет-компьютера можно создавать действительно классные вещи. Некоторые вещи, которые еще нигде не существуют, как будто мы какие-то художники или что-то в этом роде. Повторяющиеся платежи были невозможны до появления IC и ic-cron. Нет другой платформы web3 с такой функцией, которую так легко использовать и строить на ее основе.

Интернет-компьютер — это новый Интернет. И этот учебник — еще одно доказательство этого утверждения.

Другие мои учебники по ic-cron:

  • https://hackernoon.com/how-to-execute-background-tasks-on-particular-weekdays-with-ic-cron-and-chrono
  • https://hackernoon.com/tutorial-extending-sonic-with-limit-orders-using-ic-cron-library

Полный исходный код этого руководства находится здесь:

https://github.com/seniorjoinu/ic-cron-recurrent-payments-example

Спасибо за прочтение!

Оставьте комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *