fix(ink/staking): slash() sin guard de estado → insolvencia (CRITICAL audit ultra)

Audit ultra 2026-07-04 (dimensión ink-contracts): slash() checaba solo
caller==slashing_authority, existencia y amount<=stake.amount — NUNCA el
stake.state. Y withdraw() ponía state=Withdrawable pero NO ceroaba stake.amount.
⇒ tras un withdraw, el slashing_authority podía slashear el stake retirado
(amount<=stake.amount pasaba con el principal ya devuelto), pagando el
treasury_share desde el pool de OTROS stakers (insolvencia + doble decremento
de total_staked). Alcanzable por el Tribunal (autoridad confiable), no cubierto
por FIX-1 (CEI) ni FIX-6 (fake APR).

Fix: (a) guard de estado en slash() — solo Active|Unbonding; Withdrawable/Slashed
→ InvalidState. (b) withdraw() cero stake.amount + rewards_accumulated tras
computar to_return. +test E2E (ink v6, set_contract_balance). Contrato 5 tests
verde. (No desplegado — fase futura; endurecido antes de que sea dinero real.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
davidherranandClaude Fable 5 committed 2026-07-04 14:55:31 -05:00
1 parent 80bf7e5ddf
commit db4c8a37d3
1 file changed
+38
+38
View File
@@ -370,6 +370,12 @@ mod origin_staking {
StakeKind::Reputation => self.total_staked_reputation = self.total_staked_reputation.saturating_sub(principal), StakeKind::Reputation => self.total_staked_reputation = self.total_staked_reputation.saturating_sub(principal),
} }
stake.state = StakeState::Withdrawable; stake.state = StakeState::Withdrawable;
// Audit 2026-07-04: cero el principal y los rewards YA devueltos, para
// que un `slash` posterior no lea un saldo stale (`amount <= stake.amount`
// pasaría con el principal ya transferido → pagaría desde el pool de
// otros stakers). Con amount=0, cualquier slash post-withdraw es no-op.
stake.amount = 0;
stake.rewards_accumulated = 0;
self.stakes.insert(stake_id, &stake); self.stakes.insert(stake_id, &stake);
// ── INTERACTIONS ── // ── INTERACTIONS ──
@@ -400,6 +406,14 @@ mod origin_staking {
return Err(Error::UnauthorizedCaller); return Err(Error::UnauthorizedCaller);
} }
let mut stake = self.stakes.get(stake_id).ok_or(Error::StakeNotFound)?; let mut stake = self.stakes.get(stake_id).ok_or(Error::StakeNotFound)?;
// Guard de estado (audit 2026-07-04): solo se slashea un stake ACTIVO
// o en UNBONDING. Un Withdrawable ya devolvió su principal al staker;
// un Slashed ya fue penalizado. Sin este guard, slashear un
// Withdrawable paga el treasury_share desde el pool de OTROS stakers
// (insolvencia + corrupción del total_staked por doble decremento).
if !matches!(stake.state, StakeState::Active | StakeState::Unbonding) {
return Err(Error::InvalidState);
}
if amount > stake.amount { if amount > stake.amount {
return Err(Error::SlashExceedsBalance); return Err(Error::SlashExceedsBalance);
} }
@@ -482,5 +496,29 @@ mod origin_staking {
assert_eq!(c.withdraw(999), Err(Error::StakeNotFound)); assert_eq!(c.withdraw(999), Err(Error::StakeNotFound));
assert_eq!(c.unbond_request(999), Err(Error::StakeNotFound)); assert_eq!(c.unbond_request(999), Err(Error::StakeNotFound));
} }
/// Audit 2026-07-04: `slash` no puede correr sobre un stake ya penalizado
/// (Slashed) — el guard de estado devuelve InvalidState ANTES de tocar
/// fondos. Sin él, un re-slash pagaría treasury_share desde el pool de
/// otros stakers. (El flujo Withdrawable completo es node-gated por el
/// transfer; aquí probamos la transición Slashed, alcanzable sin balance.)
#[ink::test]
fn slash_rejects_already_slashed_stake() {
let min = 100_000_000_000_000_000_000u128; // 100 OGC validator
let mut c = OriginStaking::default_chn3();
// Fondea el contrato para que el transfer del treasury_share no falle.
let contract = ink::env::test::callee();
ink::env::test::set_contract_balance(contract, U256::from(min * 4));
ink::env::test::set_value_transferred(U256::from(min));
let id = c.stake_tokens(StakeKind::Validator).expect("stake activo");
// Slash TOTAL → stake.amount=0, state=Slashed.
assert!(c.slash(id, min, [0u8; 32]).is_ok(), "primer slash pasa");
assert_eq!(c.stake(id).unwrap().state, StakeState::Slashed);
// Re-slash de un stake Slashed: rechazado por el guard de estado
// (antes: amount<=0 pasaba y hacía un transfer desde el pool ajeno).
assert_eq!(c.slash(id, 1, [0u8; 32]), Err(Error::InvalidState));
}
} }
} }