Organization
- @coinbase
Engagement Type
Cantina Reviews
Period
-
Repositories
Findings
High Risk
1 findings
1 fixed
0 acknowledged
Medium Risk
3 findings
2 fixed
1 acknowledged
Low Risk
6 findings
5 fixed
1 acknowledged
Informational
12 findings
7 fixed
5 acknowledged
Gas Optimizations
1 findings
1 fixed
0 acknowledged
High Risk1 finding
Advertiser can bypass the attribution window by changing ACTIVE -> FINALIZED
Severity
- Severity: High
Submitted by
Haxatron
Description
The attribution window can be bypassed by the advertiser by updating from
INACTIVE->FINALIZEDorACTIVE->FINALIZED.To understand how this can occur we first discuss the allowed state transitions for
AdConversionin the README:Discussion on State Transitions
State Who Can Transition To Available Functions Special Behaviors INACTIVE • ACTIVE: Attribution Provider only
• FINALIZING: Attribution Provider or AdvertiserNone 🔒 Security: No party can pause active campaigns (ACTIVE→INACTIVE blocked) ACTIVE • FINALIZING: Attribution Provider or Advertiser reward() only Live campaign processing conversions FINALIZING • FINALIZED: Attribution Provider (any time), Advertiser (after deadline) reward() only Sets attribution deadline based on campaign's configured duration (max 180 days) FINALIZED None (terminal state) None Only Advertiser can withdraw remaining funds Here is the implementation of the restrictions in the code.
/// @notice Updates the status of a campaign /// /// @param campaign Address of the campaign /// @param newStatus New status of the campaign /// @param hookData Data for the campaign hook function updateStatus(address campaign, CampaignStatus newStatus, bytes calldata hookData) external nonReentrant onlyExists(campaign) { CampaignStatus oldStatus = _campaigns[campaign].status; if ( newStatus == oldStatus // must update status || oldStatus == CampaignStatus.FINALIZED // cannot update from finalized || (oldStatus == CampaignStatus.FINALIZING && newStatus != CampaignStatus.FINALIZED) // finalizing can only update to finalized ) revert InvalidCampaignStatus(); _campaigns[campaign].hooks.onUpdateStatus(msg.sender, campaign, oldStatus, newStatus, hookData); _campaigns[campaign].status = newStatus; emit CampaignStatusUpdated(campaign, msg.sender, oldStatus, newStatus); }/// @inheritdoc CampaignHooks function onUpdateStatus( address sender, address campaign, Flywheel.CampaignStatus oldStatus, Flywheel.CampaignStatus newStatus, bytes calldata hookData ) external override onlyFlywheel { // Prevent ACTIVE → INACTIVE transitions for ALL parties (no one can pause active campaigns) if (oldStatus == Flywheel.CampaignStatus.ACTIVE && newStatus == Flywheel.CampaignStatus.INACTIVE) { revert Unauthorized(); } // Attribution provider can perform other valid state transitions if (sender == state[campaign].attributionProvider) return; // Otherwise only advertiser allowed to update status if (sender != state[campaign].advertiser) revert Unauthorized(); // Advertiser always allowed to start finalization delay if (newStatus == Flywheel.CampaignStatus.FINALIZING) { state[campaign].attributionDeadline = uint48(block.timestamp) + state[campaign].attributionWindow; emit AttributionDeadlineUpdated(campaign, state[campaign].attributionDeadline); return; } // Advertiser only allowed to finalize, but only if delay has passed if (newStatus != Flywheel.CampaignStatus.FINALIZED) revert Unauthorized(); if (state[campaign].attributionDeadline > block.timestamp) revert Unauthorized(); }From the code we see the following restrictions:
For both AP and Advertiser:
newStatus == oldStatus: The status must be updatedoldStatus == CampaignStatus.FINALIZED:FINALIZEDcannot be updated from(oldStatus == CampaignStatus.FINALIZING && newStatus != CampaignStatus.FINALIZED): IfFINALIZING, must update toFINALIZEDoldStatus == Flywheel.CampaignStatus.ACTIVE && newStatus == Flywheel.CampaignStatus.INACTIVE:ACTIVE->INACTIVEis disallowed
For advertiser only:
newStatus == Flywheel.CampaignStatus.FINALIZING, if transition toFINALIZINGset the attribution deadlinenewStatus != Flywheel.CampaignStatus.FINALIZED, if transition is not toFINALIZING, then it must be toFINALIZEDand the attribution deadline must pass:state[campaign].attributionDeadline > block.timestampevaluates tofalse
We can observe the following state transition graph to see how the implementation differs from the README. Red edges represent that only the AP can perform the transition while green edges represent both the AP and advertiser can perform the transition
The Exploit
If we notice the above, the restriction does not account for the fact that the advertiser can transition from
INACTIVE -> FINALIZEDorACTIVE -> FINALIZED. Hence, a malicious advertiser can transition fromINACTIVE -> FINALIZEDorACTIVE -> FINALIZEDas seenWhen this occurs, the
attributionDeadlineremains 0 in the state. Then, the conditionstate[campaign].attributionDeadline > block.timestampwill evaluate tofalseand the revert will not occur, this will allow a malicious advertiser to immediately close a campaign and retrieve all the funds without allowing for the attribution provider to fully attribute all the recipients, allowing them to get free advertisement work without paying for it.Recommendation
The fix for this issue is similar the fix for the other issue by implementing the changes in the common
updateStatusfunction in theFlywheelcontract with the addition of one more change that should only be present in theAdConversionhookMore precisely, the following minimal changes can be used to correct the state transition graph:
For the
Flywheelcontract:-
(oldStatus == CampaignStatus.FINALIZING && newStatus != CampaignStatus.FINALIZED)should be changed to(oldStatus != CampaignStatus.FINALIZING && newStatus == CampaignStatus.FINALIZED), this means that if the new status isFINALIZED, then the old status MUST BEFINALIZING. This deletes theINACTIVE->FINALIZEDandACTIVE->FINALIZEDedges and restoresFINALIZING->INACTIVEandFINALIZING->ACTIVEedges. -
Then to delete
FINALIZING->INACTIVEedge, add(oldStatus == CampaignStatus.FINALIZING && newStatus == CampaignStatus.INACTIVE)
For the
AdConversioncontract:- To delete
FINALIZING->ACTIVEedge (note that after the changes above, this will only be allowed for AP), add(oldStatus == CampaignStatus.FINALIZING && newStatus == CampaignStatus.ACTIVE)
Medium Risk3 findings
Lack of minimum attribution window allows advertiser to immediately close the campaign
State
- Acknowledged
Severity
- Severity: Medium
Submitted by
Haxatron
Description
When creating a
AdConversioncampaign, thecampaignAttributionWindowdetermines how long the campaign must be inFINALIZINGstate before an advertiser can close the campaign by transitioning it toFINALIZED.The problem is that there is no minimum limit on what this value can be, the code only checks that it is a multiple of
daysand it cannot be more than180 days./// @inheritdoc CampaignHooks function onCreateCampaign(address campaign, bytes calldata hookData) external override onlyFlywheel { ( address attributionProvider, address advertiser, string memory uri, string[] memory allowedPublisherRefCodes, ConversionConfigInput[] memory configs, uint48 campaignAttributionWindow ) = abi.decode(hookData, (address, address, string, string[], ConversionConfigInput[], uint48)); // Validate attribution deadline duration (if non-zero, must be in days precision) if (campaignAttributionWindow % 1 days != 0) revert InvalidAttributionWindow(campaignAttributionWindow); // Validate attribution window is between 0 and 6 months (180 days) if (campaignAttributionWindow > 180 days) revert InvalidAttributionWindow(campaignAttributionWindow);Thus, an advertiser could set the
campaignAttributionWindow = 0which bypasses all checks. WithcampaignAttributionWindow = 0, an advertiser can instantly transition a campaign fromACTIVE->FINALIZING->FINALIZEDand retrieve all the funds without allowing for the attribution provider to fully attribute all the recipients, allowing them to get free advertisement work without paying for it.This issue is Medium severity and not High severity as the attribution window being 0 would probably be a red flag to potential users and attribution providers interacting with the malicious advertiser.
Recommendation
Although this seems intentional judging from the comment
(if non-zero, must be in days precision), we recommend adding a minimum check for thecampaignAttributionWindowparameter to prevent the above scenario.Coinbase: This was actually intentional. Attribution providers will be able to see the configured window and choose whether or not to set campaign to ACTIVE. There are some cases where having no delay is helpful for operations and trust can fill in the gap.
Advertiser can frontrun onReward by disabling a conversion config
Severity
- Severity: Medium
Submitted by
Haxatron
Description
An advertiser can frontrun an
onRewardcall and cause it to fail by disabling a conversion config viadisableConversionConfig, this will causeisActiveto be false and a revert to occur inonReward./// @inheritdoc CampaignHooks function onReward(address attributionProvider, address campaign, address payoutToken, bytes calldata hookData) external override onlyFlywheel returns (Flywheel.Payout[] memory payouts, Flywheel.Allocation[] memory fees) { ... if (configId != 0) { if (configId > conversionConfigCount[campaign]) { revert InvalidConversionConfigId(); } ConversionConfig memory config = conversionConfigs[campaign][configId]; if (!config.isActive) revert ConversionConfigDisabled(); ... }This can be used by a malicious advertiser to deny payouts to recipients and cause honest attribution provider calls to constantly fail and therefore waste gas.
Even in the case of honest advertisers disabling conversion config, since the attribution provider call may be delayed, the call might fail without the honest advertiser intending it to fail.
A workaround does exist where an attribution provider can set
configId = 0to bypass any conversion config checks although this might cause the data to be incorrect.Recommendation
Remove the
isActivecheck inonRewardand have the offchain attribution provider check it instead. Note that this was the same approach taken by the previous iteration of the Flywheel contracts.Changes to the attribution provider fee immediately take effect
Severity
- Severity: Medium
Submitted by
Haxatron
Description
In the
AdConversioncontract, advertisers can choose their own attribution providers who will perform attributions for them. The assumption is that the advertiser generally trusts the attribution provider to submit correct attribution data and payout amounts.One of the factors advertisers may take into account when choosing the provider is the attribution provider fee, which is the fee taken from every payout that goes to the provider for submitting attributions.
However, even an honest attribution provider might update their fee via
setAttributionProviderFee, this updates theattributionProviderFeesmapping/// @notice Sets the fee for an attribution provider /// /// @param feeBps The fee in basis points (0 to 10000, where 10000 = 100%) /// /// @dev Only the attribution provider themselves can set their fee function setAttributionProviderFee(uint16 feeBps) external { if (feeBps > MAX_BPS) revert InvalidFeeBps(feeBps); uint16 oldFeeBps = attributionProviderFees[msg.sender]; attributionProviderFees[msg.sender] = feeBps; emit AttributionProviderFeeUpdated(msg.sender, oldFeeBps, feeBps); }During
onReward, the attribution provider fee % is read from this sameattributionProviderFeesmapping./// @inheritdoc CampaignHooks function onReward(address attributionProvider, address campaign, address payoutToken, bytes calldata hookData) external override onlyFlywheel returns (Flywheel.Payout[] memory payouts, Flywheel.Allocation[] memory fees) { ... // Get the fee from the stored attribution provider fees uint16 feeBps = attributionProviderFees[attributionProvider]; ... // Deduct attribution fee from payout amount uint256 attributionFee = (attributions[i].conversion.payoutAmount * feeBps) / MAX_BPS; feeAmount += attributionFee; uint256 netAmount = attributions[i].conversion.payoutAmount - attributionFee; ... }As such any updates to the
attributionProviderFeestakes effect immediately, even if in mid-campaign. This may not be ideal for advertisers or users.Recommendation
Consider caching the attribution provider fee at the time of campaign creation in
onCreateCampaignand use that cached fee throughout the campaign.Note that if such a change were to be implemented an advertiser can cache zero fees for an attribution provider who isn't ready for attribution and hence has not yet set their fee, so you might also want to check that the attribution provider is ready via some
boolstored in a mapping (ie.providerIsReady).Coinbase: We are fixing by having the advertiser passing in attribution provider free Bps so that AP and Advertiser can come to an agreement on fees. If AP doesn't like the fee, they can not make campaign ACTIVE. This allows AP and different advertisers to come to different agreements easily.
Low Risk6 findings
BuilderCodes EIP712 domain name and version cannot change
Severity
- Severity: Low
Submitted by
Haxatron
Description
The
BuilderCodesuses Solady'sEIP712to implement EIP-712-compliant signatures. Since it is an upgradeable contract, the domain name and version returned by the_domainNameAndVersionmay subject to change in the future./// @notice Returns the domain name and version for the referral codes /// /// @return name The domain name for the referral codes /// @return version The version of the referral codes function _domainNameAndVersion() internal pure override returns (string memory name, string memory version) { name = "Builder Codes"; version = "1"; }The problem is that since the
BuilderCodescontract does not override_domainNameAndVersionMayChangefunction to returntrue, Solady will instead use the cached domain name and version when calculating the domain separator:function _buildDomainSeparator() private view returns (bytes32 separator) { // We will use `separator` to store the name hash to save a bit of gas. bytes32 versionHash; if (_domainNameAndVersionMayChange()) { (string memory name, string memory version) = _domainNameAndVersion(); separator = keccak256(bytes(name)); versionHash = keccak256(bytes(version)); } else { separator = _cachedNameHash; versionHash = _cachedVersionHash; } ...Hence, updating the domain name and version on a contract upgrade to invalidate previous signatures would not be possible.
Recommendation
Override
_domainNameAndVersionMayChangeto returntrueif you foresee that the domain name and version will change, as suggested by the Solady library./// @dev Returns if `_domainNameAndVersion()` may change /// after the contract has been deployed (i.e. after the constructor). /// Default: false. function _domainNameAndVersionMayChange() internal pure virtual returns (bool result) {}No deadline check for BuilderCodes registration signatures
Severity
- Severity: Low
Submitted by
Haxatron
Description
The
BuilderCodescontract allows users to register custom builder codes using the signature of someone withREGISTER_ROLE./// @notice Registers a new referral code in the system with a signature /// /// @param code Custom builder code for the builder code /// @param initialOwner Owner of the builder code /// @param payoutAddress Default payout address for all chains /// @param registrar Address of the registrar /// @param signature Signature of the registrar function registerWithSignature( string memory code, address initialOwner, address payoutAddress, address registrar, bytes memory signature ) external { // Check registrar has role _checkRole(REGISTER_ROLE, registrar); // Check signature is valid bytes32 structHash = keccak256(abi.encode(REGISTRATION_TYPEHASH, keccak256(bytes(code)), initialOwner, payoutAddress)); if (!SignatureCheckerLib.isValidSignatureNow(registrar, _hashTypedData(structHash), signature)) { revert Unauthorized(); } _register(code, initialOwner, payoutAddress); }Since there are no deadline checks, an already issued signature for a specific builder code in the contract will never expire and will be indefinitely valid.
The absence of a deadline check does not represent a direct security risk but is something you might want to consider adding.
Recommendation
Consider adding a deadline check, if necessary
Allocated funds can be accidentally locked if campaign is set to FINALIZED
Severity
- Severity: Low
Submitted by
Haxatron
Description
Funds passing through Flywheel can flow through main two pathways, a one-step transfer via
reward()or a two-step transfer viaallocate()thendistribute().The problem is that two-step transfers, both
allocate()anddistribute()calls can only occur during theACTIVEorFINALIZINGstates in Flywheel. Once set to theFINALIZED, none of these calls can be made. This presents a problem where funds that have been allocated viaallocate()but were not distributed viadistribute()will be locked in the contract when the status is transitioned toFINALIZED.The funds are permanently locked because
allocate()will increase thetotalReservedby the allocated amountfunction allocate(address campaign, address token, bytes calldata hookData) external nonReentrant onlyExists(campaign) acceptingPayouts(campaign) returns (Allocation[] memory allocations) { ... _assertTotalReservedSolvency(campaign, token, totalReserved[campaign][token] + totalAmount); }Then not even
withdrawFundscan be called to withdraw the allocated amount, as the_assertTotalReservedSolvencyfunction prevents the total funds in the campaign from going below thetotalReservedwhich includes the allocated but not distributed funds.function withdrawFunds(address campaign, address token, bytes calldata hookData) external nonReentrant onlyExists(campaign) { Payout memory payout = _campaigns[campaign].hooks.onWithdrawFunds(msg.sender, campaign, token, hookData); (address recipient, uint256 amount) = (payout.recipient, payout.amount); Campaign(campaign).sendTokens(token, recipient, amount); emit FundsWithdrawn(campaign, token, recipient, amount, payout.extraData); _assertTotalReservedSolvency(campaign, token, totalReserved[campaign][token]); }Recommendation
If you consider this a problem to be fixed, a possible solution would be to allow
distribute()calls even inFINALIZEDstate to allow allocated funds to be distributed even after a campaign has beenFINALIZEDCoinbase: We will allow
deallocate()inFINALIZED. Fixed in PR 97Rebase tokens can cause funds to be locked on a negative rebase
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Haxatron
Description
Rebase tokens are tokens that can have their balances changed outside of a transfer, one notable example is stETH (Lido staked ETH). In the
Flywheelcontracts, any token can be used and decided upon by the owner of the campaign.On a negative rebase of any such token, users balances can be decreased. This can cause the token balance stored in the
Campaigncontract to decrease below the reserved amounts, making it impossible to retrieve the full reserved amount from theCampaigncontract. This might cause tokens to be stuck as the_assertTotalReservedSolvencyinvariant will always fail and cause a revert.A workaround would be to batch deallocate tokens until the reserved amounts fall below the actual balance after the negative base. Another way would be to then top up the
Campaigncontract so that its balance meets the reserved amount. Both of these workarounds would require a lot of hassle which is not ideal.Recommendation
If you believe this is a problem that should be fixed, one solution would be to have a token blocklist / allowlist preventing the use of these tokens. Another solution would be to warn users of the potential consequences of using such tokens in the documentation.
Coinbase: Addressed in documentation update in PR 95.
skip failed payments instead of reverting
Summary
_validatePaymentRewardcan revert, but perhaps it should return boolean instead so you could process any other valid rewardsany call would include lots of calldata, as well as execution ops, so small failure shouldn't require re-executing
attributionProvider can block advertiser from updating deadline
Severity
- Severity: Low
Submitted by
high byte
Summary
if called by
attributionProviderthen we won't reach the deadline update part due to early return, but it will succeed in settingFINALIZINGstatus.since you can also transition
FINALIZING -> FINALIZEDit means theattributionProvidercan dosadvertiserfrom changing the deadline.Flywheel.sol:|| (oldStatus == CampaignStatus.FINALIZING && newStatus != CampaignStatus.FINALIZED) // finalizing can only update to finalized
Informational12 findings
Consider making the 32-byte maximum builder code length explicit
Severity
- Severity: Informational
Submitted by
Haxatron
Description
When registering a custom builder code, the
_registerwill calltoTokenIdto convert the builder codestringto auint256to determine the ERC721 token ID to mint./// @notice Converts a referral code to a token ID /// /// @param code Builder code to convert /// /// @return tokenId The token ID for the referral code function toTokenId(string memory code) public pure returns (uint256 tokenId) { if (!isValidCode(code)) revert InvalidCode(code); return uint256(LibString.toSmallString(code)); }It does this via Solady
LibString.toSmallStringwhich reverts if the length of thestringis more than 32 bytes.Hence, there is actually an implicit 32-byte maximum length for a builder code due to the check in the Solady library. However, it can be made more explicit through documentation and comments or even an earlier check in the
BuilderCodescontract that reverts if a given code is above 32 bytes.Recommendation
Consider making the 32-byte maximum builder code length explicit by adding documentation and code comments or even an earlier check in the
BuilderCodescontract that reverts if a given code is above 32 bytes.Only owner can withdraw funds in SimpleRewards deviating from README
Severity
- Severity: Informational
Submitted by
Haxatron
Description
The README states that for the
SimpleRewardscontract, only the Manager can withdraw fundsSimpleRewards: Manager withdraws funds
This is contrary to the code present in the
SimpleRewardscontract:/// @inheritdoc CampaignHooks function onWithdrawFunds(address sender, address campaign, address token, bytes calldata hookData) external virtual override onlyFlywheel returns (Flywheel.Payout memory payout) { if (sender != owners[campaign]) revert Unauthorized(); return (abi.decode(hookData, (Flywheel.Payout))); }Recommendation
Either fix the documentation if it is incorrect or the code to use
onlyManagerif the code is incorrect. Note that if the code is incorrect, theCashbackRewardshas to be updated to override theonWithdrawFundsto include the owner check, asCashbackRewardscurrently inheritsonWithdrawFundsfunction fromSimpleRewardsand the documentation states that the owner is to withdraw funds fromCashbackRewards.Coinbase: README will be corrected.
Allowed state transitions for SimpleRewards and CashbackRewards deviates from README
Severity
- Severity: Informational
Submitted by
Haxatron
Description
According to the README, the following are the allowed state transitions for
SimpleRewardsandCashbackRewards.State Who Can Transition Available Functions Special Behaviors INACTIVE • ACTIVE: Manager only
• FINALIZING: Manager onlyNone Initial/paused state ACTIVE • INACTIVE: Manager only
• FINALIZING: Manager onlyreward(), allocate(), deallocate(), distribute() CashbackRewards: Payment must be collected in AuthCaptureEscrow FINALIZING • ACTIVE: Manager only
• FINALIZED: Manager onlyreward(), allocate(), deallocate(), distribute() Grace period before closure FINALIZED None (terminal state) None CashbackRewards: Owner withdraws funds
SimpleRewards: Manager withdraws fundsHere is the implementation of the restrictions in the code.
/// @notice Updates the status of a campaign /// /// @param campaign Address of the campaign /// @param newStatus New status of the campaign /// @param hookData Data for the campaign hook function updateStatus(address campaign, CampaignStatus newStatus, bytes calldata hookData) external nonReentrant onlyExists(campaign) { CampaignStatus oldStatus = _campaigns[campaign].status; if ( newStatus == oldStatus // must update status || oldStatus == CampaignStatus.FINALIZED // cannot update from finalized || (oldStatus == CampaignStatus.FINALIZING && newStatus != CampaignStatus.FINALIZED) // finalizing can only update to finalized ) revert InvalidCampaignStatus(); _campaigns[campaign].hooks.onUpdateStatus(msg.sender, campaign, oldStatus, newStatus, hookData); _campaigns[campaign].status = newStatus; emit CampaignStatusUpdated(campaign, msg.sender, oldStatus, newStatus); }From the code we can see there are three restrictions:
newStatus == oldStatus: The status must be updatedoldStatus == CampaignStatus.FINALIZED:FINALIZEDcannot be updated from(oldStatus == CampaignStatus.FINALIZING && newStatus != CampaignStatus.FINALIZED): IfFINALIZING, must update toFINALIZED
This deviates from the README in the following ways:
INACTIVE->FINALIZEDandACTIVE->FINALIZEDare allowed, when they should not beFINALIZING->ACTIVEis disallowed, when they should be.
The following illustration shows the difference between the state transition graphs between the README and the current implementation
Recommendation
If the README is incorrect, then it should be corrected.
However, if the README is correct, then the following minimal changes should be made to align the implementation with the README:
-
(oldStatus == CampaignStatus.FINALIZING && newStatus != CampaignStatus.FINALIZED)should be changed to(oldStatus != CampaignStatus.FINALIZING && newStatus == CampaignStatus.FINALIZED), this means that if the new status isFINALIZED, then the old status MUST BEFINALIZING. This deletes theINACTIVE->FINALIZEDandACTIVE->FINALIZEDedges and restoresFINALIZING->INACTIVEandFINALIZING->ACTIVEedges. -
Then to delete
FINALIZING->INACTIVEedge, add(oldStatus == CampaignStatus.FINALIZING && newStatus == CampaignStatus.INACTIVE)
Coinbase: README will be corrected.
Consider adding a check that isActive is true before disabling a conversion config
Severity
- Severity: Informational
Submitted by
Haxatron
Description
An already disabled conversion config (
isActive = falsecan be passed todisableConversionConfig) without reverting./// @notice Disables a conversion config for a campaign /// @param campaign Address of the campaign /// @param configId The ID of the conversion config to disable /// @dev Only advertiser can disable conversion configs function disableConversionConfig(address campaign, uint16 configId) external { if (msg.sender != state[campaign].advertiser) revert Unauthorized(); if (configId == 0 || configId > conversionConfigCount[campaign]) { revert InvalidConversionConfigId(); } // Disable the config conversionConfigs[campaign][configId].isActive = false; emit ConversionConfigStatusChanged(campaign, configId, false); }Recommendation
Add a check that
isActiveis true before disabling a conversion config.Consider adding a check that allowed publisher refcode is in the registry in AdConversion.onCreateCampaign
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Haxatron
Description
Refcodes that are not registered in the
BuilderCodesregistry can be passed toonCreateCampaignallowlist without reverting./// @inheritdoc CampaignHooks function onCreateCampaign(address campaign, bytes calldata hookData) external override onlyFlywheel { ... // Set up allowed publishers mapping if allowlist exists if (hasAllowlist) { uint256 count = allowedPublisherRefCodes.length; for (uint256 i = 0; i < count; i++) { allowedPublishers[campaign][allowedPublisherRefCodes[i]] = true; emit PublisherAddedToAllowlist(campaign, allowedPublisherRefCodes[i]); } } ... }Recommendation
Add a check that the allowed publisher refcode is in the registry in
AdConversion.onCreateCampaignCoinbase: Acknowledged. If the refcode isn't active by the time attribution happens then rewards will be rejected so validation is already happening here. We don't want to make protocol too restrictive
Consider adding validation that the maxRewardBasisPoints cannot exceed 10_000
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Haxatron
Description
There is no input validation on the
maxRewardBasisPointswhich determines the maximum % collected in theAuthCaptureEscrowthat can be allocated or distributed viaCashbackRewards/// @inheritdoc CampaignHooks function onCreateCampaign(address campaign, bytes calldata hookData) external override onlyFlywheel { (address owner, address manager, string memory uri, uint16 maxRewardBasisPoints_) = abi.decode(hookData, (address, address, string, uint16)); owners[campaign] = owner; managers[campaign] = manager; campaignURI[campaign] = uri; maxRewardBasisPoints[campaign] = uint256(maxRewardBasisPoints_); emit CampaignCreated(campaign, owner, manager, uri); }That means that the campaign owner can set
maxRewardBasisPointsthat exceeds10_000which does not make sense as it would mean users are getting higher cashbacks than what they paid.Recommendation
It might make sense to add validation that
maxRewardBasisPointscannot exceed10_000Coinbase: We have potential use cases that would desire a maximum over 100% so will leave unbounded for now. Data is immutable and visible to all parties so not concerned of abuse here
Consider setting maxRewardBasisPoints = 10_000 in _validatePaymentReward if maxRewardBasisPoints is 0
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Haxatron
Description
In the
CashbackRewards._validatePaymentRewardfunction, ifmaxRewardBasisPoints = 0, the reward amount validation is simply skipped./// @dev Validates a payment reward and returns the payment info hash /// /// @param paymentReward The payment reward /// @param campaign The campaign address /// @param token The campaign token /// @param operation The type of operation being performed function _validatePaymentReward( PaymentReward memory paymentReward, address campaign, address token, RewardOperation operation ) internal view returns (bytes32 paymentInfoHash) { ... // Early return if no max reward percentage is configured uint256 maxRewardBps = maxRewardBasisPoints[campaign]; if (maxRewardBps == 0) return paymentInfoHash; ... // Check total reward amount doesn't exceed the max allowed reward for this payment uint120 totalRewardAmount = previouslyRewardedAmount + paymentReward.payoutAmount; uint120 maxAllowedRewardAmount = uint120(paymentAmount * maxRewardBps / BASIS_POINTS_100_PERCENT); if (totalRewardAmount > maxAllowedRewardAmount) { revert RewardExceedsMaxPercentage( paymentInfoHash, maxAllowedRewardAmount, totalRewardAmount - maxAllowedRewardAmount ); } }This means that if
maxRewardBasisPoints = 0, campaign manager can set any cashback reward amounts they want, including ones that exceeding the amount of payment tracked in theAuthCaptureEscrowRecommendation
If
maxRewardBasisPoints = 0, validation should continue butmaxRewardBasisPointsshould be set to10_000to allow the manager to cashback up to 100% of the payments tracked inAuthCaptureEscrow, but not any more than that.Fund withdrawals can occur before FINALIZED in SimpleRewards and CashbackRewards, deviating from README
Severity
- Severity: Informational
Submitted by
Haxatron
Description
The README suggests that fund withdrawals can occur only when the campaign has reached
FINALIZEDstateAspect AdConversion CashbackRewards SimpleRewards ... ... ... ... Fund Withdrawal Advertiser only (FINALIZED + deadline) Owner only (FINALIZED) Manager only (FINALIZED) ... ... ... However, this isn't true for
SimpleRewardsandCashbackRewards, bothFlywheel.withdrawFundsandSimpleRewards.onWithdrawFunds(whichCashbackRewardsinheritsonWithdrawFundsfrom) do not check for theFINALIZEDstate./// @notice Withdraw tokens from a campaign /// /// @param campaign Address of the campaign /// @param token Address of the token to withdraw /// @param hookData Data for the campaign hook function withdrawFunds(address campaign, address token, bytes calldata hookData) external nonReentrant onlyExists(campaign) { Payout memory payout = _campaigns[campaign].hooks.onWithdrawFunds(msg.sender, campaign, token, hookData); (address recipient, uint256 amount) = (payout.recipient, payout.amount); Campaign(campaign).sendTokens(token, recipient, amount); emit FundsWithdrawn(campaign, token, recipient, amount, payout.extraData); _assertTotalReservedSolvency(campaign, token, totalReserved[campaign][token]); }/// @inheritdoc CampaignHooks function onWithdrawFunds(address sender, address campaign, address token, bytes calldata hookData) external virtual override onlyFlywheel returns (Flywheel.Payout memory payout) { if (sender != owners[campaign]) revert Unauthorized(); return (abi.decode(hookData, (Flywheel.Payout))); }Recommendation
If the README is incorrect, it should be corrected. Otherwise, the check for
FINALIZEDstate should be present in thewithdrawFundsflow for both theSimpleRewardsandCashbackRewardscontracts, note that theAdConversioncontract is not affected as it does contain a check forFINALIZEDstate.Coinbase: README will be corrected.
consider changing default hook behavior to abstract or no-op
Summary
there is no point of campaign hook that reverts on things such as status update
seems like you would never want that, so it is better to simply not implement and enforce implementation by inheritors, or as no-op by default.
merge duplicated code
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
high byte
Summary
the
_allocateFeesshares most logic with theallocatefunction, can be merged into single internal functionCoinbase: Will not change as we are satisfied with current readability.
safeguard hooks by enforcing modifier
Severity
- Severity: Informational
Submitted by
high byte
Summary
all virtual functions here use
onlyFlywheelmodifier - but whenever someone overrides this function they have to include this modifier again if they want to preserve this behavior.there's a better way to implicitly enforce this, all
externalfunctions should be non-virtual and have this modifier and call respectiveinternal virtualfunctions.for example:
function onReward(..) external onlyFlywheel { return _onReward(..);} function _onReward(..) internal virtual { } // devs override this more safelyadd events index
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
high byte
Summary
you might want to consider indexing
token,recipienttooat very slightly higher cost this will allow much easier lookups of payments by users and tools, for analytics and whatnot.
Coinbase: Will not implement for now. Common production-grade indexing tools don't benefit from indexing parameters so we just want to do the minimum viable filtering which would be per-campaign
Gas Optimizations1 finding
consider converting the triple mapping to single mapping
Summary
fun little optimization - you could cache the mapping
pendingPayouts[campaign][token]like so:mapping(bytes32 key => uint256 amount) storage _pendingPayouts = pendingPayouts[campaign][token]; for (..) { .. _pendingPayouts[key] += amount; ..}alternative approach you could make
pendingPayoutsmap from single key which iskeccak256(abi.encode(key1, key2, key3))