Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Remove SafeERC20.safePermit #4582

Merged
merged 14 commits into from
Sep 11, 2023
5 changes: 5 additions & 0 deletions .changeset/green-pumpkins-end.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'openzeppelin-solidity': major
---

`SafeERC20`: Removed `safePermit` in favor of documentation-only `permit` recommendations.
35 changes: 0 additions & 35 deletions contracts/mocks/token/ERC20PermitNoRevertMock.sol

This file was deleted.

8 changes: 5 additions & 3 deletions contracts/token/ERC20/README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ There are a few core contracts that implement the behavior specified in the EIP:

Additionally there are multiple custom extensions, including:

* {ERC20Permit}: gasless approval of tokens (standardized as ERC2612).
* {ERC20Burnable}: destruction of own tokens.
* {ERC20Capped}: enforcement of a cap to the total supply when minting tokens.
* {ERC20Pausable}: ability to pause token transfers.
* {ERC20Permit}: gasless approval of tokens (standardized as ERC2612).
* {ERC20FlashMint}: token level support for flash loans through the minting and burning of ephemeral tokens (standardized as ERC3156).
* {ERC20Votes}: support for voting and vote delegation.
* {ERC20Wrapper}: wrapper to create an ERC20 backed by another ERC20, with deposit and withdraw methods. Useful in conjunction with {ERC20Votes}.
Expand All @@ -44,14 +44,16 @@ NOTE: This core set of contracts is designed to be unopinionated, allowing devel

== Extensions

{{IERC20Permit}}

{{ERC20Permit}}

{{ERC20Burnable}}

{{ERC20Capped}}

{{ERC20Pausable}}

{{ERC20Permit}}

{{ERC20Votes}}

