From 9c4309afb589a6a882b232862b2e1ad70697ef9e Mon Sep 17 00:00:00 2001 From: didi Date: Mon, 10 Mar 2025 21:33:09 +0100 Subject: [PATCH 1/7] implemented permit (eip-2612) --- .../interfaces/superfluid/ISuperToken.sol | 33 +++++---- .../contracts/mocks/SuperTokenMock.t.sol | 4 +- .../contracts/superfluid/SuperToken.sol | 72 +++++++++++++++++- packages/ethereum-contracts/foundry.toml | 3 +- packages/ethereum-contracts/package.json | 1 + .../test/foundry/superfluid/SuperToken.t.sol | 74 +++++++++++++++++++ yarn.lock | 5 ++ 7 files changed, 170 insertions(+), 22 deletions(-) diff --git a/packages/ethereum-contracts/contracts/interfaces/superfluid/ISuperToken.sol b/packages/ethereum-contracts/contracts/interfaces/superfluid/ISuperToken.sol index c1a468f631..2ef5329757 100644 --- a/packages/ethereum-contracts/contracts/interfaces/superfluid/ISuperToken.sol +++ b/packages/ethereum-contracts/contracts/interfaces/superfluid/ISuperToken.sol @@ -3,6 +3,7 @@ pragma solidity >= 0.8.11; import { ISuperfluidToken } from "./ISuperfluidToken.sol"; import { IERC20, IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import { IERC20Permit } from "@openzeppelin/contracts-v5/token/ERC20/extensions/IERC20Permit.sol"; import { IERC777 } from "@openzeppelin/contracts/token/ERC777/IERC777.sol"; import { IPoolAdminNFT } from "../agreements/gdav1/IPoolAdminNFT.sol"; import { IPoolMemberNFT } from "../agreements/gdav1/IPoolMemberNFT.sol"; @@ -11,25 +12,27 @@ import { IPoolMemberNFT } from "../agreements/gdav1/IPoolMemberNFT.sol"; * @title Super token (Superfluid Token + ERC20 + ERC777) interface * @author Superfluid */ -interface ISuperToken is ISuperfluidToken, IERC20Metadata, IERC777 { +interface ISuperToken is ISuperfluidToken, IERC20Metadata, IERC777, IERC20Permit { /************************************************************************** * Errors *************************************************************************/ - error SUPER_TOKEN_CALLER_IS_NOT_OPERATOR_FOR_HOLDER(); // 0xf7f02227 - error SUPER_TOKEN_NOT_ERC777_TOKENS_RECIPIENT(); // 0xfe737d05 - error SUPER_TOKEN_INFLATIONARY_DEFLATIONARY_NOT_SUPPORTED(); // 0xe3e13698 - error SUPER_TOKEN_NO_UNDERLYING_TOKEN(); // 0xf79cf656 - error SUPER_TOKEN_ONLY_SELF(); // 0x7ffa6648 - error SUPER_TOKEN_ONLY_ADMIN(); // 0x0484acab - error SUPER_TOKEN_ONLY_GOV_OWNER(); // 0xd9c7ed08 - error SUPER_TOKEN_APPROVE_FROM_ZERO_ADDRESS(); // 0x81638627 - error SUPER_TOKEN_APPROVE_TO_ZERO_ADDRESS(); // 0xdf070274 - error SUPER_TOKEN_BURN_FROM_ZERO_ADDRESS(); // 0xba2ab184 - error SUPER_TOKEN_MINT_TO_ZERO_ADDRESS(); // 0x0d243157 - error SUPER_TOKEN_TRANSFER_FROM_ZERO_ADDRESS(); // 0xeecd6c9b - error SUPER_TOKEN_TRANSFER_TO_ZERO_ADDRESS(); // 0xe219bd39 - error SUPER_TOKEN_NFT_PROXY_ADDRESS_CHANGED(); // 0x6bef249d + error SUPER_TOKEN_CALLER_IS_NOT_OPERATOR_FOR_HOLDER(); // 0xf7f02227 + error SUPER_TOKEN_NOT_ERC777_TOKENS_RECIPIENT(); // 0xfe737d05 + error SUPER_TOKEN_INFLATIONARY_DEFLATIONARY_NOT_SUPPORTED(); // 0xe3e13698 + error SUPER_TOKEN_NO_UNDERLYING_TOKEN(); // 0xf79cf656 + error SUPER_TOKEN_ONLY_SELF(); // 0x7ffa6648 + error SUPER_TOKEN_ONLY_ADMIN(); // 0x0484acab + error SUPER_TOKEN_ONLY_GOV_OWNER(); // 0xd9c7ed08 + error SUPER_TOKEN_APPROVE_FROM_ZERO_ADDRESS(); // 0x81638627 + error SUPER_TOKEN_APPROVE_TO_ZERO_ADDRESS(); // 0xdf070274 + error SUPER_TOKEN_BURN_FROM_ZERO_ADDRESS(); // 0xba2ab184 + error SUPER_TOKEN_MINT_TO_ZERO_ADDRESS(); // 0x0d243157 + error SUPER_TOKEN_TRANSFER_FROM_ZERO_ADDRESS(); // 0xeecd6c9b + error SUPER_TOKEN_TRANSFER_TO_ZERO_ADDRESS(); // 0xe219bd39 + error SUPER_TOKEN_NFT_PROXY_ADDRESS_CHANGED(); // 0xef1b6ddf + error SUPER_TOKEN_PERMIT_EXPIRED_SIGNATURE(uint256 deadline); // 0x6e72b90f + error SUPER_TOKEN_PERMIT_INVALID_SIGNER(address signer, address owner); // 0xb6422105 /** * @dev Initialize the contract diff --git a/packages/ethereum-contracts/contracts/mocks/SuperTokenMock.t.sol b/packages/ethereum-contracts/contracts/mocks/SuperTokenMock.t.sol index 402c753631..e4eb61f788 100644 --- a/packages/ethereum-contracts/contracts/mocks/SuperTokenMock.t.sol +++ b/packages/ethereum-contracts/contracts/mocks/SuperTokenMock.t.sol @@ -67,8 +67,8 @@ contract SuperTokenStorageLayoutTester is SuperToken { require (slot == 18 && offset == 0, "_operators changed location"); // uses 4 slots - assembly { slot:= _reserve22.slot offset := _reserve22.offset } - require (slot == 22 && offset == 0, "_reserve22 changed location"); + assembly { slot:= _reserve23.slot offset := _reserve23.offset } + require (slot == 23 && offset == 0, "_reserve23 changed location"); assembly { slot:= _reserve31.slot offset := _reserve31.offset } require (slot == 31 && offset == 0, "_reserve31 changed location"); diff --git a/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol b/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol index dbb0db043f..d2b335f547 100644 --- a/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol +++ b/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol @@ -21,6 +21,9 @@ import { IERC777Recipient } from "@openzeppelin/contracts/token/ERC777/IERC777Re import { IERC777Sender } from "@openzeppelin/contracts/token/ERC777/IERC777Sender.sol"; import { Address } from "@openzeppelin/contracts/utils/Address.sol"; +import { ECDSA } from "@openzeppelin/contracts-v5/utils/cryptography/ECDSA.sol"; +import { MessageHashUtils } from "@openzeppelin/contracts-v5/utils/cryptography/MessageHashUtils.sol"; + // placeholder types needed as an intermediate step before complete removal of FlowNFTs // solhint-disable-next-line no-empty-blocks interface IConstantOutflowNFT {} @@ -37,7 +40,6 @@ contract SuperToken is SuperfluidToken, ISuperToken { - using SafeMath for uint256; using SafeCast for uint256; using Address for address; @@ -49,6 +51,10 @@ contract SuperToken is uint8 constant private _STANDARD_DECIMALS = 18; + // EIP-712 permit typehash + bytes32 constant private _PERMIT_TYPEHASH = + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"); + // solhint-disable-next-line var-name-mixedcase IConstantOutflowNFT immutable public CONSTANT_OUTFLOW_NFT; @@ -84,6 +90,9 @@ contract SuperToken is /// @dev ERC777 operators support data ERC777Helper.Operators internal _operators; + /// @dev ERC20 Nonces for EIP-2612 (permit) + mapping(address account => uint256) internal _nonces; + // NOTE: for future compatibility, these are reserved solidity slots // The sub-class of SuperToken solidity slot will start after _reserve22 @@ -91,8 +100,8 @@ contract SuperToken is // function in its respective mock contract to ensure that it doesn't break anything or lead to unexpected // behaviors/layout when upgrading - uint256 internal _reserve22; - uint256 private _reserve23; + //uint256 internal _reserve22; + uint256 internal _reserve23; uint256 private _reserve24; uint256 private _reserve25; uint256 private _reserve26; @@ -223,6 +232,62 @@ contract SuperToken is return _STANDARD_DECIMALS; } + /************************************************************************** + * ERC20 Permit (EIP-2612) + *************************************************************************/ + + /// @dev EIP-2612 Permit + function permit( + address owner, + address spender, + uint256 value, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) public override { + if (block.timestamp > deadline) revert SUPER_TOKEN_PERMIT_EXPIRED_SIGNATURE(deadline); + + bytes32 structHash = keccak256( + abi.encode( + _PERMIT_TYPEHASH, + owner, + spender, + value, + _nonces[owner]++, + deadline + ) + ); + + bytes32 hash = MessageHashUtils.toTypedDataHash(DOMAIN_SEPARATOR(), structHash); + + address signer = ECDSA.recover(hash, v, r, s); + if (signer != owner) revert SUPER_TOKEN_PERMIT_INVALID_SIGNER(signer, owner); + + _approve(owner, spender, value); + } + + /// @dev EIP-712 Domain Separator + // solhint-disable func-name-mixedcase + function DOMAIN_SEPARATOR() public view virtual returns (bytes32) { + // TODO: can be optimized: provide immutable parts from constants + return keccak256( + abi.encode( + // TYPE_HASH + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256("SuperToken"), // name + keccak256("1"), // version + block.chainid, + address(this) + ) + ); + } + + /// @dev EIP-2612 Nonces + function nonces(address owner) public view virtual returns (uint256) { + return _nonces[owner]; + } + /************************************************************************** * (private) Token Logics *************************************************************************/ @@ -905,5 +970,4 @@ contract SuperToken is if (msg.sender != admin) revert SUPER_TOKEN_ONLY_ADMIN(); _; } - } diff --git a/packages/ethereum-contracts/foundry.toml b/packages/ethereum-contracts/foundry.toml index 0d39459284..528621cecd 100644 --- a/packages/ethereum-contracts/foundry.toml +++ b/packages/ethereum-contracts/foundry.toml @@ -14,7 +14,8 @@ optimizer_runs = 200 remappings = [ '@superfluid-finance/ethereum-contracts/contracts/=packages/ethereum-contracts/contracts/', '@superfluid-finance/solidity-semantic-money/src/=packages/solidity-semantic-money/src/', - '@openzeppelin/=node_modules/@openzeppelin/', + '@openzeppelin/contracts/=node_modules/@openzeppelin/contracts/', + '@openzeppelin/contracts-v5/=node_modules/@openzeppelin/contracts-v5/', 'ds-test/=lib/forge-std/lib/ds-test/src/', 'forge-std/=lib/forge-std/src/'] out = 'packages/ethereum-contracts/build/foundry/default' diff --git a/packages/ethereum-contracts/package.json b/packages/ethereum-contracts/package.json index eff4ccd362..b21c8a51f9 100644 --- a/packages/ethereum-contracts/package.json +++ b/packages/ethereum-contracts/package.json @@ -6,6 +6,7 @@ "@decentral.ee/web3-helpers": "0.5.3", "@nomiclabs/hardhat-ethers": "2.2.3", "@openzeppelin/contracts": "4.9.6", + "@openzeppelin/contracts-v5": "npm:@openzeppelin/contracts@5.2.0", "@truffle/contract": "4.6.31", "ethereumjs-tx": "2.1.2", "ethereumjs-util": "7.1.5", diff --git a/packages/ethereum-contracts/test/foundry/superfluid/SuperToken.t.sol b/packages/ethereum-contracts/test/foundry/superfluid/SuperToken.t.sol index e0bf1e7166..37d47d98b2 100644 --- a/packages/ethereum-contracts/test/foundry/superfluid/SuperToken.t.sol +++ b/packages/ethereum-contracts/test/foundry/superfluid/SuperToken.t.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.23; import { Test } from "forge-std/Test.sol"; +import { console } from "forge-std/console.sol"; import { UUPSProxy } from "../../../contracts/upgradability/UUPSProxy.sol"; import { UUPSProxiable } from "../../../contracts/upgradability/UUPSProxiable.sol"; import { IERC20, ISuperToken, SuperToken, IConstantOutflowNFT, IConstantInflowNFT } @@ -178,4 +179,77 @@ contract SuperTokenIntegrationTest is FoundrySuperfluidTester { "testOnlyHostCanUpdateCodeWhenNoAdmin: super token logic not updated correctly" ); } + + function testPermit( + address relayer, + uint256 signerPrivKey, + uint256 amount, + address spender, + uint32 deadlineDelta + ) public { + uint256 deadline = bound(deadlineDelta, block.timestamp, block.timestamp + deadlineDelta); + amount = bound(amount, 1, type(uint96).max); + signerPrivKey = bound(signerPrivKey, 1, type(uint128).max); + address permitSigner = vm.addr(signerPrivKey); + + (ISuperToken localSuperToken) = sfDeployer.deployPureSuperToken("Super MR", "MRx", amount * 2); + localSuperToken.transfer(permitSigner, amount * 2); + uint256 nonce = localSuperToken.nonces(permitSigner); + // check nonce is 0 + assertEq(nonce, 0, "Nonce should be 0"); + + assertEq(localSuperToken.allowance(permitSigner, spender), 0, "Allowance should be 0"); + + bytes32 digest; + // stack too deep avoidance gymnastics + { + // create permit digest + bytes32 PERMIT_TYPEHASH = + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"); + bytes32 structHash = keccak256(abi.encode(PERMIT_TYPEHASH, permitSigner, spender, amount, nonce, deadline)); + digest = keccak256( + abi.encodePacked( + "\x19\x01", + localSuperToken.DOMAIN_SEPARATOR(), + structHash + ) + ); + } + + // create signature + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPrivKey, digest); + + vm.startPrank(relayer); + + // expect revert if spender doesn't match + if (spender != relayer) { + vm.expectRevert(); + localSuperToken.permit(permitSigner, relayer, amount, deadline, v, r, s); + } + + // expect revert if amount doesn't match + vm.expectRevert(); + localSuperToken.permit(permitSigner, spender, amount + 1, deadline, v, r, s); + + // expect revert if signature is invalid + vm.expectRevert(); + localSuperToken.permit(permitSigner, spender, amount, deadline, v + 1, r, s); + + // expect revert if deadline is in the past + uint256 prevBlockTS = block.timestamp; + vm.warp(block.timestamp + deadline + 1); + vm.expectRevert(); + localSuperToken.permit(permitSigner, spender, amount, deadline, v, r, s); + // restore block timestamp + vm.warp(prevBlockTS); + + // succeed with correct parameters + localSuperToken.permit(permitSigner, spender, amount, deadline, v, r, s); + + vm.stopPrank(); + + // Verify expected state changes + assertEq(localSuperToken.nonces(permitSigner), 1, "Nonce should be incremented"); + assertEq(localSuperToken.allowance(permitSigner, spender), amount, "Allowance should be set"); + } } diff --git a/yarn.lock b/yarn.lock index 584c5423e4..6727bc53ca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3318,6 +3318,11 @@ find-up "^4.1.0" fs-extra "^8.1.0" +"@openzeppelin/contracts-v5@npm:@openzeppelin/contracts@5.2.0": + version "5.2.0" + resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-5.2.0.tgz#bd020694218202b811b0ea3eec07277814c658da" + integrity sha512-bxjNie5z89W1Ea0NZLZluFh8PrFNn9DH8DQlujEok2yjsOlraUPKID5p1Wk3qdNbf6XkQ1Os2RvfiHrrXLHWKA== + "@openzeppelin/contracts@4.9.6", "@openzeppelin/contracts@^4.9.6": version "4.9.6" resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-4.9.6.tgz#2a880a24eb19b4f8b25adc2a5095f2aa27f39677" From bfd017d2c3cbf1d9215750e60a4f9d2b2b24f025 Mon Sep 17 00:00:00 2001 From: didi Date: Tue, 11 Mar 2025 11:09:52 +0100 Subject: [PATCH 2/7] remove console import --- .../ethereum-contracts/test/foundry/superfluid/SuperToken.t.sol | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/ethereum-contracts/test/foundry/superfluid/SuperToken.t.sol b/packages/ethereum-contracts/test/foundry/superfluid/SuperToken.t.sol index 37d47d98b2..a155e72e86 100644 --- a/packages/ethereum-contracts/test/foundry/superfluid/SuperToken.t.sol +++ b/packages/ethereum-contracts/test/foundry/superfluid/SuperToken.t.sol @@ -2,7 +2,6 @@ pragma solidity ^0.8.23; import { Test } from "forge-std/Test.sol"; -import { console } from "forge-std/console.sol"; import { UUPSProxy } from "../../../contracts/upgradability/UUPSProxy.sol"; import { UUPSProxiable } from "../../../contracts/upgradability/UUPSProxiable.sol"; import { IERC20, ISuperToken, SuperToken, IConstantOutflowNFT, IConstantInflowNFT } From 86056615f4c46cb8a8769140256ec771e5ed8c60 Mon Sep 17 00:00:00 2001 From: didi Date: Tue, 11 Mar 2025 16:31:15 +0100 Subject: [PATCH 3/7] can do without OZ v5 --- .../interfaces/superfluid/ISuperToken.sol | 2 +- .../contracts/superfluid/SuperToken.sol | 17 +++++++++++++---- packages/ethereum-contracts/package.json | 1 - yarn.lock | 5 ----- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/ethereum-contracts/contracts/interfaces/superfluid/ISuperToken.sol b/packages/ethereum-contracts/contracts/interfaces/superfluid/ISuperToken.sol index 2ef5329757..d01da0a3d5 100644 --- a/packages/ethereum-contracts/contracts/interfaces/superfluid/ISuperToken.sol +++ b/packages/ethereum-contracts/contracts/interfaces/superfluid/ISuperToken.sol @@ -3,7 +3,7 @@ pragma solidity >= 0.8.11; import { ISuperfluidToken } from "./ISuperfluidToken.sol"; import { IERC20, IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; -import { IERC20Permit } from "@openzeppelin/contracts-v5/token/ERC20/extensions/IERC20Permit.sol"; +import { IERC20Permit } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; import { IERC777 } from "@openzeppelin/contracts/token/ERC777/IERC777.sol"; import { IPoolAdminNFT } from "../agreements/gdav1/IPoolAdminNFT.sol"; import { IPoolMemberNFT } from "../agreements/gdav1/IPoolMemberNFT.sol"; diff --git a/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol b/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol index d2b335f547..96fb4338a0 100644 --- a/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol +++ b/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol @@ -20,9 +20,7 @@ import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol"; import { IERC777Recipient } from "@openzeppelin/contracts/token/ERC777/IERC777Recipient.sol"; import { IERC777Sender } from "@openzeppelin/contracts/token/ERC777/IERC777Sender.sol"; import { Address } from "@openzeppelin/contracts/utils/Address.sol"; - -import { ECDSA } from "@openzeppelin/contracts-v5/utils/cryptography/ECDSA.sol"; -import { MessageHashUtils } from "@openzeppelin/contracts-v5/utils/cryptography/MessageHashUtils.sol"; +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; // placeholder types needed as an intermediate step before complete removal of FlowNFTs // solhint-disable-next-line no-empty-blocks @@ -259,7 +257,18 @@ contract SuperToken is ) ); - bytes32 hash = MessageHashUtils.toTypedDataHash(DOMAIN_SEPARATOR(), structHash); + bytes32 domainSeparator = DOMAIN_SEPARATOR(); + // Get the keccak256 digest of the EIP-712 typed data (ERC-191 version `0x01`). + // solhint-disable-next-line max-line-length + // Snippet taken from https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v5.2.0/contracts/utils/cryptography/MessageHashUtils.sol + bytes32 hash; + assembly ("memory-safe") { + let ptr := mload(0x40) + mstore(ptr, hex"19_01") + mstore(add(ptr, 0x02), domainSeparator) + mstore(add(ptr, 0x22), structHash) + hash := keccak256(ptr, 0x42) + } address signer = ECDSA.recover(hash, v, r, s); if (signer != owner) revert SUPER_TOKEN_PERMIT_INVALID_SIGNER(signer, owner); diff --git a/packages/ethereum-contracts/package.json b/packages/ethereum-contracts/package.json index b21c8a51f9..eff4ccd362 100644 --- a/packages/ethereum-contracts/package.json +++ b/packages/ethereum-contracts/package.json @@ -6,7 +6,6 @@ "@decentral.ee/web3-helpers": "0.5.3", "@nomiclabs/hardhat-ethers": "2.2.3", "@openzeppelin/contracts": "4.9.6", - "@openzeppelin/contracts-v5": "npm:@openzeppelin/contracts@5.2.0", "@truffle/contract": "4.6.31", "ethereumjs-tx": "2.1.2", "ethereumjs-util": "7.1.5", diff --git a/yarn.lock b/yarn.lock index 6727bc53ca..584c5423e4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3318,11 +3318,6 @@ find-up "^4.1.0" fs-extra "^8.1.0" -"@openzeppelin/contracts-v5@npm:@openzeppelin/contracts@5.2.0": - version "5.2.0" - resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-5.2.0.tgz#bd020694218202b811b0ea3eec07277814c658da" - integrity sha512-bxjNie5z89W1Ea0NZLZluFh8PrFNn9DH8DQlujEok2yjsOlraUPKID5p1Wk3qdNbf6XkQ1Os2RvfiHrrXLHWKA== - "@openzeppelin/contracts@4.9.6", "@openzeppelin/contracts@^4.9.6": version "4.9.6" resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-4.9.6.tgz#2a880a24eb19b4f8b25adc2a5095f2aa27f39677" From d26bd928e3fd5846572131834d4305950d22baa3 Mon Sep 17 00:00:00 2001 From: didi Date: Wed, 12 Mar 2025 10:09:52 +0100 Subject: [PATCH 4/7] add IERC5267, use token name for EIP712 domain --- packages/ethereum-contracts/CHANGELOG.md | 5 ++ .../interfaces/superfluid/ISuperToken.sol | 3 +- .../contracts/superfluid/SuperToken.sol | 50 +++++++++++++++---- 3 files changed, 48 insertions(+), 10 deletions(-) diff --git a/packages/ethereum-contracts/CHANGELOG.md b/packages/ethereum-contracts/CHANGELOG.md index 80a0de65cc..27bcfda540 100644 --- a/packages/ethereum-contracts/CHANGELOG.md +++ b/packages/ethereum-contracts/CHANGELOG.md @@ -3,6 +3,11 @@ All notable changes to the ethereum-contracts will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [unreleased] + +### Added +- `SuperToken` now implements [EIP-2612](https://eips.ethereum.org/EIPS/eip-2612) (permit extension for EIP-20 signed approvals) + ## [v1.12.1] ### Added diff --git a/packages/ethereum-contracts/contracts/interfaces/superfluid/ISuperToken.sol b/packages/ethereum-contracts/contracts/interfaces/superfluid/ISuperToken.sol index d01da0a3d5..6289f227b8 100644 --- a/packages/ethereum-contracts/contracts/interfaces/superfluid/ISuperToken.sol +++ b/packages/ethereum-contracts/contracts/interfaces/superfluid/ISuperToken.sol @@ -4,6 +4,7 @@ pragma solidity >= 0.8.11; import { ISuperfluidToken } from "./ISuperfluidToken.sol"; import { IERC20, IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import { IERC20Permit } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; +import { IERC5267 } from "@openzeppelin/contracts/interfaces/IERC5267.sol"; import { IERC777 } from "@openzeppelin/contracts/token/ERC777/IERC777.sol"; import { IPoolAdminNFT } from "../agreements/gdav1/IPoolAdminNFT.sol"; import { IPoolMemberNFT } from "../agreements/gdav1/IPoolMemberNFT.sol"; @@ -12,7 +13,7 @@ import { IPoolMemberNFT } from "../agreements/gdav1/IPoolMemberNFT.sol"; * @title Super token (Superfluid Token + ERC20 + ERC777) interface * @author Superfluid */ -interface ISuperToken is ISuperfluidToken, IERC20Metadata, IERC777, IERC20Permit { +interface ISuperToken is ISuperfluidToken, IERC20Metadata, IERC777, IERC20Permit, IERC5267 { /************************************************************************** * Errors diff --git a/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol b/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol index 96fb4338a0..bf9df7f461 100644 --- a/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol +++ b/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol @@ -52,6 +52,10 @@ contract SuperToken is // EIP-712 permit typehash bytes32 constant private _PERMIT_TYPEHASH = keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"); + bytes32 constant private _EIP712_DOMAIN_TYPEHASH = + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); + + string constant private _EIP712_VERSION = "1"; // solhint-disable-next-line var-name-mixedcase IConstantOutflowNFT immutable public CONSTANT_OUTFLOW_NFT; @@ -188,14 +192,14 @@ contract SuperToken is UUPSProxiable._updateCodeAddress(newAddress); } - function changeAdmin(address newAdmin) external override onlyAdmin { + function changeAdmin(address newAdmin) external virtual override onlyAdmin { address oldAdmin = _getAdmin(); _setAdmin(newAdmin); emit AdminChanged(oldAdmin, newAdmin); } - function getAdmin() external view override returns (address) { + function getAdmin() external view virtual override returns (address) { return _getAdmin(); } @@ -243,7 +247,7 @@ contract SuperToken is uint8 v, bytes32 r, bytes32 s - ) public override { + ) public virtual override { if (block.timestamp > deadline) revert SUPER_TOKEN_PERMIT_EXPIRED_SIGNATURE(deadline); bytes32 structHash = keccak256( @@ -278,14 +282,13 @@ contract SuperToken is /// @dev EIP-712 Domain Separator // solhint-disable func-name-mixedcase - function DOMAIN_SEPARATOR() public view virtual returns (bytes32) { + function DOMAIN_SEPARATOR() public view virtual override returns (bytes32) { // TODO: can be optimized: provide immutable parts from constants return keccak256( abi.encode( - // TYPE_HASH - keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), - keccak256("SuperToken"), // name - keccak256("1"), // version + _EIP712_DOMAIN_TYPEHASH, + keccak256(bytes(_name)), + keccak256(bytes(_EIP712_VERSION)), block.chainid, address(this) ) @@ -293,10 +296,39 @@ contract SuperToken is } /// @dev EIP-2612 Nonces - function nonces(address owner) public view virtual returns (uint256) { + function nonces(address owner) public view virtual override returns (uint256) { return _nonces[owner]; } + /// @dev EIP-5267: Retrieval of EIP-712 domain + function eip712Domain() + public + view + virtual + override + returns + ( + bytes1 fields, + /* commented out to avoid warning of name clash with name() */ + string memory /*name*/, + string memory version, + uint256 chainId, + address verifyingContract, + bytes32 salt, + uint256[] memory extensions + ) + { + return ( + hex"0f", // 01111 - field "salt" not present + _name, + _EIP712_VERSION, + block.chainid, + address(this), // verifyingContract + bytes32(0), // salt + new uint256[](0) // extensions + ); + } + /************************************************************************** * (private) Token Logics *************************************************************************/ From 8627de126aac851c13de23dde31cd83531b6a4ff Mon Sep 17 00:00:00 2001 From: didi Date: Wed, 12 Mar 2025 20:23:08 +0100 Subject: [PATCH 5/7] exclude invalid values from fuzz test --- .../test/foundry/superfluid/SuperToken.t.sol | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/ethereum-contracts/test/foundry/superfluid/SuperToken.t.sol b/packages/ethereum-contracts/test/foundry/superfluid/SuperToken.t.sol index a155e72e86..cfedd62934 100644 --- a/packages/ethereum-contracts/test/foundry/superfluid/SuperToken.t.sol +++ b/packages/ethereum-contracts/test/foundry/superfluid/SuperToken.t.sol @@ -24,7 +24,7 @@ contract SuperTokenIntegrationTest is FoundrySuperfluidTester { } function testToUnderlyingAmountWithUpgrade(uint8 decimals, uint256 amount) public { - vm.assume(amount < type(uint64).max); + amount = bound(amount, 0, type(uint64).max); // We assume that most underlying tokens will not have more than 32 decimals vm.assume(decimals <= 32); (TestToken localToken, ISuperToken localSuperToken) = @@ -42,10 +42,10 @@ contract SuperTokenIntegrationTest is FoundrySuperfluidTester { function testToUnderlyingAmountWithDowngrade(uint8 decimals, uint256 upgradeAmount, uint256 downgradeAmount) public { - vm.assume(upgradeAmount < type(uint64).max); + upgradeAmount = bound(upgradeAmount, 0, type(uint64).max); // We assume that most underlying tokens will not have more than 32 decimals vm.assume(decimals <= 32); - vm.assume(downgradeAmount < upgradeAmount); + downgradeAmount = bound(downgradeAmount, 0, upgradeAmount); (TestToken localToken, ISuperToken localSuperToken) = sfDeployer.deployWrapperSuperToken("FTT", "FTT", decimals, type(uint256).max, address(0)); (uint256 underlyingAmount, uint256 adjustedAmount) = localSuperToken.toUnderlyingAmount(upgradeAmount); @@ -190,6 +190,10 @@ contract SuperTokenIntegrationTest is FoundrySuperfluidTester { amount = bound(amount, 1, type(uint96).max); signerPrivKey = bound(signerPrivKey, 1, type(uint128).max); address permitSigner = vm.addr(signerPrivKey); + // zero address is not a valid signer + vm.assume(permitSigner != address(0)); + // SuperToken doesn't allow approval to zero address + vm.assume(spender != address(0)); (ISuperToken localSuperToken) = sfDeployer.deployPureSuperToken("Super MR", "MRx", amount * 2); localSuperToken.transfer(permitSigner, amount * 2); From fd80a42341aa05cfbe6ec409ab857939cc4177fb Mon Sep 17 00:00:00 2001 From: didi Date: Fri, 21 Mar 2025 12:41:11 +0100 Subject: [PATCH 6/7] cleanup --- .../ethereum-contracts/contracts/superfluid/SuperToken.sol | 3 +-- packages/ethereum-contracts/foundry.toml | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol b/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol index bf9df7f461..f5d15ef539 100644 --- a/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol +++ b/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol @@ -102,7 +102,6 @@ contract SuperToken is // function in its respective mock contract to ensure that it doesn't break anything or lead to unexpected // behaviors/layout when upgrading - //uint256 internal _reserve22; uint256 internal _reserve23; uint256 private _reserve24; uint256 private _reserve25; @@ -283,7 +282,7 @@ contract SuperToken is /// @dev EIP-712 Domain Separator // solhint-disable func-name-mixedcase function DOMAIN_SEPARATOR() public view virtual override returns (bytes32) { - // TODO: can be optimized: provide immutable parts from constants + // Here we could squeeze out some gas by using pre-computed hashes return keccak256( abi.encode( _EIP712_DOMAIN_TYPEHASH, diff --git a/packages/ethereum-contracts/foundry.toml b/packages/ethereum-contracts/foundry.toml index 528621cecd..df8b2e2338 100644 --- a/packages/ethereum-contracts/foundry.toml +++ b/packages/ethereum-contracts/foundry.toml @@ -15,7 +15,6 @@ remappings = [ '@superfluid-finance/ethereum-contracts/contracts/=packages/ethereum-contracts/contracts/', '@superfluid-finance/solidity-semantic-money/src/=packages/solidity-semantic-money/src/', '@openzeppelin/contracts/=node_modules/@openzeppelin/contracts/', - '@openzeppelin/contracts-v5/=node_modules/@openzeppelin/contracts-v5/', 'ds-test/=lib/forge-std/lib/ds-test/src/', 'forge-std/=lib/forge-std/src/'] out = 'packages/ethereum-contracts/build/foundry/default' From f6ab680d890960ec4e667474af6106c060fef8aa Mon Sep 17 00:00:00 2001 From: didi Date: Fri, 21 Mar 2025 13:55:01 +0100 Subject: [PATCH 7/7] fix flaky test --- .../foundry/agreements/gdav1/GeneralDistributionAgreement.t.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ethereum-contracts/test/foundry/agreements/gdav1/GeneralDistributionAgreement.t.sol b/packages/ethereum-contracts/test/foundry/agreements/gdav1/GeneralDistributionAgreement.t.sol index 59be7a3a39..f7bc8156e7 100644 --- a/packages/ethereum-contracts/test/foundry/agreements/gdav1/GeneralDistributionAgreement.t.sol +++ b/packages/ethereum-contracts/test/foundry/agreements/gdav1/GeneralDistributionAgreement.t.sol @@ -446,6 +446,7 @@ contract GeneralDistributionAgreementV1IntegrationTest is FoundrySuperfluidTeste vm.assume(distributionFlowRate < minDepositFlowRate); vm.assume(distributionFlowRate > 0); vm.assume(member != address(pool)); + vm.assume(member != address(freePool)); // yes, this also happened vm.assume(member != address(0)); _addAccount(member);