Optimizar para NEAR Storage On-Chain (Reduciendo el tamaño del contrato)

11 min read
To Share and +4 nLEARNs

Nota: Dejamos algunas “preguntas” para que experimenten ustedes mismos cuáles son los resultados finales.

El almacenamiento en la cadena es caro. Por cada 100 kB utilizados, se bloquea 1 NEAR. Estos NEAR se pueden devolver si eliminas el almacenamiento utilizado. Hay dos causas principales del uso del almacenamiento:

  • Transacciones: Cada transacción es un recibo, que se guarda en la cadena, por lo que ocupa algo de almacenamiento. Cada recibo de transacción tiene una longitud diferente (algunos llaman a más funciones, por lo tanto, son recibos más largos; otros llaman solo a 1 función, por lo tanto, recibos más cortos), por lo tanto, la cantidad de almacenamiento utilizada es diferente. Al igual que necesitas más papel para imprimir un recibo largo, utilizas más espacio de almacenamiento para almacenar recibos más largos.
  • Despliegue de contrato inteligente: Cuando despliegas un contrato inteligente, este ocupará el tamaño del contrato inteligente (puede diferir ligeramente de lo que ve en el archivo wasm local, pero en general las diferencias son pequeñas). Para las transacciones, a menos de que reduzcas las transacciones que realizaste, realmente no puedes reducir la cantidad de espacio que se ocupa debido a las transacciones.

(Pregunta: ¿Podemos eliminar nuestra cuenta de nivel superior a través de near-cli? Si la eliminamos, ¿se libera el costo de almacenamiento? (Por ejemplo, verifica el explorador después de eliminar una subcuenta). Ciertamente, las transacciones no se eliminarán, por lo tanto: Seguirá bloqueado el costo de almacenamiento, por lo tanto, ¿Nunca se podrá liberar?)

Aquí, hablaremos sobre la optimización del tamaño de su contrato inteligente.

Nota: se dice que el desarrollo en AssemblyScript conduce a un tamaño de wasm más pequeño, para el mismo código escrito, en comparación con Rust. Yo no soy un desarrollador de AS (prefiero usar Rust), por lo que no hablaré de eso; de hecho, yo no necesito hablar de eso, es trabajo del compilador de todos modos.

Optimizar el tamaño del contrato

Almacenamiento Persistente

NEAR tiene una lista de colecciones, la lista está aquí. Estas colecciones almacenan datos en caché para reducir la tarifa de gas. Las preguntas importantes aquí son: ¿cuánta tarifa de gas deseas reducir? ¿Cuánto costo de almacenamiento estás dispuesto a pagar por la reducción de la tarifa de gas? ¿Cuántos almacenamientos persistentes únicos necesitas?

La diferencia se menciona en el enlace de arriba (las palabras exactas son las siguientes):

Es importante tener en cuenta que cuando se usa std::collections (es decir, HashMap, etc. Rust locales), cada vez que se carga el estado, todas las entradas en la estructura de datos se leerán rápidamente desde el almacenamiento y se deserializarán. Esto tendrá un alto costo para cualquier cantidad de datos no trivial, por lo que para minimizar la cantidad de gas utilizado, las colecciones SDK deben usarse en la mayoría de los casos.

Considera este ejemplo: tenemos un contrato principal que tiene un diccionario/hash/mapa para vincular un ArticleId a un artículo. El Artículo es un objeto (en Rust, también se llama Struct) con sus 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.

Considera article_by_id, cuando creamos un artículo, la identificación será única y específica para ese artículo. Se almacenará para siempre. Un LookupMap almacena en caché los resultados en la memoria para que no tengamos que «calcular» (usando la tarifa de gas) cada vez que necesitamos los resultados.

Como se mencionó anteriormente, dado que todo se deserializará si se lee al usar HashMap, y LookupMap<AccountId, Article> no es una cantidad trivial de datos (cuando se crean muchos artículos), debe estar en caché en cadena.

Ahora, ¿Por qué usamos LookupMap en lugar de UnorderedMap? Este último ofrece iterar sobre la funcionalidad de la colección, que no necesitamos. Si lo necesitas, utiliza este último.

Luego, para las regalías, estamos usando HashMap. La cuestión es que tenemos muchos artículos, cada uno con su propias regalías únicas para cada Article.royalty único, necesitamos crear una nueva clave para guardar el almacenamiento.

Dato: Si aún no lo sabes, necesitas una clave única para cada objeto de las colecciones de NEAR SDK. Si dos colecciones de NEAR SDK comparten la misma clave, comparten los mismos datos (independientemente de si eso funcionaría o fallaría si tu compartes la memoria entre dos objetos diferentes, como Vector y LookupMap)

