Guardian's review of TORCH for Yuga Labs, published February 2026. The report records 26 findings across 2 review rounds, including 2 critical and 2 high.
- Published
- Review window
- January 16 to 27, 2026
- Rounds
- Main Review, Remediation Review
- Language
- Solidity
- Chains
- Ethereum
- Sector
- NFTs
- 2 Critical
- 2 High
- 1 Medium
- 6 Low
- 15 Informational
Scope
3 files in scope · 338 nSLOC
| File | nSLOC | Lines |
|---|---|---|
src/BoredApeYachtClub.sol | 180 | 345 |
src/TheSplit.sol | 117 | 260 |
src/UserOwnedRoyaltySplitter.sol | 41 | 56 |
Findings 26
Main Review
21 findings · January 16 to 20, 2026-
C-01 Critical Royalty Owner Can Steal Royalties Via Reentrancy Reentrancy Resolved
Description
UserOwnedRoyaltySplitteris a contract deployed for each original minter of the Club NFT. Three royalty recipients are configured during deployment.function initialize(address recipient1, address recipient2, address recipient3) external { ... recipientOne = recipient1; recipientTwo = recipient2; recipientThree = recipient3; }These recipients are respectively the owner of the original NFT, the royalty machine and the owner of the club NFT contract.
UserOwnedRoyaltySplitter(splitter).initialize(msg.sender, royaltyMachine, owner());UserOwnedRoyaltySplitter.withdraw()is a permissionless function that sends a third of the available native token balance of the contract to each of the three recipients. The last recipient, the NFT contract owner, is forwarded the whole balance. This is done in case one or more of the previous recipients couldn't receive their funds - then the owner gets everything.function withdraw() external { uint256 oneThird = address(this).balance / 3; (bool success,) = recipientOne.call{value: oneThird}(""); (success,) = recipientTwo.call{value: oneThird}(""); // remainder gets sent to recipient 3. If either previous recipients cannot receive ether, then recipient 3 will receive an extra allotment. (success,) = recipientThree.call{value: address(this).balance}(""); if (!success) revert TransferFailed(); }However, this mechanism introduces an exploitable reentrancy vector through which royalties can be stolen by the first recipient. The first recipient can call
withdraw()and reenter as much time as needed in order to withdraw most, if not all, of the contract balance.Then all of the calls to the second recipient from the upper call contexts will revert, but this doesn't matter since the
successflag is ignored.Only the
successflag of the call to the third recipient is checked, but this call will always be successful due to it using the whole balance of the contract.Looking back at
BoredApeYachtClub._deployRoyaltySplitter(), one can see that the first recipient is the owner of the original NFT.UserOwnedRoyaltySplitter(splitter).initialize(msg.sender, royaltyMachine, owner());The owner of the original NFT can exploit this vector and steal the royalties of the machine and the Club owner.
Recommendation
Add a
nonReentrantmodifier toUserOwnedRoyaltySplitter() -
C-02 Critical Splits Can Be Stolen Access Control Resolved
Description
TheSplit.claimYugaLabs()andTheSplit.claimMachine()are two permissionless functions that accept an arbitrarytoparameter and forward the Yuga and machine splits to that address. An attacker can call these functions with their own address and in result steal accumulated splits that should have went to Yuga and the machine.Recommendation
Either make the functions callable only by Yuga and the machine or don't allow passing arbitrary
toand instead hardcode Yuga and the machine. -
H-01 High Received Royalties Can Be Artificially Inflated Validation Resolved
Description
The
totalRoyaltiesReceivedvariable inTheSplitcontract tracks the amount of native token received. It's later used to determine the amount of royalties to be distributed.uint256 newRoyalties = totalRoyaltiesReceived - totalRoyaltiesProcessed;The variable is increased in
receive()andclaimBlurPool(). However,claimBlurPool()accepts an arbitrary pool and increases the variable with the value returned from itsbalanceOf()function.function claimBlurPool(address pool) external { uint256 claimable = IBlurPool(pool).balanceOf(address(this)); if (claimable == 0) revert NothingToClaim(); IBlurPool(pool).withdraw(claimable); unchecked { totalRoyaltiesReceived += claimable; } emit RoyaltiesReceived(claimable); }A fake contract can be used to return any value. This allows anyone to inflate the received royalties and cause incorrect distribution, stealing from other recipients.
The variable can also be set to its maximum value of
uint256.maxto completely DOS any new royalties.Recommendation
Use a hardcoded address of the pool.
-
H-02 High Royalties From The Pool Are Counted Twice Logical Error Resolved
Description
TheSplit.claimPool()can be invoked to claim the balance of the contract held in the blur pool.The
claimablevariable is assigned the value returned frombalanceOf()and is afterwards received by callingwithdraw(). Then thetotalRoyaltiesReceivedis increased with that value and will be later used to calculate how much new royalties are to be distributed. After that theRoyaltiesReceivedeven is emitted.function claimBlurPool(address pool) external { uint256 claimable = IBlurPool(pool).balanceOf(address(this)); if (claimable == 0) revert NothingToClaim(); IBlurPool(pool).withdraw(claimable); unchecked { totalRoyaltiesReceived += claimable; } emit RoyaltiesReceived(claimable); }BlurPool.withdraw()sends native tokens toTheSplitfunction withdraw(uint256 amount) external { uint256 balance = _balances[msg.sender]; require(balance >= amount, "Insufficient funds"); unchecked { _balances[msg.sender] = balance - amount; } (bool success,) = payable(msg.sender).call{value: amount}(""); require(success, "Transfer failed"); emit Transfer(msg.sender, address(0), amount); }Because of this,
TheSplit.receive()will be executed. Inside of it, thetotalRoyaltiesReceivedis increased again andRoyaltiesReceived- emitted as well.receive() external payable { totalRoyaltiesReceived += msg.value; emit RoyaltiesReceived(msg.value); }Therefore, any royalties claimed by the pool will be counted twice in
totalRoyaltiesReceivedand the event will be emitted twice. This will lead to incorrect royalties distribution for the different receivers.Recommendation
Remove the duplicated logic from
claimBlurPoolfunction claimBlurPool(address pool) external { uint256 claimable = IBlurPool(pool).balanceOf(address(this)); if (claimable == 0) revert NothingToClaim(); IBlurPool(pool).withdraw(claimable); - unchecked { - totalRoyaltiesReceived += claimable; - } - emit RoyaltiesReceived(claimable); } -
L-01 Low Minting Fails If Owner Is Renounced DoS Resolved
Description
For the first mint of a
tokenIdtheBoredApeYachtClubdeploys a splitter for the user and passesowner()as one of the recipients.function _deployRoyaltySplitter(uint256 tokenId) internal { address payable splitter = payable(address(new UserOwnedRoyaltySplitter{salt: bytes32(tokenId)}())); UserOwnedRoyaltySplitter(splitter).initialize(msg.sender, royaltyMachine, owner()); emit RoyaltySplitterDeployed(tokenId, msg.sender, address(splitter)); }Then
UserOwnedRoyaltySplitter.initialize()reverts if any of the recipients isaddress(0).function initialize(address recipient1, address recipient2, address recipient3) external { if (recipientOne != address(0) || recipientTwo != address(0) || recipientThree != address(0)) { revert AlreadyInitialized(); } if (recipient1 == address(0) || recipient2 == address(0) || recipient3 == address(0)) { revert InvalidRecipient(); } ... }Therefore, once the owner of the contract uses
renounceOwnership()all new mints will be disabled, as they will start failing withInvalidRecipient().Recommendation
If renouncing ownership is not a desirable feature, override the function to revert in order to disable renouncing.
Otherwise, use another address as fee collector instead of
owner(). -
L-02 Low Royalty Splitter Cannot Update Recipients Best Practices Resolved
Description
Two of the recipients in the
UserOwnedRoyaltySplittercontract are the royaltyMachine and the owner of the club collection. Once set, these recipients cannot be changed, so if a modification happens in theBAYCcontract, the royalties will keep going to the old addresses.Recommendation
Instead of setting
recipientTwoandrecipientThreeduring deployment, read them fromBAYCany time they are needed. -
L-03 Low Updating The Split Leads To Incorrect Accounting Logical Error Resolved
Description
BoredApeYachtClub.setSplitContract()can be used by accounts with theSPLIT_MANAGEMENT_ROLErole to enable, disable or change the split contract. However, changing this variable leads to incorrect accounting for the old split.Once changed,
onMint()won't be called on the old split. This creates a discrepancy between the data inTheSplitbecause the contract reads the storage variables of theBAYC. Example:- There is 400 minter reward to be split across 2 users, each owning 1 token
- The split is changed to another contract
- One of the users mints another NFT,
onMint()is called only on the new splitter - When this user calls
TheSplit.claimMinter()on the old contract, they would be able to claim all of the 400 tokens, because to the split it appears like they own 2 NFT and their accumulated reward hasn't been updated
Recommendation
Either:
- make
setSplitContract()callable only once and makeTheSplitreadmachineandyugaLabsfromBAYCinstead of having them as immutables - introduce a way to freeze the old splitter by caching all the needed variables from BAYC before migrating to a new splitter
-
L-04 Low Unrestricted External Call In
withdrawBlurPoolBest Practices ResolvedDescription
In
UserOwnedRoyaltySplitter, the value for_BLUR_POOLis assigned at the top but is never used, sincewithdrawBlurPoolaccepts a user-supplied pool address.Unlike the split contract, the impact here is limited. However, allowing the contract to make an external call to an arbitrary user-provided address is still not considered best practice.
Recommendation
Consider using hardcoded blur pool address instead.
-
L-05 Low Split Not Working With ERC20s Unexpected Behavior Resolved
Description
TheSplit is designed to work with native tokens only. Any ERC20s received as royalties will be stuck there since there is no way to take them out.
Recommendation
Consider whether support for ERC20s is needed.
-
I-01 Informational
totalSupply()Can Be Artificially Increased Best Practices AcknowledgedDescription
BoredApeYachtClub.totalSupply()returns the amount of underlying NFTs staying in the contract.function totalSupply() external view returns (uint256) { return IERC721(BAYC_LEGACY).balanceOf(address(this)); }Anyone can donate their
BAYC_LEGACYNFT token to increasetotalSupply()without an actual club token being minted.This is unusual for
ERC721.totalSupply()and can be misused by third-party integrators.Recommendation
Consider having an internal
totalSupplytracker. -
I-02 Informational Uneven Royalty Distribution Due To
TheSplitInformational AcknowledgedDescription
All royalties coming from a BAYC NFT sale that go to
TheSplitare split evenly between the original owners of the tokens. Some tokens are traded at a much higher price than others, but due to the wayTheSplitworks, its rightful owner will receive as much as the other participants.This also creates a MEV opportunity. For example, if a very expensive NFT is bought, an external party can frontrun the royalty transaction and enter the system before that in order to receive a part of that royalty.
Recommendation
Keep that design in mind and consider if it's acceptable.
-
I-03 Informational TransferValidator Config And Assumptions Configuration Acknowledged
Description
The owner of the new BAYC would have to set the token type and list it on the transfer validator for transfers to be processed.
setTokenTypeOfCollection and a placeholder.
Limitbreak’s payment processor enforces the payment of royalties, but the exchange used by users may not. Regular monitoring would be needed to block such operators if BAYC adopts a blacklist approach. As per current documention, Opensea, X2Y2 enforce it while Blur doesn't.
Recommendation
Beware of these considerations
-
I-04 Informational ERC721C: SupportsInterface Informational Resolved
Description
Technically, you should also add support for the IERC165 interface for this to work. https://github.com/OpenZeppelin/openzeppelin-contracts/blob/239795bea728c8dca4deb6c66856dd58a6991112/contracts/utils/introspection/ERC165.sol#L23
Also according to Limitbreak’s implementation of ERC721C, they also return support for their legacy interface.
In practice, we’ve never seen anyone actually use supportsInterface in the first place, but we are noting the difference so you’re aware of it.
Recommendation
Beware of this consideration and if deemed important consider fixing it.
-
I-05 Informational ERC721C: EIP Implementation Informational Acknowledged
Description
The official ERC721C implementation by Limitbreak also includes a feature called
AutomaticValidatorTransferApproval, which allows the transfer validator to be automatically approved as an operator.We’re not sure whether this is something desired for BAYC, but we wanted to note it in case you weren’t aware.
Recommendation
Beware of this feature and decide if its something that should be enabled for BAYC.
-
I-06 Informational BAYC: BurnAllowed Trust Assumption Gaming Acknowledged
Description
The new BAYC has an optional burnAllowed feature. If enabled, it allows users to burn their ERC721C token to receive the legacy BAYC.
However, this burn operation is not compatible with a split contract. Even if someone burns their token, there is no hook like
onBurn(similar toonMint), so that address would continue receiving its share of royalties in perpetuity.If burn is enabled, someone could even snipe NFT exchange order books and do
buy → mint → burn → sellin a single transaction, while still retaining royalties forever.They could also burn, transfer (sell), and have the buyer mint, effectively bypassing transfer validations.
Recommendation
We raised this to BAYC team, and it was noted that this feature most likely won't be used and is a emergency escape hatch.
-
I-07 Informational BAYC_LEGACY Assignment Best Practices Resolved
Description
BAYC_LEGACY value is unnecessarily assigned on top as immutable and then reassigned in constructor.
Recommendation
Consider keeping one of the assignments.
-
I-08 Informational ERC721C: Validate Transfers Configuration Acknowledged
Description
The limitbreak's implementation of ERC721C bypasses the validation if caller is validator itself.
function _preValidateTransfer( address caller, address from, address to, uint256 tokenId, uint256 /*value*/) internal virtual override { address validator = getTransferValidator(); if (validator != address(0)) { if (msg.sender == validator) { return; } ITransferValidator(validator).validateTransfer(caller, from, to, tokenId); } }The BAYC's implementation doesn't. Hence if BAYC uses the default list provided, it may not allow transfers called by validators itself.
Recommendation
Beware of this difference, if transfers made from validator is something BAYC wants to allow, either consider adding validator to the list manually OR add override.
-
I-09 Informational Minting Can Be Optimized Gas Optimization Resolved
Description
The current royalty architecture deploys one
UserOwnedRoyaltySplittercontract per tokenId on first mint. While this works correctly, it introduces significant and unnecessary gas costs during minting. Importantly, the deployed splitter is not token-owned in practice:- The splitter is initialized with the original minter as the primary recipient.
- If burning is disabled, the original minter relationship is permanent.
- All splitters have identical logic and identical recipient structure (minter / machine / Yuga).
- Different tokenIds minted by the same user ultimately route royalties to the same address(es).
As a result, per-token splitters provide no additional economic or functional guarantees beyond having a distinct contract address per tokenId. Their only real benefit is address-level isolation and per-token royalty history, which is primarily an indexing and analytics concern, not a correctness or security requirement.
Recommendation
If the offchain benefits from the current approach are not important to you, consider deploying a royalty minter per user only once and adding a mapping that tracks the original owner of each tokenID in order to route
royaltyInfo()correctly. -
I-10 Informational
hasRole()May Be Confusing Best Practices ResolvedDescription
The
BoredApeYachtClub.hasRole()function is intended to be used by theCreatorTokenTransferValidator. If it's used for any other purposes, it's return value will likely not be correct. For example, any role besides the default admin will return false. If third party integrators are not aware of this behavior, they may take wrong decisions based on the returned value.Recommendation
Consider documenting that this function shouldn't be used, or even revert if the caller is not the transfer validator.
-
I-11 Informational Gas: Mints Optimisation Gas Optimization Acknowledged
Description
Yuga deploys a new user-owned splitter for each mint. Instead, they could consider using a deterministic clone model, which keeps a single implementation contract with minimal proxy clones deployed for each mint.
This would lead to a reduction in gas costs for each mint, since much smaller bytecode would be deployed.
Recommendation
Consider this approach
-
I-12 Informational Theft Of Royalties Earned Through The Blur Pool Informational Acknowledged
Description
The splitter contract includes a function to claim royalties from the Blur pool. For standard royalty payments, funds are received directly via the receive function and are instantly accounted for, immediately increasing totalReceived. However, Blur royalties are handled differently, the royalty amount is credited within the Blur pool, but the funds are not transferred to the splitter contract until claimBlurPool is called. Only at that point does totalReceived increase. If a user mints after a blur accounting has occurred but before
claimBlurPoolis called, they will still receive a share of those previously earned royalties. This results in incorrect royalty distribution and unfair payouts.Recommendation
Since yuga team mentioned that they dont expect blur pool claims, this could be left as it is. Just beware of this case.
Remediation Review
5 findings · January 26 to 27, 2026-
M-01 Medium Non-ETH Royalties Distributed Unfairly Gaming Acknowledged
Description
The TheSplit contract uses an accumulator pattern that only tracks ETH-based royalties via totalRoyaltiesReceived (incremented in receive()). When royalties are paid in ERC20 or ERC721 tokens, the Yuga team intends to rescue them via rescueERC20/rescueERC721, swap to ETH, and send the ETH back to the splitter.
However as per above, users who mint after the original ERC20/ERC721 was received but before the ETH swap is deposited will also get the same treatment as if those royalties came in after their mint. This means the timing of when non-ETH royalties are converted and deposited as ETH creates arbitrary reward distribution unrelated to when the original royalty was actually earned.
Recommendation
There is no easy solution. Even if the Yuga team acts quickly to swap and deposit, the vector exists during the mint period (until all 10,000 BAYC are minted).
Possible mitigations:
- Accept this as a known limitation during the mint phase
- Consider a more complex accounting system that tracks "pending non-ETH royalties" separately
- Pause minting while significant ERC20/ERC721 royalties are being processed (operationally complex)
-
L-01 Low ERC721 Rescue In UserOwnedRoyaltySplitter Logical Error Resolved
Description
The TheSplit contract includes both rescueERC20 and rescueERC721 functions (TheSplit.sol:204-217) to handle royalties received in non-ETH tokens.
However, UserOwnedRoyaltySplitter only implements withdrawERC20 (UserOwnedRoyaltySplitter.sol:60-70) and has no mechanism to handle ERC721 tokens that may be sent to user-owned splitter contracts.
If an ERC721 is sent to a UserOwnedRoyaltySplitter as royalties, it will be permanently stuck.
Recommendation
Consider adding rescue for ERC721 here as well.
-
I-01 Informational Rescinded Ownership And Split Rescue Functions Configuration Acknowledged
Description
The BAYC contract allows the owner to rescind ownership by setting owner = address(0). In this case, the code intentionally treats RoyaltyMachine as the effective owner.
However, in the Split contract, this same returned owner (RoyaltyMachine when owner == address(0)) is responsible for rescuing stuck ERC20 and ERC721 tokens. If RoyaltyMachine does not implement methods to call the Split contract’s rescue functions, those assets become stuck once ownership is rescinded.
Recommendation
Beware of this constraint while developing royalty machine.
-
I-02 Informational Redundant Check In
hasRole()Superfluous Code ResolvedDescription
The
hasRole()function in theBAYCcontract reverts if the role being checked is notaddress(0). However, it checks the role againstaddress(0)again on the next line.function hasRole(bytes32 role, address account) external view returns (bool) { if (role != bytes32(0)) revert UseHasAnyRoleInstead(); return (role == bytes32(0) && hasAnyRole(account, TRANSFER_VALIDATOR_MANAGEMENT_ROLE)); }Recommendation
Remove the second check.
function hasRole(bytes32 role, address account) external view returns (bool) { if (role != bytes32(0)) revert UseHasAnyRoleInstead(); - return (role == bytes32(0) && hasAnyRole(account, TRANSFER_VALIDATOR_MANAGEMENT_ROLE)); + return hasAnyRole(account, TRANSFER_VALIDATOR_MANAGEMENT_ROLE); } -
I-03 Informational Old Recipients May Receive Splits Unexpected Behavior Resolved
Description
TheSplit.updateRecipients()readsrecipientTwoandrecipientThreefromBAYCand updates its storage variables. InBAYC, these are theroyaltyMachineand the owner, or if there is no owner it's twice the royalty machine.If the owner of the
BAYCcontract changes one of the recipients before claiming their share, the split royalties may be forwarded to the old addresses beforeupdateRecipients()is called.Recommendation
Consider removing the
updateRecipients()function and instead fetch the appropriate recipient on each call toclaimYugaLabs()andclaimMachine().
No findings match.
More from Yuga Labs
Put your code through the same review.
This review started with a conversation about scope. Tell us what you are building and we will plan yours with you.
