WEB3DEV

Cover image for Rust: Interagindo com a API do Etherscan
Isabela Curado Nehme
Isabela Curado Nehme

Posted on • Atualizado em

Rust: Interagindo com a API do Etherscan

27 de abril de 2023

Gerada pelo criador de imagem do Bing

Se você é um fã de criptomoedas, provavelmente já ouviu falar do etherscan.io, uma ferramenta popular para navegação no mundo da criptografia. Mas, você sabia que, se estiver trabalhando com qualquer solução de camada L2 baseada na Ethereum, eventualmente terá que interagir com a API do Etherscan para obter todos os detalhes sobre um determinado evento de cunhagem? Neste artigo, exploraremos como fazer exatamente isso na linguagem Rust. Quer você seja um profissional em cripto experiente ou esteja apenas começando, este guia o ajudará a aproveitar o poder das APIs do Etherscan e da ImmutableX, bem como o Rust, para seu próximo projeto.

Pré-requisitos

Estou usando a versão 1.69.0 do Rust. Este tutorial presume que você tenha conhecimento prévio de Rust e se concentra mais no uso prático da linguagem.

Usarei a vertente do projeto no qual estou trabalhando ativamente no momento - illuvi-analytics. As partes relevantes dele estarão descritas em trechos de código autossuficientes e você é convidado a conferir o projeto por inteiro.

Se você estiver curioso para saber sobre o que é esse projeto, por favor, verifique a seção de Background do meu tutorial anterior.

Existem 4 partes neste tutorial: Ethereum e ImmutableX, Explorando o etherscan.io e immutascan.io, Explorando as APIs do Etherscan e da ImmutableX e Codificação.

Ethereum e ImmutableX

Se você é bem letrado no armazenamento de dados da blockchain Ethereum e tem experiência prévia usando a API do Etherscan, sinta-se livre para seguir em frente para a seção de codificação. Caso contrário, me deixe explicar brevemente como a Ethereum utiliza um livro-razão distribuído para armazenar todas as transações na sua blockchain. Cada bloco contém um conjunto de transações validadas pelos nós da rede. Todas as transações incluem um endereço do remetente, um endereço do destinatário, um valor de Ether ou outros tokens que estão sendo transferidos e outros detalhes. Quando uma transação é enviada, ela é transmitida para a rede e validada por nós na rede, que executam cálculos matemáticos complexos para verificar a autenticidade e integridade da transação.

Uma vez que a transação é verificada e validada, a mesma é incluída no próximo bloco na cadeia. Cada bloco está conectado com um bloco anterior, formando uma cadeia de blocos, daí o termo “blockchain”.

A fim de alcançar transações mais rápidas e mais baratas, mantendo ainda assim o mesmo nível de segurança e descentralização que a rede Ethereum principal, uma solução de escalabilidade de camada 2, como a ImmutableX, foi desenvolvida. A ImmutableX utiliza uma tecnologia chamada zk-rollups - o ZK Rollup é uma solução de dimensionamento de camada 2 na qual todos os fundos são mantidos por um contrato inteligente na cadeia principal, enquanto a computação e o armazenamento são realizados off-chain. Para cada bloco de Rollup, uma prova de conhecimento zero de transição de estado (SNARK) é gerada e verificada pelo contrato da rede principal - para agrupar várias transações em uma única transação da cadeia principal da Ethereum, reduzindo taxas de gás e aumentando a taxa de transferência (ou rendimento) da transação. Quando um usuário inicia uma transação na ImmutableX, a transação é verificada pelos validadores da ImmutableX e depois incluída em um pacote zk-rollup. Esse pacote é então submetido à cadeia principal da Ethereum, onde é verificado e adicionado à blockchain Ethereum. A blockchain Ethereum registra o estado do pacote zk-rollup, que contém as mudanças de estado de todas as transações no pacote.

Por fim, também existem contratos inteligentes que são programas de computador autoexecutáveis armazenados na blockchain Ethereum. Quando um contrato é implantado na blockchain, é atribuído a ele um endereço exclusivo que pode ser usado para interagir com o mesmo. Depois de implantado, o código do contrato é imutável, o que significa que não pode ser alterado ou atualizado. Como os contratos são executados em uma rede de nós descentralizada, eles são transparentes e sem confiança. Qualquer pessoa pode ler o código de um contrato e verificar seu comportamento e, uma vez implantado, o contrato será executado exatamente como programado, sem a necessidade de qualquer autoridade central ou intermediário.

