实战案例 3:链上拍卖行(智能存储单元 + 荷兰式拍卖)
目标: 将 Smart Storage Unit 改造为荷兰式拍卖(价格随时间递减),物品自动流转给出价者,完整实现拍卖合约 + 竞拍者 dApp + Owner 管理面板。
状态:已附合约、dApp 与 Move 测试文件。正文已经接近完整案例,适合作为“定价策略 + 前端倒计时”范例。
对应代码目录
最小调用链
Owner 创建拍卖 -> 时间递减价格 -> 买家支付当前价 -> 拍卖结算 -> 物品流转
需求分析
场景: 你控制着一个存储着珍稀矿石的智能存储箱。相比固定价格,你希望通过荷兰式拍卖(价格从高到低递减)来最大化销售收益,并让价格发现更加透明:
- 🕐 拍卖开始时以 5000 LUX 起拍
- 📉 每 10 分钟降低 500 LUX
- 🏆 最低价为 500 LUX,价格不再下降
- ⚡ 任何时候有人支付当前价格,物品立即成交
- 📊 dApp 实时显示倒计时和当前价格
第一部分:Move 合约
目录结构
dutch-auction/
├── Move.toml
└── sources/
├── dutch_auction.move # 荷兰拍卖逻辑
└── auction_manager.move # 拍卖管理(创建/结束)
核心合约:dutch_auction.move
module dutch_auction::auction;
use world::storage_unit::{Self, StorageUnit};
use world::character::Character;
use world::inventory::Item;
use sui::coin::{Self, Coin};
use sui::sui::SUI;
use sui::balance::{Self, Balance};
use sui::clock::Clock;
use sui::object::{Self, UID, ID};
use sui::event;
use sui::transfer;
/// SSU 扩展 Witness
public struct AuctionAuth has drop {}
/// 拍卖状态
public struct DutchAuction has key {
id: UID,
storage_unit_id: ID, // 绑定的存储箱
item_type_id: u64, // 拍卖的物品类型
start_price: u64, // 起始价(MIST)
end_price: u64, // 最低价
start_time_ms: u64, // 拍卖开始时间
price_drop_interval_ms: u64, // 每次降价间隔(毫秒)
price_drop_amount: u64, // 每次降价幅度
is_active: bool, // 是否仍在进行
proceeds: Balance<SUI>, // 拍卖收益
owner: address, // 拍卖创建者
}
/// 事件
public struct AuctionCreated has copy, drop {
auction_id: ID,
item_type_id: u64,
start_price: u64,
end_price: u64,
}
public struct AuctionSettled has copy, drop {
auction_id: ID,
winner: address,
final_price: u64,
item_type_id: u64,
}
// ── 计算当前价格 ─────────────────────────────────────────
public fun current_price(auction: &DutchAuction, clock: &Clock): u64 {
if !auction.is_active {
return auction.end_price
}
let elapsed_ms = clock.timestamp_ms() - auction.start_time_ms;
let drops = elapsed_ms / auction.price_drop_interval_ms;
let total_drop = drops * auction.price_drop_amount;
if total_drop >= auction.start_price - auction.end_price {
auction.end_price // 已降到最低价
} else {
auction.start_price - total_drop
}
}
/// 计算下次降价的剩余时间(毫秒)
public fun ms_until_next_drop(auction: &DutchAuction, clock: &Clock): u64 {
let elapsed = clock.timestamp_ms() - auction.start_time_ms;
let interval = auction.price_drop_interval_ms;
let next_drop_at = (elapsed / interval + 1) * interval;
next_drop_at - elapsed
}
// ── 创建拍卖 ─────────────────────────────────────────────
public fun create_auction(
storage_unit: &StorageUnit,
item_type_id: u64,
start_price: u64,
end_price: u64,
price_drop_interval_ms: u64,
price_drop_amount: u64,
clock: &Clock,
ctx: &mut TxContext,
) {
assert!(start_price > end_price, EInvalidPricing);
assert!(price_drop_amount > 0, EInvalidDropAmount);
assert!(price_drop_interval_ms >= 60_000, EIntervalTooShort); // 最小1分钟
let auction = DutchAuction {
id: object::new(ctx),
storage_unit_id: object::id(storage_unit),
item_type_id,
start_price,
end_price,
start_time_ms: clock.timestamp_ms(),
price_drop_interval_ms,
price_drop_amount,
is_active: true,
proceeds: balance::zero(),
owner: ctx.sender(),
};
event::emit(AuctionCreated {
auction_id: object::id(&auction),
item_type_id,
start_price,
end_price,
});
transfer::share_object(auction);
}
// ── 竞拍:支付当前价格获得物品 ──────────────────────────
public fun buy_now(
auction: &mut DutchAuction,
storage_unit: &mut StorageUnit,
character: &Character,
mut payment: Coin<SUI>,
clock: &Clock,
ctx: &mut TxContext,
): Item {
assert!(auction.is_active, EAuctionEnded);
let price = current_price(auction, clock);
assert!(coin::value(&payment) >= price, EInsufficientPayment);
// 退还多付的部分
let change_amount = coin::value(&payment) - price;
if change_amount > 0 {
let change = payment.split(change_amount, ctx);
transfer::public_transfer(change, ctx.sender());
}
// 收入进入拍卖金库
balance::join(&mut auction.proceeds, coin::into_balance(payment));
auction.is_active = false;
event::emit(AuctionSettled {
auction_id: object::id(auction),
winner: ctx.sender(),
final_price: price,
item_type_id: auction.item_type_id,
});
// 从 SSU 取出物品
storage_unit::withdraw_item(
storage_unit,
character,
AuctionAuth {},
auction.item_type_id,
ctx,
)
}
// ── Owner:提取拍卖收益 ──────────────────────────────────
public fun withdraw_proceeds(
auction: &mut DutchAuction,
ctx: &mut TxContext,
) {
assert!(ctx.sender() == auction.owner, ENotOwner);
assert!(!auction.is_active, EAuctionStillActive);
let amount = balance::value(&auction.proceeds);
let coin = coin::take(&mut auction.proceeds, amount, ctx);
transfer::public_transfer(coin, ctx.sender());
}
// ── Owner:取消拍卖 ──────────────────────────────────────
public fun cancel_auction(
auction: &mut DutchAuction,
storage_unit: &mut StorageUnit,
character: &Character,
ctx: &mut TxContext,
): Item {
assert!(ctx.sender() == auction.owner, ENotOwner);
assert!(auction.is_active, EAuctionAlreadyEnded);
auction.is_active = false;
// 将物品取回给 Owner
storage_unit::withdraw_item(
storage_unit, character, AuctionAuth {}, auction.item_type_id, ctx,
)
}
// 错误码
const EInvalidPricing: u64 = 0;
const EInvalidDropAmount: u64 = 1;
const EIntervalTooShort: u64 = 2;
const EAuctionEnded: u64 = 3;
const EInsufficientPayment: u64 = 4;
const EAuctionStillActive: u64 = 5;
const EAuctionAlreadyEnded: u64 = 6;
const ENotOwner: u64 = 7;
第二部分:单元测试
#[test_only]
module dutch_auction::auction_tests;
use dutch_auction::auction;
use sui::test_scenario;
use sui::clock;
use sui::coin;
use sui::sui::SUI;
#[test]
fun test_price_decreases_over_time() {
let mut scenario = test_scenario::begin(@0xOwner);
let mut clock = clock::create_for_testing(scenario.ctx());
// 设置0时刻
clock.set_for_testing(0);
// 创建伪造拍卖对象测试价格计算
let auction = auction::create_test_auction(
5000, // start_price
500, // end_price
600_000, // 10分钟 (ms)
500, // 每次降 500
&clock,
scenario.ctx(),
);
// 时刻 0:价格应为 5000
assert!(auction::current_price(&auction, &clock) == 5000, 0);
// 经过 10 分钟:价格应为 4500
clock.set_for_testing(600_000);
assert!(auction::current_price(&auction, &clock) == 4500, 0);
// 经过 90 分钟(降价9次 × 500 = 4500,但最低 500):价格应为 500
clock.set_for_testing(5_400_000);
assert!(auction::current_price(&auction, &clock) == 500, 0);
clock.destroy_for_testing();
auction.destroy_test_auction();
scenario.end();
}
#[test]
#[expected_failure(abort_code = auction::EInsufficientPayment)]
fun test_underpayment_fails() {
// ...测试支付不足时的失败路径
}
第三部分:竞拍者 dApp
// src/AuctionApp.tsx
import { useState, useEffect, useCallback } from 'react'
import { useConnection, getObjectWithJson } from '@evefrontier/dapp-kit'
import { useDAppKit } from '@mysten/dapp-kit-react'
import { Transaction } from '@mysten/sui/transactions'
const DUTCH_PACKAGE = "0x_DUTCH_PACKAGE_"
const AUCTION_ID = "0x_AUCTION_ID_"
const STORAGE_UNIT_ID = "0x..."
const CHARACTER_ID = "0x..."
const CLOCK_OBJECT_ID = "0x6"
interface AuctionState {
start_price: string
end_price: string
start_time_ms: string
price_drop_interval_ms: string
price_drop_amount: string
is_active: boolean
item_type_id: string
}
function calculateCurrentPrice(state: AuctionState): number {
if (!state.is_active) return Number(state.end_price)
const now = Date.now()
const elapsed = now - Number(state.start_time_ms)
const drops = Math.floor(elapsed / Number(state.price_drop_interval_ms))
const totalDrop = drops * Number(state.price_drop_amount)
const maxDrop = Number(state.start_price) - Number(state.end_price)
if (totalDrop >= maxDrop) return Number(state.end_price)
return Number(state.start_price) - totalDrop
}
function msUntilNextDrop(state: AuctionState): number {
const now = Date.now()
const elapsed = now - Number(state.start_time_ms)
const interval = Number(state.price_drop_interval_ms)
return interval - (elapsed % interval)
}
export function AuctionApp() {
const { isConnected, handleConnect } = useConnection()
const dAppKit = useDAppKit()
const [auctionState, setAuctionState] = useState<AuctionState | null>(null)
const [currentPrice, setCurrentPrice] = useState(0)
const [countdown, setCountdown] = useState(0)
const [status, setStatus] = useState('')
const [isBuying, setIsBuying] = useState(false)
// 加载拍卖状态
const loadAuction = useCallback(async () => {
const obj = await getObjectWithJson(AUCTION_ID)
if (obj?.content?.dataType === 'moveObject') {
const fields = obj.content.fields as AuctionState
setAuctionState(fields)
}
}, [])
useEffect(() => {
loadAuction()
}, [loadAuction])
// 每秒更新价格倒计时
useEffect(() => {
if (!auctionState) return
const timer = setInterval(() => {
setCurrentPrice(calculateCurrentPrice(auctionState))
setCountdown(msUntilNextDrop(auctionState))
}, 1000)
return () => clearInterval(timer)
}, [auctionState])
const handleBuyNow = async () => {
if (!isConnected) { setStatus('请先连接钱包'); return }
setIsBuying(true)
setStatus('⏳ 提交交易...')
try {
const tx = new Transaction()
const [paymentCoin] = tx.splitCoins(tx.gas, [
tx.pure.u64(currentPrice + 1_000) // 略多于当前价,防止最后一秒涨价
])
tx.moveCall({
target: `${DUTCH_PACKAGE}::auction::buy_now`,
arguments: [
tx.object(AUCTION_ID),
tx.object(STORAGE_UNIT_ID),
tx.object(CHARACTER_ID),
paymentCoin,
tx.object(CLOCK_OBJECT_ID),
],
})
const result = await dAppKit.signAndExecuteTransaction({ transaction: tx })
setStatus(`🏆 竞拍成功!Tx: ${result.digest.slice(0, 12)}...`)
await loadAuction()
} catch (e: any) {
setStatus(`❌ ${e.message}`)
} finally {
setIsBuying(false)
}
}
const countdownSec = Math.ceil(countdown / 1000)
const priceInSui = (currentPrice / 1e9).toFixed(2)
const nextPriceSui = (
Math.max(Number(auctionState?.end_price ?? 0), currentPrice - Number(auctionState?.price_drop_amount ?? 0)) / 1e9
).toFixed(2)
return (
<div className="auction-app">
<header>
<h1>🔨 荷兰式拍卖行</h1>
{!isConnected
? <button onClick={handleConnect}>连接钱包</button>
: <span className="connected">✅ 已连接</span>
}
</header>
{auctionState ? (
<div className="auction-board">
<div className="current-price">
<span className="label">当前价格</span>
<span className="price">{priceInSui} SUI</span>
</div>
<div className="countdown">
<span className="label">⏳ {countdownSec} 秒后降为</span>
<span className="next-price">{nextPriceSui} SUI</span>
</div>
<div className="info-row">
<span>起拍价:{(Number(auctionState.start_price) / 1e9).toFixed(2)} SUI</span>
<span>最低价:{(Number(auctionState.end_price) / 1e9).toFixed(2)} SUI</span>
</div>
{auctionState.is_active ? (
<button
className="buy-btn"
onClick={handleBuyNow}
disabled={isBuying || !isConnected}
>
{isBuying ? '⏳ 购买中...' : `💰 立即购买 ${priceInSui} SUI`}
</button>
) : (
<div className="sold-banner">🎉 已售出</div>
)}
{status && <p className="tx-status">{status}</p>}
</div>
) : (
<div>加载拍卖信息...</div>
)}
</div>
)
}
🎯 完整回顾
合约层
├── create_auction() → 创建共享 DutchAuction 对象
├── current_price() → 根据时间计算当前价格(纯计算,不修改状态)
├── buy_now() → 支付 → 收益入金库 → SSU 取出物品 → 发事件
├── cancel_auction() → Owner 取消,物品归还
└── withdraw_proceeds() → Owner 提取拍卖收益
dApp 层
├── 每秒重新计算价格(纯前端,不消耗 Gas)
├── 倒计时显示下次降价时间
└── 一键购买,自动附上当前价格
🔧 扩展练习
- 支持批量拍卖:同时拍卖多种物品,每种独立倒计时
- 预约购买:玩家设定目标价格,自动在达到时触发购买(链下监听 + 定时提交)
- 历史成交记录:监听
AuctionSettled事件展示近期成交数据