Skip to content

Commit f8cc8b8

Browse files
Amxxfrangio
andauthored
Minimal support for ERC2771 (GSNv2) (#2508)
Co-authored-by: Francisco Giordano <[email protected]>
1 parent e341bdc commit f8cc8b8

File tree

9 files changed

+425
-4
lines changed

9 files changed

+425
-4
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
* `Initializable`: Make initializer check stricter during construction. ([#2531](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/2531))
1313
* `ERC721`: remove enumerability of tokens from the base implementation. This feature is now provided separately through the `ERC721Enumerable` extension. ([#2511](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/2511))
1414
* `AccessControl`: removed enumerability by default for a more lightweight contract. It is now opt-in through `AccessControlEnumerable`. ([#2512](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/2512))
15+
* Meta Transactions: add `ERC2771Context` and a `MinimalForwarder` for meta-transactions. ([#2508](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/2508))
1516

1617
## 3.4.0 (2021-02-02)
1718

contracts/cryptography/ECDSA.sol

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,9 @@ library ECDSA {
7272

7373
/**
7474
* @dev Returns an Ethereum Signed Message, created from a `hash`. This
75-
* replicates the behavior of the
76-
* https://github.com/ethereum/wiki/wiki/JSON-RPC#eth_sign[`eth_sign`]
77-
* JSON-RPC method.
75+
* produces hash corresponding to the one signed with the
76+
* https://eth.wiki/json-rpc/API#eth_sign[`eth_sign`]
77+
* JSON-RPC method as part of EIP-191.
7878
*
7979
* See {recover}.
8080
*/
@@ -83,4 +83,17 @@ library ECDSA {
8383
// enforced by the type signature above
8484
return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash));
8585
}
86+
87+
/**
88+
* @dev Returns an Ethereum Signed Typed Data, created from a
89+
* `domainSeparator` and a `structHash`. This produces hash corresponding
90+
* to the one signed with the
91+
* https://eips.ethereum.org/EIPS/eip-712[`eth_signTypedData`]
92+
* JSON-RPC method as part of EIP-712.
93+
*
94+
* See {recover}.
95+
*/
96+
function toTypedDataHash(bytes32 domainSeparator, bytes32 structHash) internal pure returns (bytes32) {
97+
return keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash));
98+
}
8699
}

contracts/drafts/EIP712.sol

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
pragma solidity ^0.8.0;
44

5+
import "../cryptography/ECDSA.sol";
6+
57
/**
68
* @dev https://eips.ethereum.org/EIPS/eip-712[EIP 712] is a standard for hashing and signing of typed structured data.
79
*
@@ -95,6 +97,6 @@ abstract contract EIP712 {
9597
* ```
9698
*/
9799
function _hashTypedDataV4(bytes32 structHash) internal view virtual returns (bytes32) {
98-
return keccak256(abi.encodePacked("\x19\x01", _domainSeparatorV4(), structHash));
100+
return ECDSA.toTypedDataHash(_domainSeparatorV4(), structHash);
99101
}
100102
}

contracts/metatx/ERC2771Context.sol

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// SPDX-License-Identifier: MIT
2+
3+
pragma solidity ^0.8.0;
4+
5+
import "../utils/Context.sol";
6+
7+
/*
8+
* @dev Context variant with ERC2771 support.
9+
*/
10+
abstract contract ERC2771Context is Context {
11+
address immutable _trustedForwarder;
12+
13+
constructor(address trustedForwarder) {
14+
_trustedForwarder = trustedForwarder;
15+
}
16+
17+
function isTrustedForwarder(address forwarder) public view virtual returns(bool) {
18+
return forwarder == _trustedForwarder;
19+
}
20+
21+
function _msgSender() internal view virtual override returns (address sender) {
22+
if (isTrustedForwarder(msg.sender)) {
23+
// The assembly code is more direct than the Solidity version using `abi.decode`.
24+
assembly { sender := shr(96, calldataload(sub(calldatasize(), 20))) }
25+
} else {
26+
return super._msgSender();
27+
}
28+
}
29+
30+
function _msgData() internal view virtual override returns (bytes calldata) {
31+
if (isTrustedForwarder(msg.sender)) {
32+
return msg.data[:msg.data.length-20];
33+
} else {
34+
return super._msgData();
35+
}
36+
}
37+
}

contracts/metatx/MinimalForwarder.sol

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// SPDX-License-Identifier: MIT
2+
3+
pragma solidity ^0.8.0;
4+
5+
import "../cryptography/ECDSA.sol";
6+
import "../drafts/EIP712.sol";
7+
8+
/*
9+
* @dev Simple minimal forwarder to be used together with an ERC2771 compatible contract. See {ERC2771Context}.
10+
*/
11+
contract MinimalForwarder is EIP712 {
12+
using ECDSA for bytes32;
13+
14+
struct ForwardRequest {
15+
address from;
16+
address to;
17+
uint256 value;
18+
uint256 gas;
19+
uint256 nonce;
20+
bytes data;
21+
}
22+
23+
bytes32 private constant TYPEHASH = keccak256("ForwardRequest(address from,address to,uint256 value,uint256 gas,uint256 nonce,bytes data)");
24+
25+
mapping(address => uint256) private _nonces;
26+
27+
constructor() EIP712("MinimalForwarder", "0.0.1") {}
28+
29+
function getNonce(address from) public view returns (uint256) {
30+
return _nonces[from];
31+
}
32+
33+
function verify(ForwardRequest calldata req, bytes calldata signature) public view returns (bool) {
34+
address signer = _hashTypedDataV4(keccak256(abi.encode(
35+
TYPEHASH,
36+
req.from,
37+
req.to,
38+
req.value,
39+
req.gas,
40+
req.nonce,
41+
keccak256(req.data)
42+
))).recover(signature);
43+
return _nonces[req.from] == req.nonce && signer == req.from;
44+
}
45+
46+
function execute(ForwardRequest calldata req, bytes calldata signature) public payable returns (bool, bytes memory) {
47+
require(verify(req, signature), "MinimalForwarder: signature does not match request");
48+
_nonces[req.from] = req.nonce + 1;
49+
50+
// solhint-disable-next-line avoid-low-level-calls
51+
(bool success, bytes memory returndata) = req.to.call{gas: req.gas, value: req.value}(abi.encodePacked(req.data, req.from));
52+
// Validate that the relayer has sent enough gas for the call.
53+
// See https://ronan.eth.link/blog/ethereum-gas-dangers/
54+
assert(gasleft() > req.gas / 63);
55+
56+
return (success, returndata);
57+
}
58+
}

contracts/metatx/README.adoc

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
= Meta Transactions
2+
3+
[.readme-notice]
4+
NOTE: This document is better viewed at https://docs.openzeppelin.com/contracts/api/math
5+
6+
== Core
7+
8+
{{ERC2771Context}}
9+
10+
== Utils
11+
12+
{{MinimalForwarder}}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// SPDX-License-Identifier: MIT
2+
3+
pragma solidity ^0.8.0;
4+
5+
import "./ContextMock.sol";
6+
import "../metatx/ERC2771Context.sol";
7+
8+
// By inheriting from ERC2771Context, Context's internal functions are overridden automatically
9+
contract ERC2771ContextMock is ContextMock, ERC2771Context {
10+
constructor(address trustedForwarder) ERC2771Context(trustedForwarder) {}
11+
12+
function _msgSender() internal override(Context, ERC2771Context) view virtual returns (address) {
13+
return ERC2771Context._msgSender();
14+
}
15+
16+
function _msgData() internal override(Context, ERC2771Context) view virtual returns (bytes calldata) {
17+
return ERC2771Context._msgData();
18+
}
19+
}

test/metatx/ERC2771Context.test.js

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
const ethSigUtil = require('eth-sig-util');
2+
const Wallet = require('ethereumjs-wallet').default;
3+
const { EIP712Domain } = require('../helpers/eip712');
4+
5+
const { expectEvent } = require('@openzeppelin/test-helpers');
6+
const { expect } = require('chai');
7+
8+
const ERC2771ContextMock = artifacts.require('ERC2771ContextMock');
9+
const MinimalForwarder = artifacts.require('MinimalForwarder');
10+
const ContextMockCaller = artifacts.require('ContextMockCaller');
11+
12+
const { shouldBehaveLikeRegularContext } = require('../utils/Context.behavior');
13+
14+
const name = 'MinimalForwarder';
15+
const version = '0.0.1';
16+
17+
contract('ERC2771Context', function (accounts) {
18+
beforeEach(async function () {
19+
this.forwarder = await MinimalForwarder.new();
20+
this.recipient = await ERC2771ContextMock.new(this.forwarder.address);
21+
22+
this.domain = {
23+
name,
24+
version,
25+
chainId: await web3.eth.getChainId(),
26+
verifyingContract: this.forwarder.address,
27+
};
28+
this.types = {
29+
EIP712Domain,
30+
ForwardRequest: [
31+
{ name: 'from', type: 'address' },
32+
{ name: 'to', type: 'address' },
33+
{ name: 'value', type: 'uint256' },
34+
{ name: 'gas', type: 'uint256' },
35+
{ name: 'nonce', type: 'uint256' },
36+
{ name: 'data', type: 'bytes' },
37+
],
38+
};
39+
});
40+
41+
it('recognize trusted forwarder', async function () {
42+
expect(await this.recipient.isTrustedForwarder(this.forwarder.address));
43+
});
44+
45+
context('when called directly', function () {
46+
beforeEach(async function () {
47+
this.context = this.recipient; // The Context behavior expects the contract in this.context
48+
this.caller = await ContextMockCaller.new();
49+
});
50+
51+
shouldBehaveLikeRegularContext(...accounts);
52+
});
53+
54+
context('when receiving a relayed call', function () {
55+
beforeEach(async function () {
56+
this.wallet = Wallet.generate();
57+
this.sender = web3.utils.toChecksumAddress(this.wallet.getAddressString());
58+
this.data = {
59+
types: this.types,
60+
domain: this.domain,
61+
primaryType: 'ForwardRequest',
62+
};
63+
});
64+
65+
describe('msgSender', function () {
66+
it('returns the relayed transaction original sender', async function () {
67+
const data = this.recipient.contract.methods.msgSender().encodeABI();
68+
69+
const req = {
70+
from: this.sender,
71+
to: this.recipient.address,
72+
value: '0',
73+
gas: '100000',
74+
nonce: (await this.forwarder.getNonce(this.sender)).toString(),
75+
data,
76+
};
77+
78+
const sign = ethSigUtil.signTypedMessage(this.wallet.getPrivateKey(), { data: { ...this.data, message: req } });
79+
80+
// rejected by lint :/
81+
// expect(await this.forwarder.verify(req, sign)).to.be.true;
82+
83+
const { tx } = await this.forwarder.execute(req, sign);
84+
await expectEvent.inTransaction(tx, ERC2771ContextMock, 'Sender', { sender: this.sender });
85+
});
86+
});
87+
88+
describe('msgData', function () {
89+
it('returns the relayed transaction original data', async function () {
90+
const integerValue = '42';
91+
const stringValue = 'OpenZeppelin';
92+
const data = this.recipient.contract.methods.msgData(integerValue, stringValue).encodeABI();
93+
94+
const req = {
95+
from: this.sender,
96+
to: this.recipient.address,
97+
value: '0',
98+
gas: '100000',
99+
nonce: (await this.forwarder.getNonce(this.sender)).toString(),
100+
data,
101+
};
102+
103+
const sign = ethSigUtil.signTypedMessage(this.wallet.getPrivateKey(), { data: { ...this.data, message: req } });
104+
105+
// rejected by lint :/
106+
// expect(await this.forwarder.verify(req, sign)).to.be.true;
107+
108+
const { tx } = await this.forwarder.execute(req, sign);
109+
await expectEvent.inTransaction(tx, ERC2771ContextMock, 'Data', { data, integerValue, stringValue });
110+
});
111+
});
112+
});
113+
});

0 commit comments

Comments
 (0)