Files
arkhe-origin-chain/contracts/origin-staking/lib.rs
T
davidherranandClaude Opus 4.8 e6a803b54f feat(emission): FIX-6 Stage 1 — emision que paga validadores (V2-CA2) + remover APR falso
Build + tests verde: emission pallet 5 + runtime 28 (incl. emission_pallet_index_13)
+ staking contract 4. Verificacion adversarial ultracode (2 skeptics): correctitud
+ seguridad CONFIRMADAS (split exacto, sin overflow, autor solo-desde-Aura sin
input de usuario, mint-None-seguro, sin dispatchable explotable, APR removal
honesto). 'partial' solo por cobertura de tests en ramas triviales (None /
reward==0), NO por vulnerabilidad.

Cierra V2-CA2 (la cadena no tenia emision -> los validadores ganaban 0 OGC):

NUEVO pallet origin-chain-emission (Index 13):
- on_initialize cada bloque: identifica al autor Aura (FindAuthor =
  pallet_session::FindAccountFromAuthorIndex<Runtime, Aura>), mintea un
  BlockReward (10 OGC, ~5.26% inflacion anual) y lo reparte:
  - 70% al autor (el validador que produjo el bloque).
  - 20% al treasury (og/gvtry — la MISMA cuenta-vault de governance FIX-6).
  - 10% al subsidy pool (og/subsy — proof-of-equity que financia el subsidio
    de hardware anti-clasista #16).
- El residual del split va al subsidy (no pierde unidades por redondeo de bps).
- TotalEmitted trackea la inflacion acumulada on-chain. mint_into (fungible::
  Mutate) = emision real (aumenta total issuance).
- El autor viene SOLO de FindAuthor/Aura (no de input de usuario) -> nadie puede
  mintear a una cuenta arbitraria. Si FindAuthor devuelve None, no mintea. Sin
  overflow (saturating_mul/div, 10^19 << u128::MAX).

sub_mult ORTOGONAL (David 2026-06-10): el subsidy pool financia el subsidio de
hardware SIN importar la suscripcion premium. Esta emision lo alimenta; el
OnChargeTransaction de Stage 2 lo consumira.

Remover APR falso (V1/V2 fake APR): staking contract default_chn3 reward rates
8/5/3% -> 0. Era enganoso (no habia motor que los pagara; los campos
reward_rate_bps se almacenaban pero NUNCA se leian, rewards_accumulated jamas se
acreditaba). Los validadores ahora cobran via la emision real; las tasas de
staking reales las define Stage 2 (NPoS por stake).

FIX-6 Stage 1 COMPLETO (con v0.0.56 treasury real): emision + treasury real +
APR removido. Stage 2: NPoS (pallet_staking + EraPayout) + fee unificado
(OnChargeTransaction con sub_mult ortogonal).

FOLLOW-UPS (low, documentados): anadir tests de cobertura para las ramas
FindAuthor==None (no mintea) y reward==0 (requieren un 2o mock runtime);
BlockReward fijo (decay schedule = Stage 2); el subsidy pool acumula pero no se
consume hasta el OnChargeTransaction de Stage 2; genesis mainnet del treasury
vault (pre-launch).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 06:06:21 -05:00

487 lines
19 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;
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));
}
}
}