Dacade平台我的SUI Move挑战合约——幸运咖啡馆(LuckyCafe)
2024.03.02 rzexin
本文是参加Dacade
挑战提交的SUI Move
合约的整体介绍,希望对SUI Move
的初学者们了解开发和测试流程有所帮助。本人同样也是Move
的初学者,合约中可能会存在错误,欢迎大家指正。
通过一段时间对Move
语言的学习,对Move
合约开发有了初步的了解。但一直没有机会实际去练手。刚好通过Move中文社区,了解到Dacade
平台有了SUI Move
合约开发技术的学习资料和有奖挑战。于是想以此为契机,实战开发一下Move
合约,加深对语法知识的掌握。
浏览了部分已经提交代码的挑战者合约,看到涉及各种场景,似乎挑战对提交的合约类型没有任何限制,可以随意发挥。于是我便打算胡造一个场景,打算尽可能多的把所学的Move
语法知识都应用上,强化实际使用经验。
但到底写什么合约,一直没有确定。就在一天晚上,在给女儿读她们最喜欢的故事书《屁屁侦探》时,故事中有一个场景是“幸运猫咖啡馆”,于是创造一个 幸运咖啡馆(Lucky Cafe) 合约的想法便应运而生了。
定了幸运咖啡馆这个合约主题后,接下来就是明确有哪些实体、实体之间的关系、以及经济模型如何。
Cafe
)Card
)Coffee
)Owner
、Admin
)Cafe
),成为店主(Owner
、Admin
)Card
)Card
)可以1:1
兑换咖啡(Coffee
)Lucky Number)
,任何人都可以触发合约接口随机抽取一个幸运数字,若挑选的幸运数字对应的咖啡卡已经被使用,将可重新抽取幸运数字Alice
可以销毁这张幸运咖啡卡,将手上持有咖啡卡的数量翻倍。$ sui move new bityoume_sui_move_lucky_cafe
Cafe
) // represents a cafe object
struct Cafe has key, store {
// uid of the cafe object
id: UID,
// balance of the cafe object
sui: Balance<SUI>,
// number of participants in the cafe
participants: u64,
// owner of the cafe object
owner: address,
// lucky number of the winner of the cafe object
winner_lucky_number: Option<u64>,
// base drand round of the cafe object
base_drand_round: u64,
// lucky number to owner mapping
lucky_number_2_owner: VecMap<u64, address>,
// lucky number to card id mapping
lucky_number_2_card_id: VecMap<u64, ID>,
// owner to card count mapping
owner_2_card_count: VecMap<address, u64>,
}
Card
) //===============================
// Module Structs
//===============================
// one Card can only purchase one cup of coffee
struct Card has key, store {
// uid of the card object
id: UID,
// id of the cafe object
cafe_id: ID,
// lucky number of the card object
lucky_number: u64,
}
Coffee
) // represents a cup of coffee object
struct Coffee has key, store {
// uid of the coffee object
id: UID,
// id of the cafe object
cafe_id: ID,
}
Admin
) // represents an admin capability object
struct Admin has key {
// uid of the admin object
id: UID,
}
抽取到幸运数字时,会触发该事件,上层应用可以监听该事件,以便通知幸运咖啡卡的持有人去领取奖励
//===============================
// Event Structs
//===============================
// event emitted when a lucky number is created
struct WinnerLuckyNumberCreated has copy, drop {
// id of the cafe object
cafe_id: ID,
// id of the card object
card_id: ID,
// lucky number of the card object
lucky_number: u64,
// owner of the card object
owner: address,
}
//===============================
// Error codes
//===============================
// 购买咖啡卡时,传入了非法金额
const EInvalidSuiAmount: u64 = 1;
// 购买咖啡或领取奖励,使用了非当前咖啡馆的咖啡卡
const EMismatchedCard: u64 = 2;
// 已经抽取到了幸运数字,再次抽取将报这个错误
const EAlreadyHasWinnerLuckyNumber: u64 = 3;
// 非法轮次参数,抽取幸运数字的轮次需要递增
const EInvalidDrandRound: u64 = 4;
// 当前咖啡卡不是抽取的幸运咖啡卡
const EInvalidWinnerLuckyNumber: u64 = 5;
// 幸运咖啡卡尚未抽取
const EEmptyWinnerLuckyNumber: u64 = 6;
//===============================
// Constants
//===============================
// 单词最大奖励咖啡卡数量
const MAX_REWARD_CARD_COUNT: u64 = 10;
create_cafe
) // create a new cafe object with the provided base_drand_round and initializes its fields.
public entry fun create_cafe(base_drand_round: u64, ctx: &mut TxContext) {
let cafe = Cafe {
id: object::new(ctx),
sui: balance::zero(),
participants: 0,
owner: tx_context::sender(ctx),
lucky_number_2_owner: vec_map::empty(),
lucky_number_2_card_id: vec_map::empty(),
owner_2_card_count: vec_map::empty(),
winner_lucky_number: option::none(),
base_drand_round: base_drand_round,
};
transfer::public_share_object(cafe);
let recipient= tx_context::sender(ctx);
transfer::transfer(
Admin {
id: object::new(ctx),
}, recipient
);
}
buy_cafe_card
) // buy a card for the cafe object with the provided sui amount.
public entry fun buy_cafe_card(cafe: &mut Cafe, sui: Coin<SUI>, ctx: &mut TxContext) {
let sui_amount = coin::value(&sui);
assert!(sui_amount % 5 == 0 , EInvalidSuiAmount);
let sui_balance = coin::into_balance(sui);
// add SUI to the cafe's balance
balance::join(&mut cafe.sui, sui_balance);
let recipient= tx_context::sender(ctx);
let card_count = sui_amount / 5;
let reward_card_count = card_count / 2;
let i = 0_u64;
let total_card_count = card_count + reward_card_count;
vec_map::insert(&mut cafe.owner_2_card_count, recipient, total_card_count);
while (i < total_card_count ) {
let card = Card {
id: object::new(ctx),
cafe_id: object::uid_to_inner(&cafe.id),
lucky_number: cafe.participants,
};
vec_map::insert(&mut cafe.lucky_number_2_owner,
cafe.participants, recipient);
vec_map::insert(&mut cafe.lucky_number_2_card_id,
cafe.participants, object::uid_to_inner(&card.id));
cafe.participants = cafe.participants + 1;
transfer::transfer(card, recipient);
i = i + 1;
}
}
buy_coffee
) // buy a coffee for the cafe object with the provided card object.
public entry fun buy_coffee(cafe: &mut Cafe, card: Card, ctx: &mut TxContext) {
let Card { id, cafe_id, lucky_number} = card;
assert!(cafe_id == object::uid_to_inner(&cafe.id), EMismatchedCard);
transfer::transfer(Coffee {
id: object::new(ctx), cafe_id: cafe_id}, tx_context::sender(ctx));
object::delete(id);
vec_map::remove(&mut cafe.lucky_number_2_owner, &lucky_number);
vec_map::remove(&mut cafe.lucky_number_2_card_id, &lucky_number);
// cafe.participants = cafe.participants - 1;
let card_count = vec_map::get_mut(&mut cafe.owner_2_card_count,
&tx_context::sender(ctx));
*card_count = *card_count - 1;
}
get_lucky_number
) // get the lucky number for the cafe object with the provided drand signature.
public entry fun get_lucky_number(cafe: &mut Cafe, current_round: u64, drand_sig: vector<u8>) {
assert!(cafe.winner_lucky_number ==
option::none(), EAlreadyHasWinnerLuckyNumber);
assert!(cafe.base_drand_round < current_round, EInvalidDrandRound);
verify_drand_signature(drand_sig, current_round);
cafe.base_drand_round = current_round;
let digest = derive_randomness(drand_sig);
cafe.winner_lucky_number=
option::some(safe_selection(cafe.participants, &digest));
let lucky_number = option::borrow(&cafe.winner_lucky_number);
assert!(vec_map::contains(&cafe.lucky_number_2_owner, lucky_number),
EInvalidWinnerLuckyNumber);
let owner = vec_map::get(&cafe.lucky_number_2_owner, lucky_number);
let card_id = vec_map::get(&cafe.lucky_number_2_card_id, lucky_number);
event::emit(WinnerLuckyNumberCreated {
cafe_id: object::uid_to_inner(&cafe.id),
lucky_number: *lucky_number,
owner: *owner,
card_id: *card_id,
});
}
get_reward_with_lucky_card
) // get the reward for the more cafe object with the provided lucky card object.
public entry fun get_reward_with_lucky_card(cafe: &mut Cafe, card: Card, ctx: &mut TxContext) {
let Card { id, cafe_id, lucky_number} = card;
assert!(cafe_id == object::uid_to_inner(&cafe.id), EMismatchedCard);
assert!(cafe.winner_lucky_number ==
option::some(lucky_number), EInvalidWinnerLuckyNumber);
vec_map::remove(&mut cafe.lucky_number_2_owner, &lucky_number);
vec_map::remove(&mut cafe.lucky_number_2_card_id, &lucky_number);
let recipient= tx_context::sender(ctx);
let card_count = vec_map::get(&cafe.owner_2_card_count, &recipient);
let reward_card_count = math::min(*card_count, MAX_REWARD_CARD_COUNT);
cafe.winner_lucky_number = option::none();
object::delete(id);
let card_count = vec_map::get_mut(&mut cafe.owner_2_card_count,
&tx_context::sender(ctx));
*card_count = *card_count - 1;
let i = 0_u64;
while (i < reward_card_count) {
let card = Card {
id: object::new(ctx),
cafe_id: object::uid_to_inner(&cafe.id),
lucky_number: cafe.participants,
};
vec_map::insert(&mut cafe.lucky_number_2_owner,
cafe.participants, recipient);
vec_map::insert(&mut cafe.lucky_number_2_card_id,
cafe.participants, object::uid_to_inner(&card.id));
cafe.participants = cafe.participants + 1;
*card_count = *card_count + 1;
transfer::transfer(card, recipient);
i = i + 1;
}
}
// remove the winner lucky number of the cafe object.
public entry fun remove_lucky_number(_: &Admin, cafe: &mut Cafe) {
assert!(cafe.winner_lucky_number !=
option::none(), EEmptyWinnerLuckyNumber);
cafe.winner_lucky_number = option::none();
}
#[test_only]
module bityoume::lucky_cafe_test {
use bityoume::lucky_cafe::{Self, Cafe, Card};
#[test]
fun use_cafe_card_by_coffee_test() {
use sui::test_scenario;
use sui::test_utils::assert_eq;
use sui::coin::mint_for_testing;
let jason = @0x11;
let alice = @0x22;
let bob = @0x33;
let scenario_val = test_scenario::begin(jason);
let scenario = &mut scenario_val;
// jason create a Cafe share object and got Admin object
test_scenario::next_tx(scenario, jason);
{
lucky_cafe::init_for_testing(test_scenario::ctx(scenario));
};
test_scenario::next_tx(scenario, alice);
{
let cafe = test_scenario::take_shared<Cafe>(scenario);
let cafe_ref = &mut cafe;
let coin = mint_for_testing(10, test_scenario::ctx(scenario));
lucky_cafe::buy_cafe_card(cafe_ref, coin, test_scenario::ctx(scenario));
let card_count = lucky_cafe::get_sender_card_count(cafe_ref, test_scenario::ctx(scenario));
assert_eq(card_count, 3);
test_scenario::return_shared(cafe);
};
test_scenario::next_tx(scenario, bob);
{
let cafe = test_scenario::take_shared<Cafe>(scenario);
let cafe_ref = &mut cafe;
let coin = mint_for_testing(5, test_scenario::ctx(scenario));
lucky_cafe::buy_cafe_card(cafe_ref, coin, test_scenario::ctx(scenario));
let card_count = lucky_cafe::get_sender_card_count(cafe_ref, test_scenario::ctx(scenario));
assert_eq(card_count, 1);
test_scenario::return_shared(cafe);
};
test_scenario::next_tx(scenario, alice);
{
let cafe = test_scenario::take_shared<Cafe>(scenario);
let cafe_ref = &mut cafe;
let card = test_scenario::take_from_sender<Card>(scenario);
lucky_cafe::buy_coffee(cafe_ref, card, test_scenario::ctx(scenario));
let card_count = lucky_cafe::get_sender_card_count(cafe_ref, test_scenario::ctx(scenario));
assert_eq(card_count, 2);
test_scenario::return_shared(cafe);
};
test_scenario::end(scenario_val);
}
}
切换到Jason帐号
$ sui client publish --gas-budget 100000000
export PACKAGE_ID=0xc0c00b50dd913b4137bfa078701b04a69f47eb87ac1b6cb6115f4042609657db
# 获取drand随机源当前轮次
export BASE_ROUND=`curl -s https://drand.cloudflare.com/52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971/public/latest | jq .round`
echo $BASE_ROUND
export GAS_BUDGET=100000000
sui client call --function create_cafe --package $PACKAGE_ID --module lucky_cafe --args $BASE_ROUND --gas-budget $GAS_BUDGET
export CAFE=0x8ed617b8bb176011023e63428de3d893e287da85390d8dabeb94d9b5588e5727
export ADMIN=0xc1c6278827259295249bf3d43ce0861c21960693a89b5677b175d9844eafc57c
切换到Alice帐号
Alice花费10 GAS,获得2张,赠送1张,合计3张咖啡卡
# 切换到alice帐号
sui client switch --address alice
# 找alice名下2个大GAS对象,一个用于支付gas,一个用于拆分出指定GAS数量的coin对象
sui client gas --json | jq '.[] | select(.gasBalance > 100000) | .gasCoinId' -r > output.txt
GAS=$(sed -n '1p' output.txt)
SPLIT_COIN=$(sed -n '2p' output.txt)
# 拆分出10 GAS,用于购买咖啡卡
export COIN=`sui client split-coin --coin-id $SPLIT_COIN --amounts 10 --gas $GAS --gas-budget $GAS_BUDGET --json | jq -r '.objectChanges[] | select(.objectType=="0x2::coin::Coin<0x2::sui::SUI>" and .type=="created") | .objectId'`
# 使用10GAS 购买咖啡卡
sui client call --function buy_cafe_card --package $PACKAGE_ID --module lucky_cafe --args $CAFE $COIN --gas-budget $GAS_BUDGET
切换到Bob帐号
Bob花费5 GAS,获得1张咖啡卡
# 切换到bob帐号
sui client switch --address bob
# 找bob名下2个大GAS对象,一个用于支付gas,一个用于拆分出指定GAS数量的coin对象
sui client gas --json | jq '.[] | select(.gasBalance > 100000) | .gasCoinId' -r > output.txt
GAS=$(sed -n '1p' output.txt)
SPLIT_COIN=$(sed -n '2p' output.txt)
# 拆分出5 GAS,用于购买咖啡卡
export COIN=`sui client split-coin --coin-id $SPLIT_COIN --amounts 5 --gas $GAS --gas-budget $GAS_BUDGET --json | jq -r '.objectChanges[] | select(.objectType=="0x2::coin::Coin<0x2::sui::SUI>" and .type=="created") | .objectId'`
# 使用5GAS 购买咖啡卡
sui client call --function buy_cafe_card --package $PACKAGE_ID --module lucky_cafe --args $CAFE $COIN --gas-budget $GAS_BUDGET
$ sui client object $CAFE
在命令结果中可以获得如下信息:
"owner_2_card_count": {
"type": "0x2::vec_map::VecMap<address, u64>",
"fields": {
"contents": [
{
"type": "0x2::vec_map::Entry<address, u64>",
"fields": {
"key": "0x2d178b9704706393d2630fe6cf9415c2c50b181e9e3c7a977237bb2929f82d19",
"value": "3"
}
},
{
"type": "0x2::vec_map::Entry<address, u64>",
"fields": {
"key": "0xf2e6ffef7d0543e258d4c47a53d6fa9872de4630cc186950accbd83415b009f0",
"value": "1"
}
}
]
}
},
"lucky_number_2_owner": {
"type": "0x2::vec_map::VecMap<u64, address>",
"fields": {
"contents": [
{
"type": "0x2::vec_map::Entry<u64, address>",
"fields": {
"key": "0",
"value": "0x2d178b9704706393d2630fe6cf9415c2c50b181e9e3c7a977237bb2929f82d19"
}
},
{
"type": "0x2::vec_map::Entry<u64, address>",
"fields": {
"key": "1",
"value": "0x2d178b9704706393d2630fe6cf9415c2c50b181e9e3c7a977237bb2929f82d19"
}
},
{
"type": "0x2::vec_map::Entry<u64, address>",
"fields": {
"key": "2",
"value": "0x2d178b9704706393d2630fe6cf9415c2c50b181e9e3c7a977237bb2929f82d19"
}
},
{
"type": "0x2::vec_map::Entry<u64, address>",
"fields": {
"key": "3",
"value": "0xf2e6ffef7d0543e258d4c47a53d6fa9872de4630cc186950accbd83415b009f0"
}
}
]
}
"lucky_number_2_card_id": {
"type": "0x2::vec_map::VecMap<u64, 0x2::object::ID>",
"fields": {
"contents": [
{
"type": "0x2::vec_map::Entry<u64, 0x2::object::ID>",
"fields": {
"key": "0",
"value": "0x8977e34301e7cca422fd3aa0a1f9a998798949fd325ff1786c7c120810a639e4"
}
},
{
"type": "0x2::vec_map::Entry<u64, 0x2::object::ID>",
"fields": {
"key": "1",
"value": "0xfba606e2c7c66488820823a246eacb3ea0cf0a8dda74e17c5713b9151762468d"
}
},
{
"type": "0x2::vec_map::Entry<u64, 0x2::object::ID>",
"fields": {
"key": "2",
"value": "0xf5c63e851050015040831c11897e7afff74f652c1c653f73abb3d27201b89cb6"
}
},
{
"type": "0x2::vec_map::Entry<u64, 0x2::object::ID>",
"fields": {
"key": "3",
"value": "0x4c5e031144be03246bf38a984981ff74ee9f25e94283d3fc094cdae113a199ff"
}
}
]
}
},
切换到
Alice
帐号,Alice
使用幸运数字是1的咖啡卡兑换一杯咖啡
sui client switch --address alice
export CARD=0xfba606e2c7c66488820823a246eacb3ea0cf0a8dda74e17c5713b9151762468d
sui client call --function buy_coffee --package $PACKAGE_ID --module lucky_cafe --args $CAFE $CARD --gas-budget $GAS_BUDGET
这个
Coffee
对象可以理解成实物咖啡,在链上的凭证,可以做成NFT的形式
"lucky_number_2_card_id": {
"type": "0x2::vec_map::VecMap<u64, 0x2::object::ID>",
"fields": {
"contents": [
{
"type": "0x2::vec_map::Entry<u64, 0x2::object::ID>",
"fields": {
"key": "0",
"value": "0x8977e34301e7cca422fd3aa0a1f9a998798949fd325ff1786c7c120810a639e4"
}
},
{
"type": "0x2::vec_map::Entry<u64, 0x2::object::ID>",
"fields": {
"key": "2",
"value": "0xf5c63e851050015040831c11897e7afff74f652c1c653f73abb3d27201b89cb6"
}
},
{
"type": "0x2::vec_map::Entry<u64, 0x2::object::ID>",
"fields": {
"key": "3",
"value": "0x4c5e031144be03246bf38a984981ff74ee9f25e94283d3fc094cdae113a199ff"
}
}
]
}
},
"lucky_number_2_owner": {
"type": "0x2::vec_map::VecMap<u64, address>",
"fields": {
"contents": [
{
"type": "0x2::vec_map::Entry<u64, address>",
"fields": {
"key": "0",
"value": "0x2d178b9704706393d2630fe6cf9415c2c50b181e9e3c7a977237bb2929f82d19"
}
},
{
"type": "0x2::vec_map::Entry<u64, address>",
"fields": {
"key": "2",
"value": "0x2d178b9704706393d2630fe6cf9415c2c50b181e9e3c7a977237bb2929f82d19"
}
},
{
"type": "0x2::vec_map::Entry<u64, address>",
"fields": {
"key": "3",
"value": "0xf2e6ffef7d0543e258d4c47a53d6fa9872de4630cc186950accbd83415b009f0"
}
}
]
}
},
"owner": "0x5c5882d73a6e5b6ea1743fb028eff5e0d7cc8b7ae123d27856c5fe666d91569a",
"owner_2_card_count": {
"type": "0x2::vec_map::VecMap<address, u64>",
"fields": {
"contents": [
{
"type": "0x2::vec_map::Entry<address, u64>",
"fields": {
"key": "0x2d178b9704706393d2630fe6cf9415c2c50b181e9e3c7a977237bb2929f82d19",
"value": "2"
}
},
{
"type": "0x2::vec_map::Entry<address, u64>",
"fields": {
"key": "0xf2e6ffef7d0543e258d4c47a53d6fa9872de4630cc186950accbd83415b009f0",
"value": "1"
}
}
]
}
},
如果幸运数字还未被抽取,或者已抽取了并已被领取,那么任何人都可以进行幸运数字的抽取。
# 获取当前轮次和随机数签名
curl -s https://drand.cloudflare.com/52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971/public/latest > output.txt
export CURRENT_ROUND=`jq '.round' output.txt`
export SIGNATURE=0x`jq -r '.signature' output.txt`
# 抽取幸运数字
sui client call --function get_lucky_number --package $PACKAGE_ID --module lucky_cafe --args $CAFE $CURRENT_ROUND $SIGNATURE --gas-budget $GAS_BUDGET
若幸运数字成功抽取,将会抛出事件,应用端可以监听该事件,通知幸运咖啡卡的持有者来领取奖励。
Error executing transaction: Failure {
error: "MoveAbort(MoveLocation { module: ModuleId { address: c0c00b50dd913b4137bfa078701b04a69f47eb87ac1b6cb6115f4042609657db, name: Identifier(\"lucky_cafe\") }, function: 3, instruction: 10, function_name: Some(\"get_lucky_number\") }, 3) in command 0",
}
抽中的幸运咖啡卡的持有人,可以凭借持有的咖啡卡,去领取奖励。当前奖励是使得持有的咖啡卡数量翻倍,但最大奖励上限为10
# 切换到Alice,因抽中的幸运数字为0,是Alice拥有的咖啡卡
sui client switch --address alice
export CARD=0x8977e34301e7cca422fd3aa0a1f9a998798949fd325ff1786c7c120810a639e4
# 使用持有的幸运咖啡卡,领取奖励
sui client call --function get_reward_with_lucky_card --package $PACKAGE_ID --module lucky_cafe --args $CAFE $CARD --gas-budget $GAS_BUDGET
因Alice共持有2张咖啡卡,调用该接口会翻倍(新产生2张)咖啡卡,给到Alice
若抽中幸运咖啡卡的持有人,一直未领取奖励。咖啡店的创建人(具备
Admin
权限),可以删除已抽取到的幸运数字,以便可以让顾客再次去抽取。
sui client call --function remove_lucky_number --package $PACKAGE_ID --module lucky_cafe --args $ADMIN $CAFE --gas-budget $GAS_BUDGET
以上就是自己参加Dacade
挑战提交合约的介绍,希望对大家有所帮助。
源码地址:https://github.com/bityoume/bityoume_sui_move_lucky_cafe
Dacade挑战提交资料地址:https://dacade.org/communities/sui/challenges/19885730-fb83-477a-b95b-4ab265b61438/submissions/a41897a8-3d74-47b7-83c6-f06a63379188
如希望更进一步学习SUI Move开发,欢迎关注微信公众号:Move中文,以及参与星航计划🚀,开启你的 Sui Move 之旅!
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!