Pular para o conteúdo principal

Patrocinando taxas de gás: Como cobrir os custos de transação para seus usuários

sem gás
Solidity
eip-712
meta-transações
Intermediário
Ori Pomerantz
27 de fevereiro de 2026
11 minutos de leitura

Introdução

Se quisermos que o Ethereum atenda a mais um bilhão de pessoas (opens in a new tab), precisamos remover o atrito e torná-lo o mais fácil de usar possível. Uma fonte desse atrito é a necessidade de ETH para pagar as taxas de gás.

Se você tem um aplicativo descentralizado (dapp) que ganha dinheiro com os usuários, pode fazer sentido permitir que os usuários enviem transações através do seu servidor e você mesmo pague as taxas de transação. Como os usuários ainda assinam uma mensagem de autorização EIP-712 (opens in a new tab) em suas carteiras, eles mantêm as garantias de integridade do Ethereum. A disponibilidade depende do servidor que retransmite as transações, portanto, é mais limitada. No entanto, você pode configurar as coisas para que os usuários também possam acessar o contrato inteligente diretamente (se eles obtiverem ETH), e permitir que outros configurem seus próprios servidores se quiserem patrocinar transações.

A técnica neste tutorial só funciona quando você controla o contrato inteligente. Existem outras técnicas, incluindo a abstração de conta (opens in a new tab), que permitem patrocinar transações para outros contratos inteligentes, as quais espero cobrir em um tutorial futuro.

Nota: Este não é um código de nível de produção. Ele é vulnerável a ataques significativos e carece de recursos importantes. Saiba mais na seção de vulnerabilidades deste guia.

Pré-requisitos

Para entender este tutorial, você já precisa estar familiarizado com:

  • Solidity
  • JavaScript
  • React e WAGMI. Se você não estiver familiarizado com essas ferramentas de interface de usuário, temos um tutorial para isso.

O aplicativo de exemplo

O aplicativo de exemplo aqui é uma variante do contrato Greeter do Hardhat. Você pode vê-lo no GitHub (opens in a new tab). O contrato inteligente já está implantado na Sepolia (opens in a new tab), no endereço 0xC87506C66c7896366b9E988FE0aA5B6dDE77CFfA (opens in a new tab).

Para vê-lo em ação, siga estas etapas.

  1. Clone o repositório e instale o software necessário.

    1git clone https://github.com/qbzzt/260301-gasless.git
    2cd 260301-gasless/server
    3npm install
  2. Edite .env para definir PRIVATE_KEY para uma carteira que tenha ETH na Sepolia. Se você precisar de ETH da Sepolia, use um faucet. Idealmente, esta chave privada deve ser diferente daquela que você tem na carteira do seu navegador.

  3. Inicie o servidor.

    1npm run dev
  4. Navegue até o aplicativo na URL http://localhost:5173 (opens in a new tab).

  5. Clique em Connect with Injected para se conectar a uma carteira. Aprove na carteira e aprove a mudança para a Sepolia, se necessário.

  6. Escreva uma nova saudação e clique em Update greeting via sponsor.

  7. Assine a mensagem.

  8. Aguarde cerca de 12 segundos (o tempo de bloco na Sepolia). Enquanto espera, você pode olhar a URL no console do servidor para ver a transação.

  9. Veja que a saudação mudou e que o valor do endereço da última atualização agora é o endereço da carteira do seu navegador.

Para entender como isso funciona, precisamos ver como a mensagem é criada na interface do usuário, como ela é retransmitida pelo servidor e como o contrato inteligente a processa.

A interface do usuário

A interface do usuário é baseada no WAGMI (opens in a new tab); você pode ler sobre isso neste tutorial.

Veja como assinamos a mensagem:

1const signGreeting = useCallback(

O hook do React useCallback (opens in a new tab) nos permite melhorar o desempenho reutilizando a mesma função quando o componente é redesenhado.

1 async (greeting) => {
2 if (!account) throw new Error("Wallet not connected")

Se não houver conta, levante um erro. Isso nunca deve acontecer porque o botão da interface do usuário que inicia o processo que chama signGreeting é desativado nesse caso. No entanto, futuros programadores podem remover essa proteção, então é uma boa ideia verificar essa condição aqui também.

1 const domain = {
2 name: "Greeter",
3 version: "1",
4 chainId,
5 verifyingContract: contractAddr,
6 }

Parâmetros para o separador de domínio (opens in a new tab). Esse valor é constante, então, em uma implementação mais otimizada, poderíamos calculá-lo uma vez em vez de recalculá-lo cada vez que a função é chamada.

  • name é um nome legível pelo usuário, como o nome do dapp para o qual estamos produzindo assinaturas.
  • version é a versão. Versões diferentes não são compatíveis.
  • chainId é a cadeia que estamos usando, conforme fornecido pelo WAGMI (opens in a new tab).
  • verifyingContract é o endereço do contrato que verificará esta assinatura. Não queremos que a mesma assinatura se aplique a vários contratos, caso existam vários contratos Greeter e queiramos que eles tenham saudações diferentes.
1
2 const types = {
3 GreetingRequest: [
4 { name: "greeting", type: "string" },
5 ],
6 }

O tipo de dados que assinamos. Aqui, temos um único parâmetro, greeting, mas sistemas da vida real normalmente têm mais.

1 const message = { greeting }

A mensagem real que queremos assinar e enviar. greeting é tanto o nome do campo quanto o nome da variável que o preenche.

1 const signature = await signTypedDataAsync({
2 domain,
3 types,
4 primaryType: "GreetingRequest",
5 message,
6 })

Obtém a assinatura de fato. Esta função é assíncrona porque os usuários levam muito tempo (da perspectiva de um computador) para assinar dados.

1 const r = `0x${signature.slice(2, 66)}`
2 const s = `0x${signature.slice(66, 130)}`
3 const v = parseInt(signature.slice(130, 132), 16)
4
5 return {
6 req: { greeting },
7 v,
8 r,
9 s,
10 }
11 },

A função retorna um único valor hexadecimal. Aqui nós o dividimos em campos.

1 [account, chainId, contractAddr, signTypedDataAsync],
2)

