Back to Blog

Практики безопасности при разработке на Move (2): Aptos Coin

Code Auditing
November 21, 2022
17 min read

В предыдущей статье мы кратко рассказали о том, как разработать программу Hello World в сети Aptos. Теперь мы немного углубимся в разработку приложений DeFi и вопросы их безопасности. Как всегда, начнем с некоторых базовых, но важных концепций. В этой статье мы сосредоточимся на Aptos coin (т.е. взаимозаменяемом токене в Aptos [1]), включая его разработку, управление и взаимодействие с ним.

TL;DR

Эта статья расскажет вам:

  • что такое Aptos Coin?
  • как создать свою монету и управлять ею?
  • как взаимодействовать со своей монетой?

0x1. О монете Aptos

Будучи атомами DeFi, токены (или монеты) широко используются в блокчейн-экосистемах. Они могут использоваться для представления многих вещей, включая электронную валюту, доли стейкинга и право голоса при управлении организацией. В некоторой степени повседневную активность DeFi можно просто рассматривать как огромный объем потоков токенов в блокчейн-системах.

В Ethereum был разработан набор стандартов для токенов. Самый известный из них называется ERC20, в котором указаны интерфейсы, которым должны соответствовать стандартные токены ERC20. ERC20 — это стандарт взаимозаменяемых токенов, в то время как существуют и стандарты невзаимозаменяемых токенов, такие как ERC721.

Подобно другим блокчейн-системам, у Aptos также есть свой стандарт токенов [2], который определяет, как создаются и используются цифровые активы в соответствующих блокчейнах. В частности, в Aptos взаимозаменяемый токен называется монетой (coin), в то время как невзаимозаменяемый токен (т.е. NFT) называется токеном (token). Далее мы обсудим способы создания, управления и взаимодействия с монетой Aptos.

0x2. Создание и управление вашей первой монетой

Aptos предоставляет официальный стандартный модуль (аналогичный ERC20): coin.move. Вызывая API этого модуля, любой пользователь может легко создать свою собственную монету. Кроме того, coin.move предоставляет механизм разрешений для управления монетами, что важно и полезно для построения сложных DeFi-приложений. Ниже мы продемонстрируем, как создать монету на основе этого модуля.

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

aptos move init --name my_coin

Затем вам нужно создать новый файл Move в папке ''sources''. Теперь давайте заполним его следующим примером кода, который определяет модуль с именем bsc для создания и управления стандартной монетой с именем BSC.

module BlockSec::bsc{
    use aptos_framework::coin;
    use aptos_framework::event;
    use aptos_framework::account;
    use aptos_std::type_info;
    use std::string::{utf8, String};
    use std::signer;


    struct BSC{}
    
    struct CapStore has key{
        mint_cap: coin::MintCapability<BSC>,
        freeze_cap: coin::FreezeCapability<BSC>,
        burn_cap: coin::BurnCapability<BSC>
    }

    struct BSCEventStore has key{
        event_handle: event::EventHandle<String>,
    }

    fun init_module(account: &signer){
        let (burn_cap, freeze_cap, mint_cap) = coin::initialize<BSC>(account, utf8(b"BSC"), utf8(b"BSC"), 6, true);
        move_to(account, CapStore{mint_cap: mint_cap, freeze_cap: freeze_cap, burn_cap: burn_cap});
    }

    public entry fun register(account: &signer){
        let address_ = signer::address_of(account);
        if(!coin::is_account_registered<BSC>(address_)){
            coin::register<BSC>(account);
        };
        if(!exists<BSCEventStore>(address_)){
            move_to(account, BSCEventStore{event_handle: account::new_event_handle(account)});
        };
    }

    fun emit_event(account: address, msg: String) acquires BSCEventStore{
        event::emit_event<String>(&mut borrow_global_mut<BSCEventStore>(account).event_handle, msg);
    }

