Files
arkhe-origin-chain/contracts/origin-staking/lib.rs
T
davidherranandClaude Opus 4.8 6663dbdc0f fix(contracts): FIX-1 contract-safety + migración ink! v6 (escrow/staking/nft)
REMEDIATION_ROADMAP_V1 FIX-1 (V1-C1 reentrancy + V1-C4 zero-burn). Los 3
templates ahora COMPILAN contra ink! 6.0.0-beta.1 + tests verde (12/12).

Hallazgo (honesto): los templates se shippearon "scaffold compile-only" pero
nunca compilaron contra su versión pinneada de ink!. ink! v6 / pallet-revive es
H160-Address + U256 nativo (no AccountId + Balance, modelo viejo). Esta
migración los vuelve reales (compilando + testeados).

Migración ink! v6:
- AccountId -> Address (H160) en parties / storage / events / reads / params.
- transfer() + transferred_value() -> U256 (+ helper u256_to_balance saturante).
- derive StorageLayout en tipos de storage (EscrowState/Milestone/Stake/...).
- doctest fences sin lenguaje -> text (diagramas ASCII del lifecycle).
- workspace: contracts/* excluidos (toolchain PolkaVM/pallet-revive distinta).

FIX-1 hardening:
- CEI (Checks-Effects-Interactions): muta TODO el storage ANTES de cualquier
  transfer() -> una reentrada ve el estado final y falla sus checks. Sitios:
  escrow confirm_milestone/resolve_dispute, staking withdraw/slash,
  nft mint/transfer. Cierra V1-C1. (nft mint encontrado más allá de la lista
  del roadmap; mismo patrón explotable -> incluido).
- Burn real (V1-C4): elimina transfer([0;32]) (que no reduce emisión y ensucia
  la cuenta cero); el burn se RETIENE en el contrato (fuera de circulación),
  a barrer por el primitivo de burn del runtime -> una sola semántica, no dos.
  (staking slash, nft mint).
- Tests de guarda de estado (la C de CEI) añadidos en los 3 contratos.

Pendiente E2E (node-gated, requiere VPS + nodo pallet-revive):
- Correctitud económica native<->eth (NATIVE_TO_ETH_RATIO): asumido 1:1; debe
  validarse en nodo real antes de testnet/mainnet.
- Tests reales de reentrancy/double-spend: el env off-chain de #[ink::test] no
  modela el revert-on-Err ni la semántica de balances -> serían engañosos.
- V1-H7 (suma milestones == total_amount): no incluido en este commit.
- credential-mint + mdk-reconciliation necesitan la misma migración ink! v6.

Cross-pillar: DC ORIGIN_ATTESTATION_SIGNING_PAYLOAD_V1 abierto con Ergon
(payload postcard canónico + domain-separation FIX-3) — gate del verificador
de attestation on-chain. OAGIP-D10 (Charon identity attestation) definido a
Palace para el protocolo.

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

482 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
800, // 8% APR validator
500, // 5% APR beacon
300, // 3% APR reputation
)
}
// ── 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));
}
}
}