Se alguma dessas variáveis mudar, crie uma nova instância da função. Os parâmetros account e chainId podem ser alterados pelo usuário na carteira. contractAddr é uma função do Id da cadeia. signTypedDataAsync não deve mudar, mas nós o importamos de um hook (opens in a new tab), então não podemos ter certeza, e é melhor adicioná-lo aqui.

Agora que a nova saudação está assinada, precisamos enviá-la ao servidor.

1 const sponsoredGreeting = async () => {
2 try {

Esta função pega uma assinatura e a envia para o servidor.

1 const signedMessage = await signGreeting(newGreeting)
2 const response = await fetch("/server/sponsor", {

Envia para o caminho /server/sponsor no servidor de onde viemos.

1 method: "POST",
2 headers: { "Content-Type": "application/json" },
3 body: JSON.stringify(signedMessage),
4 })

Usa POST para enviar as informações codificadas em JSON.

1 const data = await response.json()
2 console.log("Server response:", data)
3 } catch (err) {
4 console.error("Error:", err)
5 }
6 }

Exibe a resposta. Em um sistema de produção, também mostraríamos a resposta ao usuário.

O servidor

Gosto de usar o Vite (opens in a new tab) como meu front-end. Ele serve automaticamente as bibliotecas React e atualiza o navegador quando o código do front-end muda. No entanto, o Vite não inclui ferramentas de back-end.

A solução está em index.js (opens in a new tab).

1 app.post("/server/sponsor", async (req, res) => {
2 ...
3 })
4
5 // Deixe o Vite cuidar de todo o resto
6 const vite = await createViteServer({
7 server: { middlewareMode: true }
8 })
9
10 app.use(vite.middlewares)

Primeiro, registramos um manipulador para as solicitações que nós mesmos lidamos (POST para /server/sponsor). Em seguida, criamos e usamos um servidor Vite para lidar com todas as outras URLs.

1 app.post("/server/sponsor", async (req, res) => {
2 try {
3 const signed = req.body
4
5 const txHash = await sepoliaClient.writeContract({
6 address: greeterAddr,
7 abi: greeterABI,
8 functionName: 'sponsoredSetGreeting',
9 args: [signed.req, signed.v, signed.r, signed.s],
10 })
11 } ...
12 })

Esta é apenas uma chamada padrão de blockchain do viem (opens in a new tab).

O contrato inteligente

Finalmente, Greeter.sol (opens in a new tab) precisa verificar a assinatura.

1 constructor(string memory _greeting) {
2 greeting = _greeting;
3
4 DOMAIN_SEPARATOR = keccak256(
5 abi.encode(
6 keccak256(
7 "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
8 ),
9 keccak256(bytes("Greeter")),
10 keccak256(bytes("1")),
11 block.chainid,
12 address(this)
13 )
14 );
15 }

O construtor cria o separador de domínio (opens in a new tab), semelhante ao código da interface do usuário acima. A execução na blockchain é muito mais cara, então nós o calculamos apenas uma vez.

1 struct GreetingRequest {
2 string greeting;
3 }

Esta é a estrutura que é assinada. Aqui temos apenas um campo.

1 bytes32 private constant GREETING_TYPEHASH =
2 keccak256("GreetingRequest(string greeting)");

Este é o identificador de estrutura (opens in a new tab). Ele é calculado a cada vez na interface do usuário.