Agora que você tem uma breve introdução ao mundo das criptomoedas, vamos prosseguir para o próximo capítulo, onde examinaremos de forma mais profunda o etherscan.io e o immutascan.io para nos ajudar a entender melhor o conceito.

Explorando o etherscan.io e o immutascan.io

Como mencionado anteriormente, nosso objetivo é recuperar todas as informações relevantes sobre um evento de criação de terrenos virtuais Illuvium que ocorreu na plataforma ImmutableX. Felizmente, existe um site chamado immutascan.io que nos permite visualizar todos os tokens cunhados. Para manter as coisas simples, nos concentraremos em um NFT específico com o id de token 46378.

Para começar nossa busca pelos detalhes do evento de cunhagem, primeiro precisamos localizar a transação correspondente no Etherscan. Isso pode ser feito através da verificação da atividade da carteira que cunhou o NFT, o que pode ser encontrado nesta página. Especificamente, estamos procurando por uma transação bem sucedida que usa o método Buy L2. Encontramos apenas duas dessas transações, portanto, vamos aprofundar ainda mais nelas para ver qual é a certa.

Ao observar uma determinada transação, pode não ser aparente de forma imediata qual NFT foi adquirido. Para encontrar essa informação, você precisa localizar a seção “More Details” (Mais detalhes), abri-la e buscar pela seção “Input Data” (Dados de entrada). A decodificação dos dados de entrada revelará que a transação foi executada para a função Buy L2 com um valor de id de token tokenId de 46378. Essa é a informação exata que nós estávamos buscando.

Vamos dar uma olhada mais minuciosa nos detalhes da transação no Etherscan. Se você rolar um pouco para cima, verá dois outros campos chamados “ERC-20 Tokens Transferred” (Tokens ERC-20 transferidos) e “Value” (Valor). Nesta transação, o campo “Valor” mostra 0 ETH, enquanto o “Tokens ERC-20 transferidos” mostra 8,2217 sILV2. Está dessa forma porque, durante a venda de terra da Illuvium, os compradores tiveram a opção de utilizar tanto sILV2 quanto ETH para adquirir a terra. É importante manter isso em mente na busca pela API apropriada e escrita do código.

Observação! Apenas para referência, é assim que uma transação de EHT se parece.

Antes de seguirmos para as APIs, gostaria de falar brevemente sobre esses dados de entrada. Na Ethereum, os dados enviados junto com uma transação são codificados usando a codificação ABI (Application Binary Interface - Interface de aplicação binária). A seção de dados de uma entrada em uma transação representa os dados de chamada de função codificados, que incluem a assinatura da função e os argumentos da função. Como você já deve ter notado, uma versão codificada deste campo parece bastante ilegível:

Voltaremos a isso mais tarde na parte de Codificação, onde precisaremos decodificá-lo em Rust.

Agora que sabemos por quais dados procurar, vamos em frente e procurar as APIs correspondentes para recuperá-los.

Explorando as APIS do Etherscan e da ImmutableX

O motivo pelo qual precisamos explorar a API do Etherscan é que, no momento da escrita, a API da ImmutableX não oferece uma forma de buscar o preço de cunhagem. No entanto, para recuperar todos os ativos cunhados, ainda precisamos começar na ImmutableX. Com a ajuda da lista de APIs de cunhagem com o parâmetro “endereço de token” token_address especificado, podemos buscar todos os ativos cunhados. Isso servirá como um ponto de partida para a parte de Codificação.

Vamos aprofundar na API do Etherscan. Antes de começarmos, certifique-se de que você tenha criado uma conta e obtido sua chave de API. Depois disso, podemos começar a explorar os pontos de extremidade (endpoints) disponíveis. Porém, eu devo alertá-lo de que a estrutura da API do Etherscan pode ser um pouco contra-intuitiva. Por exemplo, você pode esperar encontrar operações relacionadas com transações na seção “Transactions” (Transações - que, por alguma razão, tem as estatísticas stats em seu URL), mas, na verdade, elas estão localizadas na seção “Accounts” (Contas).

