Otimização para Armazenamento On-Chain em NEAR (reduzindo o tamanho do contrato)

11 min read
To Share and +4 nLEARNs

Nota: Deixamos algumas “perguntas” para você experimentar quais são os resultados finais.

O armazenamento na cadeia é caro. Para cada 100kB usados, 1 NEAR é bloqueado. Esses NEAR podem ser retornados se você remover o armazenamento usado. Existem duas causas principais de uso de armazenamento:

  • Transações: Cada transação é um recibo, que é salvo na cadeia, portanto, ocupando algum espaço de armazenamento. Cada recibo de transação tem um comprimento diferente (alguns chamam mais funções, portanto, um recibo mais longo; outros chamam apenas 1 função, portanto, um recibo mais curto), portanto, a quantidade de armazenamento usada é diferente. Assim como você precisa de mais papel para imprimir um recibo longo, você usa mais espaço de armazenamento para armazenar recibos mais longos.
  • Implantação de contrato inteligente: quando você implanta um contrato inteligente, ele ocupa o tamanho do contrato inteligente (pode ser um pouco diferente do que você vê no arquivo wasm local, mas no geral as diferenças são pequenas).
    Para transações, a menos que você reduza as transações que fez, você não pode realmente reduzir quanto espaço é ocupado devido às transações.

(Pergunta: Podemos excluir nossa conta de nível superior via near-cli? Se excluirmos, isso libera o custo de armazenamento? (PS: verifique o explorer após excluir uma subconta) Certamente, as transações não serão excluídas, portanto ele ainda bloqueará o custo de armazenamento, portanto, nunca poderá ser lançado?)

Aqui, falaremos sobre como otimizar o tamanho do seu contrato inteligente.

Nota: diz-se que o desenvolvimento em AssemblyScript leva a um tamanho de wasm menor, para o mesmo código escrito, em comparação com Rust. Eu não sou um desenvolvedor AS (prefira usar Rust), então não falaremos sobre isso; na verdade, não é preciso falar sobre isso, é o trabalho do compilador de qualquer maneira.

Otimizar o tamanho do contrato

Armazenamento persistente

Near tem uma lista de coleções, a lista está aqui. Essas coleções armazenam dados em cache para reduzir a taxa de gás. A compensação é: quanto de taxa de gás você deseja reduzir? Quanto custo de armazenamento você está disposto a pagar pela redução da taxa de gás? De quantos armazenamentos persistentes exclusivos você precisa?

A diferença é mencionada no link acima (as palavras exatas são as abaixo):

É importante ter em mente que ao usar std::collections (ou seja, HashMap, etc Rust locals), que cada vez que o estado é carregado, todas as entradas na estrutura de dados serão lidas rapidamente do armazenamento e desserializadas. Isso terá um grande custo para qualquer quantidade de dados não trivial, portanto, para minimizar a quantidade de gás usada, as coleções do SDK devem ser usadas na maioria dos casos.

Considere este exemplo: Temos um contrato principal que possui um dicionário/hash/map para vincular um ArticleId a um artigo. O artigo é um objeto (em Rust, também é chamado de Struct) com seus atributos.

use near_sdk::AccountId;
use near_sdk::collections::LookupMap;
use std::collections::HashMap;

pub struct Contract {
pub article_by_id: LookupMap<ArticleId, Article>,
}

pub struct Article {
pub owner_id: AccountId,
pub article_id: String,
pub royalty: HashMap<Account, u16>,
}
We see that Contract.article_by_id uses LookupMap and Article.royalty uses HashMap. We shall discuss why we don’t use other types.

Considere article_by_id, quando criamos um artigo, o id será único e específico para aquele artigo. Ficará guardado para sempre. Um LookupMap armazena em cache os resultados na memória para que não tenhamos que “calcular” (usando a taxa de gás) toda vez que precisarmos dos resultados.

