@@ -348,6 +348,119 @@ fn realize_yield_should_fold_accrued_into_principal_when_rate_increased() {
348348 } ) ;
349349}
350350
351+ /// Setup for the realize-yield equivalence test with a deliberately
352+ /// non-terminating rate. Alice stakes 100 HDX and Bob 70 HDX (so Alice's
353+ /// GIGAHDX is *not* the whole supply and her per-account conversion actually
354+ /// floor-rounds), then 100 HDX of yield is injected into the gigapot. The
355+ /// resulting rate is 270/170 = 27/17 — `Ratio::new` does not reduce, and 17
356+ /// divides neither 100·UNITS nor the pot evenly. Returns Alice and her GIGAHDX.
357+ fn stake_two_then_set_rounding_rate ( ) -> ( AccountId , Balance ) {
358+ init_gigahdx ( ) ;
359+ reset_giga_state_for_fixture ( ) ;
360+
361+ let alice: AccountId = ALICE . into ( ) ;
362+ let bob: AccountId = BOB . into ( ) ;
363+ fund ( & bob, 1_000 * UNITS ) ;
364+
365+ assert_ok ! ( GigaHdx :: giga_stake( RuntimeOrigin :: signed( alice. clone( ) ) , 100 * UNITS ) ) ;
366+ assert_ok ! ( GigaHdx :: giga_stake( RuntimeOrigin :: signed( bob. clone( ) ) , 70 * UNITS ) ) ;
367+ assert_ok ! ( Balances :: force_set_balance(
368+ RawOrigin :: Root . into( ) ,
369+ GigaHdx :: gigapot_account_id( ) ,
370+ 100 * UNITS ,
371+ ) ) ;
372+ // total_staked_hdx = TotalLocked(170) + gigapot(100) = 270; supply = 170.
373+ assert_rate_eq ( GigaHdx :: exchange_rate ( ) , 270 * UNITS , 170 * UNITS ) ;
374+
375+ let gigahdx = pallet_gigahdx:: Stakes :: < Runtime > :: get ( & alice)
376+ . expect ( "stake exists" )
377+ . gigahdx ;
378+ assert_eq ! ( gigahdx, 100 * UNITS ) ;
379+ ( alice, gigahdx)
380+ }
381+
382+ #[ test]
383+ fn realize_yield_should_match_direct_unstake_exactly_when_rate_causes_rounding ( ) {
384+ // Alice's 100·UNITS GIGAHDX at rate 27/17 converts to
385+ // floor(10^14 · 27 / 17) = 158_823_529_411_764 (remainder 12/17 — a real
386+ // floor-round, not a clean boundary). The three paths must still agree to
387+ // the atomic unit: realize_yield leaves total_staked_hdx and the supply
388+ // untouched, so all three evaluate the *same* floored conversion.
389+ let expected: Balance = 158_823_529_411_764 ;
390+ // Yield Alice consumes from the pot; the rest backs Bob's still-live stake.
391+ let alice_accrued: Balance = expected - 100 * UNITS ; // 58_823_529_411_764
392+ let pot_residual: Balance = 100 * UNITS - alice_accrued; // 41_176_470_588_236
393+
394+ // Scenario 1: unstake everything directly — payout folds in the yield
395+ // (100·UNITS principal + `alice_accrued` pulled from the gigapot).
396+ let x1 = {
397+ TestNet :: reset ( ) ;
398+ hydra_live_ext ( PATH_TO_SNAPSHOT ) . execute_with ( || {
399+ let ( alice, gigahdx) = stake_two_then_set_rounding_rate ( ) ;
400+
401+ assert_ok ! ( GigaHdx :: giga_unstake( RuntimeOrigin :: signed( alice. clone( ) ) , gigahdx) ) ;
402+
403+ let entry = only_pending_position ( & alice) ;
404+ assert_eq ! (
405+ Balances :: free_balance( GigaHdx :: gigapot_account_id( ) ) ,
406+ pot_residual,
407+ "only Alice's share leaves the pot; Bob's backing stays"
408+ ) ;
409+ entry. amount
410+ } )
411+ } ;
412+
413+ // Scenario 2: realize yield — accrued HDX is folded into the locked
414+ // principal, GIGAHDX and the rate left unchanged.
415+ let x2 = {
416+ TestNet :: reset ( ) ;
417+ hydra_live_ext ( PATH_TO_SNAPSHOT ) . execute_with ( || {
418+ let ( alice, _) = stake_two_then_set_rounding_rate ( ) ;
419+
420+ assert_ok ! ( GigaHdx :: realize_yield( RuntimeOrigin :: signed( alice. clone( ) ) ) ) ;
421+
422+ let stake = pallet_gigahdx:: Stakes :: < Runtime > :: get ( & alice) . expect ( "stake exists" ) ;
423+ assert_eq ! ( stake. hdx, locked_under_ghdx( & alice) , "realized principal fully locked" ) ;
424+ assert_eq ! (
425+ Balances :: free_balance( GigaHdx :: gigapot_account_id( ) ) ,
426+ pot_residual,
427+ "only Alice's share leaves the pot; Bob's backing stays"
428+ ) ;
429+ stake. hdx
430+ } )
431+ } ;
432+
433+ // Scenario 3: realize yield, then unstake everything — Alice's pot share
434+ // is already folded in, so the full payout comes from principal alone and
435+ // the pot is untouched by the unstake.
436+ let x3 = {
437+ TestNet :: reset ( ) ;
438+ hydra_live_ext ( PATH_TO_SNAPSHOT ) . execute_with ( || {
439+ let ( alice, gigahdx) = stake_two_then_set_rounding_rate ( ) ;
440+
441+ assert_ok ! ( GigaHdx :: realize_yield( RuntimeOrigin :: signed( alice. clone( ) ) ) ) ;
442+ assert_ok ! ( GigaHdx :: giga_unstake( RuntimeOrigin :: signed( alice. clone( ) ) , gigahdx) ) ;
443+
444+ let entry = only_pending_position ( & alice) ;
445+ assert_eq ! (
446+ Balances :: free_balance( GigaHdx :: gigapot_account_id( ) ) ,
447+ pot_residual,
448+ "only Alice's share leaves the pot; Bob's backing stays"
449+ ) ;
450+ entry. amount
451+ } )
452+ } ;
453+
454+ // The conversion really floor-rounds — otherwise the test is vacuous.
455+ assert_ne ! ( expected % UNITS , 0 , "rate must actually floor-round" ) ;
456+
457+ assert_eq ! ( x1, expected, "direct-unstake pending payout" ) ;
458+ assert_eq ! ( x2, expected, "realized locked principal" ) ;
459+ assert_eq ! ( x3, expected, "realize-then-unstake pending payout" ) ;
460+ assert_eq ! ( x1, x2, "realize_yield must match direct unstake" ) ;
461+ assert_eq ! ( x1, x3, "realize-then-unstake must match direct unstake" ) ;
462+ }
463+
351464#[ test]
352465fn unlock_should_release_lock_when_cooldown_elapsed ( ) {
353466 TestNet :: reset ( ) ;
0 commit comments