Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions foundry.lock
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,11 @@
"name": "v5.0.1",
"rev": "01ef448981be9d20ca85f2faf6ebdf591ce409f3"
}
},
"lib/openzeppelin-contracts-upgradeable": {
"tag": {
"name": "v5.0.1",
"rev": "fbdb824a735891908d5588b28e0da5852d7ed7ba"
}
}
}
1 change: 1 addition & 0 deletions lib/openzeppelin-contracts-upgradeable
4 changes: 3 additions & 1 deletion remappings.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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/
sales/=src/sales/
@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/
echo/=src/echo/
21 changes: 21 additions & 0 deletions src/echo/Versioned.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
40 changes: 31 additions & 9 deletions src/sales/SettlementSale.sol
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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();
}
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions src/sales/SettlementSaleFactory.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
2 changes: 1 addition & 1 deletion test/BidSubmission.t.sol
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion test/Cancellation.t.sol
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion test/EdgeCases.t.sol
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion test/FullLifecycle.t.sol
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
46 changes: 28 additions & 18 deletions test/General.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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));
Expand All @@ -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({
Expand All @@ -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),
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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 {
Expand Down
Loading