diff --git a/.tool-versions b/.tool-versions index 37dc27f..3acc460 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -scarb 2.11.4 -starknet-foundry 0.43.1 +scarb 2.11.2 +sstarknet-foundry 0.40.0 diff --git a/src/base/types.cairo b/src/base/types.cairo index aac0e03..8768731 100644 --- a/src/base/types.cairo +++ b/src/base/types.cairo @@ -148,6 +148,63 @@ pub struct Purchase { pub timeout_expiry: u64, } +#[derive(Copy, Serde, Drop, PartialEq, Debug, starknet::Store)] +pub enum PayoutStatus { + #[default] + PENDING, + PAID, + CANCELLED, +} + +#[derive(Copy, Drop, Serde, starknet::Store, PartialEq, Debug)] +pub struct Payout { + pub id: u64, + pub purchase_id: u256, + pub recipient: ContractAddress, + pub amount: u256, + pub timestamp: u64, + pub status: PayoutStatus, +} + +#[derive(Copy, Drop, Serde, Default, PartialEq, starknet::Store)] +pub struct PayoutSchedule { + pub interval: u64, //interval between payouts, same type as block_timestamp + pub start_time: u64, + pub last_execution: u64, + // pub schedule_id: u256, +} + +#[allow(starknet::store_no_default_variant)] +#[derive(Copy, Drop, Serde, starknet::Store, PartialEq)] +pub enum RefundRequestReason { + CONTENT_NOT_RECEIVED, + DUPLICATE_PURCHASE, + UNABLE_TO_ACCESS, + MISREPRESENTED_CONTENT, + OTHER: felt252, +} + +#[derive(Copy, Drop, Serde, starknet::Store, PartialEq)] +pub enum RefundStatus { + #[default] + PENDING, + TIMED_OUT, + DECLINED, + APPROVED, + PAID, +} + +#[derive(Copy, Drop, Serde, starknet::Store, PartialEq)] +pub struct Refund { + pub refund_id: u64, + pub purchase_id: u256, + pub reason: RefundRequestReason, + pub user: ContractAddress, + pub status: RefundStatus, + pub request_timestamp: u64, + pub refund_amount: Option, +} + #[derive(Drop, Serde, starknet::Store, Debug)] pub enum ReceiptStatus { diff --git a/src/chainlib/ChainLib.cairo b/src/chainlib/ChainLib.cairo index 7fb6247..a62b810 100644 --- a/src/chainlib/ChainLib.cairo +++ b/src/chainlib/ChainLib.cairo @@ -1,6 +1,7 @@ #[starknet::contract] pub mod ChainLib { use core::array::{Array, ArrayTrait}; + use core::num::traits::Zero; use core::option::OptionTrait; use core::traits::Into; use openzeppelin::token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait}; @@ -12,9 +13,10 @@ pub mod ChainLib { ContractAddress, contract_address_const, get_block_timestamp, get_caller_address, get_contract_address, }; - use crate::base::errors::payment_errors; + use crate::base::errors::{payment_errors, permission_errors}; use crate::base::types::{ - AccessRule, AccessType, Permissions, Purchase, PurchaseStatus, Rank, Receipt, ReceiptStatus, + AccessRule, AccessType, Payout, PayoutSchedule, PayoutStatus, Permissions, Purchase, + PurchaseStatus, Rank, Receipt, ReceiptStatus, Refund, RefundRequestReason, RefundStatus, Role, Status, TokenBoundAccount, User, VerificationRequirement, VerificationType, permission_flags, }; @@ -222,19 +224,49 @@ pub mod ChainLib { creator_sales: Map, total_sales_for_content: Map, token_address: ContractAddress, + platform_fee: u256, //basis points; 1000 = 10% + platform_fee_recipient: ContractAddress, + payout_schedule: PayoutSchedule, + payout_history: Map< + ContractAddress, Vec, + >, // map a creator's address to a Vec of his payouts + user_refunds: Map>, + refund_window: u64, } + const REFUND_WINDOW: u64 = 86400; + const PLATFORM_FEE: u256 = 900; //9% + const PAYOUT_SCHEDULE_INTERVAL: u64 = 86400 * 7; #[constructor] fn constructor( - ref self: ContractState, admin: ContractAddress, token_address: ContractAddress, + ref self: ContractState, + admin: ContractAddress, + token_address: ContractAddress, + platform_fee: u256, + // platform_fee_recipient: ContractAddress, + payout_schedule_interval: u64, + refund_window: u64, ) { // Store the values in contract state self.admin.write(admin); self.token_address.write(token_address); // Initialize purchase ID counter self.next_purchase_id.write(1_u256); + self.next_content_id.write(0_felt252); self.purchase_timeout_duration.write(3600); + self.platform_fee_recipient.write(get_contract_address()); + // self.platform_fee.write(PLATFORM_FEE); + self.platform_fee.write(platform_fee); + let payout_schedule = PayoutSchedule { + // interval: PAYOUT_SCHEDULE_INTERVAL, + interval: payout_schedule_interval, + start_time: get_block_timestamp(), + last_execution: 0, + }; + self.payout_schedule.write(payout_schedule); + // self.refund_window.write(REFUND_WINDOW); + self.refund_window.write(refund_window); } #[event] @@ -266,6 +298,15 @@ pub mod ChainLib { SubscriptionCancelled: SubscriptionCancelled, SubscriptionRenewed: SubscriptionRenewed, ReceiptGenerated: ReceiptGenerated, + PayoutExecuted: PayoutExecuted, + PayoutScheduleSet: PayoutScheduleSet, + RefundRequested: RefundRequested, + RefundApproved: RefundApproved, + RefundDeclined: RefundDeclined, + RefundPaid: RefundPaid, + PlatformFeeChanged: PlatformFeeChanged, + RefundWindowChanged: RefundWindowChanged, + RefundTimedOut: RefundTimedOut, } #[derive(Drop, starknet::Event)] @@ -438,6 +479,73 @@ pub mod ChainLib { pub timestamp: u64, } + // Payout and Refunds + #[derive(Drop, starknet::Event)] + pub struct PayoutExecuted { + pub recipients: Array, + pub timestamp: u64, + pub amount_paid: u256, + } + + #[derive(Drop, starknet::Event)] + pub struct PayoutScheduleSet { + pub start_time: u64, + pub setter: ContractAddress, + pub interval: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct RefundRequested { + pub user: ContractAddress, + pub content_id: felt252, + pub purchase_id: u256, + pub reason: RefundRequestReason, + } + + #[derive(Drop, starknet::Event)] + pub struct RefundApproved { + pub approver: ContractAddress, + pub user_id: u256, + pub content_id: felt252, + pub refund_id: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct RefundDeclined { + pub decliner: ContractAddress, + pub user_id: u256, + pub content_id: felt252, + pub refund_id: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct RefundTimedOut { + pub user_id: u256, + pub content_id: felt252, + pub refund_id: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct RefundPaid { + pub refund_id: u64, + pub content_id: felt252, + pub purchase_id: u256, + pub executor: ContractAddress, + pub user_id: u256, + } + + #[derive(Drop, starknet::Event)] + pub struct PlatformFeeChanged { + pub new_fee: u256, + pub timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct RefundWindowChanged { + pub new_window: u64, + pub timestamp: u64, + } + #[abi(embed_v0)] impl ChainLibNetImpl of IChainLib { fn create_token_account( @@ -599,7 +707,9 @@ pub mod ChainLib { assert((self.admin.read() == caller), 'Only admin can verify users'); let mut user = self.users.read(user_id); user.verified = true; - self.users.write(user.id, user); + let user_address = user.wallet_address; + self.users.write(user.id, user.clone()); + self.user_by_address.write(user_address, user.clone()); true } fn retrieve_user_profile(ref self: ContractState, user_id: u256) -> User { @@ -1445,7 +1555,7 @@ pub mod ChainLib { let user = self.users.read(user_id); assert(user.id == user_id, 'User does not exist'); - let current_time = get_block_timestamp(); + // let current_time = get_block_timestamp(); // Create a new subscription let subscription_id = self.subscription_id.read() + 1; @@ -1486,7 +1596,7 @@ pub mod ChainLib { self.subscriptions.write(user_id, new_subscription.clone()); // read from the subscription - self.subscription_record.entry(user_id).append().write(new_subscription); + self.subscription_record.entry(user_id).push(new_subscription); let current_count = self.subscription_count.read(user_id); self.subscription_count.write(user_id, current_count + 1); @@ -1760,7 +1870,9 @@ pub mod ChainLib { fn purchase_content( ref self: ContractState, content_id: felt252, transaction_hash: felt252, ) -> u256 { - assert!(content_id != 0, "Content ID cannot be empty"); + // assert!(content_id != 0, "Content ID cannot be empty"); + // I commented the above line out because in other parts of the project, it is + // explicitly stated that first content id should be 0 assert!(transaction_hash != 0, "Transaction hash cannot be empty"); let price = self.content_prices.read(content_id); @@ -1865,13 +1977,62 @@ pub mod ChainLib { fn verify_purchase(ref self: ContractState, purchase_id: u256) -> bool { // Get the purchase details let purchase = self.purchases.read(purchase_id); + // assert() let content = self.content.read(purchase.content_id); let total_content_sales = self.total_sales_for_content.read(purchase.content_id) + purchase.price; - let total_creator_sales = self.creator_sales.read(content.creator) + purchase.price; + let platform_fee_percentage = self.platform_fee.read(); + let platform_fee_recipient = self.platform_fee_recipient.read(); + + let actual_platform_fee = (platform_fee_percentage * purchase.price) / 10000; + let creators_fraction = purchase.price - actual_platform_fee; + + let total_creator_sales = self.creator_sales.read(content.creator) + creators_fraction; self.creator_sales.write(content.creator, total_creator_sales); + self._single_payout(platform_fee_recipient, actual_platform_fee); + + let mut all_payouts = self.payout_history.entry(content.creator); + + let creator_payout_history_id = self.payout_history.entry(content.creator).len(); + let mut creator_payout = Payout { + id: creator_payout_history_id, + purchase_id, + recipient: content.creator, + amount: creators_fraction, + timestamp: get_block_timestamp(), + status: PayoutStatus::PENDING, + }; + + for i in 0..all_payouts.len() { + let current_payout = all_payouts.at(i).read(); + if current_payout.purchase_id == purchase_id { + creator_payout.id = current_payout.id; + creator_payout.amount = current_payout.amount; + if purchase.status == PurchaseStatus::Completed { + creator_payout.timestamp = current_payout.timestamp; + } else { + creator_payout.timestamp = get_block_timestamp(); + } + creator_payout.status = current_payout.status; + // I left out the timestamp so that the time starts counting from when true is + // returned from this function. In other words, when the verify purchase returns + // true, the content time in the hands of the user starts counting. This is for + // the refund window calculation. + } + } + + if creator_payout.id >= all_payouts.len() { + self.payout_history.entry(content.creator).push(creator_payout); + } else { + self + .payout_history + .entry(content.creator) + .at(creator_payout.id) + .write(creator_payout); + } + self.total_sales_for_content.write(purchase.content_id, total_content_sales); self .issue_receipt( @@ -2067,6 +2228,306 @@ pub mod ChainLib { let total_content_sales = self.total_sales_for_content.read(content_id); total_content_sales } + + fn batch_payout_creators(ref self: ContractState) { + let caller = get_caller_address(); + assert(self.admin.read() == caller, 'Only admin can execute payout'); + + let payout_schedule = self.payout_schedule.read(); + let (last_execution, interval) = ( + payout_schedule.last_execution, payout_schedule.interval, + ); + let current_time = get_block_timestamp(); + assert(current_time >= (last_execution + interval), 'Payout period not reached'); + + let current_user_count = self.user_id.read(); + let mut creators_array: Array = array![]; + let mut amount_paid_out: u256 = 0; + for i in 0..current_user_count { + if self.users.read(i).role == Role::WRITER { + creators_array.append(self.users.read(i)); + } + } + let mut recipients_array: Array = array![]; + + for i in 0..creators_array.len() { + let current_creator = creators_array.at(i); + let current_creator_address = *current_creator.wallet_address; + recipients_array.append(current_creator_address); + + let current_creator_payout_history_vec = self + .payout_history + .entry(current_creator_address); + let mut pending_payouts: Array = array![]; + let mut amt_to_be_paid_creator = 0_u256; + + for i in 0..current_creator_payout_history_vec.len() { + let mut payout = current_creator_payout_history_vec.at(i).read(); + if payout.status == PayoutStatus::PENDING { + pending_payouts.append(payout); + amt_to_be_paid_creator += payout.amount; + payout.status == PayoutStatus::PAID; + } + current_creator_payout_history_vec.at(i).write(payout); + } + + let transfer = self._single_payout(current_creator_address, amt_to_be_paid_creator); + assert(transfer, 'Transfer failed'); + amount_paid_out += amt_to_be_paid_creator; + self.creator_sales.write(current_creator_address, 0); + } + + self + .emit( + PayoutExecuted { + recipients: recipients_array, + timestamp: get_block_timestamp(), + amount_paid: amount_paid_out, + }, + ); + } + + fn set_payout_schedule(ref self: ContractState, interval: u64) { + let caller = get_caller_address(); + assert(self.admin.read() == caller, 'Only admin can verify payments'); + let current_payout_schedule = self.payout_schedule.read(); + let mut last_execution_date = get_block_timestamp(); + if current_payout_schedule != Default::default() { + last_execution_date = current_payout_schedule.last_execution; + } + let new_schedule = PayoutSchedule { + interval, start_time: last_execution_date, last_execution: 0, + }; + self.payout_schedule.write(new_schedule); + self + .emit( + PayoutScheduleSet { start_time: last_execution_date, setter: caller, interval }, + ); + } + + fn get_payout_schedule( + self: @ContractState, + ) -> (u64, u64) { // interval and last execution time + let payout_schedule = self.payout_schedule.read(); + (payout_schedule.interval, payout_schedule.last_execution) + } + + fn request_refund( + ref self: ContractState, purchase_id: u256, refund_reason: RefundRequestReason, + ) { + let caller = get_caller_address(); + // let user = self.user_by_address.read(caller); + let user_refunds_vec = self.user_refunds.entry(caller); + let refund_id = user_refunds_vec.len(); + let refund_request = Refund { + refund_id, + purchase_id, + reason: refund_reason, + user: caller, + status: RefundStatus::PENDING, + request_timestamp: get_block_timestamp(), + refund_amount: Option::None, + }; + self.user_refunds.entry(caller).push(refund_request); + let content_id = self.purchases.read(purchase_id).content_id; + let purchased_at = self.purchases.read(purchase_id).timestamp; + assert( + (get_block_timestamp() - purchased_at) < self.refund_window.read(), + 'Refund window already closed', + ); + self + .emit( + RefundRequested { + user: caller, content_id, purchase_id, reason: refund_reason, + }, + ); + } + + // The refund_percentage will only be used if the reason is OTHER + fn approve_refund( + ref self: ContractState, refund_id: u64, user_id: u256, refund_percentage: Option, + ) { + let caller = get_caller_address(); + // Ensure that only an admin can verify users. + assert((self.admin.read() == caller), 'Only admin can approve refunds'); + let user = self.users.read(user_id); + let user_address = user.wallet_address; + let mut refund = self.user_refunds.entry(user_address).at(refund_id).read(); + assert(refund.status != RefundStatus::TIMED_OUT, 'Request already timed out'); + assert(refund.status == RefundStatus::PENDING, 'Request already processed'); + + let refund_reason = refund.reason; + let mut refund_percent = self._get_refund_percentage(refund_reason); + if refund_percent == 0 { + assert(refund_percentage.is_some(), 'Custom percentage for other'); + refund_percent = refund_percentage.unwrap(); + } + // We'll take the refund percent later in the contract from the creator payout + + let request_timestamp = refund.request_timestamp; + let time_since_request = get_block_timestamp() - request_timestamp; + if time_since_request >= self.refund_window.read() { + refund.status = RefundStatus::TIMED_OUT; + let purchase_id = refund.purchase_id; + let purchase = self.purchases.read(purchase_id); + let content_id = purchase.content_id; + // let content = self.content.read(content_id); + self.user_refunds.entry(user_address).at(refund_id).write(refund); + self.emit(RefundTimedOut { user_id, content_id, refund_id }) + } else { + refund.status = RefundStatus::APPROVED; + // This should affect the creator payout for that purchase + + let purchase_id = refund.purchase_id; + let purchase = self.purchases.read(purchase_id); + let content_id = purchase.content_id; + let content = self.content.read(content_id); + let content_creator = content.creator; + + let creator_payout_vec = self.payout_history.entry(content_creator); + let mut dummy_payout_array = array![]; + // Dummy array that will help retrieve the payout we need to edit + for i in 0..creator_payout_vec.len() { + let specific_payout = creator_payout_vec.at(i).read(); + if specific_payout.purchase_id == purchase_id { + dummy_payout_array.append(specific_payout); + } + } + // Retrieve the payout, the length will be one, but we can assert too + assert(dummy_payout_array.len() == 1, 'Double Payout-purchase entry'); + let mut specific_payout = *dummy_payout_array.at(0); + + let refund_amount = refund_percent * specific_payout.amount / 100; + specific_payout.amount -= refund_amount; + if refund_amount == specific_payout.amount { + specific_payout.status = PayoutStatus::CANCELLED; + } + refund.refund_amount = Option::Some(refund_amount); + + self + .payout_history + .entry(content_creator) + .at(specific_payout.id) + .write(specific_payout); + self.user_refunds.entry(user_address).at(refund_id).write(refund); + self.emit(RefundApproved { approver: caller, user_id, content_id, refund_id }); + } + } + + fn decline_refund(ref self: ContractState, refund_id: u64, user_id: u256) { + let caller = get_caller_address(); + // Ensure that only an admin can verify users. + assert((self.admin.read() == caller), 'Only admin can approve refunds'); + let user = self.users.read(user_id); + let user_address = user.wallet_address; + let mut refund = self.user_refunds.entry(user_address).at(refund_id).read(); + let purchase = refund.purchase_id; + let content_id = self.purchases.read(purchase).content_id; + assert(refund.status == RefundStatus::PENDING, 'Request already processed'); + let request_timestamp = refund.request_timestamp; + let time_since_request = get_block_timestamp() - request_timestamp; + if time_since_request >= self.refund_window.read() { + refund.status = RefundStatus::TIMED_OUT; + let purchase_id = refund.purchase_id; + let purchase = self.purchases.read(purchase_id); + let content_id = purchase.content_id; + // let content = self.content.read(content_id); + self.user_refunds.entry(user_address).at(refund_id).write(refund); + self.emit(RefundTimedOut { user_id, content_id, refund_id }) + } else { + refund.status = RefundStatus::DECLINED; + self.emit(RefundDeclined { decliner: caller, user_id, content_id, refund_id }); + self.user_refunds.entry(user_address).at(refund_id).write(refund); + } + } + + fn refund_user(ref self: ContractState, refund_id: u64, user_id: u256) { + let caller = get_caller_address(); + // Ensure that only an admin can verify users. + assert((self.admin.read() == caller), 'Only admin can approve refunds'); + let user = self.users.read(user_id); + let user_address = user.wallet_address; + let mut refund = self.user_refunds.entry(user_address).at(refund_id).read(); + assert(refund.status == RefundStatus::APPROVED, 'Refund request declined'); + refund.status = RefundStatus::PAID; + self.user_refunds.entry(user_address).at(refund_id).write(refund); + let purchase_id = refund.purchase_id; + let content_id = self.purchases.read(purchase_id).content_id; + assert(refund.refund_amount.is_some(), 'Refund amount is none'); + let refund_amount = refund.refund_amount.unwrap(); + + self._process_refund(refund_amount, user_address); + + self + .emit( + RefundPaid { + refund_id, content_id, purchase_id: purchase_id, executor: caller, user_id, + }, + ); + } + + fn get_user_refunds(self: @ContractState, user_id: u256) -> Array { + let user = self.users.read(user_id); + let user_address = user.wallet_address; + let user_refunds_vec = self.user_refunds.entry(user_address); + let mut user_refunds_arr = array![]; + + for i in 0..user_refunds_vec.len() { + let current_refund = user_refunds_vec.at(i).read(); + user_refunds_arr.append(current_refund); + } + + user_refunds_arr + } + + fn get_all_pending_refunds(self: @ContractState) -> Array { + let caller = get_caller_address(); + // Ensure that only an admin can verify users. + assert((self.admin.read() == caller), 'Only admin can approve refunds'); + + let current_user_id = self.user_id.read(); + let mut all_pending_refunds_arr = array![]; + let mut all_users_array = array![]; + + for i in 0..current_user_id { + let current_user = self.users.read(i); + all_users_array.append(current_user); + } + + for i in 0..all_users_array.len() { + let current_user = all_users_array.at(i); + let current_user_address = current_user.wallet_address; + let current_user_refunds_vec = self.user_refunds.entry(*current_user_address); + + for i in 0..current_user_refunds_vec.len() { + let current_user_refund = current_user_refunds_vec.at(i).read(); + if current_user_refund.status == RefundStatus::PENDING { + all_pending_refunds_arr.append(current_user_refund); + } + } + } + + all_pending_refunds_arr + } + + fn set_platform_fee(ref self: ContractState, platform_fee: u256) { + let caller = get_caller_address(); + // Ensure that only an admin can verify users. + assert((self.admin.read() == caller), 'Only admin can execute refunds'); + self.platform_fee.write(platform_fee); + self + .emit( + PlatformFeeChanged { new_fee: platform_fee, timestamp: get_block_timestamp() }, + ); + } + + fn set_refund_window(ref self: ContractState, window: u64) { + let caller = get_caller_address(); + // Ensure that only an admin can verify users. + assert((self.admin.read() == caller), 'Only admin can execute refunds'); + self.refund_window.write(window); + self.emit(RefundWindowChanged { new_window: window, timestamp: get_block_timestamp() }); + } } #[generate_trait] @@ -2109,11 +2570,34 @@ pub mod ChainLib { assert(balance >= amount, payment_errors::INSUFFICIENT_BALANCE); } + fn _single_payout( + ref self: ContractState, recipient_address: ContractAddress, amount: u256, + ) -> bool { + assert(!recipient_address.is_zero(), permission_errors::ZERO_ADDRESS); + let token_dispatcher = IERC20Dispatcher { contract_address: self.token_address.read() }; + let contract_address = get_contract_address(); + self._check_token_balance(contract_address, amount); + let transfer = token_dispatcher.transfer(recipient_address, amount); + transfer + } + fn _process_refund(ref self: ContractState, amount: u256, refund_address: ContractAddress) { let token = IERC20Dispatcher { contract_address: self.token_address.read() }; let contract_address = get_contract_address(); self._check_token_balance(contract_address, amount); token.transfer(refund_address, amount); } + + fn _get_refund_percentage( + ref self: ContractState, refund_reason: RefundRequestReason, + ) -> u256 { + match refund_reason { + RefundRequestReason::CONTENT_NOT_RECEIVED => 100, + RefundRequestReason::DUPLICATE_PURCHASE => 80, + RefundRequestReason::UNABLE_TO_ACCESS => 100, + RefundRequestReason::MISREPRESENTED_CONTENT => 65, + RefundRequestReason::OTHER => 0, + } + } } } diff --git a/src/interfaces/IChainLib.cairo b/src/interfaces/IChainLib.cairo index 95d9c05..5b89feb 100644 --- a/src/interfaces/IChainLib.cairo +++ b/src/interfaces/IChainLib.cairo @@ -1,8 +1,8 @@ use core::array::Array; use starknet::ContractAddress; use crate::base::types::{ - AccessRule, Permissions, Purchase, PurchaseStatus, Rank, Receipt, Role, TokenBoundAccount, User, - VerificationRequirement, VerificationType, + AccessRule, Permissions, Purchase, PurchaseStatus, Rank, Receipt, Refund, RefundRequestReason, + Role, TokenBoundAccount, User, VerificationRequirement, VerificationType, }; use crate::chainlib::ChainLib::ChainLib::{ Category, ContentMetadata, ContentType, DelegationInfo, Payment, PlanType, Subscription, @@ -228,5 +228,21 @@ pub trait IChainLib { fn get_total_sales_by_creator(self: @TContractState, creator: ContractAddress) -> u256; fn get_total_sales_for_content(self: @TContractState, content_id: felt252) -> u256; // fn get_daily_sales(self: @TContractState, day: u64) -> u256; -// fn get_unique_buyers_count(self: @TContractState) -> u256; + // fn get_unique_buyers_count(self: @TContractState) -> u256; + + fn batch_payout_creators(ref self: TContractState); + fn set_payout_schedule(ref self: TContractState, interval: u64); + fn get_payout_schedule(self: @TContractState) -> (u64, u64); // interval and last execution time + fn request_refund( + ref self: TContractState, purchase_id: u256, refund_reason: RefundRequestReason, + ); + fn approve_refund( + ref self: TContractState, refund_id: u64, user_id: u256, refund_percentage: Option, + ); + fn decline_refund(ref self: TContractState, refund_id: u64, user_id: u256); + fn refund_user(ref self: TContractState, refund_id: u64, user_id: u256); + fn get_user_refunds(self: @TContractState, user_id: u256) -> Array; + fn get_all_pending_refunds(self: @TContractState) -> Array; + fn set_platform_fee(ref self: TContractState, platform_fee: u256); + fn set_refund_window(ref self: TContractState, window: u64); } diff --git a/tests/test_ChainLib.cairo b/tests/test_ChainLib.cairo index e16f6a0..78bf45d 100644 --- a/tests/test_ChainLib.cairo +++ b/tests/test_ChainLib.cairo @@ -1,12 +1,15 @@ // Import the contract modules -use chain_lib::base::types::{PurchaseStatus, Rank, Role, Status}; +use chain_lib::base::types::{ + PurchaseStatus, Rank, Refund, RefundRequestReason, RefundStatus, Role, Status, +}; use chain_lib::chainlib::ChainLib; use chain_lib::chainlib::ChainLib::ChainLib::{Category, ContentType, PlanType, SubscriptionStatus}; use chain_lib::interfaces::IChainLib::{IChainLib, IChainLibDispatcher, IChainLibDispatcherTrait}; use openzeppelin::token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait}; use snforge_std::{ - CheatSpan, ContractClassTrait, DeclareResultTrait, cheat_caller_address, declare, - start_cheat_caller_address, stop_cheat_caller_address, + CheatSpan, ContractClassTrait, DeclareResultTrait, cheat_block_timestamp, cheat_caller_address, + declare, start_cheat_block_timestamp, start_cheat_caller_address, stop_cheat_block_timestamp, + stop_cheat_caller_address, }; use starknet::ContractAddress; use starknet::class_hash::ClassHash; @@ -888,3 +891,646 @@ fn test_renew_subscription() { let subscription_record = dispatcher.get_user_subscription_record(account_id); assert(subscription_record.len() == 1, 'record should have length 1'); } + +#[test] +fn test_batch_payout_creators() { + let (contract_address, admin_address, erc20_address) = setup(); + let dispatcher = IChainLibDispatcher { contract_address }; + let user_address = contract_address_const::<'user'>(); + let creator_1 = contract_address_const::<'creator_1'>(); + let creator_2 = contract_address_const::<'creator_2'>(); + let creator_3 = contract_address_const::<'creator_3'>(); + + let username1: felt252 = 'creator_1'; + let username2: felt252 = 'creator_2'; + let username3: felt252 = 'creator_3'; + + let role: Role = Role::WRITER; + let rank: Rank = Rank::BEGINNER; + let metadata: felt252 = 'john is a boy'; + + start_cheat_caller_address(contract_address, creator_1); + let creator1_id = dispatcher.register_user(username1, role.clone(), rank.clone(), metadata); + println!("successfully registered creator_1 as writer"); + stop_cheat_caller_address(contract_address); + + start_cheat_caller_address(contract_address, creator_2); + let creator2_id = dispatcher.register_user(username2, role.clone(), rank.clone(), metadata); + stop_cheat_caller_address(contract_address); + + start_cheat_caller_address(contract_address, creator_3); + let creator3_id = dispatcher.register_user(username3, role.clone(), rank.clone(), metadata); + stop_cheat_caller_address(contract_address); + + start_cheat_caller_address(contract_address, admin_address); + let is_creator_1_verified = dispatcher.verify_user(creator1_id); + let is_creator_2_verified = dispatcher.verify_user(creator2_id); + let is_creator_3_verified = dispatcher.verify_user(creator3_id); + stop_cheat_caller_address(contract_address); + + let erc20_dispatcher = IERC20Dispatcher { contract_address: erc20_address }; + let creator_1_init_bal = erc20_dispatcher.balance_of(creator_1); + let creator_2_init_bal = erc20_dispatcher.balance_of(creator_2); + let creator_3_init_bal = erc20_dispatcher.balance_of(creator_3); + + token_faucet_and_allowance(dispatcher, user_address, erc20_address, 100000); + // Set up test data + let title1: felt252 = 'Creator 1 content'; + let title2: felt252 = 'Creator 2 content'; + let title3: felt252 = 'Creator 3 content'; + + let description: felt252 = 'This is a test content'; + let content_type: ContentType = ContentType::Text; + let category: Category = Category::Education; + + start_cheat_caller_address(contract_address, creator_1); + let creator1_content_id: felt252 = dispatcher + .register_content(title1, description, content_type, category); + stop_cheat_caller_address(contract_address); + + start_cheat_caller_address(contract_address, creator_2); + let creator2_content_id: felt252 = dispatcher + .register_content(title2, description, content_type, category); + stop_cheat_caller_address(contract_address); + + start_cheat_caller_address(contract_address, creator_3); + let creator3_content_id: felt252 = dispatcher + .register_content(title3, description, content_type, category); + stop_cheat_caller_address(contract_address); + let price_1: u256 = 1000_u256; + let price_2: u256 = 2000_u256; + let price_3: u256 = 1500_u256; + + // Set creator_1 as caller to set up content price + cheat_caller_address(contract_address, admin_address, CheatSpan::Indefinite); + // Set up content with price + setup_content_with_price( + dispatcher, admin_address, contract_address, creator1_content_id, price_1, + ); + + // Set up content with price + setup_content_with_price( + dispatcher, admin_address, contract_address, creator2_content_id, price_2, + ); + + // Set up content with price + setup_content_with_price( + dispatcher, admin_address, contract_address, creator3_content_id, price_3, + ); + + // Set user as content consumer + cheat_caller_address(contract_address, user_address, CheatSpan::Indefinite); + + // Purchase the content + let purchase_id_1 = dispatcher.purchase_content(creator1_content_id, 'tx1'); + let purchase_id_2 = dispatcher.purchase_content(creator2_content_id, 'tx2'); + let purchase_id_3 = dispatcher.purchase_content(creator3_content_id, 'tx3'); + + // Initially, purchase should not be verified (status is Pending) + let is_purchase_1_verified = dispatcher.verify_purchase(purchase_id_1); + let is_purchase_2_verified = dispatcher.verify_purchase(purchase_id_2); + let is_purchase_3_verified = dispatcher.verify_purchase(purchase_id_3); + assert(!is_purchase_1_verified, '1 should not be verified'); + assert(!is_purchase_2_verified, '2 should not be verified'); + assert(!is_purchase_3_verified, '3 should not be verified'); + + // Set admin as caller to update the purchase status + cheat_caller_address(contract_address, admin_address, CheatSpan::Indefinite); + cheat_block_timestamp(contract_address, 0, CheatSpan::Indefinite); + + // Update purchase status to Completed + let update_result_1 = dispatcher + .update_purchase_status(purchase_id_1, PurchaseStatus::Completed); + assert(update_result_1, 'Failed to update status1'); + let update_result_2 = dispatcher + .update_purchase_status(purchase_id_2, PurchaseStatus::Completed); + assert(update_result_2, 'Failed to update status1'); + let update_result_3 = dispatcher + .update_purchase_status(purchase_id_3, PurchaseStatus::Completed); + assert(update_result_3, 'Failed to update status1'); + + // Now the purchase should be verified + let is_now_verified1 = dispatcher.verify_purchase(purchase_id_1); + assert(is_now_verified1, 'Purchase should be verified'); + let is_now_verified2 = dispatcher.verify_purchase(purchase_id_2); + assert(is_now_verified2, 'Purchase should be verified'); + let is_now_verified3 = dispatcher.verify_purchase(purchase_id_3); + assert(is_now_verified3, 'Purchase should be verified'); + + let receipt = dispatcher.get_receipt(1); + + assert(receipt.purchase_id == purchase_id_1, 'receipt error'); + + // Still admin calling + cheat_block_timestamp(contract_address, 86400 * 8, CheatSpan::Indefinite); + // start_cheat_block_timestamp(contract_address); + dispatcher.batch_payout_creators(); + + let creator_1_new_bal = erc20_dispatcher.balance_of(creator_1); + let creator_2_new_bal = erc20_dispatcher.balance_of(creator_2); + let creator_3_new_bal = erc20_dispatcher.balance_of(creator_3); + + assert(creator_1_new_bal > creator_1_init_bal, 'Failed to credit creator1'); + assert(creator_2_new_bal > creator_2_init_bal, 'Failed to credit creator2'); + assert(creator_3_new_bal > creator_3_init_bal, 'Failed to credit creator3'); +} + +#[test] +fn test_refund_flow_approve_refund_request() { + let (contract_address, admin_address, erc20_address) = setup(); + let dispatcher = IChainLibDispatcher { contract_address }; + let user_address = contract_address_const::<'user'>(); + let creator_1 = contract_address_const::<'creator_1'>(); + let creator_2 = contract_address_const::<'creator_2'>(); + let creator_3 = contract_address_const::<'creator_3'>(); + + let username1: felt252 = 'creator_1'; + let username2: felt252 = 'creator_2'; + let username3: felt252 = 'creator_3'; + let user_name: felt252 = 'user'; + + let role: Role = Role::WRITER; + let rank: Rank = Rank::BEGINNER; + let metadata: felt252 = 'john is a boy'; + + start_cheat_caller_address(contract_address, user_address); + let user_id = dispatcher.register_user(user_name, Role::READER, Rank::BEGINNER, metadata); + stop_cheat_caller_address(contract_address); + + start_cheat_caller_address(contract_address, creator_1); + let creator1_id = dispatcher.register_user(username1, role.clone(), rank.clone(), metadata); + println!("successfully registered creator_1 as writer"); + stop_cheat_caller_address(contract_address); + + start_cheat_caller_address(contract_address, creator_2); + let creator2_id = dispatcher.register_user(username2, role.clone(), rank.clone(), metadata); + stop_cheat_caller_address(contract_address); + + start_cheat_caller_address(contract_address, creator_3); + let creator3_id = dispatcher.register_user(username3, role.clone(), rank.clone(), metadata); + stop_cheat_caller_address(contract_address); + + start_cheat_caller_address(contract_address, admin_address); + let is_creator_1_verified = dispatcher.verify_user(creator1_id); + let is_creator_2_verified = dispatcher.verify_user(creator2_id); + let is_creator_3_verified = dispatcher.verify_user(creator3_id); + stop_cheat_caller_address(contract_address); + + let erc20_dispatcher = IERC20Dispatcher { contract_address: erc20_address }; + let creator_1_init_bal = erc20_dispatcher.balance_of(creator_1); + let creator_2_init_bal = erc20_dispatcher.balance_of(creator_2); + let creator_3_init_bal = erc20_dispatcher.balance_of(creator_3); + + token_faucet_and_allowance(dispatcher, user_address, erc20_address, 100000); + // Set up test data + let title1: felt252 = 'Creator 1 content'; + let title2: felt252 = 'Creator 2 content'; + let title3: felt252 = 'Creator 3 content'; + + let description: felt252 = 'This is a test content'; + let content_type: ContentType = ContentType::Text; + let category: Category = Category::Education; + + start_cheat_caller_address(contract_address, creator_1); + let creator1_content_id: felt252 = dispatcher + .register_content(title1, description, content_type, category); + stop_cheat_caller_address(contract_address); + + start_cheat_caller_address(contract_address, creator_2); + let creator2_content_id: felt252 = dispatcher + .register_content(title2, description, content_type, category); + stop_cheat_caller_address(contract_address); + + start_cheat_caller_address(contract_address, creator_3); + let creator3_content_id: felt252 = dispatcher + .register_content(title3, description, content_type, category); + stop_cheat_caller_address(contract_address); + let price_1: u256 = 1000_u256; + let price_2: u256 = 2000_u256; + let price_3: u256 = 1500_u256; + + // Set creator_1 as caller to set up content price + cheat_caller_address(contract_address, admin_address, CheatSpan::Indefinite); + // Set up content with price + setup_content_with_price( + dispatcher, admin_address, contract_address, creator1_content_id, price_1, + ); + + // Set up content with price + setup_content_with_price( + dispatcher, admin_address, contract_address, creator2_content_id, price_2, + ); + + // Set up content with price + setup_content_with_price( + dispatcher, admin_address, contract_address, creator3_content_id, price_3, + ); + + // Set user as content consumer + cheat_caller_address(contract_address, user_address, CheatSpan::Indefinite); + + // Purchase the content + let purchase_id_1 = dispatcher.purchase_content(creator1_content_id, 'tx1'); + let purchase_id_2 = dispatcher.purchase_content(creator2_content_id, 'tx2'); + let purchase_id_3 = dispatcher.purchase_content(creator3_content_id, 'tx3'); + + // Initially, purchase should not be verified (status is Pending) + let is_purchase_1_verified = dispatcher.verify_purchase(purchase_id_1); + let is_purchase_2_verified = dispatcher.verify_purchase(purchase_id_2); + let is_purchase_3_verified = dispatcher.verify_purchase(purchase_id_3); + assert(!is_purchase_1_verified, '1 should not be verified'); + assert(!is_purchase_2_verified, '2 should not be verified'); + assert(!is_purchase_3_verified, '3 should not be verified'); + + // Set admin as caller to update the purchase status + cheat_caller_address(contract_address, admin_address, CheatSpan::Indefinite); + cheat_block_timestamp(contract_address, 0, CheatSpan::Indefinite); + + // Update purchase status to Completed + let update_result_1 = dispatcher + .update_purchase_status(purchase_id_1, PurchaseStatus::Completed); + assert(update_result_1, 'Failed to update status1'); + let update_result_2 = dispatcher + .update_purchase_status(purchase_id_2, PurchaseStatus::Completed); + assert(update_result_2, 'Failed to update status1'); + let update_result_3 = dispatcher + .update_purchase_status(purchase_id_3, PurchaseStatus::Completed); + assert(update_result_3, 'Failed to update status1'); + + // Now the purchase should be verified + let is_now_verified1 = dispatcher.verify_purchase(purchase_id_1); + assert(is_now_verified1, 'Purchase should be verified'); + let is_now_verified2 = dispatcher.verify_purchase(purchase_id_2); + assert(is_now_verified2, 'Purchase should be verified'); + let is_now_verified3 = dispatcher.verify_purchase(purchase_id_3); + assert(is_now_verified3, 'Purchase should be verified'); + + let receipt = dispatcher.get_receipt(1); + + assert(receipt.purchase_id == purchase_id_1, 'receipt error'); + + start_cheat_caller_address(contract_address, user_address); + dispatcher.request_refund(purchase_id_1, RefundRequestReason::MISREPRESENTED_CONTENT); + stop_cheat_caller_address(contract_address); + + start_cheat_caller_address(contract_address, admin_address); + let refunds_array = dispatcher.get_user_refunds(user_id); + let refund_request = refunds_array.at(0); + assert(*refund_request.status == RefundStatus::PENDING, 'Wrong refund status'); + + dispatcher.approve_refund(*refund_request.refund_id, user_id, Option::None); + let new_refunds_array = dispatcher.get_user_refunds(user_id); + let new_refund_request = new_refunds_array.at(0); + assert(*new_refund_request.status == RefundStatus::APPROVED, 'Failed to change refund status'); + + dispatcher.refund_user(*refund_request.refund_id, user_id); + + let paid_refunds_array = dispatcher.get_user_refunds(user_id); + let paid_refund = paid_refunds_array.at(0); + assert(*paid_refund.status == RefundStatus::PAID, 'Failed to change refund status'); + + // Still admin calling + cheat_block_timestamp(contract_address, 86400 * 8, CheatSpan::Indefinite); + // start_cheat_block_timestamp(contract_address); + dispatcher.batch_payout_creators(); + + let creator_1_new_bal = erc20_dispatcher.balance_of(creator_1); + let creator_2_new_bal = erc20_dispatcher.balance_of(creator_2); + let creator_3_new_bal = erc20_dispatcher.balance_of(creator_3); + + assert(creator_1_new_bal > creator_1_init_bal, 'Failed to credit creator1'); + assert(creator_2_new_bal > creator_2_init_bal, 'Failed to credit creator2'); + assert(creator_3_new_bal > creator_3_init_bal, 'Failed to credit creator3'); +} + +#[test] +#[should_panic(expected: 'Refund request declined')] +fn test_refund_flow_decline_refund_request() { + let (contract_address, admin_address, erc20_address) = setup(); + let dispatcher = IChainLibDispatcher { contract_address }; + let user_address = contract_address_const::<'user'>(); + let creator_1 = contract_address_const::<'creator_1'>(); + let creator_2 = contract_address_const::<'creator_2'>(); + let creator_3 = contract_address_const::<'creator_3'>(); + + let username1: felt252 = 'creator_1'; + let username2: felt252 = 'creator_2'; + let username3: felt252 = 'creator_3'; + let user_name: felt252 = 'user'; + + let role: Role = Role::WRITER; + let rank: Rank = Rank::BEGINNER; + let metadata: felt252 = 'john is a boy'; + + start_cheat_caller_address(contract_address, user_address); + let user_id = dispatcher.register_user(user_name, Role::READER, Rank::BEGINNER, metadata); + stop_cheat_caller_address(contract_address); + + start_cheat_caller_address(contract_address, creator_1); + let creator1_id = dispatcher.register_user(username1, role.clone(), rank.clone(), metadata); + println!("successfully registered creator_1 as writer"); + stop_cheat_caller_address(contract_address); + + start_cheat_caller_address(contract_address, creator_2); + let creator2_id = dispatcher.register_user(username2, role.clone(), rank.clone(), metadata); + stop_cheat_caller_address(contract_address); + + start_cheat_caller_address(contract_address, creator_3); + let creator3_id = dispatcher.register_user(username3, role.clone(), rank.clone(), metadata); + stop_cheat_caller_address(contract_address); + + start_cheat_caller_address(contract_address, admin_address); + let is_creator_1_verified = dispatcher.verify_user(creator1_id); + let is_creator_2_verified = dispatcher.verify_user(creator2_id); + let is_creator_3_verified = dispatcher.verify_user(creator3_id); + stop_cheat_caller_address(contract_address); + + let erc20_dispatcher = IERC20Dispatcher { contract_address: erc20_address }; + let creator_1_init_bal = erc20_dispatcher.balance_of(creator_1); + let creator_2_init_bal = erc20_dispatcher.balance_of(creator_2); + let creator_3_init_bal = erc20_dispatcher.balance_of(creator_3); + + token_faucet_and_allowance(dispatcher, user_address, erc20_address, 100000); + // Set up test data + let title1: felt252 = 'Creator 1 content'; + let title2: felt252 = 'Creator 2 content'; + let title3: felt252 = 'Creator 3 content'; + + let description: felt252 = 'This is a test content'; + let content_type: ContentType = ContentType::Text; + let category: Category = Category::Education; + + start_cheat_caller_address(contract_address, creator_1); + let creator1_content_id: felt252 = dispatcher + .register_content(title1, description, content_type, category); + stop_cheat_caller_address(contract_address); + + start_cheat_caller_address(contract_address, creator_2); + let creator2_content_id: felt252 = dispatcher + .register_content(title2, description, content_type, category); + stop_cheat_caller_address(contract_address); + + start_cheat_caller_address(contract_address, creator_3); + let creator3_content_id: felt252 = dispatcher + .register_content(title3, description, content_type, category); + stop_cheat_caller_address(contract_address); + let price_1: u256 = 1000_u256; + let price_2: u256 = 2000_u256; + let price_3: u256 = 1500_u256; + + // Set creator_1 as caller to set up content price + cheat_caller_address(contract_address, admin_address, CheatSpan::Indefinite); + // Set up content with price + setup_content_with_price( + dispatcher, admin_address, contract_address, creator1_content_id, price_1, + ); + + // Set up content with price + setup_content_with_price( + dispatcher, admin_address, contract_address, creator2_content_id, price_2, + ); + + // Set up content with price + setup_content_with_price( + dispatcher, admin_address, contract_address, creator3_content_id, price_3, + ); + + // Set user as content consumer + cheat_caller_address(contract_address, user_address, CheatSpan::Indefinite); + + // Purchase the content + let purchase_id_1 = dispatcher.purchase_content(creator1_content_id, 'tx1'); + let purchase_id_2 = dispatcher.purchase_content(creator2_content_id, 'tx2'); + let purchase_id_3 = dispatcher.purchase_content(creator3_content_id, 'tx3'); + + // Initially, purchase should not be verified (status is Pending) + let is_purchase_1_verified = dispatcher.verify_purchase(purchase_id_1); + let is_purchase_2_verified = dispatcher.verify_purchase(purchase_id_2); + let is_purchase_3_verified = dispatcher.verify_purchase(purchase_id_3); + assert(!is_purchase_1_verified, '1 should not be verified'); + assert(!is_purchase_2_verified, '2 should not be verified'); + assert(!is_purchase_3_verified, '3 should not be verified'); + + // Set admin as caller to update the purchase status + cheat_caller_address(contract_address, admin_address, CheatSpan::Indefinite); + cheat_block_timestamp(contract_address, 0, CheatSpan::Indefinite); + + // Update purchase status to Completed + let update_result_1 = dispatcher + .update_purchase_status(purchase_id_1, PurchaseStatus::Completed); + assert(update_result_1, 'Failed to update status1'); + let update_result_2 = dispatcher + .update_purchase_status(purchase_id_2, PurchaseStatus::Completed); + assert(update_result_2, 'Failed to update status1'); + let update_result_3 = dispatcher + .update_purchase_status(purchase_id_3, PurchaseStatus::Completed); + assert(update_result_3, 'Failed to update status1'); + + // Now the purchase should be verified + let is_now_verified1 = dispatcher.verify_purchase(purchase_id_1); + assert(is_now_verified1, 'Purchase should be verified'); + let is_now_verified2 = dispatcher.verify_purchase(purchase_id_2); + assert(is_now_verified2, 'Purchase should be verified'); + let is_now_verified3 = dispatcher.verify_purchase(purchase_id_3); + assert(is_now_verified3, 'Purchase should be verified'); + + let receipt = dispatcher.get_receipt(1); + + assert(receipt.purchase_id == purchase_id_1, 'receipt error'); + + start_cheat_caller_address(contract_address, user_address); + dispatcher.request_refund(purchase_id_1, RefundRequestReason::MISREPRESENTED_CONTENT); + stop_cheat_caller_address(contract_address); + + start_cheat_caller_address(contract_address, admin_address); + let refunds_array = dispatcher.get_user_refunds(user_id); + let refund_request = refunds_array.at(0); + + dispatcher.decline_refund(*refund_request.refund_id, user_id); + + let new_refunds_array = dispatcher.get_user_refunds(user_id); + let new_refund_request = new_refunds_array.at(0); + assert(*new_refund_request.status == RefundStatus::DECLINED, 'Failed to change refund status'); + + dispatcher.refund_user(*refund_request.refund_id, user_id); + + // Still admin calling + cheat_block_timestamp(contract_address, 86400 * 8, CheatSpan::Indefinite); + // start_cheat_block_timestamp(contract_address); + dispatcher.batch_payout_creators(); + + let creator_1_new_bal = erc20_dispatcher.balance_of(creator_1); + let creator_2_new_bal = erc20_dispatcher.balance_of(creator_2); + let creator_3_new_bal = erc20_dispatcher.balance_of(creator_3); + + assert(creator_1_new_bal > creator_1_init_bal, 'Failed to credit creator1'); + assert(creator_2_new_bal > creator_2_init_bal, 'Failed to credit creator2'); + assert(creator_3_new_bal > creator_3_init_bal, 'Failed to credit creator3'); +} + +#[test] +#[should_panic(expected: 'Refund window already closed')] +fn test_refund_flow_refund_request_timed_out() { + let (contract_address, admin_address, erc20_address) = setup(); + let dispatcher = IChainLibDispatcher { contract_address }; + let user_address = contract_address_const::<'user'>(); + let creator_1 = contract_address_const::<'creator_1'>(); + let creator_2 = contract_address_const::<'creator_2'>(); + let creator_3 = contract_address_const::<'creator_3'>(); + + let username1: felt252 = 'creator_1'; + let username2: felt252 = 'creator_2'; + let username3: felt252 = 'creator_3'; + let user_name: felt252 = 'user'; + + let role: Role = Role::WRITER; + let rank: Rank = Rank::BEGINNER; + let metadata: felt252 = 'john is a boy'; + + start_cheat_caller_address(contract_address, user_address); + let user_id = dispatcher.register_user(user_name, Role::READER, Rank::BEGINNER, metadata); + stop_cheat_caller_address(contract_address); + + start_cheat_caller_address(contract_address, creator_1); + let creator1_id = dispatcher.register_user(username1, role.clone(), rank.clone(), metadata); + println!("successfully registered creator_1 as writer"); + stop_cheat_caller_address(contract_address); + + start_cheat_caller_address(contract_address, creator_2); + let creator2_id = dispatcher.register_user(username2, role.clone(), rank.clone(), metadata); + stop_cheat_caller_address(contract_address); + + start_cheat_caller_address(contract_address, creator_3); + let creator3_id = dispatcher.register_user(username3, role.clone(), rank.clone(), metadata); + stop_cheat_caller_address(contract_address); + + start_cheat_caller_address(contract_address, admin_address); + let is_creator_1_verified = dispatcher.verify_user(creator1_id); + let is_creator_2_verified = dispatcher.verify_user(creator2_id); + let is_creator_3_verified = dispatcher.verify_user(creator3_id); + stop_cheat_caller_address(contract_address); + + let erc20_dispatcher = IERC20Dispatcher { contract_address: erc20_address }; + let creator_1_init_bal = erc20_dispatcher.balance_of(creator_1); + let creator_2_init_bal = erc20_dispatcher.balance_of(creator_2); + let creator_3_init_bal = erc20_dispatcher.balance_of(creator_3); + + token_faucet_and_allowance(dispatcher, user_address, erc20_address, 100000); + // Set up test data + let title1: felt252 = 'Creator 1 content'; + let title2: felt252 = 'Creator 2 content'; + let title3: felt252 = 'Creator 3 content'; + + let description: felt252 = 'This is a test content'; + let content_type: ContentType = ContentType::Text; + let category: Category = Category::Education; + + start_cheat_caller_address(contract_address, creator_1); + let creator1_content_id: felt252 = dispatcher + .register_content(title1, description, content_type, category); + stop_cheat_caller_address(contract_address); + + start_cheat_caller_address(contract_address, creator_2); + let creator2_content_id: felt252 = dispatcher + .register_content(title2, description, content_type, category); + stop_cheat_caller_address(contract_address); + + start_cheat_caller_address(contract_address, creator_3); + let creator3_content_id: felt252 = dispatcher + .register_content(title3, description, content_type, category); + stop_cheat_caller_address(contract_address); + let price_1: u256 = 1000_u256; + let price_2: u256 = 2000_u256; + let price_3: u256 = 1500_u256; + + // Set creator_1 as caller to set up content price + cheat_caller_address(contract_address, admin_address, CheatSpan::Indefinite); + // Set up content with price + setup_content_with_price( + dispatcher, admin_address, contract_address, creator1_content_id, price_1, + ); + + // Set up content with price + setup_content_with_price( + dispatcher, admin_address, contract_address, creator2_content_id, price_2, + ); + + // Set up content with price + setup_content_with_price( + dispatcher, admin_address, contract_address, creator3_content_id, price_3, + ); + + // Set user as content consumer + cheat_caller_address(contract_address, user_address, CheatSpan::Indefinite); + + // Purchase the content + let purchase_id_1 = dispatcher.purchase_content(creator1_content_id, 'tx1'); + let purchase_id_2 = dispatcher.purchase_content(creator2_content_id, 'tx2'); + let purchase_id_3 = dispatcher.purchase_content(creator3_content_id, 'tx3'); + + // Initially, purchase should not be verified (status is Pending) + let is_purchase_1_verified = dispatcher.verify_purchase(purchase_id_1); + let is_purchase_2_verified = dispatcher.verify_purchase(purchase_id_2); + let is_purchase_3_verified = dispatcher.verify_purchase(purchase_id_3); + assert(!is_purchase_1_verified, '1 should not be verified'); + assert(!is_purchase_2_verified, '2 should not be verified'); + assert(!is_purchase_3_verified, '3 should not be verified'); + + // Set admin as caller to update the purchase status + cheat_caller_address(contract_address, admin_address, CheatSpan::Indefinite); + cheat_block_timestamp(contract_address, 0, CheatSpan::Indefinite); + + // Update purchase status to Completed + let update_result_1 = dispatcher + .update_purchase_status(purchase_id_1, PurchaseStatus::Completed); + assert(update_result_1, 'Failed to update status1'); + let update_result_2 = dispatcher + .update_purchase_status(purchase_id_2, PurchaseStatus::Completed); + assert(update_result_2, 'Failed to update status1'); + let update_result_3 = dispatcher + .update_purchase_status(purchase_id_3, PurchaseStatus::Completed); + assert(update_result_3, 'Failed to update status1'); + + // Now the purchase should be verified + let is_now_verified1 = dispatcher.verify_purchase(purchase_id_1); + assert(is_now_verified1, 'Purchase should be verified'); + let is_now_verified2 = dispatcher.verify_purchase(purchase_id_2); + assert(is_now_verified2, 'Purchase should be verified'); + let is_now_verified3 = dispatcher.verify_purchase(purchase_id_3); + assert(is_now_verified3, 'Purchase should be verified'); + + let receipt = dispatcher.get_receipt(1); + + assert(receipt.purchase_id == purchase_id_1, 'receipt error'); + + start_cheat_caller_address(contract_address, user_address); + start_cheat_block_timestamp(contract_address, 86400 * 3); + dispatcher.request_refund(purchase_id_1, RefundRequestReason::MISREPRESENTED_CONTENT); + stop_cheat_block_timestamp(contract_address); + stop_cheat_caller_address(contract_address); + + start_cheat_caller_address(contract_address, admin_address); + let refunds_array = dispatcher.get_user_refunds(user_id); + let refund_request = refunds_array.at(0); + + dispatcher.decline_refund(*refund_request.refund_id, user_id); + + let new_refunds_array = dispatcher.get_user_refunds(user_id); + let new_refund_request = new_refunds_array.at(0); + assert(*new_refund_request.status == RefundStatus::DECLINED, 'Failed to change refund status'); + + dispatcher.refund_user(*refund_request.refund_id, user_id); + + // Still admin calling + cheat_block_timestamp(contract_address, 86400 * 8, CheatSpan::Indefinite); + // start_cheat_block_timestamp(contract_address); + dispatcher.batch_payout_creators(); + + let creator_1_new_bal = erc20_dispatcher.balance_of(creator_1); + let creator_2_new_bal = erc20_dispatcher.balance_of(creator_2); + let creator_3_new_bal = erc20_dispatcher.balance_of(creator_3); + + assert(creator_1_new_bal > creator_1_init_bal, 'Failed to credit creator1'); + assert(creator_2_new_bal > creator_2_init_bal, 'Failed to credit creator2'); + assert(creator_3_new_bal > creator_3_init_bal, 'Failed to credit creator3'); +} diff --git a/tests/test_utils.cairo b/tests/test_utils.cairo index e893ecf..9cfa184 100644 --- a/tests/test_utils.cairo +++ b/tests/test_utils.cairo @@ -17,9 +17,15 @@ pub fn setup() -> (ContractAddress, ContractAddress, ContractAddress) { // Deploy the ChainLib contract let declare_result = declare("ChainLib"); assert(declare_result.is_ok(), 'Contract declaration failed'); + let platform_fee: u256 = 900; + let payout_schedule_interval: u64 = 86400 * 7; + let refund_window: u64 = 86400 * 2; let contract_class = declare_result.unwrap().contract_class(); let mut calldata = array![admin_address.into(), erc20_address.into()]; + platform_fee.serialize(ref calldata); + payout_schedule_interval.serialize(ref calldata); + refund_window.serialize(ref calldata); let deploy_result = contract_class.deploy(@calldata); assert(deploy_result.is_ok(), 'Contract deployment failed');