NFTs with On-chain Metadata on TON: Tutorial

Vladislav Lenskii
MiKi Blockchain
Published in
11 min readMar 11, 2024

In last year's appearance on the “Joe Rogan Experience” podcast, Twitter owner and Tesla and SpaceX CEO Elon Musk commented on NFTs.

“The funny thing is the NFT is not even on the blockchain — it’s just a URL to the JPEG,” Musk said.
“You should at least encode the JPEG in the blockchain. If the company housing the image goes out of business, you don’t have the image anymore.”

He isn’t wrong about this being a notable pain point and concern around NFTs.

Nevertheless, he is also not fully right, NFTs can be stored fully on-chain.
In this article, I want to demonstrate a way to eliminate these issues.
The solution I offer is based on my work on the NextonNode project, which I implemented on the TON blockchain.

To make Nexton derivatives immutable and non-custodial, I had to put the metadata for NFTs on-chain.

It turned out harder than expected, the info presented in TEP 64, wasn’t very helpful (I can’t read TL-B schemas). After spending a lot of time and effort, receiving help from the dev community I succeeded in making it work.

Now I want to present you a guide for on-chain metadata implementation on TON.

Disclaimer:

In this guide, I am using VsCode, FunC and Blueprint (typescript). I show my specific implementation, you can change it or develop yours.

Notation:

  • Nexton = the liquid staking derivatives platform aiming to maximize staking profits. It provides users with flexible ownership over their stake using NFTs.
  • Metadata = content
  • TEP = TON Enhancement Proposal
  • Op, op_code = operation code
  • Tag = prefix
  • Get methods = special functions, made for querying specific data from contracts.

Requirements

First, let’s look at TEP 64 requirements.

On-chain content layout The first byte is 0x00 and the rest is the key/value dictionary. The key is the sha256 hash of the string. Value is data encoded as described in the "Data serialization" paragraph.

(The ”Data Serialization” paragraph is not that comprehensible, more about it later). What is important, is that value should be stored in a Cell.

Nft collection

Here is a basic NFT collection contract from TON GitHub:

;; NFT collection smart contract

