diff --git a/contracts/origin-staking/lib.rs b/contracts/origin-staking/lib.rs index d1a4365..d66539e 100644 --- a/contracts/origin-staking/lib.rs +++ b/contracts/origin-staking/lib.rs @@ -370,6 +370,12 @@ mod origin_staking { StakeKind::Reputation => self.total_staked_reputation = self.total_staked_reputation.saturating_sub(principal), } 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); // ── INTERACTIONS ── @@ -400,6 +406,14 @@ mod origin_staking { return Err(Error::UnauthorizedCaller); } 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 { return Err(Error::SlashExceedsBalance); } @@ -482,5 +496,29 @@ mod origin_staking { assert_eq!(c.withdraw(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)); + } } }