diff --git a/src/base/types.cairo b/src/base/types.cairo index b28387b..15b713b 100644 --- a/src/base/types.cairo +++ b/src/base/types.cairo @@ -119,3 +119,22 @@ pub mod delegation_flags { // Combined flag for full delegation capabilities pub const FULL_DELEGATION: u64 = 0xF0000; } +#[derive(Copy, Drop, Serde, starknet::Store, Clone, PartialEq, Debug)] +pub enum PurchaseStatus { + #[default] + Pending, + Completed, + Failed, + Refunded, +} + +#[derive(Drop, Serde, starknet::Store, Debug)] +pub struct Purchase { + pub id: u256, + pub content_id: felt252, + pub buyer: ContractAddress, + pub price: u256, + pub status: PurchaseStatus, + pub timestamp: u64, + pub transaction_hash: felt252, +} diff --git a/src/chainlib/ChainLib.cairo b/src/chainlib/ChainLib.cairo index bbceef3..95837c8 100644 --- a/src/chainlib/ChainLib.cairo +++ b/src/chainlib/ChainLib.cairo @@ -3,7 +3,7 @@ pub mod ChainLib { use core::array::Array; use core::array::ArrayTrait; use core::option::OptionTrait; - + use core::traits::Into; use starknet::storage::{ Map, MutableVecTrait, StorageMapReadAccess, StorageMapWriteAccess, StoragePointerReadAccess, StoragePointerWriteAccess, Vec, VecTrait, @@ -17,7 +17,7 @@ pub mod ChainLib { use crate::base::types::{ TokenBoundAccount, User, Role, Rank, Permissions, permission_flags, AccessRule, AccessType, - VerificationRequirement, VerificationType, + VerificationRequirement, VerificationType, Purchase, PurchaseStatus, }; // Define delegation-specific structures and constants @@ -155,6 +155,17 @@ pub mod ChainLib { user_reputation_verifications: Map, user_ownership_verifications: Map, user_custom_verifications: Map, + content_prices: Map::, // Maps content_id to price + next_purchase_id: u256, // Tracking the next available purchase ID + purchases: Map::, // Store purchases by ID + user_purchase_count: Map::, // Count of purchases per user + user_purchase_ids: Map::< + (ContractAddress, u32), u256, + >, // Map of (user, index) to purchase ID + content_purchase_count: Map::, // Count of purchases per content + content_purchase_ids: Map::< + (felt252, u32), u256, + > // Map of (content_id, index) to purchase ID } @@ -162,6 +173,8 @@ pub mod ChainLib { fn constructor(ref self: ContractState, admin: ContractAddress) { // Store the values in contract state self.admin.write(admin); + // Initialize purchase ID counter + self.next_purchase_id.write(1_u256); } #[event] @@ -187,6 +200,8 @@ pub mod ChainLib { DelegationRevoked: DelegationRevoked, DelegationUsed: DelegationUsed, DelegationExpired: DelegationExpired, + ContentPurchased: ContentPurchased, + PurchaseStatusUpdated: PurchaseStatusUpdated, } #[derive(Drop, starknet::Event)] @@ -321,6 +336,22 @@ pub mod ChainLib { pub timestamp: u64, } + #[derive(Drop, starknet::Event)] + pub struct ContentPurchased { + pub purchase_id: u256, + pub content_id: felt252, + pub buyer: ContractAddress, + pub price: u256, + pub timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct PurchaseStatusUpdated { + pub purchase_id: u256, + pub new_status: u8, // Using u8 for status code instead of PurchaseStatus enum + pub timestamp: u64, + } + #[abi(embed_v0)] impl ChainLibNetImpl of IChainLib { fn create_token_account( @@ -387,6 +418,7 @@ pub mod ChainLib { token_bound_account } + fn register_user( ref self: ContractState, username: felt252, role: Role, rank: Rank, metadata: felt252, ) -> u256 { @@ -1506,5 +1538,179 @@ pub mod ChainLib { true } + + /// @notice Sets the price for a content item (admin only) + /// @dev This function allows the admin to set or update the price of a content item. + /// @param self The contract state reference. + /// @param content_id The unique identifier of the content. + /// @param price The price to set for the content. + fn set_content_price(ref self: ContractState, content_id: felt252, price: u256) { + // Only admin can set content prices + let caller = get_caller_address(); + assert(self.admin.read() == caller, 'Admin only'); + + // Set the price for the content + self.content_prices.write(content_id, price); + } + + /// @notice Initiates a purchase for a specific content. + /// @dev Creates a purchase record with a pending status and emits an event. + /// @param self The contract state reference. + /// @param content_id The unique identifier of the content being purchased. + /// @param transaction_hash The hash of the transaction being used for payment. + /// @return The unique ID of the newly created purchase. + fn purchase_content( + ref self: ContractState, content_id: felt252, transaction_hash: felt252, + ) -> u256 { + // Validate input parameters + assert!(content_id != 0, "Content ID cannot be empty"); + assert!(transaction_hash != 0, "Transaction hash cannot be empty"); + + // Get the price for the content + let price = self.content_prices.read(content_id); + assert!(price > 0, "Content either doesn't exist"); + + // Get the buyer's address + let buyer = get_caller_address(); + + // Get the next purchase ID and increment for future use + let purchase_id = self.next_purchase_id.read(); + self.next_purchase_id.write(purchase_id + 1); + + // Create the purchase record + let purchase = Purchase { + id: purchase_id, + content_id: content_id, + buyer: buyer, + price: price, + status: PurchaseStatus::Pending, + timestamp: get_block_timestamp(), + transaction_hash: transaction_hash, + }; + + // Store the purchase in the purchases mapping + self.purchases.write(purchase_id, purchase); + + // Add the purchase ID to the user's purchase list + let user_purchase_count = self.user_purchase_count.read(buyer); + self.user_purchase_ids.write((buyer, user_purchase_count), purchase_id); + self.user_purchase_count.write(buyer, user_purchase_count + 1); + + // Add the purchase ID to the content's purchase list + let content_purchase_count = self.content_purchase_count.read(content_id); + self.content_purchase_ids.write((content_id, content_purchase_count), purchase_id); + self.content_purchase_count.write(content_id, content_purchase_count + 1); + + // Emit event for the purchase + let timestamp = get_block_timestamp(); + self.emit(ContentPurchased { purchase_id, content_id, buyer, price, timestamp }); + + // Return the purchase ID + purchase_id + } + + /// @notice Retrieves details of a specific purchase. + /// @dev Fetches purchase information by its ID. + /// @param self The contract state reference. + /// @param purchase_id The unique identifier of the purchase. + /// @return Purchase The purchase details. + fn get_purchase_details(ref self: ContractState, purchase_id: u256) -> Purchase { + // Fetch and return the purchase details from storage + let purchase = self.purchases.read(purchase_id); + purchase + } + + /// @notice Retrieves all purchases made by a specific user. + /// @dev Returns an array of purchase records for the user. + /// @param self The contract state reference. + /// @param user_address The address of the user whose purchases are being retrieved. + /// @return Array An array of purchase records for the user. + fn get_user_purchases( + ref self: ContractState, user_address: ContractAddress, + ) -> Array { + // Initialize an empty array to hold the purchases + let mut purchases: Array = ArrayTrait::new(); + + // Get the number of purchases for this user + let purchase_count = self.user_purchase_count.read(user_address); + + // Iterate through the purchase IDs and fetch each purchase + let mut i: u32 = 0; + + while i < purchase_count { + // Get the purchase ID at the current index + let purchase_id = self.user_purchase_ids.read((user_address, i)); + + // Fetch the purchase details using the ID + let purchase = self.purchases.read(purchase_id); + + // Add the purchase to the array + purchases.append(purchase); + + // Move to the next index + i += 1; + }; + + // Return the array of purchases + purchases + } + + /// @notice Verifies if a purchase is valid and completed. + /// @dev Checks the status of a purchase to confirm it has been completed successfully. + /// @param self The contract state reference. + /// @param purchase_id The unique identifier of the purchase to verify. + /// @return bool True if the purchase is valid and completed, false otherwise. + fn verify_purchase(ref self: ContractState, purchase_id: u256) -> bool { + // Get the purchase details + let purchase = self.purchases.read(purchase_id); + + // A purchase is valid if its status is Completed + if purchase.status == PurchaseStatus::Completed { + return true; + } else { + return false; + } + } + + /// @notice Updates the status of a purchase. + /// @dev Only admin can update the status to prevent unauthorized changes. + /// @param self The contract state reference. + /// @param purchase_id The unique identifier of the purchase. + /// @param status The new status to set for the purchase. + /// @return bool True if the status was updated successfully. + fn update_purchase_status( + ref self: ContractState, purchase_id: u256, status: PurchaseStatus, + ) -> bool { + // Only admin can update purchase status + let caller = get_caller_address(); + assert(self.admin.read() == caller, 'Only admin can update status'); + + // Get the current purchase + let mut purchase = self.purchases.read(purchase_id); + + // Validate that we're not trying to update a purchase that doesn't exist + assert(purchase.id == purchase_id, 'Purchase does not exist'); + + // Update the status + purchase.status = status; + + // Save the updated purchase + self.purchases.write(purchase_id, purchase); + + // Emit event for the status update + let timestamp = get_block_timestamp(); + + // Convert PurchaseStatus to u8 + let status_code: u8 = match status { + PurchaseStatus::Pending => 0_u8, + PurchaseStatus::Completed => 1_u8, + PurchaseStatus::Failed => 2_u8, + PurchaseStatus::Refunded => 3_u8, + }; + + self.emit(PurchaseStatusUpdated { purchase_id, new_status: status_code, timestamp }); + + true + } } } diff --git a/src/interfaces/IChainLib.cairo b/src/interfaces/IChainLib.cairo index b0a646c..0eabec4 100644 --- a/src/interfaces/IChainLib.cairo +++ b/src/interfaces/IChainLib.cairo @@ -2,7 +2,7 @@ use starknet::ContractAddress; use core::array::Array; use crate::base::types::{ TokenBoundAccount, User, Role, Rank, Permissions, AccessRule, VerificationRequirement, - VerificationType, + VerificationType, Purchase, PurchaseStatus, }; use crate::chainlib::ChainLib::ChainLib::{ Category, Subscription, Payment, ContentType, ContentMetadata, DelegationInfo, @@ -63,6 +63,9 @@ pub trait IChainLib { ) -> felt252; fn get_content(ref self: TContractState, content_id: felt252) -> ContentMetadata; + + fn set_content_price(ref self: TContractState, content_id: felt252, price: u256); + // Payment System fn process_initial_payment( ref self: TContractState, amount: u256, subscriber: ContractAddress, @@ -179,4 +182,16 @@ pub trait IChainLib { fn initialize_access_control(ref self: TContractState, default_cache_ttl: u64) -> bool; fn clear_access_cache(ref self: TContractState, user_id: u256, content_id: felt252) -> bool; + + fn purchase_content( + ref self: TContractState, content_id: felt252, transaction_hash: felt252, + ) -> u256; + fn get_purchase_details(ref self: TContractState, purchase_id: u256) -> Purchase; + fn get_user_purchases( + ref self: TContractState, user_address: ContractAddress, + ) -> Array; + fn verify_purchase(ref self: TContractState, purchase_id: u256) -> bool; + fn update_purchase_status( + ref self: TContractState, purchase_id: u256, status: PurchaseStatus, + ) -> bool; } diff --git a/tests/test_ChainLib.cairo b/tests/test_ChainLib.cairo index 5317ef1..6c09343 100644 --- a/tests/test_ChainLib.cairo +++ b/tests/test_ChainLib.cairo @@ -10,9 +10,24 @@ use starknet::{ContractAddress}; use starknet::class_hash::ClassHash; use starknet::contract_address::contract_address_const; use starknet::testing::{set_caller_address, set_contract_address}; -use chain_lib::base::types::{Role, Rank}; +use chain_lib::base::types::{Role, Rank, PurchaseStatus}; use chain_lib::chainlib::ChainLib::ChainLib::{ContentType, Category}; +/// Helper function to create a content item with a price +/// We'll use the set_content_price function implemented in the contract +fn setup_content_with_price( + dispatcher: IChainLibDispatcher, + admin_address: ContractAddress, + contract_address: ContractAddress, + content_id: felt252, + price: u256, +) { + // Set admin as caller for setting content price + cheat_caller_address(contract_address, admin_address, CheatSpan::Indefinite); + + // Use the new set_content_price function to set the price + dispatcher.set_content_price(content_id, price); +} fn setup() -> (ContractAddress, ContractAddress) { let declare_result = declare("ChainLib"); @@ -350,3 +365,228 @@ fn test_determine_access() { start_cheat_caller_address(contract_address, admin); dispatcher.verify_access(account_id, content_id); } + +#[test] +fn test_purchase_content() { + let (contract_address, admin_address) = setup(); + let dispatcher = IChainLibDispatcher { contract_address }; + let user_address = contract_address_const::<'user'>(); + + // Set up test data + let content_id: felt252 = 'content1'; + let price: u256 = 1000_u256; + let transaction_hash: felt252 = 'tx1'; + + // Set up content with price + setup_content_with_price(dispatcher, admin_address, contract_address, content_id, price); + + // We set user as the caller for the purchase + cheat_caller_address(contract_address, user_address, CheatSpan::Indefinite); + + // Call the purchase function + let purchase_id = dispatcher.purchase_content(content_id, transaction_hash); + + // Verify the purchase details + let purchase = dispatcher.get_purchase_details(purchase_id); + assert(purchase.id == purchase_id, 'ID mismatch'); + assert(purchase.content_id == content_id, 'Content ID mismatch'); + assert(purchase.buyer == user_address, 'Buyer mismatch'); + assert(purchase.price == price, 'Price mismatch'); + assert(purchase.status == PurchaseStatus::Pending, 'Status mismatch'); + assert(purchase.transaction_hash == transaction_hash, 'Transaction hash mismatch'); +} + +#[test] +#[should_panic(expected: "Content either doesn't exist")] +fn test_purchase_nonexistent_content() { + let (contract_address, _) = setup(); + let dispatcher = IChainLibDispatcher { contract_address }; + let user_address = contract_address_const::<'user'>(); + + // Set user as caller + cheat_caller_address(contract_address, user_address, CheatSpan::Indefinite); + + // Attempt to purchase nonexistent content + let content_id: felt252 = 'none'; + let transaction_hash: felt252 = 'tx1'; + + // This should fail because the content doesn't exist (no price set) + let _ = dispatcher.purchase_content(content_id, transaction_hash); +} + +#[test] +fn test_get_user_purchases() { + let (contract_address, admin_address) = setup(); + let dispatcher = IChainLibDispatcher { contract_address }; + let user_address = contract_address_const::<'user'>(); + + // Set up multiple content items + let content_id_1: felt252 = 'content1'; + let content_id_2: felt252 = 'content2'; + let price: u256 = 1000_u256; + + // Set admin as caller to prepare the contract state + cheat_caller_address(contract_address, admin_address, CheatSpan::Indefinite); + + // Set up content with price + setup_content_with_price(dispatcher, admin_address, contract_address, content_id_1, price); + setup_content_with_price(dispatcher, admin_address, contract_address, content_id_2, price * 2); + + // Set user as caller for purchases + cheat_caller_address(contract_address, user_address, CheatSpan::Indefinite); + + // Purchase multiple content items + let purchase_id_1 = dispatcher.purchase_content(content_id_1, 'tx1'); + let purchase_id_2 = dispatcher.purchase_content(content_id_2, 'tx2'); + + // Get user purchases + let _user_purchases = dispatcher.get_user_purchases(user_address); + + // Verify the purchases array contains the expected items + assert(_user_purchases.len() == 2, 'Wrong number of purchases'); + + // Due to Copy trait constraints, we need to modify how we access array elements + // Instead of dereferencing, we'll check directly using the array + let purchase_1 = dispatcher.get_purchase_details(purchase_id_1); + let purchase_2 = dispatcher.get_purchase_details(purchase_id_2); + + assert(purchase_1.id == purchase_id_1, 'Purchase 1 ID mismatch'); + assert(purchase_1.content_id == content_id_1, 'Purchase 1 content mismatch'); + + assert(purchase_2.id == purchase_id_2, 'Purchase 2 ID mismatch'); + assert(purchase_2.content_id == content_id_2, 'Purchase 2 content mismatch'); +} + +#[test] +fn test_verify_purchase() { + let (contract_address, admin_address) = setup(); + let dispatcher = IChainLibDispatcher { contract_address }; + let user_address = contract_address_const::<'user'>(); + + // Set up test data + let content_id: felt252 = 'content1'; + let price: u256 = 1000_u256; + + // Set admin 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, content_id, price); + + // Set user as caller + cheat_caller_address(contract_address, user_address, CheatSpan::Indefinite); + + // Purchase the content + let purchase_id = dispatcher.purchase_content(content_id, 'tx1'); + + // Initially, purchase should not be verified (status is Pending) + let is_verified = dispatcher.verify_purchase(purchase_id); + assert(!is_verified, 'Purchase should not be verified'); + + // Set admin as caller to update the purchase status + cheat_caller_address(contract_address, admin_address, CheatSpan::Indefinite); + + // Update purchase status to Completed + let update_result = dispatcher.update_purchase_status(purchase_id, PurchaseStatus::Completed); + assert(update_result, 'Failed to update status'); + + // Now the purchase should be verified + let is_now_verified = dispatcher.verify_purchase(purchase_id); + assert(is_now_verified, 'Purchase should be verified'); +} + +#[test] +fn test_update_purchase_status() { + let (contract_address, admin_address) = setup(); + let dispatcher = IChainLibDispatcher { contract_address }; + let user_address = contract_address_const::<'user'>(); + + // Set up test data + let content_id: felt252 = 'content1'; + let price: u256 = 1000_u256; + + // Set admin 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, content_id, price); + + // Set user as caller for purchase + cheat_caller_address(contract_address, user_address, CheatSpan::Indefinite); + + // Purchase the content + let purchase_id = dispatcher.purchase_content(content_id, 'tx1'); + + // Get initial purchase + let initial_purchase = dispatcher.get_purchase_details(purchase_id); + assert(initial_purchase.status == PurchaseStatus::Pending, 'Status should be Pending'); + + // Set admin as caller to update the purchase status + cheat_caller_address(contract_address, admin_address, CheatSpan::Indefinite); + + // Test updating to different statuses + // 1. Update to Completed + let update_to_completed = dispatcher + .update_purchase_status(purchase_id, PurchaseStatus::Completed); + assert(update_to_completed, 'Failed to update to Completed'); + + let completed_purchase = dispatcher.get_purchase_details(purchase_id); + assert(completed_purchase.status == PurchaseStatus::Completed, 'Status should be Completed'); + + // 2. Update to Failed + let update_to_failed = dispatcher.update_purchase_status(purchase_id, PurchaseStatus::Failed); + assert(update_to_failed, 'Failed to update to Failed'); + + let failed_purchase = dispatcher.get_purchase_details(purchase_id); + assert(failed_purchase.status == PurchaseStatus::Failed, 'Status should be Failed'); + + // 3. Update to Refunded + let update_to_refunded = dispatcher + .update_purchase_status(purchase_id, PurchaseStatus::Refunded); + assert(update_to_refunded, 'Failed to update to Refunded'); + + let refunded_purchase = dispatcher.get_purchase_details(purchase_id); + assert(refunded_purchase.status == PurchaseStatus::Refunded, 'Status should be Refunded'); +} + +#[test] +#[should_panic(expected: 'Only admin can update status')] +fn test_update_purchase_status_not_admin() { + let (contract_address, admin_address) = setup(); + let dispatcher = IChainLibDispatcher { contract_address }; + let user_address = contract_address_const::<'user'>(); + + // Set up test data + let content_id: felt252 = 'content1'; + let price: u256 = 1000_u256; + + // Set admin 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, content_id, price); + + // Set user as caller for purchase + cheat_caller_address(contract_address, user_address, CheatSpan::Indefinite); + + // Purchase the content + let purchase_id = dispatcher.purchase_content(content_id, 'tx1'); + + // Attempt to update purchase status as non-admin user + // This should fail with the "Only admin can update status" error + let _ = dispatcher.update_purchase_status(purchase_id, PurchaseStatus::Completed); +} + +#[test] +#[should_panic(expected: 'Purchase does not exist')] +fn test_update_nonexistent_purchase() { + let (contract_address, admin_address) = setup(); + let dispatcher = IChainLibDispatcher { contract_address }; + + // Set admin as caller + cheat_caller_address(contract_address, admin_address, CheatSpan::Indefinite); + + // Attempt to update a purchase that doesn't exist + let nonexistent_purchase_id = 42_u256; + let _ = dispatcher.update_purchase_status(nonexistent_purchase_id, PurchaseStatus::Completed); +}