    public entry fun mint_coin(cap_owner: &signer, to_address: address, amount: u64) acquires CapStore, BSCEventStore{
        let mint_cap = &borrow_global<CapStore>(signer::address_of(cap_owner)).mint_cap;
        let mint_coin = coin::mint<BSC>(amount, mint_cap);
        coin::deposit<BSC>(to_address, mint_coin);
        emit_event(to_address, utf8(b"minted BSC"));
    }

    public entry fun burn_coin(account: &signer, amount: u64) acquires CapStore, BSCEventStore{
        let owner_address = type_info::account_address(&type_info::type_of<BSC>());
        let burn_cap = &borrow_global<CapStore>(owner_address).burn_cap;
        let burn_coin = coin::withdraw<BSC>(account, amount);
        coin::burn<BSC>(burn_coin, burn_cap);
        emit_event(signer::address_of(account), utf8(b"burned BSC"));
    }

    public entry fun freeze_self(account: &signer) acquires CapStore, BSCEventStore{
        let owner_address = type_info::account_address(&type_info::type_of<BSC>());
        let freeze_cap = &borrow_global<CapStore>(owner_address).freeze_cap;
        let freeze_address = signer::address_of(account);
        coin::freeze_coin_store<BSC>(freeze_address, freeze_cap);
        emit_event(freeze_address, utf8(b"freezed self"));
    }

    public entry fun emergency_freeze(cap_owner: &signer, freeze_address: address) acquires CapStore, BSCEventStore{
        let owner_address = signer::address_of(cap_owner);
        let freeze_cap = &borrow_global<CapStore>(owner_address).freeze_cap;
        coin::freeze_coin_store<BSC>(freeze_address, freeze_cap);
        emit_event(freeze_address, utf8(b"emergency freezed"));
    }

    public entry fun unfreeze(cap_owner: &signer, unfreeze_address: address) acquires CapStore, BSCEventStore{
        let owner_address = signer::address_of(cap_owner);
        let freeze_cap = &borrow_global<CapStore>(owner_address).freeze_cap;
        coin::unfreeze_coin_store<BSC>(unfreeze_address, freeze_cap);
        emit_event(unfreeze_address, utf8(b"unfreezed"));
    }
    
}

0x2.1 Базовая структура

Сначала посмотрим на структуру. Всего определено три структуры.

  • Структура BSC, которая используется в качестве уникального идентификатора монеты. Таким образом, эта монета может быть однозначно определена через путь BlockSec::bsc::BSC.
  • Структура CapStore, которая используется для хранения некоторых возможностей (capabilities), полученных из модуля aptos_framework::coin. Эти возможности соответствуют разрешениям на выполнение специальных операций и будут объяснены позже.
  • Структура BSCEventStore, которая используется для записи событий пользователей.

После этих структур идет функция init_module, которая используется для инициализации модуля и будет вызвана только один раз при публикации модуля в цепочке. В этой функции модуль вызывает coin::initialize<BSC> для регистрации BlockSec::bsc::BSC в качестве уникального идентификатора для новой монеты.

public fun initialize<CoinType>(
    account: &signer,
    name: string::String,
    symbol: string::String,
    decimals: u8,
    monitor_supply: bool,
): (BurnCapability<CoinType>, FreezeCapability<CoinType>, MintCapability<CoinType>) {
    initialize_internal(account, name, symbol, decimals, monitor_supply, false)
}

/// Возможность, требуемая для выпуска монет.
struct MintCapability<phantom CoinType> has copy, store {}

/// Возможность, требуемая для заморозки хранилища монет.
struct FreezeCapability<phantom CoinType> has copy, store {}

/// Возможность, требуемая для сжигания монет.
struct BurnCapability<phantom CoinType> has copy, store {}

После регистрации все обобщенные (generic) функции, принимающие тип BlockSec::bsc::BSC в aptos_framework::coin, будут работать с этой монетой. Этот процесс регистрации вернет три возможности: MintCapability, FreezeCapability и BurnCapability. Эти возможности требуются для чеканки (выпуска) монет, заморозки аккаунтов пользователей и сжигания монет соответственно. В некоторой степени функциональность такой структуры возможностей похожа на ключ, который используется, чтобы открыть замок для доступа к определенному разрешению. Если у кого-то есть ключ, он может получить соответствующее разрешение. Здесь мы храним эти возможности в структуре CapStore (принадлежащей администратору/издателю этого модуля) для использования в дальнейшем.

Тем временем, в процессе регистрации структура CoinInfo будет сохранена под адресом администратора для записи соответствующей информации:

/// Информация о конкретном типе монеты. Сохраняется у создателя аккаунта монеты.
struct CoinInfo<phantom CoinType> has key {
    name: string::String,
    /// Символ монеты, обычно более короткая версия названия.
    /// Например, сингапурский доллар — SGD.
    symbol: string::String,
    /// Количество знаков после запятой, используемое для пользовательского представления.
    /// Например, если `decimals` равно `2`, баланс `505` монет должен
    /// отображаться пользователю как `5.05` (`505 / 10 ** 2`).
    decimals: u8,
    /// Количество существующего типа монет.
    supply: Option<OptionalAggregator>,
}

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

0x2.2 Управление монетой

Эта монета разработана с учетом следующих правил:

  • Только администратор (admin) может выпускать монеты.
  • Пользователи могут сжигать свои собственные монеты в любое время.
  • Пользователи могут замораживать/размораживать свои аккаунты в любое время (себя).

Соответственно, мы определяем пять функций управления: mint_coin, burn_coin, freeze_self, emergency_freeze и unfreeze. Первые две функции отвечают за выпуск монет и сжигание монет соответственно; в то время как последние три используются для заморозки и разморозки аккаунтов.

Выпуск (чеканка) монет

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

public entry fun mint_coin(cap_owner: &signer, to_address: address, amount: u64) acquires CapStore, BSCEventStore{
    let mint_cap = &borrow_global<CapStore>(signer::address_of(cap_owner)).mint_cap;
    let mint_coin = coin::mint<BSC>(amount, mint_cap);
    coin::deposit<BSC>(to_address, mint_coin);
    emit_event(to_address, utf8(b"minted BSC"));
}

Эта функция требует три параметра:

  • cap_owner имеет тип &signer, т.е. инициатор транзакции.
  • to_address указывает адрес, на который будут внесены выпущенные монеты.
  • amount указывает количество выпускаемых монет.

Она состоит из трех шагов: получение возможности выпуска монет, выпуск монет и внесение монет.

Во-первых, в начале функции mint_coin адрес аккаунта инициатора транзакции можно получить через signer::address_of(cap_owner). После этого borrow_global<CapStore> используется для подтверждения того, владеет ли аккаунт CapStore, чтобы убедиться, что это администратор модуля. Таким образом, мы можем гарантировать, что только администратор может выпускать монеты, в то время как другие пользователи получат ошибку на этом шаге.

Во-вторых, функция mint_coin затем вызовет функцию mint модуля aptos_framework::coin для выпуска монет.

public fun mint<CoinType>(
        amount: u64,
        _cap: &MintCapability<CoinType>,
    ): Coin<CoinType> acquires CoinInfo {
    if (amount == 0) {
        return zero<CoinType>()
    };

    let maybe_supply = &mut borrow_global_mut<CoinInfo<CoinType>>(coin_address<CoinType>()).supply;
    if (option::is_some(maybe_supply)) {
        let supply = option::borrow_mut(maybe_supply);
        optional_aggregator::add(supply, (amount as u128));
    };

    Coin<CoinType> { value: amount }
}

Здесь требуется MintCapability. В частности, требуется, чтобы параметр с именем _cap был передан как ссылка на MintCapability. Затем разрешение, связанное с возможностью MintCapability, также передается соответствующим образом. Хотя явного контроля доступа нет, проверка принудительно выполняется языком Move.

В-третьих, функция mint_coin вызовет функцию deposit для зачисления выпущенных монет на указанный to_address.

Совет №1: Контроль доступа для привилегированных аккаунтов можно проверить, используя аналогичный метод.

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

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

