Minimal support for ERC2771 (GSNv2) (#2508)
Co-authored-by: Francisco Giordano <frangio.1@gmail.com>pull/2503/head
parent
e341bdc1b7
commit
f8cc8b844a
@ -0,0 +1,37 @@ |
|||||||
|
// SPDX-License-Identifier: MIT |
||||||
|
|
||||||
|
pragma solidity ^0.8.0; |
||||||
|
|
||||||
|
import "../utils/Context.sol"; |
||||||
|
|
||||||
|
/* |
||||||
|
* @dev Context variant with ERC2771 support. |
||||||
|
*/ |
||||||
|
abstract contract ERC2771Context is Context { |
||||||
|
address immutable _trustedForwarder; |
||||||
|
|
||||||
|
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)) { |
||||||
|
// The assembly code is more direct than the Solidity version using `abi.decode`. |
||||||
|
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(); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,58 @@ |
|||||||
|
// SPDX-License-Identifier: MIT |
||||||
|
|
||||||
|
pragma solidity ^0.8.0; |
||||||
|
|
||||||
|
import "../cryptography/ECDSA.sol"; |
||||||
|
import "../drafts/EIP712.sol"; |
||||||
|
|
||||||
|
/* |
||||||
|
* @dev Simple minimal forwarder to be used together with an ERC2771 compatible contract. See {ERC2771Context}. |
||||||
|
*/ |
||||||
|
contract MinimalForwarder is EIP712 { |
||||||
|
using ECDSA for bytes32; |
||||||
|
|
||||||
|
struct ForwardRequest { |
||||||
|
address from; |
||||||
|
address to; |
||||||
|
uint256 value; |
||||||
|
uint256 gas; |
||||||
|
uint256 nonce; |
||||||
|
bytes data; |
||||||
|
} |
||||||
|
|
||||||
|
bytes32 private constant TYPEHASH = keccak256("ForwardRequest(address from,address to,uint256 value,uint256 gas,uint256 nonce,bytes data)"); |
||||||
|
|
||||||
|
mapping(address => uint256) private _nonces; |
||||||
|
|
||||||
|
constructor() EIP712("MinimalForwarder", "0.0.1") {} |
||||||
|
|
||||||
|
function getNonce(address from) public view returns (uint256) { |
||||||
|
return _nonces[from]; |
||||||
|
} |
||||||
|
|
||||||
|
function verify(ForwardRequest calldata req, bytes calldata signature) public view returns (bool) { |
||||||
|
address signer = _hashTypedDataV4(keccak256(abi.encode( |
||||||
|
TYPEHASH, |
||||||
|
req.from, |
||||||
|
req.to, |
||||||
|
req.value, |
||||||
|
req.gas, |
||||||
|
req.nonce, |
||||||
|
keccak256(req.data) |
||||||
|
))).recover(signature); |
||||||
|
return _nonces[req.from] == req.nonce && signer == req.from; |
||||||
|
} |
||||||
|
|
||||||
|
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; |
||||||
|
|
||||||
|
// solhint-disable-next-line avoid-low-level-calls |
||||||
|
(bool success, bytes memory returndata) = req.to.call{gas: req.gas, value: req.value}(abi.encodePacked(req.data, req.from)); |
||||||
|
// Validate that the relayer has sent enough gas for the call. |
||||||
|
// See https://ronan.eth.link/blog/ethereum-gas-dangers/ |
||||||
|
assert(gasleft() > req.gas / 63); |
||||||
|
|
||||||
|
return (success, returndata); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,12 @@ |
|||||||
|
= Meta Transactions |
||||||
|
|
||||||
|
[.readme-notice] |
||||||
|
NOTE: This document is better viewed at https://docs.openzeppelin.com/contracts/api/math |
||||||
|
|
||||||
|
== Core |
||||||
|
|
||||||
|
{{ERC2771Context}} |
||||||
|
|
||||||
|
== Utils |
||||||
|
|
||||||
|
{{MinimalForwarder}} |
@ -0,0 +1,19 @@ |
|||||||
|
// SPDX-License-Identifier: MIT |
||||||
|
|
||||||
|
pragma solidity ^0.8.0; |
||||||
|
|
||||||
|
import "./ContextMock.sol"; |
||||||
|
import "../metatx/ERC2771Context.sol"; |
||||||
|
|
||||||
|
// By inheriting from ERC2771Context, Context's internal functions are overridden automatically |
||||||
|
contract ERC2771ContextMock is ContextMock, ERC2771Context { |
||||||
|
constructor(address trustedForwarder) ERC2771Context(trustedForwarder) {} |
||||||
|
|
||||||
|
function _msgSender() internal override(Context, ERC2771Context) view virtual returns (address) { |
||||||
|
return ERC2771Context._msgSender(); |
||||||
|
} |
||||||
|
|
||||||
|
function _msgData() internal override(Context, ERC2771Context) view virtual returns (bytes calldata) { |
||||||
|
return ERC2771Context._msgData(); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,113 @@ |
|||||||
|
const ethSigUtil = require('eth-sig-util'); |
||||||
|
const Wallet = require('ethereumjs-wallet').default; |
||||||
|
const { EIP712Domain } = require('../helpers/eip712'); |
||||||
|
|
||||||
|
const { expectEvent } = require('@openzeppelin/test-helpers'); |
||||||
|
const { expect } = require('chai'); |
||||||
|
|
||||||
|
const ERC2771ContextMock = artifacts.require('ERC2771ContextMock'); |
||||||
|
const MinimalForwarder = artifacts.require('MinimalForwarder'); |
||||||
|
const ContextMockCaller = artifacts.require('ContextMockCaller'); |
||||||
|
|
||||||
|
const { shouldBehaveLikeRegularContext } = require('../utils/Context.behavior'); |
||||||
|
|
||||||
|
const name = 'MinimalForwarder'; |
||||||
|
const version = '0.0.1'; |
||||||
|
|
||||||
|
contract('ERC2771Context', function (accounts) { |
||||||
|
beforeEach(async function () { |
||||||
|
this.forwarder = await MinimalForwarder.new(); |
||||||
|
this.recipient = await ERC2771ContextMock.new(this.forwarder.address); |
||||||
|
|
||||||
|
this.domain = { |
||||||
|
name, |
||||||
|
version, |
||||||
|
chainId: await web3.eth.getChainId(), |
||||||
|
verifyingContract: this.forwarder.address, |
||||||
|
}; |
||||||
|
this.types = { |
||||||
|
EIP712Domain, |
||||||
|
ForwardRequest: [ |
||||||
|
{ name: 'from', type: 'address' }, |
||||||
|
{ name: 'to', type: 'address' }, |
||||||
|
{ name: 'value', type: 'uint256' }, |
||||||
|
{ name: 'gas', type: 'uint256' }, |
||||||
|
{ name: 'nonce', type: 'uint256' }, |
||||||
|
{ name: 'data', type: 'bytes' }, |
||||||
|
], |
||||||
|
}; |
||||||
|
}); |
||||||
|
|
||||||
|
it('recognize trusted forwarder', async function () { |
||||||
|
expect(await this.recipient.isTrustedForwarder(this.forwarder.address)); |
||||||
|
}); |
||||||
|
|
||||||
|
context('when called directly', function () { |
||||||
|
beforeEach(async function () { |
||||||
|
this.context = this.recipient; // The Context behavior expects the contract in this.context
|
||||||
|
this.caller = await ContextMockCaller.new(); |
||||||
|
}); |
||||||
|
|
||||||
|
shouldBehaveLikeRegularContext(...accounts); |
||||||
|
}); |
||||||
|
|
||||||
|
context('when receiving a relayed call', function () { |
||||||
|
beforeEach(async function () { |
||||||
|
this.wallet = Wallet.generate(); |
||||||
|
this.sender = web3.utils.toChecksumAddress(this.wallet.getAddressString()); |
||||||
|
this.data = { |
||||||
|
types: this.types, |
||||||
|
domain: this.domain, |
||||||
|
primaryType: 'ForwardRequest', |
||||||
|
}; |
||||||
|
}); |
||||||
|
|
||||||
|
describe('msgSender', function () { |
||||||
|
it('returns the relayed transaction original sender', async function () { |
||||||
|
const data = this.recipient.contract.methods.msgSender().encodeABI(); |
||||||
|
|
||||||
|
const req = { |
||||||
|
from: this.sender, |
||||||
|
to: this.recipient.address, |
||||||
|
value: '0', |
||||||
|
gas: '100000', |
||||||
|
nonce: (await this.forwarder.getNonce(this.sender)).toString(), |
||||||
|
data, |
||||||
|
}; |
||||||
|
|
||||||
|
const sign = ethSigUtil.signTypedMessage(this.wallet.getPrivateKey(), { data: { ...this.data, message: req } }); |
||||||
|
|
||||||
|
// rejected by lint :/
|
||||||
|
// expect(await this.forwarder.verify(req, sign)).to.be.true;
|
||||||
|
|
||||||
|
const { tx } = await this.forwarder.execute(req, sign); |
||||||
|
await expectEvent.inTransaction(tx, ERC2771ContextMock, 'Sender', { sender: this.sender }); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('msgData', function () { |
||||||
|
it('returns the relayed transaction original data', async function () { |
||||||
|
const integerValue = '42'; |
||||||
|
const stringValue = 'OpenZeppelin'; |
||||||
|
const data = this.recipient.contract.methods.msgData(integerValue, stringValue).encodeABI(); |
||||||
|
|
||||||
|
const req = { |
||||||
|
from: this.sender, |
||||||
|
to: this.recipient.address, |
||||||
|
value: '0', |
||||||
|
gas: '100000', |
||||||
|
nonce: (await this.forwarder.getNonce(this.sender)).toString(), |
||||||
|
data, |
||||||
|
}; |
||||||
|
|
||||||
|
const sign = ethSigUtil.signTypedMessage(this.wallet.getPrivateKey(), { data: { ...this.data, message: req } }); |
||||||
|
|
||||||
|
// rejected by lint :/
|
||||||
|
// expect(await this.forwarder.verify(req, sign)).to.be.true;
|
||||||
|
|
||||||
|
const { tx } = await this.forwarder.execute(req, sign); |
||||||
|
await expectEvent.inTransaction(tx, ERC2771ContextMock, 'Data', { data, integerValue, stringValue }); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
@ -0,0 +1,166 @@ |
|||||||
|
const ethSigUtil = require('eth-sig-util'); |
||||||
|
const Wallet = require('ethereumjs-wallet').default; |
||||||
|
const { EIP712Domain } = require('../helpers/eip712'); |
||||||
|
|
||||||
|
const { expectRevert, constants } = require('@openzeppelin/test-helpers'); |
||||||
|
const { expect } = require('chai'); |
||||||
|
|
||||||
|
const MinimalForwarder = artifacts.require('MinimalForwarder'); |
||||||
|
|
||||||
|
const name = 'MinimalForwarder'; |
||||||
|
const version = '0.0.1'; |
||||||
|
|
||||||
|
contract('MinimalForwarder', function (accounts) { |
||||||
|
beforeEach(async function () { |
||||||
|
this.forwarder = await MinimalForwarder.new(); |
||||||
|
this.domain = { |
||||||
|
name, |
||||||
|
version, |
||||||
|
chainId: await web3.eth.getChainId(), |
||||||
|
verifyingContract: this.forwarder.address, |
||||||
|
}; |
||||||
|
this.types = { |
||||||
|
EIP712Domain, |
||||||
|
ForwardRequest: [ |
||||||
|
{ name: 'from', type: 'address' }, |
||||||
|
{ name: 'to', type: 'address' }, |
||||||
|
{ name: 'value', type: 'uint256' }, |
||||||
|
{ name: 'gas', type: 'uint256' }, |
||||||
|
{ name: 'nonce', type: 'uint256' }, |
||||||
|
{ name: 'data', type: 'bytes' }, |
||||||
|
], |
||||||
|
}; |
||||||
|
}); |
||||||
|
|
||||||
|
context('with message', function () { |
||||||
|
beforeEach(async function () { |
||||||
|
this.wallet = Wallet.generate(); |
||||||
|
this.sender = web3.utils.toChecksumAddress(this.wallet.getAddressString()); |
||||||
|
this.req = { |
||||||
|
from: this.sender, |
||||||
|
to: constants.ZERO_ADDRESS, |
||||||
|
value: '0', |
||||||
|
gas: '100000', |
||||||
|
nonce: Number(await this.forwarder.getNonce(this.sender)), |
||||||
|
data: '0x', |
||||||
|
}; |
||||||
|
this.sign = ethSigUtil.signTypedMessage( |
||||||
|
this.wallet.getPrivateKey(), |
||||||
|
{ |
||||||
|
data: { |
||||||
|
types: this.types, |
||||||
|
domain: this.domain, |
||||||
|
primaryType: 'ForwardRequest', |
||||||
|
message: this.req, |
||||||
|
}, |
||||||
|
}, |
||||||
|
); |
||||||
|
}); |
||||||
|
|
||||||
|
context('verify', function () { |
||||||
|
context('valid signature', function () { |
||||||
|
beforeEach(async function () { |
||||||
|
expect(await this.forwarder.getNonce(this.req.from)) |
||||||
|
.to.be.bignumber.equal(web3.utils.toBN(this.req.nonce)); |
||||||
|
}); |
||||||
|
|
||||||
|
it('success', async function () { |
||||||
|
expect(await this.forwarder.verify(this.req, this.sign)).to.be.equal(true); |
||||||
|
}); |
||||||
|
|
||||||
|
afterEach(async function () { |
||||||
|
expect(await this.forwarder.getNonce(this.req.from)) |
||||||
|
.to.be.bignumber.equal(web3.utils.toBN(this.req.nonce)); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
context('invalid signature', function () { |
||||||
|
it('tampered from', async function () { |
||||||
|
expect(await this.forwarder.verify({ ...this.req, from: accounts[0] }, this.sign)) |
||||||
|
.to.be.equal(false); |
||||||
|
}); |
||||||
|
it('tampered to', async function () { |
||||||
|
expect(await this.forwarder.verify({ ...this.req, to: accounts[0] }, this.sign)) |
||||||
|
.to.be.equal(false); |
||||||
|
}); |
||||||
|
it('tampered value', async function () { |
||||||
|
expect(await this.forwarder.verify({ ...this.req, value: web3.utils.toWei('1') }, this.sign)) |
||||||
|
.to.be.equal(false); |
||||||
|
}); |
||||||
|
it('tampered nonce', async function () { |
||||||
|
expect(await this.forwarder.verify({ ...this.req, nonce: this.req.nonce + 1 }, this.sign)) |
||||||
|
.to.be.equal(false); |
||||||
|
}); |
||||||
|
it('tampered data', async function () { |
||||||
|
expect(await this.forwarder.verify({ ...this.req, data: '0x1742' }, this.sign)) |
||||||
|
.to.be.equal(false); |
||||||
|
}); |
||||||
|
it('tampered signature', async function () { |
||||||
|
const tamperedsign = web3.utils.hexToBytes(this.sign); |
||||||
|
tamperedsign[42] ^= 0xff; |
||||||
|
expect(await this.forwarder.verify(this.req, web3.utils.bytesToHex(tamperedsign))) |
||||||
|
.to.be.equal(false); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
context('execute', function () { |
||||||
|
context('valid signature', function () { |
||||||
|
beforeEach(async function () { |
||||||
|
expect(await this.forwarder.getNonce(this.req.from)) |
||||||
|
.to.be.bignumber.equal(web3.utils.toBN(this.req.nonce)); |
||||||
|
}); |
||||||
|
|
||||||
|
it('success', async function () { |
||||||
|
await this.forwarder.execute(this.req, this.sign); // expect to not revert
|
||||||
|
}); |
||||||
|
|
||||||
|
afterEach(async function () { |
||||||
|
expect(await this.forwarder.getNonce(this.req.from)) |
||||||
|
.to.be.bignumber.equal(web3.utils.toBN(this.req.nonce + 1)); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
context('invalid signature', function () { |
||||||
|
it('tampered from', async function () { |
||||||
|
await expectRevert( |
||||||
|
this.forwarder.execute({ ...this.req, from: accounts[0] }, this.sign), |
||||||
|
'MinimalForwarder: signature does not match request', |
||||||
|
); |
||||||
|
}); |
||||||
|
it('tampered to', async function () { |
||||||
|
await expectRevert( |
||||||
|
this.forwarder.execute({ ...this.req, to: accounts[0] }, this.sign), |
||||||
|
'MinimalForwarder: signature does not match request', |
||||||
|
); |
||||||
|
}); |
||||||
|
it('tampered value', async function () { |
||||||
|
await expectRevert( |
||||||
|
this.forwarder.execute({ ...this.req, value: web3.utils.toWei('1') }, this.sign), |
||||||
|
'MinimalForwarder: signature does not match request', |
||||||
|
); |
||||||
|
}); |
||||||
|
it('tampered nonce', async function () { |
||||||
|
await expectRevert( |
||||||
|
this.forwarder.execute({ ...this.req, nonce: this.req.nonce + 1 }, this.sign), |
||||||
|
'MinimalForwarder: signature does not match request', |
||||||
|
); |
||||||
|
}); |
||||||
|
it('tampered data', async function () { |
||||||
|
await expectRevert( |
||||||
|
this.forwarder.execute({ ...this.req, data: '0x1742' }, this.sign), |
||||||
|
'MinimalForwarder: signature does not match request', |
||||||
|
); |
||||||
|
}); |
||||||
|
it('tampered signature', async function () { |
||||||
|
const tamperedsign = web3.utils.hexToBytes(this.sign); |
||||||
|
tamperedsign[42] ^= 0xff; |
||||||
|
await expectRevert( |
||||||
|
this.forwarder.execute(this.req, web3.utils.bytesToHex(tamperedsign)), |
||||||
|
'MinimalForwarder: signature does not match request', |
||||||
|
); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
Loading…
Reference in new issue