test(npos): tests de la aritmética constitucional 70/20/10 (meta-audit)

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) <noreply@anthropic.com>
This commit is contained in:
davidherranandClaude Opus 4.8 committed 2026-06-16 17:36:25 -05:00
1 parent 1f0122beda
commit fcf768b046
1 file changed
+78 -3
+78 -3
View File
@@ -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<CreditOf> 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<Runtime>;
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");
}
}