Files
arkhe-origin-chain/contracts/origin-staking/lib.rs
T
davidherranandClaude Fable 5 db4c8a37d3 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>
2026-07-04 14:55:31 -05:00

525 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! # 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));
}
}
}