Ilustremos el mismo escenario de almacenamiento clave compartida. Digamos que creamos dos artículos, el artículo A y el artículo B. Estos son equivalentes a 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.

(Pregunta: ¿Puedes obtener los valores almacenados en Article A si inicializas con la misma clave de almacenamiento en Article B?)

 La solución es crear una clave de almacenamiento separada para cada colección.  Sin embargo, si tenemos 1 millón de artículos, necesitamos 1 millón de claves de colección diferentes para almacenarlos por separado.  ¡Esto suena estúpido!  Por lo tanto, tiene sentido almacenarlos como HashMap.  Además, son triviales.  Estas regalías están diseñadas originalmente para limitar la cantidad de datos que pueden almacenar, por lo que la obtención de datos es pequeña y la deserialización es económica.  Esto fortalece nuestra elección de usar HashMap en lugar de las colecciones SDK equivalentes, a pesar del (ligeramente) mayor uso de Gas (que es insignificante ya que la colección es tan pequeña que es insignificante).

 En conclusión, al diseñar su contrato inteligente, elija si usar colecciones NEAR SDK o colecciones Rust en función de la trivialidad y cuántas repeticiones necesita para el mismo mapa.

Reducción de código

Los primeros códigos que escribimos son malos.  De hecho, son solo un borrador.  Necesitamos una refactorización para eliminar el código innecesario.  Hay una compensación entre el código más fácil de entender y la optimización del almacenamiento.

 Por ejemplo, quizás uno tenga un PayoutObject y solo se usa en una sola función.

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 qué no solo definimos un:

HashMap::new()

en la función específica que usa esto?  Por supuesto, si haces esto último, será más difícil entender el código.  El primero hace que las cosas sean más fáciles de entender, desde una perspectiva Orientada a Objetos.  Sin embargo, (significativamente) más código conduce a más almacenamiento utilizado después de la compilación en WASM.  Entonces, es hora de hacer algunas concesiones.

En mi opinión, la legibilidad es más importante que la optimización del espacio de almacenamiento.  Si es necesario, clona el componente legible original y optimiza cada vez que realices cambios, para que las personas puedan entender lo que estás haciendo al leer tu código original.  Por supuesto, esto significa más trabajo para ti.

(Pregunta: ¿Cuánto espacio te ahorras si reemplazas el primero con el segundo? Si tienes un escenario similar en tu programa, intenta optimizarlo escribiendo menos código y ve en cuánto espacio se compila, ¿hay alguna diferencia? (A veces no la hay, a veces si la hay. Para aquellos que no, prefieran mantener el código legible para facilitar la depuración en el futuro.))

Wasm-Opt

Después de compilar la versión de lanzamiento optimizada, aún puedes reducir aún más el tamaño del contrato utilizando wasm-opt.  Para instalar, descarga el binario aquí para su sistema operativo y descomprimelo.  En el interior, hay una carpeta «bin», en la que debes copiar la ruta exacta a esa carpeta y agregarla a tu ruta de entorno.  Después de lo cual, intenta llamar a wasm-opt desde la línea de comando/terminal, ya sea que se ejecute o no.  De lo contrario, busca en Google en línea cómo resolverlo (tal vez no lo agregaste a la variable de entorno correcta, tal vez tu terminal ya está abierta y no actualiza la ruta más reciente, son los dos problemas más comunes).

 Ejecutar estos reduciría el tamaño del archivo:

#!/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

Aquí, asumimos que el contrato original se compila en tipping.wasm (el nombre puesto en Cargo.toml es tipping).  Entonces, el nombre optimizado es output_s.wasm.  Luego ejecutamos ls (en Linux) para verificar la diferencia en el tamaño del archivo.  Debería ser más pequeño.

 Nota: también puedes usar -Oz para las banderas, pero yo lo encontré innecesario, ya que para el proyecto en el que trabajo, no conduce a un tamaño de archivo más pequeño.

 Nota importante: RUSTFLAGS debe ser «link-arg=-s», si accidentalmente lo cambias a «-z», es posible que tengas un gran problema.  Al menos para mi, genera un archivo wasm mucho más grande.  Experimenta con él y podrás comprobarlo en tu propio proyecto.

Quizás en el futuro, podrían permitir el archivo .wasm.gz para que se pueda optimizar aún más el tamaño del archivo.  Actualmente, yo lo probé y no se puede deserializar un archivo comprimido con gzip, solo se admite el archivo .wasm en la cadena.

