From 1514ba981a9c164a489e7767ee78012bf2badc22 Mon Sep 17 00:00:00 2001 From: David Huber Date: Thu, 22 Jan 2026 18:26:04 +0200 Subject: [PATCH 1/3] add upgradeable lib --- .gitmodules | 3 +++ foundry.lock | 6 ++++++ lib/openzeppelin-contracts-upgradeable | 1 + 3 files changed, 10 insertions(+) create mode 160000 lib/openzeppelin-contracts-upgradeable diff --git a/.gitmodules b/.gitmodules index 690924b..9296efd 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,6 @@ [submodule "lib/openzeppelin-contracts"] path = lib/openzeppelin-contracts url = https://github.com/OpenZeppelin/openzeppelin-contracts +[submodule "lib/openzeppelin-contracts-upgradeable"] + path = lib/openzeppelin-contracts-upgradeable + url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable diff --git a/foundry.lock b/foundry.lock index c1cf78a..6a173d4 100644 --- a/foundry.lock +++ b/foundry.lock @@ -10,5 +10,11 @@ "name": "v5.0.1", "rev": "01ef448981be9d20ca85f2faf6ebdf591ce409f3" } + }, + "lib/openzeppelin-contracts-upgradeable": { + "tag": { + "name": "v5.0.1", + "rev": "fbdb824a735891908d5588b28e0da5852d7ed7ba" + } } } \ No newline at end of file diff --git a/lib/openzeppelin-contracts-upgradeable b/lib/openzeppelin-contracts-upgradeable new file mode 160000 index 0000000..fbdb824 --- /dev/null +++ b/lib/openzeppelin-contracts-upgradeable @@ -0,0 +1 @@ +Subproject commit fbdb824a735891908d5588b28e0da5852d7ed7ba From 1b896b42b240b71eb0ee3d34c6ceef85f002adc7 Mon Sep 17 00:00:00 2001 From: David Huber Date: Thu, 22 Jan 2026 18:45:48 +0200 Subject: [PATCH 2/3] add new code --- .github/workflows/test.yml | 2 +- remappings.txt | 4 +- src/echo/Versioned.sol | 21 +++++++++ src/sales/SettlementSale.sol | 40 ++++++++++++---- src/sales/SettlementSaleFactory.sol | 39 ++++++++++++++++ test/BidSubmission.t.sol | 2 +- test/Cancellation.t.sol | 2 +- test/EdgeCases.t.sol | 2 +- test/FullLifecycle.t.sol | 2 +- test/General.t.sol | 46 +++++++++++-------- test/Refund.t.sol | 2 +- test/Settlement.t.sol | 2 +- ...eTest.sol => SettlementSaleBaseTest.t.sol} | 20 ++++++-- test/Withdraw.t.sol | 2 +- 14 files changed, 147 insertions(+), 39 deletions(-) create mode 100644 src/echo/Versioned.sol create mode 100644 src/sales/SettlementSaleFactory.sol rename test/{SettlementSaleBaseTest.sol => SettlementSaleBaseTest.t.sol} (96%) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b79c8d4..247bbf6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,7 +32,7 @@ jobs: run: forge fmt --check - name: Run Forge build - run: forge build --sizes + run: forge build --sizes --skip test - name: Run Forge tests run: forge test -vvv diff --git a/remappings.txt b/remappings.txt index a5eeda7..5dc6287 100644 --- a/remappings.txt +++ b/remappings.txt @@ -4,4 +4,6 @@ echo-test/=lib/echo-test/ erc4626-tests/=lib/openzeppelin-contracts/lib/erc4626-tests/ forge-std/=lib/forge-std/src/ openzeppelin-contracts/=lib/openzeppelin-contracts/ -sales/=src/sales/ \ No newline at end of file +sales/=src/sales/ +@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/ +echo/=src/echo/ \ No newline at end of file diff --git a/src/echo/Versioned.sol b/src/echo/Versioned.sol new file mode 100644 index 0000000..46518d1 --- /dev/null +++ b/src/echo/Versioned.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.23; + +/// @title Versioned +/// @notice A base contract for version control +/// @dev This contract MUST only contain immutable state, since we will also use it for upgradeable contracts. +contract Versioned { + uint32 private immutable _major; + uint32 private immutable _minor; + uint32 private immutable _patch; + + constructor(uint32 major, uint32 minor, uint32 patch) { + _major = major; + _minor = minor; + _patch = patch; + } + + function version() external view returns (uint32, uint32, uint32) { + return (_major, _minor, _patch); + } +} diff --git a/src/sales/SettlementSale.sol b/src/sales/SettlementSale.sol index 9f6769e..5f23e4b 100644 --- a/src/sales/SettlementSale.sol +++ b/src/sales/SettlementSale.sol @@ -1,13 +1,17 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.23; -import {AccessControlEnumerable} from "@openzeppelin/contracts/access/extensions/AccessControlEnumerable.sol"; +import { + AccessControlEnumerableUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/extensions/AccessControlEnumerableUpgradeable.sol"; import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import {IERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; import {IERC20, SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Versioned} from "echo/Versioned.sol"; + import {PurchasePermitV3, PurchasePermitV3Lib} from "sales/permits/PurchasePermitV3.sol"; import {ICommitmentDataReader} from "sales/interfaces/ICommitmentDataReader.sol"; @@ -123,12 +127,13 @@ import {TokenAmount, WalletTokenAmount} from "sales/interfaces/types.sol"; /// /// @custom:security-contact security@echo.xyz contract SettlementSale is - AccessControlEnumerable, + AccessControlEnumerableUpgradeable, ICommitmentDataReader, ITotalCommitmentsReader, IOffchainSettlement, IEntityAllocationDataReader, - ITotalAllocationsReader + ITotalAllocationsReader, + Versioned(1, 0, 0) { using SafeERC20 for IERC20; using EnumerableSet for EnumerableSet.AddressSet; @@ -280,7 +285,8 @@ contract SettlementSale is } /// @notice The Sonar UUID of the sale. - bytes16 public immutable SALE_UUID; + /// @dev Set during initialization. + bytes16 public saleUUID; /// @notice The payment tokens used to fund the sale. /// @dev Only set on construction and cannot be modified after. @@ -407,7 +413,18 @@ contract SettlementSale is uint256 expectedPaymentTokenDecimals; } - constructor(Init memory init) { + /// @notice Disables initializers on the implementation contract. + /// @dev This prevents the implementation from being initialized directly when used with proxies. + constructor() { + _disableInitializers(); + } + + /// @notice Initializes the sale contract. + /// @dev This replaces the constructor for use with the clone/proxy pattern. + /// @param init The initialization parameters. + function initialize(Init memory init) external initializer { + __AccessControlEnumerable_init(); + if (init.admin == address(0)) { revert ZeroAddress(); } @@ -421,7 +438,7 @@ contract SettlementSale is revert ZeroMaxWalletsPerEntity(); } - SALE_UUID = init.saleUUID; + saleUUID = init.saleUUID; proceedsReceiver = init.proceedsReceiver; claimRefundEnabled = init.claimRefundEnabled; maxWalletsPerEntity = init.maxWalletsPerEntity; @@ -691,8 +708,8 @@ contract SettlementSale is /// @dev This ensures that the permit was issued for the right sale (preventing the reuse of the same permit across sales), /// is not expired, and is signed by the purchase permit signer. function _validatePurchasePermit(PurchasePermitV3 memory permit, bytes calldata signature) internal view { - if (permit.saleUUID != SALE_UUID) { - revert InvalidSaleUUID(permit.saleUUID, SALE_UUID); + if (permit.saleUUID != saleUUID) { + revert InvalidSaleUUID(permit.saleUUID, saleUUID); } if (permit.expiresAt <= block.timestamp) { @@ -1216,7 +1233,12 @@ contract SettlementSale is } /// @notice Checks if the contract supports an interface. - function supportsInterface(bytes4 interfaceId) public view override(AccessControlEnumerable) returns (bool) { + function supportsInterface(bytes4 interfaceId) + public + view + override(AccessControlEnumerableUpgradeable) + returns (bool) + { return interfaceId == type(ICommitmentDataReader).interfaceId || interfaceId == type(ITotalCommitmentsReader).interfaceId || interfaceId == type(IEntityAllocationDataReader).interfaceId diff --git a/src/sales/SettlementSaleFactory.sol b/src/sales/SettlementSaleFactory.sol new file mode 100644 index 0000000..3f32c7c --- /dev/null +++ b/src/sales/SettlementSaleFactory.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.23; + +import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; +import {Versioned} from "echo/Versioned.sol"; + +import {SettlementSale} from "sales/SettlementSale.sol"; + +/// @title SettlementSaleFactory +/// @notice A permissionless factory for creating SettlementSale clones using the minimal proxy pattern (EIP-1167). +/// @dev Anyone can create a new sale by calling `createSale`. The caller provides all initialization parameters. +/// @custom:security-contact security@echo.xyz +contract SettlementSaleFactory is Versioned(1, 0, 0) { + /// @notice Emitted when a new sale is created. + /// @param saleUUID The unique identifier for the sale on the sonar platform. + /// @param saleAddress The address of the newly created sale contract. + /// @param creator The address that created the sale. + event SaleCreated(bytes16 indexed saleUUID, address indexed saleAddress, address indexed creator); + + /// @notice The implementation contract that will be cloned. + address public immutable implementation; + + /// @notice Creates the factory with an initial implementation. + constructor() { + implementation = address(new SettlementSale()); + } + + /// @notice Creates a new SettlementSale clone. + /// @dev This function is permissionless - anyone can create a sale. + /// The caller provides all initialization parameters including admin, which controls the sale. + /// @param init The initialization parameters for the new sale. + /// @return sale The newly created sale contract. + function createSale(SettlementSale.Init memory init) external returns (SettlementSale sale) { + sale = SettlementSale(Clones.clone(implementation)); + sale.initialize(init); + + emit SaleCreated(init.saleUUID, address(sale), msg.sender); + } +} diff --git a/test/BidSubmission.t.sol b/test/BidSubmission.t.sol index 04ea06e..06416cf 100644 --- a/test/BidSubmission.t.sol +++ b/test/BidSubmission.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.23; -import "./SettlementSaleBaseTest.sol"; +import "./SettlementSaleBaseTest.t.sol"; contract SettlementSaleBidTestBase is SettlementSaleBaseTest { function setUp() public override { diff --git a/test/Cancellation.t.sol b/test/Cancellation.t.sol index 63ca856..8944296 100644 --- a/test/Cancellation.t.sol +++ b/test/Cancellation.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.23; -import "./SettlementSaleBaseTest.sol"; +import "./SettlementSaleBaseTest.t.sol"; contract SettlementSaleCancellationTest is SettlementSaleBaseTest { struct State { diff --git a/test/EdgeCases.t.sol b/test/EdgeCases.t.sol index 73601fd..ca41e34 100644 --- a/test/EdgeCases.t.sol +++ b/test/EdgeCases.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.23; -import "./SettlementSaleBaseTest.sol"; +import "./SettlementSaleBaseTest.t.sol"; contract SettlementSaleEdgeCasesTest is SettlementSaleBaseTest { function testSetAllocation_AfterReopenCommitment_UpdatesCorrectly() public { diff --git a/test/FullLifecycle.t.sol b/test/FullLifecycle.t.sol index 3026326..a03f96d 100644 --- a/test/FullLifecycle.t.sol +++ b/test/FullLifecycle.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.23; -import "./SettlementSaleBaseTest.sol"; +import "./SettlementSaleBaseTest.t.sol"; contract SettlementSaleFullLifecycleFuzzTest is SettlementSaleBaseTest { struct FuzzedCommitmentBids { diff --git a/test/General.t.sol b/test/General.t.sol index b1dd407..33534a6 100644 --- a/test/General.t.sol +++ b/test/General.t.sol @@ -3,11 +3,12 @@ pragma solidity ^0.8.23; import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; import {ITotalCommitmentsReader} from "sales/interfaces/ITotalCommitmentsReader.sol"; import {IEntityAllocationDataReader} from "sales/interfaces/IEntityAllocationDataReader.sol"; -import "./SettlementSaleBaseTest.sol"; +import "./SettlementSaleBaseTest.t.sol"; contract SettlementSaleConstructorTest is BaseTest { ERC20FakeWithDecimals usdc; @@ -58,7 +59,7 @@ contract SettlementSaleConstructorTest is BaseTest { paymentTokens: _defaultPaymentTokens(), expectedPaymentTokenDecimals: 6 }); - TestableSettlementSale sale = new TestableSettlementSale(init); + TestableSettlementSale sale = newTestableSettlementSale(init); assertEq(sale.getRoleMemberCount(sale.SALE_MANAGER_ROLE()), 2); assertEq(sale.getRoleMember(sale.SALE_MANAGER_ROLE(), 0), admin); @@ -97,7 +98,7 @@ contract SettlementSaleConstructorTest is BaseTest { paymentTokens: _defaultPaymentTokens(), expectedPaymentTokenDecimals: 6 }); - TestableSettlementSale sale = new TestableSettlementSale(init); + TestableSettlementSale sale = newTestableSettlementSale(init); assertEq(sale.getRoleMemberCount(sale.SALE_MANAGER_ROLE()), 1); assertEq(sale.getRoleMember(sale.SALE_MANAGER_ROLE(), 0), admin); @@ -133,7 +134,7 @@ contract SettlementSaleConstructorTest is BaseTest { paymentTokens: _defaultPaymentTokens(), expectedPaymentTokenDecimals: 6 }); - TestableSettlementSale sale = new TestableSettlementSale(init); + TestableSettlementSale sale = newTestableSettlementSale(init); assertEq(sale.getRoleMemberCount(sale.SALE_MANAGER_ROLE()), 1); assertEq(sale.getRoleMember(sale.SALE_MANAGER_ROLE(), 0), admin); @@ -170,13 +171,14 @@ contract SettlementSaleConstructorTest is BaseTest { expectedPaymentTokenDecimals: 6 }); + TestableSettlementSale testSale = newUninitializedTestableSettlementSale(); vm.expectRevert( abi.encodeWithSelector(SettlementSale.InvalidPaymentTokenDecimals.selector, address(invalidToken), 18, 6) ); - new TestableSettlementSale(init); + testSale.initialize(init); } - function testConstructor_DuplicateTokens_Reverts() public { + function testInitialize_DuplicateTokens_Reverts() public { IERC20Metadata[] memory duplicateTokens = new IERC20Metadata[](2); duplicateTokens[0] = IERC20Metadata(address(usdc)); duplicateTokens[1] = IERC20Metadata(address(usdc)); @@ -196,11 +198,12 @@ contract SettlementSaleConstructorTest is BaseTest { expectedPaymentTokenDecimals: 6 }); + TestableSettlementSale testSale = newUninitializedTestableSettlementSale(); vm.expectRevert(abi.encodeWithSelector(SettlementSale.DuplicatePaymentToken.selector, usdc)); - new TestableSettlementSale(init); + testSale.initialize(init); } - function testConstructor_NoPaymentTokens_Reverts() public { + function testInitialize_NoPaymentTokens_Reverts() public { IERC20Metadata[] memory emptyTokens = new IERC20Metadata[](0); SettlementSale.Init memory init = SettlementSale.Init({ @@ -218,11 +221,12 @@ contract SettlementSaleConstructorTest is BaseTest { expectedPaymentTokenDecimals: 6 }); + TestableSettlementSale testSale = newUninitializedTestableSettlementSale(); vm.expectRevert(abi.encodeWithSelector(SettlementSale.NoPaymentTokens.selector)); - new TestableSettlementSale(init); + testSale.initialize(init); } - function testConstructor_ZeroAdmin_Reverts() public { + function testInitialize_ZeroAdmin_Reverts() public { SettlementSale.Init memory init = SettlementSale.Init({ saleUUID: TEST_SALE_UUID, admin: address(0), @@ -238,11 +242,12 @@ contract SettlementSaleConstructorTest is BaseTest { expectedPaymentTokenDecimals: 6 }); + TestableSettlementSale testSale = newUninitializedTestableSettlementSale(); vm.expectRevert(abi.encodeWithSelector(SettlementSale.ZeroAddress.selector)); - new TestableSettlementSale(init); + testSale.initialize(init); } - function testConstructor_ZeroPurchasePermitSigner_Reverts() public { + function testInitialize_ZeroPurchasePermitSigner_Reverts() public { SettlementSale.Init memory init = SettlementSale.Init({ saleUUID: TEST_SALE_UUID, admin: admin, @@ -258,11 +263,12 @@ contract SettlementSaleConstructorTest is BaseTest { expectedPaymentTokenDecimals: 6 }); + TestableSettlementSale testSale = newUninitializedTestableSettlementSale(); vm.expectRevert(abi.encodeWithSelector(SettlementSale.ZeroAddress.selector)); - new TestableSettlementSale(init); + testSale.initialize(init); } - function testConstructor_ZeroProceedsReceiver_Reverts() public { + function testInitialize_ZeroProceedsReceiver_Reverts() public { SettlementSale.Init memory init = SettlementSale.Init({ saleUUID: TEST_SALE_UUID, admin: admin, @@ -278,11 +284,12 @@ contract SettlementSaleConstructorTest is BaseTest { expectedPaymentTokenDecimals: 6 }); + TestableSettlementSale testSale = newUninitializedTestableSettlementSale(); vm.expectRevert(abi.encodeWithSelector(SettlementSale.ZeroAddress.selector)); - new TestableSettlementSale(init); + testSale.initialize(init); } - function testConstructor_ZeroMaxWalletsPerEntity_Reverts() public { + function testInitialize_ZeroMaxWalletsPerEntity_Reverts() public { SettlementSale.Init memory init = SettlementSale.Init({ saleUUID: TEST_SALE_UUID, admin: admin, @@ -298,8 +305,9 @@ contract SettlementSaleConstructorTest is BaseTest { expectedPaymentTokenDecimals: 6 }); + TestableSettlementSale testSale = newUninitializedTestableSettlementSale(); vm.expectRevert(abi.encodeWithSelector(SettlementSale.ZeroMaxWalletsPerEntity.selector)); - new TestableSettlementSale(init); + testSale.initialize(init); } } @@ -1235,7 +1243,9 @@ contract SettlementSaleSingleTokenTest is BaseTest { paymentTokens: paymentTokens, expectedPaymentTokenDecimals: 6 }); - sale = new TestableSettlementSale(init); + TestableSettlementSale impl = new TestableSettlementSale(); + sale = TestableSettlementSale(Clones.clone(address(impl))); + sale.initialize(init); } function testPaymentTokens_SingleToken_ReturnsCorrectToken() public view { diff --git a/test/Refund.t.sol b/test/Refund.t.sol index be3e389..b0db809 100644 --- a/test/Refund.t.sol +++ b/test/Refund.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.23; -import "./SettlementSaleBaseTest.sol"; +import "./SettlementSaleBaseTest.t.sol"; contract SettlementSaleRefundsTest is SettlementSaleBaseTest { function setUp() public override { diff --git a/test/Settlement.t.sol b/test/Settlement.t.sol index a405b6a..e11a208 100644 --- a/test/Settlement.t.sol +++ b/test/Settlement.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.23; -import "./SettlementSaleBaseTest.sol"; +import "./SettlementSaleBaseTest.t.sol"; contract SettlementSaleSettlementTest is SettlementSaleBaseTest { struct State { diff --git a/test/SettlementSaleBaseTest.sol b/test/SettlementSaleBaseTest.t.sol similarity index 96% rename from test/SettlementSaleBaseTest.sol rename to test/SettlementSaleBaseTest.t.sol index 6b34f68..9fd6362 100644 --- a/test/SettlementSaleBaseTest.sol +++ b/test/SettlementSaleBaseTest.t.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.23; import {IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; @@ -35,8 +36,6 @@ uint64 constant SALE_MAX_PRICE = 100; contract TestableSettlementSale is SettlementSale { using EnumerableSet for EnumerableSet.AddressSet; - constructor(Init memory init) SettlementSale(init) {} - function allEntities() public view returns (bytes16[] memory) { uint256 num = this.numEntities(); return this.entitiesIn(0, num); @@ -61,6 +60,21 @@ contract TestableSettlementSale is SettlementSale { } } +/// @dev Helper to create a TestableSettlementSale using the clone pattern. +/// This is needed because the base SettlementSale constructor disables initializers. +function newTestableSettlementSale(SettlementSale.Init memory init) returns (TestableSettlementSale) { + TestableSettlementSale clone = newUninitializedTestableSettlementSale(); + clone.initialize(init); + return clone; +} + +/// @dev Helper to create an uninitialized TestableSettlementSale clone. +/// Use this for testing initialization failures. +function newUninitializedTestableSettlementSale() returns (TestableSettlementSale) { + TestableSettlementSale impl = new TestableSettlementSale(); + return TestableSettlementSale(Clones.clone(address(impl))); +} + contract SettlementSaleBaseTest is BaseTest { TestableSettlementSale sale; ERC20FakeWithDecimals usdc; @@ -111,7 +125,7 @@ contract SettlementSaleBaseTest is BaseTest { paymentTokens: paymentTokens, expectedPaymentTokenDecimals: 6 }); - sale = new TestableSettlementSale(init); + sale = newTestableSettlementSale(init); vm.startPrank(admin); sale.grantRole(sale.TOKEN_RECOVERER_ROLE(), recoverer); diff --git a/test/Withdraw.t.sol b/test/Withdraw.t.sol index 6df206a..9788d36 100644 --- a/test/Withdraw.t.sol +++ b/test/Withdraw.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.23; -import "./SettlementSaleBaseTest.sol"; +import "./SettlementSaleBaseTest.t.sol"; contract SettlementSaleWithdrawTest is SettlementSaleBaseTest { function setUp() public override { From eaeb5e7db4aa03559fc70a1579805e48a5327d8b Mon Sep 17 00:00:00 2001 From: David Huber Date: Thu, 22 Jan 2026 18:48:10 +0200 Subject: [PATCH 3/3] add missing test --- test/SettlementSaleFactory.t.sol | 150 +++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 test/SettlementSaleFactory.t.sol diff --git a/test/SettlementSaleFactory.t.sol b/test/SettlementSaleFactory.t.sol new file mode 100644 index 0000000..c51523a --- /dev/null +++ b/test/SettlementSaleFactory.t.sol @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.23; + +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; + +import {BaseTest} from "echo-test/BaseTest.sol"; +import {ERC20FakeWithDecimals} from "echo-test/doubles/ERC20Fake.sol"; + +import {SettlementSaleFactory} from "sales/SettlementSaleFactory.sol"; +import {SettlementSale} from "sales/SettlementSale.sol"; + +contract SettlementSaleFactoryTest is BaseTest { + SettlementSaleFactory factory; + ERC20FakeWithDecimals usdc; + ERC20FakeWithDecimals usdt; + + Account permitSigner = makeAccount("permitSigner"); + address receiver = makeAddr("receiver"); + + bytes16 constant TEST_SALE_UUID = hex"1234567890abcdef1234567890abcdef"; + + function setUp() public virtual { + factory = new SettlementSaleFactory(); + + usdc = new ERC20FakeWithDecimals("USDC", "USDC", 6); + usdt = new ERC20FakeWithDecimals("USDT", "USDT", 6); + } + + function _makePaymentTokens() internal view returns (IERC20Metadata[] memory) { + IERC20Metadata[] memory tokens = new IERC20Metadata[](2); + tokens[0] = usdc; + tokens[1] = usdt; + return tokens; + } + + function _makeDefaultInit() internal view returns (SettlementSale.Init memory) { + address[] memory extraManagers = new address[](1); + extraManagers[0] = manager; + + address[] memory extraPausers = new address[](0); + + return SettlementSale.Init({ + saleUUID: TEST_SALE_UUID, + admin: admin, + extraManagers: extraManagers, + extraPausers: extraPausers, + extraSettler: address(0), + extraRefunder: address(0), + purchasePermitSigner: permitSigner.addr, + proceedsReceiver: receiver, + claimRefundEnabled: true, + maxWalletsPerEntity: 50, + paymentTokens: _makePaymentTokens(), + expectedPaymentTokenDecimals: 6 + }); + } + + function testFactoryVersion() public view { + (uint32 major, uint32 minor, uint32 patch) = factory.version(); + assertEq(major, 1); + assertEq(minor, 0); + assertEq(patch, 0); + } + + function testFactoryHasImplementation() public view { + assertTrue(factory.implementation() != address(0)); + } +} + +contract SettlementSaleFactoryCreateTest is SettlementSaleFactoryTest { + function testCreateSale() public { + SettlementSale.Init memory init = _makeDefaultInit(); + + vm.expectEmit(true, false, true, false); + emit SettlementSaleFactory.SaleCreated(TEST_SALE_UUID, address(0), alice); + + vm.prank(alice); + SettlementSale sale = factory.createSale(init); + + // Verify initialization + assertEq(sale.saleUUID(), TEST_SALE_UUID); + assertEq(sale.proceedsReceiver(), receiver); + assertTrue(sale.claimRefundEnabled()); + assertEq(sale.maxWalletsPerEntity(), 50); + assertEq(uint256(sale.stage()), uint256(SettlementSale.Stage.PreOpen)); + + // Verify roles + assertTrue(sale.hasRole(sale.DEFAULT_ADMIN_ROLE(), admin)); + assertTrue(sale.hasRole(sale.SALE_MANAGER_ROLE(), admin)); + assertTrue(sale.hasRole(sale.SALE_MANAGER_ROLE(), manager)); + assertTrue(sale.hasRole(sale.PURCHASE_PERMIT_SIGNER_ROLE(), permitSigner.addr)); + assertTrue(sale.hasRole(sale.SETTLER_ROLE(), admin)); + assertTrue(sale.hasRole(sale.REFUNDER_ROLE(), admin)); + assertTrue(sale.hasRole(sale.PAUSER_ROLE(), admin)); + } + + function testCreateSaleIsPermissionless() public { + SettlementSale.Init memory init = _makeDefaultInit(); + + address randomUser = makeAddr("randomUser"); + vm.prank(randomUser); + SettlementSale sale = factory.createSale(init); + + assertTrue(address(sale) != address(0)); + assertEq(sale.saleUUID(), TEST_SALE_UUID); + } + + function testCreateMultipleSales() public { + SettlementSale.Init memory init1 = _makeDefaultInit(); + bytes16 uuid2 = hex"abcdef1234567890abcdef1234567890"; + + SettlementSale.Init memory init2 = _makeDefaultInit(); + init2.saleUUID = uuid2; + + SettlementSale sale1 = factory.createSale(init1); + SettlementSale sale2 = factory.createSale(init2); + + assertTrue(address(sale1) != address(sale2)); + assertEq(sale1.saleUUID(), TEST_SALE_UUID); + assertEq(sale2.saleUUID(), uuid2); + } + + function testCreateSaleWithDifferentAdmin() public { + address saleAdmin = makeAddr("saleAdmin"); + + SettlementSale.Init memory init = _makeDefaultInit(); + init.admin = saleAdmin; + + SettlementSale sale = factory.createSale(init); + + assertTrue(sale.hasRole(sale.DEFAULT_ADMIN_ROLE(), saleAdmin)); + assertFalse(sale.hasRole(sale.DEFAULT_ADMIN_ROLE(), admin)); + } + + function testCannotReinitializeSale() public { + SettlementSale.Init memory init = _makeDefaultInit(); + SettlementSale sale = factory.createSale(init); + + vm.expectRevert(); + sale.initialize(init); + } + + function testCannotInitializeImplementation() public { + SettlementSale.Init memory init = _makeDefaultInit(); + SettlementSale impl = SettlementSale(factory.implementation()); + + vm.expectRevert(); + impl.initialize(init); + } +}