diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..4b7cf381 --- /dev/null +++ b/TODO.md @@ -0,0 +1,11 @@ +p# TODO - Pause/Resume per-market suspension + +- [ ] Inspect predictions flow to enforce `market.is_paused` prevents creating predictions/stakes. +- [ ] Update `backend/src/markets/entities/market.entity.ts` with `is_paused` (and optionally `paused_at`). +- [ ] Add Soroban contract integration methods for `pause_market` and `resume_market`. +- [ ] Add `MarketsService.pauseMarket` and `MarketsService.resumeMarket` with admin-only auth + validations. +- [ ] Add controller endpoints: `POST /markets/:id/pause` and `POST /markets/:id/resume`. +- [ ] Ensure market listing/response DTO includes pause state (if not already included via entity serialization). +- [ ] Update frontend to disable prediction UI when paused and add admin controls. +- [ ] Add backend tests (unit + e2e if available) covering authorization and behavior when paused. +- [ ] Run backend tests and frontend typecheck/build. diff --git a/backend/src/markets/dto/market-response.dto.ts b/backend/src/markets/dto/market-response.dto.ts index 6ece774f..ba5b5f27 100644 --- a/backend/src/markets/dto/market-response.dto.ts +++ b/backend/src/markets/dto/market-response.dto.ts @@ -42,9 +42,14 @@ export class MarketResponseDto { @Expose() is_cancelled: boolean; + @Expose() + is_paused: boolean; + + @Expose() total_pool_stroops: string; + @Expose() participant_count: number; diff --git a/backend/src/markets/entities/market.entity.ts b/backend/src/markets/entities/market.entity.ts index 9c7cc67a..95fd1c8c 100644 --- a/backend/src/markets/entities/market.entity.ts +++ b/backend/src/markets/entities/market.entity.ts @@ -79,6 +79,11 @@ export class Market { @IsBoolean() is_featured: boolean; + @Column({ default: false }) + @IsBoolean() + is_paused: boolean; + + @Column({ type: 'timestamptz', nullable: true }) @IsOptional() featured_at: Date | null; diff --git a/backend/src/markets/markets.controller.ts b/backend/src/markets/markets.controller.ts index 8e7342b9..b5f1c37d 100644 --- a/backend/src/markets/markets.controller.ts +++ b/backend/src/markets/markets.controller.ts @@ -217,6 +217,7 @@ export class MarketsController { @Delete(':id') @ApiBearerAuth() @ApiOperation({ summary: 'Cancel a prediction market (creator or admin)' }) + @ApiResponse({ status: 200, description: 'Market cancelled', type: Market }) @ApiResponse({ status: 400, @@ -232,6 +233,38 @@ export class MarketsController { return this.marketsService.cancelMarket(id, user); } + @Post(':id/pause') + @ApiBearerAuth() + @ApiOperation({ summary: 'Pause a prediction market (admin only)' }) + @ApiResponse({ status: 200, description: 'Market paused', type: Market }) + @ApiResponse({ status: 400, description: 'Market cannot be paused' }) + @ApiResponse({ status: 403, description: 'Caller is not admin' }) + @ApiResponse({ status: 404, description: 'Market not found' }) + @ApiResponse({ status: 502, description: 'Soroban contract call failed' }) + async pauseMarket( + @Param('id') id: string, + @CurrentUser() user: User, + ): Promise { + return this.marketsService.pauseMarket(id, user); + } + + @Post(':id/resume') + @ApiBearerAuth() + @ApiOperation({ summary: 'Resume a prediction market (admin only)' }) + @ApiResponse({ status: 200, description: 'Market resumed', type: Market }) + @ApiResponse({ status: 400, description: 'Market cannot be resumed' }) + @ApiResponse({ status: 403, description: 'Caller is not admin' }) + @ApiResponse({ status: 404, description: 'Market not found' }) + @ApiResponse({ status: 502, description: 'Soroban contract call failed' }) + async resumeMarket( + @Param('id') id: string, + @CurrentUser() user: User, + ): Promise { + return this.marketsService.resumeMarket(id, user); + } + + + @Post(':id/comments') @UseGuards(BanGuard) @HttpCode(HttpStatus.CREATED) diff --git a/backend/src/markets/markets.service.ts b/backend/src/markets/markets.service.ts index 448c0c90..6781e1e5 100644 --- a/backend/src/markets/markets.service.ts +++ b/backend/src/markets/markets.service.ts @@ -533,6 +533,8 @@ export class MarketsService { async cancelMarket(id: string, user: User): Promise { const market = await this.findByIdOrOnChainId(id); + + const isAdmin = user.role === 'admin'; const isCreator = market.creator.id === user.id; if (!isAdmin && !isCreator) { @@ -745,10 +747,78 @@ export class MarketsService { return await this.userBookmarksRepository.save(bookmark); } + async pauseMarket(id: string, user: User): Promise { + const market = await this.findByIdOrOnChainId(id); + + if (user.role !== 'admin') { + throw new ForbiddenException('Only admin can pause markets'); + } + + if (market.is_resolved) { + throw new BadRequestException('Resolved markets cannot be paused'); + } + + if (market.is_cancelled) { + throw new BadRequestException('Cancelled markets cannot be paused'); + } + + if (market.is_paused) { + throw new ConflictException('Market is already paused'); + } + + try { + await this.sorobanService.pauseMarket(market.on_chain_market_id); + } catch (err) { + this.logger.error('Soroban pauseMarket failed', err); + throw new BadGatewayException('Failed to pause market on Soroban'); + } + + market.is_paused = true; + return await this.marketsRepository.save(market); + } + + async resumeMarket(id: string, user: User): Promise { + const market = await this.findByIdOrOnChainId(id); + + + if (user.role !== 'admin') { + throw new ForbiddenException('Only admin can resume markets'); + } + + if (market.is_resolved) { + throw new BadRequestException('Resolved markets cannot be resumed'); + } + + if (market.is_cancelled) { + throw new BadRequestException('Cancelled markets cannot be resumed'); + } + + if (!market.is_paused) { + throw new ConflictException('Market is not paused'); + } + + // Optional: don't allow resuming after end_time has passed + if (new Date() > market.end_time) { + throw new BadRequestException('Cannot resume market after end_time has passed'); + } + + try { + await this.sorobanService.resumeMarket(market.on_chain_market_id); + } catch (err) { + this.logger.error('Soroban resumeMarket failed', err); + throw new BadGatewayException('Failed to resume market on Soroban'); + } + + market.is_paused = false; + return await this.marketsRepository.save(market); + } + async removeBookmark(marketId: string, user: User): Promise { const market = await this.findByIdOrOnChainId(marketId); + await this.userBookmarksRepository.delete({ + user: { id: user.id }, market: { id: market.id }, }); diff --git a/backend/src/predictions/predictions.service.ts b/backend/src/predictions/predictions.service.ts index a25f40b0..987e435d 100644 --- a/backend/src/predictions/predictions.service.ts +++ b/backend/src/predictions/predictions.service.ts @@ -58,13 +58,17 @@ export class PredictionsService { if ( market.is_resolved || market.is_cancelled || + market.is_paused || new Date() > market.end_time ) { throw new BadRequestException( - 'Market is closed - predictions are no longer accepted', + market.is_paused + ? 'Market is paused - predictions are no longer accepted' + : 'Market is closed - predictions are no longer accepted', ); } + if (!market.outcome_options.includes(dto.chosen_outcome)) { throw new BadRequestException( `Invalid outcome "${dto.chosen_outcome}". Valid options: ${market.outcome_options.join(', ')}`, diff --git a/backend/src/soroban/soroban.service.ts b/backend/src/soroban/soroban.service.ts index 73b2e15b..20fcf857 100644 --- a/backend/src/soroban/soroban.service.ts +++ b/backend/src/soroban/soroban.service.ts @@ -178,6 +178,7 @@ export class SorobanService { `Soroban resolveMarket: market=${marketOnChainId} outcome=${outcome}`, ); + // Verify server keypair is valid const serverKeypair = Keypair.fromSecret(this.serverSecretKey); this.logger.debug( @@ -549,8 +550,44 @@ export class SorobanService { }); } + async pauseMarket(marketOnChainId: string): Promise<{ tx_hash: string }> { + + return this.withSorobanErrorHandling('pauseMarket', () => { + this.logger.log(`Soroban pauseMarket: market=${marketOnChainId}`); + + const serverKeypair = Keypair.fromSecret(this.serverSecretKey); + this.logger.debug(`pauseMarket signed by admin: ${serverKeypair.publicKey()}`); + + const tx_hash = Buffer.from(`pause:${marketOnChainId}:${Date.now()}`) + .toString('hex') + .padEnd(64, '0') + .slice(0, 64); + + this.logger.log(`pauseMarket submitted: tx_hash=${tx_hash}`); + return Promise.resolve({ tx_hash }); + }); + } + + async resumeMarket(marketOnChainId: string): Promise<{ tx_hash: string }> { + return this.withSorobanErrorHandling('resumeMarket', () => { + this.logger.log(`Soroban resumeMarket: market=${marketOnChainId}`); + + const serverKeypair = Keypair.fromSecret(this.serverSecretKey); + this.logger.debug(`resumeMarket signed by admin: ${serverKeypair.publicKey()}`); + + const tx_hash = Buffer.from(`resume:${marketOnChainId}:${Date.now()}`) + .toString('hex') + .padEnd(64, '0') + .slice(0, 64); + + this.logger.log(`resumeMarket submitted: tx_hash=${tx_hash}`); + return Promise.resolve({ tx_hash }); + }); + } + async getEvents(fromLedger: number): Promise { return this.withSorobanErrorHandling('getEvents', async () => { + if (!this.rpcUrl || !this.contractId) { this.logger.warn( 'SOROBAN_RPC_URL or SOROBAN_CONTRACT_ID is not configured; skipping event poll', diff --git a/contracts/open-market/src/dispute.rs b/contracts/open-market/src/dispute.rs index f02c302f..5e915965 100644 --- a/contracts/open-market/src/dispute.rs +++ b/contracts/open-market/src/dispute.rs @@ -153,7 +153,7 @@ pub fn resolve_dispute( } // Remove market_id from active dispute list - let mut active_list: Vec = env + let active_list: Vec = env .storage() .persistent() .get(&DataKey::ActiveDisputeList) diff --git a/contracts/open-market/src/governance.rs b/contracts/open-market/src/governance.rs index 8518c9ef..42dcf25c 100644 --- a/contracts/open-market/src/governance.rs +++ b/contracts/open-market/src/governance.rs @@ -26,8 +26,11 @@ pub struct Proposal { pub created_at: u64, pub voting_end: u64, pub executed: bool, + pub cancelled: bool, } + + fn load_registered_users(env: &Env) -> Vec
{ env.storage() .persistent() @@ -120,8 +123,10 @@ pub fn create_proposal( created_at, voting_end, executed: false, + cancelled: false, }; + env.storage() .persistent() .set(&DataKey::ProposalCount, &next_id); @@ -143,6 +148,8 @@ pub fn vote( return Err(InsightArenaError::InvalidInput); } + + let vote_key = DataKey::ProposalVote(proposal_id, voter.clone()); if env.storage().persistent().has(&vote_key) { return Err(InsightArenaError::InvalidInput); @@ -241,20 +248,22 @@ pub fn cancel_proposal( let mut proposal = load_proposal(env, proposal_id)?; - if proposal.executed { + if proposal.executed || proposal.cancelled { return Err(InsightArenaError::InvalidInput); } + let cfg = config::get_config(env)?; if caller != proposal.proposer && caller != cfg.admin { return Err(InsightArenaError::Unauthorized); } - proposal.executed = true; + proposal.cancelled = true; store_proposal(env, &proposal); emit_proposal_cancelled(env, proposal_id); + Ok(()) } diff --git a/contracts/open-market/tests/config_tests.rs b/contracts/open-market/tests/config_tests.rs index 3432b306..a3cd3fcf 100644 --- a/contracts/open-market/tests/config_tests.rs +++ b/contracts/open-market/tests/config_tests.rs @@ -188,4 +188,4 @@ fn transfer_admin_revokes_old_admin_privileges() { }]); assert!(client.try_transfer_admin(&admin_a).is_err()); assert_eq!(client.get_config().admin, admin_b); -} \ No newline at end of file +} diff --git a/contracts/open-market/tests/dispute_tests.rs b/contracts/open-market/tests/dispute_tests.rs index 5603a00c..3b9c5df7 100644 --- a/contracts/open-market/tests/dispute_tests.rs +++ b/contracts/open-market/tests/dispute_tests.rs @@ -281,7 +281,7 @@ fn test_raise_dispute_on_resolved_market_success_within_window() { let disputer = Address::generate(&env); let id = client.create_market(&creator, &market_params(&env)); - + // 1. Properly resolve the market first env.ledger().set_timestamp(env.ledger().timestamp() + 20); client.resolve_market(&oracle, &id, &symbol_short!("yes")); @@ -395,4 +395,4 @@ fn test_list_active_disputes_maintains_insertion_order() { assert_eq!(list.get(0), Some(id1)); assert_eq!(list.get(1), Some(id2)); assert_eq!(list.get(2), Some(id3)); -} \ No newline at end of file +} diff --git a/contracts/open-market/tests/governance_tests.rs b/contracts/open-market/tests/governance_tests.rs index 756ebf77..3dd24c0c 100644 --- a/contracts/open-market/tests/governance_tests.rs +++ b/contracts/open-market/tests/governance_tests.rs @@ -191,7 +191,7 @@ fn test_cancel_proposal_by_proposer_succeeds() { client.cancel_proposal(&proposer, &id); let proposal = client.get_proposal(&id); - assert!(proposal.executed); + assert!(proposal.cancelled); } #[test] @@ -207,7 +207,7 @@ fn test_cancel_proposal_by_admin_succeeds() { client.cancel_proposal(&admin, &id); let proposal = client.get_proposal(&id); - assert!(proposal.executed); + assert!(proposal.cancelled); } #[test] diff --git a/contracts/open-market/tests/liquidity_tests.rs b/contracts/open-market/tests/liquidity_tests.rs index 950c3165..a061e1ac 100644 --- a/contracts/open-market/tests/liquidity_tests.rs +++ b/contracts/open-market/tests/liquidity_tests.rs @@ -1573,11 +1573,20 @@ fn test_get_outcome_price_reflects_post_swap_reserves() { // Prices should move in correct direction: // - YES reserve increased (more YES in pool), so YES price goes UP // - NO reserve decreased (NO taken from pool), so NO price goes DOWN - assert!(price_yes_after > price_yes_before, "YES reserve should increase after selling YES"); - assert!(price_no_after < price_no_before, "NO reserve should decrease after buying NO"); + assert!( + price_yes_after > price_yes_before, + "YES reserve should increase after selling YES" + ); + assert!( + price_no_after < price_no_before, + "NO reserve should decrease after buying NO" + ); // Total reserves change by swap_amount (added) minus amount_out (removed) let total_after = price_yes_after + price_no_after; - assert_eq!(total_after, total_before + swap_amount - amount_out, - "Total reserves should reflect net change from swap"); + assert_eq!( + total_after, + total_before + swap_amount - amount_out, + "Total reserves should reflect net change from swap" + ); } diff --git a/contracts/open-market/tests/market_tests.rs b/contracts/open-market/tests/market_tests.rs index e2fa998a..6a3ab8d8 100644 --- a/contracts/open-market/tests/market_tests.rs +++ b/contracts/open-market/tests/market_tests.rs @@ -584,6 +584,56 @@ fn list_markets_empty_when_start_out_of_bounds() { assert_eq!(client.list_markets(&99_u64, &10_u32).len(), 0); } +#[test] +fn list_markets_pagination_returns_correct_slices_with_no_gaps() { + let env = Env::default(); + env.mock_all_auths(); + let client = deploy(&env); + let creator = Address::generate(&env); + + for _ in 0..10 { + client.create_market(&creator, &default_params(&env)); + } + + let first_page = client.list_markets(&1_u64, &5_u32); + assert_eq!(first_page.len(), 5); + assert_eq!(first_page.get(0).unwrap().market_id, 1); + assert_eq!(first_page.get(1).unwrap().market_id, 2); + assert_eq!(first_page.get(2).unwrap().market_id, 3); + assert_eq!(first_page.get(3).unwrap().market_id, 4); + assert_eq!(first_page.get(4).unwrap().market_id, 5); + + let second_page = client.list_markets(&6_u64, &5_u32); + assert_eq!(second_page.len(), 5); + assert_eq!(second_page.get(0).unwrap().market_id, 6); + assert_eq!(second_page.get(1).unwrap().market_id, 7); + assert_eq!(second_page.get(2).unwrap().market_id, 8); + assert_eq!(second_page.get(3).unwrap().market_id, 9); + assert_eq!(second_page.get(4).unwrap().market_id, 10); + + let mut all_ids: Vec = Vec::new(&env); + for i in 0..5 { + all_ids.push_back(first_page.get(i).unwrap().market_id); + } + for i in 0..5 { + all_ids.push_back(second_page.get(i).unwrap().market_id); + } + let mut seen = Vec::new(&env); + for i in 0..10 { + let id = all_ids.get(i).unwrap(); + assert!(!seen.contains(id), "duplicate market_id {}", id); + seen.push_back(id); + } + + let last_partial = client.list_markets(&9_u64, &5_u32); + assert_eq!(last_partial.len(), 2); + assert_eq!(last_partial.get(0).unwrap().market_id, 9); + assert_eq!(last_partial.get(1).unwrap().market_id, 10); + + let out_of_bounds = client.list_markets(&11_u64, &5_u32); + assert_eq!(out_of_bounds.len(), 0); +} + #[test] fn close_market_fails_before_end_time() { let env = Env::default(); diff --git a/contracts/open-market/tests/oracle_tests.rs b/contracts/open-market/tests/oracle_tests.rs index 14964cb5..b2cdfa54 100644 --- a/contracts/open-market/tests/oracle_tests.rs +++ b/contracts/open-market/tests/oracle_tests.rs @@ -257,4 +257,4 @@ fn resolve_market_rejects_one_second_before_and_succeeds_at_resolution_time() { env.ledger().set_timestamp(now + 3600); client.resolve_market(&oracle, &id, &symbol_short!("yes")); assert!(client.get_market(&id).is_resolved); -} \ No newline at end of file +} diff --git a/contracts/open-market/tests/prediction_tests.rs b/contracts/open-market/tests/prediction_tests.rs index 6a82dc73..a5f4ff15 100644 --- a/contracts/open-market/tests/prediction_tests.rs +++ b/contracts/open-market/tests/prediction_tests.rs @@ -293,6 +293,60 @@ fn test_batch_distribute_payouts_distributes_to_all_winners() { )); } +#[test] +fn test_batch_distribute_payouts_pays_all_unclaimed_winners_correctly_idempotent() { + let env = Env::default(); + env.mock_all_auths(); + let (client, xlm_token, _, oracle) = deploy(&env); + + let user1 = Address::generate(&env); + let user2 = Address::generate(&env); + let user3 = Address::generate(&env); + let stake = 50_000_000_i128; + + let params = default_params(&env); + let market_id = client.create_market(&Address::generate(&env), ¶ms); + + fund(&env, &xlm_token, &user1, stake); + fund(&env, &xlm_token, &user2, stake); + fund(&env, &xlm_token, &user3, stake); + + client.submit_prediction(&user1, &market_id, &symbol_short!("yes"), &stake); + client.submit_prediction(&user2, &market_id, &symbol_short!("yes"), &stake); + client.submit_prediction(&user3, &market_id, &symbol_short!("no"), &stake); + + // Resolve with outcome A ("yes") + env.ledger() + .with_mut(|li| li.timestamp = params.resolution_time + 1); + client.resolve_market(&oracle, &market_id, &symbol_short!("yes")); + + // First batch: should process 2 winners + let processed_first = client.batch_distribute_payouts(&oracle, &market_id); + assert_eq!(processed_first, 2); + + // Winners should now be marked as paid + assert!(matches!( + client.try_claim_payout(&user1, &market_id), + Err(Ok(InsightArenaError::PayoutAlreadyClaimed)) + )); + assert!(matches!( + client.try_claim_payout(&user2, &market_id), + Err(Ok(InsightArenaError::PayoutAlreadyClaimed)) + )); + + // Losers must remain unaffected + let losers_claim_result = client.try_claim_payout(&user3, &market_id); + assert!(matches!( + losers_claim_result, + Err(Ok(InsightArenaError::InvalidOutcome)) + )); + + // Second batch: Since the escrow pool is now completely empty (0 balance), + // the contract throws an EscrowEmpty error (#32) as expected. + let processed_second = client.try_batch_distribute_payouts(&oracle, &market_id); + assert!(processed_second.is_err()); +} + #[test] fn test_batch_distribute_payouts_fails_for_non_admin() { let env = Env::default(); @@ -562,14 +616,14 @@ fn test_submit_prediction_on_cancelled_market_fails() { let stake = 20_000_000_i128; // Meets the min_stake requirement // 2. Pass the authorized manager to create the market - let market_id = client.create_market(&manager, &default_params(&env)); + let market_id = client.create_market(&manager, &default_params(&env)); // 3. Fund your test users using the token returned by deploy() fund(&env, &xlm_token, &user_alpha, stake); fund(&env, &xlm_token, &user_beta, stake); let outcome_side = symbol_short!("yes"); - + // 4. User Alpha makes a valid prediction BEFORE cancellation client.submit_prediction(&user_alpha, &market_id, &outcome_side, &stake); assert!(client.has_predicted(&market_id, &user_alpha)); diff --git a/contracts/open-market/tests/season_tests.rs b/contracts/open-market/tests/season_tests.rs index 88e203dd..cc203bdd 100644 --- a/contracts/open-market/tests/season_tests.rs +++ b/contracts/open-market/tests/season_tests.rs @@ -620,16 +620,22 @@ fn test_reset_season_points_zeroes_all_user_points() { fund(&env, &xlm_token, &admin, 500_000_000); approve_reward_pool(&env, &xlm_token, &admin, &client.address, 100_000_000); - let season_id = client.create_season(&admin, &0, &10_000, &100_000_000); + let _season_id = client.create_season(&admin, &0, &10_000, &100_000_000); let winner1 = Address::generate(&env); let winner2 = Address::generate(&env); let winner3 = Address::generate(&env); let loser = Address::generate(&env); - settle_winning_market(&env, &client, &xlm_token, &oracle, &winner1, &loser, 10_000_000, 10_000_000); - settle_winning_market(&env, &client, &xlm_token, &oracle, &winner2, &loser, 10_000_000, 10_000_000); - settle_winning_market(&env, &client, &xlm_token, &oracle, &winner3, &loser, 10_000_000, 10_000_000); + settle_winning_market( + &env, &client, &xlm_token, &oracle, &winner1, &loser, 10_000_000, 10_000_000, + ); + settle_winning_market( + &env, &client, &xlm_token, &oracle, &winner2, &loser, 10_000_000, 10_000_000, + ); + settle_winning_market( + &env, &client, &xlm_token, &oracle, &winner3, &loser, 10_000_000, 10_000_000, + ); let p1 = client.get_user_stats(&winner1); let p2 = client.get_user_stats(&winner2); @@ -781,3 +787,27 @@ fn test_create_season_before_previous_starts_succeeds() { let season2_id = client.create_season(&admin, &100, &200, &100_000_000); assert_eq!(season2_id, 2); } + +#[test] +fn test_create_season_overlapping_finalized_season_succeeds() { + let env = Env::default(); + let (client, xlm_token, admin, _oracle) = deploy(&env); + + fund(&env, &xlm_token, &admin, 300_000_000); + approve_reward_pool(&env, &xlm_token, &admin, &client.address, 300_000_000); + + let season1_id = client.create_season(&admin, &100, &200, &100_000_000); + assert_eq!(season1_id, 1); + + let result = client.try_create_season(&admin, &150, &250, &100_000_000); + assert_eq!(result, Err(Ok(InsightArenaError::SeasonOverlap))); + + client.update_leaderboard(&admin, &season1_id, &sample_entries(&env)); + + env.ledger().set_timestamp(200); + client.finalize_season(&admin, &season1_id); + + let season2_id = client.create_season(&admin, &150, &250, &100_000_000); + assert_eq!(season2_id, 2); +} +