public entry fun burn_coin(account: &signer, amount: u64) acquires CapStore, BSCEventStore{
    let owner_address = type_info::account_address(&type_info::type_of<BSC>());
    let burn_cap = &borrow_global<CapStore>(owner_address).burn_cap;
    let burn_coin = coin::withdraw<BSC>(account, amount);
    coin::burn<BSC>(burn_coin, burn_cap);
    emit_event(signer::address_of(account), utf8(b"burned BSC"));
}

Эта функция требует два параметра:

  • account имеет тип &signer, т.е. инициатор транзакции.
  • amount указывает количество сжигаемых монет.

Она также состоит из трех шагов: получение возможности сжигания монет, вывод монет и сжигание монет.

Очевидно, что функция burn модуля aptos_framework::coin требует от вызывающего передать ссылку на BurnCapability, но эта возможность хранится в CapStore администратора. Поэтому мы должны позволить обычным пользователям получить эту возможность для сжигания монет, которыми они владеют.

public fun burn<CoinType>(
    coin: Coin<CoinType>,
    _cap: &BurnCapability<CoinType>,
) acquires CoinInfo {
    let Coin { value: amount } = coin;
    assert!(amount > 0, error::invalid_argument(EZERO_COIN_AMOUNT));

    let maybe_supply = &mut borrow_global_mut<CoinInfo<CoinType>>(coin_address<CoinType>()).supply;
    if (option::is_some(maybe_supply)) {
        let supply = option::borrow_mut(maybe_supply);
        optional_aggregator::sub(supply, (amount as u128));
    }
}

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

Однако инициатором транзакции функции burn_coin является пользователь, а не администратор. Следовательно, адрес администратора нельзя получить через signer (как в функции mint_coin). К счастью, его можно получить через aptos_std::type_info с помощью BSC, чтобы узнать адрес модуля, в котором определена эта структура. Поскольку модуль был опубликован под адресом администратора, мы можем соответствующим образом получить адрес администратора и, наконец, получить возможность BurnCapability.

Совет №2: Оператор borrow_global можно использовать для временного получения возможностей модуля.

Получив BurnCapability, модуль может вывести указанное количество монет у пользователя и сжечь их с помощью этой возможности.

Заморозка и разморозка аккаунтов монет

Основываясь на обсуждении выше, теперь мы можем легко разобраться с управлением аккаунтами монет. В частности, мы предоставляем функцию freeze_self для пользователей, чтобы они могли заморозить свои собственные аккаунты монет. Здесь мы также предоставляем функцию emergency_freeze для экстренной заморозки, которую может использовать только администратор. Кроме того, из-за наличия механизма экстренной заморозки пользователям не следует разрешать размораживать себя. Поэтому для функции unfreeze также требуется, чтобы администратор разморозил аккаунты пользователей.

public entry fun freeze_self(account: &signer) acquires CapStore, BSCEventStore{
    let owner_address = type_info::account_address(&type_info::type_of<BSC>());
    let freeze_cap = &borrow_global<CapStore>(owner_address).freeze_cap;
    let freeze_address = signer::address_of(account);
    coin::freeze_coin_store<BSC>(freeze_address, freeze_cap);
    emit_event(freeze_address, utf8(b"freezed self"));
}

public entry fun emergency_freeze(cap_owner: &signer, freeze_address: address) acquires CapStore, BSCEventStore{
    let owner_address = signer::address_of(cap_owner);
    let freeze_cap = &borrow_global<CapStore>(owner_address).freeze_cap;
    coin::freeze_coin_store<BSC>(freeze_address, freeze_cap);
    emit_event(freeze_address, utf8(b"emergency freezed"));
}

public entry fun unfreeze(cap_owner: &signer, unfreeze_address: address) acquires CapStore, BSCEventStore{
    let owner_address = signer::address_of(cap_owner);
    let freeze_cap = &borrow_global<CapStore>(owner_address).freeze_cap;
    coin::unfreeze_coin_store<BSC>(unfreeze_address, freeze_cap);
    emit_event(unfreeze_address, utf8(b"unfreezed"));
}

