diff --git a/contracts/Market.sol b/contracts/Market.sol index 7bd19dd..241bf6c 100644 --- a/contracts/Market.sol +++ b/contracts/Market.sol @@ -204,12 +204,23 @@ contract Market is ERC721, IERC2981 { emit ManagementReward(marketInfo.manager, managementReward); } - /** @dev Registers the points obtained by a bet after the results are known. Ranking should be filled - * in descending order. Bets which points were not registered cannot claimed rewards even if they - * got more points than the ones registered. + /** @dev Registers the points obtained by a bet after the results are known. + * + * The ranking is built incrementally by external callers. It maintains the invariant: + * ranking[N].points >= ranking[N+1].points (non-increasing order) + * + * There are two placement paths: + * 1. Overwrite: token has MORE points than the current occupant at _rankIndex. + * The occupant is evicted (isRanked set to false) and can be re-registered lower. + * 2. Duplicate: token has the SAME points as _rankIndex. It is appended at the end + * of the contiguous group of equal scores, at position _rankIndex + _duplicates. + * + * If neither condition is met (token has fewer points than _rankIndex), the call is a + * no-op: no revert, no state change. The caller should retry with a lower _rankIndex. + * * @param _tokenID The token id of the bet which points are going to be registered. * @param _rankIndex The alleged ranking position the bet belongs to. - * @param _duplicates The alleged number of tokens that are already registered and have the same points as _tokenID. + * @param _duplicates The number of tokens already registered with the same score at _rankIndex. */ function registerPoints( uint256 _tokenID, @@ -233,14 +244,14 @@ contract Market is ERC721, IERC2981 { } require(totalPoints > 0, "You are not a winner"); - // This ensures that ranking[N].points >= ranking[N+1].points always + // Enforce non-increasing order: must have strictly fewer points than the rank above. require( _rankIndex == 0 || totalPoints < ranking[_rankIndex - 1].points, "Invalid ranking index" ); if (totalPoints > ranking[_rankIndex].points) { + // Path 1: Overwrite — evict the weaker occupant. if (ranking[_rankIndex].points > 0) { - // Rank position is being overwritten isRanked[ranking[_rankIndex].tokenID] = false; } ranking[_rankIndex].tokenID = _tokenID; @@ -248,11 +259,11 @@ contract Market is ERC721, IERC2981 { isRanked[_tokenID] = true; emit RankingUpdated(_tokenID, totalPoints, _rankIndex); } else if (ranking[_rankIndex].points == totalPoints) { + // Path 2: Duplicate — place at the end of the equal-score group. uint256 realRankIndex = _rankIndex + _duplicates; require(totalPoints > ranking[realRankIndex].points, "Wrong _duplicates amount"); require(totalPoints == ranking[realRankIndex - 1].points, "Wrong _duplicates amount"); if (ranking[realRankIndex].points > 0) { - // Rank position is being overwritten isRanked[ranking[realRankIndex].tokenID] = false; } ranking[realRankIndex].tokenID = _tokenID; @@ -262,9 +273,16 @@ contract Market is ERC721, IERC2981 { } } - /** @dev Register all winning bets and move the contract state to the claiming phase. - * This function is gas intensive and might not be available for markets in which lots of - * bets have been placed. + /** @dev Computes the full ranking in one transaction and skips the submission timeout. + * Gas-intensive: only practical for small markets. For large markets use registerPoints(). + * + * Algorithm overview: + * - Each element in auxRanking packs points in the lower 128 bits and tokenID in the upper 128. + * CLEAN_TOKEN_ID (uint128.max) is used as a bitmask to extract the points component. + * - Maintains a sorted top-N window (N = prizeWeights.length). Tokens scoring below currentMin + * are skipped once the window is full. Tokens tying with currentMin are kept (for prize sharing). + * - When a new high score appears, the array is re-sorted and elements below currentMin are trimmed. + * - Sets resultSubmissionPeriodStart = 1 (sentinel) to allow immediate claiming. */ function registerAll() external { require(resultSubmissionPeriodStart != 0, "Not in submission period"); @@ -280,7 +298,9 @@ contract Market is ERC721, IERC2981 { } uint256 rankingLength = nextTokenID < prizeWeights.length ? prizeWeights.length : nextTokenID; - uint256[] memory auxRanking = new uint256[](rankingLength); + // +1 provides a zero sentinel so the trim loop below can safely read auxRanking[freePos] + // when freePos == rankingLength. + uint256[] memory auxRanking = new uint256[](rankingLength + 1); uint256 currentMin; uint256 freePos; for (uint256 tokenID = 0; tokenID < nextTokenID; tokenID++) { @@ -290,24 +310,30 @@ contract Market is ERC721, IERC2981 { if (betData.predictions[i] == results[i]) totalPoints += 1; } + // Skip tokens with 0 points, or those below the cutoff once the window is full. if (totalPoints == 0 || (totalPoints < currentMin && freePos >= prizeWeights.length)) continue; auxRanking[freePos++] = totalPoints | (tokenID << 128); if (totalPoints > currentMin) { + // New high score entered — re-sort descending and update the cutoff. sort(auxRanking, 0, int256(freePos - 1)); currentMin = auxRanking[prizeWeights.length - 1] & CLEAN_TOKEN_ID; if (freePos > prizeWeights.length) { + // Trim tail entries that fell below the new cutoff. The sentinel at + // auxRanking[rankingLength] is 0, so this loop always enters safely. while (currentMin > auxRanking[freePos] & CLEAN_TOKEN_ID) freePos--; freePos++; } } else if (totalPoints < currentMin) { + // A score below the current window entered (only happens while window isn't full). currentMin = totalPoints; } } + // Write the final ranking to storage. for (uint256 rankIndex = 0; rankIndex < freePos; rankIndex++) { uint256 tokenID = auxRanking[rankIndex] >> 128; uint256 totalPoints = auxRanking[rankIndex] & CLEAN_TOKEN_ID; @@ -316,9 +342,11 @@ contract Market is ERC721, IERC2981 { emit RankingUpdated(tokenID, totalPoints, rankIndex); } + // Sentinel: bypass the submission timeout so claimRewards() is immediately available. resultSubmissionPeriodStart = 1; } + /// @dev Quicksort descending by points (lower 128 bits). Token IDs ride along in upper bits. function sort( uint256[] memory arr, int256 left, @@ -341,10 +369,15 @@ contract Market is ERC721, IERC2981 { if (i < right) sort(arr, i, right); } - /** @dev Sends a prize to the token holder if applicable. + /** @dev Sends a prize to the current NFT holder for a given ranking position. + * + * Callers must specify the maximal contiguous range of equal scores that contains _rankIndex. + * The four require checks enforce this: [_firstSharedIndex, _lastSharedIndex] must be the + * exact boundaries of the score group (no narrowing allowed, which would inflate shares). + * * @param _rankIndex The ranking position of the bet which reward is being claimed. - * @param _firstSharedIndex If there are many tokens sharing the same score, this is the first ranking position of the batch. - * @param _lastSharedIndex If there are many tokens sharing the same score, this is the last ranking position of the batch. + * @param _firstSharedIndex First ranking position with the same score as _rankIndex. + * @param _lastSharedIndex Last ranking position with the same score as _rankIndex. */ function claimRewards( uint256 _rankIndex, @@ -359,7 +392,6 @@ contract Market is ERC721, IERC2981 { require(!ranking[_rankIndex].claimed, "Already claimed"); uint248 points = ranking[_rankIndex].points; - // Check that shared indexes are valid. require(points == ranking[_firstSharedIndex].points, "Wrong start index"); require(points == ranking[_lastSharedIndex].points, "Wrong end index"); require(points > ranking[_lastSharedIndex + 1].points, "Wrong end index"); @@ -392,6 +424,15 @@ contract Market is ERC721, IERC2981 { requireSendXDAI(payable(player), reimbursement); } + /** @dev Calculates the reward for one member of a shared-score group. + * + * 1. Sum the prizeWeights that fall within [_firstSharedIndex, _lastSharedIndex]. + * Positions beyond prizeWeights.length contribute 0. + * 2. Vacancy redistribution: if prize positions at the bottom of the ranking are unfilled + * (points == 0), their weight is redistributed equally among ALL filled positions. + * Each filled position gets vacantWeight / (j+1), where j+1 = number of filled positions. + * 3. The group's total weight is divided equally among its members (sharedBetween). + */ function getReward( uint256 _firstSharedIndex, uint256 _lastSharedIndex, @@ -405,12 +446,14 @@ contract Market is ERC721, IERC2981 { i++; } if (i < prizeWeights.length) { + // Some prize positions are beyond this group — check for vacant ones at the tail. uint256 vacantPrizesWeight = 0; uint256 j = prizeWeights.length - 1; while (ranking[j].points == 0) { vacantPrizesWeight += prizeWeights[j]; j--; } + // Distribute vacant weight equally: this group's share is proportional to its size. cumWeigths += (sharedBetween * vacantPrizesWeight) / (j + 1); } reward = (_totalPrize * cumWeigths) / (DIVISOR * sharedBetween); diff --git a/contracts/ads/Billing.sol b/contracts/ads/Billing.sol index 1a553d3..aee1c24 100644 --- a/contracts/ads/Billing.sol +++ b/contracts/ads/Billing.sol @@ -36,7 +36,7 @@ contract Billing { /** @dev Creates and places a new bid or replaces one that has been removed. * @param _market The address of the market the bid will be placed to. */ - function executePayment(IMarket _market) external payable { + function executePayment(IMarket _market) external { uint256 revenue = balances[_market]; balances[_market] = 0; diff --git a/contracts/ads/FirstPriceAuction.sol b/contracts/ads/FirstPriceAuction.sol index 7477d77..59917de 100644 --- a/contracts/ads/FirstPriceAuction.sol +++ b/contracts/ads/FirstPriceAuction.sol @@ -17,19 +17,32 @@ interface IBilling { function registerPayment(address _market) external payable; } +/** @title FirstPriceAuction + * @dev Continuous first-price auction for ad slots on prediction markets' NFT positions. + * Bidders deposit xDAI and set a `bidPerSecond` rate. The highest bidder's ad is displayed + * and charged `bidPerSecond * elapsed` while active. When their balance runs out or their + * curate item is deregistered, the next highest bidder takes over. + * + * Bids are stored in a per-market doubly-linked list sorted descending by `bidPerSecond`. + * - Sentinel (head) node: bids[keccak256(abi.encode(market))]. + * - Bid ID: keccak256(abi.encode(market, itemID, sender)) — one bid per (market, item, bidder). + * - List terminator: 0x0 (end of list). + * Only the highest bid (sentinel.nextBidPointer) accrues charges. + */ contract FirstPriceAuction { struct Bid { bytes32 previousBidPointer; bytes32 nextBidPointer; address bidder; - uint64 startTimestamp; + uint64 startTimestamp; // When this bid last became the highest. Only meaningful for the active highest bid. bool removed; uint256 bidPerSecond; uint256 balance; bytes32 itemID; // on curate } - uint256 public constant MIN_OFFER_DURATION = 1800; // 30 min. Can be avoided if manually removed. + /// @dev Minimum seconds a bid must be fundable for. Prevents spam bids that drain immediately. + uint256 public constant MIN_OFFER_DURATION = 1800; ICurate public curatedAds; IBilling public billing; @@ -50,10 +63,14 @@ contract FirstPriceAuction { billing = _billing; } - /** @dev Creates and places a new bid or replaces an existing one. - * @param _itemID The id of curated ad. - * @param _market The address of the market the bid will be placed to. - * @param _bidPerSecond The amount of tokens (xDAI) per second that will be payed if the bid gets the highest position, now or at some point. + /** @dev Creates a new bid, tops up an existing one, or re-places a bid with a new rate. + * Handles three cases based on bid state: + * - New/removed bid: inserts into the sorted list. + * - Active bid, same rate: tops up balance without moving list position. + * - Active bid, different rate: removes from old position, re-inserts at new position. + * @param _itemID The id of the curated ad on the Curate registry. + * @param _market The address of the market to bid on. + * @param _bidPerSecond The xDAI per second rate charged while this bid is the highest. */ function placeBid( bytes32 _itemID, @@ -67,7 +84,9 @@ contract FirstPriceAuction { bytes32 bidID = keccak256(abi.encode(_market, _itemID, msg.sender)); Bid storage bid = bids[bidID]; + // If rate unchanged, just top up balance without touching the list. bool increaseBalanceOnly = bid.bidPerSecond == _bidPerSecond; + // Active bid with changed rate: remove from old position before re-inserting. if (bid.bidder == msg.sender && !bid.removed && !increaseBalanceOnly) { _forceRemoveBid(_itemID, _market); } @@ -77,6 +96,7 @@ contract FirstPriceAuction { bid.itemID = _itemID; require(bid.balance / _bidPerSecond > MIN_OFFER_DURATION, "Not enough funds"); + // Insert into list if: new bid, previously removed, or rate changed. if (bid.bidder != msg.sender || bid.removed || !increaseBalanceOnly) { _insertBid(_market, bidID); } @@ -86,15 +106,19 @@ contract FirstPriceAuction { emit BidUpdate(_market, msg.sender, _itemID, _bidPerSecond, bid.balance); } - /** @dev Removes bid. - * @param _itemID The id of curated ad. - * @param _market The address of the market the bid will be placed to. + /** @dev Removes a bid from the linked list without settling payment or marking it as removed. + * Only called from placeBid when re-placing with a different rate. Safe because: + * - executeHighestBid already settled charges earlier in the same tx (elapsed = 0). + * - _insertBid immediately re-inserts the bid, so `removed` stays false. + * @param _itemID The id of the curated ad. + * @param _market The market address. */ function _forceRemoveBid(bytes32 _itemID, address _market) internal { bytes32 bidID = keccak256(abi.encode(_market, _itemID, msg.sender)); Bid storage bid = bids[bidID]; bytes32 startID = keccak256(abi.encode(_market)); + // If removing the highest bid, activate the next one. if (bid.previousBidPointer == startID) { if (bid.nextBidPointer != 0x0) { Bid storage newHighestBid = bids[bid.nextBidPointer]; @@ -107,9 +131,11 @@ contract FirstPriceAuction { bids[bid.previousBidPointer].nextBidPointer = bid.nextBidPointer; } - /** @dev Removes an existing bid. - * @param _itemID The id of curated ad. - * @param _market The address of the market the bid will be placed to. + /** @dev Removes a bid from the market and refunds the remaining balance. + * If the bid was the highest, accrued charges are settled before refunding. + * Non-highest bids have no accrued charges and are refunded in full. + * @param _itemID The id of the curated ad. + * @param _market The market address. */ function removeBid(bytes32 _itemID, address _market) external { bytes32 bidID = keccak256(abi.encode(_market, _itemID, msg.sender)); @@ -122,6 +148,7 @@ contract FirstPriceAuction { bytes32 startID = keccak256(abi.encode(_market)); if (bid.previousBidPointer == startID) { + // Was the highest bid — settle accrued charges. This reduces bid.balance. _registerPayment(bid, _market); if (bid.nextBidPointer != 0x0) { @@ -133,15 +160,19 @@ contract FirstPriceAuction { emit BidUpdate(_market, msg.sender, _itemID, bid.bidPerSecond, 0); + // Refund post-billing remainder. balance is zeroed before the external call (CEI). uint256 remainingBalance = bid.balance; bid.balance = 0; requireSendXDAI(payable(msg.sender), remainingBalance); } - /** @dev Either: - * - removes the current highest bid if the balance is empty or if it was removed from curate. - * - collects the current highest bid billing. - * @param _market The address of the market. + /** @dev Settles the current highest bid for a market. Two outcomes: + * - Drain: if balance is exhausted or curate item deregistered, the bid is removed + * and its entire remaining balance is sent to billing. + * - Collect: otherwise, accrued charges are collected and the bid continues. + * Called automatically at the start of placeBid. Can also be called externally. + * Only processes one bid per call — if the next bid is also drained, another call is needed. + * @param _market The market address. */ function executeHighestBid(address _market) public { bytes32 startID = keccak256(abi.encode(_market)); @@ -152,7 +183,7 @@ contract FirstPriceAuction { uint256 price = (block.timestamp - bid.startTimestamp) * bid.bidPerSecond; if (price >= bid.balance || !curatedAds.isRegistered(bid.itemID)) { - // Report bid + // Bid is drained or deregistered — remove and forfeit entire balance. bid.removed = true; bids[bid.nextBidPointer].previousBidPointer = bid.previousBidPointer; bids[bid.previousBidPointer].nextBidPointer = bid.nextBidPointer; @@ -168,7 +199,7 @@ contract FirstPriceAuction { emit NewHighestBid(_market, newHighestBid.bidder, newHighestBid.itemID); } } else { - // Collect payment from active bid + // Bid is still active — collect accrued charges and reset the billing period. bid.startTimestamp = uint64(block.timestamp); bid.balance -= price; billing.registerPayment{value: price}(_market); @@ -177,12 +208,18 @@ contract FirstPriceAuction { emit BidUpdate(_market, bid.bidder, bid.itemID, bid.bidPerSecond, bid.balance); } + /** @dev Inserts a bid into the per-market linked list at the correct position (descending by bidPerSecond). + * Equal rates are placed after existing bids (FIFO). If the bid becomes the new highest, + * the displaced bid's accrued charges are settled and it's removed if fully drained. + * @param _market The market address. + * @param _bidID The bid's storage key. + */ function _insertBid(address _market, bytes32 _bidID) internal { - // Insert the bid in the ordered list. Bid storage bid = bids[_bidID]; bytes32 startID = keccak256(abi.encode(_market)); bytes32 currentID = startID; bytes32 nextID = bids[startID].nextBidPointer; + // Walk the list until we find a bid with a lower rate (or the end). bids[0x0].bidPerSecond == 0 terminates. while (bids[nextID].bidPerSecond >= bid.bidPerSecond) { currentID = nextID; nextID = bids[nextID].nextBidPointer; @@ -193,15 +230,15 @@ contract FirstPriceAuction { bids[nextID].previousBidPointer = _bidID; if (currentID == startID) { - // Highest bid! Activate the new bid and process the previous highest bid. + // New highest bid — start billing from now. bid.startTimestamp = uint64(block.timestamp); if (nextID != 0x0) { + // Settle the displaced highest bid's accrued charges. Bid storage nextBid = bids[nextID]; _registerPayment(nextBid, _market); if (nextBid.balance == 0) { - // remove bid from list. nextBid.removed = true; bids[nextBid.nextBidPointer].previousBidPointer = _bidID; bid.nextBidPointer = nextBid.nextBidPointer; @@ -218,6 +255,12 @@ contract FirstPriceAuction { } } + /** @dev Settles a bid's accrued charges and sends them to billing. Caps the bill at the + * bid's remaining balance to prevent underflow. Resets startTimestamp to 0 since the bid + * is no longer the active highest. + * @param _bid The bid to settle. + * @param _market The market address (forwarded to billing). + */ function _registerPayment(Bid storage _bid, address _market) internal { uint256 price = (block.timestamp - _bid.startTimestamp) * _bid.bidPerSecond; uint256 bill = price > _bid.balance ? _bid.balance : price; @@ -226,11 +269,17 @@ contract FirstPriceAuction { billing.registerPayment{value: bill}(_market); } + /// @dev Transfers xDAI to an address, reverting on failure. function requireSendXDAI(address payable _to, uint256 _value) internal { (bool success, ) = _to.call{value: _value}(new bytes(0)); require(success, "Send XDAI failed"); } + /** @dev Returns the SVG ad content for the current highest bidder on a market. + * Returns empty string if no active bid, the ad contract is an EOA, or the SVG call fails. + * @param _market The market address. + * @param _tokenID The token ID to render the ad for. + */ function getAd(address _market, uint256 _tokenID) external view returns (string memory) { bytes32 startID = keccak256(abi.encode(_market)); bytes32 highestBidID = bids[startID].nextBidPointer; @@ -248,6 +297,10 @@ contract FirstPriceAuction { } } + /** @dev Returns the reference URL for the current highest bidder's ad. + * @param _market The market address. + * @param _tokenID The token ID to get the reference for. + */ function getRef(address _market, uint256 _tokenID) external view returns (string memory) { bytes32 startID = keccak256(abi.encode(_market)); bytes32 highestBidID = bids[startID].nextBidPointer; @@ -265,6 +318,11 @@ contract FirstPriceAuction { } } + /** @dev Returns a paginated slice of bids for a market, ordered by bidPerSecond descending. + * @param _market The market address. + * @param _from Start index (inclusive, 0-based). + * @param _to End index (exclusive). Array size = _to - _from. + */ function getBids( address _market, uint256 _from, @@ -272,17 +330,15 @@ contract FirstPriceAuction { ) external view returns (Bid[] memory) { Bid[] memory bidsArray = new Bid[](_to - _from); bytes32 startID = keccak256(abi.encode(_market)); - bytes32 currentID = startID; bytes32 nextID = bids[startID].nextBidPointer; - for (uint256 i = 0; i <= _to; i++) { + for (uint256 i = 0; i < _to; i++) { + if (nextID == 0x0) break; + if (i >= _from) { - bidsArray[i] = bids[nextID]; + bidsArray[i - _from] = bids[nextID]; } - if (bids[nextID].nextBidPointer == 0x0) break; - - currentID = nextID; nextID = bids[nextID].nextBidPointer; }