Skip to content

Commit de9a72f

Browse files
add realize yield
1 parent fdc444a commit de9a72f

12 files changed

Lines changed: 618 additions & 4 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

integration-tests/src/gigahdx.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,38 @@ fn giga_unstake_should_create_pending_position_when_called() {
316316
});
317317
}
318318

319+
#[test]
320+
fn realize_yield_should_fold_accrued_into_principal_when_rate_increased() {
321+
TestNet::reset();
322+
hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| {
323+
init_gigahdx();
324+
reset_giga_state_for_fixture();
325+
326+
let alice: AccountId = ALICE.into();
327+
assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS));
328+
329+
// Inject yield into the gigapot so rate = (100 + 100) / 100 = 2.
330+
assert_ok!(Balances::force_set_balance(
331+
RawOrigin::Root.into(),
332+
GigaHdx::gigapot_account_id(),
333+
100 * UNITS,
334+
));
335+
336+
let rate_before = GigaHdx::exchange_rate();
337+
let stake_before = pallet_gigahdx::Stakes::<Runtime>::get(&alice).expect("stake exists");
338+
assert_eq!(stake_before.hdx, 100 * UNITS);
339+
340+
assert_ok!(GigaHdx::realize_yield(RuntimeOrigin::signed(alice.clone())));
341+
342+
let stake_after = pallet_gigahdx::Stakes::<Runtime>::get(&alice).expect("stake exists");
343+
assert_eq!(stake_after.hdx, 200 * UNITS);
344+
assert_eq!(stake_after.gigahdx, stake_before.gigahdx, "gigahdx unchanged");
345+
assert_eq!(locked_under_ghdx(&alice), 200 * UNITS);
346+
assert_eq!(Balances::free_balance(GigaHdx::gigapot_account_id()), 0);
347+
assert_eq!(GigaHdx::exchange_rate(), rate_before, "exchange rate unchanged");
348+
});
349+
}
350+
319351
#[test]
320352
fn unlock_should_release_lock_when_cooldown_elapsed() {
321353
TestNet::reset();

pallets/gigahdx/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "pallet-gigahdx"
3-
version = "0.1.0"
3+
version = "0.1.1"
44
description = "Liquid-staking primitive on top of an EVM money market."
55
authors = ["GalacticCouncil"]
66
edition = "2021"
@@ -37,6 +37,7 @@ sp-runtime = { workspace = true, features = ["std"] }
3737
pallet-balances = { workspace = true, features = ["std"] }
3838
orml-traits = { workspace = true, features = ["std"] }
3939
orml-tokens = { workspace = true, features = ["std"] }
40+
proptest = { workspace = true }
4041

4142
[features]
4243
default = ["std"]
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Seeds for failure cases proptest has generated in the past. It is
2+
# automatically read and these particular cases re-run before any
3+
# novel cases are generated.
4+
#
5+
# It is recommended to check this file in to source control so that
6+
# everyone who runs the test benefits from these saved cases.
7+
cc 5c5f24cc46aaf315e4c96c8576ab2e68d2324771990ecc6f0f73b4d91abde09a # shrinks to ops = [AccrueYield { amount: 30376718729381 }, Stake { who: 0, amount: 1000000595531 }, Stake { who: 1, amount: 1000035632141 }]
8+
cc 009af515f5b09355d2e4eb70cdb893133d64ac2173877b500c142651be1f9d61 # shrinks to ops = [AccrueYield { amount: 1000000000000 }, Stake { who: 1, amount: 1000000000001 }, Stake { who: 0, amount: 1000000000000 }, RealizeYield { who: 0 }]
9+
cc ad55fb586f84e4dcf373f15108cbc59aa095517eba93aad82c0140489a351039 # shrinks to prefix = [AccrueYield { amount: 1000000000000 }, Stake { who: 1, amount: 1000000000000 }, RealizeYield { who: 1 }], seed = 1000000000001

pallets/gigahdx/src/benchmarking.rs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,8 @@ mod benches {
140140
set_dummy_pool::<T>();
141141

142142
let caller: T::AccountId = whitelisted_caller();
143-
let stake_amount: Balance = 100 * ONE;
143+
// Must clear legacy `pallet_staking::MinStake` (1_000 UNITS in the runtime).
144+
let stake_amount: Balance = 10_000 * ONE;
144145
assert_ok!(T::BenchmarkHelper::setup_legacy_staking_position(&caller, stake_amount));
145146

146147
#[extrinsic_call]
@@ -192,4 +193,29 @@ mod benches {
192193
assert_eq!(s.unstaking_count as u32, max - 1);
193194
assert!(s.hdx > 0);
194195
}
196+
197+
#[benchmark]
198+
fn realize_yield() {
199+
assert_ok!(T::BenchmarkHelper::register_assets());
200+
set_dummy_pool::<T>();
201+
202+
let caller: T::AccountId = whitelisted_caller();
203+
let amount: Balance = 100 * ONE;
204+
fund::<T>(&caller, amount.saturating_mul(10));
205+
206+
assert_ok!(Pallet::<T>::giga_stake(
207+
RawOrigin::Signed(caller.clone()).into(),
208+
amount,
209+
));
210+
211+
// Fund the gigapot so total_staked_hdx doubles → rate ≈ 2 → accrued ≈ amount.
212+
fund::<T>(&Pallet::<T>::gigapot_account_id(), amount);
213+
214+
#[extrinsic_call]
215+
realize_yield(RawOrigin::Signed(caller.clone()));
216+
217+
let s = Stakes::<T>::get(&caller).expect("stake recorded");
218+
assert_eq!(s.gigahdx, amount);
219+
assert!(s.hdx >= amount.saturating_mul(2));
220+
}
195221
}

pallets/gigahdx/src/lib.rs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,16 @@ pub mod pallet {
128128

129129
pub const STORAGE_VERSION: StorageVersion = StorageVersion::new(1);
130130

131+
/// Defensive tripwire bound for `realize_yield`. Aggregate solvency
132+
/// guarantees the gigapot covers all accrued yield; a *per-account*
133+
/// `realize_yield` can fall a few atomic units short purely from
134+
/// cross-user floor-rounding (one staker's clamped negative residual
135+
/// nudging another's rate up). Anything beyond this many atomic units is
136+
/// an accounting bug, not rounding — `debug_assert` panics so tests and
137+
/// fuzzing surface it; release still returns `GigapotInsufficient`.
138+
/// 1 µHDX ≫ any realistic rounding accumulation, ≪ any real shortfall.
139+
const MAX_GIGAPOT_ROUNDING_SHORTFALL: Balance = 1_000_000;
140+
131141
#[pallet::pallet]
132142
#[pallet::storage_version(STORAGE_VERSION)]
133143
pub struct Pallet<T>(_);
@@ -260,6 +270,13 @@ pub mod pallet {
260270
hdx_unlocked: Balance,
261271
gigahdx_received: Balance,
262272
},
273+
/// Accrued yield was moved from the gigapot into the caller's locked
274+
/// stake principal. `amount` is the HDX transferred and added to
275+
/// `Stakes[who].hdx`; `gigahdx` and the exchange rate are unchanged.
276+
YieldRealized {
277+
who: T::AccountId,
278+
amount: Balance,
279+
},
263280
}
264281

265282
#[pallet::error]
@@ -308,6 +325,9 @@ pub mod pallet {
308325
/// Some HDX is currently frozen (e.g. backing an active reward-eligible
309326
/// vote in `pallet-gigahdx-rewards`); release the freeze first.
310327
StakeFrozen,
328+
/// The gigapot lacks the HDX to cover the caller's accrued yield.
329+
/// Only reachable in a drained/floored state, not normal operation.
330+
GigapotInsufficient,
311331
}
312332

313333
#[pallet::call]
@@ -558,6 +578,60 @@ pub mod pallet {
558578
});
559579
Ok(())
560580
}
581+
582+
/// Realize the caller's accrued yield into their locked stake principal.
583+
///
584+
/// Moves the HDX value the caller's GIGAHDX has gained since it was last
585+
/// reconciled (`rate × gigahdx − Stakes[who].hdx`) from the gigapot into
586+
/// the caller's account, folds it into `Stakes[who].hdx`, and refreshes
587+
/// the lock. GIGAHDX balance and the exchange rate are unchanged. A
588+
/// caller with no accrued yield (or no stake) is a successful no-op.
589+
///
590+
/// Emits `YieldRealized` event when there was yield to realize.
591+
///
592+
#[pallet::call_index(6)]
593+
#[pallet::weight(T::WeightInfo::realize_yield())]
594+
#[transactional]
595+
pub fn realize_yield(origin: OriginFor<T>) -> DispatchResult {
596+
let who = ensure_signed(origin)?;
597+
598+
let stake = Stakes::<T>::get(&who).unwrap_or_default();
599+
let current_value =
600+
Self::calculate_hdx_amount_given_gigahdx(stake.gigahdx).map_err(|_| Error::<T>::Overflow)?;
601+
let accrued = current_value.saturating_sub(stake.hdx);
602+
if accrued == 0 {
603+
return Ok(());
604+
}
605+
606+
if T::NativeCurrency::transfer(
607+
&Self::gigapot_account_id(),
608+
&who,
609+
accrued,
610+
ExistenceRequirement::AllowDeath,
611+
)
612+
.is_err()
613+
{
614+
let gigapot = T::NativeCurrency::free_balance(&Self::gigapot_account_id());
615+
let shortfall = accrued.saturating_sub(gigapot);
616+
debug_assert!(
617+
shortfall <= MAX_GIGAPOT_ROUNDING_SHORTFALL,
618+
"realize_yield: gigapot short by {shortfall} (accrued {accrued}, gigapot {gigapot}) \
619+
— exceeds rounding tolerance, indicates an accounting bug"
620+
);
621+
return Err(Error::<T>::GigapotInsufficient.into());
622+
}
623+
624+
Stakes::<T>::try_mutate(&who, |maybe| -> Result<(), Error<T>> {
625+
let s = maybe.get_or_insert_with(StakeRecord::default);
626+
s.hdx = s.hdx.checked_add(accrued).ok_or(Error::<T>::Overflow)?;
627+
Ok(())
628+
})?;
629+
TotalLocked::<T>::mutate(|x| *x = x.saturating_add(accrued));
630+
Self::refresh_lock(&who)?;
631+
632+
Self::deposit_event(Event::YieldRealized { who, amount: accrued });
633+
Ok(())
634+
}
561635
}
562636

563637
impl<T: Config> Pallet<T> {

0 commit comments

Comments
 (0)