;; storage scheme
;; default#_ royalty_factor:uint16 royalty_base:uint16 royalty_address:MsgAddress = RoyaltyParams;
;; storage#_ owner_address:MsgAddress next_item_index:uint64
;; ^[collection_content:^Cell common_content:^Cell]
;; nft_item_code:^Cell
;; royalty_params:^RoyaltyParams
;; = Storage;
(slice, int, cell, cell, cell) load_data() inline {
var ds = get_data().begin_parse();
return
(ds~load_msg_addr(), ;; owner_address
ds~load_uint(64), ;; next_item_index
ds~load_ref(), ;; content
ds~load_ref(), ;; nft_item_code
ds~load_ref() ;; royalty_params
);
}
() save_data(slice owner_address, int next_item_index, cell content, cell nft_item_code, cell royalty_params) impure inline {
set_data(begin_cell()
.store_slice(owner_address)
.store_uint(next_item_index, 64)
.store_ref(content)
.store_ref(nft_item_code)
.store_ref(royalty_params)
.end_cell());
}
cell calculate_nft_item_state_init(int item_index, cell nft_item_code) {
cell data = begin_cell().store_uint(item_index, 64).store_slice(my_address()).end_cell();
return begin_cell().store_uint(0, 2).store_dict(nft_item_code).store_dict(data).store_uint(0, 1).end_cell();
}
slice calculate_nft_item_address(int wc, cell state_init) {
return begin_cell().store_uint(4, 3)
.store_int(wc, 8)
.store_uint(cell_hash(state_init), 256)
.end_cell()
.begin_parse();
}
() deploy_nft_item(int item_index, cell nft_item_code, int amount, cell nft_content) impure {
cell state_init = calculate_nft_item_state_init(item_index, nft_item_code);
slice nft_address = calculate_nft_item_address(workchain(), state_init);
var msg = begin_cell()
.store_uint(0x18, 6)
.store_slice(nft_address)
.store_coins(amount)
.store_uint(4 + 2 + 1, 1 + 4 + 4 + 64 + 32 + 1 + 1 + 1)
.store_ref(state_init)
.store_ref(nft_content);
send_raw_message(msg.end_cell(), 1); ;; pay transfer fees separately, revert on errors
}
() send_royalty_params(slice to_address, int query_id, slice data) impure inline {
var msg = begin_cell()
.store_uint(0x10, 6) ;; nobounce - int_msg_info$0 ihr_disabled:Bool bounce:Bool bounced:Bool src:MsgAddress -> 011000
.store_slice(to_address)
.store_coins(0)
.store_uint(0, 1 + 4 + 4 + 64 + 32 + 1 + 1)
.store_uint(op::report_royalty_params(), 32)
.store_uint(query_id, 64)
.store_slice(data);
send_raw_message(msg.end_cell(), 64); ;; carry all the remaining value of the inbound message
}
() recv_internal(cell in_msg_full, slice in_msg_body) impure {
if (in_msg_body.slice_empty?()) { ;; ignore empty messages
return ();
}
slice cs = in_msg_full.begin_parse();
int flags = cs~load_uint(4);

if (flags & 1) { ;; ignore all bounced messages
return ();
}
slice sender_address = cs~load_msg_addr();
int op = in_msg_body~load_uint(32);
int query_id = in_msg_body~load_uint(64);
var (owner_address, next_item_index, content, nft_item_code, royalty_params) = load_data();
if (op == op::get_royalty_params()) {
send_royalty_params(sender_address, query_id, royalty_params.begin_parse());
return ();
}
throw_unless(401, equal_slices(sender_address, owner_address));


if (op == 1) { ;; deploy new nft
int item_index = in_msg_body~load_uint(64);
throw_unless(402, item_index <= next_item_index);
var is_last = item_index == next_item_index;
deploy_nft_item(item_index, nft_item_code, in_msg_body~load_coins(), in_msg_body~load_ref());
if (is_last) {
next_item_index += 1;
save_data(owner_address, next_item_index, content, nft_item_code, royalty_params);
}
return ();
}
if (op == 2) { ;; batch deploy of new nfts
int counter = 0;
cell deploy_list = in_msg_body~load_ref();
do {
var (item_index, item, f?) = deploy_list~udict::delete_get_min(64);
if (f?) {
counter += 1;
if (counter >= 250) { ;; Limit due to limits of action list size
throw(399);
}
throw_unless(403 + counter, item_index <= next_item_index);
deploy_nft_item(item_index, nft_item_code, item~load_coins(), item~load_ref());
if (item_index == next_item_index) {
next_item_index += 1;
}
}
} until ( ~ f?);
save_data(owner_address, next_item_index, content, nft_item_code, royalty_params);
return ();
}
if (op == 3) { ;; change owner
slice new_owner = in_msg_body~load_msg_addr();
save_data(new_owner, next_item_index, content, nft_item_code, royalty_params);
return ();
}
throw(0xffff);
}
;; Get methods
(int, cell, slice) get_collection_data() method_id {
var (owner_address, next_item_index, content, _, _) = load_data();
slice cs = content.begin_parse();
return (next_item_index, cs~load_ref(), owner_address);
}
slice get_nft_address_by_index(int index) method_id {
var (_, _, _, nft_item_code, _) = load_data();
cell state_init = calculate_nft_item_state_init(index, nft_item_code);
return calculate_nft_item_address(workchain(), state_init);
}
(int, int, slice) royalty_params() method_id {
var (_, _, _, _, royalty) = load_data();
slice rs = royalty.begin_parse();
return (rs~load_uint(16), rs~load_uint(16), rs~load_msg_addr());
}
cell get_nft_content(int index, cell individual_nft_content) method_id {
var (_, _, content, _, _) = load_data();
slice cs = content.begin_parse();
cs~load_ref();
slice common_content = cs~load_ref().begin_parse();
return (begin_cell()
.store_uint(1, 8) ;; offchain tag
.store_slice(common_content)
.store_ref(individual_nft_content)
.end_cell());
}

However, it was designed to deal with off-chain metadata NFTs.

Thus, we need to change some parts of it. Particularly, get_nft_content and get_collection_data functions:

(int, cell, slice) get_collection_data() method_id {
var (owner_address, next_item_index, content, _, _) = load_data();
return (next_item_index, content, owner_address);
}

Here I store collection content as a cell (with a dictionary inside), thus just returning it, without any manipulations.

cell get_nft_content(int index, cell individual_nft_content) method_id { 
return individual_nft_content;
}