Aqui vamos explorar duas APIs: ”Obter uma lista de Transações ‘normais’ através do endereço”, para quando o terreno for adquirido com ETH, e ”Obter uma lista de ‘ERC20 - Eventos de transferência de token’ através do endereço”, para quando o terreno for adquirido com sILV2. É importante notar que ambas as APIs são limitadas a 10.000 registros, que podem ser especificados com o parâmetro offset . Caso a resposta exceda esse limite, ela será paginada e poderá ser recuperada usando o parâmetro page. Além disso, o parâmetro de início de bloco (startblock) pode reduzir significativamente a sobrecarga de chamada da API no lado do servidor. Como sabemos que o contrato do terreno foi criado no bloco 14846665, ele deve ser especificado na chamada da API.

Agora que as APIs estão definidas, é hora de começar a codificação. Na próxima seção, vou traçar todas as especificidades de uso de ambas as APIs, juntamente com a implementação do Rust.

Codificação

Para começar, vamos esclarecer as tecnologias que serão empregadas nesta seção. O aplicativo executará no tempo de execução tokio, enquanto o crate reqwest com o recurso habilitado json lida com solicitações e respostas de processamento. Variáveis de ambiente relevantes serão recuperadas com dotenvy. E as solicitações serão executadas em paralelo com futures. Se você ainda não fez, recomendo dar uma olhada no meu tutorial anterior sobre Rust: Como consumir a API REST e manter os resultados em Postgres, onde eu mergulho no código com maior detalhamento.

Agora, vamos investigar as próprias APIs, começaremos com a ImmutableX. Como mencionado anteriormente, chamaremos a lista de cunhagem de API com o parâmetro especificado “endereço de token” token_address. Como temos algumas chamadas de API para fazer, as quais todas estão retornando um corpo JSON, vamos criar um script auxiliar para facilitar isso:

use serde::de::DeserializeOwned;

pub async fn fetch_single_api_response<T: DeserializeOwned>(endpoint: &str) -> reqwest::Result<T> {
    let result = reqwest::get(endpoint).await?.json::<T>().await?;
    return Ok(result);
}
Enter fullscreen mode Exit fullscreen mode

Pulando os detalhes minuciosos do contrato da API da ImmutableX, aqui está o modelo de cunhagem.

use serde::{Deserialize};

#[derive(Deserialize, Debug)]
pub struct Mint {
    pub result: Vec<TheResult>,
    pub cursor: String,
}

#[derive(Deserialize, Debug)]
pub struct TheResult {
    #[serde(rename = "timestamp")]
    pub minted_on: String,
    pub transaction_id: i32,
    pub status: String,
    #[serde(rename = "user")]
    pub wallet: String,
    pub token: Token,
}

#[derive(Deserialize, Debug)]
pub struct Token {
    #[serde(rename = "type")]
    pub the_type: String,
    pub data: Data,
}

#[derive(Deserialize, Debug)]
pub struct Data {
    pub token_id: String,
}
Enter fullscreen mode Exit fullscreen mode

Usaremos apenas dois campos dele - o usuário user (também conhecido como wallet) e o id do token token_id.

Pronto para buscar aquelas cunhagens? Apenas esteja ciente de que a API da ImmutableX utiliza paginação, portanto, para o bem da simplicidade, focaremos nas primeiras 200 respostas.

const MINTS_URL: &str = "https://api.x.immutable.com/v1/mints?token_address=0x9e0d99b864e1ac12565125c5a82b59adea5a09cd&page_size=200";

