)
Aptos Move 实战marketplace 示例包——链上 NFT 市场的完整设计固定价、拍卖与买价单【免费下载链接】aptos-coreAptos is a layer 1 blockchain built to support the widespread use of blockchain through better technology and user experience.项目地址: https://gitcode.com/GitHub_Trending/ap/aptos-coreaptos-core 仓库中的aptos-move/move-examples/marketplace目录是 Aptos 团队对「链上资产市场」这一经典业务场景的探索性实现。它以 aptos-framework 的 Object / Resource Group 模型 为基础用六个 Move 模块搭出了一套支持 TokenV1、TokenV2 与任意对象资产的统一市场框架覆盖固定价挂牌、带 Buy-It-Now 的竞价拍卖、买方主动发起的 Token 报价Token Offer与集合报价Collection Offer并定义了市场费的单一费表FeeSchedule。读完本文你可以理解该市场的模块划分、数据结构和资金流并能参考其 entry 函数签名与测试用例在自己本地localnet/devnet部署一个类似的市场合约。一、这个示例包想解决什么问题README 开宗明义地给出了这个包的目标分离核心逻辑组件保证可读性与后续可扩展性以函数 API 作为兼容层而不是直接暴露数据结构利用 Object 与 Resource Group在不浪费存储的前提下统一公共逻辑为市场定义单一费表FeeSchedule且费用只归属于挂牌所在的那个市场统一的拍卖与固定价框架同时支持 TokenV1、TokenV2 与对象Object资产收款端既支持 Coin 也兼容 FungibleAsset 语义。README 同时明确了能力边界所有挂牌都支持指定固定购买价、定义开始时间、内嵌市场费表、以及在买方未开启 TokenV1 直存direct deposit时提供「容器holding container」拍卖支持 Buy-It-Now、按最后出价时间顺延结束时间、最小出价增量固定价挂牌与集合报价Collection Offer都可以被发起方随时终止。最后 README 有一句关键提示这是一次「理想市场框架」的探索an exploration into the ideal marketplace framework欢迎社区提 PR 扩展与泛化用例在得到社区共同认可之前它不一定会部署到 Mainnet。因此本文按「示例与参考实现」定位来讲解不涉及任何主网上线状态。二、模块结构与包配置包定义见 Move.toml包名Marketplace占位地址marketplace _发布时替换为实际部署地址依赖均来自aptos-framework仓库的mainnet分支[package] name Marketplace version 0.0.0 [addresses] marketplace _ [dependencies] AptosFramework { git https://github.com/aptos-labs/aptos-framework.git, subdir aptos-framework, rev mainnet } AptosToken { git https://github.com/aptos-labs/aptos-framework.git, subdir aptos-token, rev mainnet } AptosTokenObjects { git https://github.com/aptos-labs/aptos-framework.git, subdir aptos-token-objects, rev mainnet }sources/目录下的七个文件各司其职模块文件职责listing.move所有挂牌的基石Listing与TokenV1Container数据结构、关闭/结算核心逻辑、版税royalty计算coin_listing.move固定价挂牌与拍卖两种售卖形态创建、出价、购买、结算以及全部内嵌测试fee_schedule.move市场费表对象挂牌费、出价费、佣金固定额或百分比及运行时变更接口token_offer.move买方对单个 Token 的限时报价资金先入托管卖方接受后成交collection_offer.move买方对某集合内最多 N 件资产的限时批量报价events.move全市场事件定义挂牌/取消/成交/出价等 10 类事件的发射点test_utils.move#[test_only]工具建账、注资、铸造 TokenV1/V2 测试资产、推进链上时间从源码结构看各模块之间通过friend声明形成清晰的依赖锥events是叶子listing依赖events与fee_schedulecoin_listing/token_offer/collection_offer依赖listing与events。这种组织方式正是 README 所说的「核心逻辑组件分离」——例如coin_listing完全不关心 TokenV1/V2 的差异这些差异被收敛在listing.move中处理。三、Listing基于 Object 的托管Escrow设计3.1 核心数据结构listing.move 中的Listing是整个市场的「基石corner-stone」#[resource_group_member(group aptos_framework::object::ObjectGroup)] struct Listing has key { /// 本次挂牌的资产成交时转移给新所有者 object: ObjectObjectCore, /// 卖方 seller: address, /// 领取该挂牌所关联的费用指向市场 FeeSchedule 对象 fee_schedule: ObjectFeeSchedule, /// 允许出价/购买的 Unix 时间戳秒 start_time: u64, delete_ref: DeleteRef, extend_ref: ExtendRef, }四个错误常量定义了基础校验ENO_LISTING(1)、ELISTING_NOT_STARTED(2)、ENOT_CREATOR(3)、ENOT_OWNER(4)。3.2 托管的创建对象即托管合约listing::init展示了 Aptos Object 模型的一个典型用法——一个对象Object本身就是一组资源的容器Resource Group并且可以充当托管方object::create_object_from_account(creator)创建一个新的对象对象地址同时充当资源存储地址object::generate_transfer_ref(constructor_ref)生成转移引用后立即调用object::disable_ungated_transfer从此该对象及其资源组内所有资源在成交前「绑定灵魂soulbound」任何人包括卖方都无法中途挪走generate_signer/generate_delete_ref/generate_extend_ref分别用于以对象身份签名转移资产、在结束时删除对象、以及由内部模块扩展资源组。之后object::transfer(creator, object, listing_addr)把被挂牌的资产对象转移到 Listing 对象名下——从这一刻起资产就处于链上托管状态。3.3 TokenV1 容器为旧版 Token 提供「装盒」方案TokenV1 是账户内余额account balance形态而非对象无法直接object::transfer进托管。为此定义了#[resource_group_member(group aptos_framework::object::ObjectGroup)] struct TokenV1Container has key { token: TokenV1, delete_ref: DeleteRef, transfer_ref: TransferRef, }create_tokenv1_container(seller, token_creator, collection, name, property_version)先用tokenv1::create_token_id_raw构造TokenId再tokenv1::withdraw_token(seller, token_id, 1)从卖方账户取出 1 个 TokenV1装入新的容器对象。容器对象随后作为Listing.object被托管从而让 TokenV1 资产与 TokenV2/任意对象走同一条结算路径。3.4 结算与收尾close()close(closer, object, recipient)是 friend 级函数在成交或取消时被调用let Listing { object, seller, fee_schedule, start_time: _, delete_ref, extend_ref } move_fromListing(listing_addr); let obj_signer object::generate_signer_for_extending(extend_ref); if (existsTokenV1Container(object::object_address(object))) { extract_or_transfer_tokenv1(closer, recipient, object::convert(object)); } else { object::transfer(obj_signer, object, recipient); }; object::delete(delete_ref); (seller, fee_schedule)它用对象签名者把资产转给买方或取消时的卖方删除整个托管对象并返回seller与fee_schedule供上层模块继续分配货款。extract_or_transfer_tokenv1则按买方是否开启直存分三种情况处理 TokenV1直存已开启则direct_deposit_with_opt_in收款人是执行者本人则deposit_token否则生成LinearTransferRef把容器对象转给收款人由收款人事后手动提取。提取入口是 public entry 函数public entry fun extract_tokenv1(owner: signer, object: ObjectTokenV1Container)它校验object::is_ownerENOT_OWNER权限错误后取回 Token 并deposit_token。3.5 版税计算的统一视图compute_royalty(object, amount)是一个#[view]函数体现了 README「以函数 API 而非数据结构作为兼容层」的原则调用方只需传入挂牌对象与成交价无需知道底层是 TokenV1 还是 TokenV2若托管内容存在TokenV1Container解析TokenId上的 royaltypayee 分子/分母用bounded_percentage计算否则读 TokenV2 对象的royalty(listing.object)Option 语义无版税时返回(0x0, 0)。其中bounded_percentage(amount, numerator, denominator)保证结果不超过amount且分母为 0 时返回 0避免「版税高于售价」这类异常把佣金挤成负数。四、FeeSchedule市场的单一费表fee_schedule.move 把三类收费都建模为资源并全部挂在同一个FeeSchedule对象之下资源结构含义FeeSchedule { fee_address, extend_ref }费表本体fee_address是费用收款地址FixedRateBiddingFee { bidding_fee }每次出价的固定费用FixedRateListingFee { listing_fee }每次挂牌的固定费用FixedRateCommission { commission }成交后固定额佣金PercentageRateCommission { denominator, numerator }成交后百分比佣金初始化入口可直接被外部交易调用public entry fun init_entry( creator: signer, fee_address: address, bidding_fee: u64, listing_fee: u64, commission_denominator: u64, commission_numerator: u64, )约束commission_numerator commission_denominator否则EEXCEEDS_MAXIMUM(3)denominator ! 0否则EDENOMINATOR_IS_ZERO(2)。另有零费用入口empty(creator, fee_address)。运行时可变是这套设计的重点市场运营方object::is_owner校验否则ENOT_OWNER(4)可以在不改变 FeeSchedule 对象地址的前提下热更新收费策略——这正是「单一费表」的关键所有已挂牌的 Listing 都持有同一个 FeeSchedule 对象引用市场改费存量挂牌自动生效。可用 entry 函数一览函数作用set_fee_address(marketplace, fee_address)改收款地址set_fixed_rate_listing_fee(marketplace, fee)覆盖设置挂牌费先移除旧值set_fixed_rate_bidding_fee(marketplace, fee)覆盖设置出价费set_fixed_rate_commission(marketplace, commission)改用固定额佣金set_percentage_rate_commission(marketplace, denominator, numerator)改用百分比佣金校验同 init每次变更都会发出Mutation { marketplace, updated_resource }事件updated_resource用type_info::type_name记录被更新的资源类型名。「干净接口、后续加业务逻辑」体现在三个#[view]查询函数上——它们都是listing_fee(marketplace, base)、bidding_fee(marketplace, bid)、commission(marketplace, price)这样的输入当前价格信息、返回费用的签名。从源码结构看这是为将来接入「按价分档计费」等更复杂逻辑预留的扩展点只需替换 view 函数内部计算调用方coin_listing、token_offer等无需任何改动。目前实现为存在对应固定费率资源则返回固定值佣金则按固定额或mul_div(price, numerator, denominator)百分比计算缺省返回 0。该模块自带一组#[test]同文件 fee_schedule.move覆盖初始化后各 fee 查询、非 owner 调 set_* 触发0x50004permission denied、零分母0x20002out of range、比例超限0x10003invalid argument等场景。五、Coin Listing固定价与拍卖coin_listing.move 提供两种售卖形态均泛型化于CoinType#[resource_group_member(group aptos_framework::object::ObjectGroup)] struct FixedPriceListingphantom CoinType has key { price: u64, } #[resource_group_member(group aptos_framework::object::ObjectGroup)] struct AuctionListingphantom CoinType has key { starting_bid: u64, bid_increment: u64, current_bid: OptionBidCoinType, auction_end_time: u64, minimum_bid_time_before_end: u64, buy_it_now_price: Optionu64, }两者作为Listing所在对象资源组里的兄弟资源挂载is_auction()view 函数用existsAuctionListingCoinType区分形态。错误常量包括ENO_LISTING(1)、ENO_BUY_IT_NOW(2)、EBID_TOO_LOW(3)、EAUCTION_NOT_ENDED(4)、EAUCTION_ENDED(5)、ENOT_SELLER(6)。5.1 创建挂牌固定价public entry fun init_fixed_priceCoinType( seller: signer, object: ObjectObjectCore, fee_schedule: ObjectFeeSchedule, start_time: u64, price: u64, )拍卖public entry fun init_auctionCoinType( seller: signer, object: ObjectObjectCore, fee_schedule: ObjectFeeSchedule, start_time: u64, starting_bid: u64, bid_increment: u64, auction_end_time: u64, minimum_bid_time_before_end: u64, buy_it_now_price: Optionu64, )拍卖参数与 README 的映射关系starting_bid/bid_increment对应「最小出价增量」buy_it_now_price: Optionu64对应「Buy-it-now」minimum_bid_time_before_end对应「按最后出价时间顺延结束时间」。另有init_fixed_price_for_tokenv1/init_auction_for_tokenv1变体入参换成 TokenV1 四元组token_creator,token_collection,token_name,token_property_version内部先listing::create_tokenv1_container装盒再走同一条路径。内部函数initCoinType在调用listing::init托管资产之前会先向市场收一次挂牌费aptos_account::transfer_coinsCoinType( seller, fee_schedule::fee_address(fee_schedule), fee_schedule::listing_fee(fee_schedule, initial_price), );创建成功后发射ListingPlaced事件type字段为bfixed price或bauction。5.2 购买与资金分配顺序purchaseCoinType(purchaser, object)是固定价直购与 Buy-It-Now 的统一入口listing::assert_started(object)校验挂牌存在且start_time now若是拍卖要求now auction_end_time已结束则EAUCTION_ENDED要求存在buy_it_now_price否则ENO_BUY_IT_NOW若已有在途出价先把出价方的 Coin 原路退回再按买断价成交若是固定价直接取pricecoin::withdraw(purchaser, price)从买方扣款进入complete_purchase。complete_purchase的分配顺序是这套市场的资金流核心值得逐行对照let (royalty_addr, royalty_charge) listing::compute_royalty(object, price); let (seller, fee_schedule) listing::close(completer, object, purchaser_addr); // 1. 先付版税创作者 if (royalty_charge ! 0) { let royalty coin::extract(mut coins, royalty_charge); aptos_account::deposit_coins(royalty_addr, royalty); }; // 2. 再收市场佣金从剩余中封顶扣除创作者优先 let commission_charge fee_schedule::commission(fee_schedule, price); let actual_commission_charge math64::min(coin::value(coins), commission_charge); let commission coin::extract(mut coins, actual_commission_charge); aptos_account::deposit_coins(fee_schedule::fee_address(fee_schedule), commission); // 3. 卖方拿剩余 aptos_account::deposit_coins(seller, coins);注意两个防御性细节版税先用bounded_percentage封顶于成交价佣金再用min(剩余, 应收)封顶——当版税比例很高时佣金自然降为 0 而不会导致交易失败。最后发射ListingFilled事件含price、commission、royalties与统一TokenMetadata。end_fixed_priceCoinType(seller, object)实现 README 的「卖方可以随时终止固定价挂牌」调用listing::close(seller, object, seller)把资产转回卖方并断言执行者确为 sellerENOT_SELLER随后发ListingCanceled事件。5.3 出价与顺延机制bidCoinType(bidder, object, bid_amount)的完整流程coin_listing.move校验挂牌已开始、AuctionListing存在、now auction_end_time计算最低出价有在途出价时为previous_bid bid_increment否则为starting_bid不满足则EBID_TOO_LOW把上一位出价人的 Coin 退还再coin::withdraw(bidder, bid_amount)扣新出价人资金存入current_bid向市场支付bidding_fee(fee_schedule, bid_amount)出价费时间顺延若auction_end_time now minimum_bid_time_before_end把结束时间推后到now minimum_bid_time_before_end发射AuctionBid事件携带新旧出价人与金额、新旧结束时间。complete_auctionCoinType(completer, object)要求auction_end_time now否则EAUCTION_NOT_ENDED有最终出价则以其为买方没有则资产回卖方、货款为 0随后走同一个complete_purchase版税/佣金/卖方分配逻辑与固定价完全一致。5.4 内嵌测试中的真实资金流模块自带的listing_tests用统一的测试费表test_utils.movebidding_fee2、listing_fee1、佣金1/100即 1%和每人 10000 的注资给出了可以逐笔复核的资金流。以test_fixed_price为例coin_listing.move时点marketplacesellerpurchaser说明建挂牌后1999910000卖方支付listing_fee1purchase500 成交后6104949500市场收1 500×1% 6卖方收500 - 5 494买方付 500test_auction_biddingstart 100 / increment 50 / endnow200/ min-before-end 150展示了顺延逻辑第一次出价 100 时距结束尚余 200 秒结束时间不变时间推进 150 秒后第二次出价 150此时距结束只剩 50 秒 150 秒结束时间被改写测试断言auction_end_time ! end_time再推进 150 秒后complete_auction卖方到手100 495 10146出价费 2 次共 4佣金 1.5 向下取整为 1。其余测试覆盖未到开始时间购买/出价 abort0x30002、拍卖结束后出价/购买 abort0x30005、余额不足 abort0x10004来自aptos_framework::fungible_asset、低于最小增量 abort0x10003、高额版税下佣金归零test_fixed_price_high_royalty中 royalty 100% 时市场只收挂牌费 1等。六、Token Offer买方挂单、卖方接受token_offer.move 实现的是买方主动报价买方先把钱锁定在链上托管卖方随时可以按报价卖掉。核心结构struct TokenOffer has key { fee_schedule: ObjectFeeSchedule, item_price: u64, expiration_time: u64, delete_ref: DeleteRef, }配套资源CoinOfferCoinType { coins }存托管资金TokenOfferTokenV1 { creator_address, collection_name, token_name, property_version }或TokenOfferTokenV2 { token }描述报价指向的资产。与 Listing 一样init_offer也会disable_ungated_transfer保证托管期间资金不可被挪走。创建init_for_tokenv1_entry/init_for_tokenv2_entry先按item_price收listing_fee再coin::withdraw全部报价金额存入CoinOffer发TokenOfferPlaced。卖方接受sell_tokenv1_entry(seller, token_offer, token_name, property_version)或sell_tokenv2(seller, token_offer)。TokenV2 路径先校验seller object::owner(token)ENOT_TOKEN_OWNER后object::transfer给买方TokenV1 路径按买方直存开关决定直存或装TokenV1Container转交返回容器句柄供提取。结算settle_payments校验未过期EEXPIRED(6)从CoinOffer中取出item_price同样按 版税 → 佣金 → 卖方 的顺序分配此处佣金直接coin::extract发TokenOfferFilled然后cleanup剩余资金退还报价人、删除对象与元数据资源。取消cancelCoinType(purchaser, token_offer)仅限对象 ownerENOT_OWNER(4)发TokenOfferCanceled并cleanup退回资金——对应 README「Offerer can end at any time」。七、Collection Offer对一个集合买 N 件collection_offer.move 与 Token Offer 的差异集中在CollectionOffer多了一个remaining: u64计数struct CollectionOffer has key { fee_schedule: ObjectFeeSchedule, item_price: u64, remaining: u64, expiration_time: u64, delete_ref: DeleteRef, }创建init_for_tokenv1_entry/init_for_tokenv2_entry的amount参数即要购买的件数一次性锁定item_price * amount的总资金并收取一次listing_fee。成交sell_tokenv1_entry/sell_tokenv2(seller, collection_offer, token)中TokenV2 路径显式校验tokenv2::collection_object(token) collection_offer 的 collection否则EINCORRECT_COLLECTION(5)TokenV1 路径则靠四元组拼TokenId天然约束了 creator/collection。每次成交remaining - 1归零时自动cleanup。过期与取消EEXPIRED(6)、owner 可cancel行为与 Token Offer 一致。collection_offer_tests给出了多件成交的完整断链单价 500、数量 2 的 TokenV2 集合报价买方余额 10000 → 建单后 8999锁 1000 1 挂牌费→ 第一件成交后 8999remaining1市场 1→6卖方 495→ 第二件成交后 9489报价对象销毁市场 11卖方 10500。另有高版税、TokenV1 直存/容器、过期0x30006、超额成交0x60003、错误集合0x10005等负例。八、事件体系所有事件都挂在 FeeSchedule 下events.move 的注释点明了设计this is attached to a FeeSchedule——所有emit_*函数的第一个参数都是marketplace: ObjectT事件以市场为事件源地址发出。这对索引器非常友好订阅一个市场的地址即可拿到该市场全部交易流水。10 类事件及触发点事件字段要点触发点ListingPlacedtypefixed price/auction、seller、price、token_metadata创建挂牌ListingCanceled同上end_fixed_priceListingFilledseller、purchaser、price、commission、royaltiescomplete_purchaseAuctionBidnew/previous bidder 与金额、new/previous end_timebidCollectionOfferPlacedprice、token_amount、collection_metadata创建集合报价CollectionOfferCanceledremaining_token_amount取消集合报价CollectionOfferFilledseller、price、royalties、commission集合成交TokenOfferPlacedprice、token_metadata创建 Token 报价TokenOfferCanceled同上取消 Token 报价TokenOfferFilledseller、price、royalties、commissionToken 报价成交其中TokenMetadata用 Option 字段统一了两种资产标识TokenV1 填property_version而token/collection为 noneTokenV2 填token/collection对象而property_version为 none——再次体现「对外暴露函数/统一结构而非内部数据结构」的思路。九、本地验证与使用方式这个包没有单独的部署脚本按标准 Move 开发流程在 aptos 仓库的本地环境即可运行查看/编译将Move.toml中marketplace _替换为你本地账户地址后在 aptos-move/move-examples/marketplace 目录执行aptos move build依赖通过 git 拉取 aptos-framework 的mainnet分支需要网络。运行模块内测试aptos move test会执行fee_schedule.move、coin_listing.move、token_offer.move、collection_offer.move中全部#[test]用例包括expected_failure负例。测试通过test_utils::setup完成时间服务启动、AptosCoin测试初始化与每人 10000 注资通过increment_timestamp模拟链上时间流逝。发布与调用aptos move publish到 localnet 后各public entry funinit_entry、init_fixed_price、init_auction、bid、purchase、complete_auction、init_for_tokenv2_entry、sell_tokenv2、cancel等都可以直接作为 entry 函数通过aptos move call调用所有#[view]函数price、is_auction、current_bidder、remaining、commission等可通过aptos move view查询。需要注意的适用前提本包定位为参考实现/探索性示例README 原文已说明其 Mainnet 部署取决于社区共识依赖锁定在 aptos-framework 的mainnet分支若框架 API 演进可能需要同步适配另外fee_schedule.move中若干set_*函数的Mutation事件复用type_nameFixedRateListingFee()作为updated_resource从源码结构看这更像是占位写法而非严格区分被改资源若以此为蓝本做生产级市场建议自行完善。十、设计要点回顾回到 README 的原始目标可以在源码中一一找到对应组件分离listing托管/结算核心、coin_listing售卖形态、fee_schedule收费、两个 offer 模块买方视角、events观测面六模块职责单一函数 API 作为兼容层compute_royalty、token_metadata、listing_fee/bidding_fee/commission等 view 函数屏蔽了 TokenV1/V2 与收费策略的差异Object Resource Group 统一逻辑Listing、FixedPriceListing/AuctionListing、CoinOffer等全部是同一对象资源组的成员DeleteRef保证一次性整体清理不留垃圾状态单一费表存量挂牌持有 FeeSchedule 对象引用市场改费即时生效且费用归属唯一统一拍卖/固定价框架两种形态共享assert_started、close、complete_purchase与事件体系三类资产、两种收款形态TokenV1 经容器装盒、TokenV2 与任意对象直接托管转移收款统一走CoinCoinTypeCoin本身即 FungibleAsset 的账户余额视图。这套「对象即托管、资源组即订单、费表即市场」的写法对任何想在 Aptos 上构建二级交易市场、拍卖行或批量采购工具的开发者都是一个可以直接参考的起点复制 marketplace 目录、替换依赖为你的目标资产合约、按本文第五、六、七节的 entry 函数清单裁剪或扩展功能即可获得一个带完整测试骨架的市场合约。【免费下载链接】aptos-coreAptos is a layer 1 blockchain built to support the widespread use of blockchain through better technology and user experience.项目地址: https://gitcode.com/GitHub_Trending/ap/aptos-core创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考