I decided to store the whole item content (including the tag) inside of the item. Thus I just return its content in the collection get method.

Here is an important thing to understand: how explorers and marketplaces (Tonviewer, Getgems, etc.) read the metadata.

First, they take it from the item contract, then send it to the collection (to theget_nft_contentmethod) and parse the received result.

This procedure exists to provide an opportunity to automate the metadata creation or develop some complex logic. (Single link to metadata folder, predetermined set of attributes, etc.)

Also, I wish to make NFTs immutable, so allow only consecutive minting. So, the flow in the case of op=1 should be changed:

if (op == 1) { ;; deploy new nft
int item_index = in_msg_body~load_uint(64);
throw_unless(402, item_index <= next_item_index);
deploy_nft_item(next_item_index, nft_item_code, in_msg_body~load_coins(), in_msg_body~load_ref());
next_item_index += 1;
save_data(owner_address, next_item_index, content, nft_item_code, royalty_params);
return ();
}

In this case, item_index can be omitted from the message (I keep it to not change the scripts and adequacy check).

Example NFT item (live on auction)

NFT Item

Let’s continue with a basic NFT item contract from TON GitHub:

;;
;; TON NFT Item Smart Contract
;;

{-
NOTE that this tokens can be transferred within the same workchain.
This is suitable for most tokens, if you need tokens transferable between workchains there are two solutions:
1) use more expensive but universal function to calculate message forward fee for arbitrary destination (see `misc/forward-fee-calc.cs`)
2) use token holder proxies in target workchain (that way even 'non-universal' token can be used from any workchain)
-}
int min_tons_for_storage() asm "50000000 PUSHINT"; ;; 0.05 TON
;;
;; Storage
;;
;; uint64 index
;; MsgAddressInt collection_address
;; MsgAddressInt owner_address
;; cell content
;;
(int, int, slice, slice, cell) load_data() {
slice ds = get_data().begin_parse();
var (index, collection_address) = (ds~load_uint(64), ds~load_msg_addr());
if (ds.slice_bits() > 0) {
return (-1, index, collection_address, ds~load_msg_addr(), ds~load_ref());
} else {
return (0, index, collection_address, null(), null()); ;; nft not initialized yet
}
}
() store_data(int index, slice collection_address, slice owner_address, cell content) impure {
set_data(
begin_cell()
.store_uint(index, 64)
.store_slice(collection_address)
.store_slice(owner_address)
.store_ref(content)
.end_cell()
);
}
() send_msg(slice to_address, int amount, int op, int query_id, builder payload, int send_mode) impure inline {
var msg = begin_cell()
.store_uint(0x10, 6) ;; nobounce - int_msg_info$0 ihr_disabled:Bool bounce:Bool bounced:Bool src:MsgAddress -> 010000
.store_slice(to_address)
.store_coins(amount)
.store_uint(0, 1 + 4 + 4 + 64 + 32 + 1 + 1)
.store_uint(op, 32)
.store_uint(query_id, 64);
if (~ builder_null?(payload)) {
msg = msg.store_builder(payload);
}
send_raw_message(msg.end_cell(), send_mode);
}
() transfer_ownership(int my_balance, int index, slice collection_address, slice owner_address, cell content, slice sender_address, int query_id, slice in_msg_body, int fwd_fees) impure inline {
throw_unless(401, equal_slices(sender_address, owner_address));
slice new_owner_address = in_msg_body~load_msg_addr();
force_chain(new_owner_address);
slice response_destination = in_msg_body~load_msg_addr();
in_msg_body~load_int(1); ;; this nft don't use custom_payload
int forward_amount = in_msg_body~load_coins();
throw_unless(708, slice_bits(in_msg_body) >= 1);
int rest_amount = my_balance - min_tons_for_storage();
if (forward_amount) {
rest_amount -= (forward_amount + fwd_fees);
}
int need_response = response_destination.preload_uint(2) != 0; ;; if NOT addr_none: 00
if (need_response) {
rest_amount -= fwd_fees;
}
throw_unless(402, rest_amount >= 0); ;; base nft spends fixed amount of gas, will not check for response
if (forward_amount) {
send_msg(new_owner_address, forward_amount, op::ownership_assigned(), query_id, begin_cell().store_slice(owner_address).store_slice(in_msg_body), 1); ;; paying fees, revert on errors
}
if (need_response) {
force_chain(response_destination);
send_msg(response_destination, rest_amount, op::excesses(), query_id, null(), 1); ;; paying fees, revert on errors
}
store_data(index, collection_address, new_owner_address, content);
}
() recv_internal(int my_balance, int msg_value, cell in_msg_full, slice in_msg_body) impure {
if (in_msg_body.slice_empty?()) { ;; ignore empty messages
return ();
}
slice cs = in_msg_full.begin_parse();
int flags = cs~load_uint(4);
if (flags & 1) { ;; ignore all bounced messages
return ();
}
slice sender_address = cs~load_msg_addr();
cs~load_msg_addr(); ;; skip dst
cs~load_coins(); ;; skip value
cs~skip_bits(1); ;; skip extracurrency collection
cs~load_coins(); ;; skip ihr_fee
int fwd_fee = muldiv(cs~load_coins(), 3, 2); ;; we use message fwd_fee for estimation of forward_payload costs
(int init?, int index, slice collection_address, slice owner_address, cell content) = load_data();
if (~ init?) {
throw_unless(405, equal_slices(collection_address, sender_address));
store_data(index, collection_address, in_msg_body~load_msg_addr(), in_msg_body~load_ref());
return ();
}
int op = in_msg_body~load_uint(32);
int query_id = in_msg_body~load_uint(64);
if (op == op::transfer()) {
transfer_ownership(my_balance, index, collection_address, owner_address, content, sender_address, query_id, in_msg_body, fwd_fee);
return ();
}
if (op == op::get_static_data()) {
send_msg(sender_address, 0, op::report_static_data(), query_id, begin_cell().store_uint(index, 256).store_slice(collection_address), 64); ;; carry all the remaining value of the inbound message
return ();
}
throw(0xffff);
}
;;
;; GET Methods
;;
(int, int, slice, slice, cell) get_nft_data() method_id {
(int init?, int index, slice collection_address, slice owner_address, cell content) = load_data();
return (init?, index, collection_address, owner_address, content);
}