Совет №3: Будьте осторожны при возврате возможностей через функции! Злоумышленники, получившие эти возможности, могут злоупотребить ими и нанести ущерб держателям монет!

0x3. Взаимодействие с монетой

Здесь мы сосредоточимся на способе взаимодействия с монетой.

0x3.1 Регистрация

Возможно, вы заметили, что в модуле есть функция register:

public entry fun register(account: &signer){
    let address_ = signer::address_of(account);
    if(!coin::is_account_registered<BSC>(address_)){
        coin::register<BSC>(account);
    };
    if(!exists<BSCEventStore>(address_)){
        move_to(account, BSCEventStore{event_handle: account::new_event_handle(account)});
    };
}

Эта функция используется, чтобы помочь пользователям зарегистрировать права на использование монеты и регистраторы событий. Чтобы использовать определенную монету, модуль aptos_framework::coin оговаривает, что пользователь должен сначала явно зарегистрировать право на использование монеты через функцию aptos_framework::coin::register.

public fun register<CoinType>(account: &signer) {
    let account_addr = signer::address_of(account);
    assert!(
        !is_account_registered<CoinType>(account_addr),
        error::already_exists(ECOIN_STORE_ALREADY_PUBLISHED),
    );

    account::register_coin<CoinType>(account_addr);
    let coin_store = CoinStore<CoinType> {
        coin: Coin { value: 0 },
        frozen: false,
        deposit_events: account::new_event_handle<DepositEvent>(account),
        withdraw_events: account::new_event_handle<WithdrawEvent>(account),
    };
    move_to(account, coin_store);
}

Пользователи могут нормально владеть этой монетой только в том случае, если они регистрируют этот тип монеты через эту функцию. То есть, если вы не хотите владеть определенной монетой, другие не могут положить эту монету на ваш аккаунт без вашего согласия. Регистрация фактически помещает структуру CoinStore (целевого типа монеты) на ваш аккаунт. Эта структура CoinStore содержит структуру Coin для записи вашего баланса.

Совет №4: В отличие от токенов Ethereum, монетой Aptos нельзя владеть и управлять без явной регистрации пользователя.

0x3.2 Перевод

Предположим, у вас теперь есть немного монет BSC, тогда вы можете перевести эти монеты, вызвав функцию transfer модуля aptos_framework::coin.

public entry fun transfer<CoinType>(
    from: &signer,
    to: address,
    amount: u64,
) acquires CoinStore {
    let coin = withdraw<CoinType>(from, amount);
    deposit(to, coin);
}

Обратите внимание, что это функция типа entry, предоставляемая модулем coin. Логика состоит из вызовов двух публичных функций: withdraw и deposit. Функция withdraw требует разрешения &signer, которое используется для вывода определенного количества активов с вашего аккаунта в монету. Функция deposit может внести монету на любой зарегистрированный аккаунт монеты. Эта функция не требует дополнительных разрешений и внесет указанные монеты на адрес аккаунта. Наконец, переведенные монеты будут автоматически объединены с монетами, хранящимися в структуре CoinStore целевого адреса.

Совет №5: После вывода активы в монете находятся под контролем текущей функции transfer. Эта функция может доставить эти активы в функцию deposit, не получая дополнительных разрешений.

0x3.3 Разделение и объединение

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

1. Функция extract используется для разделения монет. Она получает структуру Coin, извлекает из нее часть активов для создания новой структуры Coin и возвращает новую структуру.

public fun extract<CoinType>(coin: &mut Coin<CoinType>, amount: u64): Coin<CoinType> {
    assert!(coin.value >= amount, error::invalid_argument(EINSUFFICIENT_BALANCE));
    coin.value = coin.value - amount;
    Coin { value: amount }
}

2. Функция extract_all используется для извлечения всей стоимости исходной структуры Coin и внесения ее в новую структуру Coin. В результате значение исходной структуры Coin станет равным нулю (так называемая zero_coin). Структуру zero_coin можно уничтожить, вызвав функцию destroy_zero.

