Урок 7 Hashmap или Словарь
Введение
В этом уроке мы напишем смарт-контракт, который умеет производить разные операции с Hashmap - словарем, в блокчейне TON на языке FunC, а протестируем его уже в следующем уроке.
Требования
Для прохождения данного урока вам достаточно установить Node.js. Желательно устанавливать одну из последних версий, например 18.
А также уметь создавать/деплоить проект с помощью Blueprint. Научиться этому можно в первом уроке.
Hashmap or dictionaries (словари)
Hashmap - это структура данных представленная деревом. Hashmap - отображает ключи, в значения произвольного типа, таким образом, чтобы был возможен быстрый поиск и модификация. В FunC hashmaps представлены ячейкой.
Смарт-контракт
Задача смарт-контракта будет добавлять и удалять данные и key/value хранилища Hashmap в частности следующая функциональность**:
- Смарт-контракт при получении сообщения со структурой описанной ниже контракт должен добавить к своим данным новую запись типа ключ/значение:
- 32-bit unsigined
op
равный 1- 64-bit unsigned
query_id
- 256-bit unsigned key
- 64-bit
valid_until
unixtime - оставшийся слайс значение
- 64-bit unsigned
- 32-bit unsigined
- Сообщение об удалении устаревших данных имеет следующую структуру: - 32-bit unsigined
op
equal to 2 - 64-bit unsignedquery_id
При получении такого сообщения контракт должен удалить из своих данных все устаревшие записи (сvalid_until
< now()). А так же проверить, что в сообщение нет лишних данных кроме 32-bit unsiginedop
и 64-bit unsignedquery_id
- Для всех других внутренних сообщений должна быть выдана ошибка
- Должен быть реализован Get-метод
get_key
который принимает 256-битный ключ без знака и должен возвращать целое числоvalid_until
и значение слайса данных для этого ключа. Если для этого ключа нет записи, должна быть выдана ошибка. - Важно! мы предполагаем, что контракт начинает работу с пустым хранилищем.
** идеи для смарт-контрактов я решил брать из задач FunC contest1, так как они очень хорошо подходят для ознакомления с разработкой смарт-контрактов для TON.
Внешний метод
Структура внешнего метода
Для того, чтобы наша прокси могла принимать сообщения будем использовать внешний методrecv_internal()
() recv_internal(int balance, int msg_value, cell in_msg_full, slice in_msg_body) {
}
Берем данные из тела сообщения
По условию в зависимости от op
контракт должен работать по-разному. Поэтому вычитаем op
и query_id
из тела сообщения.
() recv_internal(int balance, int msg_value, cell in_msg_full, slice in_msg_body) {
int op = in_msg_body~load_uint(32);
int query_id = in_msg_body~load_uint(64);
}
Подробнее про op
и query_id
можно почитать в пятом уроке.
Также используя условные операторы выстраиваем логику вокруг op
() recv_internal(int balance, int msg_value, cell in_msg_full, slice in_msg_body) {
int op = in_msg_body~load_uint(32);
int query_id = in_msg_body~load_uint(64);
if (op == 1) {
;; здесь будем добавлять новые значения
}
if (op == 2) {
;; здесь удалять
}
}
По заданию для всех других внутренних сообщений должна быть выдана ошибка, поэтому добавим исключение после условных операторов.
() recv_internal(int balance, int msg_value, cell in_msg_full, slice in_msg_body) {
int op = in_msg_body~load_uint(32);
int query_id = in_msg_body~load_uint(64);
if (op == 1) {
;; здесь будем добавлять новые значения
}
if (op == 2) {
;; здесь удалять
}
throw (1001);
}
Теперь надо взять постоянные данные контракта. Понадобятся две функции из стандартной библиотеки FunC.
А именно:
get_data
- берет ячейку постоянных данных.
begin_parse
- ячейку преобразует в slice
Передадим это значение в слайс ds
cell data = get_data();
slice ds = data.begin_parse();
важно учесть замечание в задании, что контракт будет начинать работу с пустыми данными. Поэтому, чтобы корректно проинициализировать эти переменные, воспользуемся условным оператором, синтаксис следующий:
<condition> ? <consequence> : <alternative>
Выглядеть это будет так:
cell dic = ds.slice_bits() == 0 ? new_dict() : data;
Здесь используются следующие функции из стандартной библиотеки FunC:
slice_bits()
- возвращает количество битов данных в слайсе, проверяем пустые данные контракта или нетnew_dict()
- создает пустой словарь, который на самом деле является нулевым значением. Частный случай null().
Итого каркас контракта следующий:
#include "imports/stdlib.fc";
() recv_internal(int balance, int msg_value, cell in_msg_full, slice in_msg_body) {
int op = in_msg_body~load_uint(32);
int query_id = in_msg_body~load_uint(64);
cell data = get_data();
slice ds = data.begin_parse();
cell dic = ds.slice_bits() == 0 ? new_dict() : data;
if (op == 1) {
;; здесь будем добавлять новые значения
}
if (op == 2) {
;; здесь удалять
}
throw (1001);
}
op = 1
При op
равному одному мы добавляем значение в hashmap. Соответственно по заданию нам надо:
- достать ключ из тела сообщения
- установить значение в hashmap(словарь) используя ключ и тело сообщения
- сохранить hashmap(словарь)
- завершает выполнение функции, чтобы мы не попали на исключение объявленное в конце recv_internal()
Достаем ключ
Здесь все как и раньше, используем load_uint
функцию из стандартной библиотеки FunC она загружает целое число n-бит без знака из слайса.
if (op == 1) {
int key = in_msg_body~load_uint(256);
}
Работаем с hashmap
Для добавления данных воспользуемся dict_set
которая устанавливает значение, связанное с индексом ключа key n битность в словаре dict, в слайс и возвращает результирующий словарь.
if (op == 1) { ;; add new entry
int key = in_msg_body~load_uint(256);
dic~udict_set(256, key, in_msg_body);
}
Сохраняем словарь
С помощью функции set_data()
- запишем ячейку с hashmap постоянные данные.
if (op == 1) { ;; add new entry
int key = in_msg_body~load_uint(256);
dic~udict_set(256, key, in_msg_body);
set_data(dic);
}
Завершаем выполнение функции
Здесь все просто, оператор return
нам в помощь.
if (op == 1) { ;; add new entry
int key = in_msg_body~load_uint(256);
dic~udict_set(256, key, in_msg_body);
set_data(dic);
return ();
}
op = 2
Здесь наша задача удалить из своих данных все устаревшие записи (с valid_until
< now()
). Для того, чтобы “пройти” по hashmap будем использовать цикл. В FunC есть три цикла: repeat
,until
,while
.
Так как мы уже вычитали op
и query_id
, проверим здесь, что в слайсе in_msg_body ничего нет с помощью end_parse()
end_parse()
- Проверяет, является ли слайс пустым. Если нет, выдает исключение
if (op == 2) {
in_msg_body.end_parse();
}
Для нашего случая воспользуемся циклом: until
.
if (op == 2) {
do {
} until ();
}
Чтобы на каждом шаге проверять условие valid_until
< now())
, нам необходимо получать некий минимальный ключ нашего hashmap. Для этого в стандартной библиотеке FunC есть функция udict_get_next?
.
udict_get_next?
- вычисляет минимальный ключ k в dict словаря, который больше, чем некоторое заданное значение, и возвращает k, связанное значение и флаг, указывающий на успех. Если словарь пуст, возвращает (null, null, 0).
Соответственно зададим перед циклом значение от, которого будем брать минимальный ключ, а в самом цикле будем использовать флаг, указывающий на успех.
if (op == 2) {
int key = -1;
do {
(key, slice cs, int f) = dic.udict_get_next?(256, key);
} until (~ f);
}
Теперь с помощью условного оператора будем проверять условие valid_until
< now())
. Значение valid_until
вычитаем из slice cs
.
if (op == 2) {
int key = -1;
do {
(key, slice cs, int f) = dic.udict_get_next?(256, key);
if (f) {
int valid_until = cs~load_uint(64);
if (valid_until < now()) {
;; здесь будем удалять
}
}
} until (~ f);
}
Удалять из hashmap будем используя udict_delete?
.
udict_delete?
- удаляет индекс с ключом k из словаря dict. Если ключ присутствует, возвращает модифицированный словарь (hashmap) и флаг успеха -1. В противном случае возвращает исходный словарь dict и 0.
Получаем:
if (op == 2) {
int key = -1;
do {
(key, slice cs, int f) = dic.udict_get_next?(256, key);
if (f) {
int valid_until = cs~load_uint(64);
if (valid_until < now()) {
dic~udict_delete?(256, key);
}
}
} until (~ f);
}
Сохраняем словарь
Используя dict_empty?
проверим стал ли пустым hashmap после наших манипуляций в цикле.
Если значения есть, сохраняем в постоянные данные наш hashmap. Если нет, то положим туда пустую ячейку, с помощью комбинации функций begin_cell().end_cell()
if (dic.dict_empty?()) {
set_data(begin_cell().end_cell());
} else {
set_data(dic);
}
Завершаем выполнение функции
Здесь все просто, оператор return
нам в помощь. Итоговый код op
=2
if (op == 2) {
int key = -1;
do {
(key, slice cs, int f) = dic.udict_get_next?(256, key);
if (f) {
int valid_until = cs~load_uint(64);
if (valid_until < now()) {
dic~udict_delete?(256, key);
}
}
} until (~ f);
if (dic.dict_empty?()) {
set_data(begin_cell().end_cell());
} else {
set_data(dic);
}
return ();
}
Get функция
Метод get_key
по ключу должен вернуть valid_until
и слайс с данными по этому ключу. Соответственно по заданию нам надо:
- взять данные из постоянных данных
- найти данные по ключу
- вернуть ошибку если данных нет
- вычитать
valid_until
- вернуть данные
Берем постоянные данные контракта
Для загрузки данных напишем отдельную функцию load_data(), которая будет проверить есть ли данные и возвращать либо пустой словарь new_dict()
, либо постоянные данные. Проверять будем с slice_bits()
- которое возвращает количество битов данных в слайсе.
cell load_data() {
cell data = get_data();
slice ds = data.begin_parse();
if (ds.slice_bits() == 0) {
return new_dict();
} else {
return data;
}
}
Теперь вызовем функцию в get методе.
(int, slice) get_key(int key) method_id {
cell dic = load_data();
}
Ищем данные по ключу
Для поиска данных по ключу возьмем функцию udict_get?
udict_get?
- ищет индекс ключа в словаре dict В случае успеха возвращает значение, найденное в виде слайса, вместе с флагом -1, указывающим на успех. В случае неудачи возвращает (null, 0).
Получим:
(int, slice) get_key(int key) method_id {
cell dic = load_data();
(slice payload, int success) = dic.udict_get?(256, key);
}
Возвращаем ошибку если данных нет
Функция udict_get?
возвращает удобный флаг, который мы поместили в success.
Используя throw_unless
вернем исключение.
(int, slice) get_key(int key) method_id {
cell dic = load_data();
(slice payload, int success) = dic.udict_get?(256, key);
throw_unless(98, success);
}
Вычитаем valid_until и вернем данные
Здесь все просто, из переменной payload
вычитаем valid_until
и вернем обе переменные.
(int, slice) get_key(int key) method_id {
cell dic = load_data();
(slice payload, int success) = dic.udict_get?(256, key);
throw_unless(98, success);
int valid_until = payload~load_uint(64);
return (valid_until, payload);
}
Обёртка на TypeScript
Для удобного взаимодействия с нашим смарт-контрактом, напишем обёртку на TypeScript. База для неё уже предоставляется от Blueprint.
Конфиг данных контракта
Откроем файл wrappers/Hashmap.ts
(название файла может быть другим, смотря как вы создавали проект).
Конфиг данных остаётся пустым, как и задумано.
export type HashmapConfig = {};
export function hashmapConfigToCell(config: HashmapConfig): Cell {
return beginCell().endCell();
}
Теперь перейдём к классу Hashmap
чтобы добавить методы для вызова нужных нам операций.
Метод для вызова op = 1
При вызове операции с кодом 1, в тело сообщения мы должны положить: op=1, query_id, ключ, valid_until и само значение. Назовём метод sendSet
.
async sendSet(
provider: ContractProvider,
via: Sender,
value: bigint,
opts: {
queryId: bigint;
key: bigint;
value: Slice;
validUntil: bigint;
}
) {
await provider.internal(via, {
value,
sendMode: SendMode.PAY_GAS_SEPARATELY,
body: beginCell()
.storeUint(1, 32)
.storeUint(opts.queryId, 64)
.storeUint(opts.key, 256)
.storeUint(opts.validUntil, 64)
.storeSlice(opts.value)
.endCell(),
});
}
Метод для вызова op = 2
Эта операция не требует дополнительных данных кроме op=2 и query_id. Назовём метод sendClearOldValues
.
async sendClearOldValues(
provider: ContractProvider,
via: Sender,
value: bigint,
opts: {
queryId: bigint;
}
) {
await provider.internal(via, {
value,
sendMode: SendMode.PAY_GAS_SEPARATELY,
body: beginCell().storeUint(2, 32).storeUint(opts.queryId, 64).endCell(),
});
}
Метод для вызова геттера get_key
Этот метод будет немного сложнее чем тот, что мы уже писали в одном из первых уроков, потому что здесь должно возвращаться сразу два значения. Такой тип в TypeScript можно задать как массив [bigint, Slice]
. А Promise<>
нужен потому что функция асинхронная (ключевое слово async
перед её названием).
Вызовем provider.get
и стек результата положим в константу result
. Затем оттуда мы можем читать полученные значения для возврата из функции. С первым значением всё просто - делаем readBigNumber()
чтобы прочитать bigint
(который в FunC был int
). Но с вторым значением появляется проблема: в библиотеке не предусмотрен отдельный метод для считывания слайса (что-то вроде readSlice()
). Поэтому придётся использовать peek()
который считывает следующее значение, игнорируя его тип, и явно указать компилятору, что это TupleItemSlice
, а затем получить из него само значение.
async getByKey(provider: ContractProvider, key: bigint): Promise<[bigint, Slice]> {
const result = (await provider.get('get_key', [{ type: 'int', value: key }])).stack;
return [result.readBigNumber(), (result.peek() as TupleItemSlice).cell.asSlice()];
}
Заключение
Хотел сказать отдельное спасибо, тем кто донатит для поддержки проекта, это очень мотивирует и помогает выпускать уроки быстрее. Если вы хотите помочь проекту(быстрее выпускать уроки, перевести это все на английский итд), внизу на главной странице, есть адреса для донатов.