In this contract nothing needs to be changed, content is returned as a cell (with a dictionary inside).

Imports

To compile the resulting contracts we need to add imports on top of each of them:

#include "imports/stdlib.fc";
#include "imports/nft_op_codes.fc";
#include "imports/params.fc";

Where: stdlib.fc is the Standard Func library (declaration of most of the functions)

params.fc is a file with constants

;; Constants
const int msgflag::NON_BOUNCEABLE = 0x10;
const int msgflag::BOUNCEABLE = 0x18;

const int sendmode::REGULAR = 0;
const int sendmode::PAY_FEES_SEPARETELY = 1;
const int sendmode::IGNORE_ERRORS = 2;
const int sendmode::DESTROY = 32;
const int sendmode::CARRY_ALL_REMAINING_MESSAGE_VALUE = 64;
const int sendmode::CARRY_ALL_BALANCE = 128;
int workchain() asm "0 PUSHINT";
() force_chain(slice addr) impure {
(int wc, _) = parse_std_addr(addr);
throw_unless(333, wc == workchain());
}
slice null_addr() asm "b{00} PUSHSLICE";
int flag::regular() asm "0x10 PUSHINT";
int flag::bounce() asm "0x8 PUSHINT";

op_codes.fc contains message op-codes

int op::transfer() asm "0x5fcc3d14 PUSHINT";
int op::ownership_assigned() asm "0x05138d91 PUSHINT";
int op::excesses() asm "0xd53276db PUSHINT";
int op::claim_rewards() asm "0x636c616 PUSHINT";
int op::get_static_data() asm "0x2fcb26a2 PUSHINT";
int op::report_static_data() asm "0x8b771735 PUSHINT";
int op::get_royalty_params() asm "0x693d3950 PUSHINT";
int op::report_royalty_params() asm "0xa8cb00ad PUSHINT";

Scripts

To deploy the collection and use it we first need to write a wrapper.

import { 
Address,
beginCell,
Cell,
Contract,
contractAddress,
ContractProvider,
Sender,
SendMode,
TupleItemInt,
} from '@ton/core';

