Organization
- @coinbase
Engagement Type
Cantina Reviews
Period
-
Repositories
Researchers
Findings
Low Risk
4 findings
1 fixed
3 acknowledged
Informational
11 findings
10 fixed
1 acknowledged
Gas Optimizations
1 findings
0 fixed
1 acknowledged
Low Risk4 findings
Validation inconsistency between different proceedsReceiver setters
Description
There is an inconsistency in the validation of proceedsReceiver between the
constructorandsetProceedsReceiver(). ThesetProceedsReceiver()function properly validates that the new address is not address(0), but the constructor lacks this same validation check.This inconsistency allows proceedsReceiver to be initialized to address(0) during deployment. As a result, if proceedsReceiver is set to address(0) in the constructor, subsequent calls to
_withdrawPartial()will send tokens to address(0), permanently burning those funds.Recommendation
Consider either one of the following fixes:
- Add a zero address check in
_withdrawPartial():
function _withdrawPartial(uint256 amount) internal { if (proceedsReceiver == address(0)) { revert InvalidProceedsReceiver(); } // ... rest of function }- Add a zero address check in the constructor (Recommended)
- Add a zero address check in
Single token revert in _refund() blocks entire refund process
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Sujith S
Description
The
_refund()function iterates through all supported payment tokens and transfers each back to the user's wallet. If any single token transfer reverts—due to blocklisting (e.g., USDC/USDT), or other internal token failures—the entire transaction reverts, effectively locking the user out of all their refundable funds, including tokens that would otherwise transfer successfully.While
recoverTokens()exists as an admin escape hatch, using it only recovers the raw tokens without updating the user's commitment state. This leaves the protocol in an inconsistent state, with the user's records showing funds that no longer exist in the contract.Recommendation
There are multiple ways to handle this issue:
- Extend the
recoverTokens()function to adjust the internal state of the sale so that the state matches the overall refund. - Modify the
_refund()function to do partial refunds without marking thestate.refundedas valid, if the user is not fully refunded.
Alternatively, if you beleive the sonar onboarding will reduce the likelihood of user wallets getting blocked during the sale window, then add clear documentation about this risk of diversifying bids with different tokens.
Coinbase
Since the likelihood of this is extremely small and one can triage it in the worst case using recoverTokens, we don't implement dedicated logic for it for now. We'll think about including partial refunds in future iterations.
Strong assumption of 1:1 token value parity across multiple code paths
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Sujith S
Description
The protocol assumes all payment tokens (e.g., USDC, USDT, DAI) maintain equal USD value. While the constructor enforces matching decimals, it does not guarantee value parity. This assumption is embedded throughout the contract and could lead to accounting inconsistencies if a stablecoin depegs.
Affected Code Paths include:
- Bid Amount Delta Calculation: A user could bid 100 USDC, then increase to 150 using USDT. If USDT depegs to $0.95, the protocol treats a 50 USDT delta as 50 USDC.
uint256 amountDelta = newBid.amount - previousBid.amount;- Total Commitment Aggregation: Sums raw amounts across tokens without value weighting.
function totalCommittedAmount() external view returns (uint256) { return _sumByToken(_totalCommittedAmountByToken); }- Total Accepted Amount: Same issue as above—proceeds calculation assumes parity.
function totalAcceptedAmount() public view returns (uint256) { return _sumByToken(_totalAcceptedAmountByToken); }Other areas of the code include settlement, which also assumes the token's value remains constant during the Auction-to-Settlement phases.
Recommendation
- Document and Accept Risk
Clearly document this assumption and establish operational procedures to pause/remove depegged tokens:
/// @notice CRITICAL ASSUMPTION: All payment tokens must maintain 1:1 USD parity. /// If a token depegs, the sale should be paused.-
Add Token Removal Mechanism: Implement the ability to delist a payment token mid-sale.
-
Alternatively, consider developing an off-chain depeg watcher with capabilities to automatically pause the sale if a depeg happens.
Coinbase
This is communicated to projects. The header comment of the contract has been extended.
Extensive centralization risks create multiple single points of failure
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Sujith S
Description
SettlementSale concentrates significant authority in privileged roles. By default, DEFAULT_ADMIN_ROLE controls or can grant all critical roles. Users (both teams running their sales & bidders) must fully trust operator honesty and key security for the entire sale lifecycle.
- Roles with fund control:
Role Capability Impact DEFAULT_ADMIN_ROLE Grant/revoke roles, change proceedsReceiver, callunsafeSetStage, withdraw proceedsCan redirect or seize all funds TOKEN_RECOVERER_ROLE Transfer arbitrary ERC20 via recoverTokens()Can drain contract regardless of accounting or stage SETTLER_ROLE Set and overwrite allocations Can manipulate who receives tokens - Roles that can cause operational failures:
Role Capability Impact SALE_MANAGER_ROLE Open/close/reopen auction, skip cancellation, control refund settings, unpause Can manipulate timing and user options SETTLEMENT_FINALIZER_ROLE Finalize settlement ( Settlement → Done)Can block refunds/withdrawals indefinitely PAUSER_ROLE Pause user actions Can freeze bids, cancellations, and refunds PURCHASE_PERMIT_SIGNER_ROLE Issue permits with arbitrary constraints Can exclude users or impose unfair terms Key trust assumptions include:
- Allocations are computed offchain with no onchain verification.
- No settlement deadline; funds can be locked indefinitely.
- No user-controlled emergency exit.
- Payment tokens are immutable post-deploy.
- A single compromised admin can escalate privileges and drain funds.
Recommendation
- Multisig + Timelock all privileged roles, especially DEFAULT_ADMIN_ROLE, TOKEN_RECOVERER_ROLE, SETTLER_ROLE
- Add settlement deadline with automatic user exit if exceeded
- Restrict
recoverTokens()to non-payment tokens only and introduce a new privileged function to recover user tokens (eg blacklist impact) by modifying state accordingly. - Implement allocation verification via Merkle proofs or commit-reveal
- Default
claimRefundEnabled = trueto reduce refunder dependency
Coinbase
The centralisation is intentional and by design, since investors already place significant trust in the project to honour their purchases.
We recommend the use of a multisig (typically at least 3-of-5) for all projects launching with us. The emergency escape hatch functions, such as
unsafeSetStage()andrecoverTokens(), are intended as training wheels. In a future version, they may be modified to avoid interfering with internal bookkeeping or removed entirely. For now, we prefer to retain this additional safety net.
Informational11 findings
Missing events for multiple state-changing functions
Description
Several state-changing functions throughout the contract do not emit events upon successful execution. Events are essential for:
- Off-chain monitoring: Enables real-time tracking of protocol activity by indexers, dashboards, and alerting systems
- Transparency: Provides an immutable audit trail of all significant state changes
- Integration: Allows external services (e.g., The Graph, Dune Analytics) to index and query historical protocol data
- Incident response: Facilitates forensic analysis and debugging when issues arise
Function State Changed openAuction() manualStage → Auction closeAuction() manualStage → Closed reopenAuction() closeAuctionAtTimestamp, manualStage → Auction openCancellation() manualStage → Cancellation openSettlement() manualStage → Settlement finalizeSettlement() manualStage → Done setCloseAuctionAtTimestamp() closeAuctionAtTimestamp setProceedsReceiver() proceedsReceiver setClaimRefundEnabled() claimRefundEnabled pause() paused → true setPaused() paused unsafeSetStage() manualStage recoverTokens() Transfers tokens externally Recommendation
Consider adding events to all state-changing functions listed above.
Redundant virtual keyword in supportsInterface() function
Description
The supportsInterface function includes the virtual keyword. The virtual keyword indicates that a function can be overridden by derived classes.
However, SettlementSale is a final contract not designed to be inherited. Including virtual on a function in a non-inheritable contract serves no purpose and adds unnecessary bytecode.
Recommendation
Consider removing the virtual keyword.
Missing validation for closeAuctionAtTimestamp setters
Description
The closeAuctionAtTimestamp parameter determines when the auction automatically transitions from Auction to Closed stage. This value is set in three instances without validating that it is either 0 (disabled) or a future timestamp:
- Constructor:
closeAuctionAtTimestamp = init.closeAuctionAtTimestamp;- reopenAuction():
closeAuctionAtTimestamp = newCloseAuctionAtTimestamp;The NatSpec even acknowledges this: "NB: if the newCloseAuctionAtTimestamp is in the past, the auction will be closed again immediately." for the
reopenAuction()function.- setCloseAuctionAtTimestamp():
closeAuctionAtTimestamp = timestamp;Recommendation
Consider enforcing uniform validation between the three instances:
- In the constructor: allow setting either
0or a future timestamp. - In setCloseAuctionAtTimestamp: allow setting either
0or a current or future timestamp - In reopenAuction: allow setting a future timestamp, as it will be redundant if the function is invoked successfully without reopening an auction.
Coinbase
The automatic auction closing has been moved to timestamps on the permit.
Missing zero address validation for purchasePermitSigner in constructor
Description
The constructor grants PURCHASE_PERMIT_SIGNER_ROLE to
init.purchasePermitSignerwithout validating that it is not an address(0).While OpenZeppelin's ECDSA library includes native address(0) validation during signature recovery (preventing address(0) from ever being accepted as a valid signer), deploying with an invalid signer address would result in a non-functional sale where all bids revert with UnauthorizedSigner(address(0)).
Failing at deployment time is preferable to failing at runtime when users attempt to bid.
Recommendation
Add explicit zero address validation in the constructor:
+ if (init.purchasePermitSigner == address(0)) { + revert ZeroAddress(); + }Lack of stage restriction in setCloseAuctionAtTimestamp
Description
The
setCloseAuctionAtTimestamp()function can be called at any stage of the sale:function setCloseAuctionAtTimestamp(uint64 timestamp) external onlyRole(SALE_MANAGER_ROLE) { closeAuctionAtTimestamp = timestamp; }This parameter is only relevant during PreOpen (before the auction starts) and Auction (while bids are being accepted) stages.
Modifying it during Closed, Cancellation, Settlement, or Done stages serves no functional purpose since:
- The auction has already closed
- Stage transitions past Closed are controlled by manualStage, not the timestamp
- Could cause confusion in off-chain systems monitoring the contract state
Recommendation
Add stage restriction to limit when this function can be called:
- function setCloseAuctionAtTimestamp(uint64 timestamp) external onlyRole(SALE_MANAGER_ROLE) { + function setCloseAuctionAtTimestamp(uint64 timestamp) external onlyRole(SALE_MANAGER_ROLE) onlyStages(Stage.PreOpen, Stage.Auction) { closeAuctionAtTimestamp = timestamp; }Missing onchain enforcement of maxAddressesPerEntity limit
Description
The
_trackEntity()function documentation states that an entity can use multiple addresses "up to maxAddressesPerEntity". However, there is no maxAddressesPerEntity variable defined in the contract, and no limit is placed on the number of wallets an entity can associate with.Recommendation
If this limit is intended to be enforced by the off-chain permit signer, update the NatSpec comment accordingly. If on-chain enforcement was intended, consider adding it.
Unbounded iteration in view functions can cause DoS
Description
Multiple-view functions iterate over all wallets associated with an entity without an upper bound. Since maxAddressesPerEntity is documented but not enforced on-chain, an entity can accumulate an arbitrary number of wallet addresses, causing these functions to exceed gas limits and revert.
Functions that could be directly impacted include:
Function Iteration Pattern entityStateByID()wallets.lengthviawalletStatesByAddresses()readCommitmentDataAt()wallets.length × numTokensreadEntityAllocationDataAt()wallets.length × numTokensFunctions that are indirectly impacted include:
Function Iteration Pattern walletStatesByAddresses()addrs.length × numTokensentityStatesByIDs()entityIDs.length × wallets × tokensentityStatesIn()(to − from) × wallets × tokensreadCommitmentDataIn()(to − from) × wallets × tokensreadEntityAllocationDataIn()(to − from) × wallets × tokensWhile view functions called via RPC do not consume user gas, they remain subject to:
- Node gas limits - eth_call enforces gas limits
- On-chain integrations - Any external contract calling these functions via the ICommitmentDataReader or IEntityAllocationDataReader interfaces will revert, breaking composability
Recommendation
Consider documenting the behavior, if unbounded wallets per entity is a requirement.
Purchase permit signatures lack EIP-712 domain separator
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Sujith S
Description
The PurchasePermitV2Lib in
PurchasePermitV2.soluses a simple eth_sign style message hash without an EIP-712 domain separator.Though the SaleUUID being unique for a sale protects the protocol against cross-chain replay attacks and same-chain cross-contract replay attacks, it is generally a good practice to include the
chain_idandcontract_addressin the signature.Recommendation
Consider enhancing the permit signature process by implementing EIP-712 structured data signing with a domain separator.
Coinbase
Since we already have strong replay protection with the UUID, we prefer to keep eth_sign over EIP-712 for simplicity, since the backend system does not know the contract address in every case. A comment about this will be added.
Error message inconsistency
Description
The custom errors used in SettlementSale.sol have several consistency and completeness issues:
-
Inconsistent parameter naming: Errors use different conventions — got/want (InvalidSaleUUID),
expected/actual (UnexpectedTotalAcceptedAmount), and new/previous (BidAmountCannotBeLowered). -
Inconsistent token type: Some errors use address (InvalidPaymentToken), others use IERC20
(WithdrawalExceedsAvailable, AllocationExceedsCommitment). -
Missing context in key errors: InvalidStage(Stage) — only shows current stage, not what was expected, and PurchasePermitExpired() — no timestamps for debugging
Recommendation
Consider fixing the inconsistencies in the error messages mentioned above.
-
Inconsistent event parameters
Description
Events in SettlementSale.sol have naming and type inconsistencies:
- Inconsistent wallet/address naming: Some events use addr, others use wallet for the same concept.
- Token type inconsistency: Events use an IERC20 indexed token, but indexed interface types log the address
anyway. Using an address would be more explicit and consistent with errors.
Recommendation
Consider enforcing consistent naming & type pattern:
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 AllocationSet(bytes16 indexed entityID, address indexed wallet, address indexed token, uint256 acceptedAmount); event WalletRefunded(bytes16 indexed entityID, address indexed wallet, address indexed token, uint256 amount);event ProceedsWithdrawn(address indexed receiver, address indexed token, uint256 amount);SettlementSale contract could be deployed as clones
State
Severity
- Severity: Informational
Submitted by
Sujith S
Description
The
SettlementSale.solcontract currently uses a constructor-based initialization pattern. While this approach works for standard contract deployments, it is incompatible with gas-efficient clone-factory patterns, such as EIP-1167 minimal proxies.When deploying contracts via Clones.clone() or Clones.cloneDeterministic(), the implementation contract's constructor is not executed for cloned instances. Instead, clones require an explicit initialize() function that can be called post-deployment to set up contract state.
Given that the platform intends to deploy many instances of this sale contract, the current architecture would require full contract deployment for each sale, resulting in significantly higher gas costs than minimal proxy deployments.
Recommendation
Consider refactoring the contract to use OpenZeppelin's Initializable pattern alongside the existing constructor:
- Inherit from @openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol
- Convert the constructor logic into an initialize() function with the initializer modifier
- Convert immutable variables (e.g., SALE_UUID) to regular state variables set during initialization
Cantina
The fixes look good, but they introduce additional risk assumptions, such as that saleUUID may be modified by an upgrade. In the earlier code versions, it was
immutableand could not be updated. However, the saleUUID not being immutable is not an issue if the proxy isn't upgradeable (e.g., for simple clones).
Gas Optimizations1 finding
Unnecessary struct wrapper for single boolean in PurchasePermitPayload
State
- Acknowledged
Severity
- Severity: Gas optimization
Submitted by
Sujith S
Description
The
PurchasePermitPayloadstruct wraps a single boolean field:struct PurchasePermitPayload { bool forcedLockup; }This struct is decoded in
_processBid():PurchasePermitPayload memory payload = abi.decode(purchasePermit.payload, (PurchasePermitPayload)); if (payload.forcedLockup && !newBid.lockup) { revert BidMustHaveLockup(); }Using a struct to wrap a single boolean introduces unnecessary overhead:
- Additional gas cost for struct ABI encoding/decoding vs. raw boolean
- Extra memory allocation for the struct
- Increased code complexity
Recommendation
Consider encoding and decoding the boolean directly.
Coinbase
We prefer keeping this for now, since it's instructive for projects that want to implement their own custom contracts.