1 function sponsoredSetGreeting(
2 GreetingRequest calldata req,
3 uint8 v,
4 bytes32 r,
5 bytes32 s
6 ) external {

Esta função recebe uma solicitação assinada e atualiza a saudação.

1 // Calcular o digest EIP-712
2 bytes32 digest = keccak256(
3 abi.encodePacked(
4 "\x19\x01",
5 DOMAIN_SEPARATOR,
6 keccak256(
7 abi.encode(
8 GREETING_TYPEHASH,
9 keccak256(bytes(req.greeting))
10 )
11 )
12 )
13 );

Cria o digest de acordo com a EIP 712 (opens in a new tab).

1 // Recuperar signatário
2 address signer = ecrecover(digest, v, r, s);
3 require(signer != address(0), "Invalid signature");

Usa ecrecover (opens in a new tab) para obter o endereço do signatário. Note que uma assinatura ruim ainda pode resultar em um endereço válido, apenas um aleatório.

1 // Aplicar saudação como se o signatário a tivesse chamado
2 greeting = req.greeting;
3 emit SetGreeting(signer, req.greeting);
4 }

Atualiza a saudação.

Vulnerabilidades

Este não é um código de nível de produção. Ele é vulnerável a ataques significativos e carece de recursos importantes. Aqui estão alguns, juntamente com como resolvê-los.

Para ver alguns desses ataques, clique nos botões sob o título Attacks e veja o que acontece. Para o botão Invalid signature, verifique o console do servidor para ver a resposta da transação.

Negação de serviço no servidor

O ataque mais fácil é um ataque de negação de serviço (opens in a new tab) no servidor. O servidor recebe solicitações de qualquer lugar da Internet e, com base nessas solicitações, envia transações. Não há absolutamente nada que impeça um invasor de emitir um monte de assinaturas, válidas ou inválidas. Cada uma causará uma transação. Eventualmente, o servidor ficará sem ETH para pagar pelo gás.

Uma solução para esse problema é limitar a taxa a uma transação por bloco. Se o objetivo é mostrar saudações para contas de propriedade externa, não importa qual seja a saudação no meio do bloco de qualquer maneira.

Outra solução é rastrear os endereços e permitir apenas assinaturas de clientes válidos.

Assinaturas de saudação incorretas

Quando você clica em Signature for wrong greeting, você envia uma assinatura válida para um endereço específico (0xaA92c5d426430D4769c9E878C1333BDe3d689b3e) e saudação (Hello). Mas ele a envia com uma saudação diferente. Isso confunde o ecrecover, que altera a saudação, mas tem o endereço errado.

Para resolver esse problema, adicione o endereço à estrutura assinada (opens in a new tab). Dessa forma, o endereço aleatório do ecrecover não corresponderá ao endereço na assinatura, e o contrato inteligente rejeitará a mensagem.

Ataques de repetição

Quando você clica em Replay attack, você envia a mesma assinatura "Eu sou 0xaA92c5d426430D4769c9E878C1333BDe3d689b3e, e eu gostaria que a saudação fosse Hello", mas com a saudação correta. Como resultado, o contrato inteligente acredita que o endereço (que não é seu) mudou a saudação de volta para Hello. As informações para fazer isso estão disponíveis publicamente nas informações da transação (opens in a new tab).

Se isso for um problema, uma solução é adicionar um nonce (opens in a new tab). Tenha um mapeamento (opens in a new tab) entre endereços e números, e adicione um campo nonce à assinatura. Se o campo nonce corresponder ao mapeamento para o endereço, aceite a assinatura e incremente o mapeamento para a próxima vez. Se não corresponder, rejeite a transação.

Outra solução é adicionar um carimbo de data/hora aos dados assinados e aceitar a assinatura como válida apenas por alguns segundos após esse carimbo de data/hora. Isso é mais simples e barato, mas corremos o risco de ataques de repetição dentro da janela de tempo e da falha de transações legítimas se a janela de tempo for excedida.

Outros recursos ausentes

Existem recursos adicionais que adicionaríamos em um ambiente de produção.

Acesso de outros servidores

Atualmente, permitimos que qualquer endereço envie um sponsorSetGreeting. Isso pode ser exatamente o que queremos, no interesse da descentralização. Ou talvez queiramos garantir que as transações patrocinadas passem pelo nosso servidor, caso em que verificaríamos msg.sender no contrato inteligente.

De qualquer forma, essa deve ser uma decisão de design consciente, não apenas o resultado de não pensar sobre o problema.

Tratamento de erros

Um usuário envia uma saudação. Talvez ela seja atualizada no próximo bloco. Talvez não. Os erros são invisíveis. Em um sistema de produção, o usuário deve ser capaz de distinguir entre esses casos:

  • A nova saudação ainda não foi enviada
  • A nova saudação foi enviada e está em processamento
  • A nova saudação foi rejeitada

Conclusão

Neste ponto, você deve ser capaz de criar uma experiência sem gás para os usuários do seu dapp, ao custo de alguma centralização.

No entanto, isso só funciona com contratos inteligentes que suportam ERC-712. Para transferir um token ERC-20, por exemplo, é necessário que a transação seja assinada pelo proprietário em vez de apenas uma mensagem. A solução é a abstração de conta (ERC-4337) (opens in a new tab). Espero escrever um tutorial futuro sobre isso.

Veja aqui mais do meu trabalho (opens in a new tab).

Última atualização da página: 3 de março de 2026

Este tutorial foi útil?