export type RoyaltyParams = {
royaltyFactor: number;
royaltyBase: number;
royaltyAddress: Address;
};
export type NftCollectionConfig = {
ownerAddress: Address;
nextItemIndex: number;
collectionContent: Cell;
nftItemCode: Cell;
royaltyParams: RoyaltyParams;
};
export function nftCollectionConfigToCell(config: NftCollectionConfig): Cell {
return beginCell()
.storeAddress(config.ownerAddress)
.storeUint(config.nextItemIndex, 64)
.storeRef(config.collectionContent)
.storeRef(config.nftItemCode)
.storeRef(
beginCell()
.storeUint(config.royaltyParams.royaltyFactor, 16)
.storeUint(config.royaltyParams.royaltyBase, 16)
.storeAddress(config.royaltyParams.royaltyAddress)
)
.endCell();
}
export class NftCollection implements Contract {
constructor(readonly address: Address, readonly init?: { code: Cell; data: Cell }) {}
static createFromAddress(address: Address) {
return new NftCollection(address);
}
static createFromConfig(config: NftCollectionConfig, code: Cell, workchain = 0) {
const data = nftCollectionConfigToCell(config);
const init = { code, data };
return new NftCollection(contractAddress(workchain, init), init);
}
async sendDeploy(provider: ContractProvider, via: Sender, value: bigint) {
await provider.internal(via, {
value,
sendMode: SendMode.PAY_GAS_SEPARATELY,
body: beginCell().endCell(),
});
}
async sendMintNft(provider: ContractProvider, via: Sender,
opts: {
value: bigint;
queryId: number;
amount: bigint; // to send with nft
itemIndex: number;
itemOwnerAddress: Address;
itemContent: Cell;

}
) {
const nftMessage = beginCell();
nftMessage.storeAddress(opts.itemOwnerAddress)
nftMessage.storeRef(opts.itemContent)
await provider.internal(via, {
value: opts.value,
sendMode: SendMode.PAY_GAS_SEPARATELY,
body: beginCell()
.storeUint(1,32) // operation
.storeUint(opts.queryId,64)
.storeUint(opts.itemIndex,64)
.storeCoins(opts.amount)
.storeRef(nftMessage) // body
.endCell()
})
}
async sendChangeOwner(provider: ContractProvider, via: Sender,
opts: {
value: bigint;
queryId: bigint;
newOwnerAddress: Address;
}
) {
await provider.internal(via, {
value: opts.value,
sendMode: SendMode.PAY_GAS_SEPARATELY,
body: beginCell()
.storeUint(3,32) //operation
.storeUint(opts.queryId, 64)
.storeAddress(opts.newOwnerAddress)
.endCell()
})
}
async getCollectionData(provider: ContractProvider): Promise<{
nextItemId: bigint,
ownerAddress: Address,
collectionContent: Cell
}>
{
const collection_data = await provider.get("get_collection_data", []);
const stack = await collection_data.stack;
let nextItem: bigint = stack.readBigNumber();
let collectionContent = await stack.readCell();
let ownerAddress = await stack.readAddress();
return {
nextItemId: nextItem,
collectionContent: collectionContent,
ownerAddress: ownerAddress
};
}
async getItemAddressByIndex(provider: ContractProvider, index: TupleItemInt){
const res = await provider.get("get_nft_address_by_index", [index]);
const itemAddress = await res.stack.readAddress()
return itemAddress;
}
}

As you can see it has methods for collection deployment, single item minting, changing collection owner, getting data of the collection, and finding the address of the item by its index.

You can build the methods you need based on this example.

Using the wrapper we can create the deployment script:

import { Address, toNano } from '@ton/core';
import { NftCollection } from '../wrappers/NftCollection';
import { compile, NetworkProvider } from '@ton/blueprint';
import { buildCollectionContentCell, setItemContentCell } from './nftContent/onChain';

const randomSeed= Math.floor(Math.random() * 10000);
// Deploys collection
export async function run(provider: NetworkProvider) {
const nftCollection = provider.open(NftCollection.createFromConfig({
ownerAddress: provider.sender().address!!,
nextItemIndex: 0,
collectionContent: buildCollectionContentCell({
name: "OnChain collection",
description: "Collection of items with onChain metadata",
image: "<https://raw.githubusercontent.com/Cosmodude/Nexton/main/Nexton_Logo.jpg>"
}),
nftItemCode: await compile("NftItem"),
royaltyParams: {
royaltyFactor: Math.floor(Math.random() * 500),
royaltyBase: 1000,
royaltyAddress: provider.sender().address as Address
}
}, await compile('NftCollection')));
console.log(provider.sender().address as Address)
await nftCollection.sendDeploy(provider.sender(), toNano('0.05'));
console.log()
await provider.waitForDeploy(nftCollection.address);
console.log(`NFT Collection deployed at <https://testnet.tonviewer.com/${nftCollection.address}`>);
}