Como mencionado anteriormente, como tudo será desserializado se lido ao usar HashMap, e LookupMap<AccountId, Article> não é uma quantidade trivial de dados (quando há muitos artigos sendo criados), deve ser cache on-chain.

Agora, por que estamos usando LookupMap em vez de UnorderedMap? O último oferece iteração sobre a funcionalidade de coleta, da qual não precisamos. Se precisar, use o último.

Então, para royalties, estamos usando HashMap. O fato é que temos muitos artigos, cada um com seu próprio Article.royalty. Para cada Article.royalty exclusivo, precisamos criar uma nova chave para salvar o armazenamento.

PS: Se você ainda não sabe, precisa de uma chave exclusiva para cada objeto das coleções NEAR SDK. Se duas coleções NEAR SDK compartilham a mesma chave, elas compartilham os mesmos dados (independentemente de funcionar ou falhar se você compartilhar memória entre dois objetos diferentes, como Vector e LookupMap)

Vamos ilustrar o mesmo cenário de armazenamento compartilhado de chaves. Digamos que criamos dois artigos, Artigo A e Artigo B. Esses são seus equivalentes Article.royalty.

// Values are percentage, 100.00% == 10_000.

// Article A
{
"alice.near": 1_000,
"bob.near": 500,
}

// Article B
{
"alice.near": 1_500,
"charlie.near": 2_000,
}
For some reason, there’s a repeat royalty for alice.near. If you use the same storage key, it will lead to error, complaining that: you already have an alice.near stored in Article A, and you are repeatingly storage another value to that key, and this is not possible. We also want them to be independent of each other; and same storage key used by 2 different HashMap means they share the same values.

(Pergunta: você pode buscar os valores armazenados no Artigo A se inicializar com a mesma chave de armazenamento no Artigo B?)

A solução é criar uma chave de armazenamento separada para cada coleção. No entanto, se temos 1 milhão de artigos, precisamos de 1 milhão de chaves de coleção diferentes para armazená-los separadamente. Isso parece estranho! Portanto, faz sentido armazená-los como HashMap. Além disso, eles são triviais. Esses royalties são originalmente projetados para limitar a quantidade de dados que eles podem armazenar, de modo que a busca de dados é pequena e a desserialização é barata. Isso fortalece nossa escolha de usar HashMap do que as coleções de SDK equivalentes, apesar do uso (ligeiramente) maior de Gás (que é insignificante, pois a coleção é tão pequena que é insignificante).

Concluindo, ao projetar seu contrato inteligente, escolha se deseja usar coleções NEAR SDK ou coleções Rust com base na trivialidade e quantas repetições você precisa para o mesmo Mapa.

Redução de código

O primeiro código que escrevemos, são ruins. Na verdade, eles são apenas um rascunho. Precisamos de alguma refatoração para excluir código desnecessário. Há uma compensação entre o código mais fácil de entender e a otimização do armazenamento.

Por exemplo, talvez um tenha um PayoutObject e seja usado apenas em uma única função.

use near_sdk::json_types::U128;
use near_sdk::AccountId;

pub struct Payout {
pub payout: HashMap<ArticleId, U128>,
}

impl Default for Payout {
fn default() -> Self {
Self [
payout: HashMap::new(),
]
}
}

Por que não podemos simplesmente definir um

HashMap::new()

na função específica que usa isso? Claro, se você fizer o último, seria mais difícil entender o código. O primeiro torna as coisas mais fáceis de entender, de uma perspectiva orientada a objetos. No entanto, (significativamente) mais código leva a mais armazenamento usado após a compilação para WASM. Então é hora de fazer algumas trocas.

Na opinião de One, a legibilidade é mais importante do que a otimização do espaço de armazenamento. Se necessário, clone o componente legível original e faça algumas otimizações sempre que fizer alterações, para que as pessoas possam entender o que você está fazendo lendo seu código original. Claro, isso significa mais trabalho para você.

