KaraokeSchoolV3.sol
1 // SPDX-License-Identifier: MIT 2 pragma solidity ^0.8.20; 3 4 import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; 5 import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; 6 import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; 7 8 interface IERC20 { 9 function transfer(address to, uint256 amount) external returns (bool); 10 function transferFrom(address from, address to, uint256 amount) external returns (bool); 11 function balanceOf(address account) external view returns (uint256); 12 } 13 14 contract KaraokeSchoolV3 is Initializable, OwnableUpgradeable, UUPSUpgradeable { 15 IERC20 public immutable usdcToken; 16 address public immutable splitsContract; 17 18 uint256 public constant COMBO_PRICE = 7_000_000; // 7 USDC (6 decimals) 19 uint256 public constant VOICE_PACK_PRICE = 4_000_000; // 4 USDC 20 uint256 public constant SONG_PACK_PRICE = 3_000_000; // 3 USDC 21 22 // FIXED CREDIT AMOUNTS 23 uint256 public constant COMBO_VOICE_CREDITS = 2000; 24 uint256 public constant COMBO_SONG_CREDITS = 3; 25 uint256 public constant VOICE_PACK_CREDITS = 2000; 26 uint256 public constant SONG_PACK_CREDITS = 3; 27 28 mapping(address => uint256) public voiceCredits; 29 mapping(address => uint256) public songCredits; 30 mapping(address => mapping(uint256 => bool)) public hasUnlockedSong; 31 mapping(address => string) public userCountry; 32 33 event CreditsPurchased(address indexed user, uint256 voiceAmount, uint256 songAmount); 34 event SongUnlocked(address indexed user, uint256 indexed songId); 35 event KaraokeStarted(address indexed user, uint256 indexed songId); 36 event ExerciseStarted(address indexed user, uint256 exerciseCount); 37 event PurchaseWithCountry(address indexed user, string country, uint256 usdcAmount, string packType); 38 39 error InsufficientCredits(); 40 error AlreadyUnlocked(); 41 error SongNotUnlocked(); 42 error Unauthorized(); 43 error InvalidCountryCode(); 44 45 /// @custom:oz-upgrades-unsafe-allow constructor 46 constructor( 47 address _usdcToken, 48 address _splitsContract 49 ) { 50 usdcToken = IERC20(_usdcToken); 51 splitsContract = _splitsContract; 52 _disableInitializers(); 53 } 54 55 function initialize(address initialOwner) external initializer { 56 __Ownable_init(initialOwner); 57 __UUPSUpgradeable_init(); 58 } 59 60 // Required by UUPSUpgradeable - only owner can upgrade 61 function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} 62 63 function buyCombopack(string calldata country) external { 64 if (bytes(country).length != 2) revert InvalidCountryCode(); 65 66 // Transfer directly to splits contract 67 require(usdcToken.transferFrom(msg.sender, splitsContract, COMBO_PRICE), "Transfer failed"); 68 69 // Store country if first purchase 70 if (bytes(userCountry[msg.sender]).length == 0) { 71 userCountry[msg.sender] = country; 72 } 73 74 // FIXED: Correct credit amounts 75 voiceCredits[msg.sender] += COMBO_VOICE_CREDITS; 76 songCredits[msg.sender] += COMBO_SONG_CREDITS; 77 78 emit CreditsPurchased(msg.sender, COMBO_VOICE_CREDITS, COMBO_SONG_CREDITS); 79 emit PurchaseWithCountry(msg.sender, country, COMBO_PRICE, "combo"); 80 } 81 82 function buyVoicePack(string calldata country) external { 83 if (bytes(country).length != 2) revert InvalidCountryCode(); 84 85 require(usdcToken.transferFrom(msg.sender, splitsContract, VOICE_PACK_PRICE), "Transfer failed"); 86 87 if (bytes(userCountry[msg.sender]).length == 0) { 88 userCountry[msg.sender] = country; 89 } 90 91 voiceCredits[msg.sender] += VOICE_PACK_CREDITS; 92 93 emit CreditsPurchased(msg.sender, VOICE_PACK_CREDITS, 0); 94 emit PurchaseWithCountry(msg.sender, country, VOICE_PACK_PRICE, "voice"); 95 } 96 97 function buySongPack(string calldata country) external { 98 if (bytes(country).length != 2) revert InvalidCountryCode(); 99 100 require(usdcToken.transferFrom(msg.sender, splitsContract, SONG_PACK_PRICE), "Transfer failed"); 101 102 if (bytes(userCountry[msg.sender]).length == 0) { 103 userCountry[msg.sender] = country; 104 } 105 106 songCredits[msg.sender] += SONG_PACK_CREDITS; 107 108 emit CreditsPurchased(msg.sender, 0, SONG_PACK_CREDITS); 109 emit PurchaseWithCountry(msg.sender, country, SONG_PACK_PRICE, "song"); 110 } 111 112 function unlockSong(uint256 songId) external { 113 if (songCredits[msg.sender] == 0) revert InsufficientCredits(); 114 if (hasUnlockedSong[msg.sender][songId]) revert AlreadyUnlocked(); 115 116 songCredits[msg.sender] -= 1; 117 hasUnlockedSong[msg.sender][songId] = true; 118 119 emit SongUnlocked(msg.sender, songId); 120 } 121 122 function startKaraoke(uint256 songId) external { 123 if (voiceCredits[msg.sender] < 30) revert InsufficientCredits(); 124 if (!hasUnlockedSong[msg.sender][songId]) revert SongNotUnlocked(); 125 126 voiceCredits[msg.sender] -= 30; 127 128 emit KaraokeStarted(msg.sender, songId); 129 } 130 131 function startExercise(uint256 numExercises) external { 132 if (numExercises == 0) revert InsufficientCredits(); 133 if (voiceCredits[msg.sender] < numExercises) revert InsufficientCredits(); 134 135 voiceCredits[msg.sender] -= numExercises; 136 137 emit ExerciseStarted(msg.sender, numExercises); 138 } 139 140 // Emergency function to recover any stuck tokens (not USDC from purchases) 141 function recoverToken(address token, uint256 amount) external onlyOwner { 142 require(token != address(usdcToken) || IERC20(token).balanceOf(address(this)) == 0, "Cannot withdraw purchase funds"); 143 IERC20(token).transfer(owner(), amount); 144 } 145 146 // Storage gap for future upgrades 147 uint256[50] private __gap; 148 }