diff --git a/contracts/vesting_contracts/src/lib.rs b/contracts/vesting_contracts/src/lib.rs index 02fa587..b5e1d75 100644 --- a/contracts/vesting_contracts/src/lib.rs +++ b/contracts/vesting_contracts/src/lib.rs @@ -94,6 +94,25 @@ pub enum DataKey { MetadataAnchor, VotingDelegate(Address), DelegatedBeneficiaries(Address), + SubAdminPool(Address), + MarketplaceLock(u64), + XLMAddress, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct SubAdminPool { + pub manager: Address, + pub asset: Address, + pub total_amount: i128, + pub distributed_amount: i128, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct MarketplaceLock { + pub marketplace: Address, + pub authorized_at: u64, } #[contracttype] @@ -116,6 +135,9 @@ pub enum AdminAction { // Anti-Dilution Configuration AntiDilutionConfig(u64), NetworkGrowthSnapshot(u64), + GrantManagerRights(Address, Address, i128), // Manager, Asset, Amount + RenewSchedule(u64, u64, i128), // VaultID, AdditionalDuration, AdditionalAmount + SetXLMAddress(Address), } #[contracttype] @@ -362,6 +384,25 @@ impl VestingContract { true, ); }, + AdminAction::GrantManagerRights(manager, asset, amount) => { + let pool = SubAdminPool { + manager: manager.clone(), + asset: asset.clone(), + total_amount: amount, + distributed_amount: 0, + }; + env.storage().instance().set(&DataKey::SubAdminPool(manager), &pool); + + // Transfer tokens from admin to contract to fund the pool + let admin = Self::get_admin(env.clone()); + token::Client::new(&env, &asset).transfer(&admin, &env.current_contract_address(), &amount); + }, + AdminAction::RenewSchedule(vault_id, duration, amount) => { + Self::do_renew_vault_direct(&env, vault_id, duration, amount); + }, + AdminAction::SetXLMAddress(xlm) => { + env.storage().instance().set(&DataKey::XLMAddress, &xlm); + }, _ => { // For other actions, no-op or extend as needed } @@ -976,7 +1017,20 @@ impl VestingContract { // Calculate and claim each asset in the basket for (i, allocation) in vault.allocations.iter().enumerate() { let vested_amount = Self::calculate_claimable_for_asset(&env, vault_id, &vault, i); - let claimable_amount = vested_amount - allocation.released_amount; + let mut claimable_amount = vested_amount - allocation.released_amount; + + // #90: XLM Minimum Reserve Check (2 XLM = 20,000,000 Stroops) + let xlm: Option
= env.storage().instance().get(&DataKey::XLMAddress); + if let Some(xlm_addr) = xlm { + if allocation.asset_id == xlm_addr { + let total_unreleased = allocation.total_amount - allocation.released_amount; + if total_unreleased <= 20_000_000 { + claimable_amount = 0; + } else if (total_unreleased - claimable_amount) < 20_000_000 { + claimable_amount = total_unreleased - 20_000_000; + } + } + } if claimable_amount > 0 { // Update the allocation's released amount @@ -1045,10 +1099,17 @@ impl VestingContract { env.storage().instance().set(&DataKey::VaultData(vault_id), &vault); - let token: Address = env.storage().instance().get(&DataKey::Token).expect("Token not set"); - let contract_addr = env.current_contract_address(); - token::Client::new(&env, &token).transfer(&contract_addr, &vault.owner, &claim_amount); - + // #90: XLM Minimum Reserve Check + let xlm: Option
= env.storage().instance().get(&DataKey::XLMAddress); + if let Some(xlm_addr) = xlm { + if allocation.asset_id == xlm_addr { + let total_left = allocation.total_amount - (allocation.released_amount + claim_amount); + if total_left < 20_000_000 { + panic!("Claim would leave insufficient XLM for gas (need 2 XLM reserve)"); + } + } + } + token::Client::new(&env, &allocation.asset_id) .transfer(&env.current_contract_address(), &vault.owner, &claim_amount); @@ -2635,6 +2696,72 @@ impl VestingContract { pub fn get_total_locked(env: Env) -> i128 { VestingContract::get_total_locked_value(&env) } + + // --- Marketplace Functions (#89) --- + + pub fn authorize_transfer_to_marketplace(env: Env, vault_id: u64, marketplace: Address) { + let vault = Self::get_vault_internal(&env, vault_id); + vault.owner.require_auth(); + if !vault.is_transferable { + panic!("Vault not transferable"); + } + let lock = MarketplaceLock { + marketplace, + authorized_at: env.ledger().timestamp(), + }; + env.storage().instance().set(&DataKey::MarketplaceLock(vault_id), &lock); + } + + pub fn complete_marketplace_transfer(env: Env, vault_id: u64, new_owner: Address) { + let lock: MarketplaceLock = env.storage().instance().get(&DataKey::MarketplaceLock(vault_id)).expect("Vault not authorized for marketplace"); + lock.marketplace.require_auth(); + + let mut vault = Self::get_vault_internal(&env, vault_id); + let old_owner = vault.owner.clone(); + + // Update owner + vault.owner = new_owner.clone(); + env.storage().instance().set(&DataKey::VaultData(vault_id), &vault); + + // Update indexes + Self::remove_user_vault_index(&env, &old_owner, vault_id); + Self::add_user_vault_index(&env, &new_owner, vault_id); + + // Clear lock + env.storage().instance().remove(&DataKey::MarketplaceLock(vault_id)); + + env.events().publish( + (Symbol::new(&env, "vault_marketplace_sold"), vault_id), + (old_owner, new_owner, lock.marketplace), + ); + } + + // --- Renewal Functions (#91) --- + + fn do_renew_vault_direct(env: &Env, vault_id: u64, additional_duration: u64, additional_amount: i128) { + let mut vault = Self::get_vault_internal(env, vault_id); + + // Find main asset (first one) + let mut allocation = vault.allocations.get(0).expect("Empty basket"); + let asset_id = allocation.asset_id.clone(); + + // Fund extra from admin + let admin = Self::get_admin(env.clone()); + token::Client::new(env, &asset_id).transfer(&admin, &env.current_contract_address(), &additional_amount); + + allocation.total_amount += additional_amount; + vault.allocations.set(0, allocation); + vault.end_time += additional_duration; + + env.storage().instance().set(&DataKey::VaultData(vault_id), &vault); + + env.events().publish((Symbol::new(env, "vault_renewed"), vault_id), (additional_duration, additional_amount)); + } + + pub fn renew_schedule(env: Env, vault_id: u64, additional_duration: u64, additional_amount: i128) { + Self::require_admin(&env); + Self::do_renew_vault_direct(&env, vault_id, additional_duration, additional_amount); + } } // Test modules temporarily disabled to allow iterative compilation while diff --git a/contracts/vesting_contracts/src/test.rs b/contracts/vesting_contracts/src/test.rs index 286c1b4..c4071db 100644 --- a/contracts/vesting_contracts/src/test.rs +++ b/contracts/vesting_contracts/src/test.rs @@ -1428,3 +1428,132 @@ fn test_full_happy_path_nominate_claim_finalise_new_owner_verified() { panic!("Expected Succeeded state"); } } +#[test] +fn test_sub_vault_delegation() { + let (env, _, client, admin, token) = setup(); + let sub_admin = Address::generate(&env); + let team_member = Address::generate(&env); + + // 1. Admin grants rights + // Note: We'll use the direct call for testing if allowed, + // but the implementation requires AdminProposal usually. + // In our tests mock_all_auths is on. + + // We need to bypass the "panic!("Admin actions must be executed via AdminProposal...")" + // if we call the regular public methods that are gated. + // Actually, GrantManagerRights is only in dispatch_admin_action in my last edit. + // So I should proprose it. + + let action = crate::AdminAction::GrantManagerRights(sub_admin.clone(), token.clone(), 50_000i128); + client.propose_admin_action(&admin, &action); + + // 2. Sub-admin creates vault + let now = env.ledger().timestamp(); + let vault_id = client.sub_admin_create_vault( + &sub_admin, + &team_member, + &10_000i128, + &now, + &(now + 1000), + &0i128, + &false, + &false, + &0u64, + &String::from_str(&env, "Team Lead's Vault") + ); + + assert_eq!(vault_id, 1); + let vault = client.get_vault(&vault_id); + assert_eq!(vault.owner, team_member); + assert_eq!(vault.delegate, Some(sub_admin.clone())); + + // 3. Sub-admin revokes vault + client.sub_admin_revoke_vault(&sub_admin, &vault_id); + let updated_vault = client.get_vault(&vault_id); + assert!(updated_vault.is_frozen); +} + +#[test] +fn test_marketplace_listing_and_sale() { + let (env, _, client, _admin, _) = setup(); + let beneficiary = Address::generate(&env); + let marketplace = Address::generate(&env); + let buyer = Address::generate(&env); + let now = env.ledger().timestamp(); + + let vault_id = client.create_vault_full( + &beneficiary, + &1000i128, + &now, + &(now + 1000), + &0i128, + &false, + &true, // must be transferable + &0u64 + ); + + // 1. Beneficiary authorizes marketplace + client.authorize_transfer_to_marketplace(&vault_id, &marketplace); + + // 2. Marketplace completes transfer to buyer + client.complete_marketplace_transfer(&vault_id, &buyer); + + let vault = client.get_vault(&vault_id); + assert_eq!(vault.owner, buyer); +} + +#[test] +#[should_panic(expected = "Claim would leave insufficient XLM for gas (need 2 XLM reserve)")] +fn test_xlm_gas_reserve() { + let (env, _, client, admin, xlm_token) = setup(); + let beneficiary = Address::generate(&env); + let now = env.ledger().timestamp(); + + // Set XLM address in contract + let action = crate::AdminAction::SetXLMAddress(xlm_token.clone()); + client.propose_admin_action(&admin, &action); + + // Create XLM vault with 5 XLM + let total_xlm = 50_000_000i128; // 5 XLM + let vault_id = client.create_vault_full( + &beneficiary, + &total_xlm, + &now, + &(now + 1000), + &0i128, + &false, + &false, + &0u64 + ); + + // Fast forward to end + env.ledger().set_timestamp(now + 1000); + + // Try to claim 4 XLM (leaving 1 XLM) - should fail + client.claim_tokens(&vault_id, &40_000_000i128); +} + +#[test] +fn test_vault_renewal() { + let (env, _, client, admin, _) = setup(); + let beneficiary = Address::generate(&env); + let now = env.ledger().timestamp(); + + let vault_id = client.create_vault_full( + &beneficiary, + &1000i128, + &now, + &(now + 1000), + &0i128, + &false, + &false, + &0u64 + ); + + // Renew: add 1000 seconds and 500 tokens + client.renew_schedule(&vault_id, &1000u64, &500i128); + + let vault = client.get_vault(&vault_id); + assert_eq!(vault.end_time, now + 2000); + assert_eq!(vault.allocations.get(0).unwrap().total_amount, 1500); +} diff --git a/target/.rustc_info.json b/target/.rustc_info.json index a5948a1..a198d36 100644 --- a/target/.rustc_info.json +++ b/target/.rustc_info.json @@ -1,3 +1 @@ -{"rustc_fingerprint":3924410247754035658,"outputs":{"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___.exe\nlib___.rlib\n___.dll\n___.dll\n___.lib\n___.dll\nC:\\Users\\User\\.rustup\\toolchains\\stable-x86_64-pc-windows-msvc\npacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"msvc\"\ntarget_family=\"windows\"\ntarget_feature=\"cmpxchg16b\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_feature=\"sse3\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"windows\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"pc\"\nwindows\n","stderr":""},"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.92.0 (ded5c06cf 2025-12-08)\nbinary: rustc\ncommit-hash: ded5c06cf21d2b93bffd5d884aa6e96934ee4234\ncommit-date: 2025-12-08\nhost: x86_64-pc-windows-msvc\nrelease: 1.92.0\nLLVM version: 21.1.3\n","stderr":""},"11652014622397750202":{"success":true,"status":"","code":0,"stdout":"___.wasm\nlib___.rlib\n___.wasm\nlib___.a\nC:\\Users\\User\\.rustup\\toolchains\\stable-x86_64-pc-windows-msvc\noff\n___\ndebug_assertions\npanic=\"abort\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"wasm32\"\ntarget_endian=\"little\"\ntarget_env=\"\"\ntarget_family=\"wasm\"\ntarget_feature=\"bulk-memory\"\ntarget_feature=\"multivalue\"\ntarget_feature=\"mutable-globals\"\ntarget_feature=\"nontrapping-fptoint\"\ntarget_feature=\"reference-types\"\ntarget_feature=\"sign-ext\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"unknown\"\ntarget_pointer_width=\"32\"\ntarget_vendor=\"unknown\"\n","stderr":"warning: dropping unsupported crate type `dylib` for target `wasm32-unknown-unknown`\n\nwarning: dropping unsupported crate type `proc-macro` for target `wasm32-unknown-unknown`\n\nwarning: 2 warnings emitted\n\n"}},"successes":{}} -{"rustc_fingerprint":11643027800151290351,"outputs":{"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.94.0 (4a4ef493e 2026-03-02)\nbinary: rustc\ncommit-hash: 4a4ef493e3a1488c6e321570238084b38948f6db\ncommit-date: 2026-03-02\nhost: aarch64-apple-darwin\nrelease: 1.94.0\nLLVM version: 21.1.8\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.dylib\nlib___.dylib\nlib___.a\nlib___.dylib\n/Users/mac/.rustup/toolchains/stable-aarch64-apple-darwin\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"aarch64\"\ntarget_endian=\"little\"\ntarget_env=\"\"\ntarget_family=\"unix\"\ntarget_feature=\"aes\"\ntarget_feature=\"crc\"\ntarget_feature=\"dit\"\ntarget_feature=\"dotprod\"\ntarget_feature=\"dpb\"\ntarget_feature=\"dpb2\"\ntarget_feature=\"fcma\"\ntarget_feature=\"fhm\"\ntarget_feature=\"flagm\"\ntarget_feature=\"fp16\"\ntarget_feature=\"frintts\"\ntarget_feature=\"jsconv\"\ntarget_feature=\"lor\"\ntarget_feature=\"lse\"\ntarget_feature=\"neon\"\ntarget_feature=\"paca\"\ntarget_feature=\"pacg\"\ntarget_feature=\"pan\"\ntarget_feature=\"pmuv3\"\ntarget_feature=\"ras\"\ntarget_feature=\"rcpc\"\ntarget_feature=\"rcpc2\"\ntarget_feature=\"rdm\"\ntarget_feature=\"sb\"\ntarget_feature=\"sha2\"\ntarget_feature=\"sha3\"\ntarget_feature=\"ssbs\"\ntarget_feature=\"vh\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"macos\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"apple\"\nunix\n","stderr":""}},"successes":{}} -{"rustc_fingerprint":12976345055248769160,"outputs":{"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.93.1 (01f6ddf75 2026-02-11)\nbinary: rustc\ncommit-hash: 01f6ddf7588f42ae2d7eb0a2f21d44e8e96674cf\ncommit-date: 2026-02-11\nhost: aarch64-apple-darwin\nrelease: 1.93.1\nLLVM version: 21.1.8\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.dylib\nlib___.dylib\nlib___.a\nlib___.dylib\n/Users/mac/.rustup/toolchains/1.93.1-aarch64-apple-darwin\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"aarch64\"\ntarget_endian=\"little\"\ntarget_env=\"\"\ntarget_family=\"unix\"\ntarget_feature=\"aes\"\ntarget_feature=\"crc\"\ntarget_feature=\"dit\"\ntarget_feature=\"dotprod\"\ntarget_feature=\"dpb\"\ntarget_feature=\"dpb2\"\ntarget_feature=\"fcma\"\ntarget_feature=\"fhm\"\ntarget_feature=\"flagm\"\ntarget_feature=\"fp16\"\ntarget_feature=\"frintts\"\ntarget_feature=\"jsconv\"\ntarget_feature=\"lor\"\ntarget_feature=\"lse\"\ntarget_feature=\"neon\"\ntarget_feature=\"paca\"\ntarget_feature=\"pacg\"\ntarget_feature=\"pan\"\ntarget_feature=\"pmuv3\"\ntarget_feature=\"ras\"\ntarget_feature=\"rcpc\"\ntarget_feature=\"rcpc2\"\ntarget_feature=\"rdm\"\ntarget_feature=\"sb\"\ntarget_feature=\"sha2\"\ntarget_feature=\"sha3\"\ntarget_feature=\"ssbs\"\ntarget_feature=\"vh\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"macos\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"apple\"\nunix\n","stderr":""}},"successes":{}} +{"rustc_fingerprint":7919731678241397688,"outputs":{"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.94.0 (4a4ef493e 2026-03-02)\nbinary: rustc\ncommit-hash: 4a4ef493e3a1488c6e321570238084b38948f6db\ncommit-date: 2026-03-02\nhost: x86_64-pc-windows-msvc\nrelease: 1.94.0\nLLVM version: 21.1.8\n","stderr":""}},"successes":{}} \ No newline at end of file