diff --git a/contracts/interfaces/IERC2612.sol b/contracts/interfaces/IERC2612.sol index c23d54db..f6621932 100644 --- a/contracts/interfaces/IERC2612.sol +++ b/contracts/interfaces/IERC2612.sol @@ -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 diff --git a/contracts/interfaces/_IERC2612.sol b/contracts/interfaces/_IERC2612.sol index cf6ea3f4..6dacbe3c 100644 --- a/contracts/interfaces/_IERC2612.sol +++ b/contracts/interfaces/_IERC2612.sol @@ -2,4 +2,6 @@ pragma solidity ^0.8.35; -interface _IERC2612 {} +import { _IERC20 } from './_IERC20.sol'; + +interface _IERC2612 is _IERC20 {} diff --git a/contracts/token/fungible/permit/FungibleTokenPermit.sol b/contracts/token/fungible/permit/FungibleTokenPermit.sol index 0fab2d22..cdc058c5 100644 --- a/contracts/token/fungible/permit/FungibleTokenPermit.sol +++ b/contracts/token/fungible/permit/FungibleTokenPermit.sol @@ -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'; @@ -12,7 +14,9 @@ import { IFungibleTokenPermit } from './IFungibleTokenPermit.sol'; */ abstract contract FungibleTokenPermit is IFungibleTokenPermit, - _FungibleTokenPermit + _FungibleTokenPermit, + FungibleToken, + FungibleTokenMetadata { /** * @inheritdoc IERC2612 diff --git a/contracts/token/fungible/permit/IFungibleTokenPermit.sol b/contracts/token/fungible/permit/IFungibleTokenPermit.sol index f270653d..99ecab0d 100644 --- a/contracts/token/fungible/permit/IFungibleTokenPermit.sol +++ b/contracts/token/fungible/permit/IFungibleTokenPermit.sol @@ -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 +{} diff --git a/contracts/utils/SafeERC20.sol b/contracts/utils/SafeERC20.sol index cb46edc1..e9351e63 100644 --- a/contracts/utils/SafeERC20.sol +++ b/contracts/utils/SafeERC20.sol @@ -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'; /** @@ -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( @@ -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, the existing allowance is checked; execution + * continues only if the spender already has sufficient allowance. + * @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) + revert SafeERC20__PermitFailed(); + } + /** * @notice send transaction data and check validity of return value, if present * @param token ERC20 token interface diff --git a/test/utils/SafeERC20.ts b/test/utils/SafeERC20.ts new file mode 100644 index 00000000..aaeee95a --- /dev/null +++ b/test/utils/SafeERC20.ts @@ -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'); + }); + }); + }); +});