And item minting script:

import { Address, toNano } from '@ton/core';
import { NftCollection } from '../wrappers/NftCollection';
import { NetworkProvider } from '@ton/blueprint';
import { setItemContentCell } from './nftContent/onChain';

const randomSeed= Math.floor(Math.random() * 10000);
export async function run(provider: NetworkProvider, args: string[]) {
const ui = provider.ui();
const address = Address.parse(args.length > 0 ? args[0] : await ui.input('Collection address'));
const nftCollection = provider.open(NftCollection.createFromAddress(address));
const mint = await nftCollection.sendMintNft(provider.sender(),{
value: toNano("0.02"),
queryId: randomSeed,
amount: toNano("0.014"),
itemIndex: 0,
itemOwnerAddress: provider.sender().address!!,
itemContent: setItemContentCell({
name: "OnChain",
description: "Holds onchain metadata",
image: "<https://raw.githubusercontent.com/Cosmodude/Nexton/main/Nexton_Logo.jpg>",
})
})
ui.write(`NFT Item deployed at <https://testnet.tonscan.org/address/${nftCollection.address}`>);

Both scripts import functions from the “./nftContent/onChain” file. Here it is:

import { Dictionary, beginCell, Cell } from '@ton/core';
import { sha256_sync } from '@ton/crypto'

export function toSha256(s: string): bigint {
return BigInt('0x' + sha256_sync(s).toString('hex'))
}

export function toTextCell(s: string): Cell {
return beginCell().storeUint(0, 8).storeStringTail(s).endCell()
}
export type collectionContent = {
name: string,
description: string,
image: string
}
export type itemContent = {
name: string,
description: string,
image: string,
}
export function buildCollectionContentCell(content: collectionContent): Cell {
const collectionContentDict = Dictionary.empty(Dictionary.Keys.BigUint(256), Dictionary.Values.Cell())
.set(toSha256("name"), toTextCell(content.name))
.set(toSha256("description"), toTextCell(content.description))
.set(toSha256("image"), toTextCell(content.image));

return beginCell() // need to fix
.storeUint(0,8)
.storeDict(collectionContentDict)
.endCell();
}
export function setItemContentCell(content: itemContent): Cell {
const itemContentDict = Dictionary.empty(Dictionary.Keys.BigUint(256), Dictionary.Values.Cell())
.set(toSha256("name"), toTextCell(content.name))
.set(toSha256("description"), toTextCell(content.description))
.set(toSha256("image"), toTextCell(content.image))
return beginCell()
.storeUint(0,8)
.storeDict(itemContentDict)
.endCell();
}

The functions set the collection and items’ content following TEP64 (dictionary values are set as in the “Data Serialization” paragraph).

You can add an image_data parameter in the dictionary to make your images fully decentralized (the value should be a binary representation of the image). Nexton NFTs’ images are not primary, thus I store them off-chain.

To use the scripts just type npx blueprint run in the terminal.

To try the codes you can clone the repo for this tutorial: https://github.com/Cosmodude/OnChainNftTutorial

What is next?

GitHub repository with all the codes from the tutorial: https://github.com/Cosmodude/OnChainNftTutorial

Nexton Twitter: https://twitter.com/NextonNode

Join Nexton: https://t.me/nextonofficial

Sample collection with on-chain metadata: https://testnet.tonviewer.com/kQAhHNMam9x3Zf8Tw_IbnV-uTNSzxJFgfq7MrBjJ2_u4CJaJ?section=overview

TEP64 standard: https://github.com/ton-blockchain/TEPs/blob/master/text/0064-token-data-standard.md

The Open Network official GitHub: https://github.com/ton-blockchain

FunC stdlib: https://docs.ton.org/develop/func/stdlib

Overview of NFTs by Investopedia: https://www.investopedia.com/non-fungible-tokens-nft-5115211

--

--

Vladislav Lenskii
MiKi Blockchain

Blockchain Developer and Researcher | Co-founder @miki.digital