// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; import {ERC721Enumerable} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; import {Base64} from "@openzeppelin/contracts/utils/Base64.sol"; /// @notice Redeemable NFT editions with fee-funded, isolated-margin perpetual positions. /// @dev The underlying is THIS contract's bounded curve price, not an external NFT floor. contract JPEGCurveMarket is ERC721Enumerable, ReentrancyGuard { uint256 public constant BPS = 10000; uint256 public constant WAD = 1e18; uint256 public constant CURVE_FEE_BPS = 500; uint256 public constant TRADE_FEE_BPS = 10; uint256 public constant BORROW_BPS_PER_DAY = 5; uint256 public constant MAINTENANCE_BPS = 500; uint256 public constant MIN_MARGIN = 0.000001 ether; address public immutable creator; uint256 public immutable maxSupply; uint256 public immutable basePrice; uint256 public immutable priceStep; uint256 public immutable maximumPrice; uint256 public immutable graduationTarget; uint256 public immutable maxGrossQuantity; string public imageURI; string public description; uint256 public circulatingSupply; uint256 public nextTokenId = 1; uint256 public curveReserve; uint256 public perpReserve; uint256 public reservedProfit; uint256 public totalMargin; uint256 public totalCredits; uint256 public curveFeesCollected; uint256 public totalVolume; uint256 public grossQuantity; uint256 public nextPositionId = 1; bool public perpsEnabled; uint64 public activatedAt; mapping(address => uint256) public credits; enum PositionStatus { None, Open, Closed, Liquidated } struct Position { address trader; bool isLong; uint8 leverage; uint64 openedAt; uint256 margin; uint256 notional; uint256 quantity; uint256 entryPrice; uint256 profitReserve; PositionStatus status; } mapping(uint256 => Position) public positions; uint256[] private active; mapping(uint256 => uint256) private activeIndex; event CurveTrade(address indexed trader, bool isBuy, uint256 quantity, uint256 principal, uint256 fee, uint256 priceAfter, uint256 supplyAfter, uint256 firstTokenId, uint64 timestamp); event PerpsActivated(uint256 reserve, uint256 target, uint64 activatedAt); event PositionOpened(uint256 indexed id, address indexed trader, bool isLong, uint256 margin, uint256 notional, uint256 quantity, uint256 entryPrice, uint256 profitReserve); event PositionClosed(uint256 indexed id, uint256 exitPrice, uint256 payout, uint256 borrowFee, uint256 closeFee, bool liquidated, address executor, uint256 reward); event CreditWithdrawn(address indexed owner, address indexed recipient, uint256 amount); error InvalidTerms(); error Expired(); error Slippage(); error IncorrectPayment(); error SoldOut(); error NotOwner(); error MarketNotOpen(); error ExposureLimit(); error InsufficientLiquidity(); error InvalidPosition(); error NotLiquidatable(); error NoCredit(); error TransferFailed(); constructor(string memory name_, string memory symbol_, string memory image_, string memory description_, uint256 supply_, uint256 base_, address creator_) ERC721(name_, symbol_) { bytes memory uri=bytes(image_); if(bytes(name_).length==0 || bytes(name_).length>64 || bytes(symbol_).length<2 || bytes(symbol_).length>10 || uri.length>500 || bytes(description_).length>1500 || !_validURI(uri) || creator_==address(0) || supply_<10 || supply_>10000 || base_<1e12 || base_>1 ether) revert InvalidTerms(); creator=creator_; maxSupply=supply_; basePrice=base_; uint256 step=(base_+supply_-1)/supply_; priceStep=step; maximumPrice=base_+step*supply_; uint256 allPrincipal=supply_*base_+step*supply_*(supply_-1)/2; uint256 target=(_fee(allPrincipal,CURVE_FEE_BPS)+3)/4; graduationTarget=target; // Every curve trade contributes >= f*b*abs(deltaSupply) to the reserve. // This cap bounds aggregate positive mark-to-market movement by that fee. uint256 capacity=Math.mulDiv(base_,CURVE_FEE_BPS*WAD,2*BPS*step); if(Math.mulDiv(capacity,base_,WAD)<2*MIN_MARGIN || target50) revert InvalidTerms(); uint256 length=cursor>=active.length?0:Math.min(limit,active.length-cursor); ids=new uint256[](length);for(uint256 i;imaxSupply) revert InvalidTerms(); return supply*basePrice+priceStep*supply*(supply==0?0:supply-1)/2; } function quoteBuy(uint256 quantity) public view returns(uint256 principal,uint256 fee,uint256 total){ if(quantity==0 || quantity>20) revert InvalidTerms(); if(circulatingSupply+quantity>maxSupply) revert SoldOut(); principal=reserveLiability(circulatingSupply+quantity)-curveReserve; fee=_fee(principal,CURVE_FEE_BPS);total=principal+fee; } function quoteSell(uint256 quantity) public view returns(uint256 principal,uint256 fee,uint256 received){ if(quantity==0 || quantity>20 || quantity>circulatingSupply) revert InvalidTerms(); principal=curveReserve-reserveLiability(circulatingSupply-quantity); fee=_fee(principal,CURVE_FEE_BPS);received=principal-fee; } function buy(uint256 quantity,uint256 maxCost,uint256 deadline) external payable nonReentrant returns(uint256 first){ _deadline(deadline); (uint256 principal,uint256 fee,uint256 cost)=quoteBuy(quantity); if(cost>maxCost) revert Slippage(); if(msg.value!=maxCost) revert IncorrectPayment(); first=nextTokenId;nextTokenId+=quantity;circulatingSupply+=quantity; curveReserve+=principal;_curveFee(fee);totalVolume+=principal; if(msg.value>cost) _credit(msg.sender,msg.value-cost); emit CurveTrade(msg.sender,true,quantity,principal,fee,spotPrice(),circulatingSupply,first,uint64(block.timestamp)); for(uint256 i;i100 ether || leverage<1 || leverage>3) revert InvalidTerms(); entry=spotPrice();uint256 notional=margin*leverage; quantity=Math.mulDiv(notional,WAD,entry); if(quantity==0) revert InvalidTerms(); fee=_fee(notional,TRADE_FEE_BPS); profitReserve=Math.mulDiv(quantity,isLong?maximumPrice-entry:entry-basePrice,WAD,Math.Rounding.Ceil); } function openPosition(bool isLong,uint256 margin,uint8 leverage,uint256 minPrice,uint256 maxPrice,uint256 deadline) external payable nonReentrant returns(uint256 id){ _deadline(deadline);_bounds(minPrice,maxPrice); if(!perpsEnabled) revert MarketNotOpen(); (uint256 quantity,uint256 fee,uint256 reserve,uint256 entry)=quoteOpen(isLong,margin,leverage); if(msg.value!=margin+fee) revert IncorrectPayment(); if(grossQuantity+quantity>maxGrossQuantity) revert ExposureLimit(); if(reserve>availableLiquidity()+fee) revert InsufficientLiquidity(); perpReserve+=fee;reservedProfit+=reserve;totalMargin+=margin;grossQuantity+=quantity; id=nextPositionId++; positions[id]=Position(msg.sender,isLong,leverage,uint64(block.timestamp),margin,margin*leverage,quantity,entry,reserve,PositionStatus.Open); activeIndex[id]=active.length;active.push(id); emit PositionOpened(id,msg.sender,isLong,margin,margin*leverage,quantity,entry,reserve); } function positionState(uint256 id) public view returns(int256 pnl,uint256 borrowing,uint256 equity,uint256 closingFee,bool liquidatable){ Position storage p=positions[id];if(p.status!=PositionStatus.Open) revert InvalidPosition(); uint256 price=spotPrice();bool gain=p.isLong?price>=p.entryPrice:price<=p.entryPrice; uint256 change=price>=p.entryPrice?price-p.entryPrice:p.entryPrice-price; uint256 magnitude=Math.mulDiv(p.quantity,change,WAD,gain?Math.Rounding.Floor:Math.Rounding.Ceil); pnl=gain?int256(magnitude):-int256(magnitude); borrowing=Math.mulDiv(p.notional,(block.timestamp-p.openedAt)*BORROW_BPS_PER_DAY,BPS*1 days,Math.Rounding.Ceil); int256 net=int256(p.margin)+pnl-int256(borrowing); equity=net>0?uint256(net):0; closingFee=_fee(Math.mulDiv(p.quantity,price,WAD),TRADE_FEE_BPS); if(closingFee>equity) closingFee=equity; liquidatable=equity<=_fee(p.notional,MAINTENANCE_BPS); } function closePosition(uint256 id,uint256 minPrice,uint256 maxPrice,uint256 deadline) external nonReentrant { _deadline(deadline);_bounds(minPrice,maxPrice); if(positions[id].status!=PositionStatus.Open || positions[id].trader!=msg.sender) revert InvalidPosition(); _settle(id,false); } function liquidate(uint256 id) external nonReentrant { (,,,,bool eligible)=positionState(id);if(!eligible) revert NotLiquidatable();_settle(id,true); } function withdraw(address payable recipient) external nonReentrant { if(recipient==address(0)) revert InvalidTerms(); uint256 amount=credits[msg.sender];if(amount==0) revert NoCredit(); credits[msg.sender]=0;totalCredits-=amount; (bool ok,)=recipient.call{value:amount}("");if(!ok) revert TransferFailed(); emit CreditWithdrawn(msg.sender,recipient,amount); } function _settle(uint256 id,bool liquidation) private { Position storage p=positions[id]; uint256 borrowing;uint256 equity;uint256 closingFee; { (int256 pnl,uint256 debt,uint256 value,uint256 fee,)=positionState(id); int256 gross=int256(p.margin)+pnl; borrowing=Math.min(debt,gross>0?uint256(gross):0);equity=value;closingFee=fee; } uint256 payout=equity-closingFee; // A one-edition curve move bounds the bounty; the remainder stays with the trader. uint256 reward=liquidation?Math.min(payout,Math.mulDiv(p.quantity,priceStep,WAD)):0; p.status=liquidation?PositionStatus.Liquidated:PositionStatus.Closed; totalMargin-=p.margin;reservedProfit-=p.profitReserve;grossQuantity-=p.quantity; // Full possible price profit was reserved at entry. Borrow/close fees reduce the claim. if(payout>p.margin) perpReserve-=payout-p.margin;else perpReserve+=p.margin-payout; _credit(p.trader,payout-reward);if(reward!=0) _credit(msg.sender,reward); {uint256 index=activeIndex[id];uint256 last=active[active.length-1];active[index]=last;activeIndex[last]=index;active.pop();delete activeIndex[id];} emit PositionClosed(id,spotPrice(),payout-reward,borrowing,closingFee,liquidation,msg.sender,reward); } function _curveFee(uint256 fee) private { perpReserve+=fee;curveFeesCollected+=fee; if(!perpsEnabled && perpReserve>=graduationTarget){perpsEnabled=true;activatedAt=uint64(block.timestamp);emit PerpsActivated(perpReserve,graduationTarget,activatedAt);} } function _credit(address owner,uint256 amount) private {credits[owner]+=amount;totalCredits+=amount;} function _fee(uint256 amount,uint256 bps) private pure returns(uint256){return Math.mulDiv(amount,bps,BPS,Math.Rounding.Ceil);} function _deadline(uint256 deadline) private view {if(block.timestamp>deadline) revert Expired();} function _bounds(uint256 low,uint256 high) private view {uint256 p=spotPrice();if(low>high || phigh) revert Slippage();} function tokenURI(uint256 id) public view override returns(string memory){ _requireOwned(id); return string.concat('data:application/json;base64,',Base64.encode(abi.encodePacked('{"name":"',_escape(name()),' #',Strings.toString(id),'","description":"',_escape(description),'","image":"',_escape(imageURI),'"}'))); } function _validURI(bytes memory u) private pure returns(bool){ return (u.length>8 && u[0]=='h' && u[1]=='t' && u[2]=='t' && u[3]=='p' && u[4]=='s' && u[5]==':' && u[6]=='/' && u[7]=='/') || (u.length>7 && u[0]=='i' && u[1]=='p' && u[2]=='f' && u[3]=='s' && u[4]==':' && u[5]=='/' && u[6]=='/'); } function _escape(string memory value) private pure returns(string memory){ bytes memory input=bytes(value);bytes memory out=new bytes(input.length*6);bytes16 digits='0123456789abcdef';uint256 cursor; for(uint256 i;i>4];out[cursor++]=digits[c&15];}else out[cursor++]=input[i];} assembly("memory-safe"){mstore(out,cursor)}return string(out); } }