Recentemente, tenho revisado meus conhecimentos em Solidity para reforçar os detalhes e estou escrevendo uma série chamada "Introdução Mínima ao Solidity" (WTF Solidity) para iniciantes (os programadores avançados podem procurar outros tutoriais). Atualizo de 1 a 3 lições por semana.
Twitter: @0xAA_Science
Comunidade: Discord | Grupo no WeChat | Website wtf.academy
Todo o código e tutoriais estão disponíveis no GitHub: github.com/AmazingAng/WTF-Solidity
Nesta lição, vamos falar sobre um método de assinatura mais avançado e seguro chamado Assinaturas de Dados Tipados EIP712.
Anteriormente, falamos sobre o padrão de assinatura EIP191 (personal sign), que permite assinar uma mensagem. Porém, esse padrão é muito simples e, quando a mensagem a ser assinada é complexa, o usuário só vê uma string hexadecimal (o hash dos dados), sem conseguir verificar se a assinatura está correta.
A Assinatura de Dados Tipados EIP712 é um método mais avançado e seguro de assinatura. Quando um Dapp que suporta o EIP712 solicita uma assinatura, a carteira exibirá os dados originais da mensagem para que o usuário possa verificar e, em seguida, assinar.
A aplicação do EIP712 geralmente envolve duas partes: a assinatura off-chain (no frontend ou em scripts) e a verificação on-chain (no contrato). Abaixo, vamos aprender como usar o EIP712 com um exemplo simples chamado EIP712Storage
, que possui uma variável de estado number
que só pode ser modificada com uma assinatura EIP712.
-
Uma assinatura EIP712 deve incluir a parte
EIP712Domain
, que contém o nome do contrato, a versão (geralmente "1"), o chainId e o verifyingContract (o endereço do contrato que verificara a assinatura).EIP712Domain: [ { name: "name", type: "string" }, { name: "version", type: "string" }, { name: "chainId", type: "uint256" }, { name: "verifyingContract", type: "address" }, ]
Essas informações serão exibidas para o usuário durante a assinatura e garantirão que apenas contratos específicos de uma chain específica possam verificar a assinatura. Você precisará passar esses parâmetros no script.
const domain = { name: "EIP712Storage", version: "1", chainId: "1", verifyingContract: "0xf8e81D47203A594245E36C48e151709F0C19fBe8", };
-
Você precisa definir um tipo de dados de assinatura personalizado conforme a necessidade do cenário. No exemplo do
EIP712Storage
, definimos um tipoStorage
com dois membros:spender
, do tipoaddress
, que define quem pode modificar a variável; enumber
, do tipouint256
, que define o valor a ser modificado.const types = { Storage: [ { name: "spender", type: "address" }, { name: "number", type: "uint256" }, ], };
-
Crie uma variável
message
com os dados a serem assinados.const message = { spender: "0x5B38Da6a701c568545dCfcB03FcB875f56beddC4", number: "100", };
-
Chame o método
signTypedData()
do objeto da carteira, passando as variáveisdomain
,types
emessage
para assinar (usaremos oethersjs v6
).// Obtenha o provedor const provider = new ethers.BrowserProvider(window.ethereum) // Obtenha o signer e chame o método signTypedData para a assinatura EIP712 const signature = await signer.signTypedData(domain, types, message); console.log("Assinatura:", signature);
Agora, vamos nos concentrar na parte do contrato EIP712Storage
, que precisa verificar a assinatura para modificar a variável number
. O contrato possui 5 variáveis de estado.
EIP712DOMAIN_TYPEHASH
: o hash do tipoEIP712Domain
, é uma constante.STORAGE_TYPEHASH
: o hash do tipoStorage
, é uma constante.DOMAIN_SEPARATOR
: este valor único misturado na assinatura é composto peloEIP712DOMAIN_TYPEHASH
e pelas informações doEIP712Domain
(nome, versão, chainId, verifyingContract) e é inicializado noconstructor()
.number
: a variável de estado que armazena o valor, que pode ser modificado pelo métodopermitStore()
.owner
: o dono do contrato, inicializado noconstructor()
e verificado na funçãopermitStore()
.
Além disso, o contrato EIP712Storage
possui 3 funções:
- Construtor: inicializa o
DOMAIN_SEPARATOR
e oowner
. retrieve()
: lê o valor denumber
.permitStore
: verifica a assinatura EIP712 e modifica o valor denumber
. Primeiro, ele separa a assinatura emr
,s
ev
. Em seguida, combina oDOMAIN_SEPARATOR
,STORAGE_TYPEHASH
, o endereço do chamador e o parâmetro_num
de entrada para obter a mensagem assinadadigest
. Por fim, usando o métodorecover()
daECDSA
, ele recupera o endereço do assinante e, se a assinatura for válida, atualiza o valor denumber
.
Abaixo está a implementação em Solidity do contrato EIP712Storage
:
// SPDX-License-Identifier: MIT
// By 0xAA
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract EIP712Storage {
using ECDSA for bytes32;
bytes32 private constant EIP712DOMAIN_TYPEHASH = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");
bytes32 private constant STORAGE_TYPEHASH = keccak256("Storage(address spender,uint256 number)");
bytes32 private DOMAIN_SEPARATOR;
uint256 number;
address owner;
constructor(){
DOMAIN_SEPARATOR = keccak256(abi.encode(
EIP712DOMAIN_TYPEHASH, // tipo hash
keccak256(bytes("EIP712Storage")), // nome
keccak256(bytes("1")), // versão
block.chainid, // chain id
address(this) // endereço do contrato
));
owner = msg.sender;
}
/**
* @dev Armazena valor na variável
*/
function permitStore(uint256 _num, bytes memory _signature) public {
// Verifica o comprimento da assinatura, onde 65 é o comprimento padrão das assinaturas r, s, v
require(_signature.length == 65, "comprimento de assinatura inválido");
bytes32 r;
bytes32 s;
uint8 v;
// Atualmente só conseguimos obter os valores r, s, v através de assembly
assembly {
/*
Os primeiros 32 bytes armazenam o comprimento da assinatura (regra de armazenamento de arrays dinâmicos)
add(sig, 32) = ponteiro de sig + 32
Isso é equivalente a pular os 32 primeiros bytes da assinatura
mload(p) carrega os próximos 32 bytes de dados a partir do endereço de memória p
*/
// Lê os próximos 32 bytes após o comprimento
r := mload(add(_signature, 0x20))
// Lê os próximos 32 bytes
s := mload(add(_signature, 0x40))
// Lê o último byte
v := byte(0, mload(add(_signature, 0x60)))
}
// Obter o hash da mensagem assinada
bytes32 digest = keccak256(abi.encodePacked(
"\x19\x01",
DOMAIN_SEPARATOR,
keccak256(abi.encode(STORAGE_TYPEHASH, msg.sender, _num))
));
address signer = digest.recover(v, r, s); // Recupera o endereço do assinante
require(signer == owner, "EIP712Storage: Assinatura inválida"); // Verifica a assinatura
// Modifica a variável de estado
number = _num;
}
/**
* @dev Retorna o valor
* @return valor de 'number'
*/
function retrieve() public view returns (uint256){
return number;
}
}
-
Implante o contrato
EIP712Storage
. -
Execute o arquivo
eip712storage.html
, alterando oEndereço do Contrato
para o endereço do contratoEIP712Storage
implantado. Em seguida, clique emConectar Metamask
e emAssinar Permitir
. A assinatura deve ser feita usando a carteira do contrato implantada, como a carteira de teste do Remix:Chave Pública: 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4 Chave Privada: 503f38a9c967ed597e47fe25643985f032b072db8075426a92110f82df48dfcb
-
Chame o método
permitStore()
do contrato, inserindo o_num
e a assinatura adequada para modificar o valor denumber
. -
Chame o método
retrieve()
do contrato para ver o novo valor denumber
.
Espero que você tenha compreendido bem esse método de assinatura mais avançado e seguro que é o EIP712. Ele é amplamente utilizado em diversos projetos, como Metamask, pares de tokens no Uniswap, DAI e muitos outros. Eu espero que você consiga dominar essa técnica com sucesso.