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 tipoStoragecom 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
messagecom os dados a serem assinados.const message = { spender: "0x5B38Da6a701c568545dCfcB03FcB875f56beddC4", number: "100", };
-
Chame o método
signTypedData()do objeto da carteira, passando as variáveisdomain,typesemessagepara 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_TYPEHASHe 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_SEPARATORe oowner. retrieve(): lê o valor denumber.permitStore: verifica a assinatura EIP712 e modifica o valor denumber. Primeiro, ele separa a assinatura emr,sev. Em seguida, combina oDOMAIN_SEPARATOR,STORAGE_TYPEHASH, o endereço do chamador e o parâmetro_numde 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 Contratopara o endereço do contratoEIP712Storageimplantado. Em seguida, clique emConectar Metamaske 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_nume 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.