Skip to content
Open
3 changes: 2 additions & 1 deletion contracts/interfaces/IERC2612.sol
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
pragma solidity ^0.8.35;

import { _IERC2612 } from './_IERC2612.sol';
import { IERC20 } from './IERC20.sol';

/**
* @title ERC2612 interface
* @dev see https://eips.ethereum.org/EIPS/eip-2612.
*/
interface IERC2612 is _IERC2612 {
interface IERC2612 is _IERC2612, IERC20 {
/**
* @notice return the EIP-712 domain separator unique to contract and chain
* @return domainSeparator domain separator
Expand Down
4 changes: 3 additions & 1 deletion contracts/interfaces/_IERC2612.sol
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@

pragma solidity ^0.8.35;

interface _IERC2612 {}
import { _IERC20 } from './_IERC20.sol';

interface _IERC2612 is _IERC20 {}
6 changes: 5 additions & 1 deletion contracts/token/fungible/permit/FungibleTokenPermit.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ pragma solidity ^0.8.35;

import { IERC2612 } from '../../../interfaces/IERC2612.sol';
import { IERC5267 } from '../../../interfaces/IERC5267.sol';
import { FungibleTokenMetadata } from '../metadata/FungibleTokenMetadata.sol';
import { FungibleToken } from '../FungibleToken.sol';
import { _FungibleTokenPermit } from './_FungibleTokenPermit.sol';
import { IFungibleTokenPermit } from './IFungibleTokenPermit.sol';

Expand All @@ -12,7 +14,9 @@ import { IFungibleTokenPermit } from './IFungibleTokenPermit.sol';
*/
abstract contract FungibleTokenPermit is
IFungibleTokenPermit,
_FungibleTokenPermit
_FungibleTokenPermit,
FungibleToken,
FungibleTokenMetadata
{
/**
* @inheritdoc IERC2612
Expand Down
10 changes: 9 additions & 1 deletion contracts/token/fungible/permit/IFungibleTokenPermit.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ pragma solidity ^0.8.35;

import { IERC2612 } from '../../../interfaces/IERC2612.sol';
import { IERC5267 } from '../../../interfaces/IERC5267.sol';
import { IFungibleTokenMetadata } from '../metadata/IFungibleTokenMetadata.sol';
import { IFungibleToken } from '../IFungibleToken.sol';
import { _IFungibleTokenPermit } from './_IFungibleTokenPermit.sol';

interface IFungibleTokenPermit is _IFungibleTokenPermit, IERC2612, IERC5267 {}
interface IFungibleTokenPermit is
_IFungibleTokenPermit,
IFungibleToken,
IFungibleTokenMetadata,
IERC2612,
IERC5267
{}
46 changes: 46 additions & 0 deletions contracts/utils/SafeERC20.sol
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
pragma solidity ^0.8.35;

import { IERC20 } from '../interfaces/IERC20.sol';
import { IERC2612 } from '../interfaces/IERC2612.sol';
import { Address } from './Address.sol';

/**
Expand All @@ -15,6 +16,7 @@ library SafeERC20 {
error SafeERC20__ApproveFromNonZeroToNonZero();
error SafeERC20__DecreaseAllowanceBelowZero();
error SafeERC20__OperationFailed();
error SafeERC20__PermitFailed();

function safeTransfer(IERC20 token, address to, uint256 value) internal {
_callOptionalReturn(
Expand Down Expand Up @@ -83,6 +85,50 @@ library SafeERC20 {
}
}

/**
* @notice approve spender to transfer tokens held by owner via ERC-2612 permit signature
* @dev tolerates reverts so that a front-run or already-executed permit does not block
* the caller. If the permit call reverts, execution continues only if the existing
* allowance equals the permitted value.
* @param token ERC-2612 permit token interface
* @param owner holder of tokens and signer of permit
* @param spender beneficiary of approval
* @param value quantity of tokens to approve
* @param deadline timestamp after which permit is invalid
* @param v secp256k1 'v' value
* @param r secp256k1 'r' value
* @param s secp256k1 's' value
*/
function safePermit(
IERC2612 token,
address owner,
address spender,
uint256 value,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) internal {
(bool success, ) = address(token).call(
abi.encodeWithSelector(
token.permit.selector,
owner,
spender,
value,
deadline,
v,
r,
s
)
);

// permit authorizes an exact amount; if the call reverted, the existing
// allowance must equal the signed value for the permit to be considered
// already executed (e.g., via front-running)
if (!success && token.allowance(owner, spender) != value)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Calls can succeed without executing a permit, bypassing this check. Many tokens have non-reverting fallbacks, including WETH9. A call to an address with no code will also return success == true.

Would it make sense to check the allowance unconditionally? For example:

if (token.allowance(owner, spender) != value) revert SafeERC20__PermitFailed();

The downside is an extra allowance call on successful permits.

revert SafeERC20__PermitFailed();
}

/**
* @notice send transaction data and check validity of return value, if present
* @param token ERC20 token interface
Expand Down
254 changes: 254 additions & 0 deletions test/utils/SafeERC20.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers';
import { deployMockContract } from '@solidstate/library';
import { $SafeERC20, $SafeERC20__factory } from '@solidstate/typechain-types';
import { expect } from 'chai';
import { ethers } from 'hardhat';

describe('SafeERC20', () => {
let instance: $SafeERC20;
let deployer: SignerWithAddress;
let receiver: SignerWithAddress;
let spender: SignerWithAddress;

beforeEach(async () => {
[deployer, receiver, spender] = await ethers.getSigners();
instance = await new $SafeERC20__factory(deployer).deploy();
});

describe('#safeTransfer', () => {
it('transfers tokens', async () => {
const mock = await deployMockContract(deployer, [
'function transfer(address,uint256) external returns (bool)',
]);

await mock.mock.transfer.withArgs(receiver.address, 1n).returns(true);

await expect(instance.$safeTransfer(mock.address, receiver.address, 1n))
.not.to.be.reverted;
});

describe('reverts if', () => {
it('transfer returns false', async () => {
const mock = await deployMockContract(deployer, [
'function transfer(address,uint256) external returns (bool)',
]);

await mock.mock.transfer.withArgs(receiver.address, 1n).returns(false);

await expect(
instance.$safeTransfer(mock.address, receiver.address, 1n),
).to.be.revertedWithCustomError(instance, 'SafeERC20__OperationFailed');
});

it('transfer reverts', async () => {
const mock = await deployMockContract(deployer, [
'function transfer(address,uint256) external returns (bool)',
]);

await mock.mock.transfer
.withArgs(receiver.address, 1n)
.revertsWithReason('TRANSFER_FAILED');

await expect(
instance.$safeTransfer(mock.address, receiver.address, 1n),
).to.be.revertedWith('TRANSFER_FAILED');
});
});
});

describe('#safeTransferFrom', () => {
it('transfers tokens', async () => {
const mock = await deployMockContract(deployer, [
'function transferFrom(address,address,uint256) external returns (bool)',
]);

await mock.mock.transferFrom
.withArgs(deployer.address, receiver.address, 1n)
.returns(true);

await expect(
instance.$safeTransferFrom(
mock.address,
deployer.address,
receiver.address,
1n,
),
).not.to.be.reverted;
});

describe('reverts if', () => {
it('transferFrom returns false', async () => {
const mock = await deployMockContract(deployer, [
'function transferFrom(address,address,uint256) external returns (bool)',
]);

await mock.mock.transferFrom
.withArgs(deployer.address, receiver.address, 1n)
.returns(false);

await expect(
instance.$safeTransferFrom(
mock.address,
deployer.address,
receiver.address,
1n,
),
).to.be.revertedWithCustomError(instance, 'SafeERC20__OperationFailed');
});

it('transferFrom reverts', async () => {
const mock = await deployMockContract(deployer, [
'function transferFrom(address,address,uint256) external returns (bool)',
]);

await mock.mock.transferFrom
.withArgs(deployer.address, receiver.address, 1n)
.revertsWithReason('TRANSFER_FROM_FAILED');

await expect(
instance.$safeTransferFrom(
mock.address,
deployer.address,
receiver.address,
1n,
),
).to.be.revertedWith('TRANSFER_FROM_FAILED');
});
});
});

describe('#safeApprove', () => {
it('approves spender', async () => {
const mock = await deployMockContract(deployer, [
'function approve(address,uint256) external returns (bool)',
]);

await mock.mock.approve.withArgs(spender.address, 1n).returns(true);

await expect(instance.$safeApprove(mock.address, spender.address, 1n)).not
.to.be.reverted;
});

describe('reverts if', () => {
it('approve returns false', async () => {
const mock = await deployMockContract(deployer, [
'function approve(address,uint256) external returns (bool)',
]);

await mock.mock.approve.withArgs(spender.address, 1n).returns(false);

await expect(
instance.$safeApprove(mock.address, spender.address, 1n),
).to.be.revertedWithCustomError(instance, 'SafeERC20__OperationFailed');
});
});
});

describe('#safeIncreaseAllowance', () => {
it('todo');
});

describe('#safeDecreaseAllowance', () => {
it('todo');
});

describe('#safePermit', () => {
it('executes permit', async () => {
const mock = await deployMockContract(deployer, [
'function permit(address,address,uint256,uint256,uint8,bytes32,bytes32) external',
]);

await mock.mock.permit.returns();

await expect(
instance.$safePermit(
mock.address,
deployer.address,
spender.address,
1n,
ethers.MaxUint256,
27,
ethers.ZeroHash,
ethers.ZeroHash,
),
).not.to.be.reverted;
});

it('tolerates front-run', async () => {
const mock = await deployMockContract(deployer, [
'function permit(address,address,uint256,uint256,uint8,bytes32,bytes32) external',
'function allowance(address,address) external view returns (uint256)',
]);

await mock.mock.permit.reverts();
await mock.mock.allowance
.withArgs(deployer.address, spender.address)
.returns(1n);

await expect(
instance.$safePermit(
mock.address,
deployer.address,
spender.address,
1n,
ethers.MaxUint256,
27,
ethers.ZeroHash,
ethers.ZeroHash,
),
).not.to.be.reverted;
});

describe('reverts if', () => {
it('permit fails and allowance is insufficient', async () => {
const mock = await deployMockContract(deployer, [
'function permit(address,address,uint256,uint256,uint8,bytes32,bytes32) external',
'function allowance(address,address) external view returns (uint256)',
]);

await mock.mock.permit.reverts();
await mock.mock.allowance
.withArgs(deployer.address, spender.address)
.returns(0n);

await expect(
instance.$safePermit(
mock.address,
deployer.address,
spender.address,
1n,
ethers.MaxUint256,
27,
ethers.ZeroHash,
ethers.ZeroHash,
),
).to.be.revertedWithCustomError(instance, 'SafeERC20__PermitFailed');
});

it('permit fails and allowance is excessive', async () => {
const mock = await deployMockContract(deployer, [
'function permit(address,address,uint256,uint256,uint8,bytes32,bytes32) external',
'function allowance(address,address) external view returns (uint256)',
]);

await mock.mock.permit.reverts();
await mock.mock.allowance
.withArgs(deployer.address, spender.address)
.returns(2n);

await expect(
instance.$safePermit(
mock.address,
deployer.address,
spender.address,
1n,
ethers.MaxUint256,
27,
ethers.ZeroHash,
ethers.ZeroHash,
),
).to.be.revertedWithCustomError(instance, 'SafeERC20__PermitFailed');
});
});
});
});