//! # 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, 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, staker_stakes: Mapping>, 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 = core::result::Result; /// 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 800, // 8% APR validator 500, // 5% APR beacon 300, // 3% APR reputation ) } // ── Reads ─────────────────────────────────────────── #[ink(message)] pub fn stake(&self, id: StakeId) -> Option { self.stakes.get(id) } #[ink(message)] pub fn stakes_of(&self, staker: Address) -> Vec { 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 { 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; 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)?; 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)); } } }