Permitindo que seus usuários sem gás mantenham tokens e chamem contratos
Introdução
Um artigo anterior discutiu o uso de acesso sem gás à sua própria aplicação usando assinaturas EIP-712, mas isso é limitado aos seus próprios contratos inteligentes. Usando a abstração de conta, podemos criar carteiras de contratos inteligentes que aceitam dois tipos de transações e as retransmitem para um destino solicitado:
- Transações enviadas por uma conta de propriedade externa (EOA) específica (o que exige que essa EOA tenha ETH)
- Transações enviadas de qualquer lugar, mas assinadas pela mesma EOA.
Dessa forma, podemos fornecer uma maneira sem gás para uma conta manter ativos (tokens, etc.) e executar todas as funções que uma EOA com gás pode.
Por que não podemos simplesmente retransmitir a solicitação?
No ERC-20 e em padrões relacionados, o proprietário da conta é msg.sender (opens in a new tab), o endereço que chamou o contrato do token, que não é necessariamente o originador da transação, tx.origin (opens in a new tab). Isso é exigido por motivos de segurança (opens in a new tab). Isso significa que, se retransmitirmos solicitações de transferência de tokens, elas tentarão transferir tokens do endereço do retransmissor em vez de um endereço controlado pelo usuário.
Existe uma solução que permite usar o endereço da EOA via EIP-7702 (opens in a new tab), mas ela exige a assinatura de uma delegação potencialmente perigosa, então você só pode usá-la para delegar a um contrato inteligente que o provedor da carteira aprova. Para este tutorial, prefiro o método muito mais simples de criar um contrato inteligente como um proxy para o usuário.
Vendo em ação
-
Certifique-se de ter tanto o Node (opens in a new tab) quanto o Foundry (opens in a new tab).
-
Clone a aplicação e instale o software necessário.
git clone https://github.com/qbzzt/260315-gasless-tokens.git cd 260315-gasless-tokens forge build cd server npm install -
Edite
.envpara definirSEPOLIA_PRIVATE_KEYpara uma carteira que tenha ETH na Sepolia. Se você precisar de ETH da Sepolia, use um faucet para obtê-lo. Idealmente, essa chave privada deve ser diferente daquela que você tem na carteira do seu navegador. -
Inicie o servidor.
npm run dev -
Navegue até a aplicação na URL
http://localhost:5173(opens in a new tab). -
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.
-
Role para baixo e clique em Deploy UserProxy (slow process).
-
Você pode ver quando o proxy do usuário é implantado porque há um endereço ao lado de UserProxy access. Se você esperou 24 segundos (2 blocos) e isso ainda não aconteceu, pode haver um problema com a detecção de alterações.
Se for esse o caso, vá para o explorador de blocos da Sepolia (opens in a new tab) e insira o hash da transação de implantação que você vê na saída do servidor em
npm run dev. Clique no contrato criado para ver seu endereço e, em seguida, copie-o. Cole o endereço no campo Or enter existing proxy address e clique em Set proxy address. -
Clique em Request more tokens for proxy para enviar uma chamada à função
faucet(opens in a new tab) do contrato ERC-20 para obter tokens. Confirme a assinatura na carteira. Obviamente, os tokens chegam ao endereço do proxy, não ao do usuário. -
Role para baixo e clique no link em Last transaction:. Isso abrirá o navegador para mostrar a transação
faucet. -
Em amount to transfer, insira um número entre um e mil. Clique em Transfer para transferir os tokens para o seu próprio endereço. Antes de clicar em Confirm para a solicitação, observe que os dados sendo assinados são opacos. Os usuários teriam dificuldade em entender o que estão assinando. Lembre-se de que discutiremos isso abaixo.
-
Após a transação ser confirmada, espere para ver a mudança tanto em your balance quanto em proxy balance. Observe que isso também levará algum tempo, porque a Sepolia tem um tempo de bloco de 12 segundos.
Como funciona
Para uma experiência sem gás, precisamos de uma interface de usuário para o usuário, um servidor para rotear mensagens da interface de usuário para a cadeia e um contrato inteligente para recebê-las e verificá-las.
O contrato inteligente da carteira
Este é o contrato inteligente (opens in a new tab). Seu objetivo é fazer o que o verdadeiro proprietário solicitar, independentemente do canal usado para solicitá-lo, e ignorar todo o resto. Para fazer isso, suas funções recebem um endereço de destino para chamar e os dados a serem usados para chamá-lo.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;
contract UserProxy {
address immutable OWNER;
uint public nonce = 0;
A identidade do proprietário e um nonce (opens in a new tab) para evitar que as mensagens sejam repetidas. Como o nonce é uma variável public, o compilador Solidity também cria uma função de visualização (view), nonce() (opens in a new tab), que permite que o código offchain leia seu valor.
bytes32 private constant SIGNED_ACCESS_TYPEHASH =
keccak256("SignedAccess(address target,bytes data,uint256 nonce)");
bytes32 private constant SIGNED_ACCESS_PAYABLE_TYPEHASH =
keccak256("SignedAccessPayable(address target,bytes data,uint256 nonce,uint256 value)");
bytes32 immutable DOMAIN_SEPARATOR;
As informações necessárias para verificar as assinaturas EIP-712 (opens in a new tab).
constructor(address owner_) {
OWNER = owner_;
Um UserProxy está vinculado a um único endereço de proprietário. Isso é necessário porque ele pode possuir ativos (tokens ERC-20, NFTs, etc.). Não queremos misturar ativos pertencentes a proprietários diferentes.
DOMAIN_SEPARATOR = keccak256(
abi.encode(
keccak256(
"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
),
keccak256(bytes("UserProxy")),
keccak256(bytes("1")),
block.chainid,
address(this)
)
);
}
O separador de domínio (opens in a new tab). Ele não pode ser calculado em tempo de compilação, porque depende do ID da cadeia e do endereço do contrato. Isso torna impossível que um UserProxy seja enganado por uma mensagem preparada para outro.
event CallResult(address target, bytes returnData);
Fazer o log dos resultados de uma chamada.
function directAccess(address target, bytes calldata data)
external returns (bytes memory) {
Esta função pode ser chamada diretamente pelo proprietário. Se não houver retransmissores disponíveis, o proprietário ainda poderá acessar os ativos diretamente na blockchain (se o usuário tiver ETH).
require(msg.sender == OWNER, "Only owner can call");
(bool success, bytes memory returnData) = target.call(data);
require(success, "Call failed");
emit CallResult(target, returnData);
return returnData;
}
Se formos chamados diretamente pelo proprietário, chame o destino com os dados de chamada (calldata) fornecidos.
function signedAccess(
address target,
bytes calldata data,
uint8 v,
bytes32 r,
bytes32 s)
Esta é a função principal do UserProxy. Ela obtém target e data, bem como uma assinatura.
external returns (bytes memory) {
// Calcular o digest EIP-712
bytes32 digest = keccak256(
abi.encodePacked(
"\x19\x01",
DOMAIN_SEPARATOR,
keccak256(
abi.encode(
SIGNED_ACCESS_TYPEHASH,
target,
keccak256(data),
nonce
)
)
)
);
O resumo (digest) também inclui o nonce, mas não precisamos recebê-lo da transação; já sabemos o valor correto. Uma assinatura com o nonce errado será rejeitada.
// Recuperar signatário
address signer = ecrecover(digest, v, r, s);
require(signer == OWNER, "Signature invalid or not by owner");
Se a assinatura for inválida, ecrecover geralmente retornará um endereço diferente e não será aceita.
(bool success, bytes memory returnData) = target.call(data);
require(success, "Call failed");
Chame o contrato que o usuário nos disse para chamar e reverta se não for bem-sucedido.
emit CallResult(target, returnData);
nonce++; // Incrementar o nonce para evitar repetição
return returnData;
}
Se for bem-sucedido, emita um evento de log e incremente o nonce.
function directAccessPayable(address target, uint value, bytes calldata data)
external payable returns (bytes memory) {
.
.
.
}
function signedAccessPayable(
.
.
.
}
}
Estas são variantes quase idênticas que permitem que você também transfira ETH para fora do contrato.
O retransmissor
O retransmissor é um componente de servidor. Ele é escrito em JavaScript; você pode ver o código-fonte aqui (opens in a new tab).
import express from "express";
import { createServer as createViteServer } from "vite";
import { createWalletClient, createPublicClient, http } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { sepolia } from 'viem/chains'
import dotenv from 'dotenv'
As bibliotecas de que precisamos. Este é um servidor Express (opens in a new tab), que usa o Vite (opens in a new tab) para servir o código da interface de usuário. Usamos o Viem (opens in a new tab) para nos comunicarmos com a blockchain e o dotenv (opens in a new tab) para ler a chave privada do endereço que envia a transação.
import { createRequire } from 'module'
const require = createRequire(import.meta.url)
const UserProxy = require('../contracts/out/UserProxy.sol/UserProxy.json')
Esta é uma maneira simples de ler o UserProxy compilado. Precisamos da ABI para poder chamar o UserProxy e do código compilado para poder implantá-lo para um usuário.
dotenv.config()
const sepoliaAccount = privateKeyToAccount(process.env.SEPOLIA_PRIVATE_KEY)
console.log("Using account:", sepoliaAccount.address)
Leia o arquivo .env, extraia o endereço e imprima-o no console.
const sepoliaClient = createWalletClient({
account: sepoliaAccount,
chain: sepolia,
transport: http("https://rpc.sentio.xyz/sepolia"),
})
const publicClient = createPublicClient({
chain: sepolia,
transport: http(),
})
Os clientes Viem que se comunicam com a blockchain.
const start = async () => {
const app = express()
Execute um servidor Express.
app.use(express.json())
Diga ao Express para ler o corpo da solicitação e, se for JSON, analisá-lo.
app.post("/server/deploy", async (req, res) => {
Este é o código que lida com as solicitações para implantar o proxy. Observe que somos vulneráveis a ataques de negação de serviço (opens in a new tab) aqui porque um invasor pode nos enviar spam com solicitações para implantar o proxy até que nosso ETH se esgote. Em um sistema de produção, provavelmente exigiríamos que a solicitação para implantar o proxy fosse assinada e que o signatário fosse um cliente existente.
try {
const ownerAddress = req.body.ownerAddress
Obtenha o endereço do proprietário a partir da solicitação.
const txHash = await sepoliaClient.deployContract({
abi: UserProxy.abi,
bytecode: UserProxy.bytecode.object,
args: [ownerAddress],
account: sepoliaAccount,
})
console.log("Deployment transaction hash:", txHash)
const receipt = await publicClient.waitForTransactionReceipt({
hash: txHash,
})
Implante o contrato (opens in a new tab) e espere até que ele seja implantado (opens in a new tab).
res.json({ contractAddress: receipt.contractAddress })
Se tudo estiver bem, retorne o endereço do proxy para a interface de usuário.
} catch (err) {
console.error(err)
res.status(500).json({ error: err.message })
}
})
Se houver um problema, relate-o.
app.post("/server/message", async (req, res) => {
Este é o código que processa as mensagens do usuário para o contrato UserProxy. Este é outro ponto vulnerável a um ataque de negação de serviço.
try {
const { proxy, target, data, v, r, s } = req.body
const txHash = await sepoliaClient.writeContract({
address: proxy,
abi: UserProxy.abi,
functionName: 'signedAccess',
args: [target, data, v, r, s],
account: sepoliaAccount,
})
Obtenha os dados da solicitação e use-os para chamar signedAccess no proxy.
console.log("Message transaction hash:", txHash)
res.json({ txHash })
Relate o hash da transação. Isso permite que a interface de usuário exiba uma URL para o usuário verificar a transação.
} catch (err) {
console.error(err)
res.status(500).json({ error: err.message })
}
})
Novamente, se houver um problema, relate-o.
// Deixe o Vite lidar com todo o resto
const vite = await createViteServer({
server: { middlewareMode: true }
})
app.use(vite.middlewares)
app.listen(5173, () => {
console.log("Dev server running on http://localhost:5173");
})
}
start()
Para todo o resto, use o Vite, que cuida de servir a interface de usuário para nós.
Interface de usuário
Este é o código da interface de usuário (opens in a new tab). A maior parte do código é quase idêntica à documentada neste artigo, com exceção de Token.jsx (opens in a new tab).
Partes de Token.jsx (opens in a new tab) são semelhantes a Greeter.jsx (opens in a new tab) neste artigo. Aqui estão as partes novas.
import {
encodeFunctionData
} from 'viem'
Esta função (opens in a new tab) cria os dados de chamada (calldata) para uma chamada de função da EVM. Isso é necessário para que o usuário possa assinar os dados de chamada.
import UserProxy from '../../contracts/out/UserProxy.sol/UserProxy.json'
O UserProxy, explicado acima.
import Erc20 from '../../contracts/out/Faucet.sol/FaucetToken.json'
Este contrato (opens in a new tab) é em grande parte um contrato ERC-20 normal, com a adição de uma função importante, faucet(). Esta função concede tokens a qualquer pessoa que os solicite para fins de teste.
const erc20Addrs = {
// Sepolia
11155111: '0x4cBedDEDA88fDd9e116618a5cD71BB0E440C2A78'
}
O endereço para FaucetToken.
const Address = ({ address }) => {
if (!address) return null
return (
<a href={`https://eth-sepolia.blockscout.com/address/${address}?tab=read_write_contract`} target="_blank">{address}</a>
)
}
Este componente gera um endereço com um link para o contrato em um explorador de blocos.
const Token = () => {
...
Este é o componente principal que faz a maior parte do trabalho.
const [ balanceAmount, setBalanceAmount ] = useState("Loading...")
O saldo de tokens do endereço do usuário.
const [ proxyAddr, setProxyAddr ] = useState(null)
O endereço de um proxy de propriedade do usuário.
const [ proxyBalanceAmount, setProxyBalanceAmount ] = useState("Loading...")
O saldo de tokens do proxy.
const [ newProxyAddr, setNewProxyAddr ] = useState("")
Este campo é usado quando o usuário define manualmente o endereço do proxy. Ter a capacidade de definir o endereço do proxy manualmente permite que o usuário use um proxy existente em vez de implantar um novo a cada vez (e perder todos os tokens de propriedade do proxy antigo).
const [ txHash, setTxHash ] = useState(null)
O hash da última transação, usado para mostrar um link para o explorador para que o usuário possa verificar essa transação.
const [ transferToken, setTransferToken ] = useState("")
const [ transferAmount, setTransferAmount ] = useState("")
const [ transferTo, setTransferTo ] = useState("")
Todos esses campos são usados para enviar comandos de transferência de tokens para um contrato ERC-20. Este pode ser o FaucetToken, mas não precisa ser. A função transfer faz parte do padrão ERC-20.
const balance = useReadContract({
...
})
const proxyBalance = useReadContract({
...
})
Leia os dois saldos de tokens nos quais estamos interessados: quanto o usuário possui e quanto o proxy possui.
const nonce = useReadContract({
address: proxyAddr,
abi: UserProxy.abi,
functionName: 'nonce',
args: [],
})
Para evitar ataques de repetição (por exemplo, um vendedor repetindo uma transação que lhe dá dinheiro), usamos um nonce (opens in a new tab). Precisamos saber o valor atual para adicioná-lo aos dados que assinamos.
useEffect(() => {
if (balance?.status === "success")
setBalanceAmount(balance.data / 10n**18n)
else
setBalanceAmount("Loading...")
}, [balance])
useEffect(() => {
if (proxyBalance?.status === "success")
setProxyBalanceAmount(proxyBalance.data / 10n**18n)
else
setProxyBalanceAmount("Loading...")
}, [proxyBalance])
Use useEffect (opens in a new tab) para atualizar o saldo exibido ao usuário quando as informações lidas da blockchain mudarem.
useEffect(() => {
setTransferToken(faucetAddr)
}, [faucetAddr])
useEffect(() => {
setTransferTo(account.address)
}, [account.address])
O padrão é transferir tokens FaucetToken para a própria conta do usuário. Aqui definimos esses valores quando os recebemos do Viem.
const proxyAddressChange = (evt) => setNewProxyAddr(evt.target.value)
const transferTokenChange = (evt) => setTransferToken(evt.target.value)
const transferToChange = (evt) => setTransferTo(evt.target.value)
const transferAmountChange = (evt) => setTransferAmount(evt.target.value)
Manipuladores de eventos para quando os campos de texto mudam.
const deployUserProxy = async () => {
try {
const response = await fetch("/server/deploy", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ownerAddress: account.address })
})
const data = await response.json()
setProxyAddr(data.contractAddress)
} catch (err) {
console.error("Error:", err)
}
}
Peça ao servidor para implantar um proxy para este usuário.
const signMessage = async(proxyAddr, target, calldata) => {
Assine uma mensagem antes de enviá-la ao servidor para enviar ao UserProxy onchain. Isso é explicado aqui. Precisamos assinar uma mensagem com o endereço de destino (o endereço do token que estamos chamando) e os dados de chamada a serem enviados.
const domain = {
.
.
.
return {v, r, s}
}
const messageUserProxy = async (proxy, target, data, v, r, s) => {
Envie uma mensagem assinada para o UserProxy, que verificará a assinatura e, em seguida, a enviará para o target.
try {
const response = await fetch("/server/message", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
proxy, target, // ambos os endereços
data, // dados de chamada para enviar ao alvo
v, r, s // assinatura
})
})
const serverResponse = await response.json()
setTxHash(serverResponse.txHash)
} catch (err) {
console.error("Error:", err)
}
}
Envie uma solicitação ao servidor e, ao receber a resposta, obtenha o hash da transação.
const faucetSimulation = useSimulateContract({
address: faucetAddr,
abi: Erc20.abi,
functionName: 'faucet',
account: account.address
})
Simule a chamada da função faucet. Só habilitamos o botão do faucet se isso for bem-sucedido.
const proxyFaucet = async () => {
const calldata = encodeFunctionData({
abi: Erc20.abi,
functionName: 'faucet',
args: [],
})
const {v, r, s} = await signMessage(proxyAddr, calldata)
messageUserProxy(proxyAddr, faucetAddr, calldata, v, r, s)
}
const proxyTransfer = async () => {
const calldata = encodeFunctionData({
abi: Erc20.abi,
functionName: 'transfer',
args: [transferTo, BigInt(transferAmount) * 10n**18n],
})
const {v, r, s} = await signMessage(proxyAddr, transferToken, calldata)
messageUserProxy(proxyAddr, transferToken, calldata, v, r, s)
}
Para chamar uma função através do servidor e do UserProxy, seguimos três etapas:
-
Crie os dados de chamada para assinar e enviar usando
encodeFunctionData(opens in a new tab). -
Assine a mensagem (endereço de destino, dados de chamada e nonce).
-
Envie a mensagem para o servidor.
return (
<>
<div align="left">
<h2>Token</h2>
<h4>Token contract address <Address address={faucetAddr} /></h4>
<hr />
<h4>Direct access (as <Address address={account?.address} />)</h4>
Your balance: {balanceAmount}
<br />
<button disabled={!faucetSimulation.data}
onClick={() => writeContract(
faucetSimulation.data.request
)}
>
Request more tokens
</button>
<hr />
Esta parte do componente permite que você use o FaucetToken diretamente do navegador. Seu objetivo principal é facilitar a depuração.
<h4>UserProxy access <Address address={proxyAddr} /></h4>
<button onClick={deployUserProxy}>
Deploy UserProxy (slow process)
</button>
Permita que o usuário implante um novo UserProxy.
<br /><br />
<input type="text" placeholder="Ou insira um endereço de proxy existente" value={newProxyAddr} onChange={proxyAddressChange} />
<br /><br />
<button
onClick={() => setProxyAddr(newProxyAddr)}
disabled={newProxyAddr.match(/^0x[a-fA-F0-9]{40}$/) === null}
>
Set proxy address
</button>
Só permita que os usuários cliquem em Set proxy address quando inserirem um endereço legítimo. Observe que isso não garante que o endereço em questão seja de fato um contrato UserProxy. É possível adicionar essa verificação, mas ela será muito mais lenta (pior experiência do usuário) e não melhorará a segurança (os invasores sempre podem usar seu próprio código para a interface de usuário).
<br /><br />
{ proxyAddr && (
Mostre o restante apenas se houver um endereço de proxy legítimo.
<>
Proxy balance: {proxyBalanceAmount}
<br />
Proxy nonce: {nonce?.data?.toString() ?? "Loading..."}
O usuário não precisa saber o nonce; isso é apenas para fins de depuração.
<br />
<button disabled={!proxyAddr || proxyAddr === "Loading..." || nonce?.status !== 'success'}
onClick={proxyFaucet}
>
Request more tokens for proxy
</button>
Não podemos simular uma chamada para faucet() através do proxy. No entanto, podemos pelo menos garantir que temos um proxy e que o proxy nos relatou um nonce.
<hr />
<h4>Transfer tokens from proxy</h4>
<ul>
<li> Token to transfer: <input type="text" placeholder="Token to transfer" value={transferToken} onChange={transferTokenChange} /> </li>
<li> Recipient address: <input type="text" placeholder="Recipient address" value={transferTo} onChange={transferToChange} /> </li>
<li> Amount to transfer: <input type="number" placeholder="Amount to transfer" value={transferAmount} onChange={transferAmountChange} /> </li>
</ul>
<button disabled={!proxyAddr || proxyAddr === "Loading..." || nonce?.status !== 'success'}
onClick={proxyTransfer}
>
Transfer
</button>
</>
)}
Permita que o usuário emita transações de transferência ERC-20.
<hr />
{ txHash && (
<>
<h4>Last transaction:</h4>
<a href={`https://eth-sepolia.blockscout.com/tx/${txHash}`} target="_blank">
{txHash}
</a>
</>
)}
Se houver um hash da última transação, mostre um link para que o usuário possa visualizá-lo em um explorador de blocos.
</div>
</>
)
}
export {Token}
Isso é apenas código clichê (boilerplate) do React.
Vulnerabilidades
Nosso servidor é vulnerável a ataques de negação de serviço. Esse ataque é explicado no artigo anterior da série.
Além disso, estamos incentivando o mau comportamento do usuário. Isto é o que pedimos ao usuário para assinar:
Nós sabemos que esta é uma transferência ERC-20 legítima para o token, valor e endereço de destino que o usuário deseja transferir. Mas a maioria dos usuários não sabe como interpretar dados de chamada e não tem ideia do que está assinando. Isso é um design ruim, por dois motivos:
- Alguns usuários não nos usarão porque não confiam nos dados que pedimos para eles assinarem.
- Outros usuários vão confiar em nós e aprender que devem apenas assinar os dados de chamada sem entender o que são. Isso significa que, se um invasor conseguir redirecioná-los para o site dele, ele poderá fazer com que assinem uma transação que lhe conceda todo o USDC (ou DAI, ou qualquer outro ERC-20) que o usuário possui.
A solução é ter funções separadas no UserProxy para funções comumente usadas, como transferência. Assim, os usuários podem assinar algo que entendem.
Nota: Embora os usuários possam usar qualquer carteira que desejarem, é altamente recomendável que as aplicações que usam o EIP-712 os incentivem a usar uma carteira que mostre todos os dados da assinatura (opens in a new tab). Algumas carteiras truncam o endereço, o que é inseguro. Um invasor pode criar um endereço que tenha os mesmos caracteres iniciais e finais, mas que seja diferente no meio.
Conclusão
Além das vulnerabilidades acima, a solução neste tutorial tem várias desvantagens que o Ethereum pode nos ajudar a resolver.
- Resistência à censura. Atualmente, os usuários podem usar seu servidor, um servidor concorrente configurado por outra pessoa ou se conectar diretamente ao Ethereum, o que incorre em custos de gás. O uso do ERC-4337 (opens in a new tab) permite que os usuários ofereçam suas transações a um grande pool de servidores, reduzindo a probabilidade de que suas transações sejam censuradas.
- Ativos de propriedade de EOA. Como observado acima, o EIP-7702 (opens in a new tab) pode ser usado para gerenciar ativos que já são de propriedade de um endereço de EOA. Isso tem suas dificuldades, mas às vezes é necessário.
Espero publicar tutoriais sobre como adicionar esses recursos em um futuro próximo.


