Enable ERC20Permit by default
SKYBITDev3 opened this issue · 7 comments
ERC20Permit
should exist in all newly-designed ERC20 tokens to save gas.
What do you mean "to save gas"?
In the process of a token holder changing allowance, approve
was the usual way to do it, but it requires a transaction, which costs gas. permit
also allows changing allowance, but only requires a signature, which costs no gas, so it's a better way. New tokens should have permit
as a more cost-effective way to approve allowance than approve
.
Dapps these days that work with tokens check whether permit
exists in the token, e.g. by looking for selector d505accf
. Only if permit
doesn't exist in a particular token then they fall back to calling approve
.
"Costs no gas" is not really accurate, but I see that the Wizard tooltip is claiming something similar. It should be cheaper than using approve because it's one less transaction, but there is a cost associated with cashing in the permit. It's overall cheaper to use infinite approvals, but it's arguably less secure.
I would definitely recommend including ERC20Permit in a token, but for UX reasons. I agree with making it a default in Wizard.
"Costs no gas" is not really accurate
Why do you say it's not really accurate? I've used signatures as a user of platforms like OpenSea and Rarible, as well as developed dapps that check for permit
in the token and calls it instead of calling approve
. On the UI instead of a transaction screen, some info about the signature request is shown (particularly the amount) and a "sign" button. After pressing it the allowance approval is done (without any transfer of funds), then a screen appears for the transfer transaction (which of course costs gas).
Generating the signature doesn't cost anything, but the transfer function will be slightly more expensive when it includes a permit, since it has to do additional work to validate it. Overall it will probably be cheaper than having a separate approve transaction.
The important point to note is that anyone can call permit
, and it's the caller that pays the gas fee. It doesn't matter who calls permit
, as long as a valid signature from the owner was obtained and presented when required, it just proceeds.
If the token owner calls permit
, it costs him gas.
If the spender calls permit
, it costs her gas, so then the approval process is free for the owner, which makes it much better for user experience.
Here's some of my code in which spender calls permit
:
const { ethers, network } = require(`hardhat`)
async function main() {
const [ownerWallet, spenderWallet] = await ethers.getSigners()
console.log(`Using network: ${network.name} (${network.config.chainId}), RPC url: ${network.config.url}`)
const tokenContractName = `TESTERC20`
const contractAddress = `0xc45Eb76AB6A29B64FD17A0F49e214a1f0a20A59D` // localhost
const contractOfOwner = await ethers.getContractAt(tokenContractName, contractAddress, ownerWallet)
const contractOfSpender = await ethers.getContractAt(tokenContractName, contractAddress, spenderWallet)
let txRec
const totalSupply = ethers.formatUnits(await contractOfOwner.totalSupply())
console.log(`Initially:`)
console.log(`owner has ${ethers.formatUnits(await contractOfOwner.balanceOf(ownerWallet.address))} of ${totalSupply} tokens`)
console.log(`spender has ${ethers.formatUnits(await contractOfOwner.balanceOf(spenderWallet.address))} of ${totalSupply} tokens`)
console.log(`owner has allowed spender to spend ${ethers.formatUnits(await contractOfOwner.allowance(ownerWallet.address, spenderWallet.address))} tokens`)
console.log(`owner has ${ethers.formatUnits(await ethers.provider.getBalance(ownerWallet.address), `ether`)} of native currency`)
console.log(`spender has ${ethers.formatUnits(await ethers.provider.getBalance(spenderWallet.address), `ether`)} of native currency`)
const amountAllowedToSpend = ethers.parseUnits(`2`, `ether`)
if (tokenHasPermit(await contractOfOwner.getDeployedCode())) {
const deadline = Math.floor(Date.now() / 1000 + 3600)
const splitSigAsArray = await getPermitSignature(contractOfOwner, ownerWallet, spenderWallet, amountAllowedToSpend, deadline) // ownerWallet does the signing in here to permit spender to spend his tokens
console.log(`Owner has given his signature`)
console.log(`Spender is now calling token's permit function with owner's signature so that she can spend his tokens...`)
txRec = await contractOfSpender.permit( // any account can call the permit function, as long as there's a signature (values in splitSigAsArray) that proves that owner has permitted his tokens to be spent by spender. Gas is paid by permit function caller, so approval process is free for owner if he didn't call permit.
ownerWallet.address,
spenderWallet.address,
amountAllowedToSpend,
deadline,
...splitSigAsArray
)
} else {
console.log(`Owner is calling token's approve function...`)
txRec = await contractOfOwner.approve(spenderWallet.address, amountAllowedToSpend) // only owner can call approve to let spender spend his tokens. contractOfSpender.approve doesn't work.
}
await txRec.wait()
console.log(`After permit / approve:`)
console.log(`owner has allowed spender to spend ${ethers.formatUnits(await contractOfOwner.allowance(ownerWallet.address, spenderWallet.address))} tokens`)
console.log(`owner has ${ethers.formatUnits(await ethers.provider.getBalance(ownerWallet.address), `ether`)} of native currency`)
console.log(`spender has ${ethers.formatUnits(await ethers.provider.getBalance(spenderWallet.address), `ether`)} of native currency`)
txRec = await contractOfSpender.transferFrom(ownerWallet.address, spenderWallet.address, amountAllowedToSpend) // spender calls transferFrom to transfer tokens from owner
await txRec.wait()
console.log(`After transferFrom:`)
console.log(`spender took ${ethers.formatUnits(amountAllowedToSpend)} tokens from owner`)
console.log(`owner has ${ethers.formatUnits(await contractOfOwner.balanceOf(ownerWallet.address))} of ${totalSupply} tokens`)
console.log(`spender has ${ethers.formatUnits(await contractOfOwner.balanceOf(spenderWallet.address))} of ${totalSupply} tokens`)
}
const getPermitSignature = async (tokenContract, ownerWallet, spenderWallet, value, deadline) => { // owner allows spender to spend value wei tokens before deadline
const [name, nonce] = await Promise.all([
tokenContract.name(),
tokenContract.nonces(ownerWallet.address),
])
const domain = {
name,
version: `1`,
chainId: network.config.chainId,
verifyingContract: tokenContract.target
}
// console.log(`domain: ${JSON.stringify(domain)}`)
const permitTypes = {
Permit: [{
name: `owner`,
type: `address`
},
{
name: `spender`,
type: `address`
},
{
name: `value`,
type: `uint256`
},
{
name: `nonce`,
type: `uint256`
},
{
name: `deadline`,
type: `uint256`
},
],
}
const permitValues = {
owner: ownerWallet.address,
spender: spenderWallet.address,
value,
nonce,
deadline,
}
// console.log(`permitValues: ${JSON.stringify(permitValues)}`)
const signature = await ownerWallet.signTypedData(domain, permitTypes, permitValues) // owner signs the data
const { v, r, s } = ethers.Signature.from(signature)
// const recoveredAddress = ethers.verifyTypedData(
// domain,
// permitTypes,
// permitValues,
// { v, r, s }
// )
// console.log(`recoveredAddress: ${recoveredAddress}. Verification ${recoveredAddress === ownerWallet.address ? "passed" : "failed"}.`)
return [v, r, s]
}
const tokenHasPermit = bytecode => bytecode.includes(`d505accf`) && bytecode.includes(`7ecebe00`) // permit & nonces selectors
main().catch(error => {
console.error(error)
process.exitCode = 1
})
Here's the output, showing no change in owner's native currency balance:
Using network: localhost (31337), RPC url: http://127.0.0.1:8545 Initially: owner has 100.0 of 1000.0 tokens spender has 0.0 of 1000.0 tokens owner has allowed spender to spend 0.0 tokens owner has 9999.9614911964478728 of native currency spender has 10000.0 of native currency Owner has given his signature Spender is now calling token's permit function with owner's signature so that she can spend his tokens... After permit / approve: owner has allowed spender to spend 2.0 tokens owner has 9999.9614911964478728 of native currency spender has 9999.999880784353054822 of native currency After transferFrom: spender took 2.0 tokens from owner owner has 98.0 of 1000.0 tokens spender has 2.0 of 1000.0 tokens Done in 8.48s.
I hope this will be done in v5.0, at https://docs.openzeppelin.com/contracts/5.x-rc/wizard and https://wizard.openzeppelin.com.