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:
1 parent
80bf7e5ddf
commit
db4c8a37d3
1 file changed
+38
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user