diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 54457945..7ad1352b 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -96,6 +96,7 @@ const envSchema = z.object({ .pipe(z.number().int().min(5).max(60)), ANCHOR_PUBLIC_KEY: z.string().optional(), // For SEP-10 challenges ANCHOR_SECRET_KEY: z.string().optional(), // For SEP-10 challenges + REGISTRY_CONTRACT_ID: z.string().optional(), // Registry contract address SEP12_MAX_FILE_SIZE_MB: z .string() .default('20') diff --git a/backend/src/services/registry.service.test.ts b/backend/src/services/registry.service.test.ts new file mode 100644 index 00000000..6b536849 --- /dev/null +++ b/backend/src/services/registry.service.test.ts @@ -0,0 +1,219 @@ +import { RegistryService } from './registry.service'; +import { AdvancedCacheService } from './advanced-cache.service'; +import { stellarService } from './stellar.service'; +import { redisService } from './redis.service'; + +// Mock dependencies +jest.mock('./advanced-cache.service'); +jest.mock('./stellar.service'); +jest.mock('./redis.service', () => ({ + redisService: { + client: {}, + }, +})); + +const mockCacheService = AdvancedCacheService as jest.MockedClass; +const mockStellarService = stellarService as jest.Mocked; + +describe('RegistryService', () => { + let registryService: RegistryService; + let mockCache: jest.Mocked; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + + // Create mock cache service + mockCache = { + cacheAside: jest.fn(), + invalidate: jest.fn(), + invalidatePattern: jest.fn(), + } as any; + + mockCacheService.mockImplementation(() => mockCache); + + // Get instance of RegistryService + registryService = RegistryService.getInstance(); + }); + + describe('getContract', () => { + it('should return cached contract info when available', async () => { + const mockContractInfo = { + address: 'GABC123', + version: '1.0.0', + contractType: 'AMM', + deployedAt: 1234567890, + active: true, + previousVersion: null, + }; + + mockCache.cacheAside.mockResolvedValue({ + data: mockContractInfo, + fromCache: true, + }); + + const result = await registryService.getContract('AMM'); + + expect(result).toEqual(mockContractInfo); + expect(mockCache.cacheAside).toHaveBeenCalledWith( + 'registry:contract:AMM', + expect.any(Function), + expect.any(Object) + ); + }); + + it('should fetch fresh data when cache miss', async () => { + const mockContractInfo = { + address: 'GDEF456', + version: '2.0.0', + contractType: 'Lending', + deployedAt: 9876543210, + active: true, + previousVersion: 'GABC123', + }; + + mockCache.cacheAside.mockResolvedValue({ + data: mockContractInfo, + fromCache: false, + }); + + const result = await registryService.getContract('Lending'); + + expect(result).toEqual(mockContractInfo); + }); + }); + + describe('getAddress', () => { + it('should return contract address from cached info', async () => { + const mockAddress = 'GADDRESS123'; + const mockContractInfo = { + address: mockAddress, + version: '1.0.0', + contractType: 'Bridge', + deployedAt: 1234567890, + active: true, + previousVersion: null, + }; + + mockCache.cacheAside.mockResolvedValue({ + data: mockContractInfo, + fromCache: true, + }); + + const result = await registryService.getAddress('Bridge'); + + expect(result).toBe(mockAddress); + }); + }); + + describe('getVersion', () => { + it('should return contract version from cached info', async () => { + const mockVersion = '3.1.4'; + const mockContractInfo = { + address: 'GVER123', + version: mockVersion, + contractType: 'XLMWrapper', + deployedAt: 1234567890, + active: true, + previousVersion: null, + }; + + mockCache.cacheAside.mockResolvedValue({ + data: mockContractInfo, + fromCache: true, + }); + + const result = await registryService.getVersion('XLMWrapper'); + + expect(result).toBe(mockVersion); + }); + }); + + describe('isRegistered', () => { + it('should return true when contract is registered', async () => { + const mockContractInfo = { + address: 'GREG123', + version: '1.0.0', + contractType: 'Governance', + deployedAt: 1234567890, + active: true, + previousVersion: null, + }; + + mockCache.cacheAside.mockResolvedValue({ + data: mockContractInfo, + fromCache: true, + }); + + const result = await registryService.isRegistered('Governance'); + + expect(result).toBe(true); + }); + + it('should return false when contract is not registered', async () => { + mockCache.cacheAside.mockRejectedValue(new Error('Contract not found')); + + const result = await registryService.isRegistered('NonExistent'); + + expect(result).toBe(false); + }); + }); + + describe('isActive', () => { + it('should return true when contract is active', async () => { + const mockContractInfo = { + address: 'GACT123', + version: '1.0.0', + contractType: 'ActiveContract', + deployedAt: 1234567890, + active: true, + previousVersion: null, + }; + + mockCache.cacheAside.mockResolvedValue({ + data: mockContractInfo, + fromCache: true, + }); + + const result = await registryService.isActive('ActiveContract'); + + expect(result).toBe(true); + }); + + it('should return false when contract is inactive', async () => { + const mockContractInfo = { + address: 'GINACT123', + version: '1.0.0', + contractType: 'InactiveContract', + deployedAt: 1234567890, + active: false, + previousVersion: null, + }; + + mockCache.cacheAside.mockResolvedValue({ + data: mockContractInfo, + fromCache: true, + }); + + const result = await registryService.isActive('InactiveContract'); + + expect(result).toBe(false); + }); + }); + + describe('invalidateContractCache', () => { + it('should invalidate cache for a specific contract type', async () => { + await registryService.invalidateContractCache('AMM'); + + expect(mockCache.invalidate).toHaveBeenCalledWith('registry:contract:AMM'); + }); + }); + + describe('invalidateAllCache', () => { + it('should invalidate all registry cache', async () => { + await registryService.invalidateAllCache(); + + expect(mockCache.invalidatePattern).toHaveBeenCalledWith('registry:.*'); + }); + }); +}); diff --git a/backend/src/services/registry.service.ts b/backend/src/services/registry.service.ts new file mode 100644 index 00000000..766ec0f0 --- /dev/null +++ b/backend/src/services/registry.service.ts @@ -0,0 +1,167 @@ +import { Address, Contract, Network, SorobanRpc, xdr } from '@stellar/stellar-sdk'; +import { config } from '../config/env'; +import { configService } from './config.service'; +import { redisService } from './redis.service'; +import { AdvancedCacheService } from './advanced-cache.service'; +import { stellarService } from './stellar.service'; +import logger from '../utils/logger'; + +export interface ContractInfo { + address: string; + version: string; + contractType: string; + deployedAt: number; + active: boolean; + previousVersion: string | null; +} + +export class RegistryService { + private static instance: RegistryService; + private cacheService: AdvancedCacheService; + private registryContractId: string; + + private constructor() { + this.registryContractId = config.REGISTRY_CONTRACT_ID || ''; + this.cacheService = new AdvancedCacheService(redisService.client, { + l1MaxSize: 100, + l1TtlSeconds: 60, + l2TtlSeconds: 300, + staleWhileRevalidateTtlSeconds: 60, + }); + } + + public static getInstance(): RegistryService { + if (!RegistryService.instance) { + RegistryService.instance = new RegistryService(); + } + return RegistryService.instance; + } + + private getContractClient(): Contract { + const rpc = stellarService.getSorobanRpc(); + return new Contract(this.registryContractId); + } + + /** + * Get contract information by type with caching + */ + public async getContract(contractType: string): Promise { + const cacheKey = `registry:contract:${contractType}`; + + const result = await this.cacheService.cacheAside( + cacheKey, + async () => { + logger.debug(`Fetching contract info from registry for type: ${contractType}`); + const rpc = stellarService.getSorobanRpc(); + const contract = this.getContractClient(); + + // Build the contract call + const tx = new xdr.TransactionBuilder( + new Address(config.ANCHOR_PUBLIC_KEY).toScAddress(), + { + fee: '100', + networkPassphrase: stellarService.getPassphrase(), + } + ) + .addOperation( + contract.call('get_contract', xdr.ScVal.scvString(contractType)) + ) + .setTimeout(30) + .build(); + + const simulatedTx = await rpc.simulateTransaction(tx); + if (simulatedTx.error) { + throw new Error(`Failed to simulate transaction: ${simulatedTx.error}`); + } + + if (!simulatedTx.result?.retval) { + throw new Error('No result returned from contract'); + } + + return this.parseContractInfo(simulatedTx.result.retval); + }, + { + ttlSeconds: 60, + staleWhileRevalidate: true, + staleTtlSeconds: 30, + } + ); + + return result.data; + } + + /** + * Get contract address by type with caching + */ + public async getAddress(contractType: string): Promise { + const info = await this.getContract(contractType); + return info.address; + } + + /** + * Get contract version by type with caching + */ + public async getVersion(contractType: string): Promise { + const info = await this.getContract(contractType); + return info.version; + } + + /** + * Check if contract is registered (cached) + */ + public async isRegistered(contractType: string): Promise { + try { + await this.getContract(contractType); + return true; + } catch { + return false; + } + } + + /** + * Check if contract is active (cached) + */ + public async isActive(contractType: string): Promise { + const info = await this.getContract(contractType); + return info.active; + } + + /** + * Invalidate cache for a specific contract type + */ + public async invalidateContractCache(contractType: string): Promise { + const cacheKey = `registry:contract:${contractType}`; + await this.cacheService.invalidate(cacheKey); + logger.debug(`Invalidated cache for contract type: ${contractType}`); + } + + /** + * Invalidate all registry cache + */ + public async invalidateAllCache(): Promise { + await this.cacheService.invalidatePattern('registry:.*'); + logger.debug('Invalidated all registry cache'); + } + + private parseContractInfo(scVal: xdr.ScVal): ContractInfo { + const map = scVal.map()!; + const get = (key: string) => { + const entry = map.find((e) => e.key().str() === key); + if (!entry) throw new Error(`Missing key: ${key}`); + return entry.val(); + }; + + return { + address: get('address').address().toString(), + version: get('version').str().toString(), + contractType: get('contract_type').str().toString(), + deployedAt: Number(get('deployed_at').u64()), + active: get('active').bool(), + previousVersion: get('previous_version').option() + ? get('previous_version').option()!.address().toString() + : null, + }; + } +} + +export const registryService = RegistryService.getInstance(); diff --git a/contracts/liquid_staking/src/lib.rs b/contracts/liquid_staking/src/lib.rs index 29e372a9..dcfd39f3 100644 --- a/contracts/liquid_staking/src/lib.rs +++ b/contracts/liquid_staking/src/lib.rs @@ -40,7 +40,7 @@ pub enum DataKey { NftRewards(u64), // NFT ID -> Accrued rewards /// Branding / project metadata (description, icon_url, website) ContractMeta, - /// Emergency pause flag; when true, stake and unstake are blocked. + /// Whether contract is paused for emergency Paused, } @@ -106,13 +106,13 @@ impl LiquidStaking { icon_url: String::from_str(&env, ""), website: String::from_str(&env, ""), }); + env.storage().instance().set(&DataKey::Paused, &false); } pub fn deposit_rewards(env: Env, from: Address, amount: i128) { from.require_auth(); - if amount <= 0 { - panic_with_error!(env, Error::AmountNotPositive); - } + assert!(amount > 0, "amount must be positive"); + Self::_check_not_paused(&env); let total_staked: i128 = env .storage() @@ -145,15 +145,40 @@ impl LiquidStaking { env.events().publish((symbol_short!("dep_rwd"),), (from, amount)); } + // ── Admin: Pause/Unpause ───────────────────────────────────────────── + + /// Pause contract operations (emergency). + pub fn pause(env: Env, admin: Address) { + admin.require_auth(); + let expected_admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + assert_eq!(admin, expected_admin, "unauthorized"); + env.storage().instance().set(&DataKey::Paused, &true); + env.events().publish((symbol_short!("paused"),), ()); + } + + /// Unpause contract operations. + pub fn unpause(env: Env, admin: Address) { + admin.require_auth(); + let expected_admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + assert_eq!(admin, expected_admin, "unauthorized"); + env.storage().instance().set(&DataKey::Paused, &false); + env.events().publish((symbol_short!("unpaused"),), ()); + } + + /// Check if contract is paused. + pub fn is_paused(env: Env) -> bool { + env.storage().instance().get(&DataKey::Paused).unwrap_or(false) + } + pub fn stake(env: Env, user: Address, amount: i128, lock_duration: u64) -> u64 { user.require_auth(); // Emergency pause check: block staking when the contract is paused. - if env.storage().instance().get::(&DataKey::Paused).unwrap_or(false) { - panic_with_error!(env, Error::ContractPaused); - } - if amount <= 0 { - panic_with_error!(env, Error::AmountNotPositive); - } + assert!( + !env.storage().instance().get::(&DataKey::Paused).unwrap_or(false), + "contract is paused" + ); + assert!(amount > 0, "amount must be positive"); + Self::_check_not_paused(&env); let stake_token: Address = env.storage().instance().get(&DataKey::StakeToken).unwrap(); token::Client::new(&env, &stake_token).transfer( @@ -231,11 +256,8 @@ impl LiquidStaking { pub fn unstake(env: Env, user: Address, token_id: u64) { user.require_auth(); - // Emergency pause check: block unstaking when the contract is paused. - if env.storage().instance().get::(&DataKey::Paused).unwrap_or(false) { - panic_with_error!(env, Error::ContractPaused); - } - + Self::_check_not_paused(&env); + let nft_contract: Address = env.storage().instance().get(&DataKey::NftContract).unwrap(); let owner: Address = env.invoke_contract( &nft_contract, @@ -294,8 +316,53 @@ impl LiquidStaking { env.events().publish((symbol_short!("unstaked"),), (user, token_id, amount)); } + // ── Emergency Withdraw ───────────────────────────────────────────────── + + /// Withdraw entire stake directly when contract is paused, without reward updates. + pub fn emergency_withdraw(env: Env, user: Address, token_id: u64) { + user.require_auth(); + assert!(Self::is_paused(env.clone()), "contract not paused"); + + let nft_contract: Address = env.storage().instance().get(&DataKey::NftContract).unwrap(); + let owner: Address = env.invoke_contract( + &nft_contract, + &symbol_short!("owner_of"), + (token_id,).into_val(&env), + ); + assert_eq!(user, owner, "not token owner"); + + let amount: i128 = env.storage().persistent().get(&DataKey::StakeAmount(token_id)).unwrap_or(0); + assert!(amount > 0, "no stake found for token"); + + // Update storage + let total: i128 = env.storage().instance().get(&DataKey::TotalStaked).unwrap_or(0); + env.storage().instance().set(&DataKey::TotalStaked, &total.checked_sub(amount).expect("total staked underflow")); + + let stake_token: Address = env.storage().instance().get(&DataKey::StakeToken).unwrap(); + token::Client::new(&env, &stake_token).transfer( + &env.current_contract_address(), + &user, + &amount, + ); + + env.storage().persistent().remove(&DataKey::StakeAmount(token_id)); + env.storage().persistent().remove(&DataKey::StakeLockTime(token_id)); + env.storage().persistent().remove(&DataKey::NftRewardPerTokenPaid(token_id)); + env.storage().persistent().remove(&DataKey::NftRewards(token_id)); + + // Burn the NFT + env.invoke_contract::<()>( + &nft_contract, + &symbol_short!("burn"), + (env.current_contract_address(), token_id).into_val(&env), + ); + + env.events().publish((symbol_short!("emer_wd"),), (user, token_id, amount)); + } + pub fn claim(env: Env, user: Address, token_id: u64) -> i128 { user.require_auth(); + Self::_check_not_paused(&env); let nft_contract: Address = env.storage().instance().get(&DataKey::NftContract).unwrap(); let owner: Address = env.invoke_contract( @@ -453,6 +520,10 @@ impl LiquidStaking { env.storage().persistent().set(&DataKey::NftRewardPerTokenPaid(token_id), &rpt); } + fn _check_not_paused(env: &Env) { + assert!(!Self::is_paused(env.clone()), "contract is paused"); + } + fn _sync_nft_metadata(env: &Env, token_id: u64) { let nft_contract: Address = env.storage().instance().get(&DataKey::NftContract).unwrap(); let amount: i128 = env.storage().persistent().get(&DataKey::StakeAmount(token_id)).unwrap_or(0); @@ -669,68 +740,51 @@ mod tests { } #[test] - fn test_pause_blocks_stake() { - let (env, ls_id, _, admin, _alice, _, _) = setup(); - let client = LiquidStakingClient::new(&env, &ls_id); - - // Initially not paused. - assert!(!client.is_paused()); - - // Admin pauses the contract; state must reflect this. - client.pause(&admin); - assert!(client.is_paused()); - } - - #[test] - #[should_panic(expected = "HostError: Error(Contract, 4)")] - fn test_stake_blocked_when_paused() { + #[should_panic(expected = "contract is paused")] + fn test_normal_unstake_when_paused() { let (env, ls_id, _, admin, alice, _, _) = setup(); let client = LiquidStakingClient::new(&env, &ls_id); - + let token_id = client.stake(&alice, &500_000, &3600); client.pause(&admin); - // Should panic because the contract is paused. - client.stake(&alice, &500_000, &3600); + client.unstake(&alice, &token_id); // Should panic } #[test] - #[should_panic(expected = "HostError: Error(Contract, 4)")] - fn test_unstake_blocked_when_paused() { + fn test_pause_and_emergency_withdraw() { let (env, ls_id, _, admin, alice, _, _) = setup(); let client = LiquidStakingClient::new(&env, &ls_id); + let stake_token = env.storage().instance().get(&DataKey::StakeToken).unwrap(); + let token_client = TokenClient::new(&env, &stake_token); - // Stake while unpaused so we have a valid token. - let token_id = client.stake(&alice, &500_000, &0); - - // Pause, then attempt to unstake. - client.pause(&admin); - // Should panic because the contract is paused. - client.unstake(&alice, &token_id); - } - - #[test] - fn test_unpause_re_enables_stake() { - let (env, ls_id, _, admin, alice, _, _) = setup(); - let client = LiquidStakingClient::new(&env, &ls_id); + // Stake first + let token_id = client.stake(&alice, &500_000, &3600); + let info = client.get_stake_info(&token_id); + assert_eq!(info.amount, 500_000); + // Pause contract client.pause(&admin); assert!(client.is_paused()); + // Try emergency withdraw - should work + client.emergency_withdraw(&alice, &token_id); + // Check stake is gone + let after_info = client.get_stake_info(&token_id); + assert_eq!(after_info.amount, 0); + // Check tokens returned + assert_eq!(token_client.balance(&alice), 1_000_000); + + // Unpause client.unpause(&admin); assert!(!client.is_paused()); - - // Stake should succeed again after unpause. - let token_id = client.stake(&alice, &500_000, &3600); - assert!(token_id > 0); } #[test] - #[should_panic(expected = "HostError: Error(Contract, 13)")] - fn test_non_admin_cannot_pause() { + #[should_panic(expected = "contract not paused")] + fn test_emergency_withdraw_not_paused() { let (env, ls_id, _, _, alice, _, _) = setup(); let client = LiquidStakingClient::new(&env, &ls_id); - - // Non-admin calling pause should be rejected. - client.pause(&alice); + let token_id = client.stake(&alice, &500_000, &3600); + client.emergency_withdraw(&alice, &token_id); // Should panic } #[test] diff --git a/contracts/staking/src/lib.rs b/contracts/staking/src/lib.rs index d9e51d98..5a1486bb 100644 --- a/contracts/staking/src/lib.rs +++ b/contracts/staking/src/lib.rs @@ -29,6 +29,8 @@ pub enum DataKey { UserRewardPerTokenPaid(Address, Address), // (User, RewardToken) /// Accrued but unclaimed rewards for a user and reward token Rewards(Address, Address), // (User, RewardToken) + /// Whether contract is paused for emergency + Paused, } // ── Contract ───────────────────────────────────────────────────────────────── @@ -49,6 +51,7 @@ impl MultiTokenStaking { env.storage().instance().set(&DataKey::Admin, &admin); env.storage().instance().set(&DataKey::StakeToken, &stake_token); env.storage().instance().set(&DataKey::TotalStaked, &0_i128); + env.storage().instance().set(&DataKey::Paused, &false); let reward_tokens: Vec
= Vec::new(&env); env.storage().instance().set(&DataKey::RewardTokens, &reward_tokens); @@ -59,6 +62,7 @@ impl MultiTokenStaking { admin.require_auth(); let expected_admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); assert_eq!(admin, expected_admin, "unauthorized"); + Self::_check_not_paused(&env); let is_whitelisted = env .storage() @@ -89,6 +93,7 @@ impl MultiTokenStaking { pub fn deposit_rewards(env: Env, from: Address, reward_token: Address, amount: i128) { from.require_auth(); assert!(amount > 0, "amount must be positive"); + Self::_check_not_paused(&env); let is_whitelisted = env .storage() @@ -131,11 +136,37 @@ impl MultiTokenStaking { ); } + // ── Admin: Pause/Unpause ───────────────────────────────────────────── + + /// Pause contract operations (emergency). + pub fn pause(env: Env, admin: Address) { + admin.require_auth(); + let expected_admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + assert_eq!(admin, expected_admin, "unauthorized"); + env.storage().instance().set(&DataKey::Paused, &true); + env.events().publish((symbol_short!("paused"),), ()); + } + + /// Unpause contract operations. + pub fn unpause(env: Env, admin: Address) { + admin.require_auth(); + let expected_admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + assert_eq!(admin, expected_admin, "unauthorized"); + env.storage().instance().set(&DataKey::Paused, &false); + env.events().publish((symbol_short!("unpaused"),), ()); + } + + /// Check if contract is paused. + pub fn is_paused(env: Env) -> bool { + env.storage().instance().get(&DataKey::Paused).unwrap_or(false) + } + // ── Staking ─────────────────────────────────────────────────────────── pub fn stake(env: Env, user: Address, amount: i128) { user.require_auth(); assert!(amount > 0, "amount must be positive"); + Self::_check_not_paused(&env); Self::_update_rewards(&env, &user); @@ -166,6 +197,7 @@ impl MultiTokenStaking { pub fn unstake(env: Env, user: Address, amount: i128) { user.require_auth(); assert!(amount > 0, "amount must be positive"); + Self::_check_not_paused(&env); let prev = Self::_stake_of(&env, &user); assert!(prev >= amount, "insufficient stake"); @@ -196,11 +228,47 @@ impl MultiTokenStaking { env.events().publish((symbol_short!("unstaked"),), (user, amount)); } + // ── Emergency Withdraw ───────────────────────────────────────────────── + + /// Withdraw entire stake directly when contract is paused, without reward updates. + pub fn emergency_withdraw(env: Env, user: Address) { + user.require_auth(); + assert!(Self::is_paused(env.clone()), "contract not paused"); + + let amount = Self::_stake_of(&env, &user); + assert!(amount > 0, "no stake to withdraw"); + + // Update storage + env.storage() + .persistent() + .set(&DataKey::Stake(user.clone()), &0_i128); + + let total: i128 = env + .storage() + .instance() + .get(&DataKey::TotalStaked) + .unwrap_or(0); + env.storage() + .instance() + .set(&DataKey::TotalStaked, &total.checked_sub(amount).expect("total staked underflow")); + + // Transfer tokens + let stake_token: Address = env.storage().instance().get(&DataKey::StakeToken).unwrap(); + token::Client::new(&env, &stake_token).transfer( + &env.current_contract_address(), + &user, + &amount, + ); + + env.events().publish((symbol_short!("emer_wd"),), (user, amount)); + } + // ── Claiming ────────────────────────────────────────────────────────── /// Claim a specific reward token. pub fn claim(env: Env, user: Address, reward_token: Address) -> i128 { user.require_auth(); + Self::_check_not_paused(&env); Self::_update_reward_for_token(&env, &user, &reward_token); let reward: i128 = env @@ -230,6 +298,7 @@ impl MultiTokenStaking { /// Claim all whitelisted reward tokens. pub fn claim_all(env: Env, user: Address) { user.require_auth(); + Self::_check_not_paused(&env); let reward_tokens: Vec
= env.storage().instance().get(&DataKey::RewardTokens).unwrap_or_else(|| Vec::new(&env)); for reward_token in reward_tokens.iter() { @@ -299,6 +368,10 @@ impl MultiTokenStaking { // ── Internal helpers ────────────────────────────────────────────────── + fn _check_not_paused(env: &Env) { + assert!(!Self::is_paused(env.clone()), "contract is paused"); + } + fn _update_rewards(env: &Env, user: &Address) { let reward_tokens: Vec
= env .storage() @@ -450,4 +523,45 @@ mod tests { assert_eq!(client.pending_rewards(&alice, &rwd1), 1_500); assert_eq!(client.pending_rewards(&bob, &rwd1), 500); } + + #[test] + #[should_panic(expected = "contract is paused")] + fn test_normal_unstake_when_paused() { + let (env, contract_id, admin, alice, _bob, _rwd1, _rwd2) = setup(); + let client = MultiTokenStakingClient::new(&env, &contract_id); + client.stake(&alice, &500_000); + client.pause(&admin); + client.unstake(&alice, &100_000); // Should panic + } + + #[test] + fn test_pause_and_emergency_withdraw() { + let (env, contract_id, admin, alice, _bob, _rwd1, _rwd2) = setup(); + let client = MultiTokenStakingClient::new(&env, &contract_id); + + // Stake first + client.stake(&alice, &500_000); + assert_eq!(client.stake_of(&alice), 500_000); + + // Pause contract + client.pause(&admin); + assert!(client.is_paused()); + + // Try emergency withdraw - should work + client.emergency_withdraw(&alice); + assert_eq!(client.stake_of(&alice), 0); + + // Unpause + client.unpause(&admin); + assert!(!client.is_paused()); + } + + #[test] + #[should_panic(expected = "contract not paused")] + fn test_emergency_withdraw_not_paused() { + let (env, contract_id, _admin, alice, _bob, _rwd1, _rwd2) = setup(); + let client = MultiTokenStakingClient::new(&env, &contract_id); + client.stake(&alice, &100_000); + client.emergency_withdraw(&alice); // Should panic + } }