// SPDX-License-Identifier: MIT pragma solidity ^0.8.30; import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol"; /// @notice Escrowed fixed-price COWB sales. Buyer pays seller directly; no protocol fees. contract BullMarket is IERC721Receiver, Ownable2Step, ReentrancyGuard, Pausable { using SafeERC20 for IERC20; IERC721 public immutable bulls; IERC20 public immutable paymentToken; struct Listing { address seller; uint256 price; uint64 expiresAt; uint64 nonce; } mapping(uint256 => Listing) public listings; uint256[] private _active; mapping(uint256 => uint256) private _positions; uint64 private _nonce; event Listed(uint256 indexed tokenId, address indexed seller, uint256 price, uint64 expiresAt, uint64 nonce); event Cancelled(uint256 indexed tokenId, address indexed seller, uint64 nonce); event Purchased(uint256 indexed tokenId, address indexed buyer, address indexed seller, uint256 price, uint64 nonce); error InvalidContract(); error InvalidListing(); error NotSeller(); error StaleListing(); error Expired(); error SelfPurchase(); error UnsupportedPayment(); error UnexpectedNFT(); constructor(address nft, address token, address operator) Ownable(operator) { if (nft.code.length == 0 || token.code.length == 0 || nft == token) revert InvalidContract(); bulls = IERC721(nft); paymentToken = IERC20(token); } function list(uint256 id, uint256 price, uint64 expiresAt) external nonReentrant whenNotPaused { if (price == 0 || listings[id].seller != address(0) || expiresAt <= block.timestamp || expiresAt > block.timestamp + 30 days || bulls.ownerOf(id) != msg.sender) revert InvalidListing(); uint64 nonce = ++_nonce; listings[id] = Listing(msg.sender, price, expiresAt, nonce); _positions[id] = _active.length; _active.push(id); bulls.safeTransferFrom(msg.sender, address(this), id); emit Listed(id, msg.sender, price, expiresAt, nonce); } /// @notice The seller can always recover escrow, even while paused or after expiration. function cancel(uint256 id) external nonReentrant { Listing memory offer = listings[id]; if (offer.seller != msg.sender) revert NotSeller(); _remove(id); bulls.safeTransferFrom(address(this), msg.sender, id); emit Cancelled(id, msg.sender, offer.nonce); } /// @notice Exact displayed listing nonce and price must match, protecting stale quotes. function buy(uint256 id, uint64 expectedNonce, uint256 expectedPrice) external nonReentrant whenNotPaused { Listing memory offer = listings[id]; if (offer.seller == address(0) || offer.nonce != expectedNonce || offer.price != expectedPrice) revert StaleListing(); if (block.timestamp >= offer.expiresAt) revert Expired(); if (msg.sender == offer.seller) revert SelfPurchase(); _remove(id); uint256 beforeBalance = paymentToken.balanceOf(offer.seller); paymentToken.safeTransferFrom(msg.sender, offer.seller, offer.price); uint256 afterBalance = paymentToken.balanceOf(offer.seller); if (afterBalance < beforeBalance || afterBalance - beforeBalance != offer.price) revert UnsupportedPayment(); bulls.safeTransferFrom(address(this), msg.sender, id); emit Purchased(id, msg.sender, offer.seller, offer.price, offer.nonce); } function activeCount() external view returns (uint256) { return _active.length; } function activeTokenIds(uint256 offset, uint256 limit) external view returns (uint256[] memory ids) { if (limit > 50) limit = 50; if (offset >= _active.length) return new uint256[](0); uint256 end = offset + limit; if (end > _active.length) end = _active.length; ids = new uint256[](end - offset); for (uint256 i = offset; i < end; ++i) ids[i - offset] = _active[i]; } function setPaused(bool value) external onlyOwner { if (value) _pause(); else _unpause(); } function onERC721Received(address operator, address, uint256 id, bytes calldata) external view returns (bytes4) { if (msg.sender != address(bulls) || operator != address(this) || listings[id].seller == address(0)) revert UnexpectedNFT(); return IERC721Receiver.onERC721Received.selector; } function _remove(uint256 id) private { uint256 index = _positions[id]; uint256 last = _active[_active.length - 1]; _active[index] = last; _positions[last] = index; _active.pop(); delete _positions[id]; delete listings[id]; } }