Cargo.toml
Estas son las banderas usuales para cargo.toml.

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

También puedes elegir opt-level = «z», podría o no generar un binario más pequeño.

 Algunas otros pequeños detalles:

Evita String Formatting
format! y to_string() pueden aumentar el código; así que usa una cadena estática (&str) siempre que sea posible.

Remueve rlib si no lo requieres

Si no necesitas realizar pruebas de simulación, elimina rlib.

Usa la serialización de borsh

Prefiere no usar serde cuando sea posible.  Aquí hay una página sobre cómo sobreescribir el protocolo de serialización.

Evita las aserciones estándar de Rust y la macro panic

Estos contienen información sobre el error devuelto, que introduce una código  innecesario.  En su lugar, prueba estos 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! es una macro liviana introducida en near-sdk-rs v4 (y también es la macro favorita) para reemplazar a la macro assert! Funciona como assert! en su mayoría, excepto por una pequeña diferencia: no puede especificar el format! per se.

// 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
)
);

Y como mencionamos antes para evitar el formato de string, es mejor hard-codear el mensaje.  Por supuesto, si realmente lo necesitas, simplemente sacrifica algunos bytes para usar el format! ocupa solo un espacio insignificante si no lo usas mucho.

No uses .expect()

En su lugar, utiliza unwrap_or_else.  Yo escribí la función de ayuda en la NEAR crate de ayuda que tal vez quieras revisar.

 De lo contrario, siempre puedes poner esto en 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"
);

Evita Pánicos

Estos son algunos errores comunes de los pánicos:

  • Indexación de un segmento fuera de los límites.  my_slice[i]
  •  División de cero: dividendo / 0
  •  unwrap(): prefiera usar unwrap_or o unwrap_or_else u otros métodos más seguros para no entrar en pánico.  En near-sdk, también hay env::panic_str (env::panic está en desuso) para panic, y mencionaron aquí que podría ser preferible.  Sin embargo, también podrías usar la coincidencia antigua para lidiar con cosas y ver si funciona mejor que panic_str;  y si no funciona mejor, usa panic_str para facilitar la comprensión del código.  De lo contrario, podrías cambiar para que coincida si vale la pena.

Intenta implementar una solución alternativa para que devuelva None o impida que entre en pánico mientras se desarrolla el contrato.

Aproximaciones de nivel bajo

  • Consulta el enlace en la referencia para conocer otras formas de reducir el tamaño del contrato que no se mencionan aquí.  (Las cosas mencionadas aquí tampoco se mencionan en su mayoría el la lista). Ten en cuenta que este ejemplo ya no se actualiza, por lo que requiere que obtengas la última actualización manualmente.

     La lista está aquí:

    • Tiny Contract(obsoleto)
    •  Contrato para fuzzing rs (puedes ver la rama maestra, esta es una rama fija para evitar que se elimine en el futuro). Yo no sé lo qué hace este contrato, ni qué significa «fuzzing»;  necesitarías entenderlo tú mismo.
    •  El ejemplo de Eugene para token fungible rápido, puedes ver el video de YouTube aquí.  Lo implementa sin usar near-sdk.  Experiencia de programación más difícil, pero optimizada para el tamaño.

     Aurora usa rjson como una caja de serialización JSON ligera.  Tiene una huella más pequeña que Serde actualmente empaquetado con Rust SDK.  Ve este ejemplo y tú como lector debes derivar cómo se usa.  Otro a tener en cuenta es la crate miniserde, ejemplo aquí

La herramienta Wasm-snip

Puede ser útil reemplazar funciones no utilizadas con instrucciones inalcanzables.  Por lo general, no necesitas hacer esto: solo si realmente necesitas ahorrar ese espacio, puedes continuar.

 ¡Mencionaron que la herramienta también es útil para eliminar la infraestructura de pánico!

 También puedes ejecutar wasm-opt con la bandera –dce después de recortar, para que esas funciones cortadas se eliminen.

Conclusión

Hay muchas maneras de optimizar un contrato. Algunas optimizaciones se realizan fácilmente sin ningún cambio, otras tienen compromisos y compensaciones que tu decidirás si valen la pena o no. En general, a menos que su contrato sea absolutamente grande, lo que generalmente resulta de la escritura de demasiadas líneas de código y que se recomienda que verifiques la necesidad de escribir ese código; de lo contrario, el uso simple como wasm-opt y la opción de almacenamiento persistente deberían ser suficientes.

Referencias

Near SDK documentation on reducing contract size
Reducing Wasm size for Rust

15
Ir arriba