pub async fn read_mints() -> Option<Mint> {
    match api_utils::fetch_single_api_response::<Mint>(MINTS_URL).await {
        Ok(mints) => { Some(mints) }
        Err(e) => {
            error!("Error fetching mints {e}");
            None
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Embora não seja estritamente necessário, eu prefiro converter o resultado Result em uma opção Option aqui, já que mantém o tratamento de erros mais perto do ponto da chamada da função, ao invés de propagá-lo até o nível mais alto.

Observação! Em um aplicativo do mundo real, seria melhor armazenar toda a resposta da API em um banco de dados para minimizar o número de chamadas de uma terceira API.

Agora, com as buscas bem sucedidas de cunhagem da API da ImmutableX, podemos prosseguir para a parte mais interessante deste projeto: a utilização da API do Etherscan para recuperar o preço pago pelo comprador do terreno. Como um rápido lembrete, nós podemos apenas recuperar essas informações da blockchain de camada 1 (L1).

No capítulo anterior, estabelecemos que existem dois cenários possíveis a serem considerados: o cenário mais simples, quando o terreno for adquirido com ETH, e o cenário mais complexo, que requer uma chamada de API adicional quando o terreno for comprado com sILV2.

Mas ambos os cenários exigem a busca das transações primeiro, portanto, devemos começar definindo o modelo para esta chamada de API:

#[derive(Deserialize, Debug)]
pub struct Transaction {
    pub status: String,
    pub result: Option<Vec<TheResult>>,
}

#[derive(Deserialize, Debug)]
pub struct TheResult {
    pub hash: String,
    pub from: String,
    pub to: String,
    pub value: String,
    #[serde(rename = "isError")]
    pub is_error: String,
    pub input: String,
    #[serde(rename = "methodId")]
    pub method_id: String,
    #[serde(rename = "functionName")]
    pub function_name: String,
}
Enter fullscreen mode Exit fullscreen mode

Vamos examinar de perto o modelo de resposta para a API do Etherscan. Existem alguns campos importantes a serem considerados. Primeiramente, uma resposta bem sucedida é indicada por um valor de status igual a 1. Em segundo lugar, o campo “valor” - value - fornece o preço pago pela transação, excluindo o gás. Em terceiro lugar o campo “para” - to - mostra para onde a transação foi enviada. Por fim, o campo “entrada” - input - contém todas as informações necessárias sobre a chamada da função, que são os mesmos dados que vimos anteriormente na seção “Input Data” (Dados de entrada) no Etherscan. Especificamente, vamos extrair o id do token - token_id - desse campo.

Com o modelo de transação definido, é hora de seguir para a implementação. Vamos começar com o cenário mais simples, quando o terreno for comprado com ETH.

const LAND_CONTRACT: &str = "0x7a47f7707c4b2f2b1def04a47cd8681d48eadeb8";
const LAND_CONTRACT_CREATION_BLOCK: &str = "14846665";
const LAND_FUNCTION_NAME: &str = "buyL2";

pub async fn read_transactions() {
    let api_key = String::from("ETHERSCAN_API_KEY_VALUE");
    match mints_reader::read_mints().await {
        Some(mints) => {
            let mut futures = futures::stream::iter(mints.result)
                .map(|res| process_wallet(res.wallet, &api_key))
                .buffer_unordered(3);

            // espera tudo terminar 
            while let Some(_) = futures.next().await {}
        }
        _ => {}
    }
}

async fn process_wallet(wallet: String, api_key: &String) {
    let mut page = 1;
    loop {
        let transactions = fetch_transactions(wallet.clone(), api_key, page).await;
        if transactions.is_empty() {
            break;
        }

        for res in transactions {
            if res.is_error == "1" || res.to != LAND_CONTRACT || !res.function_name.contains(LAND_FUNCTION_NAME) {
                continue;
            }

            let input_to_decode = res.input.replace(res.method_id.as_str(), "");
            match decode_input_and_get_token_id(input_to_decode.as_str()) {
                Ok(token_id) => {
                    if res.value == "0" {
                        // O terreno foi comprado com sILV2
                    } else {
                        // O valor retornado pelo ponto de extremidade da API do Etherscan está em Wei, que é a menor unidade de ether
                        let wei_value = Decimal::from_str(res.value.as_str()).unwrap();
                        let ether_value = wei_value / Decimal::new(10i64.pow(18), 0);
                        // combina os token_ids e mantém o ether_value
                    }
                }
                Err(e) => { error!("Error decoding input {e}") }
            };
        }

        page += 1;
    }
}

async fn fetch_transactions(
    wallet: String,
    api_key: &String,
    page: i8,
) -> Vec<transaction::TheResult> {
    let endpoint = format!("https://api.etherscan.io/api?module=account&action=txlist&address={}&page={}&offset=10000&startblock={}&endblock=99999999&sort=asc&apikey={}",
                           wallet, page, LAND_CONTRACT_CREATION_BLOCK, api_key);
    return match api_utils::fetch_single_api_response::<transaction::Transaction>(endpoint.as_str()).await {
        Ok(transaction) => {
            if transaction.status == "1" {
                return transaction.result.unwrap();
            }
            return vec![];
        }
        _ => vec![]
    };
}

fn decode_input_and_get_token_id(input_to_decode: &str) -> Result<i32, Box<dyn std::error::Error>> {
    fn decode_hex(s: &str) -> Result<Vec<u8>, ParseIntError> {
        (0..s.len())
            .step_by(2)
            .map(|i| u8::from_str_radix(&s[i..i + 2], 16))
            .collect()
    }

    let input_decoded = decode_hex(input_to_decode)?;
    let input_params = vec![ParamType::Tuple(vec![ParamType::Uint(32), ParamType::Uint(32), ParamType::Uint(8), ParamType::Uint(16), ParamType::Uint(16), ParamType::Uint(8), ParamType::Uint(16)]), ParamType::FixedBytes(32)];
    let decoded_input = decode(&input_params, &input_decoded[..])?;
    let token_id = decoded_input[0]
        .clone()
        .into_tuple()
        .ok_or("Error: could not convert decoded input into tuple")?[0]
        .clone()
        .into_uint()
        .ok_or("Error: could not convert tuple element into uint")?
        .as_u32();
    Ok(token_id as i32)
}
Enter fullscreen mode Exit fullscreen mode

Deixe-me percorrer o código com você em detalhes. Primeiro, usamos o mint_reader criado anteriormente para buscar as cunhagens para processar em paralelo. Nós definimos o valor do buffer_unordered como 3, já que o Plano Gratuito de API do Etherscan limita as solicitações para 5 por segundo. Em seguida, consultamos a API página por página para a carteira fornecida, processando cada página à medida que avançamos. Quando o status da resposta não for 1, não há mais dados a serem buscados. Como eu mencionei anteriormente, nós definimos o parâmetro de início do bloco explicitamente para reduzir a carga no backend do Etherscan e obter uma resposta mais rápida.

Para cada transação, precisamos verificar três condições: ela deve ser bem sucedida (is_error != 1), deve ser enviada para o contrato do terreno e a função function_name deve ter o nome esperado. Depois, precisamos decodificar os dados de entrada para recuperar o id do token - token_id. Para fazer isso, é preciso conhecer a assinatura da função, que consiste no nome da função seguido dos tipos de seus argumentos em parênteses. Por exemplo, a assinatura da função para buyL2 é buyL2(tuple plotData,bytes32[] proof), onde o tuple tem os seguintes parâmetros: uint32,uint32,uint8,uint16,uint16,uint8,uint16). Você pode encontrar essa informação no site do Etherscan. Podemos usar as regras de codificação ABI para decodificar os dados de entrada, que especificam como representar diferentes tipos de dados como inteiros, strings, arrays e estruturas. Felizmente, não precisamos implementar essa parte por nós mesmos porque existe um crate com a funcionalidade requerida - ethabi. Nosso trabalho aqui é fornecer a ele o conjunto de dados correto e analisar a resposta para recuperar o id do token token_id. Finalmente, se o campo “valor” value não for 0, significa que o terreno foi adquirido com ETH e podemos combiná-lo com o id do token token_id e manter o preço.

Agora que implementamos o cenário mais simples com as transações em ETH, podemos cobrir aproximadamente 17% de todas as cunhagens do terreno. No entanto, nós não paramos por aqui. Vamos prosseguir para o cenário mais complexo em que precisamos chamar a API dos tokens. Para começar, definimos o modelo necessário como se segue:

#[derive(Deserialize, Debug)]
pub struct Token {
    pub status: String,
    pub result: Option<Vec<TheResult>>,
}

#[derive(Deserialize, Debug)]
pub struct TheResult {
    pub hash: String,
    pub value: String,
    #[serde(rename = "tokenSymbol")]
    pub token_symbol: String,
    #[serde(rename = "tokenDecimal")]
    pub token_decimal: String,
}
Enter fullscreen mode Exit fullscreen mode

Este modelo é mais simples em comparação com o modelo de transação - transaction, mas existe um detalhe importante a ser observado. O campo “valor” value retorna um número inteiro e a posição do ponto decimal é indicada em um campo separado chamado tokenDecimal. Para obter o valor real do preço, precisaremos efetuar algumas operações aritméticas. Vamos continuar e buscar os dados restantes.

async fn process_wallet(wallet: String, api_key: &String) {
    let mut page = 1;
    let mut transaction_to_token_id = HashMap::new();
    loop {
        let transactions = fetch_transactions(wallet.clone(), api_key, page).await;
        if transactions.is_empty() {
            break;
        }

        for res in transactions {
            if res.is_error == "1" || res.to != LAND_CONTRACT {
                continue;
            }

            let input_to_decode = res.input.replace(res.method_id.as_str(), "");
            match decode_input_and_get_token_id(input_to_decode.as_str()) {
                Ok(token_id) => {
                    if res.value == "0" {
                        transaction_to_token_id.insert(res.hash.clone(), token_id);
                    } else {
                        ...
                    }
                }
                Err(e) => { error!("Error decoding input {e}") }
            };
        }

        page += 1;
    }

    if !transaction_to_token_id.is_empty() {
        process_tokens(&wallet, transaction_to_token_id, api_key).await;
    }
}

async fn process_tokens(wallet: &String, transaction_to_token_id: HashMap<String, i32>, api_key: &String) {
    fn convert_into_value(value_str: &str, token_decimal_str: &str) -> f32 {
        let value = Decimal::from_str(value_str).unwrap();
        let token_decimal = Decimal::from_str(&format!("1{}", "0".repeat(token_decimal_str.parse::<u32>().unwrap().try_into().unwrap()))).unwrap();
        let f32_value = value / token_decimal;

        return f32_value.to_f32().unwrap();
    }

    let mut page = 1;
    loop {
        let tokens = fetch_tokens(wallet.clone(), api_key, page).await;
        if tokens.is_empty() {
            break;
        }

        for res in tokens {
            if let Some(token_id) = transaction_to_token_id.get(&res.hash) {
                let price = convert_into_value(res.value.as_str(), res.token_decimal.as_str());
                // combina os token_ids e mantém o preço
            }
        }

        page += 1;
    }
}

async fn fetch_tokens(
    wallet: String,
    api_key: &String,
    page: i8,
) -> Vec<token::TheResult> {
    let endpoint = format!("https://api.etherscan.io/api?module=account&action=tokentx&address={}&page={}&offset=10000&startblock={}&endblock=99999999&sort=asc&apikey={}",
                           wallet, page, LAND_CONTRACT_CREATION_BLOCK, api_key);
    return match api_utils::fetch_single_api_response::<token::Token>(endpoint.as_str()).await {
        Ok(token) => {
            if token.status == "1" {
                return token.result.unwrap();
            }
            return vec![];
        }
        _ => vec![]
    };
}
Enter fullscreen mode Exit fullscreen mode

Eu removi alguns códigos e forneci as partes relevantes para trazer clareza. Para minimizar as chamadas de API, salvamos os hashes de transação no token_ids, que precisamos buscar mais tarde. O uso desta API é semelhante ao da API de transação, portanto, as informações da seção anterior ainda são aplicáveis. Sempre que existe uma combinação de hash de transação, nós calculamos o preço na moeda especificada no tokenSymbol e o armazenamos. Com ambas as implementações no lugar, podemos cobrir todos os eventos de cunhagem para o primeiro lote de terrenos da Illuvium.

Observação! Na verdade, existem algumas transações faltando que eu não fui capaz de encontrar no Etherscan, se você estiver curioso para saber mais - confira este tópico do fórum.

A seguir

Parabéns, você chegou até o fim deste tutorial! Ao seguir a diante, você ganhou um entendimento sólido em como interagir com a blockchain Ethereum por meio das APIs do Etherscan e da ImmutableX e como extrair dados valiosos disso.

E com o poder do Rust, esta solução é capaz de processar um grande número de cunhagens de terrenos de forma eficiente e com precisão. Ao combinar os dados dessas duas APIs, conseguimos cobrir todos os cenários possíveis de aquisição de terrenos no primeiro lote da Illuvium Land.

Além do mais, essa implementação é apenas a ponta do iceberg quando se trata da utilização do poder do Rust e da tecnologia blockchain. Os recursos de segurança e desempenho do Rust o tornam uma linguagem ideal para desenvolvimento de aplicativos de blockchain robustos e protegidos. À medida que o ecossistema blockchain continuar a crescer e evoluir, o papel do Rust neste espaço se tornará indiscutivelmente mais proeminente.

Continue aprendendo, continue construindo e continue explorando o mundo emocionante da tecnologia blockchain. Suas possibilidades são infinitas.

Apoio

Se você gostou do conteúdo que leu e quer apoiar o autor - muito obrigado!

Aqui está minha carteira da Ethereum para gorjetas:

0xB34C2BcE674104a7ca1ECEbF76d21fE1099132F0

Esse artigo foi escrito por Pudding Entertainment e traduzido por Isabela Curado Nehme. Seu original pode ser lido aqui.


Image description

Top comments (0)