Organization
- @coinbase
Engagement Type
Cantina Reviews
Period
-
Repositories
Findings
Low Risk
6 findings
2 fixed
4 acknowledged
Informational
7 findings
1 fixed
6 acknowledged
Gas Optimizations
2 findings
0 fixed
2 acknowledged
Low Risk6 findings
A single reverting token transfer in cancelBid() permanently blocks an entity from cancelling
Description
cancelBid()processes all wallet/token pairs for an entity atomically in a single transaction. For each non-zero pair it calls_reduceCommitment(), which performs asafeTransferto the wallet. If any single transfer reverts, the entirecancelBid()transaction reverts, leaving all committed funds untouched and the entity unable to cancel.A realistic trigger is a USDC or USDT address blacklist. If any wallet associated with an entity is blacklisted by the token issuer between commitment and cancellation stages,
safeTransferto that wallet will revert. BecausecancelBid() has no mechanism to skip or isolate a failing pair, the entity is permanently unable to exercise full cancellation.With
reduceCommitmentEnabled = false(the default), there is no alternative user-facing path. WithreduceCommitmentEnabled = true, the entity can usereduceCommitment()to recover individual pairs that are not blocked, but the blacklisted wallet/token pair remains unrecoverable through user-facing functions.The only admin recourse is
recoverTokens(), which requires theTOKEN_RECOVERER_ROLEand transfers tokens to an arbitrarytoaddress with no knowledge of the original committed wallet or amount. Critically,recoverTokens()performs a rawsafeTransferwith no state updates. After the call,walletState.committedAmountByToken,_totalCommittedAmountByToken, andstate.currentBid.amountall still reflect the original committed amount. The entity is left in a permanently inconsistent state: the tokens are gone from the contract but the accounting still records them as committed. Admin intervention viarecoverTokens()fixes the stuck funds but corrupts the accounting, i.e. there is no clean recovery path.Note that
_refund()in theDonestage has been acknowledged as having this same structural issue.Recommendation
Consider a dedicated admin function that refunds a specific wallet/token pair and correctly updates all associated accounting state to provide a clean and auditable recovery path that
recoverTokens()cannot offer.Coinbase
We added an admin function
forceReduceCommitmentgated byCOMMITMENT_REDUCER_ROLEthat performs a targeted refund with proper state updates. UnlikerecoverTokens(), this function correctly decrementscommittedAmountByToken,_totalCommittedAmountByToken, andcurrentBid.amount.Cantina
Fixed as recommended.
Refund events not emitted for Cancellation stage refunds may lead to undercounting
State
- Acknowledged
Severity
- Severity: Low
Submitted by
0xRajeev
Description
The contract emits
WalletRefundedandEntityRefundedexclusively from_refund(), which is only reachable via theDonestage throughprocessRefunds()orclaimRefund(). These events were previously the canonical signal that funds had been returned to an entity.Prior to this PR,
cancelBid()called_refund()internally, so a cancellation during theCancellationstage also producedWalletRefundedandEntityRefundedevents. This PR refactorscancelBid()to call_reduceCommitment()directly, which only emitsCommitmentReduced. As a result, an entity that fully cancels during theCancellationstage triggers noWalletRefundedorEntityRefundedevents at any point in the sale lifecycle, including later in theDonestage, where_refund()is blocked bystate.refunded = true.Any offchain system that tracks funds returned to users by indexing
WalletRefundedandEntityRefundedevents will silently undercount. The gap is not detectable from those events alone . A fully-cancelled entity simply produces no refund events, which is indistinguishable from an entity that has not yet been processed. The total funds returned to users now requires combining two disjoint event sets:CommitmentReducedevents (Cancellationstage) andWalletRefundedevents (Donestage). Neither event set alone gives a complete picture.This also affects
totalRefundedAmount(), which only reflectsDonestage refunds and does not account forCancellationstage cancellations. The true total of funds returned to users istotalRefundedAmount()+totalCancelledAmount(), but no combined view is exposed.Recommendation
Consider:
- Emitting
WalletRefundedandEntityRefundedfromcancelBid()alongsideCommitmentReduced, preserving the existing event-based accounting contract for offchain systems. Alternatively, - Exposing a combined view function that sums
totalRefundedAmount()andtotalCancelledAmount()so that the total funds returned to users is queryable without event reconstruction.
Coinbase
CommitmentReducedandWalletRefunded/EntityRefundedrepresent semantically distinct operations (voluntary cancellation vs. post-settlement refund of unallocated amounts) and we prefer to keep them separate. Offchain systems should index both event types to compute total funds returned. Unifying the events would also make the distinction betweentotalRefundedAmount()andtotalCancelledAmount()inconsistent.Cantina
Acknowledged.
setReduceCommitmentEnabled() timing can selectively deny partial cancellations
State
- Acknowledged
Severity
- Severity: Low
Submitted by
0xRajeev
Description
setReduceCommitmentEnabled()is aSALE_MANAGER_ROLE-controlled toggle that can be flipped on or off at any time, including during the activeCancellationstage. The function carries no stage restriction and no time-lock.reduceCommitment(), the partial reduction path gates entirely on this flag. By contrast,cancelBid()(full cancellation) is unconditionally available duringCancellationregardless of the flag.The design introduces two fairness concerns:
- Selective access window. The manager can enable partial reductions for a narrow window, then disable the toggle. Participants who are not actively monitoring onchain state or who have not been pre-informed may miss the window entirely, while others benefit from it.
- Race between toggle-off and
openSettlement(). TheCancellationstage exists specifically to let participants react to preliminary allocation information communicated offchain. Because the manager can disable partial reductions and immediately callopenSettlement(), a participant who receives their preliminary allocation and decides to partially exit can be front-run by these two manager actions before they can transact, locking them into a larger position than intended.
However, the worst case is bounded: full cancellation via
cancelBid()always remains available, so participants can never be forced to accept an unwanted allocation outright. The concern is specifically with partial reductions, which allow an entity to stay in the sale at a reduced size rather than exit entirely.Recommendation
Consider one or more of the following mitigations:
- Restrict the toggle to pre-
Cancellation. Only allowsetReduceCommitmentEnabled()to be called before theCancellationstage opens. This makes the policy known and fixed before participants need to act on it. - Allow the toggle to be enabled only once. Once
reduceCommitmentEnabledis set to true, prevent it from being disabled again. This ensures that once the window opens, all participants have equal access to partial reductions for the remainder of theCancellationstage. - Announce in advance. If the toggle must remain flexible, document and enforce offchain that any state change will be announced with a minimum notice period before taking effect.
- Remove the toggle entirely. If partial reductions are intended to be a standard feature of the
Cancellationstage, always allow them and remove the flag. This eliminates the asymmetry between full and partial cancellation.
Coinbase
The fundamental trust assumption of the sale lies with the owner/admin, who already has the authority to skip the
Cancellationstage entirely and set allocations directly. Partial cancellation toggle manipulation is strictly less impactful than these existing trusted capabilities.Cantina
Acknowledged.
init.admin granted all operational roles by default collapses privilege separation into a single point of failure
State
- Acknowledged
Severity
- Severity: Low
Submitted by
0xRajeev
Description
During initialization,
init.adminis unconditionally granted every operational role in the contract:DEFAULT_ADMIN_ROLE: grants/revokes roles, withdraws proceeds, sets proceeds receiver, force-sets stage viaunsafeSetStage()SALE_MANAGER_ROLE: controls stage transitions, toggles, and operational parametersSETTLER_ROLE: sets allocations for all entitiesSETTLEMENT_FINALIZER_ROLE: finalizes settlement and moves sale toDonePAUSER_ROLE: pauses the saleREFUNDER_ROLE: processes refunds for any entity
The comment at line 491 explicitly frames this as intentional:
// the admin should have all operational roles by default. The default state therefore concentrates all authority in a single address, which is risky.Example Scenario: A sale is deployed with a single
init.adminkey managed by the sale operator. During theCancellationstage, while participants are reviewing their preliminary allocations, the admin key is compromised. The attacker then executes the following steps:- Calls
setProceedsReceiver()to redirect proceeds to an attacker-controlled address - Calls
unsafeSetStage(Stage.Settlement), immediately ending theCancellationstage - Calls
setAllocations()to set every participant's allocation equal to their full committed amount, maximizing proceeds and leaving nothing for refunds - Calls
finalizeSettlement()to lock in the allocations and move toDone - Calls
withdraw()to drain all committed proceeds to the attacker-controlled address
Recommendation
Consider the following mitigations:
- Enforce role separation at initialization. Require distinct addresses for operationally independent roles, specifically
SETTLER_ROLE,SETTLEMENT_FINALIZER_ROLE, andSALE_MANAGER_ROLE, rather than defaulting all toinit.admin. This forces the deployer to make an explicit decision about trust boundaries. - Restrict
DEFAULT_ADMIN_ROLEto role administration only. TheDEFAULT_ADMIN_ROLEholder currently also controls proceeds withdrawal andunsafeSetStage(). These capabilities should be gated on separate roles so that the role administrator cannot unilaterally influence sale outcomes. - Renounce or timelock
DEFAULT_ADMIN_ROLEafter initialization. Since a sale has a fixed lifecycle,DEFAULT_ADMIN_ROLEshould be renounced post-deployment if role membership is static. If key rotation must remain possible, transfer it to a timelock so any role change is subject to a mandatory delay, giving participants time to observe and exit. - Gate
unsafeSetStage()behind a multisig and timelock. This function is the most dangerous capability in the contract as it allows any stage constraint to be circumvented by a single key.
Coinbase
Granting all roles to
init.adminat initialization is intentional for deployment simplicity. It's up to sale owners to define their own operational security and redistribute roles accordingly post-initialization. The contract provides the full role-based access control machinery via OpenZeppelin'sAccessControlEnumerablefor owners to configure as they see fit. The initialization state is transient and doesn't represent the operational role distribution.Cantina
Acknowledged.
_reduceCommitment() may refund accepted token amounts
Description
Before PR #9,
cancelBid()called_refund(), which computed the refund ascommittedAmountByToken - acceptedAmountByToken. Even ifunsafeSetStage()moved the sale backward to Cancellation from Settlement or Done,_refund()would only return the unallocated portion. The accepted amount was protected.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 refundAmount = walletState.committedAmountByToken[token] - walletState.acceptedAmountByToken[token]; // nothing to refund if (refundAmount == 0) { continue; }After PR #9,
cancelBid()calls_reduceCommitment(), which refunds the fullcommittedAmountByTokenwith no awareness ofacceptedAmountByToken. IfunsafeSetStage()moves the sale back to Cancellation after allocations have been set in Settlement, an entity can callcancelBid()and recover their full committed amount, including the portion already marked as accepted proceeds.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] ); }Example scenario:
- Entity committed 5000 USDC.
SettlementsetsacceptedAmountByToken= 3000. - Admin calls
unsafeSetStage(Cancellation)for an emergency correction. - Entity calls
cancelBid()._reduceCommitment()transfers 5000 USDC back to the entity. - 3000 USDC that was designated as sale proceeds is drained. The contract's token balance no longer covers its obligations.
The precondition is DEFAULT_ADMIN_ROLE calling
unsafeSetStage(), which is documented as exceptional. But the old code handled this safely as a byproduct of_refund()design. The new code is a regression in that safety property.Recommendation
It is recommended to consider the
acceptedAmountByTokenwhen processing refunds in_reduceCommitment().Coinbase
We added a defensive check in
_reduceCommitment()that caps the refundable amount atcommittedAmountByToken - acceptedAmountByToken.Cantina
Fixed as recommended.
- Entity committed 5000 USDC.
replaceBidWithPermit() may fail due to amountDelta being unexpected
State
- Acknowledged
Severity
- Severity: Low
Submitted by
cccz
Description
replaceBidWithPermit()usesamountDeltaasvalueofptoken.permit().function replaceBidWithPermit( IERC20 token, Bid calldata bid, PurchasePermitV3 calldata purchasePermit, bytes calldata purchasePermitSignature, uint256 erc20PermitDeadline, bytes calldata erc20PermitSignature ) external onlyStage(Stage.Commitment) onlyUnpaused { uint256 amountDelta = _processBid(token, bid, purchasePermit, purchasePermitSignature); if (amountDelta > 0) { // Permit signatures can be grabbed from the mempool, allowing attackers to execute them before the actual bid is placed, // which will cause the call to `ptoken.permit` to revert. // The sale contract should be able to handle this gracefully and not revert when the bid transaction is eventually included. // To do this, we wrap the call to `ptoken.permit` in a try-catch block and ignore the revert. This will also ignore any other errors, // which is fine because this method effectively just becomes equivalent to `replaceBidWithApproval`. IERC20Permit ptoken = IERC20Permit(address(token)); try ptoken.permit({ owner: msg.sender, spender: address(this), value: amountDelta, deadline: erc20PermitDeadline, r: bytes32(erc20PermitSignature[0:32]), s: bytes32(erc20PermitSignature[32:64]), v: uint8(bytes1(erc20PermitSignature[64])) }) {} catch {} token.safeTransferFrom(msg.sender, address(this), amountDelta); }It is worth noting that if another wallet of the same entity frontruns the transaction (for example, by slightly increasing
currentBid.amount),amountDeltawill not be as expected.Since the
valuecontained in theerc20PermitSignatureis already determined, this will causeptoken.permit()to fail, andreplaceBidWithPermit()will fail due to insufficient approval.Recommendation
It is recommended to allow users to provide the
erc20PermitValueparameter inreplaceBidWithPermit()instead of using theamountDelta, which may change.Or just document the issue to inform users.
Coinbase
The scenario requires two wallets of the same entity submitting bids concurrently, which is uncommon in practice. No funds are at risk as the transaction reverts cleanly. Users encountering this can retry or use
replaceBidWithApproval(), which is not subject to this constraint. Adding a separateerc20PermitValueparameter would be a breaking change to the interface, which we'd like to avoid at this stage.Cantina
Acknowledged.
Informational7 findings
cancelBid() not setting state.refunded at entry diverges from _refund() pattern
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
0xRajeev
Description
cancelBid()and_refund()both iterate over all wallet/token pairs and transfer committed funds back to the entity. However, they handle thestate.refundedflag differently:_refund()setsstate.refunded = trueimmediately at entry, before any loop or transfer.cancelBid()never setsstate.refunded = truedirectly. It relies on_reduceCommitment()to set the flag, which only does so whenstate.currentBid.amountreaches zero in the last iteration of the loop.
For entities with a single wallet and a single payment token, there is only one iteration and so
state.refundedis set before the transfer and the behavior is equivalent. For entities with multiple wallets or tokens,state.refundedremains false across all but the final transfer. This is an inconsistency in how two structurally identical functions handle the same guard, and deviates from the established safe pattern in the codebase.In the current deployment configuration, where payment tokens are standard stablecoins with no transfer callbacks, there is no exploitable path. The risk is latent: if a payment token with a transfer callback is ever added, the unprotected window between the entry guard check and the final
state.refunded = truewrite would be active across all intermediate transfers, allowing a reentrant call to pass theAlreadyRefundedguard.Recommendation
Consider setting
state.refunded = trueat the entry ofcancelBid(), before the transfer loop, matching the pattern already established in_refund(). Theif (state.currentBid.amount == 0) { state.refunded = true; }check inside_reduceCommitment()can remain as-is because it is still needed for thereduceCommitment()path, where an entity that fully reduces its commitment via that function must also be marked as refunded.Coinbase
On the pattern consistency point: we considered setting
state.refunded = trueat the entry ofcancelBid(), as_refund()does. We chose not to because_reduceCommitment()is shared by bothcancelBid()andreduceCommitment(), and already sets the flag when the full commitment reaches zero. Adding another write incancelBid()would spread responsibility for this flag across multiple call sites rather than keeping it in one place. We prefer_reduceCommitment()as the single authoritative location. It also preserves thatcancelBid()andreduceCommitment(allCommitments)are functionally equivalent, which would break otherwise.The latent reentrancy concern is mitigated by the check-effects-interactions pattern already upheld within
_reduceCommitment(): committed amounts are decremented before each transfer, so any reentrant invocation ofcancelBid()would find zero for already-processed pairs and skip them. No double-spending or misappropriation of funds is possible under current token configurations or with callback tokens.Cantina
Acknowledged.
purchasePermit.minAmount from the purchase permit is not enforced after the Commitment stage
Description
During the
Commitmentstage,_processBid()enforces thatnewBid.amount >= purchasePermit.minAmount, ensuring every entity's committed amount meets the minimum participation threshold specified in their purchase permit. This constraint is issued offchain by Sonar and validated onchain at bid time.minAmountis not stored onchain after the bid is accepted. It exists only in the purchase permit, which is consumed at bid submission and not referenced again. As a result, once the sale moves to theCancellationstage, there is nothing preventing an entity from usingreduceCommitment()to bring theircurrentBid.amountbelow theminAmountthat was enforced when they committed.Example scenario:
- A purchase permit specifies
minAmount= 5000e6. - The entity commits 8000e6, which passes the bid check.
- During the
Cancellationstage, the entity callsreduceCommitment()with 4000e6, reducingcurrentBid.amountto 4000e6, a value that would have been rejected at bid time. The contract accepts this without error.
If
minAmountis aCommitment-stage constraint and theCancellationstage is explicitly designed to allow entities to reduce their exposure then it is an implicit assumption that should be documented clearly. The offchain settlement system, which computes allocations based on remaining committed amounts, must be aware that entities can hold commitments below their originalminAmountat settlement time and handle that case explicitly. If the offchain system assumes all remaining commitments are aboveminAmount, for example, to apply a minimum allocation floor then it will produce incorrect results for partially-reduced entities.Recommendation
Consider:
- Documenting explicitly that
minAmountis enforced only at bid submission time and does not represent a floor on the committed amount through the rest of the sale lifecycle. - Having the offchain settlement system treat
currentBid.amountas the authoritative committed amount at settlement time regardless of the originalminAmount, and not assume any minimum floor on remaining commitments when computing allocations.
Coinbase
This is intentional behaviour. The
minAmountis mainly a commitment-stage gate, limiting the number of participants by enforcing a minimum participation threshold when entities enter the sale. This isn't relevant in theCancellationstage, so we allow entities to reduce their commitment by any amount for simplicity. We added natspec toreduceCommitment()and_processBid()clarifying thatminAmountis enforced only at bid submission and doesn't constrain subsequent reductions.Regarding the offchain settlement concern: the settlement system already computes allocations solely from the
currentBid.amountvalues read from the contract at settlement time. It doesn't reference or assume any relationship to the originalminAmountfrom the purchase permit. If a post-reduction minimum is needed, the settlement system can handle it by opting not to grant any allocation to entities that reduce their commitment below some minimum communicated off-chain.Cantina
Acknowledged.
- A purchase permit specifies
setAllocations() reverts if any fully-cancelled entity is included in the batch
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
0xRajeev
Description
_setAllocation()checksentityState.refundedand reverts withAlreadyRefundedif the flag is set. Prior to this change,refundedwas only set during theDonestage via_refund(), so an entity entering theSettlementstage was guaranteed to haverefunded = false. The separatecancelledflag trackedCancellationstage exits.This change removes the
cancelledflag and repurposesrefundedto cover both outcomes: an entity that fully cancels during theCancellationstage now hasrefunded = truebeforeSettlementbegins. The_setAllocation()check, which was previously safe to apply to all entities enteringSettlement, now rejects any entity that cancelled.The consequence is that
setAllocations(), which the settler calls in batches covering all participating entities, will revert if even a single cancelled entity is included in a batch. The settler's offchain system presumably computes allocations from commitment data and constructs these batches. Previously it could include all entities without concern; now it must independently track which entities cancelled during theCancellationstage and filter them out before constructing each batch. Sincecancelledno longer exists as a distinct onchain flag, the settler must derive this from event history (CommitmentReducedevents where the entity'scurrentBid.amountreached zero) rather than from a simple state read.Example scenario: 500 entities participate in a sale. During the
Cancellationstage, 50 entities fully cancel. The settler's offchain system, unaware of the semantic change torefunded, constructs allocation batches from all 500 entities. The first batch containing any of the 50 cancelled entities reverts entirely. The settler must identify all cancelled entities from event history, reconstruct the batches excluding them, and resubmit while the sale is in theSettlementstage awaiting finalization.Recommendation
Consider:
- Restoring the
cancelledfield onEntityState, set whencurrentBid.amountreaches zero during theCancellationstage, allowing the settler to distinguish cancelled entities from those requiring allocation via a simple state read rather than event reconstruction. Alternatively, - Having
_setAllocation()skip rather than revert for fully-cancelled entities, with an accompanying event, so that a batch containing cancelled entities degrades gracefully rather than failing entirely.
Coinbase
The revert is a desirable safety check that prevents accidentally setting allocations for entities that have fully cancelled. The offchain systems construct allocation batches from live contract state. Filtering out entities with zero remaining commitment is straightforward and already implemented, since unset allocations default to zero anyway. We prefer the explicit revert over silent skipping because it surfaces batch construction errors rather than masking them.
Cantina
Acknowledged.
Removal of the Closed stage reduces operational safety margins around stage transitions
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
0xRajeev
Description
The
Closedstage previously provided a stable intermediate state between theCommitmentandCancellation/Settlementstages. Two useful operational safety capabilities are lost with its removal:- No way to halt new bids without also halting cancellations
The
Closedstage blocked new bids while leaving all other contract functionality unaffected. The only remaining mechanism to prevent new bids now ispause(), which applies theonlyUnpausedmodifier globally. This also blockscancelBid()andreduceCommitment()as a side effect. If the manager needs to freeze bidding to review commitment data before deciding on the next stage, they must now either accept that new bids may arrive during the review window, or block participants from exercising their cancellation rights while the review is in progress. These were previously independent concerns.- Premature
openCancellation()is irreversible withoutunsafeSetStage()
Previously, a premature
closeCommitment()was recoverable. The manager could callopenCommitment()from theClosedstage to reopen bidding.openCommitment()now only accepts thePreOpenstage, making any transition out of Commitment permanent through normal functions. IfopenCancellation()is called before the intended window closes, participants can immediately begin cancelling bids. The only recovery path isunsafeSetStage(), an emergency function that carries its own risks. TheClosedstage acted as a reversible intermediate step; without it,Commitmentstage transitions are one-way under normal operation.Recommendation
Consider:
- Restoring a mechanism to pause new bids independently of cancellations, for example, a dedicated
bidsEnabledflag checked in_processBid(), so that the manager can freeze the commitment surface without affecting participant withdrawal rights. - Documenting explicitly that
openCancellation()andopenSettlement()are one-way transitions under normal operation and that operators should verify commitment data is final before calling either function.
Coinbase
Bidding and cancellation are gated by separate, mutually exclusive stages (
CommitmentandCancellationrespectively). So pausing during the respective stages only affects the current functionality, so the concern aboutpause()blocking cancellations while trying to freeze bids doesn't apply given this stage separation, assuming sale operators unpause the contract when transitioning stages. One-way stage transitions under normal operation are intentional and provide stronger guarantees to participants about lifecycle progression.Cantina
Acknowledged.
Incomplete reduceCommitment() can leave an entity unknowingly allocated
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
0xRajeev
Description
As designed for partial reduction,
reduceCommitment()accepts a caller-supplied list of (wallet, token, amount) tuples and processes each entry against live storage without verifying that the list is exhaustive. If any wallet/token pair with a non-zero committed balance is omitted, the transaction succeeds and the entity is left in a partially reduced state with no onchain indication that the exit was incomplete.cancelBid(), by contrast, reads all wallets and payment tokens directly from contract storage and cannot miss any pair.An entity that wants to verify exactly what they are exiting, particularly after a complex series of partial reductions across multiple wallets and tokens, may construct an explicit list for
reduceCommitment()instead of usingcancelBid(). This verification-oriented approach is what makes the incompleteness risk non-obvious: the entity believes they have carefully enumerated every remaining position, when in practice a missed or stale entry causes the transaction to succeed with a residual committed balance left behind.Example scenario:
- An entity has two wallets, W1 and W2, each with 1,000 USDC committed (total bid: 2,000 USDC).
- During the
Cancellationstage, they partially reduce W1 by 500 USDC viareduceCommitment(), bringing the total bid to 1,500 USDC. They then decide to fully exit. - Wanting to control the exact amounts being returned, they call
reduceCommitment()again with [(W1, USDC, 500), (W2, USDC, 1000)], intending to clear all remaining commitments. - However, their client constructed the list from a cached state and silently dropped the W2 entry.
- The call succeeds, W1's balance reaches zero, but W2's 1,000 USDC remains committed.
currentBid.amountis 1,000 rather than 0, so refunded stays false. The entity has no indication anything went wrong. - When
openSettlement()is subsequently called, the settler sees W2's 1,000 USDC and allocates accordingly, leaving the entity with an unwanted position they believed they had exited.
Recommendation
Consider:
- Documenting frontend interface to clearly distinguish the two functions:
cancelBid()is the safe, complete path for full cancellation;reduceCommitment()is only for intentional partial reductions. - Validating completeness onchain.
reduceCommitment()could accept an optionalcompleteflag asserting that no committed balance should remain after the call, reverting if any wallet/token pair for the entity still has a non-zero committed amount. - Emitting a warning event on partial reduction. If
currentBid.amount > 0after processing the list, emit a distinct event to make the partial state visible to offchain monitors and the entity itself.
Coinbase
This is by design.
reduceCommitment()accepts caller-supplied wallet/token/amount tuples for intentional partial reductions.cancelBid()is the safe, exhaustive path for full cancellation, reading all wallets and payment tokens directly from contract storage. The scenario described requires a client that silently drops entries from the reduction list, which is a client-side integration concern. Entities can verify their remaining commitment via public view functions before theCancellationstage ends.We improved the natspec on both functions to make the distinction and the incompleteness risk explicit:
reduceCommitment()now documents that omitted pairs are left unchanged and carried into settlement, and cross-referencescancelBid()as the exhaustive alternative (and vice versa).Cantina
Acknowledged.
Any entity wallet can unilaterally reduce or cancel commitments of other wallets in the same entity
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
0xRajeev
Description
The contract resolves authorization for both
cancelBid()andreduceCommitment()by mappingmsg.senderto anentityID, and then permitting the caller to act on any wallet that belongs to that same entity. ForcancelBid(), this means any single wallet can atomically cancel every other wallet's committed position. ForreduceCommitment(), any wallet can specify arbitrary reductions across other wallets' committed balances. In both cases, funds are returned to the respective committing wallet, not to the caller.This design assumes all wallets grouped under an
entityIDrepresent a single coordinated unit operating with mutual trust. However,entityIDgroupings are determined offchain by the Sonar system, and in practice an entity may have wallets operated by independent sub-units, for example, distinct legal entities, departments, or custodians that share anentityIDbut do not have authority over each other's positions.In a multi-wallet entity where wallets are operated independently, any one wallet can:
- Fully cancel the committed positions of all other wallets in the entity via a single
cancelBid()call, forcing immediate refunds without the consent of the other wallets. - Selectively reduce the committed balance of any specific wallet via
reduceCommitment(), partially or fully exiting another sub-unit's position In both cases the affected wallets receive their funds back, so there is no direct loss of funds. However, a sub-unit whose position is cancelled by another wallet loses its place in the sale and has no recourse once theCancellationstage ends orSettlementopens. The harm is the loss of the intended allocation rather than the loss of capital.
Example scenario: Two subsidiary entities are grouped under a single
entityID, each operating an independent wallet: W1 (500 USDC) and W2 (1,000 USDC). During the Cancellation stage, W1's operator, without consulting W2, callsreduceCommitment()with [(W2, USDC, 1000)], fully exiting W2's position while leaving its own intact. W2 receives its funds back but loses its intended allocation with no recourse.Recommendation
Consider one or more of the following mitigations:
- Document the trust assumption explicitly. Make clear in the contract documentation that all wallets grouped under an
entityIDare assumed to be fully trusted by one another. Operators should not group wallets belonging to independent sub-units under a singleentityIDif those sub-units have distinct allocation interests. - Require per-wallet authorization for cross-wallet reductions.
reduceCommitment()could require that each reduction entry is either submitted by the wallet being reduced, or accompanied by an offchain signature from that wallet, rather than relying solely on entity-level authorization. - Restrict
cancelBid()to the calling wallet's commitments only. Rather than cancelling all entity wallets,cancelBid()could cancel only the commitments ofmsg.sender, with a separate privileged function for full entity-wide cancellation.
Coinbase
Entity-level authorization is the intended design. All wallets under an entity are associated during the commitment process via purchase permits signed by the Sonar system. In both
cancelBid()andreduceCommitment(), funds are always returned to the committing wallet, not to the caller, so no misappropriation is possible. Per-wallet authorization would add complexity and gas costs for a scenario that only results in an overreduction of an entity's commitment in the worst case, so we opted for simplicity here.We added documentation to the contract-level natspec making the mutual trust assumption explicit.
Cantina
Acknowledged.
No address(0) check on extraManagers and extraPausers
Description
The protocol performs
address(0)checks on almost all addresses, except forextraManagersandextraPausers.// grant extra roles if (init.extraSettler != address(0)) { _grantRole(SETTLER_ROLE, init.extraSettler); } if (init.extraRefunder != address(0)) { _grantRole(REFUNDER_ROLE, init.extraRefunder); } for (uint256 i = 0; i < init.extraManagers.length; i++) { _grantRole(SALE_MANAGER_ROLE, init.extraManagers[i]); } for (uint256 i = 0; i < init.extraPausers.length; i++) { _grantRole(PAUSER_ROLE, init.extraPausers[i]); }Recommendation
It is recommended to add
address(0)check onextraManagersandextraPausers// grant extra roles if (init.extraSettler != address(0)) { _grantRole(SETTLER_ROLE, init.extraSettler); } if (init.extraRefunder != address(0)) { _grantRole(REFUNDER_ROLE, init.extraRefunder); } for (uint256 i = 0; i < init.extraManagers.length; i++) {+ require(init.extraManagers[i] != address(0),"..."); _grantRole(SALE_MANAGER_ROLE, init.extraManagers[i]); } for (uint256 i = 0; i < init.extraPausers.length; i++) {+ require(init.extraPausers[i] != address(0),"..."); _grantRole(PAUSER_ROLE, init.extraPausers[i]); }
Gas Optimizations2 findings
Redundant validation checks in _reduceCommitment() when called from cancelBid()
State
- Acknowledged
Severity
- Severity: Gas optimization
Submitted by
0xRajeev
Description
cancelBid()constructs its inputs directly from contract storage - wallets fromstate.wallets.values()and tokens from_paymentTokensand then calls_reduceCommitment()for each pair. However,_reduceCommitment()re-validates all three of the following, which are already guaranteed in thecancelBid()path:state.wallets.contains(wallet): wallets come fromstate.wallets.values()_isValidPaymentToken[token]: tokens come from_paymentTokensamount == 0:cancelBid()already checks if(amount > 0)before calling
These checks are meaningful in the
reduceCommitment()path where inputs are caller-supplied, but add NxM redundant SLOADs percancelBid()call for an entity with N wallets and M tokens.Recommendation
Consider splitting
_reduceCommitment()into two variants:- A checked version used by
reduceCommitment()that validates caller-supplied inputs, and - An unchecked internal version used by
cancelBid()that skips these guards entirely
Coinbase
We prefer maintaining a single
_reduceCommitment()implementation shared by bothcancelBid()andreduceCommitment(). Splitting into checked and unchecked variants introduces a second internal function that must be kept in sync, increasing the surface area for bugs. The associated gas overhead is acceptable for typical entity sizes.Cantina
Acknowledged.
Cancelled token amount updating incurs unnecessary SSTOREs on full cancellation
State
- Acknowledged
Severity
- Severity: Gas optimization
Submitted by
0xRajeev
Description
PR 9 introduced
walletState.cancelledAmountByToken[token]and_totalCancelledAmountByToken[token]for audit tracking. Both are updated on every call to_reduceCommitment(), and are noted in the code as being used for audit purposes only with no effect on control flow.On a full
cancelBid()across N wallets and M tokens, this results in up to 2×N×M cold SSTOREs. Additionally,_totalCancelledAmountByToken[token]is written once per wallet per token, even though it is keyed only by token, which means that the same global counter is updated redundantly across all N wallets for each token.Recommendation
If the
CommitmentReducedevent already captures entity, wallet, token, and amount, consider whether the onchaincancelledAmountByTokenand_totalCancelledAmountByTokenstorage fields are necessary for audit needs, because dropping them would eliminate the cold SSTORE overhead entirely.If they must be retained, consider accumulating a local sum for
_totalCancelledAmountByTokenoutside the per-wallet inner loop and write to the global counter once per token rather than once per wallet.Coinbase
The
cancelledAmountByTokenand_totalCancelledAmountByTokenstorage fields serve thetotalCancelledAmount()public view function, which our offchain systems use for accounting. TheCommitmentReducedevent captures the same data, but having on-chain queryable state means we don't need to rely on event indexing. The redundant write pattern for_totalCancelledAmountByToken(once per wallet per token rather than once per token) is a consequence of keeping a single_reduceCommitment()implementation shared by bothcancelBid()andreduceCommitment(). Splitting into separate code paths or accumulating sums outside the loop would save gas but increases the surface area for bugs, which we'd rather avoid.Cantina
Acknowledged.