Files
arkhe-origin-chain/contracts/origin-staking/lib.rs
T
davidherran 8634be5b87 feat(contracts): v0.0.45 — Slice 3.5 origin-staking template SCAFFOLD (3 stake kinds + slashing) 🎉
Ship Slice 3.5 v0.0.45 — fourth foundational template del Origin Contract Atelier.
Multi-purpose staking patterns: validator (CHN-3 NPoS Phase 2 prep) + beacon
(Pillar 6 featured visibility) + reputation (AGI commercial reputation per BM V2 §6.4).

Cambios:
- contracts/origin-staking/{Cargo.toml,lib.rs,README.md}: full template
- 3 StakeKind variants canonical: Validator + Beacon + Reputation
- 4 StakeState lifecycle: Active → Unbonding → Withdrawable + Slashed branch
- Storage canonical: config per kind (min_stake + lockup + reward_rate) + state +
  per-kind totals + staker indices
- 4 events: Staked, UnbondRequested, Withdrawn, Slashed
- 8 error variants + Result type
- 2 constructors: new (full params) + default_chn3 (NPoS Phase 2 prep defaults
  100 OGC validator min + 28 días lockup + 8% APR)
- 5 reads canonical (Inmutable #37 gratis): stake, stakes_of, total_staked,
  min_stake, lockup_blocks
- 4 writes: stake_tokens (payable per kind), unbond_request (transitions Unbonding),
  withdraw (post-lockup), slash (slashing_authority Tribunal-only, 50/50 treasury/burn)
- 3 unit tests canonical

Inmutables compliance:
- #16 HTS: min_stake graduated HardwareTier inclusion canonical Pi 5
- #37 Free perpetua: 5 reads sin payment
- #26 Content-Addressed: reason_hash BLAKE3 inmutable slash events
- #41 AGI Cross-Species: AGI stake canonical (especially Reputation kind)

Slashing canonical: 50% Treasury + 50% Burn (BM V2 burn matrix Pillar 3).
Tribunal Pillar 6 = slashing_authority typical (governance Phase 5 assigned).

Cross-pillar wire:
- Pillar 4 Charon: validator stake compose pallet-session NPoS Phase 2
- Pillar 6 Mathi/Tribunal: beacon stake + slashing authority
- BM V2 Pillar 4 §6.4: AGI reputation stake/slash mechanics

Próximo Ship Slice 3.6 v0.0.46: origin-mdk-reconciliation design study
(ink! ↔ origin-mdk WASM boundary architectural design + cross-pillar Marihan coord).

⚓ Path A Slice 3.5 SCAFFOLD shipped. Templates 4/5 complete + CHN-3 NPoS prep.

🤖 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 09:08:52 -05:00

436 lines
17 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
//!
//! ```
//! 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;
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))]
pub enum StakeKind {
Validator,
Beacon,
Reputation,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, scale::Encode, scale::Decode)]
#[cfg_attr(feature = "std", derive(scale_info::TypeInfo))]
pub enum StakeState {
Active,
Unbonding,
Withdrawable,
Slashed,
}
#[derive(Debug, Clone, scale::Encode, scale::Decode)]
#[cfg_attr(feature = "std", derive(scale_info::TypeInfo))]
pub struct Stake {
pub staker: AccountId,
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: AccountId,
treasury: AccountId,
// ── 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: AccountId,
// ── State ─────────────────────────────────────────────
total_stakes: u32,
stakes: Mapping<StakeId, Stake>,
staker_stakes: Mapping<AccountId, 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: AccountId,
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>;
impl OriginStaking {
#[ink(constructor)]
pub fn new(
treasury: AccountId,
slashing_authority: AccountId,
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: AccountId) -> 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 = 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);
self.env().transfer(stake.staker, to_return).map_err(|_| Error::TransferFailed)?;
// Update totals
match stake.kind {
StakeKind::Validator => self.total_staked_validator = self.total_staked_validator.saturating_sub(stake.amount),
StakeKind::Beacon => self.total_staked_beacon = self.total_staked_beacon.saturating_sub(stake.amount),
StakeKind::Reputation => self.total_staked_reputation = self.total_staked_reputation.saturating_sub(stake.amount),
}
stake.state = StakeState::Withdrawable;
self.stakes.insert(stake_id, &stake);
self.env().emit_event(Withdrawn {
stake_id,
amount: stake.amount,
rewards: stake.rewards_accumulated,
});
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);
self.env().transfer(self.treasury, treasury_share).map_err(|_| Error::TransferFailed)?;
// Burn portion canonical — transfer to zero address (interim Slice 3.5 SCAFFOLD)
let zero = AccountId::from([0u8; 32]);
self.env().transfer(zero, burn).map_err(|_| Error::TransferFailed)?;
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 stake.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),
}
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);
}
}
}