(Pergunta: Quanto espaço é economizado se você substituir o primeiro pelo último? Se você tiver um cenário semelhante em seu programa, tente otimizá-lo escrevendo menos código e veja quanto espaço ele compila, há diferenças? (Às vezes não há, às vezes há. Para aqueles que não, prefira manter o código legível para facilitar a depuração no futuro. ))

Wasm-Opt

Depois de compilar a versão de lançamento otimizada, você ainda pode reduzir ainda mais o tamanho do contrato usando wasm-opt. Para instalar, baixe o binário aqui para o seu sistema operacional e descompacte-o. Dentro, há uma pasta “bin”, que você deve copiar o caminho exato para essa pasta e adicioná-la ao caminho do seu ambiente. Depois disso, tente chamar wasm-opt na linha de comando/terminal, seja executado ou não. Se não, pesquise online como resolvê-lo (talvez você não o tenha adicionado à variável de ambiente correta, talvez seu terminal já esteja aberto, o que não atualiza o caminho mais recente, são os dois problemas mais comuns).

Executá-los reduziria o tamanho do arquivo:

#!/bin/bash
set -e

export WASM_NAME=tipping.wasm
z
mkdir res

RUSTFLAGS='-C link-arg=-s' cargo build --target wasm32-unknown-unknown --release
cp target/wasm32-unknown-unknown/release/$WASM_NAME ./res/
wasm-opt -Os -o res/output_s.wasm res/$WASM_NAME
ls res -lh

Aqui, assumimos que o contrato original é compilado para tipping.wasm, (o nome Cargo.toml é tipping). Em seguida, o nome otimizado é output_s.wasm. Em seguida, executamos ls (no Linux) para verificar a diferença no tamanho do arquivo. Deve ser menor.

Nota: você também pode use -Oz para os sinalizadores, mas One achou desnecessário, quanto ao projeto em que se trabalha, isso não leva a um tamanho de arquivo menor.

Nota importante: O RUSTFLAGS deve ser “link-arg=-s”, se você acidentalmente alterá-lo para “-z”, você pode ter um grande problema. Pelo menos para um, ele gera um arquivo wasm muito maior. Você deve experimentá-lo e verificar o seu próprio projeto.

Talvez no futuro, eles permitam o arquivo .wasm.gz para que você possa otimizar ainda mais o tamanho do arquivo. Atualmente, One tentou e não pôde desserializar um arquivo gzipado, suportando apenas o arquivo .wasm on-chain.

Cargo.toml
Estas são as bandeiras usuais para cargo.toml.

[profile.release]
codegen-units = 1
opt-level = "s"
lto = true
debug = false
panic = "abort"
overflow-checks = true

Você pode escolher opt-level = “z” também, pode ou não gerar um binário menor.

Algumas outras pequenas vitórias

Evite a formatação de strings
format! e to_string() pode trazer inchaço de código; então use string estático (&str) quando possível.

Removendo rlib se não for necessário
Se você não precisar fazer testes de simulação, remova rlib.

Use serialização Borsh
Prefira não usar serde quando possível. Aqui está uma página sobre como substituir o protocolo de serialização.

Evitar asserções padrão Rust e Panic macro

Estes contêm informações sobre o erro retornado, o que introduz inchaço desnecessário. Em vez disso, tente estes métodos:

// Instead of this
assert_eq!(
contract_owner,
predecessor_account,
"ERR_NOT_OWNER"
);

// Try this (for all versions of near-sdk-rs)
if contract_owner != predecessor_account {
env::panic_str("ERR_NOT_OWNER")
}

// For near-sdk-rs v4.0 pre-release
use near_sdk::require;
require!(
contract_owner == predecessor_account,
"ERR_NOT_OWNER"
);

require! é uma macro leve introduzida no near-sdk-rs v4 (e também é a macro favorita) para substituir assert! macro. Funciona principalmente como assert!, exceto por uma pequena diferença: não pode especificar o format! por si.

// assert! can specify format
assert!(
some_conditions,
"{}: {}"
error_type,
error_message
);

