Skip to content

fix(MarketUtilsV2): pay out full remaining amount on indivisible ETH splits#24

Open
charlescrain wants to merge 1 commit into
mainfrom
charlescrain/marketutilsv2-eth-split-rounding-revert
Open

fix(MarketUtilsV2): pay out full remaining amount on indivisible ETH splits#24
charlescrain wants to merge 1 commit into
mainfrom
charlescrain/marketutilsv2-eth-split-rounding-revert

Conversation

@charlescrain

Copy link
Copy Markdown

Problem

MarketUtilsV2.payoutWithMarketplaceFee computes each split recipient's cut by flooring:

splitAmounts[i] = (remainingAmount * _splitRatios[i]) / 100;

but passes the un-floored remainingAmount as the total to performPayouts. On the ETH path, Payments.payout enforces require(msg.value == sum(amounts)). Since integer division drops the remainder, the floored amounts almost always sum to less than remainingAmount, and the call reverts with payout::not enough sent.

This triggers in the common case, not an edge case: after marketplace fee, primary commission, and royalties are subtracted, remainingAmount is an arbitrary wei value, and any multi-recipient split (or a single recipient whose ratio doesn't divide cleanly) leaves a rounding remainder. Auction bids use a 1% minimum increment (minimumBidIncreasePercentage = 1), so winning bids are essentially never round.

Impact

  • RareBatchAuctionHouse.settleAuction — unrecoverable. The winning bid's ETH and the NFT are escrowed and settlement is the only release path, so funds and token are locked permanently.
  • RareBatchListingMarketplace.buyWithMerkleProof — same revert; recoverable by re-registering with a single clean split.

The bug is ETH-only — the ERC20 branch of performPayouts transfers per-recipient and ignores the total, which is why the existing (ERC20-based) settle tests never caught it.

This is a regression: v1 MarketUtils.payout passed the summed amounts; v2 passed remainingAmount.

Fix

Assign the rounding remainder to the last recipient so the per-recipient amounts sum to remainingAmount exactly — distributing the seller's full proceeds with no dust stranded in the contract.

Tests (TDD)

  • Added test_payout_multipleSplits_indivisibleAmount (odd-wei amount, 50/50 ETH split). Confirmed it failed with payout::not enough sent before the fix and passes after.
  • All 33 MarketUtilsV2 tests pass; all 113 auction + marketplace tests pass.

Deployment

Both contracts are UUPS proxies, so this ships as an implementation swap with no state migration. Added owner-broadcast upgrade scripts:

  • script/auctionhouse/rare-batch-auctionhouse-upgrade/ (env: PRIVATE_KEY, RARE_BATCH_AUCTIONHOUSE)
  • script/marketplace/rare-batch-listing-marketplace-upgrade/ (env: PRIVATE_KEY, RARE_BATCH_LISTING_MARKETPLACE)

Each deploys a fresh implementation and calls upgradeTo(newImpl) on the proxy.

🤖 Generated with Claude Code

…splits

payoutWithMarketplaceFee floored each split recipient's share but passed
the un-floored remainingAmount as the total to performPayouts. When the
floored amounts don't sum back to remainingAmount (the common case for
non-round amounts, e.g. 1%-increment auction bids), Payments.payout's
require(msg.value == sum(amounts)) reverts on the ETH path.

For RareBatchAuctionHouse.settleAuction this is unrecoverable: the winning
bid and NFT are escrowed and settlement is the only release path, so the
funds and token are locked permanently. RareBatchListingMarketplace.
buyWithMerkleProof hits the same revert (recoverable by re-registering).
v1 MarketUtils.payout passed the summed amounts; v2 regressed this.

Fix: assign the rounding remainder to the last recipient so the per-
recipient amounts sum to remainingAmount exactly, distributing the full
proceeds with no dust stranded in the contract.

Also adds upgrade scripts to swap the implementation behind each UUPS proxy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant