From fcf768b046cce944191420d19c1e640093f93d7f Mon Sep 17 00:00:00 2001 From: DavidHerran Date: Tue, 16 Jun 2026 17:36:25 -0500 Subject: [PATCH] =?UTF-8?q?test(npos):=20tests=20de=20la=20aritm=C3=A9tica?= =?UTF-8?q?=20constitucional=2070/20/10=20(meta-audit)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Meta-auditoría de mi propio trabajo (NPoS Paso A se shippeó en checkpoint sin verificación adversarial). Verificado contra el source de pallet-staking 43.0.0 que la infra es REALMENTE inerte (on_initialize=weight-only; on_finalize no-op con ActiveEra=None sin session) — cero regresión confirmada. Mejora aplicada: la aritmetica constitucional de OriginEraPayout + DealWithEraRemainder estaba escrita pero SIN tests (dormida, no se ejercita). Antes de encenderla en Paso C/D conviene probar el split: - era_remainder_split extraido a fn pura testeable (sin mock). - 5 tests: era_payout preserva tasa lineal + split 70/30 exacto + cero perdida; no overflow en era de 1 anio; remainder 20/10 del total; residual-al-subsidy (el dust del redondeo favorece el pool anti-clasista #16); invariante en montos reales. Sin bugs encontrados en Paso A. cargo +1.88. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/origin-chain-runtime/src/staking.rs | 81 +++++++++++++++++++++- 1 file changed, 78 insertions(+), 3 deletions(-) diff --git a/crates/origin-chain-runtime/src/staking.rs b/crates/origin-chain-runtime/src/staking.rs index 52b9e09..bc805fb 100644 --- a/crates/origin-chain-runtime/src/staking.rs +++ b/crates/origin-chain-runtime/src/staking.rs @@ -83,11 +83,19 @@ parameter_types! { /// `pool_covered=0` y el usuario pagaría el 100%. pub struct DealWithEraRemainder; +/// Reparte el `rest` de una era (30% de la emisión) en `(treasury, subsidy)` = +/// 2/3 + 1/3 = 20% + 10% del total. El residual (dust del redondeo entero) va al +/// subsidy — favorece el pool anti-clasista #16, cero pérdida. Función pura para +/// testear la invariante constitucional sin montar un mock runtime. +pub(crate) fn era_remainder_split(rest: u128) -> (u128, u128) { + let treasury = rest.saturating_mul(2) / 3; + let subsidy = rest.saturating_sub(treasury); // residual al subsidy + (treasury, subsidy) +} + impl OnUnbalanced for DealWithEraRemainder { fn on_nonzero_unbalanced(amount: CreditOf) { - let total = amount.peek(); - // 2/3 del rest = 20% del total de emisión → treasury. - let treasury_part = total.saturating_mul(2) / 3; + let (treasury_part, _) = era_remainder_split(amount.peek()); let (to_treasury, to_subsidy) = amount.split(treasury_part); // resolve deposita cada crédito en su cuenta vault; si falla, el crédito // se descarta (drop = burn) — nunca se duplica ni se pierde a un tercero. @@ -193,3 +201,70 @@ impl pallet_staking::Config for Runtime { type WeightInfo = pallet_staking::weights::SubstrateWeight; type RuntimeEvent = RuntimeEvent; } + +// ─── Tests de la aritmética constitucional (verificar 70/20/10 ANTES de +// activar NPoS en Paso C/D — hoy la emisión está dormida) ────────── + +#[cfg(test)] +mod tests { + use super::*; + use pallet_staking::EraPayout as _; + + const ERA_24H_MILLIS: u64 = 24 * 60 * 60 * 1_000; // 86_400_000 + + #[test] + fn era_payout_preserva_tasa_lineal_y_split_70_30() { + // Era de 24h = 14_400 bloques de 6s. + let (validator_payout, rest) = OriginEraPayout::era_payout(0, 0, ERA_24H_MILLIS); + let blocks = (ERA_24H_MILLIS / MILLISECS_PER_BLOCK) as u128; + assert_eq!(blocks, 14_400, "14_400 bloques en 24h a 6s"); + let total = EmissionBlockReward::get().saturating_mul(blocks); + // 70% validadores / 30% rest, y sin pérdida: validator + rest == total. + assert_eq!(validator_payout, total * 7_000 / 10_000, "70% a validadores"); + assert_eq!(validator_payout + rest, total, "cero pérdida: validator + rest == total"); + // El rest es exactamente el 30% (con el dust del redondeo capturado). + assert_eq!(rest, total - validator_payout); + } + + #[test] + fn era_payout_no_overflow_en_era_larga() { + // Era de 1 año (peor caso plausible): no debe panic ni saturar mal. + let one_year_millis: u64 = 365 * ERA_24H_MILLIS; + let (validator_payout, rest) = OriginEraPayout::era_payout(0, 0, one_year_millis); + let blocks = (one_year_millis / MILLISECS_PER_BLOCK) as u128; + let total = EmissionBlockReward::get().saturating_mul(blocks); + assert_eq!(validator_payout + rest, total, "sin overflow: invariante intacta"); + // ~5.256M bloques/año × 10 OGC = ~5.256e25, lejos del techo u128. + assert!(total < u128::MAX / 2); + } + + #[test] + fn remainder_split_20_10_del_total() { + // El rest = 30% del total. Su split 2/3+1/3 = 20%+10% del total. + // Ejemplo redondo: total=100 → rest=30 → treasury=20, subsidy=10. + let (treasury, subsidy) = era_remainder_split(30); + assert_eq!(treasury, 20, "2/3 del rest = 20% del total"); + assert_eq!(subsidy, 10, "1/3 del rest = 10% del total"); + assert_eq!(treasury + subsidy, 30, "cero pérdida"); + } + + #[test] + fn remainder_split_residual_al_subsidy_anti_clasista() { + // Con rest no divisible por 3, el dust va al SUBSIDY (#16), no al treasury. + let (treasury, subsidy) = era_remainder_split(31); + assert_eq!(treasury, 20, "floor(31*2/3) = 20"); + assert_eq!(subsidy, 11, "el residual (11) favorece el pool anti-clasista"); + assert_eq!(treasury + subsidy, 31, "cero pérdida"); + } + + #[test] + fn remainder_split_invariante_para_montos_reales() { + // Para un rest real (30% de una era de 24h) la invariante se mantiene. + let (_, rest) = OriginEraPayout::era_payout(0, 0, ERA_24H_MILLIS); + let (treasury, subsidy) = era_remainder_split(rest); + assert_eq!(treasury + subsidy, rest, "cero pérdida en montos reales"); + // treasury ≈ 2× subsidy (dust aparte). + assert!(treasury >= subsidy, "treasury (20%) >= subsidy (10%)"); + assert!(treasury <= subsidy.saturating_mul(2).saturating_add(2), "ratio ~2:1"); + } +}