diff --git a/contract/contracts/creator-earnings/Cargo.toml b/contract/contracts/creator-earnings/Cargo.toml new file mode 100644 index 0000000..0b37335 --- /dev/null +++ b/contract/contracts/creator-earnings/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "creator-earnings" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["lib", "cdylib"] + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } + +[features] +testutils = ["soroban-sdk/testutils"] + diff --git a/contract/contracts/creator-earnings/src/lib.rs b/contract/contracts/creator-earnings/src/lib.rs new file mode 100644 index 0000000..a9e8bef --- /dev/null +++ b/contract/contracts/creator-earnings/src/lib.rs @@ -0,0 +1,159 @@ +#![no_std] + +use soroban_sdk::{ + contract, contractimpl, contracttype, token, Address, Env, Symbol, +}; + +#[contracttype] +pub enum DataKey { + Admin, + Token, + Balance(Address), + AuthorizedDepositor(Address), +} + +#[contracttype] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum EarningsError { + NotInitialized = 1, + NotAuthorized = 2, + InsufficientBalance = 3, +} + +#[contract] +pub struct CreatorEarnings; + +#[contractimpl] +impl CreatorEarnings { + /// Initialize contract with admin and accepted token + pub fn initialize(env: Env, admin: Address, token_address: Address) { + if env.storage().instance().has(&DataKey::Admin) { + panic!("already initialized"); + } + + admin.require_auth(); + + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::Token, &token_address); + } + + /// Add authorized depositor contract (admin only) + pub fn add_authorized(env: Env, contract: Address) { + let admin: Address = Self::get_admin(&env); + admin.require_auth(); + + env.storage() + .instance() + .set(&DataKey::AuthorizedDepositor(contract), &true); + } + + /// Deposit earnings for creator + /// Callable by authorized contracts or admin +pub fn deposit(env: Env, from: Address, creator: Address, amount: i128) { + if amount <= 0 { + panic!("invalid amount"); + } + + from.require_auth(); + Self::require_authorized(&env, &from); + + let token_address: Address = Self::get_token(&env); + let token_client = token::Client::new(&env, &token_address); + + token_client.transfer( + &from, + &env.current_contract_address(), + &amount, + ); + + let balance = Self::balance(env.clone(), creator.clone()); + let new_balance = balance + amount; + + env.storage() + .instance() + .set(&DataKey::Balance(creator.clone()), &new_balance); +} + + /// Get creator balance + pub fn balance(env: Env, creator: Address) -> i128 { + env.storage() + .instance() + .get(&DataKey::Balance(creator)) + .unwrap_or(0) + } + + /// Withdraw earnings + pub fn withdraw(env: Env, creator: Address, amount: i128) { + if amount <= 0 { + panic!("invalid amount"); + } + + creator.require_auth(); + + let current_balance = Self::balance(env.clone(), creator.clone()); + + if current_balance < amount { + panic!("insufficient balance"); + } + + let token_address: Address = Self::get_token(&env); + let token_client = token::Client::new(&env, &token_address); + + // Transfer from contract to creator + token_client.transfer( + &env.current_contract_address(), + &creator, + &amount, + ); + + let new_balance = current_balance - amount; + + env.storage() + .instance() + .set(&DataKey::Balance(creator.clone()), &new_balance); + + env.events().publish( + (Symbol::new(&env, "withdraw"), creator), + amount, + ); + } + + // -------- Internal helpers -------- + + fn get_admin(env: &Env) -> Address { + env.storage() + .instance() + .get(&DataKey::Admin) + .expect("not initialized") + } + + fn get_token(env: &Env) -> Address { + env.storage() + .instance() + .get(&DataKey::Token) + .expect("not initialized") + } + + fn require_authorized(env: &Env, caller: &Address) { + let admin = Self::get_admin(env); + + if caller == &admin { + admin.require_auth(); + return; + } + + if env + .storage() + .instance() + .has(&DataKey::AuthorizedDepositor(caller.clone())) + { + caller.require_auth(); + return; + } + + panic!("not authorized"); + } +} + +#[cfg(test)] +mod test; \ No newline at end of file diff --git a/contract/contracts/creator-earnings/src/test.rs b/contract/contracts/creator-earnings/src/test.rs new file mode 100644 index 0000000..4512cba --- /dev/null +++ b/contract/contracts/creator-earnings/src/test.rs @@ -0,0 +1,113 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{ + testutils::Address as _, + Address, Env, +}; +use soroban_sdk::token::{Client as TokenClient, StellarAssetClient}; + +fn setup<'a>( + env: &'a Env, +) -> ( + Address, // admin + Address, // creator + Address, // depositor + CreatorEarningsClient<'a>, + TokenClient<'a>, + StellarAssetClient<'a>, +) { + env.mock_all_auths(); + + let admin = Address::generate(env); + let creator = Address::generate(env); + let depositor = Address::generate(env); + + // Deploy Stellar Asset + let token_admin = Address::generate(env); + let token_id = env.register_stellar_asset_contract(token_admin.clone()); + + let token_client = TokenClient::new(env, &token_id); + let token_admin_client = StellarAssetClient::new(env, &token_id); + + // Mint initial balance to depositor + token_admin_client.mint(&depositor, &1_000); + + // Deploy earnings contract + let contract_id = env.register_contract(None, CreatorEarnings); + let client = CreatorEarningsClient::new(env, &contract_id); + + client.initialize(&admin, &token_id); + client.add_authorized(&depositor); + + ( + admin, + creator, + depositor, + client, + token_client, + token_admin_client, + ) +} + +#[test] +fn deposit_increases_balance() { + let env = Env::default(); + + let (_admin, creator, depositor, client, token_client, _) = + setup(&env); + + client.deposit(&depositor, &creator, &500); + + assert_eq!(client.balance(&creator), 500); + + // Contract custody verification + let contract_balance = + token_client.balance(&client.address); + assert_eq!(contract_balance, 500); +} + +#[test] +fn withdraw_reduces_balance_and_transfers_tokens() { + let env = Env::default(); + + let (_admin, creator, depositor, client, token_client, _) = + setup(&env); + + client.deposit(&depositor, &creator, &500); + + client.withdraw(&creator, &200); + + assert_eq!(client.balance(&creator), 300); + + // Creator should receive withdrawn tokens + assert_eq!(token_client.balance(&creator), 200); +} + +#[test] +#[should_panic(expected = "insufficient balance")] +fn withdraw_insufficient_balance_reverts() { + let env = Env::default(); + + let (_admin, creator, _depositor, client, _, _) = + setup(&env); + + client.withdraw(&creator, &100); +} + +#[test] +#[should_panic(expected = "not authorized")] +fn unauthorized_deposit_reverts() { + let env = Env::default(); + + let (_admin, creator, _depositor, client, _, token_admin_client) = + setup(&env); + + let unauthorized = Address::generate(&env); + + // Mint tokens to unauthorized user + token_admin_client.mint(&unauthorized, &500); + + // Unauthorized address not added via add_authorized + client.deposit(&unauthorized, &creator, &100); +} \ No newline at end of file