From 47419de257bb363efaae084f98a7b8dd10ea0e29 Mon Sep 17 00:00:00 2001 From: Carter Carlson Date: Wed, 26 Nov 2025 12:55:20 -0800 Subject: [PATCH 01/13] build: pin frax-standard-solidity --- package.json | 2 +- pnpm-lock.yaml | 47 ++++++++++++++++++++--------------------------- 2 files changed, 21 insertions(+), 28 deletions(-) diff --git a/package.json b/package.json index 16b551d..12f86b7 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@prb/math": "^4.1.0", "ds-test": "github:dapphub/ds-test", "forge-std": "github:foundry-rs/forge-std#60acb7aaadcce2d68e52986a0a66fe79f07d138f", - "frax-standard-solidity": "github:FraxFinance/frax-standard-solidity", + "frax-standard-solidity": "github:FraxFinance/frax-standard-solidity#v1.1.3", "solidity-bytes-utils": "github:GNSPS/solidity-bytes-utils", "solmate": "github:transmissions11/solmate#fadb2e2778adbf01c80275bfb99e5c14969d964b", "@fraxfinance/layerzero-v2-upgradeable": "github:fraxfinance/LayerZero-v2-upgradeable#deps/pin-oz", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e98b990..3bbc124 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,8 +41,8 @@ importers: specifier: github:foundry-rs/forge-std#60acb7aaadcce2d68e52986a0a66fe79f07d138f version: https://codeload.github.com/foundry-rs/forge-std/tar.gz/60acb7aaadcce2d68e52986a0a66fe79f07d138f frax-standard-solidity: - specifier: github:FraxFinance/frax-standard-solidity - version: https://codeload.github.com/FraxFinance/frax-standard-solidity/tar.gz/edd667c2be4b455176a799cff9b932386393ef8d + specifier: github:FraxFinance/frax-standard-solidity#v1.1.3 + version: https://codeload.github.com/FraxFinance/frax-standard-solidity/tar.gz/5aab6135d727fd604f31f082fd061dd983b39b23 solidity-bytes-utils: specifier: github:GNSPS/solidity-bytes-utils version: https://codeload.github.com/GNSPS/solidity-bytes-utils/tar.gz/fc502455bb2a7e26a743378df042612dd50d1eb9 @@ -73,7 +73,7 @@ importers: version: 0.1.0(prettier-plugin-solidity@1.4.3(prettier@3.6.2))(prettier@3.6.2) ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@18.19.120)(typescript@5.8.3) + version: 10.9.2(@types/node@24.10.1)(typescript@5.8.3) typescript: specifier: ^5.8.3 version: 5.8.3 @@ -285,10 +285,6 @@ packages: resolution: { integrity: sha512-zj/KGoW7zxWUE8qOI++rUM18v+VeLTTzKs/DJFkSzHpQFPD/jKKF0TrMxBfGLl3kpdELCNccvB3zmofSzm4nlA== } - "@openzeppelin/contracts@5.4.0": - resolution: - { integrity: sha512-eCYgWnLg6WO+X52I16TZt8uEjbtdkgLC0SUX/xnAksjjrQI4Xfn4iBRoI5j55dmlOhDv1Y7BoR3cU7e3WWhC6A== } - "@prb/math@4.1.0": resolution: { integrity: sha512-ef5Xrlh3BeX4xT5/Wi810dpEPq2bYPndRxgFIaKSU1F/Op/s8af03kyom+mfU7gEpvfIZ46xu8W0duiHplbBMg== } @@ -341,9 +337,9 @@ packages: resolution: { integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ== } - "@types/node@18.19.120": + "@types/node@24.10.1": resolution: - { integrity: sha512-WtCGHFXnVI8WHLxDAt5TbnCM4eSE+nI0QN2NJtwzcgMhht2eNz6V9evJrk+lwC8bCY8OWV5Ym8Jz7ZEyGnKnMA== } + { integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ== } "@types/qs@6.14.0": resolution: @@ -799,12 +795,12 @@ packages: { integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow== } engines: { node: ">= 6" } - frax-standard-solidity@https://codeload.github.com/FraxFinance/frax-standard-solidity/tar.gz/edd667c2be4b455176a799cff9b932386393ef8d: + frax-standard-solidity@https://codeload.github.com/FraxFinance/frax-standard-solidity/tar.gz/5aab6135d727fd604f31f082fd061dd983b39b23: resolution: { - tarball: https://codeload.github.com/FraxFinance/frax-standard-solidity/tar.gz/edd667c2be4b455176a799cff9b932386393ef8d, + tarball: https://codeload.github.com/FraxFinance/frax-standard-solidity/tar.gz/5aab6135d727fd604f31f082fd061dd983b39b23, } - version: 1.1.0 + version: 1.1.3 fs-extra@10.1.0: resolution: @@ -1559,9 +1555,9 @@ packages: engines: { node: ">=14.17" } hasBin: true - undici-types@5.26.5: + undici-types@7.16.0: resolution: - { integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== } + { integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw== } universalify@2.0.1: resolution: @@ -1960,8 +1956,6 @@ snapshots: "@openzeppelin/contracts@5.3.0": {} - "@openzeppelin/contracts@5.4.0": {} - "@prb/math@4.1.0": {} "@prettier/sync@0.3.0(prettier@3.6.2)": @@ -1991,15 +1985,15 @@ snapshots: "@types/fs-extra@11.0.4": dependencies: "@types/jsonfile": 6.1.4 - "@types/node": 18.19.120 + "@types/node": 24.10.1 "@types/jsonfile@6.1.4": dependencies: - "@types/node": 18.19.120 + "@types/node": 24.10.1 - "@types/node@18.19.120": + "@types/node@24.10.1": dependencies: - undici-types: 5.26.5 + undici-types: 7.16.0 "@types/qs@6.14.0": {} @@ -2379,11 +2373,10 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 - frax-standard-solidity@https://codeload.github.com/FraxFinance/frax-standard-solidity/tar.gz/edd667c2be4b455176a799cff9b932386393ef8d: + frax-standard-solidity@https://codeload.github.com/FraxFinance/frax-standard-solidity/tar.gz/5aab6135d727fd604f31f082fd061dd983b39b23: dependencies: - "@openzeppelin/contracts": 5.4.0 "@types/fs-extra": 11.0.4 - "@types/node": 18.19.120 + "@types/node": 24.10.1 change-case: 4.1.2 commander: 10.0.1 date-fns: 2.30.0 @@ -2399,7 +2392,7 @@ snapshots: solhint-plugin-prettier: 0.1.0(prettier-plugin-solidity@1.4.3(prettier@3.6.2))(prettier@3.6.2) solidity-bytes-utils: 0.8.4 toml: 3.0.0 - ts-node: 10.9.2(@types/node@18.19.120)(typescript@5.8.3) + ts-node: 10.9.2(@types/node@24.10.1)(typescript@5.8.3) typescript: 5.8.3 transitivePeerDependencies: - "@swc/core" @@ -3041,14 +3034,14 @@ snapshots: toml@3.0.0: {} - ts-node@10.9.2(@types/node@18.19.120)(typescript@5.8.3): + ts-node@10.9.2(@types/node@24.10.1)(typescript@5.8.3): dependencies: "@cspotcode/source-map-support": 0.8.1 "@tsconfig/node10": 1.0.11 "@tsconfig/node12": 1.0.11 "@tsconfig/node14": 1.0.3 "@tsconfig/node16": 1.0.4 - "@types/node": 18.19.120 + "@types/node": 24.10.1 acorn: 8.15.0 acorn-walk: 8.3.4 arg: 4.1.3 @@ -3065,7 +3058,7 @@ snapshots: typescript@5.8.3: {} - undici-types@5.26.5: {} + undici-types@7.16.0: {} universalify@2.0.1: {} From d8495e70a4114ab29a913993a1763ccfe0ada681 Mon Sep 17 00:00:00 2001 From: Carter Carlson Date: Wed, 26 Nov 2025 13:28:26 -0800 Subject: [PATCH 02/13] feat: freezers --- src/contracts/fraxtal/frxUSD/FrxUSD.sol | 31 ++++++++++++++- src/test/FrxUSD/Fraxtal/CompilanceTests.t.sol | 38 +++++++++++++++++-- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/contracts/fraxtal/frxUSD/FrxUSD.sol b/src/contracts/fraxtal/frxUSD/FrxUSD.sol index a84346a..7b05c7e 100644 --- a/src/contracts/fraxtal/frxUSD/FrxUSD.sol +++ b/src/contracts/fraxtal/frxUSD/FrxUSD.sol @@ -9,6 +9,9 @@ contract FrxUSD is ERC20PermitPermissionedOptiMintable { /// @notice Whether or not the contract is paused bool public isPaused; + /// @notice Mapping indiciating which addresses can freeze accounts + mapping(address => bool) public isFreezer; + /// @param _creator_address The contract creator /// @param _timelock_address The timelock /// @param _bridge Address of the L2 standard bridge @@ -29,6 +32,18 @@ contract FrxUSD is ERC20PermitPermissionedOptiMintable { ) {} + function addFreezer(address _freezer) external onlyOwner { + if (isFreezer[_freezer]) revert AlreadyFreezer(); + isFreezer[_freezer] = true; + emit AddFreezer(_freezer); + } + + function removeFreezer(address _freezer) external onlyOwner { + if (!isFreezer[_freezer]) revert NotFreezer(); + isFreezer[_freezer] = false; + emit RemoveFreezer(_freezer); + } + /// @notice External admin gated function to unfreeze a set of accounts /// @param _owners Array of accounts to be unfrozen function thawMany(address[] memory _owners) external onlyOwner { @@ -46,7 +61,8 @@ contract FrxUSD is ERC20PermitPermissionedOptiMintable { /// @notice External admin gated function to batch freeze a set of accounts /// @param _owners Array of accounts to be frozen - function freezeMany(address[] memory _owners) external onlyOwner { + function freezeMany(address[] memory _owners) external { + if (!isFreezer[msg.sender] && msg.sender != owner) revert NotFreezer(); uint256 len = _owners.length; for (uint256 i; i < len; ++i) { _freeze(_owners[i]); @@ -55,7 +71,8 @@ contract FrxUSD is ERC20PermitPermissionedOptiMintable { /// @notice External admin gated function to freeze a given account /// @param _owner The account to be - function freeze(address _owner) external onlyOwner { + function freeze(address _owner) external { + if (!isFreezer[msg.sender] && msg.sender != owner) revert NotFreezer(); _freeze(_owner); } @@ -140,8 +157,18 @@ contract FrxUSD is ERC20PermitPermissionedOptiMintable { /// @param account The account being thawed event AccountThawed(address account); + /// @notice Event Emitted when an address is added as a freezer + /// @param account The account being added as a freezer + event AddFreezer(address account); + + /// @notice Event Emitted when an address is removed as a freezer + /// @param account The account being removed as a freezer + event RemoveFreezer(address account); + /* ========== ERRORS ========== */ error ArrayMisMatch(); error IsPaused(); error IsFrozen(); + error NotFreezer(); + error AlreadyFreezer(); } diff --git a/src/test/FrxUSD/Fraxtal/CompilanceTests.t.sol b/src/test/FrxUSD/Fraxtal/CompilanceTests.t.sol index c198f30..d230755 100644 --- a/src/test/FrxUSD/Fraxtal/CompilanceTests.t.sol +++ b/src/test/FrxUSD/Fraxtal/CompilanceTests.t.sol @@ -55,7 +55,7 @@ contract FrxUSD_Fraxtal_Compliance is FraxTest { } _upgradeFrxUSD(); - // check that all slots less slot #12 match + // check that all slots match for (uint256 i; i < 20; i++) { bytes32 slotVal = vm.load(address(frxusd), bytes32(uint256(i))); assertEq({ left: frxusdStorageLayoutInitial[i], right: slotVal, err: "// THEN: slot value not expected" }); @@ -163,6 +163,36 @@ contract FrxUSD_Fraxtal_Compliance is FraxTest { _upgradeAndFreeze(al); } + function test_upgrade_and_addFreezer_successful() public { + _upgradeFrxUSD(); + + assertEq({ left: frxusd.isFreezer(al), right: false, err: "// THEN: al is already a freezer" }); + vm.prank(frxusd.owner()); + frxusd.addFreezer(al); + assertEq({ left: frxusd.isFreezer(al), right: true, err: "// THEN: al is not a freezer" }); + } + + function test_upgrade_and_removeFreezer_successful() public { + _upgradeFrxUSD(); + vm.prank(frxusd.owner()); + frxusd.addFreezer(al); + assertEq({ left: frxusd.isFreezer(al), right: true, err: "// THEN: al is not a freezer" }); + + vm.prank(frxusd.owner()); + frxusd.removeFreezer(al); + assertEq({ left: frxusd.isFreezer(al), right: false, err: "// THEN: al is still a freezer" }); + } + + function test_upgrade_and_freezer_freezes() public { + _upgradeFrxUSD(); + vm.prank(frxusd.owner()); + frxusd.addFreezer(al); + + vm.prank(al); + frxusd.freeze(bob); + assertEq({ left: frxusd.isFrozen(bob), right: true, err: "// THEN: bob was not frozen" }); + } + function test_upgrade_and_freeze_transfer_Reverts() public { _upgradeAndFreeze(al); @@ -500,11 +530,11 @@ contract FrxUSD_Fraxtal_Compliance is FraxTest { frxusd.unpause(); } - function test_only_owner_can_freeze() public { + function test_only_freezer_can_freeze() public { _upgradeFrxUSD(); vm.prank(badActor); - vm.expectRevert(bytes4(keccak256("OnlyOwner()"))); + vm.expectRevert(bytes4(keccak256("NotFreezer()"))); frxusd.freeze(bob); } @@ -523,7 +553,7 @@ contract FrxUSD_Fraxtal_Compliance is FraxTest { targets.push(carl); vm.prank(badActor); - vm.expectRevert(bytes4(keccak256("OnlyOwner()"))); + vm.expectRevert(bytes4(keccak256("NotFreezer()"))); frxusd.freezeMany(targets); } From 351774173030fca735efc6f1364efa8766e2304f Mon Sep 17 00:00:00 2001 From: Carter Carlson Date: Wed, 26 Nov 2025 13:55:56 -0800 Subject: [PATCH 03/13] feat: fraxtal frxUSD with signatures --- src/contracts/fraxtal/frxUSD/FrxUSD.sol | 276 ++++++++++++ src/script/.gitkeep | 14 - src/script/fraxtal/frxUSD/DeployFrxUSD.s.sol | 73 ++++ src/test/FrxUSD/Fraxtal/SignatureTests.t.sol | 436 +++++++++++++++++++ src/test/utils/SigUtils.sol | 136 ++++++ 5 files changed, 921 insertions(+), 14 deletions(-) delete mode 100644 src/script/.gitkeep create mode 100644 src/script/fraxtal/frxUSD/DeployFrxUSD.s.sol create mode 100644 src/test/FrxUSD/Fraxtal/SignatureTests.t.sol create mode 100644 src/test/utils/SigUtils.sol diff --git a/src/contracts/fraxtal/frxUSD/FrxUSD.sol b/src/contracts/fraxtal/frxUSD/FrxUSD.sol index 7b05c7e..4a8950b 100644 --- a/src/contracts/fraxtal/frxUSD/FrxUSD.sol +++ b/src/contracts/fraxtal/frxUSD/FrxUSD.sol @@ -1,8 +1,24 @@ pragma solidity ^0.8.0; import { ERC20PermitPermissionedOptiMintable } from "src/contracts/fraxtal/shared/ERC20PermitPermissionedOptiMintable.sol"; +import { SignatureChecker } from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; contract FrxUSD is ERC20PermitPermissionedOptiMintable { + /// @dev keccak256("TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)") + bytes32 private constant TRANSFER_WITH_AUTHORIZATION_TYPEHASH = + 0x7c7c6cdb67a18743f49ec6fa9b35f50d52ed05cbed4cc592e13b44501c1a2267; + + /// @dev keccak256("ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)") + bytes32 private constant RECEIVE_WITH_AUTHORIZATION_TYPEHASH = + 0xd099cc98ef71107a616c4f0f941f04c322d8e254fe26b3c6668db87aae413de8; + + /// @dev keccak256("CancelAuthorization(address authorizer,bytes32 nonce)") + bytes32 private constant CANCEL_AUTHORIZATION_TYPEHASH = + 0x158b0a9edf7a828aad02f63cd515c68ef2f50ba807396f6d12842833a1597429; + + /// @dev keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)") + bytes32 private constant PERMIT_TYPEHASH = 0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9; + /// @notice Mapping indicating which addresses are frozen mapping(address => bool) public isFrozen; @@ -12,6 +28,15 @@ contract FrxUSD is ERC20PermitPermissionedOptiMintable { /// @notice Mapping indiciating which addresses can freeze accounts mapping(address => bool) public isFreezer; + /// @notice Mapping of authorizer to nonce to authorization state used for EIP-3009 + mapping(address authorizer => mapping(bytes32 nonce => bool used)) public authorizationState; + + /// @notice Upgrade version of the contract + /// @dev Does not impact EIP712 version, which is automatically set to "1" in constructor + function version() public pure override returns (string memory) { + return "2.0.0"; + } + /// @param _creator_address The contract creator /// @param _timelock_address The timelock /// @param _bridge Address of the L2 standard bridge @@ -110,6 +135,230 @@ contract FrxUSD is ERC20PermitPermissionedOptiMintable { emit Unpaused(); } + /* ========== PERMIT ========== */ + + function permit( + address owner, + address spender, + uint256 value, + uint256 deadline, + bytes memory signature + ) external virtual { + if (block.timestamp > deadline) { + revert ERC2612ExpiredSignature(deadline); + } + + _requireIsValidSignatureNow({ + signer: owner, + structHash: keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, _useNonce(owner), deadline)), + signature: signature + }); + + _approve(owner, spender, value); + } + + /* ========== EIP-3009 ========== */ + + /// @notice The ```transferWithAuthorization``` function executes a transfer with a signed authorization according to Eip3009 + /// @dev EOA wallet signatures should be packed in the order of r, s, v + /// @dev added in v1.1.0 + /// @param from Payer's address (Authorizer) + /// @param to Payee's address + /// @param value Amount to be transferred + /// @param validAfter The block.timestamp after which the authorization is valid + /// @param validBefore The block.timestamp before which the authorization is valid + /// @param nonce Unique nonce + /// @param v ECDSA signature parameter v + /// @param r ECDSA signature parameters r + /// @param s ECDSA signature parameters s + function transferWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) external { + // Packs signature pieces into bytes + transferWithAuthorization({ + from: from, + to: to, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce, + signature: abi.encodePacked(r, s, v) + }); + } + + /// @notice The ```transferWithAuthorization``` function executes a transfer with a signed authorization + /// @dev EOA wallet signatures should be packed in the order of r, s, v + /// @param from Payer's address (Authorizer) + /// @param to Payee's address + /// @param value Amount to be transferred + /// @param validAfter The time after which this is valid (unix time) + /// @param validBefore The time before which this is valid (unix time) + /// @param nonce Unique nonce + /// @param signature Signature byte array produced by an EOA wallet or a contract wallet + function transferWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + bytes memory signature + ) public { + // Checks: authorization validity + if (block.timestamp <= validAfter) revert InvalidAuthorization(); + if (block.timestamp >= validBefore) revert ExpiredAuthorization(); + _requireUnusedAuthorization({ authorizer: from, nonce: nonce }); + + // Checks: valid signature + _requireIsValidSignatureNow({ + signer: from, + structHash: keccak256( + abi.encode(TRANSFER_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce) + ), + signature: signature + }); + + // Effects: mark authorization as used and transfer + _markAuthorizationAsUsed({ authorizer: from, nonce: nonce }); + _transfer({ from: from, to: to, value: value }); + } + + /// @notice The ```receiveWithAuthorization``` function receives a transfer with a signed authorization from the payer + /// @dev This has an additional check to ensure that the payee's address matches the caller of this function to prevent front-running attacks + /// @dev EOA wallet signatures should be packed in the order of r, s, v + /// @param from Payer's address (Authorizer) + /// @param to Payee's address + /// @param value Amount to be transferred + /// @param validAfter The block.timestamp after which the authorization is valid + /// @param validBefore The block.timestamp before which the authorization is valid + /// @param nonce Unique nonce + /// @param v ECDSA signature parameter v + /// @param r ECDSA signature parameters r + /// @param s ECDSA signature parameters s + function receiveWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) external { + // Packs signature pieces into bytes + receiveWithAuthorization({ + from: from, + to: to, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce, + signature: abi.encodePacked(r, s, v) + }); + } + + /// @notice The ```receiveWithAuthorization``` function receives a transfer with a signed authorization from the payer + /// @dev This has an additional check to ensure that the payee's address matches the caller of this function to prevent front-running attacks + /// @dev EOA wallet signatures should be packed in the order of r, s, v + /// @param from Payer's address (Authorizer) + /// @param to Payee's address + /// @param value Amount to be transferred + /// @param validAfter The block.timestamp after which the authorization is valid + /// @param validBefore The block.timestamp before which the authorization is valid + /// @param nonce Unique nonce + /// @param signature Signature byte array produced by an EOA wallet or a contract wallet + function receiveWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + bytes memory signature + ) public { + // Checks: authorization validity + if (to != msg.sender) revert InvalidPayee({ caller: msg.sender, payee: to }); + if (block.timestamp <= validAfter) revert InvalidAuthorization(); + if (block.timestamp >= validBefore) revert ExpiredAuthorization(); + _requireUnusedAuthorization({ authorizer: from, nonce: nonce }); + + // Checks: valid signature + _requireIsValidSignatureNow({ + signer: from, + structHash: keccak256( + abi.encode(RECEIVE_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce) + ), + signature: signature + }); + + // Effects: mark authorization as used and transfer + _markAuthorizationAsUsed({ authorizer: from, nonce: nonce }); + _transfer({ from: from, to: to, value: value }); + } + + /// @notice The ```cancelAuthorization``` function cancels an authorization nonce + /// @dev EOA wallet signatures should be packed in the order of r, s, v + /// @param authorizer Authorizer's address + /// @param nonce Nonce of the authorization + /// @param v ECDSA signature v value + /// @param r ECDSA signature r value + /// @param s ECDSA signature s value + function cancelAuthorization(address authorizer, bytes32 nonce, uint8 v, bytes32 r, bytes32 s) external { + cancelAuthorization({ authorizer: authorizer, nonce: nonce, signature: abi.encodePacked(r, s, v) }); + } + + /// @notice The ```cancelAuthorization``` function cancels an authorization nonce + /// @dev EOA wallet signatures should be packed in the order of r, s, v + /// @param authorizer Authorizer's address + /// @param nonce Nonce of the authorization + /// @param signature Signature byte array produced by an EOA wallet or a contract wallet + function cancelAuthorization(address authorizer, bytes32 nonce, bytes memory signature) public { + _requireUnusedAuthorization({ authorizer: authorizer, nonce: nonce }); + _requireIsValidSignatureNow({ + signer: authorizer, + structHash: keccak256(abi.encode(CANCEL_AUTHORIZATION_TYPEHASH, authorizer, nonce)), + signature: signature + }); + + authorizationState[authorizer][nonce] = true; + emit AuthorizationCanceled({ authorizer: authorizer, nonce: nonce }); + } + + /* ========== INTERNAL METHODS ========== */ + function _requireIsValidSignatureNow(address signer, bytes32 structHash, bytes memory signature) internal view { + if ( + !SignatureChecker.isValidSignatureNow({ + signer: signer, + hash: _hashTypedDataV4({ structHash: structHash }), + signature: signature + }) || signer == address(0) + ) revert InvalidSignature(); + } + + /// @notice The ```_requireUnusedAuthorization``` checks that an authorization nonce is unused + /// @param authorizer Authorizer's address + /// @param nonce Nonce of the authorization + function _requireUnusedAuthorization(address authorizer, bytes32 nonce) private view { + if (authorizationState[authorizer][nonce]) revert UsedOrCanceledAuthorization(); + } + + /// @notice The ```_markAuthorizationAsUsed``` function marks an authorization nonce as used + /// @param authorizer Authorizer's address + /// @param nonce Nonce of the authorization + function _markAuthorizationAsUsed(address authorizer, bytes32 nonce) private { + authorizationState[authorizer][nonce] = true; + emit AuthorizationUsed({ authorizer: authorizer, nonce: nonce }); + } + /* ========== Internals For Admin Gated ========== */ /// @notice Internal helper function to freeze an account @@ -143,6 +392,16 @@ contract FrxUSD is ERC20PermitPermissionedOptiMintable { } /* ========== EVENTS ========== */ + /// @notice ```AuthorizationUsed``` event is emitted when an authorization is used + /// @param authorizer Authorizer's address + /// @param nonce Nonce of the authorization + event AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce); + + /// @notice ```AuthorizationCanceled``` event is emitted when an authorization is canceled + /// @param authorizer Authorizer's address + /// @param nonce Nonce of the authorization + event AuthorizationCanceled(address indexed authorizer, bytes32 indexed nonce); + /// @notice Event Emitted when the contract is paused event Paused(); @@ -171,4 +430,21 @@ contract FrxUSD is ERC20PermitPermissionedOptiMintable { error IsFrozen(); error NotFreezer(); error AlreadyFreezer(); + + /// @notice Error thrown when a signature is invalid + error InvalidSignature(); + + /// @notice The ```InvalidPayee``` error is emitted when the payee does not match sender in receiveWithAuthorization + /// @param caller The caller of the function + /// @param payee The expected payee in the function + error InvalidPayee(address caller, address payee); + + /// @notice The ```InvalidAuthorization``` error is emitted when the authorization is invalid because its too early + error InvalidAuthorization(); + + /// @notice The ```ExpiredAuthorization``` error is emitted when the authorization is expired + error ExpiredAuthorization(); + + /// @notice The ```UsedOrCanceledAuthorization``` error is emitted when the authorization nonce is already used or canceled + error UsedOrCanceledAuthorization(); } diff --git a/src/script/.gitkeep b/src/script/.gitkeep deleted file mode 100644 index c005c10..0000000 --- a/src/script/.gitkeep +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright 2025 carter -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// https://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - diff --git a/src/script/fraxtal/frxUSD/DeployFrxUSD.s.sol b/src/script/fraxtal/frxUSD/DeployFrxUSD.s.sol new file mode 100644 index 0000000..da1d715 --- /dev/null +++ b/src/script/fraxtal/frxUSD/DeployFrxUSD.s.sol @@ -0,0 +1,73 @@ +pragma solidity ^0.8.0; + +import { BaseScript } from "frax-std/BaseScript.sol"; +import { ERC1967Utils } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Utils.sol"; +import { ProxyAdmin, ITransparentUpgradeableProxy } from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; +import { console } from "forge-std/console.sol"; + +import { FrxUSD } from "src/contracts/fraxtal/frxUSD/FrxUSD.sol"; + +import { SafeTx, SafeTxHelper } from "frax-std/SafeTxHelper.sol"; + +address constant FRXUSD_PROXY = 0xFc00000000000000000000000000000000000001; + +// forge script src/script/fraxtal/frxUSD/DeployFrxUSD.s.sol --rpc-url https://rpc.frax.com TODO: verify +contract DeployFrxUSD is BaseScript { + address public proxyAdmin; + address public owner; + address public implementation; + SafeTx[] public txs; + SafeTxHelper public txHelper; + + bool public isTest = false; + + function setUp() public override { + bytes32 adminSlot = vm.load(FRXUSD_PROXY, ERC1967Utils.ADMIN_SLOT); + proxyAdmin = address(uint160(uint256(adminSlot))); + owner = ProxyAdmin(proxyAdmin).owner(); + + txHelper = new SafeTxHelper(); + } + + function run() public { + deployFrxUsd(); + generateMsigTx(); + } + + function runTest() public { + isTest = true; + run(); + } + + function deployFrxUsd() public broadcaster { + implementation = address( + new FrxUSD( + address(1), + address(1), + address(0x4200000000000000000000000000000000000010), + address(0xCAcd6fd266aF91b8AeD52aCCc382b4e165586E29) + ) + ); + require(implementation != address(0), "Failed implementation"); + } + + function generateMsigTx() public { + bytes memory initializeData = abi.encodeWithSignature("totalSupply()"); + bytes memory upgradeData = abi.encodeCall( + ProxyAdmin.upgradeAndCall, + (ITransparentUpgradeableProxy(payable(FRXUSD_PROXY)), implementation, initializeData) + ); + vm.prank(owner); + (bool success, ) = proxyAdmin.call(upgradeData); + require(success, "Upgrade failed"); + + if (isTest) return; // skip writing to file in test mode + + txs.push(SafeTx({ name: "upgrade", to: proxyAdmin, value: 0, data: upgradeData })); + string memory root = vm.projectRoot(); + string memory filename = string.concat(root, "/src/script/fraxtal/frxUSD/DeployFrxUSD.json"); + txHelper.writeTxs(txs, filename); + + console.log("Deploy msig tx from %s", owner); + } +} diff --git a/src/test/FrxUSD/Fraxtal/SignatureTests.t.sol b/src/test/FrxUSD/Fraxtal/SignatureTests.t.sol new file mode 100644 index 0000000..45d501b --- /dev/null +++ b/src/test/FrxUSD/Fraxtal/SignatureTests.t.sol @@ -0,0 +1,436 @@ +pragma solidity ^0.8.0; + +import "frax-std/FraxTest.sol"; +import "src/script/fraxtal/frxUSD/DeployFrxUSD.s.sol"; + +import { SigUtils } from "src/test/utils/SigUtils.sol"; + +contract TestFrxUSDSignatures is FraxTest { + FrxUSD public frxUsd; + DeployFrxUSD public deployFrxUsd; + SigUtils public sigUtils; + + uint256 BLOCK_NUM = 28_000_000; + uint256 alPrivateKey = 0x42; + address al = vm.addr(alPrivateKey); + address bob = vm.addr(0xb0b); + address owner = vm.addr(0x12345); + uint256 value = 1e18; + bytes32 nonce = bytes32(abi.encode(1)); // Example nonce, can be any value + uint256 validAfter; + uint256 validBefore; + + function setUp() public { + vm.createSelectFork("https://rpc.frax.com", BLOCK_NUM); + + deployFrxUsd = new DeployFrxUSD(); + deployFrxUsd.setUp(); + deployFrxUsd.runTest(); + + frxUsd = FrxUSD(FRXUSD_PROXY); + sigUtils = new SigUtils(frxUsd.DOMAIN_SEPARATOR()); + + validAfter = block.timestamp - 1; + validBefore = block.timestamp + 1 days; + + deal(address(frxUsd), al, 100e18); + deal(address(frxUsd), bob, 100e18); + } + + function test_Deploy_StorageMatches() public { + // create a fresh fork instance before deploy + vm.createSelectFork("https://rpc.frax.com", BLOCK_NUM - 1); + + // load pre-existing storage to memory + bytes32[] memory slots = new bytes32[](20); + for (uint256 i = 0; i < slots.length; i++) { + slots[i] = vm.load(FRXUSD_PROXY, bytes32(i)); + } + + // simulate deploy + deployFrxUsd = new DeployFrxUSD(); + deployFrxUsd.setUp(); + deployFrxUsd.runTest(); + + // validate slots post-upgrade + for (uint256 i = 0; i < slots.length; i++) { + require(slots[i] == vm.load(FRXUSD_PROXY, bytes32(i)), "Storage overwritten"); + } + } + + function test_Permit_succeeds() external { + /// al permits to bob + uint256 permitAllowanceBefore = frxUsd.allowance(al, bob); + assertEq(permitAllowanceBefore, 0, "Permit allowance should be 0 beforehand"); + + uint256 deadline = block.timestamp + 1 days; + SigUtils.Permit memory permit = SigUtils.Permit({ + owner: al, + spender: bob, + value: 1e18, + nonce: frxUsd.nonces(al), + deadline: deadline + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(alPrivateKey, sigUtils.getPermitTypedDataHash(permit)); + + vm.prank(bob); + frxUsd.permit({ + owner: permit.owner, + spender: permit.spender, + value: permit.value, + deadline: permit.deadline, + v: v, + r: r, + s: s + }); + + uint256 permitAllowanceAfter = frxUsd.allowance(al, bob); + assertEq(permitAllowanceAfter, 1e18, "Permit allowance should now be 1e18"); + } + + function test_TransferWithAuthorization_succeeds() public { + uint256 balanceBefore = frxUsd.balanceOf(bob); + assertEq(balanceBefore, 100e18, "Bob's balance should be 100e18 before transfer"); + + // al authorized bob to transfer 1e18 from al to bob + SigUtils.Authorization memory authorization = SigUtils.Authorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + alPrivateKey, + sigUtils.getTransferWithAuthorizationTypedDataHash(authorization) + ); + + vm.prank(bob); + frxUsd.transferWithAuthorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce, + v: v, + r: r, + s: s + }); + + uint256 balanceAfter = frxUsd.balanceOf(bob); + assertEq(balanceAfter, balanceBefore + value, "Bob's balance should now be 101e18"); + assertTrue(frxUsd.authorizationState(al, nonce), "Authorization should be marked as used"); + } + + function test_ReceiveWithAuthorization_succeeds() public { + uint256 balanceBefore = frxUsd.balanceOf(bob); + assertEq(balanceBefore, 100e18, "Bob's balance should be 100e18 before transfer"); + + // al authorized bob to transfer 1e18 from al to bob + SigUtils.Authorization memory authorization = SigUtils.Authorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + alPrivateKey, + sigUtils.getReceiveWithAuthorizationTypedDataHash(authorization) + ); + + vm.prank(bob); + frxUsd.receiveWithAuthorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce, + v: v, + r: r, + s: s + }); + + uint256 balanceAfter = frxUsd.balanceOf(bob); + assertEq(balanceAfter, balanceBefore + value, "Bob's balance should now be 101e18"); + assertTrue(frxUsd.authorizationState(al, nonce), "Authorization should be marked as used"); + } + + function test_CancelAuthorization_succeeds() public { + // al authorizes bob to transfer 1e18 from al to bob + SigUtils.CancelAuthorization memory cancelAuthorization = SigUtils.CancelAuthorization({ + authorizer: al, + nonce: nonce + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + alPrivateKey, + sigUtils.getCancelAuthorizationTypedDataHash(cancelAuthorization) + ); + + // al cancels the authorization + vm.prank(al); + frxUsd.cancelAuthorization({ authorizer: al, nonce: nonce, v: v, r: r, s: s }); + + assertTrue(frxUsd.authorizationState(al, nonce), "Authorization should be marked as used"); + + SigUtils.Authorization memory authorization = SigUtils.Authorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce + }); + (v, r, s) = vm.sign(alPrivateKey, sigUtils.getTransferWithAuthorizationTypedDataHash(authorization)); + + // try to transfer with authorization should fail + vm.expectRevert(FrxUSD.UsedOrCanceledAuthorization.selector); + vm.prank(bob); + frxUsd.transferWithAuthorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce, + v: v, + r: r, + s: s + }); + } + + function test_CancelAuthorization_UsedOrCanceledAuthorization_reverts() external { + // Successful auth with nonce 1 + test_CancelAuthorization_succeeds(); + + SigUtils.CancelAuthorization memory cancelAuthorization = SigUtils.CancelAuthorization({ + authorizer: al, + nonce: nonce + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + alPrivateKey, + sigUtils.getCancelAuthorizationTypedDataHash(cancelAuthorization) + ); + + // al cancels the authorization + vm.prank(al); + vm.expectRevert(FrxUSD.UsedOrCanceledAuthorization.selector); + frxUsd.cancelAuthorization({ authorizer: al, nonce: nonce, v: v, r: r, s: s }); + } + + function test_TransferWithAuthorization_UsedOrCanceledAuthorization_reverts() external { + // Successful auth with nonce 1 + test_TransferWithAuthorization_succeeds(); + + SigUtils.Authorization memory authorization = SigUtils.Authorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + alPrivateKey, + sigUtils.getTransferWithAuthorizationTypedDataHash(authorization) + ); + + // Try to transfer with authorization should fail + vm.expectRevert(FrxUSD.UsedOrCanceledAuthorization.selector); + vm.prank(bob); + frxUsd.transferWithAuthorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce, + v: v, + r: r, + s: s + }); + } + + function test_ReceiveWithAuthorization_UsedOrCanceledAuthorization_reverts() external { + // Successful auth with nonce 1 + test_ReceiveWithAuthorization_succeeds(); + + SigUtils.Authorization memory authorization = SigUtils.Authorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + alPrivateKey, + sigUtils.getReceiveWithAuthorizationTypedDataHash(authorization) + ); + + // Try to receive with authorization should fail + vm.expectRevert(FrxUSD.UsedOrCanceledAuthorization.selector); + vm.prank(bob); + frxUsd.receiveWithAuthorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce, + v: v, + r: r, + s: s + }); + } + + function test_TransferWithAuthorization_InvalidAuthorization_reverts() external { + // Try to transfer with authorization should fail + vm.expectRevert(FrxUSD.InvalidAuthorization.selector); + vm.prank(bob); + frxUsd.transferWithAuthorization({ + from: al, + to: bob, + value: value, + validAfter: block.timestamp, + validBefore: validBefore, + nonce: nonce, + v: uint8(0), + r: bytes32(0), + s: bytes32(0) + }); + } + + function test_ReceiveWithAuthorization_InvalidAuthorization_reverts() external { + // Try to transfer with authorization should fail + vm.expectRevert(FrxUSD.InvalidAuthorization.selector); + vm.prank(bob); + frxUsd.receiveWithAuthorization({ + from: al, + to: bob, + value: value, + validAfter: block.timestamp, + validBefore: validBefore, + nonce: nonce, + v: uint8(0), + r: bytes32(0), + s: bytes32(0) + }); + } + + function test_TransferWithAuthorization_ExpiredAuthorization_reverts() external { + // Try to transfer with authorization should fail + vm.expectRevert(FrxUSD.ExpiredAuthorization.selector); + vm.prank(bob); + frxUsd.transferWithAuthorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: block.timestamp, + nonce: nonce, + v: uint8(0), + r: bytes32(0), + s: bytes32(0) + }); + } + + function test_ReceiveWithAuthorization_ExpiredAuthorization_reverts() external { + // Try to transfer with authorization should fail + vm.expectRevert(FrxUSD.ExpiredAuthorization.selector); + vm.prank(bob); + frxUsd.receiveWithAuthorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: block.timestamp, + nonce: nonce, + v: uint8(0), + r: bytes32(0), + s: bytes32(0) + }); + } + + function test_ReceiveWithAuthorization_InvalidPayee_reverts() external { + vm.expectRevert(abi.encodeWithSelector(FrxUSD.InvalidPayee.selector, bob, owner)); + vm.prank(bob); + frxUsd.receiveWithAuthorization({ + from: al, + to: owner, // Invalid payee + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce, + v: uint8(0), + r: bytes32(0), + s: bytes32(0) + }); + } + + function test_TransferWithAuthorization_InvalidSignature_reverts() external { + // al authorized bob to transfer 1e18 from al to bob + SigUtils.Authorization memory authorization = SigUtils.Authorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + alPrivateKey, + sigUtils.getTransferWithAuthorizationTypedDataHash(authorization) + ); + + // bob tries to transfer from al to owner, which is not conformed to the signature + vm.prank(bob); + vm.expectRevert(FrxUSD.InvalidSignature.selector); + frxUsd.transferWithAuthorization({ + from: al, + to: owner, // note: this is causing the revert + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce, + v: v, + r: r, + s: s + }); + } + + function test_ReceiveWithAuthorization_InvalidSignature_reverts() external { + // al authorized owner to transfer 1e18 from al to bob + SigUtils.Authorization memory authorization = SigUtils.Authorization({ + from: al, + to: owner, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + alPrivateKey, + sigUtils.getReceiveWithAuthorizationTypedDataHash(authorization) + ); + + // bob tries to receive the tokens, which is not conformed to the signature + vm.prank(bob); + vm.expectRevert(FrxUSD.InvalidSignature.selector); + frxUsd.receiveWithAuthorization({ + from: al, + to: bob, // note: this is causing the revert + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce, + v: v, + r: r, + s: s + }); + } +} diff --git a/src/test/utils/SigUtils.sol b/src/test/utils/SigUtils.sol new file mode 100644 index 0000000..f9c01b2 --- /dev/null +++ b/src/test/utils/SigUtils.sol @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: ISC +pragma solidity ^0.8.0; + +// NO NEED TO AUDIT. USED FOR TESTS ONLY +contract SigUtils { + bytes32 internal DOMAIN_SEPARATOR; + + constructor(bytes32 _DOMAIN_SEPARATOR) { + DOMAIN_SEPARATOR = _DOMAIN_SEPARATOR; + } + + // keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"); + bytes32 public constant PERMIT_TYPEHASH = 0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9; + + // keccak256("TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)") + bytes32 public constant TRANSFER_WITH_AUTHORIZATION_TYPEHASH = + 0x7c7c6cdb67a18743f49ec6fa9b35f50d52ed05cbed4cc592e13b44501c1a2267; + + // keccak256("ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)") + bytes32 public constant RECEIVE_WITH_AUTHORIZATION_TYPEHASH = + 0xd099cc98ef71107a616c4f0f941f04c322d8e254fe26b3c6668db87aae413de8; + + // keccak256("CancelAuthorization(address authorizer,bytes32 nonce)") + bytes32 public constant CANCEL_AUTHORIZATION_TYPEHASH = + 0x158b0a9edf7a828aad02f63cd515c68ef2f50ba807396f6d12842833a1597429; + + struct Permit { + address owner; + address spender; + uint256 value; + uint256 nonce; + uint256 deadline; + } + + function getPermitStructHash(Permit memory _permit) internal pure returns (bytes32) { + return + keccak256( + abi.encode( + PERMIT_TYPEHASH, + _permit.owner, + _permit.spender, + _permit.value, + _permit.nonce, + _permit.deadline + ) + ); + } + + function getPermitTypedDataHash(Permit memory _permit) public view returns (bytes32) { + return keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, getPermitStructHash(_permit))); + } + + struct Authorization { + address from; + address to; + uint256 value; + uint256 validAfter; + uint256 validBefore; + bytes32 nonce; + } + + function getTransferWithAuthorizationStructHash( + Authorization memory _authorization + ) internal pure returns (bytes32) { + return + keccak256( + abi.encode( + TRANSFER_WITH_AUTHORIZATION_TYPEHASH, + _authorization.from, + _authorization.to, + _authorization.value, + _authorization.validAfter, + _authorization.validBefore, + _authorization.nonce + ) + ); + } + + function getTransferWithAuthorizationTypedDataHash( + Authorization memory _authorization + ) public view returns (bytes32) { + return + keccak256( + abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, getTransferWithAuthorizationStructHash(_authorization)) + ); + } + + function getReceivewithAuthorizationStructHash( + Authorization memory _authorization + ) internal pure returns (bytes32) { + return + keccak256( + abi.encode( + RECEIVE_WITH_AUTHORIZATION_TYPEHASH, + _authorization.from, + _authorization.to, + _authorization.value, + _authorization.validAfter, + _authorization.validBefore, + _authorization.nonce + ) + ); + } + + function getReceiveWithAuthorizationTypedDataHash( + Authorization memory _authorization + ) public view returns (bytes32) { + return + keccak256( + abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, getReceivewithAuthorizationStructHash(_authorization)) + ); + } + + struct CancelAuthorization { + address authorizer; + bytes32 nonce; + } + + function getCancelAuthorizationStructHash( + CancelAuthorization memory _cancelAuthorization + ) internal pure returns (bytes32) { + return + keccak256( + abi.encode(CANCEL_AUTHORIZATION_TYPEHASH, _cancelAuthorization.authorizer, _cancelAuthorization.nonce) + ); + } + + function getCancelAuthorizationTypedDataHash( + CancelAuthorization memory _cancelAuthorization + ) public view returns (bytes32) { + return + keccak256( + abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, getCancelAuthorizationStructHash(_cancelAuthorization)) + ); + } +} From d438a83983c3b3acb0baac1e9ab7f28dc060ce5c Mon Sep 17 00:00:00 2001 From: Carter Carlson Date: Mon, 1 Dec 2025 08:47:24 -0800 Subject: [PATCH 04/13] ci: write to files --- foundry.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/foundry.toml b/foundry.toml index fd87a18..d1da3e4 100644 --- a/foundry.toml +++ b/foundry.toml @@ -16,7 +16,7 @@ script = "src/script" # A list of paths to look for libraries in libs = ['lib', 'node_modules'] # Whether or not to enable `vm.ffi` -ffi = false +ffi = true # Enables or disables the optimizer optimizer = true # The number of optimizer runs @@ -29,6 +29,7 @@ bytecode_hash = "none" cbor_metadata = false # Contracts to track with --gas-report #gas_reports = [] +fs_permissions = [{ access = "read-write", path = "./"}] [fuzz] # Amount of runs per fuzz test From b48eba2eca83c8148e597d7ae024592bb44de685 Mon Sep 17 00:00:00 2001 From: Carter Carlson Date: Mon, 1 Dec 2025 08:47:57 -0800 Subject: [PATCH 05/13] fix: pin versions --- src/contracts/fraxtal/frxUSD/FrxUSD.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/contracts/fraxtal/frxUSD/FrxUSD.sol b/src/contracts/fraxtal/frxUSD/FrxUSD.sol index 4a8950b..5e13f52 100644 --- a/src/contracts/fraxtal/frxUSD/FrxUSD.sol +++ b/src/contracts/fraxtal/frxUSD/FrxUSD.sol @@ -1,7 +1,7 @@ pragma solidity ^0.8.0; import { ERC20PermitPermissionedOptiMintable } from "src/contracts/fraxtal/shared/ERC20PermitPermissionedOptiMintable.sol"; -import { SignatureChecker } from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; +import { SignatureChecker } from "@openzeppelin/contracts-5.2.0/utils/cryptography/SignatureChecker.sol"; contract FrxUSD is ERC20PermitPermissionedOptiMintable { /// @dev keccak256("TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)") @@ -34,7 +34,7 @@ contract FrxUSD is ERC20PermitPermissionedOptiMintable { /// @notice Upgrade version of the contract /// @dev Does not impact EIP712 version, which is automatically set to "1" in constructor function version() public pure override returns (string memory) { - return "2.0.0"; + return "3.0.0"; } /// @param _creator_address The contract creator From 59b078dab88b7981a7923a571d9918a7b70ebce2 Mon Sep 17 00:00:00 2001 From: Carter Carlson Date: Mon, 1 Dec 2025 08:48:27 -0800 Subject: [PATCH 06/13] feat: 3009 to fraxtal sfrxUSD --- src/contracts/fraxtal/sfrxUSD/SfrxUSD.sol | 280 +++++++++++ .../fraxtal/sfrxUSD/DeploySfrxUSD.s.sol | 73 +++ src/test/SfrxUSD/Fraxtal/SignatureTests.t.sol | 436 ++++++++++++++++++ 3 files changed, 789 insertions(+) create mode 100644 src/script/fraxtal/sfrxUSD/DeploySfrxUSD.s.sol create mode 100644 src/test/SfrxUSD/Fraxtal/SignatureTests.t.sol diff --git a/src/contracts/fraxtal/sfrxUSD/SfrxUSD.sol b/src/contracts/fraxtal/sfrxUSD/SfrxUSD.sol index 24d40c2..1e76c30 100644 --- a/src/contracts/fraxtal/sfrxUSD/SfrxUSD.sol +++ b/src/contracts/fraxtal/sfrxUSD/SfrxUSD.sol @@ -1,8 +1,33 @@ pragma solidity ^0.8.0; import { ERC20PermitPermissionedOptiMintable } from "src/contracts/fraxtal/shared/ERC20PermitPermissionedOptiMintable.sol"; +import { SignatureChecker } from "@openzeppelin/contracts-5.2.0/utils/cryptography/SignatureChecker.sol"; contract SfrxUSD is ERC20PermitPermissionedOptiMintable { + /// @dev keccak256("TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)") + bytes32 private constant TRANSFER_WITH_AUTHORIZATION_TYPEHASH = + 0x7c7c6cdb67a18743f49ec6fa9b35f50d52ed05cbed4cc592e13b44501c1a2267; + + /// @dev keccak256("ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)") + bytes32 private constant RECEIVE_WITH_AUTHORIZATION_TYPEHASH = + 0xd099cc98ef71107a616c4f0f941f04c322d8e254fe26b3c6668db87aae413de8; + + /// @dev keccak256("CancelAuthorization(address authorizer,bytes32 nonce)") + bytes32 private constant CANCEL_AUTHORIZATION_TYPEHASH = + 0x158b0a9edf7a828aad02f63cd515c68ef2f50ba807396f6d12842833a1597429; + + /// @dev keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)") + bytes32 private constant PERMIT_TYPEHASH = 0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9; + + /// @notice Mapping of authorizer to nonce to authorization state used for EIP-3009 + mapping(address authorizer => mapping(bytes32 nonce => bool used)) public authorizationState; + + /// @notice Upgrade version of the contract + /// @dev Does not impact EIP712 version, which is automatically set to "1" in constructor + function version() public pure override returns (string memory) { + return "2.0.0"; + } + /// @param _creator_address The contract creator /// @param _timelock_address The timelock /// @param _bridge Address of the L2 standard bridge @@ -22,4 +47,259 @@ contract SfrxUSD is ERC20PermitPermissionedOptiMintable { "sfrxUSD" ) {} + + /* ========== PERMIT ========== */ + + function permit( + address owner, + address spender, + uint256 value, + uint256 deadline, + bytes memory signature + ) external virtual { + if (block.timestamp > deadline) { + revert ERC2612ExpiredSignature(deadline); + } + + _requireIsValidSignatureNow({ + signer: owner, + structHash: keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, _useNonce(owner), deadline)), + signature: signature + }); + + _approve(owner, spender, value); + } + + /* ========== EIP-3009 ========== */ + + /// @notice The ```transferWithAuthorization``` function executes a transfer with a signed authorization according to Eip3009 + /// @dev EOA wallet signatures should be packed in the order of r, s, v + /// @dev added in v1.1.0 + /// @param from Payer's address (Authorizer) + /// @param to Payee's address + /// @param value Amount to be transferred + /// @param validAfter The block.timestamp after which the authorization is valid + /// @param validBefore The block.timestamp before which the authorization is valid + /// @param nonce Unique nonce + /// @param v ECDSA signature parameter v + /// @param r ECDSA signature parameters r + /// @param s ECDSA signature parameters s + function transferWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) external { + // Packs signature pieces into bytes + transferWithAuthorization({ + from: from, + to: to, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce, + signature: abi.encodePacked(r, s, v) + }); + } + + /// @notice The ```transferWithAuthorization``` function executes a transfer with a signed authorization + /// @dev EOA wallet signatures should be packed in the order of r, s, v + /// @param from Payer's address (Authorizer) + /// @param to Payee's address + /// @param value Amount to be transferred + /// @param validAfter The time after which this is valid (unix time) + /// @param validBefore The time before which this is valid (unix time) + /// @param nonce Unique nonce + /// @param signature Signature byte array produced by an EOA wallet or a contract wallet + function transferWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + bytes memory signature + ) public { + // Checks: authorization validity + if (block.timestamp <= validAfter) revert InvalidAuthorization(); + if (block.timestamp >= validBefore) revert ExpiredAuthorization(); + _requireUnusedAuthorization({ authorizer: from, nonce: nonce }); + + // Checks: valid signature + _requireIsValidSignatureNow({ + signer: from, + structHash: keccak256( + abi.encode(TRANSFER_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce) + ), + signature: signature + }); + + // Effects: mark authorization as used and transfer + _markAuthorizationAsUsed({ authorizer: from, nonce: nonce }); + _transfer({ from: from, to: to, value: value }); + } + + /// @notice The ```receiveWithAuthorization``` function receives a transfer with a signed authorization from the payer + /// @dev This has an additional check to ensure that the payee's address matches the caller of this function to prevent front-running attacks + /// @dev EOA wallet signatures should be packed in the order of r, s, v + /// @param from Payer's address (Authorizer) + /// @param to Payee's address + /// @param value Amount to be transferred + /// @param validAfter The block.timestamp after which the authorization is valid + /// @param validBefore The block.timestamp before which the authorization is valid + /// @param nonce Unique nonce + /// @param v ECDSA signature parameter v + /// @param r ECDSA signature parameters r + /// @param s ECDSA signature parameters s + function receiveWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) external { + // Packs signature pieces into bytes + receiveWithAuthorization({ + from: from, + to: to, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce, + signature: abi.encodePacked(r, s, v) + }); + } + + /// @notice The ```receiveWithAuthorization``` function receives a transfer with a signed authorization from the payer + /// @dev This has an additional check to ensure that the payee's address matches the caller of this function to prevent front-running attacks + /// @dev EOA wallet signatures should be packed in the order of r, s, v + /// @param from Payer's address (Authorizer) + /// @param to Payee's address + /// @param value Amount to be transferred + /// @param validAfter The block.timestamp after which the authorization is valid + /// @param validBefore The block.timestamp before which the authorization is valid + /// @param nonce Unique nonce + /// @param signature Signature byte array produced by an EOA wallet or a contract wallet + function receiveWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + bytes memory signature + ) public { + // Checks: authorization validity + if (to != msg.sender) revert InvalidPayee({ caller: msg.sender, payee: to }); + if (block.timestamp <= validAfter) revert InvalidAuthorization(); + if (block.timestamp >= validBefore) revert ExpiredAuthorization(); + _requireUnusedAuthorization({ authorizer: from, nonce: nonce }); + + // Checks: valid signature + _requireIsValidSignatureNow({ + signer: from, + structHash: keccak256( + abi.encode(RECEIVE_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce) + ), + signature: signature + }); + + // Effects: mark authorization as used and transfer + _markAuthorizationAsUsed({ authorizer: from, nonce: nonce }); + _transfer({ from: from, to: to, value: value }); + } + + /// @notice The ```cancelAuthorization``` function cancels an authorization nonce + /// @dev EOA wallet signatures should be packed in the order of r, s, v + /// @param authorizer Authorizer's address + /// @param nonce Nonce of the authorization + /// @param v ECDSA signature v value + /// @param r ECDSA signature r value + /// @param s ECDSA signature s value + function cancelAuthorization(address authorizer, bytes32 nonce, uint8 v, bytes32 r, bytes32 s) external { + cancelAuthorization({ authorizer: authorizer, nonce: nonce, signature: abi.encodePacked(r, s, v) }); + } + + /// @notice The ```cancelAuthorization``` function cancels an authorization nonce + /// @dev EOA wallet signatures should be packed in the order of r, s, v + /// @param authorizer Authorizer's address + /// @param nonce Nonce of the authorization + /// @param signature Signature byte array produced by an EOA wallet or a contract wallet + function cancelAuthorization(address authorizer, bytes32 nonce, bytes memory signature) public { + _requireUnusedAuthorization({ authorizer: authorizer, nonce: nonce }); + _requireIsValidSignatureNow({ + signer: authorizer, + structHash: keccak256(abi.encode(CANCEL_AUTHORIZATION_TYPEHASH, authorizer, nonce)), + signature: signature + }); + + authorizationState[authorizer][nonce] = true; + emit AuthorizationCanceled({ authorizer: authorizer, nonce: nonce }); + } + + /* ========== INTERNAL METHODS ========== */ + function _requireIsValidSignatureNow(address signer, bytes32 structHash, bytes memory signature) internal view { + if ( + !SignatureChecker.isValidSignatureNow({ + signer: signer, + hash: _hashTypedDataV4({ structHash: structHash }), + signature: signature + }) || signer == address(0) + ) revert InvalidSignature(); + } + + /// @notice The ```_requireUnusedAuthorization``` checks that an authorization nonce is unused + /// @param authorizer Authorizer's address + /// @param nonce Nonce of the authorization + function _requireUnusedAuthorization(address authorizer, bytes32 nonce) private view { + if (authorizationState[authorizer][nonce]) revert UsedOrCanceledAuthorization(); + } + + /// @notice The ```_markAuthorizationAsUsed``` function marks an authorization nonce as used + /// @param authorizer Authorizer's address + /// @param nonce Nonce of the authorization + function _markAuthorizationAsUsed(address authorizer, bytes32 nonce) private { + authorizationState[authorizer][nonce] = true; + emit AuthorizationUsed({ authorizer: authorizer, nonce: nonce }); + } + + /* ========== EVENTS ========== */ + + /// @notice ```AuthorizationUsed``` event is emitted when an authorization is used + /// @param authorizer Authorizer's address + /// @param nonce Nonce of the authorization + event AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce); + + /// @notice ```AuthorizationCanceled``` event is emitted when an authorization is canceled + /// @param authorizer Authorizer's address + /// @param nonce Nonce of the authorization + event AuthorizationCanceled(address indexed authorizer, bytes32 indexed nonce); + + /* ========== ERRORS ========== */ + + /// @notice Error thrown when a signature is invalid + error InvalidSignature(); + + /// @notice The ```InvalidPayee``` error is emitted when the payee does not match sender in receiveWithAuthorization + /// @param caller The caller of the function + /// @param payee The expected payee in the function + error InvalidPayee(address caller, address payee); + + /// @notice The ```InvalidAuthorization``` error is emitted when the authorization is invalid because its too early + error InvalidAuthorization(); + + /// @notice The ```ExpiredAuthorization``` error is emitted when the authorization is expired + error ExpiredAuthorization(); + + /// @notice The ```UsedOrCanceledAuthorization``` error is emitted when the authorization nonce is already used or canceled + error UsedOrCanceledAuthorization(); } diff --git a/src/script/fraxtal/sfrxUSD/DeploySfrxUSD.s.sol b/src/script/fraxtal/sfrxUSD/DeploySfrxUSD.s.sol new file mode 100644 index 0000000..9b433c3 --- /dev/null +++ b/src/script/fraxtal/sfrxUSD/DeploySfrxUSD.s.sol @@ -0,0 +1,73 @@ +pragma solidity ^0.8.0; + +import { BaseScript } from "frax-std/BaseScript.sol"; +import { ERC1967Utils } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Utils.sol"; +import { ProxyAdmin, ITransparentUpgradeableProxy } from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; +import { console } from "forge-std/console.sol"; + +import { SfrxUSD } from "src/contracts/fraxtal/sfrxUSD/SfrxUSD.sol"; + +import { SafeTx, SafeTxHelper } from "frax-std/SafeTxHelper.sol"; + +address constant SFRXUSD_PROXY = 0xfc00000000000000000000000000000000000008; + +// forge script src/script/fraxtal/sfrxUSD/DeploySfrxUSD.s.sol --rpc-url https://rpc.frax.com TODO: verify +contract DeploySfrxUSD is BaseScript { + address public proxyAdmin; + address public owner; + address public implementation; + SafeTx[] public txs; + SafeTxHelper public txHelper; + + bool public isTest = false; + + function setUp() public override { + bytes32 adminSlot = vm.load(SFRXUSD_PROXY, ERC1967Utils.ADMIN_SLOT); + proxyAdmin = address(uint160(uint256(adminSlot))); + owner = ProxyAdmin(proxyAdmin).owner(); + + txHelper = new SafeTxHelper(); + } + + function run() public { + deployFrxUsd(); + generateMsigTx(); + } + + function runTest() public { + isTest = true; + run(); + } + + function deployFrxUsd() public broadcaster { + implementation = address( + new SfrxUSD( + address(1), + address(1), + address(0x4200000000000000000000000000000000000010), + address(0xCAcd6fd266aF91b8AeD52aCCc382b4e165586E29) + ) + ); + require(implementation != address(0), "Failed implementation"); + } + + function generateMsigTx() public { + bytes memory initializeData = abi.encodeWithSignature("totalSupply()"); + bytes memory upgradeData = abi.encodeCall( + ProxyAdmin.upgradeAndCall, + (ITransparentUpgradeableProxy(payable(SFRXUSD_PROXY)), implementation, initializeData) + ); + vm.prank(owner); + (bool success, ) = proxyAdmin.call(upgradeData); + require(success, "Upgrade failed"); + + if (isTest) return; // skip writing to file in test mode + + txs.push(SafeTx({ name: "upgrade", to: proxyAdmin, value: 0, data: upgradeData })); + string memory root = vm.projectRoot(); + string memory filename = string.concat(root, "/src/script/fraxtal/sfrxUSD/DeploySfrxUSD.json"); + txHelper.writeTxs(txs, filename); + + console.log("Deploy msig tx from %s", owner); + } +} diff --git a/src/test/SfrxUSD/Fraxtal/SignatureTests.t.sol b/src/test/SfrxUSD/Fraxtal/SignatureTests.t.sol new file mode 100644 index 0000000..0466741 --- /dev/null +++ b/src/test/SfrxUSD/Fraxtal/SignatureTests.t.sol @@ -0,0 +1,436 @@ +pragma solidity ^0.8.0; + +import "frax-std/FraxTest.sol"; +import "src/script/fraxtal/sfrxUSD/DeploySfrxUSD.s.sol"; + +import { SigUtils } from "src/test/utils/SigUtils.sol"; + +contract TestSfrxUSDSignatures is FraxTest { + SfrxUSD public sfrxUsd; + DeploySfrxUSD public deploySfrxUsd; + SigUtils public sigUtils; + + uint256 BLOCK_NUM = 28_000_000; + uint256 alPrivateKey = 0x42; + address al = vm.addr(alPrivateKey); + address bob = vm.addr(0xb0b); + address owner = vm.addr(0x12345); + uint256 value = 1e18; + bytes32 nonce = bytes32(abi.encode(1)); // Example nonce, can be any value + uint256 validAfter; + uint256 validBefore; + + function setUp() public { + vm.createSelectFork("https://rpc.frax.com", BLOCK_NUM); + + deploySfrxUsd = new DeploySfrxUSD(); + deploySfrxUsd.setUp(); + deploySfrxUsd.runTest(); + + sfrxUsd = SfrxUSD(SFRXUSD_PROXY); + sigUtils = new SigUtils(sfrxUsd.DOMAIN_SEPARATOR()); + + validAfter = block.timestamp - 1; + validBefore = block.timestamp + 1 days; + + deal(address(sfrxUsd), al, 100e18); + deal(address(sfrxUsd), bob, 100e18); + } + + function test_Deploy_StorageMatches() public { + // create a fresh fork instance before deploy + vm.createSelectFork("https://rpc.frax.com", BLOCK_NUM - 1); + + // load pre-existing storage to memory + bytes32[] memory slots = new bytes32[](20); + for (uint256 i = 0; i < slots.length; i++) { + slots[i] = vm.load(SFRXUSD_PROXY, bytes32(i)); + } + + // simulate deploy + deploySfrxUsd = new DeploySfrxUSD(); + deploySfrxUsd.setUp(); + deploySfrxUsd.runTest(); + + // validate slots post-upgrade + for (uint256 i = 0; i < slots.length; i++) { + require(slots[i] == vm.load(SFRXUSD_PROXY, bytes32(i)), "Storage overwritten"); + } + } + + function test_Permit_succeeds() external { + /// al permits to bob + uint256 permitAllowanceBefore = sfrxUsd.allowance(al, bob); + assertEq(permitAllowanceBefore, 0, "Permit allowance should be 0 beforehand"); + + uint256 deadline = block.timestamp + 1 days; + SigUtils.Permit memory permit = SigUtils.Permit({ + owner: al, + spender: bob, + value: 1e18, + nonce: sfrxUsd.nonces(al), + deadline: deadline + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(alPrivateKey, sigUtils.getPermitTypedDataHash(permit)); + + vm.prank(bob); + sfrxUsd.permit({ + owner: permit.owner, + spender: permit.spender, + value: permit.value, + deadline: permit.deadline, + v: v, + r: r, + s: s + }); + + uint256 permitAllowanceAfter = sfrxUsd.allowance(al, bob); + assertEq(permitAllowanceAfter, 1e18, "Permit allowance should now be 1e18"); + } + + function test_TransferWithAuthorization_succeeds() public { + uint256 balanceBefore = sfrxUsd.balanceOf(bob); + assertEq(balanceBefore, 100e18, "Bob's balance should be 100e18 before transfer"); + + // al authorized bob to transfer 1e18 from al to bob + SigUtils.Authorization memory authorization = SigUtils.Authorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + alPrivateKey, + sigUtils.getTransferWithAuthorizationTypedDataHash(authorization) + ); + + vm.prank(bob); + sfrxUsd.transferWithAuthorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce, + v: v, + r: r, + s: s + }); + + uint256 balanceAfter = sfrxUsd.balanceOf(bob); + assertEq(balanceAfter, balanceBefore + value, "Bob's balance should now be 101e18"); + assertTrue(sfrxUsd.authorizationState(al, nonce), "Authorization should be marked as used"); + } + + function test_ReceiveWithAuthorization_succeeds() public { + uint256 balanceBefore = sfrxUsd.balanceOf(bob); + assertEq(balanceBefore, 100e18, "Bob's balance should be 100e18 before transfer"); + + // al authorized bob to transfer 1e18 from al to bob + SigUtils.Authorization memory authorization = SigUtils.Authorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + alPrivateKey, + sigUtils.getReceiveWithAuthorizationTypedDataHash(authorization) + ); + + vm.prank(bob); + sfrxUsd.receiveWithAuthorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce, + v: v, + r: r, + s: s + }); + + uint256 balanceAfter = sfrxUsd.balanceOf(bob); + assertEq(balanceAfter, balanceBefore + value, "Bob's balance should now be 101e18"); + assertTrue(sfrxUsd.authorizationState(al, nonce), "Authorization should be marked as used"); + } + + function test_CancelAuthorization_succeeds() public { + // al authorizes bob to transfer 1e18 from al to bob + SigUtils.CancelAuthorization memory cancelAuthorization = SigUtils.CancelAuthorization({ + authorizer: al, + nonce: nonce + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + alPrivateKey, + sigUtils.getCancelAuthorizationTypedDataHash(cancelAuthorization) + ); + + // al cancels the authorization + vm.prank(al); + sfrxUsd.cancelAuthorization({ authorizer: al, nonce: nonce, v: v, r: r, s: s }); + + assertTrue(sfrxUsd.authorizationState(al, nonce), "Authorization should be marked as used"); + + SigUtils.Authorization memory authorization = SigUtils.Authorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce + }); + (v, r, s) = vm.sign(alPrivateKey, sigUtils.getTransferWithAuthorizationTypedDataHash(authorization)); + + // try to transfer with authorization should fail + vm.expectRevert(SfrxUSD.UsedOrCanceledAuthorization.selector); + vm.prank(bob); + sfrxUsd.transferWithAuthorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce, + v: v, + r: r, + s: s + }); + } + + function test_CancelAuthorization_UsedOrCanceledAuthorization_reverts() external { + // Successful auth with nonce 1 + test_CancelAuthorization_succeeds(); + + SigUtils.CancelAuthorization memory cancelAuthorization = SigUtils.CancelAuthorization({ + authorizer: al, + nonce: nonce + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + alPrivateKey, + sigUtils.getCancelAuthorizationTypedDataHash(cancelAuthorization) + ); + + // al cancels the authorization + vm.prank(al); + vm.expectRevert(SfrxUSD.UsedOrCanceledAuthorization.selector); + sfrxUsd.cancelAuthorization({ authorizer: al, nonce: nonce, v: v, r: r, s: s }); + } + + function test_TransferWithAuthorization_UsedOrCanceledAuthorization_reverts() external { + // Successful auth with nonce 1 + test_TransferWithAuthorization_succeeds(); + + SigUtils.Authorization memory authorization = SigUtils.Authorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + alPrivateKey, + sigUtils.getTransferWithAuthorizationTypedDataHash(authorization) + ); + + // Try to transfer with authorization should fail + vm.expectRevert(SfrxUSD.UsedOrCanceledAuthorization.selector); + vm.prank(bob); + sfrxUsd.transferWithAuthorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce, + v: v, + r: r, + s: s + }); + } + + function test_ReceiveWithAuthorization_UsedOrCanceledAuthorization_reverts() external { + // Successful auth with nonce 1 + test_ReceiveWithAuthorization_succeeds(); + + SigUtils.Authorization memory authorization = SigUtils.Authorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + alPrivateKey, + sigUtils.getReceiveWithAuthorizationTypedDataHash(authorization) + ); + + // Try to receive with authorization should fail + vm.expectRevert(SfrxUSD.UsedOrCanceledAuthorization.selector); + vm.prank(bob); + sfrxUsd.receiveWithAuthorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce, + v: v, + r: r, + s: s + }); + } + + function test_TransferWithAuthorization_InvalidAuthorization_reverts() external { + // Try to transfer with authorization should fail + vm.expectRevert(SfrxUSD.InvalidAuthorization.selector); + vm.prank(bob); + sfrxUsd.transferWithAuthorization({ + from: al, + to: bob, + value: value, + validAfter: block.timestamp, + validBefore: validBefore, + nonce: nonce, + v: uint8(0), + r: bytes32(0), + s: bytes32(0) + }); + } + + function test_ReceiveWithAuthorization_InvalidAuthorization_reverts() external { + // Try to transfer with authorization should fail + vm.expectRevert(SfrxUSD.InvalidAuthorization.selector); + vm.prank(bob); + sfrxUsd.receiveWithAuthorization({ + from: al, + to: bob, + value: value, + validAfter: block.timestamp, + validBefore: validBefore, + nonce: nonce, + v: uint8(0), + r: bytes32(0), + s: bytes32(0) + }); + } + + function test_TransferWithAuthorization_ExpiredAuthorization_reverts() external { + // Try to transfer with authorization should fail + vm.expectRevert(SfrxUSD.ExpiredAuthorization.selector); + vm.prank(bob); + sfrxUsd.transferWithAuthorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: block.timestamp, + nonce: nonce, + v: uint8(0), + r: bytes32(0), + s: bytes32(0) + }); + } + + function test_ReceiveWithAuthorization_ExpiredAuthorization_reverts() external { + // Try to transfer with authorization should fail + vm.expectRevert(SfrxUSD.ExpiredAuthorization.selector); + vm.prank(bob); + sfrxUsd.receiveWithAuthorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: block.timestamp, + nonce: nonce, + v: uint8(0), + r: bytes32(0), + s: bytes32(0) + }); + } + + function test_ReceiveWithAuthorization_InvalidPayee_reverts() external { + vm.expectRevert(abi.encodeWithSelector(SfrxUSD.InvalidPayee.selector, bob, owner)); + vm.prank(bob); + sfrxUsd.receiveWithAuthorization({ + from: al, + to: owner, // Invalid payee + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce, + v: uint8(0), + r: bytes32(0), + s: bytes32(0) + }); + } + + function test_TransferWithAuthorization_InvalidSignature_reverts() external { + // al authorized bob to transfer 1e18 from al to bob + SigUtils.Authorization memory authorization = SigUtils.Authorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + alPrivateKey, + sigUtils.getTransferWithAuthorizationTypedDataHash(authorization) + ); + + // bob tries to transfer from al to owner, which is not conformed to the signature + vm.prank(bob); + vm.expectRevert(SfrxUSD.InvalidSignature.selector); + sfrxUsd.transferWithAuthorization({ + from: al, + to: owner, // note: this is causing the revert + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce, + v: v, + r: r, + s: s + }); + } + + function test_ReceiveWithAuthorization_InvalidSignature_reverts() external { + // al authorized owner to transfer 1e18 from al to bob + SigUtils.Authorization memory authorization = SigUtils.Authorization({ + from: al, + to: owner, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + alPrivateKey, + sigUtils.getReceiveWithAuthorizationTypedDataHash(authorization) + ); + + // bob tries to receive the tokens, which is not conformed to the signature + vm.prank(bob); + vm.expectRevert(SfrxUSD.InvalidSignature.selector); + sfrxUsd.receiveWithAuthorization({ + from: al, + to: bob, // note: this is causing the revert + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce, + v: v, + r: r, + s: s + }); + } +} From 62277efae801e3a655393c24e988f1d89427fed2 Mon Sep 17 00:00:00 2001 From: Carter Carlson Date: Mon, 1 Dec 2025 12:52:48 -0800 Subject: [PATCH 07/13] feat: ethereum 3009 contracts --- src/contracts/ethereum/frxUSD/FrxUSD.sol | 10 +- .../ethereum/frxUSD/versioning/FrxUSD2.sol | 42 +- .../ethereum/frxUSD/versioning/FrxUSD3.sol | 51 ++ src/contracts/ethereum/sfrxUSD/SfrxUSD.sol | 11 +- .../ethereum/sfrxUSD/versioning/SfrxUSD2.sol | 2 + .../ethereum/sfrxUSD/versioning/SfrxUSD3.sol | 87 ++++ .../ethereum/shared/modules/EIP3009Module.sol | 284 +++++++++++ .../ethereum/shared/modules/PermitModule.sol | 63 +++ .../shared/modules/SignatureModule.sol | 25 + src/script/ethereum/frxUSD/DeployFrxUSD.s.sol | 66 +++ .../ethereum/sfrxUSD/DeploySfrxUSD.s.sol | 68 +++ .../fraxtal/sfrxUSD/DeploySfrxUSD.s.sol | 2 +- src/test/FrxUSD/Fraxtal/CompilanceTests.t.sol | 2 +- src/test/FrxUSD/Mainnet/CompilanceTests.t.sol | 17 +- src/test/FrxUSD/Mainnet/SignatureTests.t.sol | 466 ++++++++++++++++++ src/test/SfrxUSD/Mainnet/SignatureTests.t.sol | 453 +++++++++++++++++ 16 files changed, 1609 insertions(+), 40 deletions(-) create mode 100644 src/contracts/ethereum/frxUSD/versioning/FrxUSD3.sol create mode 100644 src/contracts/ethereum/sfrxUSD/versioning/SfrxUSD3.sol create mode 100644 src/contracts/ethereum/shared/modules/EIP3009Module.sol create mode 100644 src/contracts/ethereum/shared/modules/PermitModule.sol create mode 100644 src/contracts/ethereum/shared/modules/SignatureModule.sol create mode 100644 src/script/ethereum/frxUSD/DeployFrxUSD.s.sol create mode 100644 src/script/ethereum/sfrxUSD/DeploySfrxUSD.s.sol create mode 100644 src/test/FrxUSD/Mainnet/SignatureTests.t.sol create mode 100644 src/test/SfrxUSD/Mainnet/SignatureTests.t.sol diff --git a/src/contracts/ethereum/frxUSD/FrxUSD.sol b/src/contracts/ethereum/frxUSD/FrxUSD.sol index 07f6e16..d959e9e 100644 --- a/src/contracts/ethereum/frxUSD/FrxUSD.sol +++ b/src/contracts/ethereum/frxUSD/FrxUSD.sol @@ -14,12 +14,8 @@ pragma solidity ^0.8.0; // Frax Finance: https://github.com/FraxFinance // Tested for 18-decimal underlying assets only -import { FrxUSD2 } from "src/contracts/ethereum/frxUSD/versioning/FrxUSD2.sol"; +import { FrxUSD3 } from "src/contracts/ethereum/frxUSD/versioning/FrxUSD3.sol"; -contract FrxUSD is FrxUSD2 { - constructor( - address _ownerAddress, - string memory _name, - string memory _symbol - ) FrxUSD2(_ownerAddress, _name, _symbol) {} +contract FrxUSD is FrxUSD3 { + constructor() FrxUSD3() {} } diff --git a/src/contracts/ethereum/frxUSD/versioning/FrxUSD2.sol b/src/contracts/ethereum/frxUSD/versioning/FrxUSD2.sol index 9bef61c..7e13c30 100644 --- a/src/contracts/ethereum/frxUSD/versioning/FrxUSD2.sol +++ b/src/contracts/ethereum/frxUSD/versioning/FrxUSD2.sol @@ -27,6 +27,10 @@ contract FrxUSD2 is ERC20Permit, ERC20Burnable, Ownable2Step { /// @notice Whether or not the contract is paused bool public isPaused; + mapping(address => bool) public isFreezer; + + uint256[45] private __gap; + /* ========== CONSTRUCTOR ========== */ /// @param _ownerAddress The initial owner /// @param _name ERC20 name @@ -37,16 +41,6 @@ contract FrxUSD2 is ERC20Permit, ERC20Burnable, Ownable2Step { string memory _symbol ) ERC20(_name, _symbol) ERC20Permit(_name) Ownable(_ownerAddress) {} - /* ========== INITIALIZER ========== */ - /// @dev Used to initialize the contract when it is behind a proxy - function initialize(address _owner, string memory _name, string memory _symbol) public { - require(owner() == address(0), "Already initialized"); - if (_owner == address(0)) revert OwnerCannotInitToZeroAddress(); - _transferOwnership(_owner); - StorageSlot.getBytesSlot(bytes32(uint256(3))).value = bytes(_name); - StorageSlot.getBytesSlot(bytes32(uint256(4))).value = bytes(_symbol); - } - /* ========== MODIFIERS ========== */ /// @notice A modifier that only allows a minters to call @@ -106,6 +100,18 @@ contract FrxUSD2 is ERC20Permit, ERC20Burnable, Ownable2Step { emit MinterRemoved(minter_address); } + function addFreezer(address _freezer) external onlyOwner { + if (isFreezer[_freezer]) revert AlreadyFreezer(); + isFreezer[_freezer] = true; + emit AddFreezer(_freezer); + } + + function removeFreezer(address _freezer) external onlyOwner { + if (!isFreezer[_freezer]) revert NotFreezer(); + isFreezer[_freezer] = false; + emit RemoveFreezer(_freezer); + } + /// @notice External admin gated function to unfreeze a set of accounts /// @param _owners Array of accounts to be unfrozen function thawMany(address[] memory _owners) external onlyOwner { @@ -123,7 +129,8 @@ contract FrxUSD2 is ERC20Permit, ERC20Burnable, Ownable2Step { /// @notice External admin gated function to batch freeze a set of accounts /// @param _owners Array of accounts to be frozen - function freezeMany(address[] memory _owners) external onlyOwner { + function freezeMany(address[] memory _owners) external { + if (!isFreezer[msg.sender] && msg.sender != owner()) revert NotFreezer(); uint256 len = _owners.length; for (uint256 i; i < len; ++i) { _freeze(_owners[i]); @@ -132,7 +139,8 @@ contract FrxUSD2 is ERC20Permit, ERC20Burnable, Ownable2Step { /// @notice External admin gated function to freeze a given account /// @param _owner The account to be - function freeze(address _owner) external onlyOwner { + function freeze(address _owner) external { + if (!isFreezer[msg.sender] && msg.sender != owner()) revert NotFreezer(); _freeze(_owner); } @@ -204,6 +212,14 @@ contract FrxUSD2 is ERC20Permit, ERC20Burnable, Ownable2Step { /* ========== EVENTS ========== */ + /// @notice Emitted when a freezer is added + /// @param freezer The address that was added as a freezer + event AddFreezer(address indexed freezer); + + /// @notice Emitted when a freezer is removed + /// @param freezer The address that was removed as a freezer + event RemoveFreezer(address indexed freezer); + /// @notice Emitted whenever the bridge burns tokens from an account /// @param account Address of the account tokens are being burned from /// @param amount Amount of tokens burned @@ -253,4 +269,6 @@ contract FrxUSD2 is ERC20Permit, ERC20Burnable, Ownable2Step { error IsPaused(); error IsFrozen(); error OwnerCannotInitToZeroAddress(); + error NotFreezer(); + error AlreadyFreezer(); } diff --git a/src/contracts/ethereum/frxUSD/versioning/FrxUSD3.sol b/src/contracts/ethereum/frxUSD/versioning/FrxUSD3.sol new file mode 100644 index 0000000..fd79994 --- /dev/null +++ b/src/contracts/ethereum/frxUSD/versioning/FrxUSD3.sol @@ -0,0 +1,51 @@ +//SPDX-License-Identifier: Unlicense +pragma solidity ^0.8.0; + +import { FrxUSD2, ERC20, EIP712, Nonces, ERC20Permit } from "src/contracts/ethereum/frxUSD/versioning/FrxUSD2.sol"; +import { PermitModule } from "src/contracts/ethereum/shared/modules/PermitModule.sol"; +import { EIP3009Module, SignatureModule } from "src/contracts/ethereum/shared/modules/EIP3009Module.sol"; + +/// @title FrxUSD v3.0.0 +/// @notice Frax USD Stablecoin by Frax Finance +/// @dev v3.0.0 adds ERC-1271 and EIP-3009 support +contract FrxUSD3 is FrxUSD2, EIP3009Module, PermitModule { + constructor() FrxUSD2(address(1), "Frax USD", "frxUSD") {} + + /*////////////////////////////////////////////////////////////// + Module Overrides + //////////////////////////////////////////////////////////////*/ + + function __transfer(address from, address to, uint256 amount) internal override returns (bool) { + ERC20._transfer(from, to, amount); + return true; + } + + function __hashTypedDataV4(bytes32 structHash) internal view override(SignatureModule) returns (bytes32) { + return EIP712._hashTypedDataV4(structHash); + } + + function __approve(address owner, address spender, uint256 amount) internal override(PermitModule) { + ERC20._approve(owner, spender, amount); + } + + function __useNonce(address owner) internal override(PermitModule) returns (uint256) { + return Nonces._useNonce(owner); + } + + function __domainSeparatorV4() internal view override(PermitModule) returns (bytes32) { + return EIP712._domainSeparatorV4(); + } + + function permit( + address owner, + address spender, + uint256 value, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) public override(ERC20Permit, PermitModule) { + return + PermitModule.permit({ owner: owner, spender: spender, value: value, deadline: deadline, v: v, r: r, s: s }); + } +} diff --git a/src/contracts/ethereum/sfrxUSD/SfrxUSD.sol b/src/contracts/ethereum/sfrxUSD/SfrxUSD.sol index 8288c81..d8aef86 100644 --- a/src/contracts/ethereum/sfrxUSD/SfrxUSD.sol +++ b/src/contracts/ethereum/sfrxUSD/SfrxUSD.sol @@ -14,13 +14,8 @@ pragma solidity ^0.8.21; // Frax Finance: https://github.com/FraxFinance // Tested for 18-decimal underlying assets only -import { SfrxUSD2, IERC20 } from "src/contracts/ethereum/sfrxUSD/versioning/SfrxUSD2.sol"; +import { SfrxUSD3 } from "src/contracts/ethereum/sfrxUSD/versioning/SfrxUSD3.sol"; -contract SfrxUSD is SfrxUSD2 { - constructor( - IERC20 _underlying, - string memory _name, - string memory _symbol, - address _timelockAddress - ) SfrxUSD2(_underlying, _name, _symbol, _timelockAddress) {} +contract SfrxUSD is SfrxUSD3 { + constructor(address _underlying) SfrxUSD3(_underlying) {} } diff --git a/src/contracts/ethereum/sfrxUSD/versioning/SfrxUSD2.sol b/src/contracts/ethereum/sfrxUSD/versioning/SfrxUSD2.sol index a08ae93..a18e847 100644 --- a/src/contracts/ethereum/sfrxUSD/versioning/SfrxUSD2.sol +++ b/src/contracts/ethereum/sfrxUSD/versioning/SfrxUSD2.sol @@ -35,6 +35,8 @@ contract SfrxUSD2 is LinearRewardsErc4626_2, Timelock2Step { /// @dev Mapping is used for faster verification mapping(address => bool) public minters; + uint256[47] public __gap; + function version() public pure virtual returns (string memory) { return "2.0.0"; } diff --git a/src/contracts/ethereum/sfrxUSD/versioning/SfrxUSD3.sol b/src/contracts/ethereum/sfrxUSD/versioning/SfrxUSD3.sol new file mode 100644 index 0000000..64754b0 --- /dev/null +++ b/src/contracts/ethereum/sfrxUSD/versioning/SfrxUSD3.sol @@ -0,0 +1,87 @@ +pragma solidity ^0.8.21; + +// ==================================================================== +// | ______ _______ | +// | / _____________ __ __ / ____(_____ ____ _____ ________ | +// | / /_ / ___/ __ `| |/_/ / /_ / / __ \/ __ `/ __ \/ ___/ _ \ | +// | / __/ / / / /_/ _> < / __/ / / / / / /_/ / / / / /__/ __/ | +// | /_/ /_/ \__,_/_/|_| /_/ /_/_/ /_/\__,_/_/ /_/\___/\___/ | +// | | +// ==================================================================== +//=========================== StakedFrxUSD3 =========================== +// ==================================================================== + +import { IERC20 } from "@openzeppelin/contracts-5.3.0/token/ERC20/IERC20.sol"; +import { ERC20 } from "solmate/tokens/ERC20.sol"; + +import { SfrxUSD2 } from "src/contracts/ethereum/sfrxUSD/versioning/SfrxUSD2.sol"; +import { EIP3009Module, SignatureModule } from "src/contracts/ethereum/shared/modules/EIP3009Module.sol"; +import { PermitModule } from "src/contracts/ethereum/shared/modules/PermitModule.sol"; + +/** + * @title StakedFrxUSD3 + * @notice This contract is an upgrade of SfrxUSD2 with EIP-3009, ERC-1271. + */ +contract SfrxUSD3 is SfrxUSD2, EIP3009Module, PermitModule { + function version() public pure override returns (string memory) { + return "3.0.0"; + } + + constructor(address _underlying) SfrxUSD2(IERC20(_underlying), "Staked Frax USD", "sfrxUSD", address(0)) {} + + /*////////////////////////////////////////////////////////////// + Module Overrides + //////////////////////////////////////////////////////////////*/ + + /// @dev PermitModule override + /// @dev solmate ERC20 does not have _approve like OZ: so we create it here + function __approve(address owner, address spender, uint256 amount) internal override { + allowance[owner][spender] = amount; + + emit Approval(owner, spender, amount); + } + + function __transfer(address owner, address spender, uint256 amount) internal override returns (bool) { + balanceOf[owner] -= amount; + + // Cannot overflow because the sum of all user + // balances can't exceed the max uint256 value. + unchecked { + balanceOf[spender] += amount; + } + + emit Transfer(owner, spender, amount); + return true; + } + + function __domainSeparatorV4() internal view override(PermitModule) returns (bytes32) { + return DOMAIN_SEPARATOR(); + } + + function __hashTypedDataV4(bytes32 structHash) internal view override(SignatureModule) returns (bytes32) { + return keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR(), structHash)); + } + + function __useNonce(address owner) internal override(PermitModule) returns (uint256) { + return nonces[owner]++; + } + + /// @dev Use PermitModule permit() with ERC-1271 support + function permit( + address owner, + address spender, + uint256 value, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) public override(ERC20, PermitModule) { + return + PermitModule.permit({ owner: owner, spender: spender, value: value, deadline: deadline, v: v, r: r, s: s }); + } + + /// @dev override DOMAIN_SEPARATOR() to utilize the proxy address over the cached implementation address + function DOMAIN_SEPARATOR() public view override returns (bytes32) { + return computeDomainSeparator(); + } +} diff --git a/src/contracts/ethereum/shared/modules/EIP3009Module.sol b/src/contracts/ethereum/shared/modules/EIP3009Module.sol new file mode 100644 index 0000000..de1be9c --- /dev/null +++ b/src/contracts/ethereum/shared/modules/EIP3009Module.sol @@ -0,0 +1,284 @@ +pragma solidity ^0.8.0; + +import { SignatureModule } from "./SignatureModule.sol"; + +/// @title Eip3009 +/// @notice Eip3009 provides internal implementations for gas-abstracted transfers under Eip3009 guidelines +/// @author Frax Finance, inspired by Agora (thanks Drake) +abstract contract EIP3009Module is SignatureModule { + /// @notice keccak256("TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)") + bytes32 internal constant TRANSFER_WITH_AUTHORIZATION_TYPEHASH = + 0x7c7c6cdb67a18743f49ec6fa9b35f50d52ed05cbed4cc592e13b44501c1a2267; + + /// @notice keccak256("ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)") + bytes32 internal constant RECEIVE_WITH_AUTHORIZATION_TYPEHASH = + 0xd099cc98ef71107a616c4f0f941f04c322d8e254fe26b3c6668db87aae413de8; + + /// @notice keccak256("CancelAuthorization(address authorizer,bytes32 nonce)") + bytes32 internal constant CANCEL_AUTHORIZATION_TYPEHASH = + 0x158b0a9edf7a828aad02f63cd515c68ef2f50ba807396f6d12842833a1597429; + + //============================================================================== + // Storage + //============================================================================== + mapping(address authorizer => mapping(bytes32 nonce => bool used)) isAuthorizationUsed; + + uint256[49] private __gap; + + //============================================================================== + // Functions + //============================================================================== + + /// @notice The ```transferWithAuthorization``` function executes a transfer with a signed authorization according to Eip3009 + /// @dev EOA wallet signatures should be packed in the order of r, s, v + /// @dev added in v1.1.0 + /// @param from Payer's address (Authorizer) + /// @param to Payee's address + /// @param value Amount to be transferred + /// @param validAfter The block.timestamp after which the authorization is valid + /// @param validBefore The block.timestamp before which the authorization is valid + /// @param nonce Unique nonce + /// @param v ECDSA signature parameter v + /// @param r ECDSA signature parameters r + /// @param s ECDSA signature parameters s + function transferWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) external { + // Packs signature pieces into bytes + transferWithAuthorization({ + from: from, + to: to, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce, + signature: abi.encodePacked(r, s, v) + }); + } + + /// @notice The ```transferWithAuthorization``` function executes a transfer with a signed authorization + /// @dev EOA wallet signatures should be packed in the order of r, s, v + /// @param from Payer's address (Authorizer) + /// @param to Payee's address + /// @param value Amount to be transferred + /// @param validAfter The time after which this is valid (unix time) + /// @param validBefore The time before which this is valid (unix time) + /// @param nonce Unique nonce + /// @param signature Signature byte array produced by an EOA wallet or a contract wallet + function transferWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + bytes memory signature + ) public { + // Checks: authorization validity + if (block.timestamp <= validAfter) revert InvalidAuthorization(); + if (block.timestamp >= validBefore) revert ExpiredAuthorization(); + _requireUnusedAuthorization({ authorizer: from, nonce: nonce }); + + // Checks: valid signature + _requireIsValidSignatureNow({ + signer: from, + structHash: keccak256( + abi.encode(TRANSFER_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce) + ), + signature: signature + }); + + // Effects: mark authorization as used and transfer + _markAuthorizationAsUsed({ authorizer: from, nonce: nonce }); + __transfer({ from: from, to: to, amount: value }); + } + + /// @notice The ```receiveWithAuthorization``` function receives a transfer with a signed authorization from the payer + /// @dev This has an additional check to ensure that the payee's address matches the caller of this function to prevent front-running attacks + /// @dev EOA wallet signatures should be packed in the order of r, s, v + /// @param from Payer's address (Authorizer) + /// @param to Payee's address + /// @param value Amount to be transferred + /// @param validAfter The block.timestamp after which the authorization is valid + /// @param validBefore The block.timestamp before which the authorization is valid + /// @param nonce Unique nonce + /// @param v ECDSA signature parameter v + /// @param r ECDSA signature parameters r + /// @param s ECDSA signature parameters s + function receiveWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) external { + // Packs signature pieces into bytes + receiveWithAuthorization({ + from: from, + to: to, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce, + signature: abi.encodePacked(r, s, v) + }); + } + + /// @notice The ```receiveWithAuthorization``` function receives a transfer with a signed authorization from the payer + /// @dev This has an additional check to ensure that the payee's address matches the caller of this function to prevent front-running attacks + /// @dev EOA wallet signatures should be packed in the order of r, s, v + /// @param from Payer's address (Authorizer) + /// @param to Payee's address + /// @param value Amount to be transferred + /// @param validAfter The block.timestamp after which the authorization is valid + /// @param validBefore The block.timestamp before which the authorization is valid + /// @param nonce Unique nonce + /// @param signature Signature byte array produced by an EOA wallet or a contract wallet + function receiveWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + bytes memory signature + ) public { + // Checks: authorization validity + if (to != msg.sender) revert InvalidPayee({ caller: msg.sender, payee: to }); + if (block.timestamp <= validAfter) revert InvalidAuthorization(); + if (block.timestamp >= validBefore) revert ExpiredAuthorization(); + _requireUnusedAuthorization({ authorizer: from, nonce: nonce }); + + // Checks: valid signature + _requireIsValidSignatureNow({ + signer: from, + structHash: keccak256( + abi.encode(RECEIVE_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce) + ), + signature: signature + }); + + // Effects: mark authorization as used and transfer + _markAuthorizationAsUsed({ authorizer: from, nonce: nonce }); + __transfer({ from: from, to: to, amount: value }); + } + + /// @notice The ```cancelAuthorization``` function cancels an authorization nonce + /// @dev EOA wallet signatures should be packed in the order of r, s, v + /// @param authorizer Authorizer's address + /// @param nonce Nonce of the authorization + /// @param v ECDSA signature v value + /// @param r ECDSA signature r value + /// @param s ECDSA signature s value + function cancelAuthorization(address authorizer, bytes32 nonce, uint8 v, bytes32 r, bytes32 s) external { + cancelAuthorization({ authorizer: authorizer, nonce: nonce, signature: abi.encodePacked(r, s, v) }); + } + + /// @notice The ```cancelAuthorization``` function cancels an authorization nonce + /// @dev EOA wallet signatures should be packed in the order of r, s, v + /// @param authorizer Authorizer's address + /// @param nonce Nonce of the authorization + /// @param signature Signature byte array produced by an EOA wallet or a contract wallet + function cancelAuthorization(address authorizer, bytes32 nonce, bytes memory signature) public { + _requireUnusedAuthorization({ authorizer: authorizer, nonce: nonce }); + _requireIsValidSignatureNow({ + signer: authorizer, + structHash: keccak256(abi.encode(CANCEL_AUTHORIZATION_TYPEHASH, authorizer, nonce)), + signature: signature + }); + + isAuthorizationUsed[authorizer][nonce] = true; + emit AuthorizationCanceled({ authorizer: authorizer, nonce: nonce }); + } + + //============================================================================== + // Internal Checks Functions + //============================================================================== + + /// @notice The ```_requireUnusedAuthorization``` checks that an authorization nonce is unused + /// @param authorizer Authorizer's address + /// @param nonce Nonce of the authorization + function _requireUnusedAuthorization(address authorizer, bytes32 nonce) private view { + if (isAuthorizationUsed[authorizer][nonce]) { + revert UsedOrCanceledAuthorization(); + } + } + + //============================================================================== + // Internal Effects Functions + //============================================================================== + + /// @notice The ```_markAuthorizationAsUsed``` function marks an authorization nonce as used + /// @param authorizer Authorizer's address + /// @param nonce Nonce of the authorization + function _markAuthorizationAsUsed(address authorizer, bytes32 nonce) private { + isAuthorizationUsed[authorizer][nonce] = true; + emit AuthorizationUsed({ authorizer: authorizer, nonce: nonce }); + } + + //============================================================================== + // Views + //============================================================================== + + /** + * @notice Returns the state of an authorization + * @dev Nonces are randomly generated 32-byte data unique to the authorizer's + * address + * @param authorizer Authorizer's address + * @param nonce Nonce of the authorization + * @return True if the nonce is used + */ + function authorizationState(address authorizer, bytes32 nonce) external view returns (bool) { + return isAuthorizationUsed[authorizer][nonce]; + } + + //============================================================================== + // Overridden methods + //============================================================================== + + function __transfer(address from, address to, uint256 amount) internal virtual returns (bool); + + //============================================================================== + // Events + //============================================================================== + + /// @notice ```AuthorizationUsed``` event is emitted when an authorization is used + /// @param authorizer Authorizer's address + /// @param nonce Nonce of the authorization + event AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce); + + /// @notice ```AuthorizationCanceled``` event is emitted when an authorization is canceled + /// @param authorizer Authorizer's address + /// @param nonce Nonce of the authorization + event AuthorizationCanceled(address indexed authorizer, bytes32 indexed nonce); + + //============================================================================== + // Errors + //============================================================================== + + /// @notice The ```InvalidPayee``` error is emitted when the payee does not match sender in receiveWithAuthorization + /// @param caller The caller of the function + /// @param payee The expected payee in the function + error InvalidPayee(address caller, address payee); + + /// @notice The ```InvalidAuthorization``` error is emitted when the authorization is invalid because its too early + error InvalidAuthorization(); + + /// @notice The ```ExpiredAuthorization``` error is emitted when the authorization is expired + error ExpiredAuthorization(); + + /// @notice The ```UsedOrCanceledAuthorization``` error is emitted when the authorization nonce is already used or canceled + error UsedOrCanceledAuthorization(); +} diff --git a/src/contracts/ethereum/shared/modules/PermitModule.sol b/src/contracts/ethereum/shared/modules/PermitModule.sol new file mode 100644 index 0000000..1876f88 --- /dev/null +++ b/src/contracts/ethereum/shared/modules/PermitModule.sol @@ -0,0 +1,63 @@ +pragma solidity ^0.8.0; + +import { SignatureModule } from "./SignatureModule.sol"; + +/// @dev Ripped from OZ 4.9.4 ERC20Permit.sol with namespaced storage and support of ERC1271 signatures +abstract contract PermitModule is SignatureModule { + //============================================================================== + // Storage + //============================================================================== + + bytes32 private constant PERMIT_TYPEHASH = + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"); + + //============================================================================== + // Functions + //============================================================================== + + function permit( + address owner, + address spender, + uint256 value, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) public virtual { + permit({ + owner: owner, + spender: spender, + value: value, + deadline: deadline, + signature: abi.encodePacked(r, s, v) + }); + } + + function permit( + address owner, + address spender, + uint256 value, + uint256 deadline, + bytes memory signature + ) public virtual { + require(block.timestamp <= deadline, "Permit: expired deadline"); + + _requireIsValidSignatureNow({ + signer: owner, + structHash: keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, __useNonce(owner), deadline)), + signature: signature + }); + + __approve(owner, spender, value); + } + + //============================================================================== + // Virtual methods to override in child class + //============================================================================== + + function __approve(address owner, address spender, uint256 amount) internal virtual; + + function __domainSeparatorV4() internal view virtual returns (bytes32); + + function __useNonce(address owner) internal virtual returns (uint256); +} diff --git a/src/contracts/ethereum/shared/modules/SignatureModule.sol b/src/contracts/ethereum/shared/modules/SignatureModule.sol new file mode 100644 index 0000000..c00a923 --- /dev/null +++ b/src/contracts/ethereum/shared/modules/SignatureModule.sol @@ -0,0 +1,25 @@ +pragma solidity ^0.8.0; + +import { SignatureChecker } from "@openzeppelin/contracts-5.3.0/utils/cryptography/SignatureChecker.sol"; + +/** + * @dev This is a base contract to aid in writing upgradeable contracts that use EIP-712 signatures. + * It provides functionality to initialize the EIP-712 domain separator and verify signatures. + */ +abstract contract SignatureModule { + /// @notice Error thrown when a signature is invalid + error InvalidSignature(); + + /// @dev Added supportive function to check if the signature is valid + function _requireIsValidSignatureNow(address signer, bytes32 structHash, bytes memory signature) internal view { + if ( + !SignatureChecker.isValidSignatureNow({ + signer: signer, + hash: __hashTypedDataV4({ structHash: structHash }), + signature: signature + }) || signer == address(0) + ) revert InvalidSignature(); + } + + function __hashTypedDataV4(bytes32 structHash) internal view virtual returns (bytes32); +} diff --git a/src/script/ethereum/frxUSD/DeployFrxUSD.s.sol b/src/script/ethereum/frxUSD/DeployFrxUSD.s.sol new file mode 100644 index 0000000..84590e4 --- /dev/null +++ b/src/script/ethereum/frxUSD/DeployFrxUSD.s.sol @@ -0,0 +1,66 @@ +pragma solidity ^0.8.0; + +import { BaseScript } from "frax-std/BaseScript.sol"; +import { ERC1967Utils } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Utils.sol"; +import { ProxyAdmin, ITransparentUpgradeableProxy } from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; +import { console } from "forge-std/console.sol"; + +import { FrxUSD } from "src/contracts/ethereum/frxUSD/FrxUSD.sol"; + +import { SafeTx, SafeTxHelper } from "frax-std/SafeTxHelper.sol"; + +address constant FRXUSD_PROXY = 0xCAcd6fd266aF91b8AeD52aCCc382b4e165586E29; + +// forge script src/script/ethereum/frxUSD/DeployFrxUSD.s.sol --rpc-url https://eth-mainnet.public.blastapi.io TODO: verify +contract DeployFrxUSD is BaseScript { + address public proxyAdmin; + address public owner; + address public implementation; + SafeTx[] public txs; + SafeTxHelper public txHelper; + + bool public isTest = false; + + function setUp() public override { + bytes32 adminSlot = vm.load(FRXUSD_PROXY, ERC1967Utils.ADMIN_SLOT); + proxyAdmin = address(uint160(uint256(adminSlot))); + owner = ProxyAdmin(proxyAdmin).owner(); + + txHelper = new SafeTxHelper(); + } + + function run() public { + deployFrxUsd(); + generateMsigTx(); + } + + function runTest() public { + isTest = true; + run(); + } + + function deployFrxUsd() public broadcaster { + implementation = address(new FrxUSD()); + require(implementation != address(0), "Failed implementation"); + } + + function generateMsigTx() public { + bytes memory initializeData = abi.encodeWithSignature("totalSupply()"); + bytes memory upgradeData = abi.encodeCall( + ProxyAdmin.upgradeAndCall, + (ITransparentUpgradeableProxy(payable(FRXUSD_PROXY)), implementation, initializeData) + ); + vm.prank(owner); + (bool success, ) = proxyAdmin.call(upgradeData); + require(success, "Upgrade failed"); + + if (isTest) return; // skip writing to file in test mode + + txs.push(SafeTx({ name: "upgrade", to: proxyAdmin, value: 0, data: upgradeData })); + string memory root = vm.projectRoot(); + string memory filename = string.concat(root, "/src/script/fraxtal/frxUSD/DeployFrxUSD.json"); + txHelper.writeTxs(txs, filename); + + console.log("Deploy msig tx from %s", owner); + } +} diff --git a/src/script/ethereum/sfrxUSD/DeploySfrxUSD.s.sol b/src/script/ethereum/sfrxUSD/DeploySfrxUSD.s.sol new file mode 100644 index 0000000..d31996b --- /dev/null +++ b/src/script/ethereum/sfrxUSD/DeploySfrxUSD.s.sol @@ -0,0 +1,68 @@ +pragma solidity ^0.8.0; + +import { BaseScript } from "frax-std/BaseScript.sol"; +import { console } from "frax-std/FraxTest.sol"; + +import { SfrxUSD } from "src/contracts/ethereum/sfrxUSD/SfrxUSD.sol"; + +import { SafeTx, SafeTxHelper } from "frax-std/SafeTxHelper.sol"; + +import { ProxyAdmin, ITransparentUpgradeableProxy } from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; +import { ERC1967Utils } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Utils.sol"; + +address constant SFRXUSD_PROXY = 0xcf62F905562626CfcDD2261162a51fd02Fc9c5b6; + +// forge script src/script/DeploySfrxUSD.s.sol --rpc-url https://eth-mainnet.public.blastapi.io (TODO: verifier) +contract DeploySfrxUSD is BaseScript { + address public frxUsd = 0xCAcd6fd266aF91b8AeD52aCCc382b4e165586E29; // frxUSD proxy address + address public proxyAdmin; + address public owner; + address public implementation; + SafeTx[] public msigTxs; + SafeTxHelper public safeTxHelper; + + bool public isTest = false; + + function setUp() public override { + bytes32 adminSlot = vm.load(SFRXUSD_PROXY, ERC1967Utils.ADMIN_SLOT); + proxyAdmin = address(uint160(uint256(adminSlot))); + owner = ProxyAdmin(proxyAdmin).owner(); + + safeTxHelper = new SafeTxHelper(); + } + + function run() public { + deploySfrxUSD(); + generateMsigTx(); + } + + function runTest() public { + isTest = true; + run(); + } + + function deploySfrxUSD() public broadcaster { + implementation = address(new SfrxUSD(frxUsd)); + require(implementation != address(0)); + } + + function generateMsigTx() public { + // bytes memory initData = abi.encodeWithSignature("initialize()"); + bytes memory upgradeData = abi.encodeCall( + ProxyAdmin.upgradeAndCall, + (ITransparentUpgradeableProxy(payable(SFRXUSD_PROXY)), implementation, hex"") + ); + vm.prank(owner); + (bool success, ) = proxyAdmin.call(upgradeData); + require(success, "Upgrade failed"); + + msigTxs.push(SafeTx({ name: "upgrade", to: proxyAdmin, value: 0, data: upgradeData })); + + if (isTest) return; // skip writing to file + + // write to file + string memory root = vm.projectRoot(); + string memory filename = string.concat(root, "/src/script/ethereum/sfrxUSD/DeploySfrxUSD.json"); + safeTxHelper.writeTxs(msigTxs, filename); + } +} diff --git a/src/script/fraxtal/sfrxUSD/DeploySfrxUSD.s.sol b/src/script/fraxtal/sfrxUSD/DeploySfrxUSD.s.sol index 9b433c3..cd53854 100644 --- a/src/script/fraxtal/sfrxUSD/DeploySfrxUSD.s.sol +++ b/src/script/fraxtal/sfrxUSD/DeploySfrxUSD.s.sol @@ -45,7 +45,7 @@ contract DeploySfrxUSD is BaseScript { address(1), address(1), address(0x4200000000000000000000000000000000000010), - address(0xCAcd6fd266aF91b8AeD52aCCc382b4e165586E29) + address(0xcf62F905562626CfcDD2261162a51fd02Fc9c5b6) ) ); require(implementation != address(0), "Failed implementation"); diff --git a/src/test/FrxUSD/Fraxtal/CompilanceTests.t.sol b/src/test/FrxUSD/Fraxtal/CompilanceTests.t.sol index d230755..edb83b6 100644 --- a/src/test/FrxUSD/Fraxtal/CompilanceTests.t.sol +++ b/src/test/FrxUSD/Fraxtal/CompilanceTests.t.sol @@ -546,7 +546,7 @@ contract FrxUSD_Fraxtal_Compliance is FraxTest { frxusd.thaw(al); } - function test_only_owner_can_freezeMany() public { + function test_only_freezer_can_freezeMany() public { _upgradeFrxUSD(); targets.push(bob); diff --git a/src/test/FrxUSD/Mainnet/CompilanceTests.t.sol b/src/test/FrxUSD/Mainnet/CompilanceTests.t.sol index ead05da..5caaa2d 100644 --- a/src/test/FrxUSD/Mainnet/CompilanceTests.t.sol +++ b/src/test/FrxUSD/Mainnet/CompilanceTests.t.sol @@ -28,7 +28,8 @@ contract FrxUSD_Mainnet_Compliance is FraxTest { /// @notice needed to register under coverage report // implV2 = IFrxUSD(deployFrxUsdImplementationEth()); - implV2 = IFrxUSD(address(new FrxUSD(address(Constants.Mainnet.COMPTROLLER_MULTISIG), "Frax USD", "frxUSD"))); + // implV2 = IFrxUSD(address(new FrxUSD(address(Constants.Mainnet.COMPTROLLER_MULTISIG), "Frax USD", "frxUSD"))); + implV2 = IFrxUSD(address(new FrxUSD())); // implV2 = FrxUSD(0x000000003C7F01B12c2D2097Cf7b95358E7E5812); deal(address(frxusd), al, 5000e18); @@ -44,12 +45,6 @@ contract FrxUSD_Mainnet_Compliance is FraxTest { assertEq({ left: frxusd.balanceOf(alice), right: 0, err: "// THEN: balance not constant" }); } - function test_cannot_reInit_post_upgrade() public { - _upgradeFrxUSD(); - vm.expectRevert(bytes("Already initialized")); - frxusd.initialize(badActor, "Bad", "Bad"); - } - function test_storage_layout_remains_constant() public { for (uint256 i; i < 20; i++) { bytes32 slotVal = vm.load(address(frxusd), bytes32(uint256(i))); @@ -431,11 +426,11 @@ contract FrxUSD_Mainnet_Compliance is FraxTest { frxusd.unpause(); } - function test_only_owner_can_freeze() public { + function test_only_freezer_can_freeze() public { _upgradeFrxUSD(); vm.prank(badActor); - vm.expectRevert(abi.encodeWithSignature("OwnableUnauthorizedAccount(address)", badActor)); + vm.expectRevert(bytes4(keccak256("NotFreezer()"))); frxusd.freeze(bob); } @@ -447,14 +442,14 @@ contract FrxUSD_Mainnet_Compliance is FraxTest { frxusd.thaw(al); } - function test_only_owner_can_freezeMany() public { + function test_only_freezer_can_freezeMany() public { _upgradeFrxUSD(); targets.push(bob); targets.push(carl); vm.prank(badActor); - vm.expectRevert(abi.encodeWithSignature("OwnableUnauthorizedAccount(address)", badActor)); + vm.expectRevert(bytes4(keccak256("NotFreezer()"))); frxusd.freezeMany(targets); } diff --git a/src/test/FrxUSD/Mainnet/SignatureTests.t.sol b/src/test/FrxUSD/Mainnet/SignatureTests.t.sol new file mode 100644 index 0000000..9833369 --- /dev/null +++ b/src/test/FrxUSD/Mainnet/SignatureTests.t.sol @@ -0,0 +1,466 @@ +pragma solidity ^0.8.0; + +import "frax-std/FraxTest.sol"; +import "src/script/ethereum/frxUSD/DeployFrxUSD.s.sol"; +import { console } from "forge-std/console.sol"; +import { SigUtils } from "src/test/utils/SigUtils.sol"; + +import { EIP3009Module } from "src/contracts/ethereum/shared/modules/EIP3009Module.sol"; +import { SignatureModule } from "src/contracts/ethereum/shared/modules/SignatureModule.sol"; + +contract TestFrxUSDSignatures is FraxTest { + FrxUSD public frxUsd; + DeployFrxUSD public deployFrxUsd; + SigUtils public sigUtils; + + uint256 BLOCK_NUM = 23_920_113; + uint256 alPrivateKey = 0x42; + address al = vm.addr(alPrivateKey); + address bob = vm.addr(0xb0b); + address owner = vm.addr(0x12345); + uint256 value = 1e18; + bytes32 nonce = bytes32(abi.encode(1)); // Example nonce, can be any value + uint256 validAfter; + uint256 validBefore; + + function setUp() public { + vm.createSelectFork("https://eth-mainnet.public.blastapi.io", BLOCK_NUM); + + frxUsd = FrxUSD(FRXUSD_PROXY); + + deployFrxUsd = new DeployFrxUSD(); + deployFrxUsd.setUp(); + deployFrxUsd.runTest(); + + frxUsd = FrxUSD(FRXUSD_PROXY); + sigUtils = new SigUtils(frxUsd.DOMAIN_SEPARATOR()); + + validAfter = block.timestamp - 1; + validBefore = block.timestamp + 1 days; + + deal(address(frxUsd), al, 100e18); + deal(address(frxUsd), bob, 100e18); + } + + function test_Deploy_StorageMatches() public { + // create a fresh fork instance before deploy + vm.createSelectFork("https://eth-mainnet.public.blastapi.io", BLOCK_NUM - 1); + + // load pre-existing storage to memory + bytes32[] memory slots = new bytes32[](20); + for (uint256 i = 0; i < slots.length; i++) { + slots[i] = vm.load(FRXUSD_PROXY, bytes32(i)); + } + + bytes32 domainSeparator = frxUsd.DOMAIN_SEPARATOR(); + + // simulate deploy + deployFrxUsd = new DeployFrxUSD(); + deployFrxUsd.setUp(); + deployFrxUsd.runTest(); + + assertEq(domainSeparator, frxUsd.DOMAIN_SEPARATOR(), "Domain separator mismatch"); + + // validate slots post-upgrade + for (uint256 i = 0; i < slots.length; i++) { + require(slots[i] == vm.load(FRXUSD_PROXY, bytes32(i)), "Storage overwritten"); + } + } + + function test_Permit_succeeds() external { + /// al permits to bob + uint256 permitAllowanceBefore = frxUsd.allowance(al, bob); + assertEq(permitAllowanceBefore, 0, "Permit allowance should be 0 beforehand"); + + uint256 deadline = block.timestamp + 1 days; + SigUtils.Permit memory permit = SigUtils.Permit({ + owner: al, + spender: bob, + value: 1e18, + nonce: frxUsd.nonces(al), + deadline: deadline + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(alPrivateKey, sigUtils.getPermitTypedDataHash(permit)); + + vm.prank(bob); + frxUsd.permit({ + owner: permit.owner, + spender: permit.spender, + value: permit.value, + deadline: permit.deadline, + v: v, + r: r, + s: s + }); + + uint256 permitAllowanceAfter = frxUsd.allowance(al, bob); + assertEq(permitAllowanceAfter, 1e18, "Permit allowance should now be 1e18"); + + permit = SigUtils.Permit({ + owner: al, + spender: bob, + value: 2e18, + nonce: frxUsd.nonces(al), + deadline: deadline + }); + (v, r, s) = vm.sign(alPrivateKey, sigUtils.getPermitTypedDataHash(permit)); + bytes memory signature = abi.encodePacked(r, s, v); + + vm.prank(bob); + frxUsd.permit({ + owner: permit.owner, + spender: permit.spender, + value: permit.value, + deadline: permit.deadline, + signature: signature + }); + permitAllowanceAfter = frxUsd.allowance(al, bob); + assertEq(permitAllowanceAfter, 2e18, "Permit allowance should now be 2e18"); + } + + function test_TransferWithAuthorization_succeeds() public { + uint256 balanceBefore = frxUsd.balanceOf(bob); + assertEq(balanceBefore, 100e18, "Bob's balance should be 100e18 before transfer"); + + // al authorized bob to transfer 1e18 from al to bob + SigUtils.Authorization memory authorization = SigUtils.Authorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + alPrivateKey, + sigUtils.getTransferWithAuthorizationTypedDataHash(authorization) + ); + + vm.prank(bob); + frxUsd.transferWithAuthorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce, + v: v, + r: r, + s: s + }); + + uint256 balanceAfter = frxUsd.balanceOf(bob); + assertEq(balanceAfter, balanceBefore + value, "Bob's balance should now be 101e18"); + assertTrue(frxUsd.authorizationState(al, nonce), "Authorization should be marked as used"); + } + + function test_ReceiveWithAuthorization_succeeds() public { + uint256 balanceBefore = frxUsd.balanceOf(bob); + assertEq(balanceBefore, 100e18, "Bob's balance should be 100e18 before transfer"); + + // al authorized bob to transfer 1e18 from al to bob + SigUtils.Authorization memory authorization = SigUtils.Authorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + alPrivateKey, + sigUtils.getReceiveWithAuthorizationTypedDataHash(authorization) + ); + + vm.prank(bob); + frxUsd.receiveWithAuthorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce, + v: v, + r: r, + s: s + }); + + uint256 balanceAfter = frxUsd.balanceOf(bob); + assertEq(balanceAfter, balanceBefore + value, "Bob's balance should now be 101e18"); + assertTrue(frxUsd.authorizationState(al, nonce), "Authorization should be marked as used"); + } + + function test_CancelAuthorization_succeeds() public { + // al authorizes bob to transfer 1e18 from al to bob + SigUtils.CancelAuthorization memory cancelAuthorization = SigUtils.CancelAuthorization({ + authorizer: al, + nonce: nonce + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + alPrivateKey, + sigUtils.getCancelAuthorizationTypedDataHash(cancelAuthorization) + ); + + // al cancels the authorization + vm.prank(al); + frxUsd.cancelAuthorization({ authorizer: al, nonce: nonce, v: v, r: r, s: s }); + + assertTrue(frxUsd.authorizationState(al, nonce), "Authorization should be marked as used"); + + SigUtils.Authorization memory authorization = SigUtils.Authorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce + }); + (v, r, s) = vm.sign(alPrivateKey, sigUtils.getTransferWithAuthorizationTypedDataHash(authorization)); + + // try to transfer with authorization should fail + vm.expectRevert(EIP3009Module.UsedOrCanceledAuthorization.selector); + vm.prank(bob); + frxUsd.transferWithAuthorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce, + v: v, + r: r, + s: s + }); + } + + function test_CancelAuthorization_UsedOrCanceledAuthorization_reverts() external { + // Successful auth with nonce 1 + test_CancelAuthorization_succeeds(); + + SigUtils.CancelAuthorization memory cancelAuthorization = SigUtils.CancelAuthorization({ + authorizer: al, + nonce: nonce + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + alPrivateKey, + sigUtils.getCancelAuthorizationTypedDataHash(cancelAuthorization) + ); + + // al cancels the authorization + vm.prank(al); + vm.expectRevert(EIP3009Module.UsedOrCanceledAuthorization.selector); + frxUsd.cancelAuthorization({ authorizer: al, nonce: nonce, v: v, r: r, s: s }); + } + + function test_TransferWithAuthorization_UsedOrCanceledAuthorization_reverts() external { + // Successful auth with nonce 1 + test_TransferWithAuthorization_succeeds(); + + SigUtils.Authorization memory authorization = SigUtils.Authorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + alPrivateKey, + sigUtils.getTransferWithAuthorizationTypedDataHash(authorization) + ); + + // Try to transfer with authorization should fail + vm.expectRevert(EIP3009Module.UsedOrCanceledAuthorization.selector); + vm.prank(bob); + frxUsd.transferWithAuthorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce, + v: v, + r: r, + s: s + }); + } + + function test_ReceiveWithAuthorization_UsedOrCanceledAuthorization_reverts() external { + // Successful auth with nonce 1 + test_ReceiveWithAuthorization_succeeds(); + + SigUtils.Authorization memory authorization = SigUtils.Authorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + alPrivateKey, + sigUtils.getReceiveWithAuthorizationTypedDataHash(authorization) + ); + + // Try to receive with authorization should fail + vm.expectRevert(EIP3009Module.UsedOrCanceledAuthorization.selector); + vm.prank(bob); + frxUsd.receiveWithAuthorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce, + v: v, + r: r, + s: s + }); + } + + function test_TransferWithAuthorization_InvalidAuthorization_reverts() external { + // Try to transfer with authorization should fail + vm.expectRevert(EIP3009Module.InvalidAuthorization.selector); + vm.prank(bob); + frxUsd.transferWithAuthorization({ + from: al, + to: bob, + value: value, + validAfter: block.timestamp, + validBefore: validBefore, + nonce: nonce, + v: uint8(0), + r: bytes32(0), + s: bytes32(0) + }); + } + + function test_ReceiveWithAuthorization_InvalidAuthorization_reverts() external { + // Try to transfer with authorization should fail + vm.expectRevert(EIP3009Module.InvalidAuthorization.selector); + vm.prank(bob); + frxUsd.receiveWithAuthorization({ + from: al, + to: bob, + value: value, + validAfter: block.timestamp, + validBefore: validBefore, + nonce: nonce, + v: uint8(0), + r: bytes32(0), + s: bytes32(0) + }); + } + + function test_TransferWithAuthorization_ExpiredAuthorization_reverts() external { + // Try to transfer with authorization should fail + vm.expectRevert(EIP3009Module.ExpiredAuthorization.selector); + vm.prank(bob); + frxUsd.transferWithAuthorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: block.timestamp, + nonce: nonce, + v: uint8(0), + r: bytes32(0), + s: bytes32(0) + }); + } + + function test_ReceiveWithAuthorization_ExpiredAuthorization_reverts() external { + // Try to transfer with authorization should fail + vm.expectRevert(EIP3009Module.ExpiredAuthorization.selector); + vm.prank(bob); + frxUsd.receiveWithAuthorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: block.timestamp, + nonce: nonce, + v: uint8(0), + r: bytes32(0), + s: bytes32(0) + }); + } + + function test_ReceiveWithAuthorization_InvalidPayee_reverts() external { + vm.expectRevert(abi.encodeWithSelector(EIP3009Module.InvalidPayee.selector, bob, owner)); + vm.prank(bob); + frxUsd.receiveWithAuthorization({ + from: al, + to: owner, // Invalid payee + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce, + v: uint8(0), + r: bytes32(0), + s: bytes32(0) + }); + } + + function test_TransferWithAuthorization_InvalidSignature_reverts() external { + // al authorized bob to transfer 1e18 from al to bob + SigUtils.Authorization memory authorization = SigUtils.Authorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + alPrivateKey, + sigUtils.getTransferWithAuthorizationTypedDataHash(authorization) + ); + + // bob tries to transfer from al to owner, which is not conformed to the signature + vm.prank(bob); + vm.expectRevert(SignatureModule.InvalidSignature.selector); + frxUsd.transferWithAuthorization({ + from: al, + to: owner, // note: this is causing the revert + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce, + v: v, + r: r, + s: s + }); + } + + function test_ReceiveWithAuthorization_InvalidSignature_reverts() external { + // al authorized owner to transfer 1e18 from al to bob + SigUtils.Authorization memory authorization = SigUtils.Authorization({ + from: al, + to: owner, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + alPrivateKey, + sigUtils.getReceiveWithAuthorizationTypedDataHash(authorization) + ); + + // bob tries to receive the tokens, which is not conformed to the signature + vm.prank(bob); + vm.expectRevert(SignatureModule.InvalidSignature.selector); + frxUsd.receiveWithAuthorization({ + from: al, + to: bob, // note: this is causing the revert + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce, + v: v, + r: r, + s: s + }); + } +} diff --git a/src/test/SfrxUSD/Mainnet/SignatureTests.t.sol b/src/test/SfrxUSD/Mainnet/SignatureTests.t.sol new file mode 100644 index 0000000..4b50ed7 --- /dev/null +++ b/src/test/SfrxUSD/Mainnet/SignatureTests.t.sol @@ -0,0 +1,453 @@ +pragma solidity ^0.8.0; + +import "frax-std/FraxTest.sol"; +import "src/script/ethereum/sfrxUSD/DeploySfrxUSD.s.sol"; +import { console } from "forge-std/console.sol"; +import { SigUtils } from "src/test/utils/SigUtils.sol"; + +import { EIP3009Module } from "src/contracts/ethereum/shared/modules/EIP3009Module.sol"; +import { SignatureModule } from "src/contracts/ethereum/shared/modules/SignatureModule.sol"; + +contract TestSfrxUSDSignatures is FraxTest { + SfrxUSD public sfrxUsd; + DeploySfrxUSD public deploySfrxUsd; + SigUtils public sigUtils; + + uint256 BLOCK_NUM = 23_920_113; + uint256 alPrivateKey = 0x42; + address al = vm.addr(alPrivateKey); + address bob = vm.addr(0xb0b); + address owner = vm.addr(0x12345); + uint256 value = 1e18; + bytes32 nonce = bytes32(abi.encode(1)); // Example nonce, can be any value + uint256 validAfter; + uint256 validBefore; + + function setUp() public { + vm.createSelectFork("https://eth-mainnet.public.blastapi.io", BLOCK_NUM); + + deploySfrxUsd = new DeploySfrxUSD(); + deploySfrxUsd.setUp(); + deploySfrxUsd.runTest(); + + sfrxUsd = SfrxUSD(SFRXUSD_PROXY); + sigUtils = new SigUtils(sfrxUsd.DOMAIN_SEPARATOR()); + + validAfter = block.timestamp - 1; + validBefore = block.timestamp + 1 days; + + deal(address(sfrxUsd), al, 100e18); + deal(address(sfrxUsd), bob, 100e18); + } + + function test_DOMAIN_SEPARATOR_isCorrect() external { + bytes32 domainSeparator = sfrxUsd.DOMAIN_SEPARATOR(); + bytes32 expectedDomainSeparator = keccak256( + abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256(bytes(sfrxUsd.name())), + keccak256("1"), + block.chainid, + address(sfrxUsd) + ) + ); + assertEq(domainSeparator, expectedDomainSeparator, "DOMAIN_SEPARATOR is incorrect"); + } + + function test_Permit_succeeds() external { + /// al permits to bob + uint256 permitAllowanceBefore = sfrxUsd.allowance(al, bob); + assertEq(permitAllowanceBefore, 0, "Permit allowance should be 0 beforehand"); + + uint256 deadline = block.timestamp + 1 days; + SigUtils.Permit memory permit = SigUtils.Permit({ + owner: al, + spender: bob, + value: 1e18, + nonce: sfrxUsd.nonces(al), + deadline: deadline + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(alPrivateKey, sigUtils.getPermitTypedDataHash(permit)); + + vm.prank(bob); + sfrxUsd.permit({ + owner: permit.owner, + spender: permit.spender, + value: permit.value, + deadline: permit.deadline, + v: v, + r: r, + s: s + }); + + uint256 permitAllowanceAfter = sfrxUsd.allowance(al, bob); + assertEq(permitAllowanceAfter, 1e18, "Permit allowance should now be 1e18"); + + permit = SigUtils.Permit({ + owner: al, + spender: bob, + value: 2e18, + nonce: sfrxUsd.nonces(al), + deadline: deadline + }); + (v, r, s) = vm.sign(alPrivateKey, sigUtils.getPermitTypedDataHash(permit)); + bytes memory signature = abi.encodePacked(r, s, v); + + vm.prank(bob); + sfrxUsd.permit({ + owner: permit.owner, + spender: permit.spender, + value: permit.value, + deadline: permit.deadline, + signature: signature + }); + permitAllowanceAfter = sfrxUsd.allowance(al, bob); + assertEq(permitAllowanceAfter, 2e18, "Permit allowance should now be 2e18"); + } + + function test_TransferWithAuthorization_succeeds() public { + uint256 balanceBefore = sfrxUsd.balanceOf(bob); + assertEq(balanceBefore, 100e18, "Bob's balance should be 100e18 before transfer"); + + // al authorized bob to transfer 1e18 from al to bob + SigUtils.Authorization memory authorization = SigUtils.Authorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + alPrivateKey, + sigUtils.getTransferWithAuthorizationTypedDataHash(authorization) + ); + + vm.prank(bob); + sfrxUsd.transferWithAuthorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce, + v: v, + r: r, + s: s + }); + + uint256 balanceAfter = sfrxUsd.balanceOf(bob); + assertEq(balanceAfter, balanceBefore + value, "Bob's balance should now be 101e18"); + assertTrue(sfrxUsd.authorizationState(al, nonce), "Authorization should be marked as used"); + } + + function test_ReceiveWithAuthorization_succeeds() public { + uint256 balanceBefore = sfrxUsd.balanceOf(bob); + assertEq(balanceBefore, 100e18, "Bob's balance should be 100e18 before transfer"); + + // al authorized bob to transfer 1e18 from al to bob + SigUtils.Authorization memory authorization = SigUtils.Authorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + alPrivateKey, + sigUtils.getReceiveWithAuthorizationTypedDataHash(authorization) + ); + + vm.prank(bob); + sfrxUsd.receiveWithAuthorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce, + v: v, + r: r, + s: s + }); + + uint256 balanceAfter = sfrxUsd.balanceOf(bob); + assertEq(balanceAfter, balanceBefore + value, "Bob's balance should now be 101e18"); + assertTrue(sfrxUsd.authorizationState(al, nonce), "Authorization should be marked as used"); + } + + function test_CancelAuthorization_succeeds() public { + // al authorizes bob to transfer 1e18 from al to bob + SigUtils.CancelAuthorization memory cancelAuthorization = SigUtils.CancelAuthorization({ + authorizer: al, + nonce: nonce + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + alPrivateKey, + sigUtils.getCancelAuthorizationTypedDataHash(cancelAuthorization) + ); + + // al cancels the authorization + vm.prank(al); + sfrxUsd.cancelAuthorization({ authorizer: al, nonce: nonce, v: v, r: r, s: s }); + + assertTrue(sfrxUsd.authorizationState(al, nonce), "Authorization should be marked as used"); + + SigUtils.Authorization memory authorization = SigUtils.Authorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce + }); + (v, r, s) = vm.sign(alPrivateKey, sigUtils.getTransferWithAuthorizationTypedDataHash(authorization)); + + // try to transfer with authorization should fail + vm.expectRevert(EIP3009Module.UsedOrCanceledAuthorization.selector); + vm.prank(bob); + sfrxUsd.transferWithAuthorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce, + v: v, + r: r, + s: s + }); + } + + function test_CancelAuthorization_UsedOrCanceledAuthorization_reverts() external { + // Successful auth with nonce 1 + test_CancelAuthorization_succeeds(); + + SigUtils.CancelAuthorization memory cancelAuthorization = SigUtils.CancelAuthorization({ + authorizer: al, + nonce: nonce + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + alPrivateKey, + sigUtils.getCancelAuthorizationTypedDataHash(cancelAuthorization) + ); + + // al cancels the authorization + vm.prank(al); + vm.expectRevert(EIP3009Module.UsedOrCanceledAuthorization.selector); + sfrxUsd.cancelAuthorization({ authorizer: al, nonce: nonce, v: v, r: r, s: s }); + } + + function test_TransferWithAuthorization_UsedOrCanceledAuthorization_reverts() external { + // Successful auth with nonce 1 + test_TransferWithAuthorization_succeeds(); + + SigUtils.Authorization memory authorization = SigUtils.Authorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + alPrivateKey, + sigUtils.getTransferWithAuthorizationTypedDataHash(authorization) + ); + + // Try to transfer with authorization should fail + vm.expectRevert(EIP3009Module.UsedOrCanceledAuthorization.selector); + vm.prank(bob); + sfrxUsd.transferWithAuthorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce, + v: v, + r: r, + s: s + }); + } + + function test_ReceiveWithAuthorization_UsedOrCanceledAuthorization_reverts() external { + // Successful auth with nonce 1 + test_ReceiveWithAuthorization_succeeds(); + + SigUtils.Authorization memory authorization = SigUtils.Authorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + alPrivateKey, + sigUtils.getReceiveWithAuthorizationTypedDataHash(authorization) + ); + + // Try to receive with authorization should fail + vm.expectRevert(EIP3009Module.UsedOrCanceledAuthorization.selector); + vm.prank(bob); + sfrxUsd.receiveWithAuthorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce, + v: v, + r: r, + s: s + }); + } + + function test_TransferWithAuthorization_InvalidAuthorization_reverts() external { + // Try to transfer with authorization should fail + vm.expectRevert(EIP3009Module.InvalidAuthorization.selector); + vm.prank(bob); + sfrxUsd.transferWithAuthorization({ + from: al, + to: bob, + value: value, + validAfter: block.timestamp, + validBefore: validBefore, + nonce: nonce, + v: uint8(0), + r: bytes32(0), + s: bytes32(0) + }); + } + + function test_ReceiveWithAuthorization_InvalidAuthorization_reverts() external { + // Try to transfer with authorization should fail + vm.expectRevert(EIP3009Module.InvalidAuthorization.selector); + vm.prank(bob); + sfrxUsd.receiveWithAuthorization({ + from: al, + to: bob, + value: value, + validAfter: block.timestamp, + validBefore: validBefore, + nonce: nonce, + v: uint8(0), + r: bytes32(0), + s: bytes32(0) + }); + } + + function test_TransferWithAuthorization_ExpiredAuthorization_reverts() external { + // Try to transfer with authorization should fail + vm.expectRevert(EIP3009Module.ExpiredAuthorization.selector); + vm.prank(bob); + sfrxUsd.transferWithAuthorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: block.timestamp, + nonce: nonce, + v: uint8(0), + r: bytes32(0), + s: bytes32(0) + }); + } + + function test_ReceiveWithAuthorization_ExpiredAuthorization_reverts() external { + // Try to transfer with authorization should fail + vm.expectRevert(EIP3009Module.ExpiredAuthorization.selector); + vm.prank(bob); + sfrxUsd.receiveWithAuthorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: block.timestamp, + nonce: nonce, + v: uint8(0), + r: bytes32(0), + s: bytes32(0) + }); + } + + function test_ReceiveWithAuthorization_InvalidPayee_reverts() external { + vm.expectRevert(abi.encodeWithSelector(EIP3009Module.InvalidPayee.selector, bob, owner)); + vm.prank(bob); + sfrxUsd.receiveWithAuthorization({ + from: al, + to: owner, // Invalid payee + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce, + v: uint8(0), + r: bytes32(0), + s: bytes32(0) + }); + } + + function test_TransferWithAuthorization_InvalidSignature_reverts() external { + // al authorized bob to transfer 1e18 from al to bob + SigUtils.Authorization memory authorization = SigUtils.Authorization({ + from: al, + to: bob, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + alPrivateKey, + sigUtils.getTransferWithAuthorizationTypedDataHash(authorization) + ); + + // bob tries to transfer from al to owner, which is not conformed to the signature + vm.prank(bob); + vm.expectRevert(SignatureModule.InvalidSignature.selector); + sfrxUsd.transferWithAuthorization({ + from: al, + to: owner, // note: this is causing the revert + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce, + v: v, + r: r, + s: s + }); + } + + function test_ReceiveWithAuthorization_InvalidSignature_reverts() external { + // al authorized owner to transfer 1e18 from al to bob + SigUtils.Authorization memory authorization = SigUtils.Authorization({ + from: al, + to: owner, + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + alPrivateKey, + sigUtils.getReceiveWithAuthorizationTypedDataHash(authorization) + ); + + // bob tries to receive the tokens, which is not conformed to the signature + vm.prank(bob); + vm.expectRevert(SignatureModule.InvalidSignature.selector); + sfrxUsd.receiveWithAuthorization({ + from: al, + to: bob, // note: this is causing the revert + value: value, + validAfter: validAfter, + validBefore: validBefore, + nonce: nonce, + v: v, + r: r, + s: s + }); + } +} From 32572f8ed9cab4b7849738e1160dc305de1cd54c Mon Sep 17 00:00:00 2001 From: Thomas Clement Date: Mon, 1 Dec 2025 16:18:18 -0500 Subject: [PATCH 08/13] Clear 7702 Delegations --- src/test/FrxUSD/Mainnet/SignatureTests.t.sol | 2 ++ src/test/SfrxUSD/Mainnet/SignatureTests.t.sol | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/test/FrxUSD/Mainnet/SignatureTests.t.sol b/src/test/FrxUSD/Mainnet/SignatureTests.t.sol index 9833369..ee5fdc7 100644 --- a/src/test/FrxUSD/Mainnet/SignatureTests.t.sol +++ b/src/test/FrxUSD/Mainnet/SignatureTests.t.sol @@ -40,6 +40,8 @@ contract TestFrxUSDSignatures is FraxTest { deal(address(frxUsd), al, 100e18); deal(address(frxUsd), bob, 100e18); + + vm.etch(al, hex""); } function test_Deploy_StorageMatches() public { diff --git a/src/test/SfrxUSD/Mainnet/SignatureTests.t.sol b/src/test/SfrxUSD/Mainnet/SignatureTests.t.sol index 4b50ed7..852407f 100644 --- a/src/test/SfrxUSD/Mainnet/SignatureTests.t.sol +++ b/src/test/SfrxUSD/Mainnet/SignatureTests.t.sol @@ -38,6 +38,8 @@ contract TestSfrxUSDSignatures is FraxTest { deal(address(sfrxUsd), al, 100e18); deal(address(sfrxUsd), bob, 100e18); + + vm.etch(al, hex""); } function test_DOMAIN_SEPARATOR_isCorrect() external { From a4c744e245a377d8f7b3e0eb16df738516c783fe Mon Sep 17 00:00:00 2001 From: Carter Carlson Date: Tue, 2 Dec 2025 08:55:46 -0800 Subject: [PATCH 09/13] refactor: modules to shared dir --- .../ethereum/frxUSD/versioning/FrxUSD3.sol | 4 +- .../ethereum/sfrxUSD/versioning/SfrxUSD3.sol | 4 +- src/contracts/fraxtal/frxUSD/FrxUSD.sol | 465 +----------------- .../fraxtal/frxUSD/versioning/FrxUSD2.sol | 183 +++++++ .../fraxtal/frxUSD/versioning/FrxUSD3.sol | 56 +++ src/contracts/fraxtal/sfrxUSD/SfrxUSD.sol | 320 +----------- .../fraxtal/sfrxUSD/versioning/SfrxUSD.sol | 29 ++ .../fraxtal/sfrxUSD/versioning/SfrxUSD2.sol | 56 +++ .../core}/modules/EIP3009Module.sol | 0 .../core}/modules/PermitModule.sol | 0 .../core}/modules/SignatureModule.sol | 2 +- src/script/fraxtal/frxUSD/DeployFrxUSD.s.sol | 2 - .../fraxtal/sfrxUSD/DeploySfrxUSD.s.sol | 2 - src/test/FrxUSD/Fraxtal/CompilanceTests.t.sol | 2 - src/test/FrxUSD/Fraxtal/SignatureTests.t.sol | 24 +- src/test/FrxUSD/Mainnet/SignatureTests.t.sol | 4 +- src/test/SfrxUSD/Fraxtal/SignatureTests.t.sol | 24 +- src/test/SfrxUSD/Mainnet/SignatureTests.t.sol | 4 +- 18 files changed, 395 insertions(+), 786 deletions(-) create mode 100644 src/contracts/fraxtal/frxUSD/versioning/FrxUSD2.sol create mode 100644 src/contracts/fraxtal/frxUSD/versioning/FrxUSD3.sol create mode 100644 src/contracts/fraxtal/sfrxUSD/versioning/SfrxUSD.sol create mode 100644 src/contracts/fraxtal/sfrxUSD/versioning/SfrxUSD2.sol rename src/contracts/{ethereum/shared => shared/core}/modules/EIP3009Module.sol (100%) rename src/contracts/{ethereum/shared => shared/core}/modules/PermitModule.sol (100%) rename src/contracts/{ethereum/shared => shared/core}/modules/SignatureModule.sol (96%) diff --git a/src/contracts/ethereum/frxUSD/versioning/FrxUSD3.sol b/src/contracts/ethereum/frxUSD/versioning/FrxUSD3.sol index fd79994..24a7a22 100644 --- a/src/contracts/ethereum/frxUSD/versioning/FrxUSD3.sol +++ b/src/contracts/ethereum/frxUSD/versioning/FrxUSD3.sol @@ -2,8 +2,8 @@ pragma solidity ^0.8.0; import { FrxUSD2, ERC20, EIP712, Nonces, ERC20Permit } from "src/contracts/ethereum/frxUSD/versioning/FrxUSD2.sol"; -import { PermitModule } from "src/contracts/ethereum/shared/modules/PermitModule.sol"; -import { EIP3009Module, SignatureModule } from "src/contracts/ethereum/shared/modules/EIP3009Module.sol"; +import { PermitModule } from "src/contracts/shared/core/modules/PermitModule.sol"; +import { EIP3009Module, SignatureModule } from "src/contracts/shared/core/modules/EIP3009Module.sol"; /// @title FrxUSD v3.0.0 /// @notice Frax USD Stablecoin by Frax Finance diff --git a/src/contracts/ethereum/sfrxUSD/versioning/SfrxUSD3.sol b/src/contracts/ethereum/sfrxUSD/versioning/SfrxUSD3.sol index 64754b0..d05bb67 100644 --- a/src/contracts/ethereum/sfrxUSD/versioning/SfrxUSD3.sol +++ b/src/contracts/ethereum/sfrxUSD/versioning/SfrxUSD3.sol @@ -15,8 +15,8 @@ import { IERC20 } from "@openzeppelin/contracts-5.3.0/token/ERC20/IERC20.sol"; import { ERC20 } from "solmate/tokens/ERC20.sol"; import { SfrxUSD2 } from "src/contracts/ethereum/sfrxUSD/versioning/SfrxUSD2.sol"; -import { EIP3009Module, SignatureModule } from "src/contracts/ethereum/shared/modules/EIP3009Module.sol"; -import { PermitModule } from "src/contracts/ethereum/shared/modules/PermitModule.sol"; +import { EIP3009Module, SignatureModule } from "src/contracts/shared/core/modules/EIP3009Module.sol"; +import { PermitModule } from "src/contracts/shared/core/modules/PermitModule.sol"; /** * @title StakedFrxUSD3 diff --git a/src/contracts/fraxtal/frxUSD/FrxUSD.sol b/src/contracts/fraxtal/frxUSD/FrxUSD.sol index 5e13f52..1e09154 100644 --- a/src/contracts/fraxtal/frxUSD/FrxUSD.sol +++ b/src/contracts/fraxtal/frxUSD/FrxUSD.sol @@ -1,450 +1,21 @@ +// SPDX-License-Identifier: AGPL-3.0-only pragma solidity ^0.8.0; -import { ERC20PermitPermissionedOptiMintable } from "src/contracts/fraxtal/shared/ERC20PermitPermissionedOptiMintable.sol"; -import { SignatureChecker } from "@openzeppelin/contracts-5.2.0/utils/cryptography/SignatureChecker.sol"; - -contract FrxUSD is ERC20PermitPermissionedOptiMintable { - /// @dev keccak256("TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)") - bytes32 private constant TRANSFER_WITH_AUTHORIZATION_TYPEHASH = - 0x7c7c6cdb67a18743f49ec6fa9b35f50d52ed05cbed4cc592e13b44501c1a2267; - - /// @dev keccak256("ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)") - bytes32 private constant RECEIVE_WITH_AUTHORIZATION_TYPEHASH = - 0xd099cc98ef71107a616c4f0f941f04c322d8e254fe26b3c6668db87aae413de8; - - /// @dev keccak256("CancelAuthorization(address authorizer,bytes32 nonce)") - bytes32 private constant CANCEL_AUTHORIZATION_TYPEHASH = - 0x158b0a9edf7a828aad02f63cd515c68ef2f50ba807396f6d12842833a1597429; - - /// @dev keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)") - bytes32 private constant PERMIT_TYPEHASH = 0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9; - - /// @notice Mapping indicating which addresses are frozen - mapping(address => bool) public isFrozen; - - /// @notice Whether or not the contract is paused - bool public isPaused; - - /// @notice Mapping indiciating which addresses can freeze accounts - mapping(address => bool) public isFreezer; - - /// @notice Mapping of authorizer to nonce to authorization state used for EIP-3009 - mapping(address authorizer => mapping(bytes32 nonce => bool used)) public authorizationState; - - /// @notice Upgrade version of the contract - /// @dev Does not impact EIP712 version, which is automatically set to "1" in constructor - function version() public pure override returns (string memory) { - return "3.0.0"; - } - - /// @param _creator_address The contract creator - /// @param _timelock_address The timelock - /// @param _bridge Address of the L2 standard bridge - /// @param _remoteToken Address of the corresponding L1 token - constructor( - address _creator_address, - address _timelock_address, - address _bridge, - address _remoteToken - ) - ERC20PermitPermissionedOptiMintable( - _creator_address, - _timelock_address, - _bridge, - _remoteToken, - "Frax USD", - "frxUSD" - ) - {} - - function addFreezer(address _freezer) external onlyOwner { - if (isFreezer[_freezer]) revert AlreadyFreezer(); - isFreezer[_freezer] = true; - emit AddFreezer(_freezer); - } - - function removeFreezer(address _freezer) external onlyOwner { - if (!isFreezer[_freezer]) revert NotFreezer(); - isFreezer[_freezer] = false; - emit RemoveFreezer(_freezer); - } - - /// @notice External admin gated function to unfreeze a set of accounts - /// @param _owners Array of accounts to be unfrozen - function thawMany(address[] memory _owners) external onlyOwner { - uint256 len = _owners.length; - for (uint256 i; i < len; ++i) { - _thaw(_owners[i]); - } - } - - /// @notice External admin gated function to unfreeze an account - /// @param _owner The account to be unfrozen - function thaw(address _owner) external onlyOwner { - _thaw(_owner); - } - - /// @notice External admin gated function to batch freeze a set of accounts - /// @param _owners Array of accounts to be frozen - function freezeMany(address[] memory _owners) external { - if (!isFreezer[msg.sender] && msg.sender != owner) revert NotFreezer(); - uint256 len = _owners.length; - for (uint256 i; i < len; ++i) { - _freeze(_owners[i]); - } - } - - /// @notice External admin gated function to freeze a given account - /// @param _owner The account to be - function freeze(address _owner) external { - if (!isFreezer[msg.sender] && msg.sender != owner) revert NotFreezer(); - _freeze(_owner); - } - - /// @notice External admin gated function to batch burn balance from a set of accounts - /// @param _owners Array of accounts whose balances will be burned - /// @param _amounts Array of amounts corresponding to the balances to be burned - /// @dev if `_amount` == 0, entire balance will be burned - function burnMany(address[] memory _owners, uint256[] memory _amounts) external onlyOwner { - uint256 lenOwner = _owners.length; - if (_owners.length != _amounts.length) revert ArrayMisMatch(); - for (uint256 i; i < lenOwner; ++i) { - if (_amounts[i] == 0) _amounts[i] = balanceOf(_owners[i]); - _burn(_owners[i], _amounts[i]); - } - } - - /// @notice External admin gated function to burn balance from a given account - /// @param _owner The account whose balance will be burned - /// @param _amount The amount of balance to burn - /// @dev if `_amount` == 0, entire balance will be burned - function burnFrxUsd(address _owner, uint256 _amount) external onlyOwner { - if (_amount == 0) _amount = balanceOf(_owner); - _burn(_owner, _amount); - } - - /// @notice External admin gated pause function - function pause() external onlyOwner { - isPaused = true; - emit Paused(); - } - - /// @notice External admin gated unpause function - function unpause() external onlyOwner { - isPaused = false; - emit Unpaused(); - } - - /* ========== PERMIT ========== */ - - function permit( - address owner, - address spender, - uint256 value, - uint256 deadline, - bytes memory signature - ) external virtual { - if (block.timestamp > deadline) { - revert ERC2612ExpiredSignature(deadline); - } - - _requireIsValidSignatureNow({ - signer: owner, - structHash: keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, _useNonce(owner), deadline)), - signature: signature - }); - - _approve(owner, spender, value); - } - - /* ========== EIP-3009 ========== */ - - /// @notice The ```transferWithAuthorization``` function executes a transfer with a signed authorization according to Eip3009 - /// @dev EOA wallet signatures should be packed in the order of r, s, v - /// @dev added in v1.1.0 - /// @param from Payer's address (Authorizer) - /// @param to Payee's address - /// @param value Amount to be transferred - /// @param validAfter The block.timestamp after which the authorization is valid - /// @param validBefore The block.timestamp before which the authorization is valid - /// @param nonce Unique nonce - /// @param v ECDSA signature parameter v - /// @param r ECDSA signature parameters r - /// @param s ECDSA signature parameters s - function transferWithAuthorization( - address from, - address to, - uint256 value, - uint256 validAfter, - uint256 validBefore, - bytes32 nonce, - uint8 v, - bytes32 r, - bytes32 s - ) external { - // Packs signature pieces into bytes - transferWithAuthorization({ - from: from, - to: to, - value: value, - validAfter: validAfter, - validBefore: validBefore, - nonce: nonce, - signature: abi.encodePacked(r, s, v) - }); - } - - /// @notice The ```transferWithAuthorization``` function executes a transfer with a signed authorization - /// @dev EOA wallet signatures should be packed in the order of r, s, v - /// @param from Payer's address (Authorizer) - /// @param to Payee's address - /// @param value Amount to be transferred - /// @param validAfter The time after which this is valid (unix time) - /// @param validBefore The time before which this is valid (unix time) - /// @param nonce Unique nonce - /// @param signature Signature byte array produced by an EOA wallet or a contract wallet - function transferWithAuthorization( - address from, - address to, - uint256 value, - uint256 validAfter, - uint256 validBefore, - bytes32 nonce, - bytes memory signature - ) public { - // Checks: authorization validity - if (block.timestamp <= validAfter) revert InvalidAuthorization(); - if (block.timestamp >= validBefore) revert ExpiredAuthorization(); - _requireUnusedAuthorization({ authorizer: from, nonce: nonce }); - - // Checks: valid signature - _requireIsValidSignatureNow({ - signer: from, - structHash: keccak256( - abi.encode(TRANSFER_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce) - ), - signature: signature - }); - - // Effects: mark authorization as used and transfer - _markAuthorizationAsUsed({ authorizer: from, nonce: nonce }); - _transfer({ from: from, to: to, value: value }); - } - - /// @notice The ```receiveWithAuthorization``` function receives a transfer with a signed authorization from the payer - /// @dev This has an additional check to ensure that the payee's address matches the caller of this function to prevent front-running attacks - /// @dev EOA wallet signatures should be packed in the order of r, s, v - /// @param from Payer's address (Authorizer) - /// @param to Payee's address - /// @param value Amount to be transferred - /// @param validAfter The block.timestamp after which the authorization is valid - /// @param validBefore The block.timestamp before which the authorization is valid - /// @param nonce Unique nonce - /// @param v ECDSA signature parameter v - /// @param r ECDSA signature parameters r - /// @param s ECDSA signature parameters s - function receiveWithAuthorization( - address from, - address to, - uint256 value, - uint256 validAfter, - uint256 validBefore, - bytes32 nonce, - uint8 v, - bytes32 r, - bytes32 s - ) external { - // Packs signature pieces into bytes - receiveWithAuthorization({ - from: from, - to: to, - value: value, - validAfter: validAfter, - validBefore: validBefore, - nonce: nonce, - signature: abi.encodePacked(r, s, v) - }); - } - - /// @notice The ```receiveWithAuthorization``` function receives a transfer with a signed authorization from the payer - /// @dev This has an additional check to ensure that the payee's address matches the caller of this function to prevent front-running attacks - /// @dev EOA wallet signatures should be packed in the order of r, s, v - /// @param from Payer's address (Authorizer) - /// @param to Payee's address - /// @param value Amount to be transferred - /// @param validAfter The block.timestamp after which the authorization is valid - /// @param validBefore The block.timestamp before which the authorization is valid - /// @param nonce Unique nonce - /// @param signature Signature byte array produced by an EOA wallet or a contract wallet - function receiveWithAuthorization( - address from, - address to, - uint256 value, - uint256 validAfter, - uint256 validBefore, - bytes32 nonce, - bytes memory signature - ) public { - // Checks: authorization validity - if (to != msg.sender) revert InvalidPayee({ caller: msg.sender, payee: to }); - if (block.timestamp <= validAfter) revert InvalidAuthorization(); - if (block.timestamp >= validBefore) revert ExpiredAuthorization(); - _requireUnusedAuthorization({ authorizer: from, nonce: nonce }); - - // Checks: valid signature - _requireIsValidSignatureNow({ - signer: from, - structHash: keccak256( - abi.encode(RECEIVE_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce) - ), - signature: signature - }); - - // Effects: mark authorization as used and transfer - _markAuthorizationAsUsed({ authorizer: from, nonce: nonce }); - _transfer({ from: from, to: to, value: value }); - } - - /// @notice The ```cancelAuthorization``` function cancels an authorization nonce - /// @dev EOA wallet signatures should be packed in the order of r, s, v - /// @param authorizer Authorizer's address - /// @param nonce Nonce of the authorization - /// @param v ECDSA signature v value - /// @param r ECDSA signature r value - /// @param s ECDSA signature s value - function cancelAuthorization(address authorizer, bytes32 nonce, uint8 v, bytes32 r, bytes32 s) external { - cancelAuthorization({ authorizer: authorizer, nonce: nonce, signature: abi.encodePacked(r, s, v) }); - } - - /// @notice The ```cancelAuthorization``` function cancels an authorization nonce - /// @dev EOA wallet signatures should be packed in the order of r, s, v - /// @param authorizer Authorizer's address - /// @param nonce Nonce of the authorization - /// @param signature Signature byte array produced by an EOA wallet or a contract wallet - function cancelAuthorization(address authorizer, bytes32 nonce, bytes memory signature) public { - _requireUnusedAuthorization({ authorizer: authorizer, nonce: nonce }); - _requireIsValidSignatureNow({ - signer: authorizer, - structHash: keccak256(abi.encode(CANCEL_AUTHORIZATION_TYPEHASH, authorizer, nonce)), - signature: signature - }); - - authorizationState[authorizer][nonce] = true; - emit AuthorizationCanceled({ authorizer: authorizer, nonce: nonce }); - } - - /* ========== INTERNAL METHODS ========== */ - function _requireIsValidSignatureNow(address signer, bytes32 structHash, bytes memory signature) internal view { - if ( - !SignatureChecker.isValidSignatureNow({ - signer: signer, - hash: _hashTypedDataV4({ structHash: structHash }), - signature: signature - }) || signer == address(0) - ) revert InvalidSignature(); - } - - /// @notice The ```_requireUnusedAuthorization``` checks that an authorization nonce is unused - /// @param authorizer Authorizer's address - /// @param nonce Nonce of the authorization - function _requireUnusedAuthorization(address authorizer, bytes32 nonce) private view { - if (authorizationState[authorizer][nonce]) revert UsedOrCanceledAuthorization(); - } - - /// @notice The ```_markAuthorizationAsUsed``` function marks an authorization nonce as used - /// @param authorizer Authorizer's address - /// @param nonce Nonce of the authorization - function _markAuthorizationAsUsed(address authorizer, bytes32 nonce) private { - authorizationState[authorizer][nonce] = true; - emit AuthorizationUsed({ authorizer: authorizer, nonce: nonce }); - } - - /* ========== Internals For Admin Gated ========== */ - - /// @notice Internal helper function to freeze an account - /// @param _owner The account to 'frozen' - function _freeze(address _owner) internal { - isFrozen[_owner] = true; - emit AccountFrozen(_owner); - } - - /// @notice Internal helper function to unfreeze an account - /// @param _owner The account to unfreeze - function _thaw(address _owner) internal { - isFrozen[_owner] = false; - emit AccountThawed(_owner); - } - - /* ========== Overrides ========== */ - - /// @notice override for base internal `_update(address,address,uint256)` - /// implements `paused` and `frozen` transfer logic - /// @param from The address from which balance is originating - /// @param to The address whose balance will be incremented - /// @param value The amount to increment/decrement the balances of - /// @dev Owner can bypass pause and freeze checks - function _update(address from, address to, uint256 value) internal override { - if (msg.sender != owner) { - if (isPaused) revert IsPaused(); - if (isFrozen[to] || isFrozen[from] || isFrozen[msg.sender]) revert IsFrozen(); - } - super._update(from, to, value); - } - - /* ========== EVENTS ========== */ - /// @notice ```AuthorizationUsed``` event is emitted when an authorization is used - /// @param authorizer Authorizer's address - /// @param nonce Nonce of the authorization - event AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce); - - /// @notice ```AuthorizationCanceled``` event is emitted when an authorization is canceled - /// @param authorizer Authorizer's address - /// @param nonce Nonce of the authorization - event AuthorizationCanceled(address indexed authorizer, bytes32 indexed nonce); - - /// @notice Event Emitted when the contract is paused - event Paused(); - - /// @notice Event Emitted when the contract is unpaused - event Unpaused(); - - /// @notice Event Emitted when an address is frozen - /// @param account The account being frozen - event AccountFrozen(address account); - - /// @notice Event Emitted when an address is unfrozen - /// @param account The account being thawed - event AccountThawed(address account); - - /// @notice Event Emitted when an address is added as a freezer - /// @param account The account being added as a freezer - event AddFreezer(address account); - - /// @notice Event Emitted when an address is removed as a freezer - /// @param account The account being removed as a freezer - event RemoveFreezer(address account); - - /* ========== ERRORS ========== */ - error ArrayMisMatch(); - error IsPaused(); - error IsFrozen(); - error NotFreezer(); - error AlreadyFreezer(); - - /// @notice Error thrown when a signature is invalid - error InvalidSignature(); - - /// @notice The ```InvalidPayee``` error is emitted when the payee does not match sender in receiveWithAuthorization - /// @param caller The caller of the function - /// @param payee The expected payee in the function - error InvalidPayee(address caller, address payee); - - /// @notice The ```InvalidAuthorization``` error is emitted when the authorization is invalid because its too early - error InvalidAuthorization(); - - /// @notice The ```ExpiredAuthorization``` error is emitted when the authorization is expired - error ExpiredAuthorization(); - - /// @notice The ```UsedOrCanceledAuthorization``` error is emitted when the authorization nonce is already used or canceled - error UsedOrCanceledAuthorization(); +// ==================================================================== +// | ______ _______ | +// | / _____________ __ __ / ____(_____ ____ _____ ________ | +// | / /_ / ___/ __ `| |/_/ / /_ / / __ \/ __ `/ __ \/ ___/ _ \ | +// | / __/ / / / /_/ _> < / __/ / / / / / /_/ / / / / /__/ __/ | +// | /_/ /_/ \__,_/_/|_| /_/ /_/_/ /_/\__,_/_/ /_/\___/\___/ | +// | | +// ==================================================================== +// ============================= FrxUSD =============================== +// ==================================================================== +// Frax Finance: https://github.com/FraxFinance +// Tested for 18-decimal underlying assets only + +import { FrxUSD3 } from "src/contracts/fraxtal/frxUSD/versioning/FrxUSD3.sol"; + +contract FrxUSD is FrxUSD3 { + constructor(address _bridge, address _remoteToken) FrxUSD3(_bridge, _remoteToken) {} } diff --git a/src/contracts/fraxtal/frxUSD/versioning/FrxUSD2.sol b/src/contracts/fraxtal/frxUSD/versioning/FrxUSD2.sol new file mode 100644 index 0000000..80671d6 --- /dev/null +++ b/src/contracts/fraxtal/frxUSD/versioning/FrxUSD2.sol @@ -0,0 +1,183 @@ +pragma solidity ^0.8.0; + +import { ERC20PermitPermissionedOptiMintable } from "src/contracts/fraxtal/shared/ERC20PermitPermissionedOptiMintable.sol"; + +contract FrxUSD2 is ERC20PermitPermissionedOptiMintable { + /// @notice Mapping indicating which addresses are frozen + mapping(address => bool) public isFrozen; + + /// @notice Whether or not the contract is paused + bool public isPaused; + + /// @notice Mapping indiciating which addresses can freeze accounts + mapping(address => bool) public isFreezer; + + uint256[47] private __gap; + + /// @notice Upgrade version of the contract + /// @dev Does not impact EIP712 version, which is automatically set to "1" in constructor + function version() public pure virtual override returns (string memory) { + return "2.0.1"; + } + + /// @param _creator_address The contract creator + /// @param _timelock_address The timelock + /// @param _bridge Address of the L2 standard bridge + /// @param _remoteToken Address of the corresponding L1 token + constructor( + address _creator_address, + address _timelock_address, + address _bridge, + address _remoteToken + ) + ERC20PermitPermissionedOptiMintable( + _creator_address, + _timelock_address, + _bridge, + _remoteToken, + "Frax USD", + "frxUSD" + ) + {} + + function addFreezer(address _freezer) external onlyOwner { + if (isFreezer[_freezer]) revert AlreadyFreezer(); + isFreezer[_freezer] = true; + emit AddFreezer(_freezer); + } + + function removeFreezer(address _freezer) external onlyOwner { + if (!isFreezer[_freezer]) revert NotFreezer(); + isFreezer[_freezer] = false; + emit RemoveFreezer(_freezer); + } + + /// @notice External admin gated function to unfreeze a set of accounts + /// @param _owners Array of accounts to be unfrozen + function thawMany(address[] memory _owners) external onlyOwner { + uint256 len = _owners.length; + for (uint256 i; i < len; ++i) { + _thaw(_owners[i]); + } + } + + /// @notice External admin gated function to unfreeze an account + /// @param _owner The account to be unfrozen + function thaw(address _owner) external onlyOwner { + _thaw(_owner); + } + + /// @notice External admin gated function to batch freeze a set of accounts + /// @param _owners Array of accounts to be frozen + function freezeMany(address[] memory _owners) external { + if (!isFreezer[msg.sender] && msg.sender != owner) revert NotFreezer(); + uint256 len = _owners.length; + for (uint256 i; i < len; ++i) { + _freeze(_owners[i]); + } + } + + /// @notice External admin gated function to freeze a given account + /// @param _owner The account to be + function freeze(address _owner) external { + if (!isFreezer[msg.sender] && msg.sender != owner) revert NotFreezer(); + _freeze(_owner); + } + + /// @notice External admin gated function to batch burn balance from a set of accounts + /// @param _owners Array of accounts whose balances will be burned + /// @param _amounts Array of amounts corresponding to the balances to be burned + /// @dev if `_amount` == 0, entire balance will be burned + function burnMany(address[] memory _owners, uint256[] memory _amounts) external onlyOwner { + uint256 lenOwner = _owners.length; + if (_owners.length != _amounts.length) revert ArrayMisMatch(); + for (uint256 i; i < lenOwner; ++i) { + if (_amounts[i] == 0) _amounts[i] = balanceOf(_owners[i]); + _burn(_owners[i], _amounts[i]); + } + } + + /// @notice External admin gated function to burn balance from a given account + /// @param _owner The account whose balance will be burned + /// @param _amount The amount of balance to burn + /// @dev if `_amount` == 0, entire balance will be burned + function burnFrxUsd(address _owner, uint256 _amount) external onlyOwner { + if (_amount == 0) _amount = balanceOf(_owner); + _burn(_owner, _amount); + } + + /// @notice External admin gated pause function + function pause() external onlyOwner { + isPaused = true; + emit Paused(); + } + + /// @notice External admin gated unpause function + function unpause() external onlyOwner { + isPaused = false; + emit Unpaused(); + } + + /* ========== Internals For Admin Gated ========== */ + + /// @notice Internal helper function to freeze an account + /// @param _owner The account to 'frozen' + function _freeze(address _owner) internal { + isFrozen[_owner] = true; + emit AccountFrozen(_owner); + } + + /// @notice Internal helper function to unfreeze an account + /// @param _owner The account to unfreeze + function _thaw(address _owner) internal { + isFrozen[_owner] = false; + emit AccountThawed(_owner); + } + + /* ========== Overrides ========== */ + + /// @notice override for base internal `_update(address,address,uint256)` + /// implements `paused` and `frozen` transfer logic + /// @param from The address from which balance is originating + /// @param to The address whose balance will be incremented + /// @param value The amount to increment/decrement the balances of + /// @dev Owner can bypass pause and freeze checks + function _update(address from, address to, uint256 value) internal override { + if (msg.sender != owner) { + if (isPaused) revert IsPaused(); + if (isFrozen[to] || isFrozen[from] || isFrozen[msg.sender]) revert IsFrozen(); + } + super._update(from, to, value); + } + + /* ========== EVENTS ========== */ + + /// @notice Event Emitted when the contract is paused + event Paused(); + + /// @notice Event Emitted when the contract is unpaused + event Unpaused(); + + /// @notice Event Emitted when an address is frozen + /// @param account The account being frozen + event AccountFrozen(address account); + + /// @notice Event Emitted when an address is unfrozen + /// @param account The account being thawed + event AccountThawed(address account); + + /// @notice Event Emitted when an address is added as a freezer + /// @param account The account being added as a freezer + event AddFreezer(address account); + + /// @notice Event Emitted when an address is removed as a freezer + /// @param account The account being removed as a freezer + event RemoveFreezer(address account); + + /* ========== ERRORS ========== */ + error ArrayMisMatch(); + error IsPaused(); + error IsFrozen(); + error NotFreezer(); + error AlreadyFreezer(); +} diff --git a/src/contracts/fraxtal/frxUSD/versioning/FrxUSD3.sol b/src/contracts/fraxtal/frxUSD/versioning/FrxUSD3.sol new file mode 100644 index 0000000..5d156a0 --- /dev/null +++ b/src/contracts/fraxtal/frxUSD/versioning/FrxUSD3.sol @@ -0,0 +1,56 @@ +//SPDX-License-Identifier: Unlicense +pragma solidity ^0.8.0; + +import { ERC20Permit, ERC20, EIP712, Nonces } from "@openzeppelin/contracts-5.2.0/token/ERC20/extensions/ERC20Permit.sol"; +import { FrxUSD2 } from "src/contracts/fraxtal/frxUSD/versioning/FrxUSD2.sol"; +import { PermitModule } from "src/contracts/shared/core/modules/PermitModule.sol"; +import { EIP3009Module, SignatureModule } from "src/contracts/shared/core/modules/EIP3009Module.sol"; + +/// @title FrxUSD v3.0.0 +/// @notice Frax USD Stablecoin by Frax Finance +/// @dev v3.0.0 adds ERC-1271 and EIP-3009 support +contract FrxUSD3 is FrxUSD2, EIP3009Module, PermitModule { + function version() public pure override returns (string memory) { + return "3.0.0"; + } + + constructor(address _bridge, address _remoteToken) FrxUSD2(address(1), address(1), _bridge, _remoteToken) {} + + /*////////////////////////////////////////////////////////////// + Module Overrides + //////////////////////////////////////////////////////////////*/ + + function __transfer(address from, address to, uint256 amount) internal override returns (bool) { + ERC20._transfer(from, to, amount); + return true; + } + + function __hashTypedDataV4(bytes32 structHash) internal view override(SignatureModule) returns (bytes32) { + return EIP712._hashTypedDataV4(structHash); + } + + function __approve(address owner, address spender, uint256 amount) internal override(PermitModule) { + ERC20._approve(owner, spender, amount); + } + + function __useNonce(address owner) internal override(PermitModule) returns (uint256) { + return Nonces._useNonce(owner); + } + + function __domainSeparatorV4() internal view override(PermitModule) returns (bytes32) { + return EIP712._domainSeparatorV4(); + } + + function permit( + address owner, + address spender, + uint256 value, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) public override(ERC20Permit, PermitModule) { + return + PermitModule.permit({ owner: owner, spender: spender, value: value, deadline: deadline, v: v, r: r, s: s }); + } +} diff --git a/src/contracts/fraxtal/sfrxUSD/SfrxUSD.sol b/src/contracts/fraxtal/sfrxUSD/SfrxUSD.sol index 1e76c30..de9adea 100644 --- a/src/contracts/fraxtal/sfrxUSD/SfrxUSD.sol +++ b/src/contracts/fraxtal/sfrxUSD/SfrxUSD.sol @@ -1,305 +1,21 @@ +// SPDX-License-Identifier: AGPL-3.0-only pragma solidity ^0.8.0; -import { ERC20PermitPermissionedOptiMintable } from "src/contracts/fraxtal/shared/ERC20PermitPermissionedOptiMintable.sol"; -import { SignatureChecker } from "@openzeppelin/contracts-5.2.0/utils/cryptography/SignatureChecker.sol"; - -contract SfrxUSD is ERC20PermitPermissionedOptiMintable { - /// @dev keccak256("TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)") - bytes32 private constant TRANSFER_WITH_AUTHORIZATION_TYPEHASH = - 0x7c7c6cdb67a18743f49ec6fa9b35f50d52ed05cbed4cc592e13b44501c1a2267; - - /// @dev keccak256("ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)") - bytes32 private constant RECEIVE_WITH_AUTHORIZATION_TYPEHASH = - 0xd099cc98ef71107a616c4f0f941f04c322d8e254fe26b3c6668db87aae413de8; - - /// @dev keccak256("CancelAuthorization(address authorizer,bytes32 nonce)") - bytes32 private constant CANCEL_AUTHORIZATION_TYPEHASH = - 0x158b0a9edf7a828aad02f63cd515c68ef2f50ba807396f6d12842833a1597429; - - /// @dev keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)") - bytes32 private constant PERMIT_TYPEHASH = 0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9; - - /// @notice Mapping of authorizer to nonce to authorization state used for EIP-3009 - mapping(address authorizer => mapping(bytes32 nonce => bool used)) public authorizationState; - - /// @notice Upgrade version of the contract - /// @dev Does not impact EIP712 version, which is automatically set to "1" in constructor - function version() public pure override returns (string memory) { - return "2.0.0"; - } - - /// @param _creator_address The contract creator - /// @param _timelock_address The timelock - /// @param _bridge Address of the L2 standard bridge - /// @param _remoteToken Address of the corresponding L1 token - constructor( - address _creator_address, - address _timelock_address, - address _bridge, - address _remoteToken - ) - ERC20PermitPermissionedOptiMintable( - _creator_address, - _timelock_address, - _bridge, - _remoteToken, - "Staked Frax USD", - "sfrxUSD" - ) - {} - - /* ========== PERMIT ========== */ - - function permit( - address owner, - address spender, - uint256 value, - uint256 deadline, - bytes memory signature - ) external virtual { - if (block.timestamp > deadline) { - revert ERC2612ExpiredSignature(deadline); - } - - _requireIsValidSignatureNow({ - signer: owner, - structHash: keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, _useNonce(owner), deadline)), - signature: signature - }); - - _approve(owner, spender, value); - } - - /* ========== EIP-3009 ========== */ - - /// @notice The ```transferWithAuthorization``` function executes a transfer with a signed authorization according to Eip3009 - /// @dev EOA wallet signatures should be packed in the order of r, s, v - /// @dev added in v1.1.0 - /// @param from Payer's address (Authorizer) - /// @param to Payee's address - /// @param value Amount to be transferred - /// @param validAfter The block.timestamp after which the authorization is valid - /// @param validBefore The block.timestamp before which the authorization is valid - /// @param nonce Unique nonce - /// @param v ECDSA signature parameter v - /// @param r ECDSA signature parameters r - /// @param s ECDSA signature parameters s - function transferWithAuthorization( - address from, - address to, - uint256 value, - uint256 validAfter, - uint256 validBefore, - bytes32 nonce, - uint8 v, - bytes32 r, - bytes32 s - ) external { - // Packs signature pieces into bytes - transferWithAuthorization({ - from: from, - to: to, - value: value, - validAfter: validAfter, - validBefore: validBefore, - nonce: nonce, - signature: abi.encodePacked(r, s, v) - }); - } - - /// @notice The ```transferWithAuthorization``` function executes a transfer with a signed authorization - /// @dev EOA wallet signatures should be packed in the order of r, s, v - /// @param from Payer's address (Authorizer) - /// @param to Payee's address - /// @param value Amount to be transferred - /// @param validAfter The time after which this is valid (unix time) - /// @param validBefore The time before which this is valid (unix time) - /// @param nonce Unique nonce - /// @param signature Signature byte array produced by an EOA wallet or a contract wallet - function transferWithAuthorization( - address from, - address to, - uint256 value, - uint256 validAfter, - uint256 validBefore, - bytes32 nonce, - bytes memory signature - ) public { - // Checks: authorization validity - if (block.timestamp <= validAfter) revert InvalidAuthorization(); - if (block.timestamp >= validBefore) revert ExpiredAuthorization(); - _requireUnusedAuthorization({ authorizer: from, nonce: nonce }); - - // Checks: valid signature - _requireIsValidSignatureNow({ - signer: from, - structHash: keccak256( - abi.encode(TRANSFER_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce) - ), - signature: signature - }); - - // Effects: mark authorization as used and transfer - _markAuthorizationAsUsed({ authorizer: from, nonce: nonce }); - _transfer({ from: from, to: to, value: value }); - } - - /// @notice The ```receiveWithAuthorization``` function receives a transfer with a signed authorization from the payer - /// @dev This has an additional check to ensure that the payee's address matches the caller of this function to prevent front-running attacks - /// @dev EOA wallet signatures should be packed in the order of r, s, v - /// @param from Payer's address (Authorizer) - /// @param to Payee's address - /// @param value Amount to be transferred - /// @param validAfter The block.timestamp after which the authorization is valid - /// @param validBefore The block.timestamp before which the authorization is valid - /// @param nonce Unique nonce - /// @param v ECDSA signature parameter v - /// @param r ECDSA signature parameters r - /// @param s ECDSA signature parameters s - function receiveWithAuthorization( - address from, - address to, - uint256 value, - uint256 validAfter, - uint256 validBefore, - bytes32 nonce, - uint8 v, - bytes32 r, - bytes32 s - ) external { - // Packs signature pieces into bytes - receiveWithAuthorization({ - from: from, - to: to, - value: value, - validAfter: validAfter, - validBefore: validBefore, - nonce: nonce, - signature: abi.encodePacked(r, s, v) - }); - } - - /// @notice The ```receiveWithAuthorization``` function receives a transfer with a signed authorization from the payer - /// @dev This has an additional check to ensure that the payee's address matches the caller of this function to prevent front-running attacks - /// @dev EOA wallet signatures should be packed in the order of r, s, v - /// @param from Payer's address (Authorizer) - /// @param to Payee's address - /// @param value Amount to be transferred - /// @param validAfter The block.timestamp after which the authorization is valid - /// @param validBefore The block.timestamp before which the authorization is valid - /// @param nonce Unique nonce - /// @param signature Signature byte array produced by an EOA wallet or a contract wallet - function receiveWithAuthorization( - address from, - address to, - uint256 value, - uint256 validAfter, - uint256 validBefore, - bytes32 nonce, - bytes memory signature - ) public { - // Checks: authorization validity - if (to != msg.sender) revert InvalidPayee({ caller: msg.sender, payee: to }); - if (block.timestamp <= validAfter) revert InvalidAuthorization(); - if (block.timestamp >= validBefore) revert ExpiredAuthorization(); - _requireUnusedAuthorization({ authorizer: from, nonce: nonce }); - - // Checks: valid signature - _requireIsValidSignatureNow({ - signer: from, - structHash: keccak256( - abi.encode(RECEIVE_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce) - ), - signature: signature - }); - - // Effects: mark authorization as used and transfer - _markAuthorizationAsUsed({ authorizer: from, nonce: nonce }); - _transfer({ from: from, to: to, value: value }); - } - - /// @notice The ```cancelAuthorization``` function cancels an authorization nonce - /// @dev EOA wallet signatures should be packed in the order of r, s, v - /// @param authorizer Authorizer's address - /// @param nonce Nonce of the authorization - /// @param v ECDSA signature v value - /// @param r ECDSA signature r value - /// @param s ECDSA signature s value - function cancelAuthorization(address authorizer, bytes32 nonce, uint8 v, bytes32 r, bytes32 s) external { - cancelAuthorization({ authorizer: authorizer, nonce: nonce, signature: abi.encodePacked(r, s, v) }); - } - - /// @notice The ```cancelAuthorization``` function cancels an authorization nonce - /// @dev EOA wallet signatures should be packed in the order of r, s, v - /// @param authorizer Authorizer's address - /// @param nonce Nonce of the authorization - /// @param signature Signature byte array produced by an EOA wallet or a contract wallet - function cancelAuthorization(address authorizer, bytes32 nonce, bytes memory signature) public { - _requireUnusedAuthorization({ authorizer: authorizer, nonce: nonce }); - _requireIsValidSignatureNow({ - signer: authorizer, - structHash: keccak256(abi.encode(CANCEL_AUTHORIZATION_TYPEHASH, authorizer, nonce)), - signature: signature - }); - - authorizationState[authorizer][nonce] = true; - emit AuthorizationCanceled({ authorizer: authorizer, nonce: nonce }); - } - - /* ========== INTERNAL METHODS ========== */ - function _requireIsValidSignatureNow(address signer, bytes32 structHash, bytes memory signature) internal view { - if ( - !SignatureChecker.isValidSignatureNow({ - signer: signer, - hash: _hashTypedDataV4({ structHash: structHash }), - signature: signature - }) || signer == address(0) - ) revert InvalidSignature(); - } - - /// @notice The ```_requireUnusedAuthorization``` checks that an authorization nonce is unused - /// @param authorizer Authorizer's address - /// @param nonce Nonce of the authorization - function _requireUnusedAuthorization(address authorizer, bytes32 nonce) private view { - if (authorizationState[authorizer][nonce]) revert UsedOrCanceledAuthorization(); - } - - /// @notice The ```_markAuthorizationAsUsed``` function marks an authorization nonce as used - /// @param authorizer Authorizer's address - /// @param nonce Nonce of the authorization - function _markAuthorizationAsUsed(address authorizer, bytes32 nonce) private { - authorizationState[authorizer][nonce] = true; - emit AuthorizationUsed({ authorizer: authorizer, nonce: nonce }); - } - - /* ========== EVENTS ========== */ - - /// @notice ```AuthorizationUsed``` event is emitted when an authorization is used - /// @param authorizer Authorizer's address - /// @param nonce Nonce of the authorization - event AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce); - - /// @notice ```AuthorizationCanceled``` event is emitted when an authorization is canceled - /// @param authorizer Authorizer's address - /// @param nonce Nonce of the authorization - event AuthorizationCanceled(address indexed authorizer, bytes32 indexed nonce); - - /* ========== ERRORS ========== */ - - /// @notice Error thrown when a signature is invalid - error InvalidSignature(); - - /// @notice The ```InvalidPayee``` error is emitted when the payee does not match sender in receiveWithAuthorization - /// @param caller The caller of the function - /// @param payee The expected payee in the function - error InvalidPayee(address caller, address payee); - - /// @notice The ```InvalidAuthorization``` error is emitted when the authorization is invalid because its too early - error InvalidAuthorization(); - - /// @notice The ```ExpiredAuthorization``` error is emitted when the authorization is expired - error ExpiredAuthorization(); - - /// @notice The ```UsedOrCanceledAuthorization``` error is emitted when the authorization nonce is already used or canceled - error UsedOrCanceledAuthorization(); +// ==================================================================== +// | ______ _______ | +// | / _____________ __ __ / ____(_____ ____ _____ ________ | +// | / /_ / ___/ __ `| |/_/ / /_ / / __ \/ __ `/ __ \/ ___/ _ \ | +// | / __/ / / / /_/ _> < / __/ / / / / / /_/ / / / / /__/ __/ | +// | /_/ /_/ \__,_/_/|_| /_/ /_/_/ /_/\__,_/_/ /_/\___/\___/ | +// | | +// ==================================================================== +// ============================= FrxUSD =============================== +// ==================================================================== +// Frax Finance: https://github.com/FraxFinance +// Tested for 18-decimal underlying assets only + +import { SfrxUSD2 } from "src/contracts/fraxtal/sfrxUSD/versioning/SfrxUSD2.sol"; + +contract SfrxUSD is SfrxUSD2 { + constructor(address _bridge, address _remoteToken) SfrxUSD2(_bridge, _remoteToken) {} } diff --git a/src/contracts/fraxtal/sfrxUSD/versioning/SfrxUSD.sol b/src/contracts/fraxtal/sfrxUSD/versioning/SfrxUSD.sol new file mode 100644 index 0000000..7f5f8b9 --- /dev/null +++ b/src/contracts/fraxtal/sfrxUSD/versioning/SfrxUSD.sol @@ -0,0 +1,29 @@ +pragma solidity ^0.8.0; + +import { ERC20PermitPermissionedOptiMintable } from "src/contracts/fraxtal/shared/ERC20PermitPermissionedOptiMintable.sol"; + +contract SfrxUSD is ERC20PermitPermissionedOptiMintable { + function version() public pure virtual override returns (string memory) { + return "1.0.0"; + } + + /// @param _creator_address The contract creator + /// @param _timelock_address The timelock + /// @param _bridge Address of the L2 standard bridge + /// @param _remoteToken Address of the corresponding L1 token + constructor( + address _creator_address, + address _timelock_address, + address _bridge, + address _remoteToken + ) + ERC20PermitPermissionedOptiMintable( + _creator_address, + _timelock_address, + _bridge, + _remoteToken, + "Staked Frax USD", + "sfrxUSD" + ) + {} +} diff --git a/src/contracts/fraxtal/sfrxUSD/versioning/SfrxUSD2.sol b/src/contracts/fraxtal/sfrxUSD/versioning/SfrxUSD2.sol new file mode 100644 index 0000000..9ac63de --- /dev/null +++ b/src/contracts/fraxtal/sfrxUSD/versioning/SfrxUSD2.sol @@ -0,0 +1,56 @@ +//SPDX-License-Identifier: Unlicense +pragma solidity ^0.8.0; + +import { ERC20Permit, ERC20, EIP712, Nonces } from "@openzeppelin/contracts-5.2.0/token/ERC20/extensions/ERC20Permit.sol"; +import { SfrxUSD } from "src/contracts/fraxtal/sfrxUSD/versioning/SfrxUSD.sol"; +import { PermitModule } from "src/contracts/shared/core/modules/PermitModule.sol"; +import { EIP3009Module, SignatureModule } from "src/contracts/shared/core/modules/EIP3009Module.sol"; + +/// @title FrxUSD v3.0.0 +/// @notice Frax USD Stablecoin by Frax Finance +/// @dev v3.0.0 adds ERC-1271 and EIP-3009 support +contract SfrxUSD2 is SfrxUSD, EIP3009Module, PermitModule { + function version() public pure override returns (string memory) { + return "2.0.0"; + } + + constructor(address _bridge, address _remoteToken) SfrxUSD(address(1), address(1), _bridge, _remoteToken) {} + + /*////////////////////////////////////////////////////////////// + Module Overrides + //////////////////////////////////////////////////////////////*/ + + function __transfer(address from, address to, uint256 amount) internal override returns (bool) { + ERC20._transfer(from, to, amount); + return true; + } + + function __hashTypedDataV4(bytes32 structHash) internal view override(SignatureModule) returns (bytes32) { + return EIP712._hashTypedDataV4(structHash); + } + + function __approve(address owner, address spender, uint256 amount) internal override(PermitModule) { + ERC20._approve(owner, spender, amount); + } + + function __useNonce(address owner) internal override(PermitModule) returns (uint256) { + return Nonces._useNonce(owner); + } + + function __domainSeparatorV4() internal view override(PermitModule) returns (bytes32) { + return EIP712._domainSeparatorV4(); + } + + function permit( + address owner, + address spender, + uint256 value, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) public override(ERC20Permit, PermitModule) { + return + PermitModule.permit({ owner: owner, spender: spender, value: value, deadline: deadline, v: v, r: r, s: s }); + } +} diff --git a/src/contracts/ethereum/shared/modules/EIP3009Module.sol b/src/contracts/shared/core/modules/EIP3009Module.sol similarity index 100% rename from src/contracts/ethereum/shared/modules/EIP3009Module.sol rename to src/contracts/shared/core/modules/EIP3009Module.sol diff --git a/src/contracts/ethereum/shared/modules/PermitModule.sol b/src/contracts/shared/core/modules/PermitModule.sol similarity index 100% rename from src/contracts/ethereum/shared/modules/PermitModule.sol rename to src/contracts/shared/core/modules/PermitModule.sol diff --git a/src/contracts/ethereum/shared/modules/SignatureModule.sol b/src/contracts/shared/core/modules/SignatureModule.sol similarity index 96% rename from src/contracts/ethereum/shared/modules/SignatureModule.sol rename to src/contracts/shared/core/modules/SignatureModule.sol index c00a923..0a03e75 100644 --- a/src/contracts/ethereum/shared/modules/SignatureModule.sol +++ b/src/contracts/shared/core/modules/SignatureModule.sol @@ -17,7 +17,7 @@ abstract contract SignatureModule { signer: signer, hash: __hashTypedDataV4({ structHash: structHash }), signature: signature - }) || signer == address(0) + }) ) revert InvalidSignature(); } diff --git a/src/script/fraxtal/frxUSD/DeployFrxUSD.s.sol b/src/script/fraxtal/frxUSD/DeployFrxUSD.s.sol index da1d715..a98e17d 100644 --- a/src/script/fraxtal/frxUSD/DeployFrxUSD.s.sol +++ b/src/script/fraxtal/frxUSD/DeployFrxUSD.s.sol @@ -42,8 +42,6 @@ contract DeployFrxUSD is BaseScript { function deployFrxUsd() public broadcaster { implementation = address( new FrxUSD( - address(1), - address(1), address(0x4200000000000000000000000000000000000010), address(0xCAcd6fd266aF91b8AeD52aCCc382b4e165586E29) ) diff --git a/src/script/fraxtal/sfrxUSD/DeploySfrxUSD.s.sol b/src/script/fraxtal/sfrxUSD/DeploySfrxUSD.s.sol index cd53854..2546e50 100644 --- a/src/script/fraxtal/sfrxUSD/DeploySfrxUSD.s.sol +++ b/src/script/fraxtal/sfrxUSD/DeploySfrxUSD.s.sol @@ -42,8 +42,6 @@ contract DeploySfrxUSD is BaseScript { function deployFrxUsd() public broadcaster { implementation = address( new SfrxUSD( - address(1), - address(1), address(0x4200000000000000000000000000000000000010), address(0xcf62F905562626CfcDD2261162a51fd02Fc9c5b6) ) diff --git a/src/test/FrxUSD/Fraxtal/CompilanceTests.t.sol b/src/test/FrxUSD/Fraxtal/CompilanceTests.t.sol index edb83b6..eca21e6 100644 --- a/src/test/FrxUSD/Fraxtal/CompilanceTests.t.sol +++ b/src/test/FrxUSD/Fraxtal/CompilanceTests.t.sol @@ -30,8 +30,6 @@ contract FrxUSD_Fraxtal_Compliance is FraxTest { // implV2 = FrxUSD(deployFrxUsdImplementationFraxtal()); // implV2 = FrxUSD(0x00000000cd6f03dd0A6389C40c263838636c2C01); implV2 = new FrxUSD( - address(1), - address(1), address(0x4200000000000000000000000000000000000010), address(0xCAcd6fd266aF91b8AeD52aCCc382b4e165586E29) ); diff --git a/src/test/FrxUSD/Fraxtal/SignatureTests.t.sol b/src/test/FrxUSD/Fraxtal/SignatureTests.t.sol index 45d501b..8a404fb 100644 --- a/src/test/FrxUSD/Fraxtal/SignatureTests.t.sol +++ b/src/test/FrxUSD/Fraxtal/SignatureTests.t.sol @@ -4,6 +4,8 @@ import "frax-std/FraxTest.sol"; import "src/script/fraxtal/frxUSD/DeployFrxUSD.s.sol"; import { SigUtils } from "src/test/utils/SigUtils.sol"; +import { EIP3009Module } from "src/contracts/shared/core/modules/EIP3009Module.sol"; +import { SignatureModule } from "src/contracts/shared/core/modules/SignatureModule.sol"; contract TestFrxUSDSignatures is FraxTest { FrxUSD public frxUsd; @@ -188,7 +190,7 @@ contract TestFrxUSDSignatures is FraxTest { (v, r, s) = vm.sign(alPrivateKey, sigUtils.getTransferWithAuthorizationTypedDataHash(authorization)); // try to transfer with authorization should fail - vm.expectRevert(FrxUSD.UsedOrCanceledAuthorization.selector); + vm.expectRevert(EIP3009Module.UsedOrCanceledAuthorization.selector); vm.prank(bob); frxUsd.transferWithAuthorization({ from: al, @@ -218,7 +220,7 @@ contract TestFrxUSDSignatures is FraxTest { // al cancels the authorization vm.prank(al); - vm.expectRevert(FrxUSD.UsedOrCanceledAuthorization.selector); + vm.expectRevert(EIP3009Module.UsedOrCanceledAuthorization.selector); frxUsd.cancelAuthorization({ authorizer: al, nonce: nonce, v: v, r: r, s: s }); } @@ -240,7 +242,7 @@ contract TestFrxUSDSignatures is FraxTest { ); // Try to transfer with authorization should fail - vm.expectRevert(FrxUSD.UsedOrCanceledAuthorization.selector); + vm.expectRevert(EIP3009Module.UsedOrCanceledAuthorization.selector); vm.prank(bob); frxUsd.transferWithAuthorization({ from: al, @@ -273,7 +275,7 @@ contract TestFrxUSDSignatures is FraxTest { ); // Try to receive with authorization should fail - vm.expectRevert(FrxUSD.UsedOrCanceledAuthorization.selector); + vm.expectRevert(EIP3009Module.UsedOrCanceledAuthorization.selector); vm.prank(bob); frxUsd.receiveWithAuthorization({ from: al, @@ -290,7 +292,7 @@ contract TestFrxUSDSignatures is FraxTest { function test_TransferWithAuthorization_InvalidAuthorization_reverts() external { // Try to transfer with authorization should fail - vm.expectRevert(FrxUSD.InvalidAuthorization.selector); + vm.expectRevert(EIP3009Module.InvalidAuthorization.selector); vm.prank(bob); frxUsd.transferWithAuthorization({ from: al, @@ -307,7 +309,7 @@ contract TestFrxUSDSignatures is FraxTest { function test_ReceiveWithAuthorization_InvalidAuthorization_reverts() external { // Try to transfer with authorization should fail - vm.expectRevert(FrxUSD.InvalidAuthorization.selector); + vm.expectRevert(EIP3009Module.InvalidAuthorization.selector); vm.prank(bob); frxUsd.receiveWithAuthorization({ from: al, @@ -324,7 +326,7 @@ contract TestFrxUSDSignatures is FraxTest { function test_TransferWithAuthorization_ExpiredAuthorization_reverts() external { // Try to transfer with authorization should fail - vm.expectRevert(FrxUSD.ExpiredAuthorization.selector); + vm.expectRevert(EIP3009Module.ExpiredAuthorization.selector); vm.prank(bob); frxUsd.transferWithAuthorization({ from: al, @@ -341,7 +343,7 @@ contract TestFrxUSDSignatures is FraxTest { function test_ReceiveWithAuthorization_ExpiredAuthorization_reverts() external { // Try to transfer with authorization should fail - vm.expectRevert(FrxUSD.ExpiredAuthorization.selector); + vm.expectRevert(EIP3009Module.ExpiredAuthorization.selector); vm.prank(bob); frxUsd.receiveWithAuthorization({ from: al, @@ -357,7 +359,7 @@ contract TestFrxUSDSignatures is FraxTest { } function test_ReceiveWithAuthorization_InvalidPayee_reverts() external { - vm.expectRevert(abi.encodeWithSelector(FrxUSD.InvalidPayee.selector, bob, owner)); + vm.expectRevert(abi.encodeWithSelector(EIP3009Module.InvalidPayee.selector, bob, owner)); vm.prank(bob); frxUsd.receiveWithAuthorization({ from: al, @@ -389,7 +391,7 @@ contract TestFrxUSDSignatures is FraxTest { // bob tries to transfer from al to owner, which is not conformed to the signature vm.prank(bob); - vm.expectRevert(FrxUSD.InvalidSignature.selector); + vm.expectRevert(SignatureModule.InvalidSignature.selector); frxUsd.transferWithAuthorization({ from: al, to: owner, // note: this is causing the revert @@ -420,7 +422,7 @@ contract TestFrxUSDSignatures is FraxTest { // bob tries to receive the tokens, which is not conformed to the signature vm.prank(bob); - vm.expectRevert(FrxUSD.InvalidSignature.selector); + vm.expectRevert(SignatureModule.InvalidSignature.selector); frxUsd.receiveWithAuthorization({ from: al, to: bob, // note: this is causing the revert diff --git a/src/test/FrxUSD/Mainnet/SignatureTests.t.sol b/src/test/FrxUSD/Mainnet/SignatureTests.t.sol index ee5fdc7..0efe8bf 100644 --- a/src/test/FrxUSD/Mainnet/SignatureTests.t.sol +++ b/src/test/FrxUSD/Mainnet/SignatureTests.t.sol @@ -5,8 +5,8 @@ import "src/script/ethereum/frxUSD/DeployFrxUSD.s.sol"; import { console } from "forge-std/console.sol"; import { SigUtils } from "src/test/utils/SigUtils.sol"; -import { EIP3009Module } from "src/contracts/ethereum/shared/modules/EIP3009Module.sol"; -import { SignatureModule } from "src/contracts/ethereum/shared/modules/SignatureModule.sol"; +import { EIP3009Module } from "src/contracts/shared/core/modules/EIP3009Module.sol"; +import { SignatureModule } from "src/contracts/shared/core/modules/SignatureModule.sol"; contract TestFrxUSDSignatures is FraxTest { FrxUSD public frxUsd; diff --git a/src/test/SfrxUSD/Fraxtal/SignatureTests.t.sol b/src/test/SfrxUSD/Fraxtal/SignatureTests.t.sol index 0466741..eaeda26 100644 --- a/src/test/SfrxUSD/Fraxtal/SignatureTests.t.sol +++ b/src/test/SfrxUSD/Fraxtal/SignatureTests.t.sol @@ -4,6 +4,8 @@ import "frax-std/FraxTest.sol"; import "src/script/fraxtal/sfrxUSD/DeploySfrxUSD.s.sol"; import { SigUtils } from "src/test/utils/SigUtils.sol"; +import { EIP3009Module } from "src/contracts/shared/core/modules/EIP3009Module.sol"; +import { SignatureModule } from "src/contracts/shared/core/modules/SignatureModule.sol"; contract TestSfrxUSDSignatures is FraxTest { SfrxUSD public sfrxUsd; @@ -188,7 +190,7 @@ contract TestSfrxUSDSignatures is FraxTest { (v, r, s) = vm.sign(alPrivateKey, sigUtils.getTransferWithAuthorizationTypedDataHash(authorization)); // try to transfer with authorization should fail - vm.expectRevert(SfrxUSD.UsedOrCanceledAuthorization.selector); + vm.expectRevert(EIP3009Module.UsedOrCanceledAuthorization.selector); vm.prank(bob); sfrxUsd.transferWithAuthorization({ from: al, @@ -218,7 +220,7 @@ contract TestSfrxUSDSignatures is FraxTest { // al cancels the authorization vm.prank(al); - vm.expectRevert(SfrxUSD.UsedOrCanceledAuthorization.selector); + vm.expectRevert(EIP3009Module.UsedOrCanceledAuthorization.selector); sfrxUsd.cancelAuthorization({ authorizer: al, nonce: nonce, v: v, r: r, s: s }); } @@ -240,7 +242,7 @@ contract TestSfrxUSDSignatures is FraxTest { ); // Try to transfer with authorization should fail - vm.expectRevert(SfrxUSD.UsedOrCanceledAuthorization.selector); + vm.expectRevert(EIP3009Module.UsedOrCanceledAuthorization.selector); vm.prank(bob); sfrxUsd.transferWithAuthorization({ from: al, @@ -273,7 +275,7 @@ contract TestSfrxUSDSignatures is FraxTest { ); // Try to receive with authorization should fail - vm.expectRevert(SfrxUSD.UsedOrCanceledAuthorization.selector); + vm.expectRevert(EIP3009Module.UsedOrCanceledAuthorization.selector); vm.prank(bob); sfrxUsd.receiveWithAuthorization({ from: al, @@ -290,7 +292,7 @@ contract TestSfrxUSDSignatures is FraxTest { function test_TransferWithAuthorization_InvalidAuthorization_reverts() external { // Try to transfer with authorization should fail - vm.expectRevert(SfrxUSD.InvalidAuthorization.selector); + vm.expectRevert(EIP3009Module.InvalidAuthorization.selector); vm.prank(bob); sfrxUsd.transferWithAuthorization({ from: al, @@ -307,7 +309,7 @@ contract TestSfrxUSDSignatures is FraxTest { function test_ReceiveWithAuthorization_InvalidAuthorization_reverts() external { // Try to transfer with authorization should fail - vm.expectRevert(SfrxUSD.InvalidAuthorization.selector); + vm.expectRevert(EIP3009Module.InvalidAuthorization.selector); vm.prank(bob); sfrxUsd.receiveWithAuthorization({ from: al, @@ -324,7 +326,7 @@ contract TestSfrxUSDSignatures is FraxTest { function test_TransferWithAuthorization_ExpiredAuthorization_reverts() external { // Try to transfer with authorization should fail - vm.expectRevert(SfrxUSD.ExpiredAuthorization.selector); + vm.expectRevert(EIP3009Module.ExpiredAuthorization.selector); vm.prank(bob); sfrxUsd.transferWithAuthorization({ from: al, @@ -341,7 +343,7 @@ contract TestSfrxUSDSignatures is FraxTest { function test_ReceiveWithAuthorization_ExpiredAuthorization_reverts() external { // Try to transfer with authorization should fail - vm.expectRevert(SfrxUSD.ExpiredAuthorization.selector); + vm.expectRevert(EIP3009Module.ExpiredAuthorization.selector); vm.prank(bob); sfrxUsd.receiveWithAuthorization({ from: al, @@ -357,7 +359,7 @@ contract TestSfrxUSDSignatures is FraxTest { } function test_ReceiveWithAuthorization_InvalidPayee_reverts() external { - vm.expectRevert(abi.encodeWithSelector(SfrxUSD.InvalidPayee.selector, bob, owner)); + vm.expectRevert(abi.encodeWithSelector(EIP3009Module.InvalidPayee.selector, bob, owner)); vm.prank(bob); sfrxUsd.receiveWithAuthorization({ from: al, @@ -389,7 +391,7 @@ contract TestSfrxUSDSignatures is FraxTest { // bob tries to transfer from al to owner, which is not conformed to the signature vm.prank(bob); - vm.expectRevert(SfrxUSD.InvalidSignature.selector); + vm.expectRevert(SignatureModule.InvalidSignature.selector); sfrxUsd.transferWithAuthorization({ from: al, to: owner, // note: this is causing the revert @@ -420,7 +422,7 @@ contract TestSfrxUSDSignatures is FraxTest { // bob tries to receive the tokens, which is not conformed to the signature vm.prank(bob); - vm.expectRevert(SfrxUSD.InvalidSignature.selector); + vm.expectRevert(SignatureModule.InvalidSignature.selector); sfrxUsd.receiveWithAuthorization({ from: al, to: bob, // note: this is causing the revert diff --git a/src/test/SfrxUSD/Mainnet/SignatureTests.t.sol b/src/test/SfrxUSD/Mainnet/SignatureTests.t.sol index 852407f..b37c045 100644 --- a/src/test/SfrxUSD/Mainnet/SignatureTests.t.sol +++ b/src/test/SfrxUSD/Mainnet/SignatureTests.t.sol @@ -5,8 +5,8 @@ import "src/script/ethereum/sfrxUSD/DeploySfrxUSD.s.sol"; import { console } from "forge-std/console.sol"; import { SigUtils } from "src/test/utils/SigUtils.sol"; -import { EIP3009Module } from "src/contracts/ethereum/shared/modules/EIP3009Module.sol"; -import { SignatureModule } from "src/contracts/ethereum/shared/modules/SignatureModule.sol"; +import { EIP3009Module } from "src/contracts/shared/core/modules/EIP3009Module.sol"; +import { SignatureModule } from "src/contracts/shared/core/modules/SignatureModule.sol"; contract TestSfrxUSDSignatures is FraxTest { SfrxUSD public sfrxUsd; From 9670b60ecf8fc7b759e65bd2b75b6a935d3c20fb Mon Sep 17 00:00:00 2001 From: Carter Carlson Date: Tue, 9 Dec 2025 09:21:23 -0800 Subject: [PATCH 10/13] Update src/script/ethereum/frxUSD/DeployFrxUSD.s.sol Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/script/ethereum/frxUSD/DeployFrxUSD.s.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/script/ethereum/frxUSD/DeployFrxUSD.s.sol b/src/script/ethereum/frxUSD/DeployFrxUSD.s.sol index 84590e4..d22a303 100644 --- a/src/script/ethereum/frxUSD/DeployFrxUSD.s.sol +++ b/src/script/ethereum/frxUSD/DeployFrxUSD.s.sol @@ -58,7 +58,7 @@ contract DeployFrxUSD is BaseScript { txs.push(SafeTx({ name: "upgrade", to: proxyAdmin, value: 0, data: upgradeData })); string memory root = vm.projectRoot(); - string memory filename = string.concat(root, "/src/script/fraxtal/frxUSD/DeployFrxUSD.json"); + string memory filename = string.concat(root, "/src/script/ethereum/frxUSD/DeployFrxUSD.json"); txHelper.writeTxs(txs, filename); console.log("Deploy msig tx from %s", owner); From fc42d4d75a8694c66cd7f4cb208885a68bc3c3bc Mon Sep 17 00:00:00 2001 From: Carter Carlson Date: Tue, 9 Dec 2025 09:21:50 -0800 Subject: [PATCH 11/13] Update src/contracts/fraxtal/frxUSD/versioning/FrxUSD2.sol Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/contracts/fraxtal/frxUSD/versioning/FrxUSD2.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/contracts/fraxtal/frxUSD/versioning/FrxUSD2.sol b/src/contracts/fraxtal/frxUSD/versioning/FrxUSD2.sol index 80671d6..b0c61e5 100644 --- a/src/contracts/fraxtal/frxUSD/versioning/FrxUSD2.sol +++ b/src/contracts/fraxtal/frxUSD/versioning/FrxUSD2.sol @@ -9,7 +9,7 @@ contract FrxUSD2 is ERC20PermitPermissionedOptiMintable { /// @notice Whether or not the contract is paused bool public isPaused; - /// @notice Mapping indiciating which addresses can freeze accounts + /// @notice Mapping indicating which addresses can freeze accounts mapping(address => bool) public isFreezer; uint256[47] private __gap; From bc02b74dec55abcf60f648f3f849588d40df6609 Mon Sep 17 00:00:00 2001 From: Carter Carlson Date: Tue, 9 Dec 2025 09:23:18 -0800 Subject: [PATCH 12/13] Update src/contracts/fraxtal/frxUSD/versioning/FrxUSD2.sol Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/contracts/fraxtal/frxUSD/versioning/FrxUSD2.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/contracts/fraxtal/frxUSD/versioning/FrxUSD2.sol b/src/contracts/fraxtal/frxUSD/versioning/FrxUSD2.sol index b0c61e5..ad5f021 100644 --- a/src/contracts/fraxtal/frxUSD/versioning/FrxUSD2.sol +++ b/src/contracts/fraxtal/frxUSD/versioning/FrxUSD2.sol @@ -78,7 +78,7 @@ contract FrxUSD2 is ERC20PermitPermissionedOptiMintable { } /// @notice External admin gated function to freeze a given account - /// @param _owner The account to be + /// @param _owner The account to be frozen function freeze(address _owner) external { if (!isFreezer[msg.sender] && msg.sender != owner) revert NotFreezer(); _freeze(_owner); From b917a9819c5f3a9d28fb60c921e37afe2649d933 Mon Sep 17 00:00:00 2001 From: Carter Carlson Date: Tue, 9 Dec 2025 09:23:48 -0800 Subject: [PATCH 13/13] Update src/contracts/ethereum/frxUSD/versioning/FrxUSD2.sol Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/contracts/ethereum/frxUSD/versioning/FrxUSD2.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/contracts/ethereum/frxUSD/versioning/FrxUSD2.sol b/src/contracts/ethereum/frxUSD/versioning/FrxUSD2.sol index 7e13c30..a2536fa 100644 --- a/src/contracts/ethereum/frxUSD/versioning/FrxUSD2.sol +++ b/src/contracts/ethereum/frxUSD/versioning/FrxUSD2.sol @@ -138,7 +138,7 @@ contract FrxUSD2 is ERC20Permit, ERC20Burnable, Ownable2Step { } /// @notice External admin gated function to freeze a given account - /// @param _owner The account to be + /// @param _owner The account to be frozen function freeze(address _owner) external { if (!isFreezer[msg.sender] && msg.sender != owner()) revert NotFreezer(); _freeze(_owner);