diff --git a/contracts/launchpad/Move.lock b/contracts/launchpad/Move.lock index 56c40448..d508a657 100644 --- a/contracts/launchpad/Move.lock +++ b/contracts/launchpad/Move.lock @@ -2,7 +2,7 @@ [move] version = 0 -manifest_digest = "461044ADFC5BFF564B120D25764387C867ACD614C75866CF29CC57E3E5C0E117" +manifest_digest = "FB7BC10285E871AB700F3DC850EF22AB7AEB29A1EA8BCD02C80A1778E7635952" deps_digest = "397E6A9F7A624706DBDFEE056CE88391A15876868FD18A88504DA74EB458D697" dependencies = [ @@ -77,3 +77,8 @@ dependencies = [ { name = "Pseudorandom" }, { name = "Sui" }, ] + +[move.toolchain-version] +compiler-version = "1.21.1" +edition = "legacy" +flavor = "sui" diff --git a/contracts/launchpad/sources/launchpad/discount/crypto.move b/contracts/launchpad/sources/launchpad/discount/crypto.move new file mode 100644 index 00000000..cbcd9aa8 --- /dev/null +++ b/contracts/launchpad/sources/launchpad/discount/crypto.move @@ -0,0 +1,92 @@ +module ob_launchpad::crypto_utils { + use sui::ecdsa_k1; + use std::vector; + use std::debug::print; + use sui::object::{Self, ID}; + use sui::address as sui_address; + use sui::hash::keccak256; + + const EIncorrectSignature: u64 = 0; + + const KECCAK256: u8 = 0; + + public entry fun verify_message( + venue_id: ID, + addr: address, + nonce: u64, + addr_pubkey: vector, + signature: vector, + ) { + let msg = construct_msg( + object::id_to_bytes(&venue_id), + address_to_bytes(addr), + address_to_bytes(sui_address::from_u256((nonce as u256))), + ); + + assert!( + ecdsa_k1::secp256k1_verify(&signature, &addr_pubkey, &msg, KECCAK256), + EIncorrectSignature + ); + } + + // This function converts a public key to a Sui address using Keccak-256 for hashing. + // The public key is expected to be in the form of a vector (byte array). + public fun public_key_to_sui_address(public_key: vector): address { + let data_to_hash = vector[]; + + // Append the Ed25519 signature scheme flag byte (0x00) to the data to hash. + vector::push_back(&mut data_to_hash, 0x00); + + // Append the public key bytes to the data to hash. + let public_key_len = vector::length(&public_key); + let i = 0; + while (i < public_key_len) { + vector::push_back(&mut data_to_hash, *vector::borrow(&public_key, i)); + i = i + 1; + }; + + print(&data_to_hash); + + // Hash the data using Keccak-256. + sui_address::from_bytes(keccak256(&data_to_hash)) + } + + // This function converts a public key to a Sui address using Keccak-256 for hashing. + // The public key is expected to be in the form of a vector (byte array). + public fun public_key_to_sui_address_(public_key: vector): vector { + let data_to_hash = vector[]; + + // Append the Ed25519 signature scheme flag byte (0x00) to the data to hash. + vector::push_back(&mut data_to_hash, 0x00); + + // Append the public key bytes to the data to hash. + let public_key_len = vector::length(&public_key); + let i = 0; + while (i < public_key_len) { + vector::push_back(&mut data_to_hash, *vector::borrow(&public_key, i)); + i = i + 1; + }; + + print(&data_to_hash); + + // Hash the data using Keccak-256. + keccak256(&data_to_hash) + } + + public fun construct_msg( + listing_id: vector, + addr: vector, + nonce: vector, + ): vector { + let msg = vector::empty(); + vector::append(&mut msg, listing_id); + vector::append(&mut msg, addr); + vector::append(&mut msg, nonce); + + msg + } + + public fun address_to_bytes(addr: address): vector { + object::id_to_bytes(&object::id_from_address(addr)) + } +} diff --git a/contracts/launchpad/sources/launchpad/discount/free_mint.move b/contracts/launchpad/sources/launchpad/discount/free_mint.move new file mode 100644 index 00000000..f545fac1 --- /dev/null +++ b/contracts/launchpad/sources/launchpad/discount/free_mint.move @@ -0,0 +1,127 @@ +module ob_launchpad::free_mint { + use sui::tx_context::TxContext; + use sui::table::{Self, Table}; + use sui::object::ID; + + use ob_launchpad::listing::{Self, Listing}; + use ob_launchpad::marketplace::Marketplace; + use ob_launchpad::crypto_utils; + + friend ob_launchpad::flat_fee; + friend ob_launchpad::fixed_price; + + struct FreeMintList has store { + list: Table, + } + + struct State has store, drop { + // Bit flag: + // 1 --> Registered + // 2 --> Checked-in + // 3 --> Collected + phase: u8, + } + + struct FreeMintPotato { + venue_id: ID, + addr: address, + } + + public fun add_free_mint_address( + marketplace: &Marketplace, + listing: &mut Listing, + venue_id: ID, + addr: address, + ctx: &mut TxContext, + ) { + listing::assert_listing_marketplace_match(marketplace, listing); + listing::assert_correct_admin_or_member(marketplace, listing, ctx); + listing::assert_venue(listing, venue_id); + + let free_mint_exists = listing::free_mint_exists(listing, venue_id); + + if (!free_mint_exists) { + listing::add_free_mint_internal(listing, venue_id, FreeMintList { + list: table::new(ctx), + }); + }; + + let free_mint = listing::borrow_free_mint_mut(listing, venue_id); + + assert!(!table::contains(&free_mint.list, addr), 0); + + let disc = State { phase: 1 }; + table::add(&mut free_mint.list, addr, disc); + } + + public fun checkin_free_mint_with_sig( + listing: &mut Listing, + venue_id: ID, + addr: address, + nonce: u64, + addr_pubkey: vector, + signature: vector, + ): FreeMintPotato { + listing::assert_venue(listing, venue_id); + + // 1. Check if address is in the list + let free_mint = listing::borrow_free_mint_mut(listing, venue_id); + let state = table::borrow_mut(&mut free_mint.list, addr); + + assert!(state.phase == 1, 0); + + state.phase = 2; + + crypto_utils::verify_message( + venue_id, + addr, + nonce, + addr_pubkey, + signature, + ); + + FreeMintPotato { venue_id, addr } + } + + // To be called internally. Does not assert venue ID as it's asserted upstream + public(friend) fun apply_free_mint_if_any( + listing: &mut Listing, + venue_id: ID, + addr: address, + ): bool { + let free_mint_exists = listing::free_mint_exists(listing, venue_id); + + if (!free_mint_exists) { + false + } else { + let free_mint = listing::borrow_free_mint_mut(listing, venue_id); + + let on_the_list = table::contains(&free_mint.list, addr); + + if (!on_the_list) { + false + } else { + let state = table::borrow_mut(&mut free_mint.list, addr); + assert!(state.phase == 2, 0); + + state.phase = 3; + + true + } + } + } + + public fun checkout_free_mint( + listing: &mut Listing, + potato: FreeMintPotato, + ) { + let FreeMintPotato { venue_id, addr } = potato; + listing::assert_venue(listing, venue_id); + + let free_mint = listing::borrow_free_mint_mut(listing, venue_id); + + let state = table::remove(&mut free_mint.list, addr); + + assert!(state.phase == 3, 0); + } +} diff --git a/contracts/launchpad/sources/launchpad/rebate.move b/contracts/launchpad/sources/launchpad/discount/rebate.move similarity index 100% rename from contracts/launchpad/sources/launchpad/rebate.move rename to contracts/launchpad/sources/launchpad/discount/rebate.move diff --git a/contracts/launchpad/sources/launchpad/fees/fee_cut.move b/contracts/launchpad/sources/launchpad/fees/fee_cut.move new file mode 100644 index 00000000..cdcb464e --- /dev/null +++ b/contracts/launchpad/sources/launchpad/fees/fee_cut.move @@ -0,0 +1,89 @@ +module ob_launchpad::fee_cut { + use sui::tx_context::TxContext; + use sui::table::{Self, Table}; + use sui::table_vec::{Self, TableVec}; + use sui::balance; + use sui::coin; + use sui::transfer; + + use ob_launchpad::listing::{Self, Listing}; + use ob_launchpad::marketplace::Marketplace; + use ob_launchpad::proceeds; + + friend ob_launchpad::flat_fee; + + struct FeeCutList has store { + list: Table, + addresses: TableVec
, + } + + public fun add_fee_cut( + marketplace: &Marketplace, + listing: &mut Listing, + addr: address, + cut_amount: u64, + ctx: &mut TxContext, + ) { + listing::assert_listing_marketplace_match(marketplace, listing); + listing::assert_correct_admin_or_member(marketplace, listing, ctx); + + let fee_cut_exists = listing::fee_cut_exists(listing); + + if (!fee_cut_exists) { + listing::add_fee_cut_internal(listing, FeeCutList { + list: table::new(ctx), + addresses: table_vec::empty(ctx), + }); + }; + + let fee_cut = listing::borrow_fee_cut_mut(listing); + + if (!table::contains(&fee_cut.list, addr)) { + table::add(&mut fee_cut.list, addr, cut_amount); + table_vec::push_back(&mut fee_cut.addresses, addr); + } else { + let cut = table::borrow_mut(&mut fee_cut.list, addr); + *cut = *cut + cut_amount; + }; + } + + public(friend) fun collect_fee_cuts_if_any( + marketplace: &Marketplace, + listing: &mut Listing, + ctx: &mut TxContext, + ) { + listing::assert_listing_marketplace_match(marketplace, listing); + listing::assert_correct_admin_or_member(marketplace, listing, ctx); + + let fee_cut_exists = listing::fee_cut_exists(listing); + + // Pay fee cuts + if (fee_cut_exists) { + let fee_cut = listing::borrow_fee_cut_mut(listing); + let len = table_vec::length(&fee_cut.addresses); + + while (len > 0) { + let fee_cut = listing::borrow_fee_cut_mut(listing); + let addr = table_vec::pop_back(&mut fee_cut.addresses); + let cut_amount = table::remove(&mut fee_cut.list, addr); + + let proceeds = listing::borrow_proceeds_mut(listing); + let balance = proceeds::balance_mut(proceeds); + + let cut_balance = balance::split( + balance, + cut_amount, + ); + + let cut = coin::from_balance(cut_balance, ctx); + + transfer::public_transfer( + cut, + addr, + ); + + len = len - 1; + }; + }; + } +} diff --git a/contracts/launchpad/sources/launchpad/fees/flat_fee.move b/contracts/launchpad/sources/launchpad/fees/flat_fee.move index ea53dd88..cd62a723 100644 --- a/contracts/launchpad/sources/launchpad/fees/flat_fee.move +++ b/contracts/launchpad/sources/launchpad/fees/flat_fee.move @@ -14,6 +14,7 @@ module ob_launchpad::flat_fee { use ob_launchpad::proceeds; use ob_launchpad::listing::{Self, Listing}; use ob_launchpad::marketplace::{Self as mkt, Marketplace}; + use ob_launchpad::fee_cut; /// `Listing` did not have `FlatFee` policy const EInvalidFeePolicy: u64 = 1; @@ -55,6 +56,12 @@ module ob_launchpad::flat_fee { listing::assert_listing_marketplace_match(marketplace, listing); listing::assert_correct_admin_or_member(marketplace, listing, ctx); + fee_cut::collect_fee_cuts_if_any( + marketplace, + listing, + ctx, + ); + let (proceeds_value, listing_receiver) = { let proceeds = listing::borrow_proceeds(listing); let listing_receiver = listing::receiver(listing); diff --git a/contracts/launchpad/sources/launchpad/listing.move b/contracts/launchpad/sources/launchpad/listing.move index 4a5738c6..222c8ca5 100644 --- a/contracts/launchpad/sources/launchpad/listing.move +++ b/contracts/launchpad/sources/launchpad/listing.move @@ -54,6 +54,8 @@ module ob_launchpad::listing { use ob_kiosk::ob_kiosk; friend ob_launchpad::flat_fee; + friend ob_launchpad::fee_cut; + friend ob_launchpad::free_mint; friend ob_launchpad::market_whitelist; friend ob_launchpad::dutch_auction; friend ob_launchpad::fixed_price; @@ -129,6 +131,8 @@ module ob_launchpad::listing { struct MembersDfKey has store, copy, drop {} struct WhitelistDfKey has store, copy, drop { venue_id: ID } struct StartSaleDfKey has store, copy, drop { venue_id: ID } + struct FeeCutDfKey has store, copy, drop { } + struct FreeMintDfKey has store, copy, drop { venue_id: ID} // === Events === @@ -1203,6 +1207,64 @@ module ob_launchpad::listing { df::borrow_mut(&mut listing.id, WhitelistDfKey { venue_id }) } + /// Adds `FeeCut` object. We use a generic `FeeCut` to represent the + /// `FeeCut` type object to avoid dependency cycles. + /// + /// Warning: This function is not safe to be shared, it does not check + /// for permissions and should ONLY be used by the function + /// `fee_cut::add_fee_cut` + public(friend) fun add_fee_cut_internal( + listing: &mut Listing, + fee_cut: FeeCut + ) { + df::add(&mut listing.id, FeeCutDfKey { }, fee_cut); + } + + public(friend) fun fee_cut_exists( + listing: &Listing, + ): bool { + df::exists_(&listing.id, FeeCutDfKey {}) + } + + /// Borrows `FeeCut` object mutably. + /// + /// Warning: This function is not safe to be shared, it does not check + /// for permissions and should ONLY be used by the function + /// `fee_cut::add_fee_cut` + public(friend) fun borrow_fee_cut_mut( + listing: &mut Listing, + ): &mut FeeCut { + df::borrow_mut(&mut listing.id, FeeCutDfKey {}) + } + + /// Adds `DiscountList` object. We use a generic `DiscountList` to represent the + /// `DiscountList` type object to avoid dependency cycles. + /// + /// Warning: This function is not safe to be shared, it does not check + /// for permissions and should ONLY be used by the function + /// `discount::add_discount` + public(friend) fun add_free_mint_internal( + listing: &mut Listing, + venue_id: ID, + discount_list: DiscountList + ) { + df::add(&mut listing.id, FreeMintDfKey { venue_id }, discount_list); + } + + public(friend) fun free_mint_exists( + listing: &Listing, + venue_id: ID, + ): bool { + df::exists_(&listing.id, FreeMintDfKey { venue_id }) + } + + public(friend) fun borrow_free_mint_mut( + listing: &mut Listing, + venue_id: ID, + ): &mut DiscountList { + df::borrow_mut(&mut listing.id, FreeMintDfKey { venue_id }) + } + /// Mutably borrow the listing's `Venue` /// /// `Venue` and inventories are unprotected therefore only market modules diff --git a/contracts/launchpad/sources/launchpad/market/fixed_price.move b/contracts/launchpad/sources/launchpad/market/fixed_price.move index 064abfb7..5c980a4c 100644 --- a/contracts/launchpad/sources/launchpad/market/fixed_price.move +++ b/contracts/launchpad/sources/launchpad/market/fixed_price.move @@ -18,6 +18,7 @@ module ob_launchpad::fixed_price { use ob_launchpad::venue::{Self, Venue}; use ob_launchpad::listing::{Self, Listing}; use ob_launchpad::market_whitelist::{Self, Certificate}; + use ob_launchpad::free_mint; /// Fixed price market object struct FixedPriceMarket has key, store { @@ -227,11 +228,17 @@ module ob_launchpad::fixed_price { balance: &mut Balance, ctx: &mut TxContext, ): T { + let sender = tx_context::sender(ctx); listing::apply_rebate(listing, balance); + let is_free_mint = free_mint::apply_free_mint_if_any( + listing, + venue_id, + sender, + ); let market = borrow_market(listing::borrow_venue(listing, venue_id)); + let price = if (is_free_mint) {0} else {market.price}; - let price = market.price; let inventory_id = market.inventory_id; listing::buy_pseudorandom_nft, MarketKey>( @@ -239,7 +246,7 @@ module ob_launchpad::fixed_price { MarketKey {}, inventory_id, venue_id, - tx_context::sender(ctx), + sender, balance::split(balance, price), ctx, ) diff --git a/contracts/launchpad/sources/launchpad/proceeds.move b/contracts/launchpad/sources/launchpad/proceeds.move index 29acb2e4..21b9278b 100644 --- a/contracts/launchpad/sources/launchpad/proceeds.move +++ b/contracts/launchpad/sources/launchpad/proceeds.move @@ -18,6 +18,7 @@ module ob_launchpad::proceeds { friend ob_launchpad::flat_fee; friend ob_launchpad::listing; friend ob_launchpad::venue; + friend ob_launchpad::fee_cut; struct Proceeds has key, store { id: UID, @@ -150,7 +151,7 @@ module ob_launchpad::proceeds { // === Private Functions === - fun balance_mut( + public(friend) fun balance_mut( proceeds: &mut Proceeds, ): &mut Balance { df::borrow_mut( diff --git a/contracts/launchpad/tests/fees.move b/contracts/launchpad/tests/fees.move index d7bf7a5a..f9d5739d 100644 --- a/contracts/launchpad/tests/fees.move +++ b/contracts/launchpad/tests/fees.move @@ -6,11 +6,14 @@ module ob_launchpad::test_fees { use sui::test_scenario::{Self, ctx}; use ob_launchpad::flat_fee; + use ob_launchpad::fee_cut; use ob_launchpad::listing; use ob_launchpad::test_listing; const MARKETPLACE: address = @0xA123; const CREATOR: address = @0xA1C05; + const CUT_ADDR_1: address = @0x1; + const CUT_ADDR_2: address = @0x2; #[test] public fun marketplace_default_fee() { @@ -110,4 +113,123 @@ module ob_launchpad::test_fees { test_scenario::return_shared(listing); test_scenario::end(scenario); } + + #[test] + public fun test_fee_cut() { + let scenario = test_scenario::begin(MARKETPLACE); + + // Creates `Marketplace` with default fee + let (marketplace, listing) = test_listing::init_listing_and_marketplace( + CREATOR, MARKETPLACE, 2000, &mut scenario, + ); + + fee_cut::add_fee_cut( + &marketplace, + &mut listing, + CUT_ADDR_1, + 70, + ctx(&mut scenario) + ); + + fee_cut::add_fee_cut( + &marketplace, + &mut listing, + CUT_ADDR_2, + 30, + ctx(&mut scenario) + ); + + listing::pay(&mut listing, balance::create_for_testing(10_000), 1); + + flat_fee::collect_proceeds_and_fees( + &marketplace, &mut listing, ctx(&mut scenario), + ); + + test_scenario::next_tx(&mut scenario, MARKETPLACE); + + let marketplace_proceeds = + test_scenario::take_from_address>(&scenario, MARKETPLACE); + assert!(coin::value(&marketplace_proceeds) == 1_980, 0); + + test_scenario::return_to_address(MARKETPLACE, marketplace_proceeds); + + let creator_proceeds = + test_scenario::take_from_address>(&scenario, CREATOR); + assert!(coin::value(&creator_proceeds) == 7_920, 0); + + let addr1_proceeds = test_scenario::take_from_address>(&scenario, CUT_ADDR_1); + assert!(coin::value(&addr1_proceeds) == 70, 0); + + let addr2_proceeds = test_scenario::take_from_address>(&scenario, CUT_ADDR_2); + assert!(coin::value(&addr2_proceeds) == 30, 0); + + test_scenario::return_to_address(CREATOR, creator_proceeds); + test_scenario::return_to_address(CUT_ADDR_1, addr1_proceeds); + test_scenario::return_to_address(CUT_ADDR_2, addr2_proceeds); + + test_scenario::return_shared(marketplace); + test_scenario::return_shared(listing); + test_scenario::end(scenario); + } + + #[test] + #[expected_failure(abort_code = ob_launchpad::listing::EWrongListingOrMarketplaceAdmin)] + public fun test_fail_add_fee_cut_if_wrong_admin() { + let scenario = test_scenario::begin(MARKETPLACE); + + // Creates `Marketplace` with default fee + let (marketplace, listing) = test_listing::init_listing_and_marketplace( + CREATOR, MARKETPLACE, 2000, &mut scenario, + ); + + test_scenario::next_tx(&mut scenario, @0x999); + + fee_cut::add_fee_cut( + &marketplace, + &mut listing, + CUT_ADDR_1, + 70, + ctx(&mut scenario) + ); + + fee_cut::add_fee_cut( + &marketplace, + &mut listing, + CUT_ADDR_2, + 30, + ctx(&mut scenario) + ); + + listing::pay(&mut listing, balance::create_for_testing(10_000), 1); + + flat_fee::collect_proceeds_and_fees( + &marketplace, &mut listing, ctx(&mut scenario), + ); + + test_scenario::next_tx(&mut scenario, MARKETPLACE); + + let marketplace_proceeds = + test_scenario::take_from_address>(&scenario, MARKETPLACE); + assert!(coin::value(&marketplace_proceeds) == 1_980, 0); + + test_scenario::return_to_address(MARKETPLACE, marketplace_proceeds); + + let creator_proceeds = + test_scenario::take_from_address>(&scenario, CREATOR); + assert!(coin::value(&creator_proceeds) == 7_920, 0); + + let addr1_proceeds = test_scenario::take_from_address>(&scenario, CUT_ADDR_1); + assert!(coin::value(&addr1_proceeds) == 70, 0); + + let addr2_proceeds = test_scenario::take_from_address>(&scenario, CUT_ADDR_2); + assert!(coin::value(&addr2_proceeds) == 30, 0); + + test_scenario::return_to_address(CREATOR, creator_proceeds); + test_scenario::return_to_address(CUT_ADDR_1, addr1_proceeds); + test_scenario::return_to_address(CUT_ADDR_2, addr2_proceeds); + + test_scenario::return_shared(marketplace); + test_scenario::return_shared(listing); + test_scenario::end(scenario); + } }