OpenZeppelin/contracts-wizard

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.