From cba3351a71c3b7611fadf1dc4e700853dbca7667 Mon Sep 17 00:00:00 2001 From: Nick Barry Date: Sat, 13 Jun 2026 13:49:17 +0900 Subject: [PATCH 1/8] inherit IERC20 into IERC2612 --- contracts/interfaces/IERC2612.sol | 3 ++- contracts/interfaces/_IERC2612.sol | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) 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 {} From 7dd66604fa3bbd3a4027f795cd86a4c7fd6978fd Mon Sep 17 00:00:00 2001 From: Nick Barry Date: Sat, 13 Jun 2026 15:28:58 +0900 Subject: [PATCH 2/8] add SafeERC20.safePermit with front-run tolerance --- contracts/test/SafeERC20Test.sol | 74 ++++++++++++++++++++++++++++++++ contracts/utils/SafeERC20.sol | 43 +++++++++++++++++++ todo | 3 ++ 3 files changed, 120 insertions(+) create mode 100644 contracts/test/SafeERC20Test.sol create mode 100644 todo diff --git a/contracts/test/SafeERC20Test.sol b/contracts/test/SafeERC20Test.sol new file mode 100644 index 00000000..647d60fe --- /dev/null +++ b/contracts/test/SafeERC20Test.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.35; + +import { IERC20 } from '../interfaces/IERC20.sol'; +import { IERC2612 } from '../interfaces/IERC2612.sol'; +import { SafeERC20 } from '../utils/SafeERC20.sol'; + +contract SafeERC20Test { + using SafeERC20 for IERC20; + using SafeERC20 for IERC2612; + + function safeTransfer( + IERC20 token, + address to, + uint256 value + ) external { + token.safeTransfer(to, value); + } + + function safeTransferFrom( + IERC20 token, + address from, + address to, + uint256 value + ) external { + token.safeTransferFrom(from, to, value); + } + + function safeApprove( + IERC20 token, + address spender, + uint256 value + ) external { + token.safeApprove(spender, value); + } + + function safeIncreaseAllowance( + IERC20 token, + address spender, + uint256 value + ) external { + token.safeIncreaseAllowance(spender, value); + } + + function safeDecreaseAllowance( + IERC20 token, + address spender, + uint256 value + ) external { + token.safeDecreaseAllowance(spender, value); + } + + function safePermit( + IERC2612 token, + address owner, + address spender, + uint256 value, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external { + token.safePermit( + owner, + spender, + value, + deadline, + v, + r, + s + ); + } +} diff --git a/contracts/utils/SafeERC20.sol b/contracts/utils/SafeERC20.sol index cb46edc1..a207088b 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,47 @@ 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 + ) + ); + + 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/todo b/todo new file mode 100644 index 00000000..866c67be --- /dev/null +++ b/todo @@ -0,0 +1,3 @@ +ERC2612 inherit ERC20? + +stash pop and add safePermit From c3632b944e25c8f12180b151098d81ef0e4f8db0 Mon Sep 17 00:00:00 2001 From: Nick Barry Date: Sat, 13 Jun 2026 15:51:50 +0900 Subject: [PATCH 3/8] refactor SafeERC20.safePermit to require exact allowance match on revert --- contracts/utils/SafeERC20.sol | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/contracts/utils/SafeERC20.sol b/contracts/utils/SafeERC20.sol index a207088b..e9351e63 100644 --- a/contracts/utils/SafeERC20.sol +++ b/contracts/utils/SafeERC20.sol @@ -122,7 +122,10 @@ library SafeERC20 { ) ); - if (!success && token.allowance(owner, spender) < value) + // 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(); } From 980cea98ae81549680ada0f0dda808da481e7393 Mon Sep 17 00:00:00 2001 From: Nick Barry Date: Sat, 13 Jun 2026 15:57:40 +0900 Subject: [PATCH 4/8] remove redundant SafeERC20Test helper contract --- contracts/test/SafeERC20Test.sol | 74 -------------------------------- 1 file changed, 74 deletions(-) delete mode 100644 contracts/test/SafeERC20Test.sol diff --git a/contracts/test/SafeERC20Test.sol b/contracts/test/SafeERC20Test.sol deleted file mode 100644 index 647d60fe..00000000 --- a/contracts/test/SafeERC20Test.sol +++ /dev/null @@ -1,74 +0,0 @@ -// SPDX-License-Identifier: MIT - -pragma solidity ^0.8.35; - -import { IERC20 } from '../interfaces/IERC20.sol'; -import { IERC2612 } from '../interfaces/IERC2612.sol'; -import { SafeERC20 } from '../utils/SafeERC20.sol'; - -contract SafeERC20Test { - using SafeERC20 for IERC20; - using SafeERC20 for IERC2612; - - function safeTransfer( - IERC20 token, - address to, - uint256 value - ) external { - token.safeTransfer(to, value); - } - - function safeTransferFrom( - IERC20 token, - address from, - address to, - uint256 value - ) external { - token.safeTransferFrom(from, to, value); - } - - function safeApprove( - IERC20 token, - address spender, - uint256 value - ) external { - token.safeApprove(spender, value); - } - - function safeIncreaseAllowance( - IERC20 token, - address spender, - uint256 value - ) external { - token.safeIncreaseAllowance(spender, value); - } - - function safeDecreaseAllowance( - IERC20 token, - address spender, - uint256 value - ) external { - token.safeDecreaseAllowance(spender, value); - } - - function safePermit( - IERC2612 token, - address owner, - address spender, - uint256 value, - uint256 deadline, - uint8 v, - bytes32 r, - bytes32 s - ) external { - token.safePermit( - owner, - spender, - value, - deadline, - v, - r, - s - ); - } -} From a3fb5b6ac8dccf26a8940b4e9fe4b942dd90ccba Mon Sep 17 00:00:00 2001 From: Nick Barry Date: Sat, 13 Jun 2026 15:59:43 +0900 Subject: [PATCH 5/8] remove todo file --- todo | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 todo diff --git a/todo b/todo deleted file mode 100644 index 866c67be..00000000 --- a/todo +++ /dev/null @@ -1,3 +0,0 @@ -ERC2612 inherit ERC20? - -stash pop and add safePermit From 07981079936bc91b8105d2fd4d07fd9e4333e4cf Mon Sep 17 00:00:00 2001 From: Nick Barry Date: Sat, 13 Jun 2026 16:36:25 +0900 Subject: [PATCH 6/8] add SafeERC20 unit tests --- test/utils/SafeERC20.ts | 254 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 test/utils/SafeERC20.ts 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'); + }); + }); + }); +}); From 56deb6d512b0b1e93256033fee127e6c26a3431c Mon Sep 17 00:00:00 2001 From: Nick Barry Date: Sun, 14 Jun 2026 01:44:50 +0900 Subject: [PATCH 7/8] inherit FungibleToken and FungibleTokenMetadata into FungibleTokenPermit --- .../token/fungible/permit/FungibleTokenPermit.sol | 6 +++++- .../token/fungible/permit/IFungibleTokenPermit.sol | 10 +++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) 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 +{} From e8aac0c98355f6c903be86298aadae76213ed30c Mon Sep 17 00:00:00 2001 From: Nick Barry Date: Mon, 5 Oct 2026 11:46:16 +0200 Subject: [PATCH 8/8] clarify dev comment --- contracts/utils/SafeERC20.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/utils/SafeERC20.sol b/contracts/utils/SafeERC20.sol index e9351e63..bfe41aa0 100644 --- a/contracts/utils/SafeERC20.sol +++ b/contracts/utils/SafeERC20.sol @@ -88,8 +88,8 @@ 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. + * 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