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 | Belongarrow-up-right

  • 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_000 used in all _pendingPayment calculations for both native and ERC20 releases.

  • Supporting / contributing component:

    • contracts/v2/platform/Factory.sol — Royalties parameter validation permits creatorBps + platformBps ≤ 10_000 without 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:

  1. Admin configures Factory with royalties where amountToCreator + amountToPlatform < 10_000 (e.g., creator=500, platform=500).

  2. Factory deploys an AccessToken collection via ERC1967Proxy and creates a RoyaltiesReceiverV2 instance via Clones.cloneDeterministic with these parameters.

  3. Royalties (native ETH or ERC20) are sent to the RoyaltiesReceiverV2 by marketplaces or direct transfers.

  4. When releaseAll() is called, only (creatorBps + platformBps) / 10_000 of the total balance is distributed; the remainder stays in the contract.

  5. 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

  1. A collection is configured with creator=500 BPS, platform=500 BPS (total 1,000 BPS).

  2. The collection generates 100 ETH in secondary-market royalties.

  3. 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.

  4. The same loss applies to any ERC20 royalties (e.g., USDC, WETH).

  5. 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

1

Clone repository

2

Install dependencies

3

Create the test file

4

Configure foundry.toml (if needed)

Example settings (optional):

If compilation issues occur, you can create a separate directory with only the two relevant contracts (RoyaltiesReceiverV2 and Factory) and the test file.

5

Save the PoC test implementation

Save the provided test file contents to test/RoyaltiesResidualFreeze_Valid.t.sol (full implementation included below).

6

Run tests

Run the full test suite with verbose tracing:

Or run individual tests:

  • ERC20 Residual Freeze Test:

  • Native Currency Residual Freeze Test:

  • Control Test (No Residual):

Optional Gas Report:

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

  1. 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.

  2. 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.

  3. 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.


  1. Primary Fix: Dynamic Share Denominator Replace the fixed TOTAL_SHARES = 10_000 with dynamic calculation using the actual sum of configured shares. Example adjustment to _pendingPayment:

Benefit: Ensures full distributability regardless of configured share percentages.

  1. 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?