57432 sc insight royaltiesreceiverv2 fails to distribute full balance when royalties percentages do not sum to 10000
Submitted on Oct 26th 2025 at 07:56:36 UTC by @RoadRunner26383 for Audit Comp | Belong
Report ID: #57432
Report Type: Smart Contract
Report severity: Insight
Target: https://github.com/immunefi-team/audit-comp-belong/blob/main/contracts/v2/periphery/RoyaltiesReceiverV2.sol
Impacts:
Permanent freezing of unclaimed royalties
Description
Brief/Intro
RoyaltiesReceiverV2 uses a hardcoded 10,000 BPS denominator for royalty payout calculations, while Factory permits creator+platform shares to sum to any value ≤ 10,000 BPS. When the configured sum is below 10,000, the difference between total shares and the fixed denominator results in a residual fraction of every royalty payment that cannot be released to any payee, causing permanent freezing of unclaimed royalties for both native currency and ERC20 tokens. This matches the program's in-scope High-severity impact "Permanent freezing of unclaimed royalties."
Vulnerability Details
Affected Assets
Primary affected contract:
contracts/v2/periphery/RoyaltiesReceiverV2.sol — Fixed
TOTAL_SHARES = 10_000used in all_pendingPaymentcalculations for both native and ERC20 releases.
Supporting / contributing component:
contracts/v2/platform/Factory.sol — Royalties parameter validation permits
creatorBps + platformBps ≤ 10_000without requiring equality.
Description
The issue arises due to a mismatch between the royalty denominator fixed at 10,000 in RoyaltiesReceiverV2 and the flexible BPS configuration allowed by Factory.sol (creatorBps + platformBps ≤ 10,000). When the total share is less than 10,000, residual ETH/ERC20 funds become permanently stuck within the RoyaltiesReceiverV2 contract, as the payout function cannot release the remainder to any recipient.
In production, Factory deploys RoyaltiesReceiverV2 via OpenZeppelin's Clones library, and each clone reads royalty shares from the Factory instance during payout calculations.
Root Cause
RoyaltiesReceiverV2 computes each payee's pending payment using the formula:
where TOTAL_SHARES is a constant set to 10_000:
However, Factory's setRoyaltiesParameters function only enforces that the sum of amountToCreator and amountToPlatform is less than or equal to 10,000 BPS:
Residual arises whenever total configured shares are below the fixed denominator: with totalShares = 1,000 BPS and denominator 10,000, the distributed fraction is 1000/10000 = 10% and the residual is 1 - 1000/10000 = 90%, which is never attributable to any payee.
This allows valid configurations where creatorBps + platformBps < 10_000, such as 500 + 500 = 1,000 BPS (10% total). When such a configuration is deployed, the royalty receiver divides incoming funds by 10,000 and allocates only the configured percentage to payees, leaving the remainder permanently stranded in the contract.
Attack/Exploitation Path
No active exploitation is required; the vulnerability is triggered by normal contract usage:
Admin configures Factory with royalties where
amountToCreator + amountToPlatform < 10_000(e.g., creator=500, platform=500).Factory deploys an AccessToken collection via ERC1967Proxy and creates a RoyaltiesReceiverV2 instance via Clones.cloneDeterministic with these parameters.
Royalties (native ETH or ERC20) are sent to the RoyaltiesReceiverV2 by marketplaces or direct transfers.
When
releaseAll()is called, only(creatorBps + platformBps) / 10_000of the total balance is distributed; the remainder stays in the contract.Repeated calls to
releaseAll()never distribute the residual because no payee has shares covering the unallocated fraction, resulting in permanent freezing.
Vulnerable Calculation (excerpt from RoyaltiesReceiverV2.sol):
The function uses TOTAL_SHARES = 10_000 regardless of the actual sum of configured shares, creating a mismatch when total configured shares are below this denominator.
Impact Details
Severity Classification: High – Permanent freezing of unclaimed royalties
Financial Impact
Every royalty payment sent to a misconfigured RoyaltiesReceiverV2 results in a proportional loss:
For a configuration with total shares = 1,000 BPS (10%), 90% of all royalties are permanently frozen.
For total shares = 5,000 BPS (50%), 50% of all royalties are permanently frozen.
Example Scenario
A collection is configured with creator=500 BPS, platform=500 BPS (total 1,000 BPS).
The collection generates 100 ETH in secondary-market royalties.
After
releaseAll()calls, only 10 ETH (5 ETH to creator, 5 ETH to platform) is distributed; 90 ETH remains permanently stuck in the RoyaltiesReceiverV2 with no recovery path.The same loss applies to any ERC20 royalties (e.g., USDC, WETH).
If a collection accrues 1,000,000 USDC in royalties under a 1,000 BPS configuration, approximately 900,000 USDC becomes permanently frozen.
Protocol-Wide Risk
Any collection deployed with misconfigured royalties experiences cumulative loss.
Creators and platform lose revenue, undermining trust in the royalty infrastructure.
The issue affects all RoyaltiesReceiverV2 instances deployed via Factory with non-10,000 share totals.
Why This Qualifies as In-Scope High Impact
The program explicitly lists "Permanent freezing of unclaimed royalties" as an in-scope High-severity impact. This vulnerability deterministically causes this outcome when Factory's allowed configuration permits total shares < 10,000.
References
Affected In-Scope Contract Files:
contracts/v2/periphery/RoyaltiesReceiverV2.sol— TOTAL_SHARES constant, _pendingPayment(), releaseAll();contracts/v2/platform/Factory.sol— Royalties parameter validation.
Program Scope:
Immunefi Audit Comp | Belong – Information page: https://immunefi.com/audit-competition/audit-comp-belong/information/
External Reference:
OpenZeppelin PaymentSplitter implementation (correct use of dynamic share denominator): https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/finance/PaymentSplitter.sol
Belong Contract Documentation & Build Guides:
https://github.com/belongnet/checkin-contracts/tree/main/docs/guides
Testing Environment:
Foundry (forge), Solidity 0.8.27, local VM (no fork required)
Proof-of-Concept link:
https://gist.github.com/DEX0029/658fc088033ad9926a4db1dee968c18d
Proof of Concept
Overview
This proof of concept demonstrates that RoyaltiesReceiverV2 permanently freezes royalty payments when the sum of creator and platform basis points is less than 10,000. The PoC uses the actual Belong contracts deployed via their production patterns:
Factory deployed and initialized via ERC1967Proxy (upgradeable proxy pattern).
RoyaltiesReceiverV2 deployed via OpenZeppelin Clones (EIP-1167).
Tests run against real contract logic without mocks.
The PoC includes three test cases: ERC20 residual freeze, native currency residual freeze, and a control test showing zero residual when shares total exactly 10,000 BPS.
Prerequisites
Git
Foundry (forge)
Basic familiarity with Solidity 0.8.27
Cloned Belong audit competition repository from Immunefi
Setup and PoC Steps
Complete PoC Implementation (test/RoyaltiesResidualFreeze_Valid.t.sol)
Running the PoC
The three tests pass and demonstrate:
Residual freeze for ERC20 and native currency when total shares < 10,000.
No residual when total shares == 10,000.
An example verbose output from forge test -vvvv is included in the original report (omitted here for brevity).
Test Breakdown
test_ERC20_RoyaltiesResidualFreeze
Factory configured with creator=500 BPS, platform=500 BPS (10% total).
Transfer 100e18 tokens to receiver.
releaseAll(token)distributes 5e18 to creator, 5e18 to platform; 90e18 remains. Second call distributes nothing further.
test_Native_RoyaltiesResidualFreeze
Same 500/500 BPS configuration.
Fund receiver with 100 ETH.
releaseAll(NATIVE_CURRENCY_ADDRESS)distributes 5 ETH to creator, 5 ETH to platform; 90 ETH remains. Second call distributes nothing further.
test_Control_NoResidual_WhenTotalShares_10000
Factory configured with creator=6000 BPS, platform=4000 BPS (100% total).
Transfer 100e18 tokens to receiver.
releaseAll(token)distributes full 60e18/40e18, leaving 0 residual.
Recommended Fixes
Primary Fix: Dynamic Share Denominator Replace the fixed
TOTAL_SHARES = 10_000with dynamic calculation using the actual sum of configured shares. Example adjustment to_pendingPayment:
Benefit: Ensures full distributability regardless of configured share percentages.
Alternative Fix: Enforce creatorBps + platformBps == 10,000 at configuration Modify Factory validation to require exact 10,000 total:
Benefit: Keeps fixed denominator invariant intact.
Recommended additional measures:
Add emergency withdrawal function with timelock for recovering stuck funds after grace period.
Validate share totals in RoyaltiesReceiverV2 initialization to reject misconfigured deployments.
Add residual tracking view function to expose unallocated balance for monitoring.
Extend tests to cover edge cases (single payee, zero shares, referral configurations).
Responsible Disclosure
This vulnerability was discovered during the Immunefi Audit Competition for Belong Network and is disclosed according to the program's Responsible Publication Category 3 policy. The issue affects in-scope smart contracts within the competition's listed assets and aligns with the program's high-severity impact: "Permanent freezing of unclaimed royalties."
Conclusion
The PoC conclusively demonstrates that RoyaltiesReceiverV2 permanently freezes a proportional amount of every royalty payment when configured with share totals below 10,000 BPS. The vulnerability is deterministic, irreversible under current contract interfaces, affects both native currency and ERC20 tokens, and directly maps to the program's listed High-severity impact.
If you want, I can:
Produce a minimal patch diff for RoyaltiesReceiverV2 showing the dynamic share change.
Produce a small Factory patch enforcing equality to 10,000 if you prefer the invariant approach.
Was this helpful?