Revisamos casos de uso e as implicações das Metatransações.
As transações típicas da Ethereum exigem que o remetente pague gas, caso contrário serão marcadas como inválidas e não serão incluídas no bloco. O fato de que o remetente deve possuir ETH para interagir com a Ethereum limita a participação na rede somente àqueles usuários que são capazes de converter moeda fiat ou adquirir um saldo de ETH. Metatransações contornam isto, permitindo que um usuário envie uma mensagem assinada como uma transação. Essa mensagem pode ser empacotada em uma transação através de um contrato de recebimento que depois paga o gas.
Casos de Uso
As metatransações apresentam uma variedade de possibilidades para os usuários da Ethereum.
Elas podem servir como um facilitador de privacidade para os usuários que não querem que sua atividade on chain seja exposta por exploradores de blocos. As metatransações ocultam a identidade de seu remetente porque seu endereço não aparece nos campos da transação. Note que qualquer parte poderia “descascar” a camada externa da metatransação para revelar o signatário da mensagem, que é o endereço do usuário.
Alguns protocolos utilizam este método para rapidamente incluir novos usuários sem exigir que eles gastem ativos criptográficos. Com as metatransações, os dApps podem cobrar os usuários em moeda fiat, permitindo que usuários sem carteiras criptográficas tenham acesso a seus serviços.
O OpenZeppelin criou o Defender, que permite esses casos de uso e outros.
EIP-2771
Abordamos sobre componentes de transações padrão em um post de blog anterior. As metatransações introduzem alguns componentes novos, diagramados abaixo.
É importante notar que com as metatransações, a variável global msg.sender
não pode ser usada para identificar o signatário da transação. Em vez de enviar uma transação, um usuário envia uma mensagem assinada com os métodos especificados na EIP-712.
Qualquer ator poderia, teoricamente, operar um relayer (retransmissor de transação). Este documento se concentra na rede existente mais popular, a Gas Station Network (GSN).
GSN
A complexidade do protocolo da GSN merece sua própria redação. No diagrama abaixo, delineamos as funções dos participantes da rede para prepará-lo para um exemplo posterior neste documento. Note que a versão do protocolo da GSN que discutimos neste documento é GSN v2 - a versão estável no momento desta publicação.
RelayHub
O RelayHub recebe transações de um componente do relayer chamado de relay worker (operador de retransmissão), que é essencialmente uma EoA (Externally Owned Account) que é sempre abastecida com ETH suficiente para pagar o gas. Ao tentar observar as metatransações no Etherscan, vemos o RelayHub como o destinatário das transações pai, pois é o primeiro a se comunicar com um contrato on chain.
A primeira chamada de função que veremos na cadeia é relayCall()
:
pragma solidity ^0.8.13;
...
function relayCall(
uint maxAcceptanceBudget,
GsnTypes.RelayRequest calldata relayRequest,
bytes calldata signature,
bytes calldata approvalData,
uint externalGasLimit
)
external
override
returns (bool paymasterAccepted, bytes memory returnValue)
Esta função é bastante longa, então destacaremos as etapas mais importantes de uma metatransação na rede. Primeiro, vamos rever como fica a struct RelayRequest
:
pragma solidity ^0.8.13;
...
struct RelayRequest {
IForwarder.ForwardRequest request;
RelayData relayData;
}
struct RelayData {
uint256 gasPrice;
uint256 pctRelayFee;
uint256 baseRelayFee;
address relayWorker;
address paymaster;
address forwarder;
bytes paymasterData;
uint256 clientId;
}
//Da IForwarder.sol
struct ForwardRequest {
address from;
address to;
uint256 value;
uint256 gas;
uint256 nonce;
bytes data;
uint256 validUntil;
}
Essa é uma struct agrupada bem grande! Aqui, a struct RelayData
são metadados utilizados no contexto da GSN para o faturamento correto da transação, irrelevante para a transação principal representada na struct ForwardRequest
.
Após algumas verificações internas da GSN, dentro da innerRelayCall()
, nós vemos uma chamada para execute()
que invoca o fluxo real de execução da transação:
pragma solidity ^0.8.13;
...
(forwarderSuccess, vars.relayedCallSuccess, vars.relayedCallReturnValue) = GsnEip712Library.execute(relayRequest, signature);
Vamos explorar a função de execução:
pragma solidity ^0.8.13;
...
function execute(GsnTypes.RelayRequest calldata relayRequest, bytes calldata signature) internal returns (bool forwarderSuccess, bool callSuccess, bytes memory ret) {
(bytes memory suffixData) = splitRequest(relayRequest);
bytes32 _domainSeparator = domainSeparator(relayRequest.relayData.forwarder);
/* solhint-disable-next-line avoid-low-level-calls */
(forwarderSuccess, ret) = relayRequest.relayData.forwarder.call(
abi.encodeWithSelector(IForwarder.execute.selector,
relayRequest.request, _domainSeparator, RELAY_REQUEST_TYPEHASH, suffixData, signature
));
if ( forwarderSuccess ) {
//decodificar valor de retorno de execução:
(callSuccess, ret) = abi.decode(ret, (bool, bytes));
}
truncateInPlace(ret);
}
Primeiro, observe que nossa struct de RelayRequest
está dividida para construir o suffixData
:
pragma solidity ^0.8.13;
...
function splitRequest(
GsnTypes.RelayRequest calldata req
)
internal
pure
returns (
bytes memory suffixData
) {
suffixData = abi.encode(
hashRelayData(req.relayData));
}
O suffixData é o relayData que é criptografado com keccak256, utilizando a função hashRelayData()
. Então, o_domainSeparator
é construído:
pragma solidity ^0.8.13;
...
function domainSeparator(address forwarder) internal pure returns (bytes32) {
return hashDomain(EIP712Domain({
name : "GSN Relayed Transaction",
version : "2",
chainId : getChainID(),
verifyingContract : forwarder
}));
}
E um hash separador de domínio é devolvido.
Encaminhador de Solicitação Confiável
Nosso pedido é passado ao encaminhador de solicitação (forwarder) especificado no RelayRequest original enviado ao hub (concentrador):
pragma solidity ^0.8.13;
...
(forwarderSuccess, ret) = relayRequest.relayData.forwarder.call(
abi.encodeWithSelector(IForwarder.execute.selector,
relayRequest.request,
_domainSeparator,
RELAY_REQUEST_TYPEHASH,
suffixData,
signature
));
Outra função execute()
é chamada, desta vez no contrato Forwarder:
pragma solidity ^0.8.13;
...
function execute(
ForwardRequest calldata req,
bytes32 domainSeparator,
bytes32 requestTypeHash,
bytes calldata suffixData,
bytes calldata sig
)
external payable
override
returns (bool success, bytes memory ret) {
_verifySig(req, domainSeparator, requestTypeHash, suffixData, sig);
_verifyAndUpdateNonce(req);
require(req.validUntil == 0 || req.validUntil > block.number, "FWD: request expired");
uint gasForTransfer = 0;
if ( req.value != 0 ) {
gasForTransfer = 40000; //buffer in case we need to move eth after the transaction.
}
bytes memory callData = abi.encodePacked(req.data, req.from);
require(gasleft()*63/64 >= req.gas + gasForTransfer, "FWD: insufficient gas");
// solhint-disable-next-line chamadas de baixo nível
(success,ret) = req.to.call{gas : req.gas, value : req.value}(callData);
if ( req.value != 0 && address(this).balance>0 ) {
// não pode falhar: req.from assinou (off chain) o pedido, portanto deve ser uma EOA.
payable(req.from).transfer(address(this).balance);
}
return (success,ret);
}
Como esperado, vemos as duas funções de verificação no início desta função. O encaminhador de solicitação assina e não assina para assegurar que o pedido é legítimo e evitar repetições.
Depois de nos certificarmos de que este pedido ainda é válido, não expirado, de que há gas suficiente para realmente realizar o pedido e de que há alguma memória para a transferir ETH posteriormente, finalmente vemos a chamada real feita para o destino desta transação:
pragma solidity ^0.8.13;
...
bytes memory callData = abi.encodePacked(req.data, req.from);
// ...
(success,ret) = req.to.call{gas : req.gas, value : req.value}(callData);
Nota: a chamada é realizada com o parâmetro from
(o remetente) anexado ao final da calldata. Isto é exclusivo para chamadas retransmitidas!
Finalmente, a chamada para o destinatário foi feita. Mas esta não é uma chamada comum - o destinatário deve estar ciente das metatransações e processá-las convenientemente.
Destinatário
Em nossa transação do exemplo, o remetente quis usar a GSN para invocar a função createOption()
em um dos contratos em Hegic, um protocolo de negociação de opções peer-to-pool.
O contrato de recebimento nesta transação é chamado de Facade. Como mencionado anteriormente, o Facade DEVE estar ciente do Trusted Forwarder a fim de processar corretamente a calldata passada para sua chamada createOption
. Vamos ver como esta lógica foi implementada:
pragma solidity ^0.8.13;
...
contract Facade is Ownable {
using SafeERC20 for IERC20;
IWETH public immutable WETH;
IUniswapV2Router01 public immutable exchange;
IOptionsManager public immutable optionsManager;
address public _trustedForwarder; // <----- slot reservado para o encaminhador de solicitação confiável.
constructor(
IWETH weth,
IUniswapV2Router01 router,
IOptionsManager manager,
address trustedForwarder
) {
WETH = weth;
exchange = router;
_trustedForwarder = trustedForwarder; // <---- inicializar o trustedForwarder
optionsManager = manager;
}
O destinatário reconhece o encaminhador de solicitação confiável com um slot de armazenamento. Isto se alinha com o que apresentamos em nosso diagrama GSN.
Vamos dar uma olhada na função createOption()
:
pragma solidity ^0.8.13;
...
function createOption(
IHegicPool pool,
uint256 period,
uint256 amount,
uint256 strike,
address[] calldata swappath,
uint256 acceptablePrice
) external {
address buyer = _msgSender(); // <---- ????
(uint256 optionPrice, uint256 rawOptionPrice, , ) =
getOptionPrice(pool, period, amount, strike, swappath);
require(
optionPrice <= acceptablePrice,
"Facade Error: The option price is too high"
);
IERC20 paymentToken = IERC20(swappath[0]);
paymentToken.safeTransferFrom(buyer, address(this), optionPrice);
if (swappath.length > 1) {
if (
paymentToken.allowance(address(this), address(exchange)) <
optionPrice
) {
paymentToken.safeApprove(address(exchange), 0);
paymentToken.safeApprove(address(exchange), type(uint256).max);
}
exchange.swapTokensForExactTokens(
rawOptionPrice,
optionPrice,
swappath,
address(this),
block.timestamp
);
}
pool.sellOption(buyer, period, amount, strike);
}
Observe o comentário na linha 9. Esta linha exemplifica o conhecimento das transações relayed da GSN:
pragma solidity ^0.8.13;
...
function _msgSender() internal view override returns (address signer) {
signer = msg.sender;
if (msg.data.length >= 20 && isTrustedForwarder(signer)) {
assembly {
signer := shr(96, calldataload(sub(calldatasize(), 20)))
}
}
}
O destinatário checa se o msg.sender
corresponde a um Forwarder confiável, chamando isTrustedForwarder()
. Se TrustedForwarder()
é falso, então msg.sender
é retornado como está, o que permite a compatibilidade retroativa desta função.
Se o msg.sender
for um encaminhador de solicitação confiável, o contrato destinatário extrai os últimos 20 bytes da calldata e trata esses bytes como endereço do remetente. Lembre-se de que o verdadeiro endereço from
foi anexado pelo forwarder à calldata previamente.
Isto é tudo o que é necessário para o suporte das transações retransmitidas da GSN.
As verificações acima mencionadas evitam um efeito dominó potencialmente catastrófico. Imagine que um Forwarder malicioso tivesse a oportunidade de encaminhar chamadas para funções-alvo no Contrato do Destinatário, sem supervisão. Ao anexar qualquer endereço à calldata seria possível forjar chamadas em nome de qualquer remetente. O destinatário destas chamadas aceitaria incondicionalmente estes endereços forjados como msg.sender
, permitindo que atores maliciosos personifiquem suas vítimas enquanto executam uma variedade de operações confidenciais.
Implementação
A fim de tornar um padrão a partir do conhecimento do contrato do destinatário sobre as metatransações, o time da OpenGSN, juntamente com algumas outras pessoas, criou a EIP-2771 para criar uma maneira segura de lidar com essas transações.
Enquanto o conteúdo desta EIP é quase inteiramente coberto pelos exemplos dados acima, a OpenGSN definiu os contratos padrão EIP-2771 de destinatário e Forwarder, chamados ERC2771Context e MinimalForwarder respectivamente.
ERC2771Context
Esta é uma implementação bastante direta e padronizada do que vimos no Facade.sol. Onde ele vê que o remetente é um forwarder confiável, ele recupera o verdadeiro remetente a partir dos dados da chamada:
pragma solidity ^0.8.13;
...
abstract contract ERC2771Context is Context {
/// @custom:oz-upgrades-unsafe-allow state-variable-immutable
address private immutable _trustedForwarder;
/// construtor @custom:oz-upgrades-unsafe-allow
constructor(address trustedForwarder) {
_trustedForwarder = trustedForwarder;
}
function isTrustedForwarder(address forwarder) public view virtual returns (bool) {
return forwarder == _trustedForwarder;
}
function _msgSender() internal view virtual override returns (address sender) {
if (isTrustedForwarder(msg.sender)) {
// O código _assembly_ fica mais direto do que a versão Solidity usando `abi.decode`.
/// @solidity memory-safe-assembly
assembly {
sender := shr(96, calldataload(sub(calldatasize(), 20)))
}
} else {
return super._msgSender();
}
}
function _msgData() internal view virtual override returns (bytes calldata) {
if (isTrustedForwarder(msg.sender)) {
return msg.data[:msg.data.length - 20];
} else {
return super._msgData();
}
}
}
MinimalForwarder
O execute()
da MinimalForwarder é uma versão simplificada do que vimos com o Forwarder da GSN:
pragma solidity ^0.8.13;
...
function execute(ForwardRequest calldata req, bytes calldata signature)
public
payable
returns (bool, bytes memory)
{
require(verify(req, signature), "MinimalForwarder: signature does not match request");
_nonces[req.from] = req.nonce + 1;
(bool success, bytes memory returndata) = req.to.call{gas: req.gas, value: req.value}(
abi.encodePacked(req.data, req.from)
);
// Confirmar que o relayer enviou _gas_ suficiente para a chamada.
// Ver https://ronan.eth.link/blog/ethereum-gas-dangers/
se (gasleft() <= req.gas / 63) {
// Acionamos explicitamente o opcode inválido para consumir todo o _gas_ e borbulhar os efeitos, uma vez que
// nem reverter, nem afirmar consome todo o _gas_ a partir do Solidity 0.8.0
// https://docs.soliditylang.org/en/v0.8.0/control-structures.html#panic-via-assert-and-error-via-require
/// @solidity memory-safe-assembly
assembly {
invalid()
}
}
return (success, returndata);
}
Observe que não há validação sobre o nonce ou sobre a expiração da metatransação. A única validação realizada é em relação a se a assinatura do signatário da transação corresponde ao remetente original. O OpenZeppelin reconhece que esta é uma validação minimalista e mais adequada apenas para fins de teste. Na verdade, eles apontam a GSN como um melhor exemplo de um forwarder pronto para produção. Mas dado que isto é apresentado como um padrão dentro do repositório do GitHub do OpenZeppelin, suspeitamos que ela possa ser usada de forma ampla como está.
Implicações
Como vimos anteriormente, o contrato do destinatário não pode depender do msg.sender para entender quem originou a transação, pois pode apenas mostrar o forwarder. Isto pode causar confusão, pois pode parecer que o transitário esteja de fato invocando funções como claim()
, stake()
, borrow()
, etc.
Para evitar confusões, devemos rastrear até as origens de uma metatransação, examinando a relayCall()
no contrato RelayHub e extraindo informações necessárias da calldata.
Decodificando Transações Retransmitidas
Vamos escrever um script Python para decodificar a calldata da relayCall()
. Para isso, utilizaremos esta metatransação como um exemplo.
Observe como o Etherscan não mostra que esta transação é uma chamada retransmitida e se recusa a decodificar a calldata. Nós podemos fazer melhor!
Vamos começar com a calldata bruta:
0x10c454310000000000000000000000000000000000000000000000000000000000045a4400000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000042000000000000000000000000000000000000000000000000000000000000004a000000000000000000000000000000000000000000000000000000000000f373100000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000260000000000000000000000000871c78f0668d911b1d97859036f3c99d10714254000000000000000000000000d56b5a63dac64990e7eccd046ec7119e38e422dc00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000085ed6000000000000000000000000000000000000000000000000000000000000000d00000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000e432dc0000000000000000000000000000000000000000000000000000000000000104c0485cf6000000000000000000000000790e96e7452c3c2200bbcaa58a468256d482dd8b0000000000000000000000000000000000000000000000000000000000093a800000000000000000000000000000000000000000000000008ac7230489e80000000000000000000000000000000000000000000000000000000000225eab1f8900000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000047d174bf0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000063cea549b000000000000000000000000000000000000000000000000000000000000003200000000000000000000000000000000000000000000000000000000000000000000000000000000000000004758dfdd66b37bf17be568f78b6f825ac3880ccd00000000000000000000000076dd5e45c6a404290a660952367edf8e68906e45000000000000000000000000aa3e82b4c4093b4ba13cb5714382c99adbf750ca00000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000416dbccdb9146d6316b354eae19b3d5cf540e2c843a61a11d04a4796e1d7f1c2996f79073fbb462e61d0e2661d1c29e04918d433f7c4b827bb27880fe51822574e1b000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
Vamos tirar o sighash (signature hash - hash de assinatura) deste menino mau usando nosso redis (armazenamento de estrutura de dados em memória):
"IP"[4]> smembers 10c45431
1) "relayCall(uint256,((address,address,uint256,uint256,uint256,bytes,uint256),(uint256,uint256,uint256,address,address,address,bytes,uint256)),bytes,bytes,uint256)"
"IP"[4]> select 5
OK
"IP"[5]> smembers 10c45431
1) "relayCall(uint256 maxAcceptanceBudget, struct GsnTypes.RelayRequest relayRequest, bytes signature, bytes approvalData, uint256 externalGasLimit)"
"IP"[5]>
Agora podemos detalhar a calldata até seus parâmetros subjacentes:
mport eth_abi
import binascii
# para visibilidade não incluí todo o hexstring do calldata...
calldata = binascii.unhexlify(calldata_hex_string[10:]) # <-- redact selector
# assinatura relayCall()
relay_call_abi_types = ['uint256','((address,address,uint256,uint256,uint256,bytes,uint256),(uint256,uint256,uint256,address,address,address,bytes,uint256))','bytes','bytes','uint256']
# parâmetros com nomes do redis
relay_call_param_names = ['maxAcceptanceBudget','relayRequest','signature','approvalData','externalGasLimit']
def get_relayed_call_from_calldata(calldata):
ret = {}
for i,param in enumerate(eth_abi.decode_abi(relay_call_abi_types,calldata)):
ret[relay_call_param_names[i]] = param
return ret
# Estamos interessados na parte ForwardRequest da struct:
# struct RelayRequest {
# Pedido IForwarder.ForwardRequest;
# RelayData relayData;
#}
#
#struct ForwardRequest {
# address from;
# address to;
# uint256 value;
# uint256 gas;
# uint256 nonce;
# bytes data;
# uint256 validUntil;
#}
forward_request_underlying = ['from','to','value','gas','nonce','data','validUntil']
def get_forward_request(relay_request):
ret = {}
for i,param in enumerate(relay_request[0]):
ret[forward_request_underlying[i]] = param
return ret
def main():
j = get_relayed_call_from_calldata(calldata)
underlying_txn = get_forward_request(j["relayRequest"])
print(underlying_txn)
### SAÍDA ###
{'from': '0xda72af8d05d0fd794a726820ac90d4bb12fd9f7d',
'to': '0x8af11d6009dd31dacd990a88393f79746b197760',
'value': 0,
'gas': 1502371,
'nonce': 107,
'data': b'<REDACTED>',
'validUntil': 15045745}
Bastante fácil, uma vez que você saiba o que procurar 🙂
Se quisermos entender a transação subjacente, podemos decodificar o campo de dados no JSON resultante:
func_selector = binascii.hexlify(underlying_txn["data"][:4])
print(func_selector)
### SAÍDA ###
b'39bf70d1'
### RESULTADO DO REDIS ###
"IP"> smembers 39bf70d1
#1) "callOnExtension(address _extension, uint256 _actionId, bytes _callArgs)"
Nota: no trecho acima, usamos nossa instância privada redis, que contém os sighashes de 4bytes de funções, para resolver o hash que corresponde à assinatura da função. Um serviço público como 4bytes.directory faz o mesmo.
Agora conhecemos o remetente original da transação, a função que foi chamada, o contrato do destinatário, o valor a ser enviado e cada parâmetro da transação a ser executada!
Agradecimentos a Yoav Weiss, Michael Zhu, e os membros do time smlXL que forneceram orientações e feedback para esse post.
Responda a este post se você tiver alguma dúvida, e dê uma olhada em nossa página de contratação se você quiser trabalhar conosco.
Esse artigo foi escrito por Tal e traduzido por Fátima Lima. O original pode ser lido aqui.
Latest comments (0)