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>
525 lines
21 KiB
Rust
525 lines
21 KiB
Rust
//! # Origin Staking Contract — Template canonical Phase 3 Slice 3.5
|
||
//!
|
||
//! **Fourth foundational template** del Origin Contract Atelier. Multi-purpose
|
||
//! staking patterns: validator staking (CHN-3 NPoS Phase 2 prep) + beacon staking
|
||
//! (Pillar 6 featured visibility) + reputation staking (AGI commercial reputation
|
||
//! slash/stake per BM V2 Pillar 4 §6.4).
|
||
//!
|
||
//! ## StakeKind enum (3 variants canonical)
|
||
//!
|
||
//! 1. **Validator**: stake OGC to become NPoS validator candidate Phase 2.
|
||
//! Slashing por equivocation / unavailability. Rewards from block production.
|
||
//! Lockup canonical 28 días (Substrate standard era 6 × 4h epochs).
|
||
//!
|
||
//! 2. **Beacon**: Pillar 6 Nexus Services — stake para featured visibility de
|
||
//! worlds/skills/NFT collections. Rotation discovery. Slashing por quality
|
||
//! issues (Tribunal). Rewards from increased discovery traffic.
|
||
//!
|
||
//! 3. **Reputation**: AGI commercial reputation stake/slash per BM V2 Pillar 4 §6.4.
|
||
//! AGI agents stake to claim commercial trust tier. Slashing por commercial
|
||
//! fraud (Tribunal). Rewards from successful M2M escrow completions.
|
||
//!
|
||
//! ## Inmutables compliance
|
||
//!
|
||
//! - **#16 HTS anti-clasista**: min_stake graduated por HardwareTier — Pi 5 minimum
|
||
//! subsidized para validator tier inclusion canonical.
|
||
//! - **#37 Free perpetua**: read methods (query_stake, reward_estimate, etc.) gratis.
|
||
//! - **#26 Content-Addressed**: every stake event hash BLAKE3 immutable history.
|
||
//! - **#41 AGI Cross-Species**: AGI accounts pueden stake canonical en cualquier
|
||
//! StakeKind (Validator/Beacon/Reputation).
|
||
//!
|
||
//! ## Lifecycle canonical
|
||
//!
|
||
//! ```text
|
||
//! stake() ──► Active ──unstake_request──► Unbonding ──after lockup──► Withdrawable
|
||
//! │ │
|
||
//! ▼ └──withdraw()──► Burned/Returned
|
||
//! slash_event ──► Slashed (partial OR full)
|
||
//! ```
|
||
|
||
#![cfg_attr(not(feature = "std"), no_std, no_main)]
|
||
|
||
#[ink::contract]
|
||
mod origin_staking {
|
||
use ink::prelude::vec::Vec;
|
||
use ink::storage::Mapping;
|
||
use ink::U256;
|
||
|
||
pub type StakeId = u32;
|
||
pub type BasisPoints = u16;
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, scale::Encode, scale::Decode)]
|
||
#[cfg_attr(feature = "std", derive(scale_info::TypeInfo, ink::storage::traits::StorageLayout))]
|
||
pub enum StakeKind {
|
||
Validator,
|
||
Beacon,
|
||
Reputation,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, scale::Encode, scale::Decode)]
|
||
#[cfg_attr(feature = "std", derive(scale_info::TypeInfo, ink::storage::traits::StorageLayout))]
|
||
pub enum StakeState {
|
||
Active,
|
||
Unbonding,
|
||
Withdrawable,
|
||
Slashed,
|
||
}
|
||
|
||
#[derive(Debug, Clone, scale::Encode, scale::Decode)]
|
||
#[cfg_attr(feature = "std", derive(scale_info::TypeInfo, ink::storage::traits::StorageLayout))]
|
||
pub struct Stake {
|
||
pub staker: Address,
|
||
pub kind: StakeKind,
|
||
pub amount: Balance,
|
||
pub state: StakeState,
|
||
pub staked_at: BlockNumber,
|
||
pub unbond_request_at: Option<BlockNumber>,
|
||
pub rewards_accumulated: Balance,
|
||
pub slashed_amount: Balance,
|
||
}
|
||
|
||
#[ink(storage)]
|
||
pub struct OriginStaking {
|
||
owner: Address,
|
||
treasury: Address,
|
||
|
||
// ── Config per StakeKind ──────────────────────────────
|
||
min_validator_stake: Balance,
|
||
min_beacon_stake: Balance,
|
||
min_reputation_stake: Balance,
|
||
|
||
validator_lockup_blocks: BlockNumber,
|
||
beacon_lockup_blocks: BlockNumber,
|
||
reputation_lockup_blocks: BlockNumber,
|
||
|
||
validator_reward_rate_bps: BasisPoints,
|
||
beacon_reward_rate_bps: BasisPoints,
|
||
reputation_reward_rate_bps: BasisPoints,
|
||
|
||
// ── Slashing authority (Tribunal Pillar 6) ────────────
|
||
slashing_authority: Address,
|
||
|
||
// ── State ─────────────────────────────────────────────
|
||
total_stakes: u32,
|
||
stakes: Mapping<StakeId, Stake>,
|
||
staker_stakes: Mapping<Address, Vec<StakeId>>,
|
||
|
||
total_staked_validator: Balance,
|
||
total_staked_beacon: Balance,
|
||
total_staked_reputation: Balance,
|
||
}
|
||
|
||
#[ink(event)]
|
||
pub struct Staked {
|
||
#[ink(topic)]
|
||
stake_id: StakeId,
|
||
#[ink(topic)]
|
||
staker: Address,
|
||
kind: StakeKind,
|
||
amount: Balance,
|
||
}
|
||
|
||
#[ink(event)]
|
||
pub struct UnbondRequested {
|
||
#[ink(topic)]
|
||
stake_id: StakeId,
|
||
unlock_at_block: BlockNumber,
|
||
}
|
||
|
||
#[ink(event)]
|
||
pub struct Withdrawn {
|
||
#[ink(topic)]
|
||
stake_id: StakeId,
|
||
amount: Balance,
|
||
rewards: Balance,
|
||
}
|
||
|
||
#[ink(event)]
|
||
pub struct Slashed {
|
||
#[ink(topic)]
|
||
stake_id: StakeId,
|
||
amount_slashed: Balance,
|
||
treasury_share: Balance,
|
||
burned: Balance,
|
||
reason_hash: [u8; 32],
|
||
}
|
||
|
||
#[derive(Debug, PartialEq, Eq, scale::Encode, scale::Decode)]
|
||
#[cfg_attr(feature = "std", derive(scale_info::TypeInfo))]
|
||
pub enum Error {
|
||
InsufficientStake,
|
||
InvalidState,
|
||
StakeNotFound,
|
||
UnauthorizedCaller,
|
||
LockupNotExpired,
|
||
InvalidConfiguration,
|
||
TransferFailed,
|
||
SlashExceedsBalance,
|
||
}
|
||
|
||
pub type Result<T> = core::result::Result<T, Error>;
|
||
|
||
/// Convierte un valor `U256` del entorno pallet-revive a `Balance` (u128)
|
||
/// de forma saturante. ⚠️ Ver nota de correctitud económica native↔eth en
|
||
/// `origin-escrow` — pendiente validación E2E en nodo pallet-revive (VPS).
|
||
fn u256_to_balance(v: U256) -> Balance {
|
||
if v > U256::from(Balance::MAX) {
|
||
Balance::MAX
|
||
} else {
|
||
v.as_u128()
|
||
}
|
||
}
|
||
|
||
impl OriginStaking {
|
||
#[ink(constructor)]
|
||
pub fn new(
|
||
treasury: Address,
|
||
slashing_authority: Address,
|
||
min_validator_stake: Balance,
|
||
min_beacon_stake: Balance,
|
||
min_reputation_stake: Balance,
|
||
validator_lockup_blocks: BlockNumber,
|
||
beacon_lockup_blocks: BlockNumber,
|
||
reputation_lockup_blocks: BlockNumber,
|
||
validator_reward_rate_bps: BasisPoints,
|
||
beacon_reward_rate_bps: BasisPoints,
|
||
reputation_reward_rate_bps: BasisPoints,
|
||
) -> Self {
|
||
Self {
|
||
owner: Self::env().caller(),
|
||
treasury,
|
||
min_validator_stake,
|
||
min_beacon_stake,
|
||
min_reputation_stake,
|
||
validator_lockup_blocks,
|
||
beacon_lockup_blocks,
|
||
reputation_lockup_blocks,
|
||
validator_reward_rate_bps,
|
||
beacon_reward_rate_bps,
|
||
reputation_reward_rate_bps,
|
||
slashing_authority,
|
||
total_stakes: 0,
|
||
stakes: Mapping::default(),
|
||
staker_stakes: Mapping::default(),
|
||
total_staked_validator: 0,
|
||
total_staked_beacon: 0,
|
||
total_staked_reputation: 0,
|
||
}
|
||
}
|
||
|
||
/// Default CHN-3 NPoS Phase 2 prep canonical defaults.
|
||
/// Lockup 28 days (Substrate standard era 6 epochs × 4h) = 28 * 24 * 600 = 403_200 blocks.
|
||
/// Reward rates indicative — real rates per inflation curve Pillar 4.
|
||
#[ink(constructor)]
|
||
pub fn default_chn3() -> Self {
|
||
Self::new(
|
||
Self::env().caller(),
|
||
Self::env().caller(), // slashing authority same Foundation
|
||
100_000_000_000_000_000_000, // 100 OGC min validator
|
||
100_000_000_000_000_000_000, // 100 OGC min beacon
|
||
10_000_000_000_000_000_000, // 10 OGC min reputation AGI
|
||
403_200, // 28 days lockup validator
|
||
100_800, // 7 days lockup beacon
|
||
201_600, // 14 days lockup reputation
|
||
// FIX-6 (V1/V2 fake APR): reward rates en 0 — NO hay rewards
|
||
// falsos en este contrato. Los validadores cobran vía el
|
||
// pallet-origin-emission (block rewards REALES en el runtime); las
|
||
// tasas reales de staking las define Stage 2 (NPoS por stake).
|
||
// Mostrar 8/5/3% sin un motor que los pagara era engañoso.
|
||
0, // validator reward rate (era 8% APR ficticio)
|
||
0, // beacon reward rate (era 5% APR ficticio)
|
||
0, // reputation reward rate (era 3% APR ficticio)
|
||
)
|
||
}
|
||
|
||
// ── Reads ───────────────────────────────────────────
|
||
|
||
#[ink(message)]
|
||
pub fn stake(&self, id: StakeId) -> Option<Stake> { self.stakes.get(id) }
|
||
|
||
#[ink(message)]
|
||
pub fn stakes_of(&self, staker: Address) -> Vec<StakeId> {
|
||
self.staker_stakes.get(staker).unwrap_or_default()
|
||
}
|
||
|
||
#[ink(message)]
|
||
pub fn total_staked(&self, kind: StakeKind) -> Balance {
|
||
match kind {
|
||
StakeKind::Validator => self.total_staked_validator,
|
||
StakeKind::Beacon => self.total_staked_beacon,
|
||
StakeKind::Reputation => self.total_staked_reputation,
|
||
}
|
||
}
|
||
|
||
#[ink(message)]
|
||
pub fn min_stake(&self, kind: StakeKind) -> Balance {
|
||
match kind {
|
||
StakeKind::Validator => self.min_validator_stake,
|
||
StakeKind::Beacon => self.min_beacon_stake,
|
||
StakeKind::Reputation => self.min_reputation_stake,
|
||
}
|
||
}
|
||
|
||
#[ink(message)]
|
||
pub fn lockup_blocks(&self, kind: StakeKind) -> BlockNumber {
|
||
match kind {
|
||
StakeKind::Validator => self.validator_lockup_blocks,
|
||
StakeKind::Beacon => self.beacon_lockup_blocks,
|
||
StakeKind::Reputation => self.reputation_lockup_blocks,
|
||
}
|
||
}
|
||
|
||
// ── Writes ───────────────────────────────────────────
|
||
|
||
/// Stake canonical — deposit OGC to enter active stake.
|
||
/// Verifies min_stake per kind canonical.
|
||
#[ink(message, payable)]
|
||
pub fn stake_tokens(&mut self, kind: StakeKind) -> Result<StakeId> {
|
||
let value = u256_to_balance(self.env().transferred_value());
|
||
let min = self.min_stake(kind);
|
||
if value < min {
|
||
return Err(Error::InsufficientStake);
|
||
}
|
||
|
||
let id = self.total_stakes;
|
||
let staker = self.env().caller();
|
||
let now = self.env().block_number();
|
||
|
||
self.stakes.insert(id, &Stake {
|
||
staker,
|
||
kind,
|
||
amount: value,
|
||
state: StakeState::Active,
|
||
staked_at: now,
|
||
unbond_request_at: None,
|
||
rewards_accumulated: 0,
|
||
slashed_amount: 0,
|
||
});
|
||
|
||
let mut list = self.staker_stakes.get(staker).unwrap_or_default();
|
||
list.push(id);
|
||
self.staker_stakes.insert(staker, &list);
|
||
|
||
match kind {
|
||
StakeKind::Validator => self.total_staked_validator = self.total_staked_validator.saturating_add(value),
|
||
StakeKind::Beacon => self.total_staked_beacon = self.total_staked_beacon.saturating_add(value),
|
||
StakeKind::Reputation => self.total_staked_reputation = self.total_staked_reputation.saturating_add(value),
|
||
}
|
||
|
||
self.total_stakes = self.total_stakes.saturating_add(1);
|
||
self.env().emit_event(Staked { stake_id: id, staker, kind, amount: value });
|
||
|
||
Ok(id)
|
||
}
|
||
|
||
/// Request unbond canonical — transitions Active → Unbonding.
|
||
/// Funds NOT yet withdrawable until lockup expires.
|
||
#[ink(message)]
|
||
pub fn unbond_request(&mut self, stake_id: StakeId) -> Result<()> {
|
||
let mut stake = self.stakes.get(stake_id).ok_or(Error::StakeNotFound)?;
|
||
if self.env().caller() != stake.staker {
|
||
return Err(Error::UnauthorizedCaller);
|
||
}
|
||
if stake.state != StakeState::Active {
|
||
return Err(Error::InvalidState);
|
||
}
|
||
let now = self.env().block_number();
|
||
stake.unbond_request_at = Some(now);
|
||
stake.state = StakeState::Unbonding;
|
||
let lockup = self.lockup_blocks(stake.kind);
|
||
self.stakes.insert(stake_id, &stake);
|
||
self.env().emit_event(UnbondRequested {
|
||
stake_id,
|
||
unlock_at_block: now.saturating_add(lockup),
|
||
});
|
||
Ok(())
|
||
}
|
||
|
||
/// Withdraw canonical — only after lockup expired.
|
||
/// Returns principal + accumulated rewards al staker.
|
||
#[ink(message)]
|
||
pub fn withdraw(&mut self, stake_id: StakeId) -> Result<()> {
|
||
let mut stake = self.stakes.get(stake_id).ok_or(Error::StakeNotFound)?;
|
||
if self.env().caller() != stake.staker {
|
||
return Err(Error::UnauthorizedCaller);
|
||
}
|
||
if stake.state != StakeState::Unbonding {
|
||
return Err(Error::InvalidState);
|
||
}
|
||
let unbond_at = stake.unbond_request_at.ok_or(Error::InvalidState)?;
|
||
let lockup = self.lockup_blocks(stake.kind);
|
||
let now = self.env().block_number();
|
||
if now < unbond_at.saturating_add(lockup) {
|
||
return Err(Error::LockupNotExpired);
|
||
}
|
||
|
||
let to_return = stake.amount.saturating_add(stake.rewards_accumulated);
|
||
let staker = stake.staker;
|
||
let principal = stake.amount;
|
||
let rewards = stake.rewards_accumulated;
|
||
let kind = stake.kind;
|
||
|
||
// FIX-1 (V1-C1) CEI: muta TODO el estado ANTES del transfer, para que
|
||
// una reentrada vea state=Withdrawable y falle el check de InvalidState
|
||
// (cierra el drenado por re-entrada en `withdraw`).
|
||
//
|
||
// ── EFFECTS ──
|
||
match kind {
|
||
StakeKind::Validator => self.total_staked_validator = self.total_staked_validator.saturating_sub(principal),
|
||
StakeKind::Beacon => self.total_staked_beacon = self.total_staked_beacon.saturating_sub(principal),
|
||
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 ──
|
||
self.env()
|
||
.transfer(staker, U256::from(to_return))
|
||
.map_err(|_| Error::TransferFailed)?;
|
||
|
||
// ── EVENTS ──
|
||
self.env().emit_event(Withdrawn {
|
||
stake_id,
|
||
amount: principal,
|
||
rewards,
|
||
});
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Slash canonical — only slashing_authority (Tribunal Pillar 6).
|
||
/// Splits canonical: 50% to treasury + 50% burn (per BM V2 burn matrix).
|
||
#[ink(message)]
|
||
pub fn slash(
|
||
&mut self,
|
||
stake_id: StakeId,
|
||
amount: Balance,
|
||
reason_hash: [u8; 32],
|
||
) -> Result<()> {
|
||
if self.env().caller() != self.slashing_authority {
|
||
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);
|
||
}
|
||
|
||
let treasury_share = amount / 2;
|
||
let burn = amount.saturating_sub(treasury_share);
|
||
|
||
// FIX-1 (V1-C1) CEI + (V1-C4) burn real:
|
||
// · TODO el estado muta ANTES del transfer.
|
||
// · El "burn" NO se transfiere a la zero-address (que no reduce
|
||
// emisión y ensucia la cuenta cero). Se RETIENE en el contrato
|
||
// (fuera de circulación), a barrer por el primitivo de burn del
|
||
// runtime — una sola semántica de burn, no dos.
|
||
//
|
||
// ── EFFECTS ──
|
||
let kind = stake.kind;
|
||
stake.amount = stake.amount.saturating_sub(amount);
|
||
stake.slashed_amount = stake.slashed_amount.saturating_add(amount);
|
||
if stake.amount == 0 {
|
||
stake.state = StakeState::Slashed;
|
||
}
|
||
self.stakes.insert(stake_id, &stake);
|
||
match kind {
|
||
StakeKind::Validator => self.total_staked_validator = self.total_staked_validator.saturating_sub(amount),
|
||
StakeKind::Beacon => self.total_staked_beacon = self.total_staked_beacon.saturating_sub(amount),
|
||
StakeKind::Reputation => self.total_staked_reputation = self.total_staked_reputation.saturating_sub(amount),
|
||
}
|
||
|
||
// ── INTERACTIONS (solo treasury_share sale; el burn queda retenido) ──
|
||
self.env()
|
||
.transfer(self.treasury, U256::from(treasury_share))
|
||
.map_err(|_| Error::TransferFailed)?;
|
||
|
||
// ── EVENTS ──
|
||
self.env().emit_event(Slashed {
|
||
stake_id,
|
||
amount_slashed: amount,
|
||
treasury_share,
|
||
burned: burn,
|
||
reason_hash,
|
||
});
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[ink::test]
|
||
fn default_chn3_constructor() {
|
||
let contract = OriginStaking::default_chn3();
|
||
assert_eq!(contract.min_stake(StakeKind::Validator), 100_000_000_000_000_000_000);
|
||
assert_eq!(contract.lockup_blocks(StakeKind::Validator), 403_200); // 28 days
|
||
assert_eq!(contract.total_staked(StakeKind::Validator), 0);
|
||
}
|
||
|
||
#[ink::test]
|
||
fn lockup_periods_canonical() {
|
||
let contract = OriginStaking::default_chn3();
|
||
// Validator > Reputation > Beacon canonical hierarchy
|
||
assert!(contract.lockup_blocks(StakeKind::Validator) > contract.lockup_blocks(StakeKind::Reputation));
|
||
assert!(contract.lockup_blocks(StakeKind::Reputation) > contract.lockup_blocks(StakeKind::Beacon));
|
||
}
|
||
|
||
#[ink::test]
|
||
fn stake_kind_variants_distinct() {
|
||
assert_ne!(StakeKind::Validator, StakeKind::Beacon);
|
||
assert_ne!(StakeKind::Beacon, StakeKind::Reputation);
|
||
}
|
||
|
||
/// FIX-1 (V1-C1) — guarda: las acciones que mueven fondos sobre un stake
|
||
/// inexistente cortan en el check (StakeNotFound) ANTES de cualquier
|
||
/// transfer. Los tests E2E de reentrancy/double-spend reales son
|
||
/// node-gated (cargo-contract v6 + nodo pallet-revive) — ver nota en
|
||
/// `origin-escrow`.
|
||
#[ink::test]
|
||
fn fund_moving_actions_blocked_for_unknown_stake() {
|
||
let mut c = OriginStaking::default_chn3();
|
||
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));
|
||
}
|
||
}
|
||
}
|