Этот туториал посвящен разработке канистр на платформе Internet Computer (Dfinity). Завершив его, вы:
- Будете знать некоторые передовые технологии разработки канистр (смарт-контрактов) на платформе Internet Computer с использованием языка программирования Rust.
- Создадите свой собственный токен-канистратор.
- Будете использовать библиотеку 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
Спасибо за прочтение!