// require! cannot
require!(
some_conditions,
format!( // you need format! yourself
"{}: {}",
error_type,
error_message
)
);

E, como mencionamos antes, para evitar a formatação de strings, é melhor codificar a mensagem. Claro, se você realmente precisar, apenas sacrifique alguns bytes para usar o format! Está ok: ocupa apenas um espaço insignificante se você não o usar extensivamente.

Não use .expect()

Em vez disso, use unwrap_or_else. Alguém escreveu a função auxiliar na caixa de ajuda Near que você pode querer verificar.

Caso contrário, você sempre pode colocar isso em internal.rs:

fn expect_lightweight(option: Option, message: &str) -> T {
option.unwrap_or_else(||
env::panic_str(message)
)
}

// instead of:
let owner_id = self.owner_by_id
.get(&token_id)
.expect("Token not found");

// use this:
let owner_id = expect_lightweight(
self.owner_by_id.get(&token_id),
"Token not found"
);

Evite entrar em pânico

Estes são alguns erros comuns aos pânicos:

  •  Indexando uma fatia fora dos limites. my_slice[i]
  • Divisão de zero: dividend / 0
  • unwrap(): prefira usar unwrap_or ou unwrap_or_else ou outros métodos mais seguros para não entrar em pânico. No near-sdk, também há env::panic_str (env::panic está obsoleto) para entrar em pânico, e eles mencionaram aqui que poderia ser preferido. No entanto, você também pode usar a correspondência antiga para lidar com coisas e ver se funciona melhor que panic_str; caso contrário, use panic_str para facilitar a compreensão do código. Caso contrário, você pode mudar para combinar se valer a pena.

Tente implementar a solução alternativa para que ela retorne None ou não imponha nenhum pânico durante o desenvolvimento do contrato.

Abordagens de nível inferior

Confira o link na referência para outras formas de reduzir o tamanho do contrato não mencionadas aqui. (As coisas mencionadas aqui também não são mencionados aqui).

Lembre-se de que este exemplo não é mais atualizado, portanto, é necessário derivar manualmente para a atualização mais recente.

A lista está aqui:

  • Contrato minúsculo (obsoleto)
  • Contrato para fuzzing rs (você pode visualizar o branch master, este é um branch fixo para evitar que ele seja removido no futuro). Não se sabe o que esse contrato faz, nem o que significa “fuzzing”; você precisaria entender por si próprio.
  • O exemplo de Eugene para token fungível rápido, e você pode assistir ao vídeo do youtube aqui. Ele implementa sem usar near-sdk. Experiência de programação muito infeliz, mas otimizada para tamanho.
  • O Aurora usa rjson como uma caixa de serialização JSON leve. Ele tem uma pegada menor do que o serde atualmente empacotado com o Rust SDK. Veja este exemplo e o leitor exige derivar como ele é usado. Outro a considerar é a caixa de miniserde, exemplo aqui.

A ferramenta Wasm-snip

Pode ser útil substituir funções não usadas por instruções inacessíveis. Normalmente, você não precisa fazer isso: somente se você realmente precisar economizar esse espaço, poderá seguir em frente.

Eles mencionaram que a ferramenta também é útil para remover a infraestrutura em panic!

Você também pode executar wasm-opt com o sinalizador –dce após o recorte, para que essas funções recortadas sejam removidas.

Conclusão

Há muitas maneiras de otimizar um contrato. Algumas otimizações são feitas facilmente sem nenhuma alteração, outras têm compromissos e trocas que você deve decidir se vale a pena ou não as trocas. Em geral, a menos que seu contrato seja muito grande, o que geralmente resulta de muitas linhas de código sendo escritas e que você é encorajado a verificar a necessidade de código escrito; caso contrário, o uso simples, como wasm-opt e escolha de armazenamento persistente, deve ser suficiente.

Referências

Documentação SDK Near sobre como reduzir o tamanho do contrato

Reduzindo o tamanho do Wasm para Rust

17
Scroll to Top