Skip to content

Commit 59c34f5

Browse files
authored
token: erc20 permit (#213)
* token: add permit function to allow for meta-transactions
1 parent 0339f70 commit 59c34f5

File tree

2 files changed

+255
-29
lines changed

2 files changed

+255
-29
lines changed

contracts/GraphToken.sol

Lines changed: 86 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,24 @@ import "@openzeppelin/contracts/token/ERC20/ERC20Burnable.sol";
1111
* @dev This is the implementation of the ERC20 Graph Token.
1212
*/
1313
contract GraphToken is Governed, ERC20, ERC20Burnable {
14+
// -- EIP712 --
15+
// https://github.com/ethereum/EIPs/blob/master/EIPS/eip-712.md#definition-of-domainseparator
16+
17+
bytes32 private constant DOMAIN_TYPE_HASH = keccak256(
18+
"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract,bytes32 salt)"
19+
);
20+
bytes32 private constant DOMAIN_NAME_HASH = keccak256("Graph Token");
21+
bytes32 private constant DOMAIN_VERSION_HASH = keccak256("0");
22+
bytes32 private constant DOMAIN_SALT = 0x51f3d585afe6dfeb2af01bba0889a36c1db03beec88c6a4d0c53817069026afa; // Randomly generated salt
23+
bytes32 private constant PERMIT_TYPEHASH = keccak256(
24+
"Permit(address owner,address spender,uint256 nonce,uint256 expiry,bool allowed)"
25+
);
26+
1427
// -- State --
1528

29+
bytes32 private DOMAIN_SEPARATOR;
1630
mapping(address => bool) private _minters;
31+
mapping(address => uint256) public nonces;
1732

1833
// -- Events --
1934

@@ -26,7 +41,7 @@ contract GraphToken is Governed, ERC20, ERC20Burnable {
2641
}
2742

2843
/**
29-
* @dev Graph Token Contract Constructor
44+
* @dev Graph Token Contract Constructor.
3045
* @param _governor Owner address of this contract
3146
* @param _initialSupply Initial supply of GRT
3247
*/
@@ -37,35 +52,86 @@ contract GraphToken is Governed, ERC20, ERC20Burnable {
3752
{
3853
// The Governor has the initial supply of tokens
3954
_mint(_governor, _initialSupply);
55+
4056
// The Governor is the default minter
4157
_addMinter(_governor);
58+
59+
// EIP-712 domain separator
60+
DOMAIN_SEPARATOR = keccak256(
61+
abi.encode(
62+
DOMAIN_TYPE_HASH,
63+
DOMAIN_NAME_HASH,
64+
DOMAIN_VERSION_HASH,
65+
_getChainID(),
66+
address(this),
67+
DOMAIN_SALT
68+
)
69+
);
70+
}
71+
72+
/**
73+
* @dev Approve token allowance by validating a message signed by the holder.
74+
* This function will approve MAX_UINT256 tokens to be spent.
75+
* @param _owner Address of the token holder
76+
* @param _spender Address of the approved spender
77+
* @param _nonce Sequence number to avoid permit reuse
78+
* @param _expiry Expiration time of the signed permit
79+
* @param _allowed Whether to approve or dissaprove the spender
80+
* @param _v Signature version
81+
* @param _r Signature r value
82+
* @param _s Signature s value
83+
*/
84+
function permit(
85+
address _owner,
86+
address _spender,
87+
uint256 _nonce,
88+
uint256 _expiry,
89+
bool _allowed,
90+
uint8 _v,
91+
bytes32 _r,
92+
bytes32 _s
93+
) external {
94+
bytes32 digest = keccak256(
95+
abi.encodePacked(
96+
"\x19\x01",
97+
DOMAIN_SEPARATOR,
98+
keccak256(abi.encode(PERMIT_TYPEHASH, _owner, _spender, _nonce, _expiry, _allowed))
99+
)
100+
);
101+
102+
require(_owner == ecrecover(digest, _v, _r, _s), "GRT: invalid permit");
103+
require(_expiry == 0 || block.timestamp <= _expiry, "GRT: permit expired");
104+
require(_nonce == nonces[_owner]++, "GRT: invalid nonce");
105+
106+
uint256 allowance = _allowed ? uint256(-1) : 0;
107+
_approve(_owner, _spender, allowance);
42108
}
43109

44110
/**
45-
* @dev Add a new minter
111+
* @dev Add a new minter.
46112
* @param _account Address of the minter
47113
*/
48114
function addMinter(address _account) external onlyGovernor {
49115
_addMinter(_account);
50116
}
51117

52118
/**
53-
* @dev Remove a minter
119+
* @dev Remove a minter.
54120
* @param _account Address of the minter
55121
*/
56122
function removeMinter(address _account) external onlyGovernor {
57123
_removeMinter(_account);
58124
}
59125

60126
/**
61-
* @dev Renounce to be a minter
127+
* @dev Renounce to be a minter.
62128
*/
63129
function renounceMinter() external {
64130
_removeMinter(msg.sender);
65131
}
66132

67133
/**
68-
* @dev Mint new tokens
134+
* @dev Mint new tokens.
69135
* @param _to Address to send the newly minted tokens
70136
* @param _amount Amount of tokens to mint
71137
*/
@@ -74,7 +140,7 @@ contract GraphToken is Governed, ERC20, ERC20Burnable {
74140
}
75141

76142
/**
77-
* @dev Return if the `_account` is a minter or not
143+
* @dev Return if the `_account` is a minter or not.
78144
* @param _account Address to check
79145
* @return True if the `_account` is minter
80146
*/
@@ -83,7 +149,7 @@ contract GraphToken is Governed, ERC20, ERC20Burnable {
83149
}
84150

85151
/**
86-
* @dev Add a new minter
152+
* @dev Add a new minter.
87153
* @param _account Address of the minter
88154
*/
89155
function _addMinter(address _account) internal {
@@ -92,11 +158,23 @@ contract GraphToken is Governed, ERC20, ERC20Burnable {
92158
}
93159

94160
/**
95-
* @dev Remove a minter
161+
* @dev Remove a minter.
96162
* @param _account Address of the minter
97163
*/
98164
function _removeMinter(address _account) internal {
99165
_minters[_account] = false;
100166
emit MinterRemoved(_account);
101167
}
168+
169+
/**
170+
* @dev Get the running network chain ID.
171+
* @return The chain ID
172+
*/
173+
function _getChainID() private pure returns (uint256) {
174+
uint256 id;
175+
assembly {
176+
id := chainid()
177+
}
178+
return id;
179+
}
102180
}

test/graphToken.test.ts

Lines changed: 169 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,192 @@
11
import { expect } from 'chai'
22
import { AddressZero } from 'ethers/constants'
3+
import { BigNumber, Arrayish, HDNode, SigningKey, keccak256, Signature } from 'ethers/utils'
4+
import { eip712 } from '@graphprotocol/common-ts/dist/attestations'
35

46
import { GraphToken } from '../build/typechain/contracts/GraphToken'
57

68
import * as deployment from './lib/deployment'
7-
import { provider, toGRT } from './lib/testHelpers'
9+
import { getChainID, provider, toBN, toGRT } from './lib/testHelpers'
10+
11+
const MAX_UINT256 = toBN('2')
12+
.pow('256')
13+
.sub(1)
14+
15+
const PERMIT_TYPE_HASH = eip712.typeHash(
16+
'Permit(address owner,address spender,uint256 nonce,uint256 expiry,bool allowed)',
17+
)
18+
const SALT = '0x51f3d585afe6dfeb2af01bba0889a36c1db03beec88c6a4d0c53817069026afa'
19+
20+
interface Permit {
21+
owner: string
22+
spender: string
23+
nonce: BigNumber
24+
expiry: BigNumber
25+
allowed: boolean
26+
}
27+
28+
function hashEncodePermit(permit: Permit) {
29+
return eip712.hashStruct(
30+
PERMIT_TYPE_HASH,
31+
['address', 'address', 'uint256', 'uint256', 'bool'],
32+
[permit.owner, permit.spender, permit.nonce, permit.expiry, permit.allowed],
33+
)
34+
}
35+
36+
function signPermit(
37+
signer: Arrayish | HDNode.HDNode,
38+
chainId: number,
39+
contractAddress: string,
40+
permit: Permit,
41+
): Signature {
42+
const domainSeparator = eip712.domainSeparator({
43+
name: 'Graph Token',
44+
version: '0',
45+
chainId,
46+
verifyingContract: contractAddress,
47+
salt: SALT,
48+
})
49+
const hashEncodedPermit = hashEncodePermit(permit)
50+
const message = eip712.encode(domainSeparator, hashEncodedPermit)
51+
const messageHash = keccak256(message)
52+
const signingKey = new SigningKey(signer)
53+
return signingKey.signDigest(messageHash)
54+
}
855

956
describe('GraphToken', () => {
10-
const [me, governor] = provider().getWallets()
57+
const [me, other, governor] = provider().getWallets()
1158

1259
let grt: GraphToken
1360

61+
async function permitOK(): Promise<Permit> {
62+
const nonce = await grt.nonces(me.address)
63+
return {
64+
owner: me.address,
65+
spender: other.address,
66+
nonce: nonce,
67+
expiry: toBN('0'),
68+
allowed: true,
69+
}
70+
}
71+
72+
async function permitExpired(): Promise<Permit> {
73+
const permit = await permitOK()
74+
permit.expiry = toBN('1')
75+
return permit
76+
}
77+
78+
async function permitDeny(): Promise<Permit> {
79+
const permit = await permitOK()
80+
permit.allowed = false
81+
return permit
82+
}
83+
84+
async function createPermitTransaction(permit: Permit, signer: string) {
85+
const chainID = (await getChainID()) as number
86+
const signature: Signature = signPermit(signer, chainID, grt.address, permit)
87+
88+
return grt.permit(
89+
permit.owner,
90+
permit.spender,
91+
permit.nonce,
92+
permit.expiry,
93+
permit.allowed,
94+
signature.v,
95+
signature.r,
96+
signature.s,
97+
)
98+
}
99+
14100
beforeEach(async function() {
15101
// Deploy graph token
16102
grt = await deployment.deployGRT(governor.address, me)
103+
104+
// Mint some tokens
105+
const tokens = toGRT('100')
106+
await grt.connect(governor).mint(me.address, tokens)
107+
})
108+
109+
describe('permit', function() {
110+
it('should permit max token allowance', async function() {
111+
// Allow to transfer tokens
112+
const permit = await permitOK()
113+
const tx = createPermitTransaction(permit, me.privateKey)
114+
await expect(tx)
115+
.to.emit(grt, 'Approval')
116+
.withArgs(permit.owner, permit.spender, MAX_UINT256)
117+
118+
// Allowance updated
119+
const allowance = await grt.allowance(me.address, other.address)
120+
expect(allowance).to.be.eq(MAX_UINT256)
121+
122+
// Transfer tokens should work
123+
const tokens = toGRT('100')
124+
await grt.connect(other).transferFrom(me.address, other.address, tokens)
125+
})
126+
127+
it('reject use two permits with same nonce', async function() {
128+
// Allow to transfer tokens
129+
const permit = await permitOK()
130+
await createPermitTransaction(permit, me.privateKey)
131+
132+
// Try to re-use the permit
133+
const tx = createPermitTransaction(permit, me.privateKey)
134+
await expect(tx).to.revertedWith('GRT: invalid nonce')
135+
})
136+
137+
it('reject use expired permit', async function() {
138+
const permit = await permitExpired()
139+
const tx = createPermitTransaction(permit, me.privateKey)
140+
await expect(tx).to.revertedWith('GRT: permit expired')
141+
})
142+
143+
it('reject permit if holder address does not match', async function() {
144+
const permit = await permitOK()
145+
const tx = createPermitTransaction(permit, other.privateKey)
146+
await expect(tx).to.revertedWith('GRT: invalid permit')
147+
})
148+
149+
it('should deny transfer from if permit was denied', async function() {
150+
// Allow to transfer tokens
151+
const permit1 = await permitOK()
152+
await createPermitTransaction(permit1, me.privateKey)
153+
154+
// Deny transfer tokens
155+
const permit2 = await permitDeny()
156+
await createPermitTransaction(permit2, me.privateKey)
157+
158+
// Allowance updated
159+
const allowance = await grt.allowance(me.address, other.address)
160+
expect(allowance).to.be.eq(toBN('0'))
161+
162+
// Try to transfer without permit should fail
163+
const tokens = toGRT('100')
164+
const tx = grt.connect(other).transferFrom(me.address, other.address, tokens)
165+
await expect(tx).to.revertedWith('ERC20: transfer amount exceeds allowance')
166+
})
17167
})
18168

19169
describe('mint', function() {
20-
context('> when is not minter', function() {
21-
describe('addMinter()', function() {
22-
it('reject add a new minter if not allowed', async function() {
23-
const tx = grt.connect(me).addMinter(me.address)
24-
await expect(tx).to.be.revertedWith('Only Governor can call')
25-
})
170+
describe('addMinter()', function() {
171+
it('reject add a new minter if not allowed', async function() {
172+
const tx = grt.connect(me).addMinter(me.address)
173+
await expect(tx).to.be.revertedWith('Only Governor can call')
174+
})
26175

27-
it('should add a new minter', async function() {
28-
expect(await grt.isMinter(me.address)).to.be.eq(false)
29-
const tx = grt.connect(governor).addMinter(me.address)
30-
await expect(tx)
31-
.to.emit(grt, 'MinterAdded')
32-
.withArgs(me.address)
33-
expect(await grt.isMinter(me.address)).to.be.eq(true)
34-
})
176+
it('should add a new minter', async function() {
177+
expect(await grt.isMinter(me.address)).to.be.eq(false)
178+
const tx = grt.connect(governor).addMinter(me.address)
179+
await expect(tx)
180+
.to.emit(grt, 'MinterAdded')
181+
.withArgs(me.address)
182+
expect(await grt.isMinter(me.address)).to.be.eq(true)
35183
})
184+
})
36185

37-
describe('mint()', async function() {
38-
it('reject mint if not minter', async function() {
39-
const tx = grt.connect(me).mint(me.address, toGRT('100'))
40-
await expect(tx).to.be.revertedWith('Only minter can call')
41-
})
186+
describe('mint()', async function() {
187+
it('reject mint if not minter', async function() {
188+
const tx = grt.connect(me).mint(me.address, toGRT('100'))
189+
await expect(tx).to.be.revertedWith('Only minter can call')
42190
})
43191
})
44192

0 commit comments

Comments
 (0)