public fun extract_all<CoinType>(coin: &mut Coin<CoinType>): Coin<CoinType> {
    let total_value = coin.value;
    coin.value = 0;
    Coin { value: total_value }
}

public fun destroy_zero<CoinType>(zero_coin: Coin<CoinType>) {
    let Coin { value } = zero_coin;
    assert!(value == 0, error::invalid_argument(EDESTRUCTION_OF_NONZERO_TOKEN))
}

3. Функция merge используется для объединения монет. Вы можете объединить стоимость двух структур Coin, т.е. source_coin (исходная монета) и dst_coin (целевая монета), в структуру dst_coin и уничтожить структуру source_coin.

public fun merge<CoinType>(dst_coin: &mut Coin<CoinType>, source_coin: Coin<CoinType>) {
    spec {
        assume dst_coin.value + source_coin.value <= MAX_U64;
    };
    dst_coin.value = dst_coin.value + source_coin.value;
    let Coin { value: _ } = source_coin;
}

4. Функция zero используется для генерации структуры zero_coin.

public fun zero<CoinType>(): Coin<CoinType> {
    Coin<CoinType> {
        value: 0
    }
}

0x4. Тестирование монеты

Чтобы быстро протестировать эту монету, вы можете сначала развернуть её с помощью следующей команды (не забудьте указать адрес аккаунта издателя в Move.toml!)

$ aptos move publish --package-dir ./
Compiling, may take a little while to download git dependencies...
INCLUDING DEPENDENCY AptosFramework
INCLUDING DEPENDENCY AptosStdlib
INCLUDING DEPENDENCY MoveStdlib
BUILDING my_coin
package size 2751 bytes
Do you want to submit a transaction for a range of [868300 - 1302400] Octas at a gas unit price of 100 Octas? [yes/no] >
yes
{
  "Result": {
    "transaction_hash": "xxx",
    ...

Таким образом, монета была успешно опубликована в сети, но у нее пока нет никакого оборота. Вам нужно зарегистрировать свой аккаунт, чтобы получить выпущенные монеты.

$ aptos move run --function-id default::bsc::register
Do you want to submit a transaction for a range of [153100 - 229600] Octas at a gas unit price of 100 Octas? [yes/no] >
yes
{
  "Result": {
    "transaction_hash": "xxx",
    ...

Обратите внимание, что Your-address во второй команде должен быть заменен на ваш собственный адрес (см. поле account в файле .aptos/config.yaml). Введите свой адрес в браузере и перейдите на вкладку Resources (Ресурсы), вы увидите, что на этом аккаунте теперь есть 100 монет BSC.

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

$ aptos move run — function-id 0x1::coin::transfer — type-args BSC-module-address::bsc::BSC — args address:To_address u64:1

Эта команда вызовет функцию transfer модуля монеты, чтобы перевести 1 монету BSC на To_address. Здесь 0x1::coin::transfer — это идентификатор функции transfer. Помните, что BlockSec::bsc::BSC является идентификатором вашей монеты, обобщенный параметр должен быть указан именно так. Кроме того, BSC-module-address должен быть заменен на адрес аккаунта издателя модуля, который назначен BlockSec в файле Move.toml.

0x5. Что дальше

Поняв, как создавать, управлять и взаимодействовать со своей собственной монетой, мы продемонстрируем, как создать первый краеугольный проект DeFi: Автоматизированный маркет-мейкер (AMM). Будут охвачены более интересные темы, связанные с разработкой на Move и практикой безопасности. Оставайтесь с нами!

Ссылки

[1] https://aptos.dev/concepts/coin-and-token/aptos-coin/
[2] https://aptos.dev/concepts/coin-and-token/index
[3] https://aptos.dev/concepts/coin-and-token/aptos-token

Best Security Auditor for Web3

Validate design, code, and business logic before launch. Aligned with the highest industry security standards.

BlockSec Audit