Skip to content

Commit 1e2dbfd

Browse files
Merge pull request #562 from michaelvic123/bug/sweep-idempotency
fix: make unclaimed winnings sweep idempotent
2 parents c0bd041 + 4fd2c0c commit 1e2dbfd

6 files changed

Lines changed: 129 additions & 0 deletions

File tree

contracts/predictify-hybrid/src/err.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ pub enum Error {
121121
DisputeFeeFailed = 409,
122122
/// Generic dispute subsystem error. Check dispute state and configuration.
123123
DisputeError = 410,
124+
/// Unclaimed winnings have already been swept for this market. Repeat sweeps are not allowed.
125+
SweepAlreadyDone = 411,
124126
/// Platform fee has already been collected from this market.
125127
FeeAlreadyCollected = 413,
126128
/// No fees are available to collect from this market.

contracts/predictify-hybrid/src/lib.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -691,6 +691,7 @@ impl PredictifyHybrid {
691691
min_pool_size,
692692
bet_deadline,
693693
dispute_window_seconds: dispute_window_seconds.unwrap_or(86400),
694+
winnings_swept: false,
694695
};
695696

696697
// Store the market
@@ -1883,6 +1884,11 @@ impl PredictifyHybrid {
18831884
return Err(Error::InvalidState);
18841885
}
18851886

1887+
// Idempotency guard: reject a repeat sweep so the treasury is never double-credited.
1888+
if market.winnings_swept {
1889+
return Err(Error::SweepAlreadyDone);
1890+
}
1891+
18861892
let fee_percent = crate::config::ConfigManager::get_config(&env)
18871893
.map(|cfg| cfg.fees.platform_fee_percentage)
18881894
.unwrap_or_else(|_| {
@@ -2024,6 +2030,8 @@ impl PredictifyHybrid {
20242030
Some(treasury)
20252031
};
20262032

2033+
// Mark this market as swept so a second call returns SweepAlreadyDone.
2034+
market.winnings_swept = true;
20272035
env.storage().persistent().set(&market_id, &market);
20282036
EventEmitter::emit_unclaimed_winnings_swept(
20292037
&env,

contracts/predictify-hybrid/src/monitoring.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,7 @@ impl ContractMonitor {
601601
min_pool_size: None,
602602
bet_deadline: 0,
603603
dispute_window_seconds: 86400,
604+
winnings_swept: false,
604605
})
605606
}
606607

contracts/predictify-hybrid/src/storage_layout_tests.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ fn create_test_market(env: &Env, admin: &Address) -> (Symbol, Market) {
8282
min_pool_size: None,
8383
bet_deadline: 0,
8484
dispute_window_seconds: 0,
85+
winnings_swept: false,
8586
};
8687

8788
(market_id, market)

contracts/predictify-hybrid/src/types.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1089,6 +1089,9 @@ pub struct Market {
10891089
pub bet_deadline: u64,
10901090
/// Dispute window in seconds after end_time. Payouts allowed only after end_time + this period (or dispute resolved).
10911091
pub dispute_window_seconds: u64,
1092+
/// Whether unclaimed winnings have already been swept for this market.
1093+
/// Set to true after the first successful sweep to prevent double-crediting the treasury.
1094+
pub winnings_swept: bool,
10921095
}
10931096

10941097
// ===== CLAIM INFO =====
@@ -1452,6 +1455,7 @@ impl Market {
14521455
min_pool_size: None,
14531456
bet_deadline: 0,
14541457
dispute_window_seconds: 86400, // 24h default
1458+
winnings_swept: false,
14551459
}
14561460
}
14571461

contracts/predictify-hybrid/src/unclaimed_winnings_timeout_tests.rs

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,3 +294,116 @@ fn test_delayed_resolution_has_fresh_claim_window() {
294294
.client()
295295
.sweep_unclaimed_winnings(&setup.admin, &setup.market_id, &true);
296296
}
297+
298+
#[test]
299+
#[should_panic(expected = "Error(Contract, #411)")]
300+
fn test_double_sweep_rejected() {
301+
// A second sweep on the same market must return SweepAlreadyDone (411).
302+
let setup = TimeoutSweepSetup::new();
303+
304+
setup
305+
.client()
306+
.set_global_claim_period(&setup.admin, &100u64);
307+
setup.client().set_treasury(&setup.admin, &setup.treasury);
308+
309+
setup.set_time(setup.end_time + 100);
310+
311+
// First sweep succeeds.
312+
let first = setup
313+
.client()
314+
.sweep_unclaimed_winnings(&setup.admin, &setup.market_id, &false);
315+
assert!(first > 0);
316+
317+
// Second sweep must panic with #411.
318+
setup
319+
.client()
320+
.sweep_unclaimed_winnings(&setup.admin, &setup.market_id, &false);
321+
}
322+
323+
#[test]
324+
#[should_panic(expected = "Error(Contract, #411)")]
325+
fn test_double_sweep_burn_rejected() {
326+
// Same guard applies when burn=true.
327+
let setup = TimeoutSweepSetup::new();
328+
329+
setup
330+
.client()
331+
.set_global_claim_period(&setup.admin, &100u64);
332+
333+
setup.set_time(setup.end_time + 100);
334+
335+
setup
336+
.client()
337+
.sweep_unclaimed_winnings(&setup.admin, &setup.market_id, &true);
338+
339+
// Second call must panic.
340+
setup
341+
.client()
342+
.sweep_unclaimed_winnings(&setup.admin, &setup.market_id, &true);
343+
}
344+
345+
#[test]
346+
fn test_claim_then_sweep_excludes_claimed_user() {
347+
// winner_1 claims before the sweep; the swept amount must not include their payout,
348+
// and the treasury must only receive the unclaimed portion.
349+
let setup = TimeoutSweepSetup::new();
350+
351+
setup
352+
.client()
353+
.set_global_claim_period(&setup.admin, &100u64);
354+
setup.client().set_treasury(&setup.admin, &setup.treasury);
355+
356+
// winner_1 claims while the window is still open.
357+
setup.set_time(setup.end_time + 50);
358+
setup
359+
.client()
360+
.claim_winnings(&setup.winner_1, &setup.market_id);
361+
362+
// Advance past the claim period.
363+
setup.set_time(setup.end_time + 100);
364+
let swept = setup
365+
.client()
366+
.sweep_unclaimed_winnings(&setup.admin, &setup.market_id, &false);
367+
368+
// Only winner_2's share should be swept (winner_1 already claimed).
369+
let treasury_balance = setup
370+
.client()
371+
.get_balance(&setup.treasury, &ReflectorAsset::Stellar)
372+
.amount;
373+
assert_eq!(treasury_balance, swept);
374+
375+
// Verify winner_1 is still marked claimed (their own claim, not the sweep).
376+
let market = setup.client().get_market(&setup.market_id).unwrap();
377+
assert!(market
378+
.claimed
379+
.get(setup.winner_1.clone())
380+
.map(|i| i.is_claimed())
381+
.unwrap_or(false));
382+
assert!(market
383+
.claimed
384+
.get(setup.winner_2.clone())
385+
.map(|i| i.is_claimed())
386+
.unwrap_or(false));
387+
388+
// The swept flag must be set.
389+
assert!(market.winnings_swept);
390+
}
391+
392+
#[test]
393+
fn test_sweep_sets_winnings_swept_flag() {
394+
// After a successful sweep the market's winnings_swept field must be true.
395+
let setup = TimeoutSweepSetup::new();
396+
397+
setup
398+
.client()
399+
.set_global_claim_period(&setup.admin, &100u64);
400+
setup.client().set_treasury(&setup.admin, &setup.treasury);
401+
setup.set_time(setup.end_time + 100);
402+
403+
setup
404+
.client()
405+
.sweep_unclaimed_winnings(&setup.admin, &setup.market_id, &false);
406+
407+
let market = setup.client().get_market(&setup.market_id).unwrap();
408+
assert!(market.winnings_swept);
409+
}

0 commit comments

Comments
 (0)