Echo
Cantina Security Report
Organization
- @echo
Engagement Type
Cantina Reviews
Period
-
Researchers
Findings
Medium Risk
1 findings
0 fixed
1 acknowledged
Informational
4 findings
3 fixed
1 acknowledged
Medium Risk1 finding
Zero delta bid can be used to keep auction running
State
- Acknowledged
PR #1
Severity
- Severity: Medium
≈
Likelihood: Medium×
Impact: Medium Submitted by
rvierdiiev
Description
In case the last bid was made more than
auctionInactivityDurationago, the auction is considered closed:if ( auctionInactivityDuration > 0 && lastBidTimestamp > 0 && block.timestamp >= lastBidTimestamp + auctionInactivityDuration) { return Stage.Closed;}Each new bid updates
lastBidTimestampwith the current block timestamp. Currently, users are allowed to place a bid with the same price and amount as the previous one. Such a bid does not revert and still updateslastBidTimestamp, effectively keeping the auction open indefinitely without increasing the bid — resulting in a zero-delta bid scenario.POC
contract GriefingPoCTest is MegaSaleTest { // PoC: a bidder can keep the auction from auto-closing by repeatedly rebidding // with identical price/amount/lockup, which refreshes lastBidTimestamp each time. function testZeroDeltaRebidsKeepAuctionOpen() public { openAuction(); uint64 price = 10; uint64 amount = SALE_MIN_AMOUNT; // satisfies min amount // initial bid doBid({user: alice, price: price, amount: amount, lockup: false}); assertEq(uint8(sale.stage()), uint8(MegaSale.Stage.Auction)); uint64 inactivity = sale.auctionInactivityDuration(); // Repeat near the inactivity boundary to keep extending auction indefinitely for (uint256 i = 0; i < 8; i++) { // move time to just before auto-close threshold from the current last bid vm.warp(sale.lastBidTimestamp() + inactivity - 1); // if no activity happened now, auction would close in 1 second assertEq(uint8(sale.stage()), uint8(MegaSale.Stage.Auction)); // zero-delta re-bid (same price, same amount, same lockup) doBid({user: alice, price: price, amount: amount, lockup: false}); // lastBidTimestamp refreshed; auction remains open assertEq(sale.lastBidTimestamp(), block.timestamp); assertEq(uint8(sale.stage()), uint8(MegaSale.Stage.Auction)); } // Once the bidder stops rebidding, the auction auto-closes after the inactivity window vm.warp(sale.lastBidTimestamp() + inactivity); assertEq(uint8(sale.stage()), uint8(MegaSale.Stage.Closed)); }}Recommendation
Allow new bids only if either the price or amount is increased compared to the previous bid.
Informational4 findings
Unused Fixed-Precision Types and Permit Allocation Struct
Description
The codebase includes fixed-precision amount types and a permit allocation struct that are not referenced by any production contracts under src/ or by tests:
Fixed6andFixed6Libin lib/sales/Fixed6.sol, andPurchasePermitWithAllocationFixed6inlib/sales/permits/PermitAllocation.sol. Additionally, the associated ECDSA helper functionsdigestandrecoverSignerinPermitAllocation.solare unused. Keeping unreferenced arithmetic and cryptographic code increases maintenance overhead and security risk by broadening the potential attack surface, complicating reviews, and inviting accidental future integration without proper scrutiny or test coverage.Recommendation
Remove the unused files and symbols to reduce surface area: delete
lib/sales/Fixed6.solandlib/sales/permits/PermitAllocation.sol, including thedigestandrecoverSignerfunctions. If this functionality is needed later or for external consumers, reintroduce it in a separate package with tests and explicit integration points.Unused DepositPlaced Event and Missing Withdraw Event Emission
Description
The
DepositPlaced(bytes16 indexed entityID, uint256 amount)event is declared inMegaSale.solbut never emitted. In addition,withdraw() (external, onlyStage(Stage.Done))does not emit an event. Emitting these events would improve on‑chain management and operational visibility; the project documentation indicates withdrawals should be logged, so adding a dedicated withdrawal event aligns code with documented expectations.Recommendation
Remove
DepositPlacedif deposits are not intended to be tracked, or emit it where deposits occur. Add and emit a Withdrawn (or similar) event fromwithdraw()to support on‑chain management and to match the documentation.Bids without lockup that are created before forcedLockup is set may stay unlocked
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
rvierdiiev
Description
If
forcedLockupis set totrue, then all new bids should have lockup.if (payload.forcedLockup && !newBid.lockup) { revert BidMustHaveLockup();}However, it’s possible that
forcedLockupis enabled after some bids have already been placed withlockup == false. If those users don’t rebid, their unlocked bids will remain valid and can still win allocations — violating the intended forced lockup rule.Recommendation
If
forcedLockupis enabled, ensure that existing bids without lockup are filtered out during the winner selection process and assigned zero allocation. Otherwise, if such behavior is supported, document it.Non-decrementing totalActiveBidAmount (docs clarification)
Description
totalActiveBidAmountis intentionally monotonic and only increases on bids, while refunds are tracked separately via totalRefundedAmount. Off-chain consumers must compute outstanding exposure astotalActiveBidAmount - totalRefundedAmount. Previous documentation implied thattotalActiveBidAmount would decrease as refunds were processed, which can mislead off-chain analytics if they read the raw counter directly.Recommendation
Document that outstanding exposure must be computed as totalActiveBidAmount - totalRefundedAmount and that totalActiveBidAmount does not decrement on refund/cancel. Optionally add a view helper (for example, outstandingActiveBidAmount()) returning the derived value to reduce integration mistakes, communicate this to integrators.