diff --git a/src/sales/SettlementSale.sol b/src/sales/SettlementSale.sol index 5f23e4b..975fe86 100644 --- a/src/sales/SettlementSale.sol +++ b/src/sales/SettlementSale.sol @@ -36,10 +36,9 @@ import {TokenAmount, WalletTokenAmount} from "sales/interfaces/types.sol"; /// /// 1. **PreOpen**: Initial state, no commitments allowed /// 2. **Commitment**: Users submit bids with price and amount -/// 3. **Closed**: Commitment stage closes at a specified timestamp -/// 4. **Cancellation**: Participants can cancel their bids and receive refunds -/// 5. **Settlement**: Final allocations computed offchain are recorded onchain -/// 6. **Done**: Refunds processed and proceeds withdrawn +/// 3. **Cancellation**: Participants can cancel their bids and receive refunds +/// 4. **Settlement**: Final allocations computed offchain are recorded onchain +/// 5. **Done**: Refunds processed and proceeds withdrawn /// /// ## PreOpen Stage /// @@ -58,21 +57,14 @@ import {TokenAmount, WalletTokenAmount} from "sales/interfaces/types.sol"; /// Total commitment per entity cannot exceed the maximum amount specified in their purchase permit. /// Bid prices must fall within the minimum and maximum price bounds specified in the purchase permit. /// These price bounds are determined offchain and can change dynamically. -/// The commitment stage closes at a specified timestamp, though admins can manually override if needed. /// -/// Transitions to: Closed -/// -/// ## Closed Stage -/// -/// No new commitments can be submitted in this stage. The sale manager can reopen the commitment stage, proceed directly to settlement, or proceed to the cancellation or settlement stage. -/// Note: Once the sale moves from Closed to Cancellation or Settlement, the commitment stage cannot be reopened. -/// -/// Transitions to: Commitment, Cancellation, Settlement +/// Transitions to: Cancellation, Settlement /// /// ## Cancellation Stage /// /// After the commitment stage closes, preliminary allocations are computed offchain and communicated to participants. -/// During this stage, participants can cancel their bids at any time, which triggers an immediate refund of their committed amount. +/// During this stage, participants can fully cancel their bids at any time, which triggers an immediate refund of their committed amount. +/// If enabled by the sale manager, participants can also partially reduce specific wallet/token commitments without fully cancelling. /// /// Transitions to: Settlement /// @@ -133,7 +125,7 @@ contract SettlementSale is IOffchainSettlement, IEntityAllocationDataReader, ITotalAllocationsReader, - Versioned(1, 0, 0) + Versioned(2, 0, 0) { using SafeERC20 for IERC20; using EnumerableSet for EnumerableSet.AddressSet; @@ -187,16 +179,21 @@ contract SettlementSale is error InvalidPaymentToken(address token); // Settlement errors - error AllocationAlreadySet(bytes16 entityID, uint256 acceptedAmount); + error AllocationAlreadySet(bytes16 entityID); error AllocationExceedsCommitment( bytes16 entityID, address wallet, address token, uint256 allocation, uint256 commitment ); error WalletNotAssociatedWithEntity(address wallet, bytes16 entityID); error UnexpectedTotalAcceptedAmount(uint256 got, uint256 want); + // Cancellation errors + error ReduceCommitmentDisabled(); + error ReductionExceedsCommitment( + bytes16 entityID, address wallet, address token, uint256 amount, uint256 committed + ); + // Refund errors error AlreadyRefunded(bytes16 entityID); - error BidAlreadyCancelled(bytes16 entityID); error ClaimRefundDisabled(); // Withdrawal errors @@ -215,7 +212,8 @@ contract SettlementSale is event EntityInitialized(bytes16 indexed entityID, address indexed wallet); event WalletInitialized(bytes16 indexed entityID, address indexed wallet); event BidPlaced(bytes16 indexed entityID, address indexed wallet, Bid bid); - event BidCancelled(bytes16 indexed entityID, address indexed wallet, uint256 amount); + event CommitmentIncreased(bytes16 indexed entityID, address indexed wallet, address indexed token, uint256 amount); + event CommitmentReduced(bytes16 indexed entityID, address indexed wallet, address indexed token, uint256 amount); event AllocationSet( bytes16 indexed entityID, address indexed wallet, address indexed token, uint256 acceptedAmount ); @@ -224,16 +222,23 @@ contract SettlementSale is event RefundedEntitySkipped(bytes16 indexed entityID); event ProceedsWithdrawn(address indexed receiver, address indexed token, uint256 amount); event ProceedsReceiverChanged(address indexed previousReceiver, address indexed newReceiver); + event ClaimRefundEnabledChanged(bool enabled); event MaxWalletsPerEntityChanged(uint8 previousMax, uint8 newMax); event PausedStateChanged(bool paused); event TokensRecovered(address indexed token, uint256 amount, address indexed to); + event ReduceCommitmentEnabledChanged(bool enabled); /// @notice The state of a wallet in the sale. - /// @dev This tracks the wallet's committed and accepted amounts for each payment token. + /// @dev This tracks the wallet's committed, cancelled, and accepted amounts for each payment token. struct WalletState { - /// The amount of each payment token that has been committed to the commitment stage of the sale, tracked separately by token. + /// The amount of each payment token currently committed to the sale, tracked separately by token. + /// This is decremented when commitments are cancelled during the Cancellation stage. mapping(IERC20 => uint256) committedAmountByToken; + /// The amount of each payment token that has been cancelled during the Cancellation stage. + /// Tracked for audit purposes. The sum of committedAmountByToken and cancelledAmountByToken + /// for a given token equals the original amount committed. + mapping(IERC20 => uint256) cancelledAmountByToken; /// The amount of each payment token that has been accepted from the wallet to purchase tokens after clearing the sale. /// The accepted amounts will be withdrawn as proceeds at the end of the sale. /// The difference per token, i.e. `committedAmountByToken[token] - acceptedAmountByToken[token]`, will be refunded to the wallet. @@ -245,11 +250,10 @@ contract SettlementSale is struct EntityState { /// The timestamp of the last bid placed by the entity. uint32 bidTimestamp; - /// Whether the entity cancelled their bid during the cancellation stage. This is tracked mostly for audit purposes and is not used for any logic. - bool cancelled; - /// Whether the entity was refunded. + /// Whether the entity was fully refunded (either via full cancellation or Done-stage refund processing). bool refunded; /// The active bid of the entity in the commitment stage of the sale, including price, total amount, and lockup preference. + /// The amount is decremented when commitments are cancelled during the Cancellation stage. Bid currentBid; /// The set of wallets that the entity has used to commit funds to the sale. EnumerableSet.AddressSet wallets; @@ -278,7 +282,6 @@ contract SettlementSale is enum Stage { PreOpen, Commitment, - Closed, Cancellation, Settlement, Done @@ -313,27 +316,40 @@ contract SettlementSale is /// @notice The current stage of the sale. Stage public stage; - /// @notice The amount of each payment token that has been committed to the sale, across all entities, tracked separately by token. + /// @notice The amount of each payment token currently committed to the sale, across all entities, tracked separately by token. /// @dev This is the sum of all `_entityStateByID[entityID].walletStates[wallet].committedAmountByToken[token]` over all entities and wallets. - /// Note: It is monotonically increasing during the commitment stage and will not decrease on refunds/cancellations. Those are tracked separately by `totalRefundedAmountByToken`. + /// It increases during the commitment stage and decreases when commitments are cancelled during the cancellation stage. mapping(IERC20 => uint256) internal _totalCommittedAmountByToken; /// @notice Returns the total committed amount for each payment token across all entities. - /// @dev It is monotonically increasing and will not decrease on refunds/cancellations. Those are tracked separately by `totalRefundedAmount()`. + /// @dev Decreases when commitments are cancelled. Cancellations are tracked separately by `totalCancelledAmountByToken()`. function totalCommittedAmountByToken() external view returns (TokenAmount[] memory) { return _toTokenAmounts(_totalCommittedAmountByToken); } /// @notice Returns the total committed amount across all payment tokens. - /// @dev This is computed by summing totalCommittedAmountByToken over all payment tokens. - /// Note: It is monotonically increasing and will not decrease on refunds/cancellations. Those are tracked separately by `totalRefundedAmount()`. + /// @dev Decreases when commitments are cancelled. Cancellations are tracked separately by `totalCancelledAmount()`. function totalCommittedAmount() external view returns (uint256) { return _sumByToken(_totalCommittedAmountByToken); } + /// @notice The amount of each payment token that has been cancelled during the cancellation stage, across all entities. + /// @dev This is mainly used for audit purposes and is not used for any logic. + mapping(IERC20 => uint256) internal _totalCancelledAmountByToken; + + /// @notice Returns the total cancelled amount for each payment token across all entities. + function totalCancelledAmountByToken() external view returns (TokenAmount[] memory) { + return _toTokenAmounts(_totalCancelledAmountByToken); + } + + /// @notice Returns the total cancelled amount across all payment tokens. + function totalCancelledAmount() external view returns (uint256) { + return _sumByToken(_totalCancelledAmountByToken); + } + /// @notice The amount of refunds processed, across all entities, tracked separately by token. /// @dev For each token, this is the sum of all `WalletState.committedAmountByToken[token] - WalletState.acceptedAmountByToken[token]` over all refunded entities. - /// @dev This is mainly used for audit purposes and is not used for any logic. + /// @dev This is mainly used for audit purposes and is not used for any logic. Does not include cancellation refunds (tracked by `_totalCancelledAmountByToken`). mapping(IERC20 => uint256) internal _totalRefundedAmountByToken; /// @notice Returns the total refunded amount for each payment token across all entities. @@ -386,6 +402,11 @@ contract SettlementSale is /// @dev If disabled, only addresses with the REFUNDER_ROLE can process refunds. bool public claimRefundEnabled; + /// @notice Whether reducing commitments is enabled during the `Cancellation` stage. + /// @dev When enabled, entities can reduce their commitment via `reduceCommitment()` without fully cancelling. + /// Full cancellation via `cancelBid()` is always available regardless of this setting. + bool public reduceCommitmentEnabled; + /// @notice The list of all entity IDs that have participated in the sale. bytes16[] internal _entityIDs; @@ -408,6 +429,8 @@ contract SettlementSale is address purchasePermitSigner; address proceedsReceiver; bool claimRefundEnabled; + bool reduceCommitmentEnabled; + bool skipPreOpen; uint8 maxWalletsPerEntity; IERC20Metadata[] paymentTokens; uint256 expectedPaymentTokenDecimals; @@ -441,6 +464,7 @@ contract SettlementSale is saleUUID = init.saleUUID; proceedsReceiver = init.proceedsReceiver; claimRefundEnabled = init.claimRefundEnabled; + reduceCommitmentEnabled = init.reduceCommitmentEnabled; maxWalletsPerEntity = init.maxWalletsPerEntity; if (init.paymentTokens.length == 0) { @@ -490,19 +514,17 @@ contract SettlementSale is for (uint256 i = 0; i < init.extraPausers.length; i++) { _grantRole(PAUSER_ROLE, init.extraPausers[i]); } + + if (init.skipPreOpen) { + _setStage(Stage.Commitment); + } } /// @notice Moves the sale to the `Commitment` stage, allowing participants to submit bids. - /// @dev Can be called from `PreOpen` (first open) or `Closed` (reopen after closing). - function openCommitment() external onlyRole(SALE_MANAGER_ROLE) onlyStages(Stage.PreOpen, Stage.Closed) { + function openCommitment() external onlyRole(SALE_MANAGER_ROLE) onlyStage(Stage.PreOpen) { _setStage(Stage.Commitment); } - /// @notice Moves the sale to the `Closed` stage, preventing any new bids from being submitted. - function closeCommitment() external onlyRole(SALE_MANAGER_ROLE) onlyStage(Stage.Commitment) { - _setStage(Stage.Closed); - } - /// @notice Tracks entities that placed bids in the sale. /// @dev Ensures that each address can only be tied to a single entityID. An entity can use multiple addresses (up to `maxWalletsPerEntity`). function _trackEntity(bytes16 entityID, address addr) internal { @@ -699,7 +721,8 @@ contract SettlementSale is // updating global state _totalCommittedAmountByToken[token] += amountDelta; - emit BidPlaced(purchasePermit.saleSpecificEntityID, msg.sender, newBid); + emit BidPlaced(purchasePermit.saleSpecificEntityID, wallet, newBid); + emit CommitmentIncreased(purchasePermit.saleSpecificEntityID, wallet, address(token), amountDelta); return amountDelta; } @@ -727,12 +750,12 @@ contract SettlementSale is } /// @notice Moves the sale to the `Cancellation` stage, allowing participants to cancel their bids and receive refunds. - function openCancellation() external onlyRole(SALE_MANAGER_ROLE) onlyStage(Stage.Closed) { + function openCancellation() external onlyRole(SALE_MANAGER_ROLE) onlyStage(Stage.Commitment) { _setStage(Stage.Cancellation); } - /// @notice Cancels a bid during the `Cancellation` stage, allowing participants to cancel their bids and receive refunds. - /// @dev This differs from a refund in the `Done` stage in that it can only be triggered by the wallet itself and additionally marks the bid as cancelled. + /// @notice Fully cancels an entity's bid during the `Cancellation` stage, refunding all committed amounts. + /// @dev Can be called by any wallet associated with the entity. Always available regardless of `reduceCommitmentEnabled`. function cancelBid() external onlyStage(Stage.Cancellation) onlyUnpaused { bytes16 entityID = _entityIDByAddress[msg.sender]; if (entityID == bytes16(0)) { @@ -740,19 +763,93 @@ contract SettlementSale is } EntityState storage state = _entityStateByID[entityID]; - if (state.cancelled) { - revert BidAlreadyCancelled(entityID); + if (state.refunded) { + revert AlreadyRefunded(entityID); } - state.cancelled = true; - emit BidCancelled(entityID, msg.sender, state.currentBid.amount); + // Build cancellation entries for all non-zero wallet/token pairs + address[] memory wallets = state.wallets.values(); + uint256 numTokens = _paymentTokens.length; + for (uint256 i = 0; i < wallets.length; i++) { + WalletState storage walletState = state.walletStates[wallets[i]]; + for (uint256 j = 0; j < numTokens; j++) { + IERC20 token = _paymentTokens[j]; + uint256 amount = walletState.committedAmountByToken[token]; + if (amount > 0) { + _reduceCommitment(entityID, wallets[i], token, amount); + } + } + } + } - _refund(entityID); + /// @notice Partially reduces specific wallet/token commitments during the `Cancellation` stage. + /// @dev Requires `reduceCommitmentEnabled`. The caller must be a wallet associated with the entity. + /// @param reductions Array of (wallet, token, amount) tuples specifying what to reduce. + function reduceCommitment(WalletTokenAmount[] calldata reductions) + external + onlyStage(Stage.Cancellation) + onlyUnpaused + { + if (!reduceCommitmentEnabled) { + revert ReduceCommitmentDisabled(); + } + + bytes16 entityID = _entityIDByAddress[msg.sender]; + if (entityID == bytes16(0)) { + revert WalletNotInitialized(msg.sender); + } + + EntityState storage state = _entityStateByID[entityID]; + if (state.refunded) { + revert AlreadyRefunded(entityID); + } + + for (uint256 i = 0; i < reductions.length; i++) { + WalletTokenAmount calldata c = reductions[i]; + _reduceCommitment(entityID, c.wallet, IERC20(c.token), c.amount); + } + } + + /// @notice Reduces a wallet's commitment for a given token by `amount` and transfers the funds back. + function _reduceCommitment(bytes16 entityID, address wallet, IERC20 token, uint256 amount) internal { + EntityState storage state = _entityStateByID[entityID]; + + if (!state.wallets.contains(wallet)) { + revert WalletNotAssociatedWithEntity(wallet, entityID); + } + + if (!_isValidPaymentToken[token]) { + revert InvalidPaymentToken(address(token)); + } + + if (amount == 0) { + revert ZeroAmount(); + } + + WalletState storage walletState = state.walletStates[wallet]; + if (walletState.committedAmountByToken[token] < amount) { + revert ReductionExceedsCommitment( + entityID, wallet, address(token), amount, walletState.committedAmountByToken[token] + ); + } + + walletState.committedAmountByToken[token] -= amount; + walletState.cancelledAmountByToken[token] += amount; + _totalCommittedAmountByToken[token] -= amount; + _totalCancelledAmountByToken[token] += amount; + state.currentBid.amount -= amount; + + if (state.currentBid.amount == 0) { + state.refunded = true; + } + + emit CommitmentReduced(entityID, wallet, address(token), amount); + token.safeTransfer(wallet, amount); } /// @notice Moves the sale to the `Settlement` stage, allowing the settler to set allocations. - /// @dev Can be called during the `Closed` stage (skipping the cancellation stage) or the `Cancellation` stage. - function openSettlement() external onlyRole(SALE_MANAGER_ROLE) onlyStages(Stage.Closed, Stage.Cancellation) { + /// @dev Can be called during the `Commitment` stage (skipping the cancellation stage) or the `Cancellation` stage. + function openSettlement() external onlyRole(SALE_MANAGER_ROLE) onlyStages(Stage.Commitment, Stage.Cancellation) { _setStage(Stage.Settlement); } @@ -809,9 +906,7 @@ contract SettlementSale is uint256 prevAcceptedAmountForToken = walletState.acceptedAmountByToken[token]; if (prevAcceptedAmountForToken > 0) { if (!allowOverwrite) { - revert AllocationAlreadySet( - allocation.saleSpecificEntityID, _sumByToken(walletState.acceptedAmountByToken) - ); + revert AllocationAlreadySet(allocation.saleSpecificEntityID); } // reset global counter @@ -981,6 +1076,12 @@ contract SettlementSale is emit ClaimRefundEnabledChanged(enabled); } + /// @notice Sets whether reducing commitments is enabled during the `Cancellation` stage. + function setReduceCommitmentEnabled(bool enabled) external onlyRole(SALE_MANAGER_ROLE) { + reduceCommitmentEnabled = enabled; + emit ReduceCommitmentEnabledChanged(enabled); + } + /// @notice Sets the maximum number of wallets that can be associated with a single entity. /// @param max The new maximum. Must be > 0. function setMaxWalletsPerEntity(uint8 max) external onlyRole(SALE_MANAGER_ROLE) { @@ -1042,11 +1143,12 @@ contract SettlementSale is struct WalletStateView { address addr; bytes16 entityID; - TokenAmount[] acceptedAmountByToken; TokenAmount[] committedAmountByToken; + TokenAmount[] cancelledAmountByToken; + TokenAmount[] acceptedAmountByToken; } - function walletStateByAddress(address addr) public view returns (WalletStateView memory) { + function _walletStateViewByAddress(address addr) internal view returns (WalletStateView memory) { bytes16 entityID = _entityIDByAddress[addr]; if (entityID == bytes16(0)) { revert WalletNotInitialized(addr); @@ -1057,6 +1159,7 @@ contract SettlementSale is addr: addr, entityID: entityID, committedAmountByToken: _toTokenAmounts(state.walletStates[addr].committedAmountByToken), + cancelledAmountByToken: _toTokenAmounts(state.walletStates[addr].cancelledAmountByToken), acceptedAmountByToken: _toTokenAmounts(state.walletStates[addr].acceptedAmountByToken) }); } @@ -1064,7 +1167,7 @@ contract SettlementSale is function walletStatesByAddresses(address[] memory addrs) public view returns (WalletStateView[] memory) { WalletStateView[] memory states = new WalletStateView[](addrs.length); for (uint256 i = 0; i < addrs.length; i++) { - states[i] = walletStateByAddress(addrs[i]); + states[i] = _walletStateViewByAddress(addrs[i]); } return states; } @@ -1072,21 +1175,19 @@ contract SettlementSale is struct EntityStateView { bytes16 entityID; uint32 bidTimestamp; - bool cancelled; bool refunded; Bid currentBid; WalletStateView[] walletStates; } /// @notice Returns the state of an entity. - function entityStateByID(bytes16 entityID) public view returns (EntityStateView memory) { + function _entityStateViewByID(bytes16 entityID) internal view returns (EntityStateView memory) { EntityState storage state = _entityStateByID[entityID]; address[] memory wallets = state.wallets.values(); return EntityStateView({ entityID: entityID, bidTimestamp: state.bidTimestamp, - cancelled: state.cancelled, refunded: state.refunded, currentBid: state.currentBid, walletStates: walletStatesByAddresses(wallets) @@ -1097,7 +1198,7 @@ contract SettlementSale is function entityStatesByIDs(bytes16[] calldata entityIDs) external view returns (EntityStateView[] memory) { EntityStateView[] memory states = new EntityStateView[](entityIDs.length); for (uint256 i = 0; i < entityIDs.length; i++) { - states[i] = entityStateByID(entityIDs[i]); + states[i] = _entityStateViewByID(entityIDs[i]); } return states; } @@ -1106,7 +1207,7 @@ contract SettlementSale is function entityStatesIn(uint256 from, uint256 to) external view returns (EntityStateView[] memory) { EntityStateView[] memory states = new EntityStateView[](to - from); for (uint256 i = from; i < to; i++) { - states[i - from] = entityStateByID(entityAt(i)); + states[i - from] = _entityStateViewByID(entityAt(i)); } return states; } diff --git a/src/sales/SettlementSaleFactory.sol b/src/sales/SettlementSaleFactory.sol index 3f32c7c..1dbdfad 100644 --- a/src/sales/SettlementSaleFactory.sol +++ b/src/sales/SettlementSaleFactory.sol @@ -10,7 +10,7 @@ import {SettlementSale} from "sales/SettlementSale.sol"; /// @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) { +contract SettlementSaleFactory is Versioned(2, 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. diff --git a/test/BidSubmission.t.sol b/test/BidSubmission.t.sol index 06416cf..8997f7a 100644 --- a/test/BidSubmission.t.sol +++ b/test/BidSubmission.t.sol @@ -111,6 +111,9 @@ contract SettlementSaleBidTestBase is SettlementSaleBaseTest { vm.expectEmit(true, true, true, true, address(sale)); emit SettlementSale.BidPlaced(entityID, user, bid); + vm.expectEmit(true, true, true, true, address(sale)); + emit SettlementSale.CommitmentIncreased(entityID, user, address(token), amountDelta); + { bytes memory purchasePermitSignature = signPurchasePermit(purchasePermit); @@ -545,18 +548,18 @@ contract SettlementSaleBidTest is SettlementSaleBidTestBase { assertEq(sale.entityStateByID(aliceID).currentBid.amount, 2000e6); } - function testBid_AfterClose_Reverts() public { + function testBid_AfterCancellation_Reverts() public { bidSuccess({user: alice, price: 10, amount: 1000e6, token: usdc}); - closeCommitment(); - assertEq(uint8(sale.stage()), uint8(SettlementSale.Stage.Closed)); + openCancellation(); + assertEq(uint8(sale.stage()), uint8(SettlementSale.Stage.Cancellation)); bidFail({ user: alice, price: 10, amount: 1000e6, token: usdc, - err: encodeInvalidStage(SettlementSale.Stage.Closed, SettlementSale.Stage.Commitment) + err: encodeInvalidStage(SettlementSale.Stage.Cancellation, SettlementSale.Stage.Commitment) }); } @@ -771,7 +774,6 @@ contract SettlementSaleBidAfterRefundTest is SettlementSaleBidTestBase { // Alice places a bid doBid({user: alice, amount: 2000e6, price: 10, token: usdc}); - closeCommitment(); openCancellation(); // Alice cancels (gets refunded) diff --git a/test/Cancellation.t.sol b/test/Cancellation.t.sol index 8944296..ca05497 100644 --- a/test/Cancellation.t.sol +++ b/test/Cancellation.t.sol @@ -7,7 +7,6 @@ contract SettlementSaleCancellationTest is SettlementSaleBaseTest { struct State { uint256 bidAmount; bool refunded; - bool cancelled; TokenAmount[] userBalance; TokenAmount[] saleBalance; TokenAmount[] committedAmountByToken; @@ -19,7 +18,6 @@ contract SettlementSaleCancellationTest is SettlementSaleBaseTest { return State({ bidAmount: entityState.currentBid.amount, refunded: entityState.refunded, - cancelled: entityState.cancelled, userBalance: tokenBalances(wallet), saleBalance: tokenBalances(address(sale)), committedAmountByToken: walletState.committedAmountByToken @@ -29,8 +27,6 @@ contract SettlementSaleCancellationTest is SettlementSaleBaseTest { function cancelBidSuccess(address user) internal { bytes16 entityID = addressToEntityID(user); State memory stateBefore = getState(entityID, user); - vm.expectEmit(true, true, true, true, address(sale)); - emit SettlementSale.BidCancelled(entityID, user, stateBefore.bidAmount); vm.prank(user); sale.cancelBid(); @@ -38,7 +34,7 @@ contract SettlementSaleCancellationTest is SettlementSaleBaseTest { State memory stateAfter = getState(entityID, user); assertEq(stateAfter.refunded, true, "refunded should be true"); - assertEq(stateAfter.cancelled, true, "cancelled should be true"); + assertEq(stateAfter.bidAmount, 0, "bid amount should be zero after full cancel"); assertEq( stateAfter.userBalance, @@ -62,9 +58,11 @@ contract SettlementSaleCancellationTest is SettlementSaleBaseTest { openCommitment(); doBid({user: alice, amount: 2000e6, price: 10, token: usdc}); - closeCommitment(); openCancellation(); + vm.expectEmit(true, true, true, true, address(sale)); + emit SettlementSale.CommitmentReduced(aliceID, alice, address(usdc), 2000e6); + cancelBidSuccess(alice); } @@ -72,18 +70,16 @@ contract SettlementSaleCancellationTest is SettlementSaleBaseTest { openCommitment(); doBid({user: alice, amount: 2000e6, price: 10, token: usdc}); - closeCommitment(); openCancellation(); cancelBidSuccess(alice); - cancelBidFail(alice, abi.encodeWithSelector(SettlementSale.BidAlreadyCancelled.selector, aliceID)); + cancelBidFail(alice, abi.encodeWithSelector(SettlementSale.AlreadyRefunded.selector, aliceID)); } function testCancelBid_AfterCancellationPhase_Reverts() public { openCommitment(); doBid({user: alice, amount: 2000e6, price: 10, token: usdc}); - closeCommitment(); openCancellation(); openSettlement(); @@ -110,7 +106,6 @@ contract SettlementSaleCancellationTest is SettlementSaleBaseTest { openCommitment(); doBid({user: alice, amount: 2000e6, price: 10, token: usdc}); - closeCommitment(); openCancellation(); cancelBidFail(bob, abi.encodeWithSelector(SettlementSale.WalletNotInitialized.selector, bob)); @@ -120,7 +115,6 @@ contract SettlementSaleCancellationTest is SettlementSaleBaseTest { openCommitment(); doBid({user: alice, amount: 2000e6, price: 10, token: usdc}); - closeCommitment(); openCancellation(); vm.prank(pauser); @@ -140,9 +134,11 @@ contract SettlementSaleCancellationTest is SettlementSaleBaseTest { // Alice bids with USDT doBid({user: alice, amount: 2000e6, price: 10, token: usdt}); - closeCommitment(); openCancellation(); + vm.expectEmit(true, true, true, true, address(sale)); + emit SettlementSale.CommitmentReduced(aliceID, alice, address(usdt), 2000e6); + cancelBidSuccess(alice); } @@ -151,16 +147,20 @@ contract SettlementSaleCancellationTest is SettlementSaleBaseTest { doBid({user: alice, amount: 2000e6, price: 10, token: usdc}); doBid({user: bob, amount: 3000e6, price: 10, token: usdt}); - closeCommitment(); openCancellation(); + vm.expectEmit(true, true, true, true, address(sale)); + emit SettlementSale.CommitmentReduced(aliceID, alice, address(usdc), 2000e6); cancelBidSuccess(alice); + + vm.expectEmit(true, true, true, true, address(sale)); + emit SettlementSale.CommitmentReduced(bobID, bob, address(usdt), 3000e6); cancelBidSuccess(bob); assertEq(usdc.balanceOf(alice), 2000e6, "alice should get USDC back"); assertEq(usdt.balanceOf(bob), 3000e6, "bob should get USDT back"); - assertTrue(sale.entityStateByID(aliceID).cancelled); - assertTrue(sale.entityStateByID(bobID).cancelled); + assertTrue(sale.entityStateByID(aliceID).refunded); + assertTrue(sale.entityStateByID(bobID).refunded); } function testCancelBid_FromSecondWallet_RefundsBothWallets() public { @@ -174,21 +174,14 @@ contract SettlementSaleCancellationTest is SettlementSaleBaseTest { // Alice bids with second wallet using USDT (same entity) doBid({entityID: aliceID, user: aliceWallet2, amount: 5000e6, price: 10, token: usdt}); - closeCommitment(); openCancellation(); // Second wallet cancels the entire entity's bid vm.expectEmit(true, true, true, true, address(sale)); - emit SettlementSale.BidCancelled(aliceID, aliceWallet2, 5000e6); - - vm.expectEmit(true, true, true, true, address(sale)); - emit SettlementSale.WalletRefunded(aliceID, alice, address(usdc), 2000e6); - - vm.expectEmit(true, true, true, true, address(sale)); - emit SettlementSale.WalletRefunded(aliceID, aliceWallet2, address(usdt), 3000e6); + emit SettlementSale.CommitmentReduced(aliceID, alice, address(usdc), 2000e6); vm.expectEmit(true, true, true, true, address(sale)); - emit SettlementSale.EntityRefunded(aliceID, 5000e6); + emit SettlementSale.CommitmentReduced(aliceID, aliceWallet2, address(usdt), 3000e6); vm.prank(aliceWallet2); sale.cancelBid(); @@ -196,7 +189,6 @@ contract SettlementSaleCancellationTest is SettlementSaleBaseTest { // Both wallets should receive their committed amounts back assertEq(usdc.balanceOf(alice), 2000e6, "alice wallet1 should get USDC back"); assertEq(usdt.balanceOf(aliceWallet2), 3000e6, "alice wallet2 should get USDT back"); - assertTrue(sale.entityStateByID(aliceID).cancelled); assertTrue(sale.entityStateByID(aliceID).refunded); } @@ -209,16 +201,560 @@ contract SettlementSaleCancellationTest is SettlementSaleBaseTest { // Verify lockup is set assertTrue(sale.entityStateByID(aliceID).currentBid.lockup, "lockup should be enabled"); - closeCommitment(); openCancellation(); + vm.expectEmit(true, true, true, true, address(sale)); + emit SettlementSale.CommitmentReduced(aliceID, alice, address(usdc), 2000e6); + // Cancel should still work and refund full amount cancelBidSuccess(alice); // Verify refund occurred assertEq(usdc.balanceOf(alice), 2000e6, "alice should get full USDC refund"); - assertTrue(sale.entityStateByID(aliceID).cancelled, "should be cancelled"); assertTrue(sale.entityStateByID(aliceID).refunded, "should be refunded"); } -} + // --- Reduce commitment tests --- + + function enableReduceCommitment() internal { + vm.prank(manager); + sale.setReduceCommitmentEnabled(true); + } + + function testReduceCommitment_PartialAmount_Success() public { + openCommitment(); + doBid({user: alice, amount: 5000e6, price: 10, token: usdc}); + + openCancellation(); + enableReduceCommitment(); + + WalletTokenAmount[] memory reductions = new WalletTokenAmount[](1); + reductions[0] = WalletTokenAmount({wallet: alice, token: address(usdc), amount: 2000e6}); + + vm.expectEmit(true, true, true, true, address(sale)); + emit SettlementSale.CommitmentReduced(aliceID, alice, address(usdc), 2000e6); + + vm.prank(alice); + sale.reduceCommitment(reductions); + + SettlementSale.EntityStateView memory entityState = sale.entityStateByID(aliceID); + assertEq(entityState.currentBid.amount, 3000e6, "bid amount should be reduced"); + assertFalse(entityState.refunded, "should not be fully refunded"); + + assertEq(usdc.balanceOf(alice), 2000e6, "alice should receive partial refund"); + assertEq(usdc.balanceOf(address(sale)), 3000e6, "sale should retain remaining commitment"); + + SettlementSale.WalletStateView memory walletState = sale.walletStateByAddress(alice); + assertEq(walletState.committedAmountByToken[0].amount, 3000e6, "committed should be reduced"); + assertEq(walletState.cancelledAmountByToken[0].amount, 2000e6, "cancelled should be tracked"); + } + + function testReduceCommitment_FullAmount_SetsRefunded() public { + openCommitment(); + doBid({user: alice, amount: 5000e6, price: 10, token: usdc}); + + openCancellation(); + enableReduceCommitment(); + + WalletTokenAmount[] memory reductions = new WalletTokenAmount[](1); + reductions[0] = WalletTokenAmount({wallet: alice, token: address(usdc), amount: 5000e6}); + + vm.expectEmit(true, true, true, true, address(sale)); + emit SettlementSale.CommitmentReduced(aliceID, alice, address(usdc), 5000e6); + + vm.prank(alice); + sale.reduceCommitment(reductions); + + SettlementSale.EntityStateView memory entityState = sale.entityStateByID(aliceID); + assertEq(entityState.currentBid.amount, 0, "bid amount should be zero"); + assertTrue(entityState.refunded, "should be refunded when fully cancelled"); + assertEq(usdc.balanceOf(alice), 5000e6, "alice should get everything back"); + } + + function testReduceCommitment_MultipleEntries_Success() public { + address aliceWallet2 = makeAddr("aliceWallet2"); + + openCommitment(); + doBid({entityID: aliceID, user: alice, amount: 3000e6, price: 10, token: usdc}); + doBid({entityID: aliceID, user: aliceWallet2, amount: 6000e6, price: 10, token: usdt}); + + openCancellation(); + enableReduceCommitment(); + + WalletTokenAmount[] memory reductions = new WalletTokenAmount[](2); + reductions[0] = WalletTokenAmount({wallet: alice, token: address(usdc), amount: 1000e6}); + reductions[1] = WalletTokenAmount({wallet: aliceWallet2, token: address(usdt), amount: 2000e6}); + + vm.prank(alice); + sale.reduceCommitment(reductions); + + SettlementSale.EntityStateView memory entityState = sale.entityStateByID(aliceID); + assertEq(entityState.currentBid.amount, 3000e6, "bid should be reduced by 3000"); + assertFalse(entityState.refunded, "should not be refunded"); + + assertEq(usdc.balanceOf(alice), 1000e6, "alice wallet1 refund"); + assertEq(usdt.balanceOf(aliceWallet2), 2000e6, "alice wallet2 refund"); + } + + function testReduceCommitment_DisabledByDefault_Reverts() public { + openCommitment(); + doBid({user: alice, amount: 5000e6, price: 10, token: usdc}); + + openCancellation(); + + WalletTokenAmount[] memory reductions = new WalletTokenAmount[](1); + reductions[0] = WalletTokenAmount({wallet: alice, token: address(usdc), amount: 1000e6}); + + vm.expectRevert(abi.encodeWithSelector(SettlementSale.ReduceCommitmentDisabled.selector)); + vm.prank(alice); + sale.reduceCommitment(reductions); + } + + function testReduceCommitment_UninitializedWallet_Reverts() public { + openCommitment(); + doBid({user: alice, amount: 5000e6, price: 10, token: usdc}); + + openCancellation(); + enableReduceCommitment(); + + WalletTokenAmount[] memory reductions = new WalletTokenAmount[](1); + reductions[0] = WalletTokenAmount({wallet: bob, token: address(usdc), amount: 1000e6}); + + vm.expectRevert(abi.encodeWithSelector(SettlementSale.WalletNotInitialized.selector, bob)); + vm.prank(bob); + sale.reduceCommitment(reductions); + } + + function testReduceCommitment_DuringWrongStage_Reverts(uint8 s) public { + openCommitment(); + doBid({user: alice, amount: 5000e6, price: 10, token: usdc}); + + enableReduceCommitment(); + + SettlementSale.Stage stage = SettlementSale.Stage(bound(s, 0, uint8(SettlementSale.Stage.Done))); + + vm.prank(admin); + sale.unsafeSetStage(stage); + + WalletTokenAmount[] memory reductions = new WalletTokenAmount[](1); + reductions[0] = WalletTokenAmount({wallet: alice, token: address(usdc), amount: 2000e6}); + + if (stage == SettlementSale.Stage.Cancellation) { + vm.prank(alice); + sale.reduceCommitment(reductions); + + assertEq(sale.entityStateByID(aliceID).currentBid.amount, 3000e6, "bid should be reduced"); + } else { + vm.expectRevert(encodeInvalidStage(stage, SettlementSale.Stage.Cancellation)); + vm.prank(alice); + sale.reduceCommitment(reductions); + } + } + + function testReduceCommitment_WhilePaused_Reverts() public { + openCommitment(); + doBid({user: alice, amount: 5000e6, price: 10, token: usdc}); + + openCancellation(); + enableReduceCommitment(); + + vm.prank(pauser); + sale.pause(); + + WalletTokenAmount[] memory reductions = new WalletTokenAmount[](1); + reductions[0] = WalletTokenAmount({wallet: alice, token: address(usdc), amount: 2000e6}); + + vm.expectRevert(abi.encodeWithSelector(SettlementSale.SalePaused.selector)); + vm.prank(alice); + sale.reduceCommitment(reductions); + + vm.prank(admin); + sale.setPaused(false); + + vm.prank(alice); + sale.reduceCommitment(reductions); + + assertEq(sale.entityStateByID(aliceID).currentBid.amount, 3000e6, "bid should be reduced after unpause"); + } + + function testReduceCommitment_EmptyArray_NoOp() public { + openCommitment(); + doBid({user: alice, amount: 5000e6, price: 10, token: usdc}); + + openCancellation(); + enableReduceCommitment(); + + WalletTokenAmount[] memory reductions = new WalletTokenAmount[](0); + + vm.prank(alice); + sale.reduceCommitment(reductions); + + assertEq(sale.entityStateByID(aliceID).currentBid.amount, 5000e6, "bid should be unchanged"); + assertEq(usdc.balanceOf(alice), 0, "alice balance should be unchanged"); + assertEq(usdc.balanceOf(address(sale)), 5000e6, "sale balance should be unchanged"); + } + + function testReduceCommitment_ExceedsCommitment_Reverts() public { + openCommitment(); + doBid({user: alice, amount: 5000e6, price: 10, token: usdc}); + + openCancellation(); + enableReduceCommitment(); + + WalletTokenAmount[] memory reductions = new WalletTokenAmount[](1); + reductions[0] = WalletTokenAmount({wallet: alice, token: address(usdc), amount: 6000e6}); + + vm.expectRevert( + abi.encodeWithSelector( + SettlementSale.ReductionExceedsCommitment.selector, aliceID, alice, address(usdc), 6000e6, 5000e6 + ) + ); + vm.prank(alice); + sale.reduceCommitment(reductions); + } + + function testReduceCommitment_WrongWallet_Reverts() public { + openCommitment(); + doBid({user: alice, amount: 5000e6, price: 10, token: usdc}); + doBid({user: bob, amount: 3000e6, price: 10, token: usdc}); + + openCancellation(); + enableReduceCommitment(); + + // Alice tries to cancel Bob's commitment + WalletTokenAmount[] memory reductions = new WalletTokenAmount[](1); + reductions[0] = WalletTokenAmount({wallet: bob, token: address(usdc), amount: 1000e6}); + + vm.expectRevert(abi.encodeWithSelector(SettlementSale.WalletNotAssociatedWithEntity.selector, bob, aliceID)); + vm.prank(alice); + sale.reduceCommitment(reductions); + } + + function testReduceCommitment_ZeroAmount_Reverts() public { + openCommitment(); + doBid({user: alice, amount: 5000e6, price: 10, token: usdc}); + + openCancellation(); + enableReduceCommitment(); + + WalletTokenAmount[] memory reductions = new WalletTokenAmount[](1); + reductions[0] = WalletTokenAmount({wallet: alice, token: address(usdc), amount: 0}); + + vm.expectRevert(abi.encodeWithSelector(SettlementSale.ZeroAmount.selector)); + vm.prank(alice); + sale.reduceCommitment(reductions); + } + + function testReduceCommitment_AlreadyRefunded_Reverts() public { + openCommitment(); + doBid({user: alice, amount: 5000e6, price: 10, token: usdc}); + + openCancellation(); + enableReduceCommitment(); + + // Full cancel via cancelBid + vm.prank(alice); + sale.cancelBid(); + + // Try reduce commitment after full cancel + WalletTokenAmount[] memory reductions = new WalletTokenAmount[](1); + reductions[0] = WalletTokenAmount({wallet: alice, token: address(usdc), amount: 1000e6}); + + vm.expectRevert(abi.encodeWithSelector(SettlementSale.AlreadyRefunded.selector, aliceID)); + vm.prank(alice); + sale.reduceCommitment(reductions); + } + + function testReduceCommitment_ThenSettlement_Success() public { + openCommitment(); + doBid({user: alice, amount: 5000e6, price: 10, token: usdc}); + + openCancellation(); + enableReduceCommitment(); + + // Reduce commitment + WalletTokenAmount[] memory reductions = new WalletTokenAmount[](1); + reductions[0] = WalletTokenAmount({wallet: alice, token: address(usdc), amount: 2000e6}); + vm.prank(alice); + sale.reduceCommitment(reductions); + + // Settlement with allocation against remaining commitment + openSettlement(); + doSetAllocation(alice, usdc, 2000e6); + finalizeSettlement(2000e6); + + // Refund the unallocated remainder (3000 committed - 2000 accepted = 1000) + vm.prank(alice); + sale.claimRefund(); + + // Alice should have: 2000 (cancellation) + 1000 (refund) = 3000 back, 2000 kept as proceeds + assertEq(usdc.balanceOf(alice), 3000e6, "alice total refund"); + assertEq(sale.totalCancelledAmount(), 2000e6, "total cancelled"); + assertEq(sale.totalRefundedAmount(), 1000e6, "total refunded"); + + // Withdraw proceeds + vm.prank(admin); + sale.withdraw(); + assertEq(usdc.balanceOf(receiver), 2000e6, "proceeds"); + } + + function testReduceCommitment_InvalidToken_Reverts() public { + openCommitment(); + doBid({user: alice, amount: 5000e6, price: 10, token: usdc}); + + openCancellation(); + enableReduceCommitment(); + + address fakeToken = makeAddr("fakeToken"); + WalletTokenAmount[] memory reductions = new WalletTokenAmount[](1); + reductions[0] = WalletTokenAmount({wallet: alice, token: fakeToken, amount: 1000e6}); + + vm.expectRevert(abi.encodeWithSelector(SettlementSale.InvalidPaymentToken.selector, fakeToken)); + vm.prank(alice); + sale.reduceCommitment(reductions); + } + + function testReduceCommitment_GlobalCounters_Updated() public { + openCommitment(); + doBid({user: alice, amount: 5000e6, price: 10, token: usdc}); + doBid({user: bob, amount: 3000e6, price: 10, token: usdt}); + + openCancellation(); + enableReduceCommitment(); + + assertEq(sale.totalCommittedAmount(), 8000e6, "total committed before"); + + WalletTokenAmount[] memory reductions = new WalletTokenAmount[](1); + reductions[0] = WalletTokenAmount({wallet: alice, token: address(usdc), amount: 2000e6}); + vm.prank(alice); + sale.reduceCommitment(reductions); + + assertEq(sale.totalCommittedAmount(), 6000e6, "total committed after reduce commitment"); + assertEq(sale.totalCancelledAmount(), 2000e6, "total cancelled"); + + // Verify per-token counters + TokenAmount[] memory cancelledByToken = sale.totalCancelledAmountByToken(); + assertEq(cancelledByToken[0].amount, 2000e6, "cancelled USDC"); + assertEq(cancelledByToken[1].amount, 0, "cancelled USDT"); + } + + function testReduceCommitment_ThenCancelBid_CancelsRemainder() public { + openCommitment(); + doBid({user: alice, amount: 5000e6, price: 10, token: usdc}); + + openCancellation(); + enableReduceCommitment(); + + // Partial reduce + WalletTokenAmount[] memory reductions = new WalletTokenAmount[](1); + reductions[0] = WalletTokenAmount({wallet: alice, token: address(usdc), amount: 2000e6}); + vm.prank(alice); + sale.reduceCommitment(reductions); + + assertEq(sale.entityStateByID(aliceID).currentBid.amount, 3000e6, "bid after partial reduce"); + assertFalse(sale.entityStateByID(aliceID).refunded, "should not be refunded yet"); + + // Full cancel of remainder via cancelBid + vm.prank(alice); + sale.cancelBid(); + + assertEq(sale.entityStateByID(aliceID).currentBid.amount, 0, "bid after cancelBid"); + assertTrue(sale.entityStateByID(aliceID).refunded, "should be refunded"); + assertEq(usdc.balanceOf(alice), 5000e6, "alice should get everything back"); + assertEq(sale.totalCancelledAmount(), 5000e6, "total cancelled should include both"); + assertEq(sale.totalCommittedAmount(), 0, "total committed should be zero"); + } + + function testReduceCommitment_DuplicateEntriesSameWalletToken_RevertsOnSecond() public { + openCommitment(); + doBid({user: alice, amount: 5000e6, price: 10, token: usdc}); + + openCancellation(); + enableReduceCommitment(); + + // Two entries targeting the same wallet/token: first succeeds, second exceeds remaining + WalletTokenAmount[] memory reductions = new WalletTokenAmount[](2); + reductions[0] = WalletTokenAmount({wallet: alice, token: address(usdc), amount: 3000e6}); + reductions[1] = WalletTokenAmount({wallet: alice, token: address(usdc), amount: 3000e6}); + + vm.expectRevert( + abi.encodeWithSelector( + SettlementSale.ReductionExceedsCommitment.selector, aliceID, alice, address(usdc), 3000e6, 2000e6 + ) + ); + vm.prank(alice); + sale.reduceCommitment(reductions); + } + + function testReduceCommitment_DuplicateEntriesSameWalletToken_WithinBounds_Success() public { + openCommitment(); + doBid({user: alice, amount: 5000e6, price: 10, token: usdc}); + + openCancellation(); + enableReduceCommitment(); + + // Two entries for the same wallet/token, total 3000 < 5000 committed + WalletTokenAmount[] memory reductions = new WalletTokenAmount[](2); + reductions[0] = WalletTokenAmount({wallet: alice, token: address(usdc), amount: 1000e6}); + reductions[1] = WalletTokenAmount({wallet: alice, token: address(usdc), amount: 2000e6}); + + vm.prank(alice); + sale.reduceCommitment(reductions); + + assertEq(sale.entityStateByID(aliceID).currentBid.amount, 2000e6, "bid should reflect both reductions"); + assertFalse(sale.entityStateByID(aliceID).refunded, "should not be refunded"); + assertEq(usdc.balanceOf(alice), 3000e6, "alice should receive combined refund"); + assertEq(sale.totalCancelledAmount(), 3000e6, "total cancelled"); + + SettlementSale.WalletStateView memory walletState = sale.walletStateByAddress(alice); + assertEq(walletState.committedAmountByToken[0].amount, 2000e6, "committed after both reductions"); + assertEq(walletState.cancelledAmountByToken[0].amount, 3000e6, "cancelled tracks both reductions"); + } + + function testReduceCommitment_AllWalletsAllTokensToZero_SetsRefunded() public { + address aliceWallet2 = makeAddr("aliceWallet2"); + + openCommitment(); + doBid({entityID: aliceID, user: alice, amount: 3000e6, price: 10, token: usdc}); + // Second bid raises total to 6000, so wallet2 commits the delta: 3000 USDT + doBid({entityID: aliceID, user: aliceWallet2, amount: 6000e6, price: 10, token: usdt}); + + openCancellation(); + enableReduceCommitment(); + + // Reduce every wallet/token pair to zero via reduceCommitment (not cancelBid) + WalletTokenAmount[] memory reductions = new WalletTokenAmount[](2); + reductions[0] = WalletTokenAmount({wallet: alice, token: address(usdc), amount: 3000e6}); + reductions[1] = WalletTokenAmount({wallet: aliceWallet2, token: address(usdt), amount: 3000e6}); + + vm.prank(alice); + sale.reduceCommitment(reductions); + + assertTrue(sale.entityStateByID(aliceID).refunded, "should be refunded when all pairs zeroed"); + assertEq(sale.entityStateByID(aliceID).currentBid.amount, 0, "bid should be zero"); + assertEq(usdc.balanceOf(alice), 3000e6, "wallet1 refund"); + assertEq(usdt.balanceOf(aliceWallet2), 3000e6, "wallet2 refund"); + } + + function testReduceCommitment_ThenSettlement_AllocationExceedsReducedCommitment_Reverts() public { + openCommitment(); + doBid({user: alice, amount: 5000e6, price: 10, token: usdc}); + + openCancellation(); + enableReduceCommitment(); + + // Reduce to 2000 + WalletTokenAmount[] memory reductions = new WalletTokenAmount[](1); + reductions[0] = WalletTokenAmount({wallet: alice, token: address(usdc), amount: 3000e6}); + vm.prank(alice); + sale.reduceCommitment(reductions); + + openSettlement(); + + // Try to allocate 3000 against the reduced 2000 commitment + IOffchainSettlement.Allocation[] memory allocations = new IOffchainSettlement.Allocation[](1); + allocations[0] = IOffchainSettlement.Allocation({ + saleSpecificEntityID: aliceID, wallet: alice, token: address(usdc), acceptedAmount: 3000e6 + }); + + vm.expectRevert( + abi.encodeWithSelector( + SettlementSale.AllocationExceedsCommitment.selector, aliceID, alice, address(usdc), 3000e6, 2000e6 + ) + ); + vm.prank(settler); + sale.setAllocations({allocations: allocations, allowOverwrite: false}); + } + + function testCancelBid_AfterPartialReduce_EventsEmitted() public { + openCommitment(); + doBid({user: alice, amount: 5000e6, price: 10, token: usdc}); + + openCancellation(); + enableReduceCommitment(); + + // Partial reduce first + WalletTokenAmount[] memory reductions = new WalletTokenAmount[](1); + reductions[0] = WalletTokenAmount({wallet: alice, token: address(usdc), amount: 2000e6}); + vm.prank(alice); + sale.reduceCommitment(reductions); + + // cancelBid should emit CommitmentReduced for the remaining 3000 + vm.expectEmit(true, true, true, true, address(sale)); + emit SettlementSale.CommitmentReduced(aliceID, alice, address(usdc), 3000e6); + + vm.prank(alice); + sale.cancelBid(); + } + + function testSetReduceCommitmentEnabled_EmitsEvent() public { + vm.expectEmit(true, true, true, true, address(sale)); + emit SettlementSale.ReduceCommitmentEnabledChanged(true); + + vm.prank(manager); + sale.setReduceCommitmentEnabled(true); + + vm.expectEmit(true, true, true, true, address(sale)); + emit SettlementSale.ReduceCommitmentEnabledChanged(false); + + vm.prank(manager); + sale.setReduceCommitmentEnabled(false); + } + + function testReduceCommitment_EnableThenDisable_Reverts() public { + openCommitment(); + doBid({user: alice, amount: 5000e6, price: 10, token: usdc}); + + openCancellation(); + enableReduceCommitment(); + + // First reduce succeeds + WalletTokenAmount[] memory reductions = new WalletTokenAmount[](1); + reductions[0] = WalletTokenAmount({wallet: alice, token: address(usdc), amount: 1000e6}); + vm.prank(alice); + sale.reduceCommitment(reductions); + + assertEq(sale.entityStateByID(aliceID).currentBid.amount, 4000e6, "after first reduce"); + + // Manager disables the feature + vm.prank(manager); + sale.setReduceCommitmentEnabled(false); + + // Second reduce reverts + WalletTokenAmount[] memory reductions2 = new WalletTokenAmount[](1); + reductions2[0] = WalletTokenAmount({wallet: alice, token: address(usdc), amount: 1000e6}); + + vm.expectRevert(abi.encodeWithSelector(SettlementSale.ReduceCommitmentDisabled.selector)); + vm.prank(alice); + sale.reduceCommitment(reductions2); + + // Bid unchanged after failed attempt + assertEq(sale.entityStateByID(aliceID).currentBid.amount, 4000e6, "bid unchanged after disable"); + } + + function testReduceCommitment_MultipleCalls_Success() public { + openCommitment(); + doBid({user: alice, amount: 5000e6, price: 10, token: usdc}); + + openCancellation(); + enableReduceCommitment(); + + // First reduce commitment + WalletTokenAmount[] memory c1 = new WalletTokenAmount[](1); + c1[0] = WalletTokenAmount({wallet: alice, token: address(usdc), amount: 1000e6}); + vm.prank(alice); + sale.reduceCommitment(c1); + + assertEq(sale.entityStateByID(aliceID).currentBid.amount, 4000e6, "after first cancel"); + + // Second reduce commitment + WalletTokenAmount[] memory c2 = new WalletTokenAmount[](1); + c2[0] = WalletTokenAmount({wallet: alice, token: address(usdc), amount: 1500e6}); + vm.prank(alice); + sale.reduceCommitment(c2); + + assertEq(sale.entityStateByID(aliceID).currentBid.amount, 2500e6, "after second cancel"); + assertEq(usdc.balanceOf(alice), 2500e6, "alice balance after two cancels"); + assertEq(sale.totalCancelledAmount(), 2500e6, "total cancelled"); + } +} diff --git a/test/EdgeCases.t.sol b/test/EdgeCases.t.sol index ca41e34..1a67dda 100644 --- a/test/EdgeCases.t.sol +++ b/test/EdgeCases.t.sol @@ -18,7 +18,6 @@ contract SettlementSaleEdgeCasesTest is SettlementSaleBaseTest { assertEq(sale.entityStateByID(aliceID).currentBid.amount, 3000e6, "total commitment should be 3000"); // Close commitment phase and open settlement - closeCommitment(); openSettlement(); // Set allocation: U1 gets allocation of 2500 diff --git a/test/FullLifecycle.t.sol b/test/FullLifecycle.t.sol index a03f96d..5045edf 100644 --- a/test/FullLifecycle.t.sol +++ b/test/FullLifecycle.t.sol @@ -65,10 +65,12 @@ contract SettlementSaleFullLifecycleFuzzTest is SettlementSaleBaseTest { // assume we have at least one commitment so the commitment phase can be closed vm.assume(sale.totalCommittedAmount() > 0); - closeCommitment(); - // open the cancellation stage, so some users can cancel their bids + // open the cancellation stage, so some users can cancel their bids or partially reduce openCancellation(); + vm.prank(manager); + sale.setReduceCommitmentEnabled(true); + bytes16[] memory entities = sale.allEntities(); for (uint256 i = 0; i < entities.length; i++) { bytes16 entityID = entities[i]; @@ -76,15 +78,46 @@ contract SettlementSaleFullLifecycleFuzzTest is SettlementSaleBaseTest { // Get first wallet for this entity (in this test, each entity has one wallet) address wallet = entityState.walletStates[0].addr; bytes32 rand = keccak256(abi.encode(i, wallet, "cancel")); + uint256 roll = uint256(rand) % 100; - // do nothing for 80% of the wallets - if (uint256(rand) % 100 < 80) { + if (roll < 60) { + // 60%: no cancellation + continue; + } + if (roll < 80) { + // 20%: full cancel via cancelBid + vm.prank(wallet); + sale.cancelBid(); + continue; + } + // 20%: partial reduce (half of each token commitment) + TokenAmount[] memory committed = entityState.walletStates[0].committedAmountByToken; + uint256 reductionCount = 0; + for (uint256 j = 0; j < committed.length; j++) { + if (committed[j].amount > 0) { + reductionCount++; + } + } + if (reductionCount == 0) { continue; } - // 20% of wallets will cancel their bid + WalletTokenAmount[] memory reductions = new WalletTokenAmount[](reductionCount); + uint256 idx = 0; + for (uint256 j = 0; j < committed.length; j++) { + if (committed[j].amount > 0) { + uint256 reduceAmount = committed[j].amount / 2; + if (reduceAmount == 0) { + reduceAmount = committed[j].amount; + } + reductions[idx] = + WalletTokenAmount({wallet: wallet, token: committed[j].token, amount: reduceAmount}); + idx++; + } + } + vm.prank(wallet); - sale.cancelBid(); + sale.reduceCommitment(reductions); } checkInvariants(manuallySentUSDC); @@ -218,23 +251,48 @@ contract SettlementSaleFullLifecycleFuzzTest is SettlementSaleBaseTest { SettlementSale.EntityStateView[] memory entityStates = sale.allEntityStates(); assertEq(entities.length, entityStates.length); - // sum of commitments == total commitments + // sum of bid amounts == totalCommittedAmount (both decrease on cancellation) uint256 sumCommitments = 0; for (uint256 i = 0; i < entityStates.length; i++) { sumCommitments += entityStates[i].currentBid.amount; } assertEq(sale.totalCommittedAmount(), sumCommitments, "total commitments"); + // sum of Done-stage refunds uint256 sumRefundedAmounts = 0; for (uint256 i = 0; i < entityStates.length; i++) { if (!entityStates[i].refunded) { continue; } + // For refunded entities: refund = committed - accepted + // For fully cancelled entities: committed is 0 so refund contribution is 0 sumRefundedAmounts += entityStates[i].currentBid.amount - sum(entityStates[i].walletStates[0].acceptedAmountByToken); } assertEq(sale.totalRefundedAmount(), sumRefundedAmounts, "total refunded amount"); + // per-entity: bid amount == sum of wallet committed amounts + for (uint256 i = 0; i < entityStates.length; i++) { + uint256 sumWalletCommitted = 0; + uint256 sumWalletCancelled = 0; + for (uint256 j = 0; j < entityStates[i].walletStates.length; j++) { + sumWalletCommitted += sum(entityStates[i].walletStates[j].committedAmountByToken); + sumWalletCancelled += sum(entityStates[i].walletStates[j].cancelledAmountByToken); + } + assertEq(entityStates[i].currentBid.amount, sumWalletCommitted, "bid amount == sum wallet commitments"); + } + + // global cancelled counter == sum of per-wallet cancelled amounts + uint256 sumCancelled = 0; + for (uint256 i = 0; i < entityStates.length; i++) { + for (uint256 j = 0; j < entityStates[i].walletStates.length; j++) { + sumCancelled += sum(entityStates[i].walletStates[j].cancelledAmountByToken); + } + } + assertEq(sale.totalCancelledAmount(), sumCancelled, "total cancelled"); + + // sale balance = current committed - Done-stage refunds + manually sent + // (cancellation outflows are already reflected by the decrease in committed) assertEq( usdc.balanceOf(address(sale)) + usdt.balanceOf(address(sale)), sumCommitments - sumRefundedAmounts + manuallySentUSDC, diff --git a/test/General.t.sol b/test/General.t.sol index 33534a6..0db0d8f 100644 --- a/test/General.t.sol +++ b/test/General.t.sol @@ -57,7 +57,9 @@ contract SettlementSaleConstructorTest is BaseTest { claimRefundEnabled: true, maxWalletsPerEntity: 50, paymentTokens: _defaultPaymentTokens(), - expectedPaymentTokenDecimals: 6 + expectedPaymentTokenDecimals: 6, + reduceCommitmentEnabled: false, + skipPreOpen: false }); TestableSettlementSale sale = newTestableSettlementSale(init); @@ -96,7 +98,9 @@ contract SettlementSaleConstructorTest is BaseTest { claimRefundEnabled: true, maxWalletsPerEntity: 50, paymentTokens: _defaultPaymentTokens(), - expectedPaymentTokenDecimals: 6 + expectedPaymentTokenDecimals: 6, + reduceCommitmentEnabled: false, + skipPreOpen: false }); TestableSettlementSale sale = newTestableSettlementSale(init); @@ -132,7 +136,9 @@ contract SettlementSaleConstructorTest is BaseTest { claimRefundEnabled: true, maxWalletsPerEntity: 50, paymentTokens: _defaultPaymentTokens(), - expectedPaymentTokenDecimals: 6 + expectedPaymentTokenDecimals: 6, + reduceCommitmentEnabled: false, + skipPreOpen: false }); TestableSettlementSale sale = newTestableSettlementSale(init); @@ -168,7 +174,9 @@ contract SettlementSaleConstructorTest is BaseTest { claimRefundEnabled: true, maxWalletsPerEntity: 50, paymentTokens: invalidTokens, - expectedPaymentTokenDecimals: 6 + expectedPaymentTokenDecimals: 6, + reduceCommitmentEnabled: false, + skipPreOpen: false }); TestableSettlementSale testSale = newUninitializedTestableSettlementSale(); @@ -195,7 +203,9 @@ contract SettlementSaleConstructorTest is BaseTest { claimRefundEnabled: true, maxWalletsPerEntity: 50, paymentTokens: duplicateTokens, - expectedPaymentTokenDecimals: 6 + expectedPaymentTokenDecimals: 6, + reduceCommitmentEnabled: false, + skipPreOpen: false }); TestableSettlementSale testSale = newUninitializedTestableSettlementSale(); @@ -218,7 +228,9 @@ contract SettlementSaleConstructorTest is BaseTest { claimRefundEnabled: true, maxWalletsPerEntity: 50, paymentTokens: emptyTokens, - expectedPaymentTokenDecimals: 6 + expectedPaymentTokenDecimals: 6, + reduceCommitmentEnabled: false, + skipPreOpen: false }); TestableSettlementSale testSale = newUninitializedTestableSettlementSale(); @@ -239,7 +251,9 @@ contract SettlementSaleConstructorTest is BaseTest { claimRefundEnabled: true, maxWalletsPerEntity: 50, paymentTokens: _defaultPaymentTokens(), - expectedPaymentTokenDecimals: 6 + expectedPaymentTokenDecimals: 6, + reduceCommitmentEnabled: false, + skipPreOpen: false }); TestableSettlementSale testSale = newUninitializedTestableSettlementSale(); @@ -260,7 +274,9 @@ contract SettlementSaleConstructorTest is BaseTest { claimRefundEnabled: true, maxWalletsPerEntity: 50, paymentTokens: _defaultPaymentTokens(), - expectedPaymentTokenDecimals: 6 + expectedPaymentTokenDecimals: 6, + reduceCommitmentEnabled: false, + skipPreOpen: false }); TestableSettlementSale testSale = newUninitializedTestableSettlementSale(); @@ -281,7 +297,9 @@ contract SettlementSaleConstructorTest is BaseTest { claimRefundEnabled: true, maxWalletsPerEntity: 50, paymentTokens: _defaultPaymentTokens(), - expectedPaymentTokenDecimals: 6 + expectedPaymentTokenDecimals: 6, + reduceCommitmentEnabled: false, + skipPreOpen: false }); TestableSettlementSale testSale = newUninitializedTestableSettlementSale(); @@ -302,13 +320,37 @@ contract SettlementSaleConstructorTest is BaseTest { claimRefundEnabled: true, maxWalletsPerEntity: 0, paymentTokens: _defaultPaymentTokens(), - expectedPaymentTokenDecimals: 6 + expectedPaymentTokenDecimals: 6, + reduceCommitmentEnabled: false, + skipPreOpen: false }); TestableSettlementSale testSale = newUninitializedTestableSettlementSale(); vm.expectRevert(abi.encodeWithSelector(SettlementSale.ZeroMaxWalletsPerEntity.selector)); testSale.initialize(init); } + + function testInitialize_WithSkipPreOpen_StartsInCommitment() public { + SettlementSale.Init memory init = SettlementSale.Init({ + saleUUID: TEST_SALE_UUID, + admin: admin, + extraManagers: defaultExtraManagers, + purchasePermitSigner: permitSigner.addr, + proceedsReceiver: receiver, + extraPausers: defaultPausers, + extraSettler: settler, + extraRefunder: refunder, + claimRefundEnabled: true, + maxWalletsPerEntity: 50, + paymentTokens: _defaultPaymentTokens(), + expectedPaymentTokenDecimals: 6, + reduceCommitmentEnabled: false, + skipPreOpen: true + }); + TestableSettlementSale testSale = newTestableSettlementSale(init); + + assertEq(uint8(testSale.stage()), uint8(SettlementSale.Stage.Commitment)); + } } contract SettlementSaleMaxWalletsTest is SettlementSaleBaseTest { @@ -409,48 +451,26 @@ contract SettlementSaleStageEventsTest is SettlementSaleBaseTest { sale.openCommitment(); } - function testCloseCommitment_EmitsStageChanged() public { - openCommitment(); - - vm.expectEmit(true, true, false, true); - emit SettlementSale.StageChanged(SettlementSale.Stage.Commitment, SettlementSale.Stage.Closed); - vm.prank(manager); - sale.closeCommitment(); - } - - function testOpenCommitment_FromClosed_EmitsStageChanged() public { - openCommitment(); - closeCommitment(); - - vm.expectEmit(true, true, false, true); - emit SettlementSale.StageChanged(SettlementSale.Stage.Closed, SettlementSale.Stage.Commitment); - vm.prank(manager); - sale.openCommitment(); - } - function testOpenCancellation_EmitsStageChanged() public { openCommitment(); - closeCommitment(); vm.expectEmit(true, true, false, true); - emit SettlementSale.StageChanged(SettlementSale.Stage.Closed, SettlementSale.Stage.Cancellation); + emit SettlementSale.StageChanged(SettlementSale.Stage.Commitment, SettlementSale.Stage.Cancellation); vm.prank(manager); sale.openCancellation(); } - function testOpenSettlement_FromClosed_EmitsStageChanged() public { + function testOpenSettlement_FromCommitment_EmitsStageChanged() public { openCommitment(); - closeCommitment(); vm.expectEmit(true, true, false, true); - emit SettlementSale.StageChanged(SettlementSale.Stage.Closed, SettlementSale.Stage.Settlement); + emit SettlementSale.StageChanged(SettlementSale.Stage.Commitment, SettlementSale.Stage.Settlement); vm.prank(manager); sale.openSettlement(); } function testOpenSettlement_FromCancellation_EmitsStageChanged() public { openCommitment(); - closeCommitment(); openCancellation(); vm.expectEmit(true, true, false, true); @@ -461,7 +481,6 @@ contract SettlementSaleStageEventsTest is SettlementSaleBaseTest { function testFinalizeSettlement_EmitsStageChanged() public { openCommitment(); - closeCommitment(); openSettlement(); vm.expectEmit(true, true, false, true); @@ -538,14 +557,6 @@ contract SettlementSaleVandalTest is SettlementSaleBaseTest { sale.openCancellation(); } - function testCloseCommitment_ByUnauthorizedUser_Reverts(address vandal) public { - vm.assume(vandal != admin); - vm.assume(vandal != manager); - vm.expectRevert(missingRoleError(vandal, sale.SALE_MANAGER_ROLE())); - vm.prank(vandal); - sale.closeCommitment(); - } - function testOpenSettlement_ByUnauthorizedUser_Reverts(address vandal) public { vm.assume(vandal != admin); vm.assume(vandal != manager); @@ -611,90 +622,41 @@ contract SettlementSaleStageTest is SettlementSaleBaseTest { assertEq(uint8(sale.stage()), uint8(SettlementSale.Stage.Commitment)); // Try to open commitment while in Commitment stage - vm.expectRevert( - encodeInvalidStage( - SettlementSale.Stage.Commitment, SettlementSale.Stage.PreOpen, SettlementSale.Stage.Closed - ) - ); + vm.expectRevert(encodeInvalidStage(SettlementSale.Stage.Commitment, SettlementSale.Stage.PreOpen)); vm.prank(manager); sale.openCommitment(); - // Try to open commitment phase while in Cancellation stage - closeCommitment(); + // Try to open commitment while in Cancellation stage openCancellation(); - vm.expectRevert( - encodeInvalidStage( - SettlementSale.Stage.Cancellation, SettlementSale.Stage.PreOpen, SettlementSale.Stage.Closed - ) - ); + vm.expectRevert(encodeInvalidStage(SettlementSale.Stage.Cancellation, SettlementSale.Stage.PreOpen)); vm.prank(manager); sale.openCommitment(); - // Try to open commitment phase while in Settlement stage + // Try to open commitment while in Settlement stage openSettlement(); - vm.expectRevert( - encodeInvalidStage( - SettlementSale.Stage.Settlement, SettlementSale.Stage.PreOpen, SettlementSale.Stage.Closed - ) - ); + vm.expectRevert(encodeInvalidStage(SettlementSale.Stage.Settlement, SettlementSale.Stage.PreOpen)); vm.prank(manager); sale.openCommitment(); - // Try to open commitment phase while in Done stage + // Try to open commitment while in Done stage finalizeSettlement(); - vm.expectRevert( - encodeInvalidStage(SettlementSale.Stage.Done, SettlementSale.Stage.PreOpen, SettlementSale.Stage.Closed) - ); + vm.expectRevert(encodeInvalidStage(SettlementSale.Stage.Done, SettlementSale.Stage.PreOpen)); vm.prank(manager); sale.openCommitment(); } - function testOpenCommitment_FromClosed_Succeeds() public { - openCommitment(); - closeCommitment(); - assertEq(uint8(sale.stage()), uint8(SettlementSale.Stage.Closed)); - - vm.prank(manager); - sale.openCommitment(); - assertEq(uint8(sale.stage()), uint8(SettlementSale.Stage.Commitment)); - } - - function testCloseCommitment_WhenNotCommitment_Reverts() public { - // Try to close while in PreOpen - vm.expectRevert(encodeInvalidStage(SettlementSale.Stage.PreOpen, SettlementSale.Stage.Commitment)); - vm.prank(manager); - sale.closeCommitment(); - } - - function testOpenCancellation_WhenNotClosed_Reverts() public { + function testOpenCancellation_WhenNotCommitment_Reverts() public { // Try to open cancellation while in PreOpen - vm.expectRevert(encodeInvalidStage(SettlementSale.Stage.PreOpen, SettlementSale.Stage.Closed)); - vm.prank(manager); - sale.openCancellation(); - - // Try to open cancellation while in Commitment - openCommitment(); - vm.expectRevert(encodeInvalidStage(SettlementSale.Stage.Commitment, SettlementSale.Stage.Closed)); - + vm.expectRevert(encodeInvalidStage(SettlementSale.Stage.PreOpen, SettlementSale.Stage.Commitment)); vm.prank(manager); sale.openCancellation(); } - function testOpenSettlement_WhenNotClosedOrCancellation_Reverts() public { + function testOpenSettlement_WhenNotCommitmentOrCancellation_Reverts() public { // Try while in PreOpen vm.expectRevert( encodeInvalidStage( - SettlementSale.Stage.PreOpen, SettlementSale.Stage.Closed, SettlementSale.Stage.Cancellation - ) - ); - vm.prank(manager); - sale.openSettlement(); - - // Try while in Commitment - openCommitment(); - vm.expectRevert( - encodeInvalidStage( - SettlementSale.Stage.Commitment, SettlementSale.Stage.Closed, SettlementSale.Stage.Cancellation + SettlementSale.Stage.PreOpen, SettlementSale.Stage.Commitment, SettlementSale.Stage.Cancellation ) ); vm.prank(manager); @@ -902,7 +864,6 @@ contract CommitmentDataReaderTest is SettlementSaleBaseTest { doBid(alice, usdc, 1000e6, 10); doBid(bob, usdt, 2000e6, 20); - closeCommitment(); openSettlement(); doSetAllocation(alice, usdc, 500e6); @@ -1091,7 +1052,6 @@ contract SettlementSaleViewFunctionsTest is SettlementSaleBaseTest { doBid({user: alice, amount: 5000e6, price: 10, token: usdc}); doBid({user: bob, amount: 3000e6, price: 10, token: usdt}); - closeCommitment(); openSettlement(); doSetAllocation(alice, usdc, 2000e6); doSetAllocation(bob, usdt, 1000e6); @@ -1169,7 +1129,6 @@ contract SettlementSaleViewFunctionsTest is SettlementSaleBaseTest { doBid({entityID: aliceID, user: wallet2, token: usdt, amount: 5000e6, price: 12}); // Test settlement with multiple wallets - closeCommitment(); openSettlement(); // Allocate 1500 USDC from wallet1 and 2000 USDT from wallet2 @@ -1241,7 +1200,9 @@ contract SettlementSaleSingleTokenTest is BaseTest { claimRefundEnabled: true, maxWalletsPerEntity: 50, paymentTokens: paymentTokens, - expectedPaymentTokenDecimals: 6 + expectedPaymentTokenDecimals: 6, + reduceCommitmentEnabled: false, + skipPreOpen: false }); TestableSettlementSale impl = new TestableSettlementSale(); sale = TestableSettlementSale(Clones.clone(address(impl))); @@ -1289,9 +1250,6 @@ contract SettlementSaleSingleTokenTest is BaseTest { assertEq(committed[0].amount, 2000e6); assertEq(sale.totalCommittedAmount(), 2000e6); - vm.prank(manager); - sale.closeCommitment(); - vm.prank(manager); sale.openSettlement(); @@ -1402,7 +1360,6 @@ contract SettlementSaleViewFunctionsCoverageTest is SettlementSaleBaseTest { assertEq(states[0].entityID, aliceID, "first state entityID should be alice"); assertEq(states[0].currentBid.amount, 1000e6, "alice bid amount should be 1000e6"); assertEq(states[0].currentBid.price, 10, "alice bid price should be 10"); - assertFalse(states[0].cancelled, "alice should not be cancelled"); assertFalse(states[0].refunded, "alice should not be refunded"); assertEq(states[1].entityID, bobID, "second state entityID should be bob"); @@ -1484,7 +1441,6 @@ contract EntityAllocationDataReaderTest is SettlementSaleBaseTest { openCommitment(); doBid(alice, usdc, 1000e6, 10); - closeCommitment(); openSettlement(); doSetAllocation(alice, usdc, 500e6); @@ -1506,7 +1462,6 @@ contract EntityAllocationDataReaderTest is SettlementSaleBaseTest { doBid(bob, usdt, 2000e6, 20); doBid(charlie, usdc, 3000e6, 30); - closeCommitment(); openSettlement(); doSetAllocation(alice, usdc, 500e6); doSetAllocation(bob, usdt, 1000e6); @@ -1543,7 +1498,6 @@ contract EntityAllocationDataReaderTest is SettlementSaleBaseTest { doBid(alice, usdc, 1000e6, 10); doBid(alice, usdt, 2000e6, 10); // Same entity, different token - closeCommitment(); openSettlement(); doSetAllocation(alice, usdc, 500e6); doSetAllocation(alice, usdt, 800e6); @@ -1586,7 +1540,6 @@ contract EntityAllocationDataReaderTest is SettlementSaleBaseTest { doBid(bob, usdt, 2000e6, 20); doBid(charlie, usdc, 3000e6, 30); - closeCommitment(); openSettlement(); doSetAllocation(alice, usdc, 500e6); doSetAllocation(bob, usdt, 1000e6); @@ -1627,7 +1580,6 @@ contract EntityAllocationDataReaderTest is SettlementSaleBaseTest { doBid(bob, usdt, 2000e6, 20); doBid(charlie, usdc, 3000e6, 30); - closeCommitment(); openSettlement(); doSetAllocation(alice, usdc, 500e6); doSetAllocation(bob, usdt, 1000e6); @@ -1670,7 +1622,6 @@ contract EntityAllocationDataReaderTest is SettlementSaleBaseTest { openCommitment(); doBid(alice, usdc, 1000e6, 10); - closeCommitment(); openSettlement(); // Set initial allocation @@ -1715,7 +1666,6 @@ contract EntityAllocationDataReaderTest is SettlementSaleBaseTest { // Alice bids from second wallet (same entity) doBid(aliceEntityID, alice2, usdt, 2000e6, 10); - closeCommitment(); openSettlement(); // Set allocations for both wallets diff --git a/test/Refund.t.sol b/test/Refund.t.sol index b0db809..f557cde 100644 --- a/test/Refund.t.sol +++ b/test/Refund.t.sol @@ -15,7 +15,6 @@ contract SettlementSaleRefundsTest is SettlementSaleBaseTest { doBid({user: bob, amount: 10000e6, price: 10, token: usdt}); doBid({user: charlie, amount: 10000e6, price: 10, token: usdt}); - closeCommitment(); openSettlement(); // alice committed 5k USDC -> allocated 2k USDC @@ -92,7 +91,6 @@ contract SettlementSaleRefundsTest is SettlementSaleBaseTest { } function testProcessRefunds_WrongStage_Reverts() public { - closeCommitment(); openSettlement(); bytes16[] memory entityIDs = new bytes16[](1); @@ -313,7 +311,6 @@ contract SettlementSaleRefundsTest is SettlementSaleBaseTest { // Alice bids additional 3000 USDT with her second wallet (bringing her total commitment to 5000 USD) doBid({entityID: aliceID, user: aliceWallet2, token: usdt, amount: 5000e6, price: 10}); - closeCommitment(); openSettlement(); // Set allocations per wallet/token diff --git a/test/Settlement.t.sol b/test/Settlement.t.sol index e11a208..7b860eb 100644 --- a/test/Settlement.t.sol +++ b/test/Settlement.t.sol @@ -67,7 +67,6 @@ contract SettlementSaleSettlementTest is SettlementSaleBaseTest { openCommitment(); doBid({user: alice, amount: 2000e6, price: 10, token: usdc}); - closeCommitment(); openSettlement(); setAllocationSuccess(alice, usdc, 2000e6, false); @@ -83,7 +82,6 @@ contract SettlementSaleSettlementTest is SettlementSaleBaseTest { doBid({user: alice, amount: 2000e6, price: 10, token: usdt}); doBid({user: alice, amount: 5000e6, price: 10, token: usdc}); - closeCommitment(); openSettlement(); // Alice committed 3000 USDC and 2000 USDT (total 5000, bid amount is 5000) @@ -103,7 +101,6 @@ contract SettlementSaleSettlementTest is SettlementSaleBaseTest { doBid({user: alice, amount: 3000e6, price: 10, token: usdc}); doBid({user: bob, amount: 5000e6, price: 10, token: usdt}); - closeCommitment(); openCancellation(); openSettlement(); @@ -123,7 +120,6 @@ contract SettlementSaleSettlementTest is SettlementSaleBaseTest { openCommitment(); doBid({user: alice, amount: 3000e6, price: 10, token: usdc}); - closeCommitment(); openCancellation(); openSettlement(); @@ -145,7 +141,6 @@ contract SettlementSaleSettlementTest is SettlementSaleBaseTest { doBid({user: alice, amount: 1000e6, price: 10, token: usdc}); doBid({user: alice, amount: 2000e6, price: 10, token: usdt}); - closeCommitment(); openCancellation(); openSettlement(); @@ -165,7 +160,6 @@ contract SettlementSaleSettlementTest is SettlementSaleBaseTest { openCommitment(); doBid({user: alice, amount: 1000e6, price: 10, token: usdc}); - closeCommitment(); openCancellation(); openSettlement(); @@ -183,7 +177,6 @@ contract SettlementSaleSettlementTest is SettlementSaleBaseTest { openCommitment(); doBid({user: alice, amount: 2000e6, price: 10, token: usdc}); - closeCommitment(); openCancellation(); openSettlement(); @@ -217,7 +210,6 @@ contract SettlementSaleSettlementTest is SettlementSaleBaseTest { err: encodeInvalidStage(SettlementSale.Stage.Commitment, SettlementSale.Stage.Settlement) }); - closeCommitment(); openCancellation(); openSettlement(); @@ -238,7 +230,6 @@ contract SettlementSaleSettlementTest is SettlementSaleBaseTest { openCommitment(); doBid({user: alice, amount: 1000e6, price: 10, token: usdc}); - closeCommitment(); openCancellation(); openSettlement(); @@ -256,7 +247,6 @@ contract SettlementSaleSettlementTest is SettlementSaleBaseTest { openCommitment(); doBid({user: alice, amount: 2000e6, price: 10, token: usdc}); - closeCommitment(); openCancellation(); openSettlement(); @@ -285,7 +275,6 @@ contract SettlementSaleSettlementTest is SettlementSaleBaseTest { openCommitment(); doBid({user: alice, amount: 2000e6, price: 10, token: usdc}); - closeCommitment(); openCancellation(); vm.prank(alice); @@ -301,25 +290,10 @@ contract SettlementSaleSettlementTest is SettlementSaleBaseTest { }); } - function testOpenSettlement_FromClosed_Success() public { - openCommitment(); - doBid({user: alice, amount: 1000e6, price: 10, token: usdc}); - - closeCommitment(); - assertEq(uint8(sale.stage()), uint8(SettlementSale.Stage.Closed)); - - // Can open settlement directly from Closed stage - vm.prank(admin); - sale.openSettlement(); - - assertEq(uint8(sale.stage()), uint8(SettlementSale.Stage.Settlement)); - } - function testSetAllocation_EmptyArray_Succeeds() public { openCommitment(); doBid({user: alice, amount: 1000e6, price: 10, token: usdc}); - closeCommitment(); openSettlement(); IOffchainSettlement.Allocation[] memory emptyAllocations = new IOffchainSettlement.Allocation[](0); @@ -336,7 +310,6 @@ contract SettlementSaleSettlementTest is SettlementSaleBaseTest { doBid({user: alice, amount: 1000e6, price: 10, token: usdc}); doBid({user: bob, amount: 2000e6, price: 15, token: usdt}); - closeCommitment(); openSettlement(); // Don't set any allocations, finalize with 0 @@ -352,7 +325,6 @@ contract SettlementSaleSettlementTest is SettlementSaleBaseTest { doBid({user: alice, amount: 1000e6, price: 10, token: usdc}); doBid({user: bob, amount: 2000e6, price: 15, token: usdt}); - closeCommitment(); openCancellation(); vm.prank(alice); @@ -380,7 +352,6 @@ contract SettlementSaleSettlementTest is SettlementSaleBaseTest { openCommitment(); doBid({user: alice, amount: 1000e6, price: 10, token: usdc}); - closeCommitment(); openSettlement(); // Try to set allocation for USDT (which alice never committed) @@ -397,7 +368,6 @@ contract SettlementSaleSettlementTest is SettlementSaleBaseTest { openCommitment(); doBid({user: alice, amount: 1000e6, price: 10, token: usdc}); - closeCommitment(); openSettlement(); // Setting 0 allocation for USDT should succeed (0 <= 0) @@ -417,7 +387,6 @@ contract SettlementSaleSettlementTest is SettlementSaleBaseTest { doBid({user: alice, amount: 1000e6, price: 10, token: usdc}); doBid({user: bob, amount: 2000e6, price: 10, token: usdc}); - closeCommitment(); openSettlement(); // Try to set allocation for Alice's entity but with Bob's wallet @@ -435,7 +404,6 @@ contract SettlementSaleSettlementTest is SettlementSaleBaseTest { openCommitment(); doBid({user: alice, amount: 1000e6, price: 10, token: usdc}); - closeCommitment(); openSettlement(); // Create a fake token that's not a valid payment token diff --git a/test/SettlementSaleBaseTest.t.sol b/test/SettlementSaleBaseTest.t.sol index 9fd6362..f38d8be 100644 --- a/test/SettlementSaleBaseTest.t.sol +++ b/test/SettlementSaleBaseTest.t.sol @@ -58,6 +58,20 @@ contract TestableSettlementSale is SettlementSale { function getEntityID(address addr) public view returns (bytes16) { return _entityIDByAddress[addr]; } + + function entityStateByID(bytes16 entityID) public view returns (EntityStateView memory) { + // deliberately not using the internal function so we're testing the public interface + bytes16[] memory entityIDs = new bytes16[](1); + entityIDs[0] = entityID; + return this.entityStatesByIDs(entityIDs)[0]; + } + + function walletStateByAddress(address addr) public view returns (WalletStateView memory) { + // deliberately not using the internal function so we're testing the public interface + address[] memory addrs = new address[](1); + addrs[0] = addr; + return this.walletStatesByAddresses(addrs)[0]; + } } /// @dev Helper to create a TestableSettlementSale using the clone pattern. @@ -121,6 +135,8 @@ contract SettlementSaleBaseTest is BaseTest { extraSettler: settler, extraRefunder: refunder, claimRefundEnabled: true, + reduceCommitmentEnabled: false, + skipPreOpen: false, maxWalletsPerEntity: 50, paymentTokens: paymentTokens, expectedPaymentTokenDecimals: 6 @@ -556,8 +572,9 @@ contract SettlementSaleBaseTest is BaseTest { ) internal pure { assertEq(a.addr, b.addr, string.concat(message, ": addr")); assertEq(a.entityID, b.entityID, string.concat(message, ": entityID")); - assertEq(a.acceptedAmountByToken, b.acceptedAmountByToken, string.concat(message, ": acceptedAmountByToken")); assertEq(a.committedAmountByToken, b.committedAmountByToken, string.concat(message, ": committedAmountByToken")); + assertEq(a.cancelledAmountByToken, b.cancelledAmountByToken, string.concat(message, ": cancelledAmountByToken")); + assertEq(a.acceptedAmountByToken, b.acceptedAmountByToken, string.concat(message, ": acceptedAmountByToken")); } function assertEq( @@ -588,11 +605,6 @@ contract SettlementSaleBaseTest is BaseTest { sale.openCommitment(); } - function closeCommitment() internal { - vm.prank(manager); - sale.closeCommitment(); - } - function openCancellation() public { vm.prank(admin); sale.openCancellation(); diff --git a/test/SettlementSaleFactory.t.sol b/test/SettlementSaleFactory.t.sol index c51523a..ae88df6 100644 --- a/test/SettlementSaleFactory.t.sol +++ b/test/SettlementSaleFactory.t.sol @@ -51,13 +51,15 @@ contract SettlementSaleFactoryTest is BaseTest { claimRefundEnabled: true, maxWalletsPerEntity: 50, paymentTokens: _makePaymentTokens(), - expectedPaymentTokenDecimals: 6 + expectedPaymentTokenDecimals: 6, + reduceCommitmentEnabled: false, + skipPreOpen: false }); } function testFactoryVersion() public view { (uint32 major, uint32 minor, uint32 patch) = factory.version(); - assertEq(major, 1); + assertEq(major, 2); assertEq(minor, 0); assertEq(patch, 0); } diff --git a/test/Withdraw.t.sol b/test/Withdraw.t.sol index 9788d36..54bafe3 100644 --- a/test/Withdraw.t.sol +++ b/test/Withdraw.t.sol @@ -13,7 +13,6 @@ contract SettlementSaleWithdrawTest is SettlementSaleBaseTest { doBid({user: bob, amount: 10000e6, price: 10, token: usdt}); doBid({user: charlie, amount: 10000e6, price: 10, token: usdt}); - closeCommitment(); openCancellation(); openSettlement();