{{ERC20Wrapper}}
Expand Down
6 changes: 3 additions & 3 deletions contracts/token/ERC20/extensions/ERC20Permit.sol
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ abstract contract ERC20Permit is ERC20, IERC20Permit, EIP712, Nonces {
constructor(string memory name) EIP712(name, "1") {}

/**
* @dev See {IERC20Permit-permit}.
* @inheritdoc IERC20Permit
*/
function permit(
address owner,
Expand Down Expand Up @@ -67,14 +67,14 @@ abstract contract ERC20Permit is ERC20, IERC20Permit, EIP712, Nonces {
}

/**
* @dev See {IERC20Permit-nonces}.
* @inheritdoc IERC20Permit
*/
function nonces(address owner) public view virtual override(IERC20Permit, Nonces) returns (uint256) {
return super.nonces(owner);
}

/**
* @dev See {IERC20Permit-DOMAIN_SEPARATOR}.
* @inheritdoc IERC20Permit
*/
// solhint-disable-next-line func-name-mixedcase
function DOMAIN_SEPARATOR() external view virtual returns (bytes32) {
Expand Down
23 changes: 23 additions & 0 deletions contracts/token/ERC20/extensions/IERC20Permit.sol
Copy link
Contributor

@frangio frangio Sep 8, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like IERC20Permit is not listed in the docs site. (https://docs.openzeppelin.com/contracts/4.x/api/token/erc20)

I added it in this PR, let's see if it looks nice.

When we have this interface/implementation separation I don't like having duplicate docs in the site. The same applies to I/AccessManager, for example. But if the interface is in the library it should be somewhere in the documentation... so I may have to accept adding it.

Using @inheritdoc leads to more duplication but it might make it nicer to navigate than a link like "See IERC20Permit.permit".

Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,26 @@ pragma solidity ^0.8.20;
* Adds the {permit} method, which can be used to change an account's ERC20 allowance (see {IERC20-allowance}) by
* presenting a message signed by the account. By not relying on {IERC20-approve}, the token holder account doesn't
* need to send a transaction, and thus is not required to hold Ether at all.
*
* ==== Security Considerations
frangio marked this conversation as resolved.
Show resolved Hide resolved
*
* There are two important considerations concerning the use of `permit`. The first is that a valid permit signature
* expresses an allowance, and it should not be assumed to convey additional meaning. In particular, it should not be
* considered as an intention to spend the approval in any specific way. The second is that because permits have
* built-in replay protection and can be submitted by anyone, they can be frontrun. A protocol that uses permits should
* take this into consideration and allow a `permit` call to fail. Combining these two aspects, a good pattern that can
* be generally recommended is:
Amxx marked this conversation as resolved.
Show resolved Hide resolved
*
* ```
* try token.permit(msg.sender, spender, value, deadline, v, r, s) {} catch {}
* ```
frangio marked this conversation as resolved.
Show resolved Hide resolved
*
* Observe that: 1) `msg.sender` is used as the owner, leaving no ambiguity as to the signer intent, and 2) the use of
* `try/catch` allows the permit to fail and makes the code tolerant to frontrunning. In general, `address(this)` would
* be used as the `spender` parameter.
*
* Additionally, note that smart contract wallets (such as Argent or Safe) are not able to produce permit signatures, so
* contracts should have entry points that don't rely on permit.
*/
interface IERC20Permit {
/**
Expand All @@ -32,6 +52,9 @@ interface IERC20Permit {
* For more information on the signature format, see the
* https://eips.ethereum.org/EIPS/eip-2612#specification[relevant EIP
* section].
*
* WARNING: relying on {permit} for user authentication is a bad practice.
* See xref:utilities.adoc#authentication-using-permit[this].
frangio marked this conversation as resolved.
Show resolved Hide resolved
*/
function permit(
address owner,
Expand Down
22 changes: 0 additions & 22 deletions contracts/token/ERC20/utils/SafeERC20.sol
Original file line number Diff line number Diff line change
Expand Up @@ -82,28 +82,6 @@ library SafeERC20 {
}
}

/**
* @dev Use a ERC-2612 signature to set the `owner` approval toward `spender` on `token`.
* Revert on invalid signature.
*/
function safePermit(
IERC20Permit token,
address owner,
address spender,
uint256 value,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) internal {
uint256 nonceBefore = token.nonces(owner);
token.permit(owner, spender, value, deadline, v, r, s);
uint256 nonceAfter = token.nonces(owner);
if (nonceAfter != nonceBefore + 1) {
revert SafeERC20FailedOperation(address(token));
}
}

/**
* @dev Imitates a Solidity high-level call (i.e. a regular function call to a contract), relaxing the requirement
* on the return value: the return value is optional (but if data is returned, it must not be false).
Expand Down
123 changes: 0 additions & 123 deletions test/token/ERC20/utils/SafeERC20.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,10 @@ const SafeERC20 = artifacts.require('$SafeERC20');
const ERC20ReturnFalseMock = artifacts.require('$ERC20ReturnFalseMock');
const ERC20ReturnTrueMock = artifacts.require('$ERC20'); // default implementation returns true
const ERC20NoReturnMock = artifacts.require('$ERC20NoReturnMock');
const ERC20PermitNoRevertMock = artifacts.require('$ERC20PermitNoRevertMock');
const ERC20ForceApproveMock = artifacts.require('$ERC20ForceApproveMock');

const { getDomain, domainType, Permit } = require('../../../helpers/eip712');
const { expectRevertCustomError } = require('../../../helpers/customError');

const { fromRpcSig } = require('ethereumjs-util');
const ethSigUtil = require('eth-sig-util');
const Wallet = require('ethereumjs-wallet').default;

const name = 'ERC20Mock';
const symbol = 'ERC20Mock';

Expand Down Expand Up @@ -122,123 +116,6 @@ contract('SafeERC20', function (accounts) {
shouldOnlyRevertOnErrors(accounts);
});

describe("with token that doesn't revert on invalid permit", function () {
const wallet = Wallet.generate();
const owner = wallet.getAddressString();
const spender = hasNoCode;

beforeEach(async function () {
this.token = await ERC20PermitNoRevertMock.new(name, symbol, name);

this.data = await getDomain(this.token).then(domain => ({
primaryType: 'Permit',
types: { EIP712Domain: domainType(domain), Permit },
domain,
message: { owner, spender, value: '42', nonce: '0', deadline: constants.MAX_UINT256 },
}));

this.signature = fromRpcSig(ethSigUtil.signTypedMessage(wallet.getPrivateKey(), { data: this.data }));
});

it('accepts owner signature', async function () {
expect(await this.token.nonces(owner)).to.be.bignumber.equal('0');
expect(await this.token.allowance(owner, spender)).to.be.bignumber.equal('0');

await this.mock.$safePermit(
this.token.address,
this.data.message.owner,
this.data.message.spender,
this.data.message.value,
this.data.message.deadline,
this.signature.v,
this.signature.r,
this.signature.s,
);

expect(await this.token.nonces(owner)).to.be.bignumber.equal('1');
expect(await this.token.allowance(owner, spender)).to.be.bignumber.equal(this.data.message.value);
});

it('revert on reused signature', async function () {
expect(await this.token.nonces(owner)).to.be.bignumber.equal('0');
// use valid signature and consume nounce
await this.mock.$safePermit(
this.token.address,
this.data.message.owner,
this.data.message.spender,
this.data.message.value,
this.data.message.deadline,
this.signature.v,
this.signature.r,
this.signature.s,
);
expect(await this.token.nonces(owner)).to.be.bignumber.equal('1');
// invalid call does not revert for this token implementation
await this.token.permit(
this.data.message.owner,
this.data.message.spender,
this.data.message.value,
this.data.message.deadline,
this.signature.v,
this.signature.r,
this.signature.s,
);
expect(await this.token.nonces(owner)).to.be.bignumber.equal('1');
// invalid call revert when called through the SafeERC20 library
await expectRevertCustomError(
this.mock.$safePermit(
this.token.address,
this.data.message.owner,
this.data.message.spender,
this.data.message.value,
this.data.message.deadline,
this.signature.v,
this.signature.r,
this.signature.s,
),
'SafeERC20FailedOperation',
[this.token.address],
);
expect(await this.token.nonces(owner)).to.be.bignumber.equal('1');
});

it('revert on invalid signature', async function () {
// signature that is not valid for owner
const invalidSignature = {
v: 27,
r: '0x71753dc5ecb5b4bfc0e3bc530d79ce5988760ed3f3a234c86a5546491f540775',
s: '0x0049cedee5aed990aabed5ad6a9f6e3c565b63379894b5fa8b512eb2b79e485d',
};

// invalid call does not revert for this token implementation
await this.token.permit(
this.data.message.owner,
this.data.message.spender,
this.data.message.value,
this.data.message.deadline,
invalidSignature.v,
invalidSignature.r,
invalidSignature.s,
);

// invalid call revert when called through the SafeERC20 library
await expectRevertCustomError(
this.mock.$safePermit(
this.token.address,
this.data.message.owner,
this.data.message.spender,
this.data.message.value,
this.data.message.deadline,
invalidSignature.v,
invalidSignature.r,
invalidSignature.s,
),
'SafeERC20FailedOperation',
[this.token.address],
);
});
});

describe('with usdt approval beaviour', function () {
const spender = hasNoCode;

Expand Down