GroupMarket 交易市场
GroupMarket 是 LOVE20 Group NFT 的专用交易市场。
它不是多合集通用市场,而是部署时通过构造函数绑定:
- 一个
ILOVE20Group - 一个
ILOVE20Token
也就是说,市场内只交易这一组 Group NFT,并统一用对应的 LOVE20 代币结算。
核心设计
- 挂卖单:卖家把指定
tokenId托管到市场合约,并设置固定售价 - 买入:买家支付
LOVE20,市场将 NFT 转给买家 - 特定 NFT 报价:报价者可对某个
tokenId单独报价,并把报价金额先托管到市场合约 - 接受报价:NFT 持有人可接受指定报价,完成成交
- 手续费销毁:每笔成交收取
10%手续费,并由市场合约直接调用ILOVE20Token.burn()销毁,变回未铸造量 - 防误转:市场合约不实现
ERC721Receiver,因此外部对市场地址发起的safeTransferFrom(...)会直接失败
部署
构造函数:
constructor(ILOVE20Group group_, ILOVE20Token love20Token_);
特点:
group_和love20Token_都不能为空地址- 部署后市场固定绑定这两个合约
部署成功后,建议至少校验:
market.group() == groupmarket.love20Token() == love20Tokengroup.LOVE20_TOKEN_ADDRESS() == love20Token
仓库里也已补了对应部署检查脚本:
script/deploy/group-market/01_deploy.shscript/deploy/group-market/99_check.shscript/deploy/group-market/one_click_deploy.sh
手续费规则
市场固定手续费:
fee = price * 10%
sellerProceeds = price - fee
手续费不会转给平台地址,而是直接销毁,因此会增加 LOVE20 的未铸造量。
交易流程
1. 挂卖单
卖家先授权市场转移 NFT,再调用:
function createListing(uint256 tokenId, uint256 price) external;
约束:
price > 0- 调用者必须是 NFT 当前所有者
- 同一个
tokenId不能重复挂单
挂单成功后:
- NFT 转入市场合约托管
- 市场记录
seller和price
重要:
- 正确上架方式只有
createListing() - 不要手工把 NFT 直接转给市场地址
- 直接
safeTransferFrom(..., market, tokenId)会失败,这是刻意的防误转设计 - 但 ERC721 标准层面的
transferFrom(..., market, tokenId)仍然可能把 NFT 直接打进市场地址;这种直转不会创建卖单,后续也没有正常交易路径可取回,因此必须由前端和操作流程避免
2. 取消卖单
function cancelListing(uint256 tokenId) external;
只有挂单卖家自己可以取消。取消后 NFT 原路退回卖家。
3. 直接购买
买家先授权市场支出 LOVE20,再调用:
function buyListing(uint256 tokenId) external;
成交时:
- 买家支付全部售价金额
- NFT 从市场托管地址转给买家
10%被销毁90%转给卖家
卖家不能买自己的卖单。
说明:
buyListing()不会自动清理买家自己此前对该tokenId的已有报价;如需取回托管代币,需后续自行取消该报价
报价流程
每个 tokenId 最多保留 20 个 active offers。
1. 对指定 NFT 报价
报价者先授权市场支出 LOVE20,再调用:
function makeOffer(uint256 tokenId, uint256 amount) external;
约束:
amount > 0- 同一地址对同一
tokenId仅保留 1 个报价 - 已有报价时,只允许继续加价,不允许主动降价
- 新报价后,约
30126个区块内不能主动取消报价 - 当某个
tokenId已经有20个 active offers 时,新报价必须至少高于当前最低报价1%
说明:
- 报价金额会先转入市场合约托管
- NFT 持有人也可以给自己的 NFT 报价,但不能自己接受自己的报价
- 同一地址再次对同一
tokenId报价时,仅允许加价,且只补足差额到合约托管;每次加价都会重置取消冷却期 - 若当前报价池未满
20个,则新报价直接进入 - 若当前报价池已满
20个,则只有达到“最低报价 + 1%”门槛的新报价才能进入,并把当前最低报价挤出Top20 - 被挤出的报价不会自动退款,而是状态变为
pending pending报价不再参与最高/最低报价计算,也不再出现在activeOffers(tokenId)的 active 列表里pending报价人可以通过offer(tokenId, bidder)看到自己的报价状态,并可随时取消取回代币pending报价不可被接受,只表示该报价已被挤出Top20、待报价人自行撤回pending报价不能直接继续加价恢复,必须先取消,再重新报价
2. 取消报价
function cancelOffer(uint256 tokenId) external;
取消前提:
active报价需超过约30126个区块冷却期后才能主动取消pending报价可立即取消
取消后,托管的 LOVE20 全额退回报价者。
3. 接受报价
function acceptOffer(uint256 tokenId, address bidder) external;
分两种情况:
- NFT 未挂单:NFT 所有人可直接接受
active报价,但需要先授权市场转移 NFT - NFT 已挂单:挂单卖家可直接接受某个
active报价,市场会清掉卖单并把托管 NFT 转给报价者
成交后:
10%报价金额被销毁90%转给卖家- NFT 转给报价者
当前 NFT 持有人不能接受自己的报价。
只读接口
function listingCount() external view returns (uint256);
function activeOfferCount(uint256 tokenId) external view returns (uint256);
function bidderOfferCount(address bidder) external view returns (uint256);
function listings(uint256 offset, uint256 limit) external view returns (ListingView[] memory);
function activeOffers(uint256 tokenId) external view returns (OfferView[] memory);
function bidderOffers(address bidder, uint256 offset, uint256 limit) external view returns (BidderOfferView[] memory);
function highestOffer(uint256 tokenId) external view returns (OfferView memory);
function highestOffers(uint256[] calldata tokenIds) external view returns (OfferView[] memory);
function listing(uint256 tokenId) external view returns (Listing memory);
function offer(uint256 tokenId, address bidder) external view returns (Offer memory);
function calculateFee(uint256 amount) external pure returns (uint256);
function calculateSellerProceeds(uint256 amount) external pure returns (uint256);
说明:
listings(offset, limit)用于前端分页展示当前所有挂单activeOffers(tokenId)用于查看某个 NFT 当前全部 active 报价bidderOffers(bidder, offset, limit)用于分页查看某个地址当前所有报价,包含active和pendingactiveOfferCount(tokenId)和highestOffer(tokenId)都仅统计active报价highestOffers(tokenIds)用于批量查询多个 NFT 的当前最高active报价,返回结果顺序与输入tokenIds一一对应;若某个 NFT 当前没有active报价,则对应位置返回零值- 分页结果的顺序仅保证是当前合约内部存储顺序,不承诺稳定排序;前端如需稳定展示,建议自行按价格、时间或
tokenId排序 Offer/OfferView中会返回报价状态和取消解锁区块,前端可据此判断是否仍在Top20以及何时允许取消
性能说明
- 挂单列表和“我的报价列表”都使用可枚举集合维护,因此分页读取是可行的
- 每个
tokenId最多只有20个 active offers,因此报价池维护成本是常数级 makeOffer、cancelOffer、acceptOffer会在最多20个报价内刷新最高/最低报价缓存highestOffer(tokenId)直接读取缓存的最高报价,不再需要全量扫描
这意味着:
- 不会因为报价无限增长而把写交易拖死
- 最高报价读取是常数时间
- 代价是市场只保留每个 NFT 当前最有竞争力的
20个 active 报价;被挤出的报价会转为pending
因此更稳妥的前端策略是:
- 链上使用分页接口拿当前挂单和“我的报价”,使用
activeOffers(tokenId)直接拿当前活跃报价 - 前端展示“取消报价”按钮时,同时展示冷却剩余区块或预估时间
- 若需要完整历史报价,仍建议依赖事件索引或数据库
当前边界
- 当前是
Group NFT专用市场,不是多 NFT 合约通用市场 - 报价是“按
tokenId+bidder”存储 - 每个
tokenId最多只保留20个 active offers - 被挤出的旧报价会保留为
pending,直到报价人自行取消取回 - 卖单采用 NFT 托管模式
- 报价采用
LOVE20托管模式 - 市场拒绝外部
safeTransferFrom直接打入 NFT,必须走createListing() - 但无法从市场合约侧彻底阻止 ERC721 的原始
transferFrom直转到市场地址,因此前端和用户操作仍需避免此类误转