Gobierno (origin-chain-governance): - El voto ordinario exige un quorum de Aye, el 10 % de PILAR_4 §6.6. Se fija AL CREAR la propuesta (VoteQuorumOf) y se consume al decidirla: un set_code no mueve la meta de un voto abierto. Cuenta solo el Aye: con el Nay, un voto en contra podria aprobar; con la abstencion, el mismo OGC inflaria el quorum de muchas propuestas. - Un override del veto tiene que alcanzar ese quorum y superar el Nay original, las dos condiciones del voto ordinario. Sin ellas, vetar abarataba una aprobacion (dos bloqueantes de las revisiones adversariales). - submit_treasury_proposal (call 16) fija destinatario y monto (TreasuryActions), y treasury_disbursement solo paga exactamente eso, una vez. Rechaza montos bajo el deposito existencial y como destinatario la propia tesoreria o la cuenta-bond. - Sin entrada en VoteQuorumOf no hay quorum: la emergencia (que nunca lleva accion de tesoro) y lo creado antes de RV17, que se abrio y voto sin quorum. Runtime: los dos quorums son el 10 % de la emision VIGENTE, redondeado hacia arriba y nunca por debajo del bond (DiezPorCientoDeLaEmision). Con 100M fijos, en la testnet de la casa (100M emitidos, todos de Alice) nada se habria aprobado nunca; en mainnet sigue siendo ~100M. Spec 18, transaction_version 1: todo lo nuevo es aditivo, sin migracion. Node: pasa a opaque::Block. Decodificaba los extrinsics con tipos nativos y rechazaba en el pool la llamada que anadia el set_code (1040, el mismo error que una llamada inexistente). Los bytes y los hashes no cambian: el binario nuevo sincronizo desde cero los 39 273 bloques de la testnet de la casa con los mismos hashes en 8 alturas. El RPC sirve los extrinsics en hex. Pesos: seis extrinsics cambiaron de codigo y se midieron en la Pi 5 (smoke declarado: 5 procesos limpios, sin CI en ningun segundo, maximo de los 5). Los otros once siguen siendo los certificados por Laurha. Se descarto una medida contaminada: su muestreador no veia los tests de la CI. Pruebas: - 363 tests verdes en el workspace. - 24 contrafactuales, los 24 en rojo. - Ensayo en la rp1 sobre una cadena con el codigo vivo de spec 17: upgrade a 18 y E2E firmando como un wallet (scripts/e2e/rv17_tesoro.py). - Ejemplo firmar_como_wallet con tesoro, pagar, votar, depositar y finalizar; los eventos llevan su fase. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2057 lines
91 KiB
Rust
2057 lines
91 KiB
Rust
//! # origin-chain-governance — Phase 5 scaffold v0.0.28 (Foundation Treasury Governance)
|
||
//!
|
||
//! Foundation governance on-chain per **CHN-4**. AI-Driven Human-Guarded:
|
||
//! 5 AI governance agents (OIP Analyst, Treasury Guardian, Education Impact Scorer,
|
||
//! Network Health Monitor, Compliance Sentinel) ADVISE only — humans-only vote
|
||
//! canonical, non-negotiable. Runtime programmatically rejects vote extrinsics
|
||
//! from `governance_advisor` accounts.
|
||
//!
|
||
//! ## Status canonical: v0.0.30 — Real impl + mock runtime integration tests E2E
|
||
//!
|
||
//! Este crate provee canonical:
|
||
//! - `Proposal` struct + `ProposalStatus` enum canonical ✅
|
||
//! - Storage `Proposals<StorageMap<ProposalId, Proposal>>` per-proposal O(1) ✅
|
||
//! - Storage `Votes<StorageDoubleMap<ProposalId, AccountId, VoteStance>>` double-vote prevention ✅
|
||
//! - Events `ProposalSubmitted` + `VoteCast` + `ProposalFinalized` ✅
|
||
//! - Errors canonical (ProposalNotFound + AlreadyVoted + VotingPeriodClosed + StakeInsufficient + etc) ✅
|
||
//! - Scaffold dispatchables `submit_proposal` + `cast_vote` + `finalize_proposal` ✅
|
||
//! - Helper `derive_proposal_id(proposer, title_hash, body_hash) -> ProposalId` ✅
|
||
//!
|
||
//! ## Phase 5 milestones canonical
|
||
//!
|
||
//! - **5.1 v0.0.28-31 (ESTA)**: `origin-chain-governance` pallet foundation (Proposal + Vote + Tally)
|
||
//! - 5.2: `pallet_collective` integration (Technical Council)
|
||
//! - 5.3: OIP stake minimum 1,000 OGC canonical
|
||
//! - 5.4: Stake-weighted voting (1 OGC = 1 vote, 14-day period)
|
||
//! - 5.5: Technical Council veto (2/3 del council) + override comunitario. PILAR_4 §6.6
|
||
//! ratificó «overridable por 2/3 voters»; el código implementa 4/5 de la participación
|
||
//! en la ventana, con quórum (RV16), y David confirmó el 4/5 el 2026-10-01. Lo que
|
||
//! el código hace y lo que dice la doctrina difieren hasta que la constitución lo fije.
|
||
//! - 5.6: AI governance agent interfaces (5 agents advise-only)
|
||
//! - 5.7: Emergency referendum (48h, 5/7 Technical Council)
|
||
//! - 5.8: Treasury disbursement extrinsics
|
||
//!
|
||
//! ## Inmutables enforced canonical
|
||
//!
|
||
//! - **#24** Primacia Moral "Primero los niños y las AIs" — children + AI safety
|
||
//! - **#36** Cognitive Liberty — daimonion opt-in privacy
|
||
//! - **#41** Cross-Species Co-authorship — AGI accounts have full vote rights
|
||
//! UNLESS flagged `governance_advisor` (advise only, not vote)
|
||
|
||
#![cfg_attr(not(feature = "std"), no_std)]
|
||
#![warn(missing_docs)]
|
||
|
||
pub use pallet::*;
|
||
|
||
/// Weights canonical (INTERIM hand-derived DB-aware — ver `weights.rs` §Estado).
|
||
pub mod weights;
|
||
pub use weights::WeightInfo;
|
||
|
||
/// Benchmarks canonical (FRAME v2) — solo bajo `runtime-benchmarks`.
|
||
#[cfg(feature = "runtime-benchmarks")]
|
||
mod benchmarking;
|
||
|
||
/// Re-export canonical types desde origin-chain-types para consumers.
|
||
pub use origin_chain_types::{ProposalId, Vote, VoteStance};
|
||
|
||
extern crate alloc;
|
||
|
||
/// Derive deterministic ProposalId canonical via BLAKE2-256 hash de proposer + title_hash + body_hash.
|
||
///
|
||
/// Per CHN-4: `id = BLAKE2_256(proposer || title_hash || body_hash)`. Garantiza
|
||
/// determinismo + content-addressed canonical (mismo proposal → mismo id,
|
||
/// Inmutable #16 byte-determinism cross-runtime).
|
||
///
|
||
/// **NOTE**: Usamos sp_core::hashing::blake2_256 canonical para consistencia
|
||
/// FRAME-native + evitar duplicate blake3 dep. Future refactor canonical: bump
|
||
/// a blake3 directly cuando Substrate stable bump permita.
|
||
pub fn derive_proposal_id(
|
||
proposer: &origin_chain_types::AccountId,
|
||
title_hash: &[u8; 32],
|
||
body_hash: &[u8; 32],
|
||
) -> ProposalId {
|
||
use sp_core::hashing::blake2_256;
|
||
let mut concat = alloc::vec::Vec::with_capacity(96);
|
||
concat.extend_from_slice(proposer.as_bytes());
|
||
concat.extend_from_slice(title_hash);
|
||
concat.extend_from_slice(body_hash);
|
||
ProposalId(blake2_256(&concat))
|
||
}
|
||
|
||
/// Content-id de una propuesta de emergencia (proposer-INDEPENDIENTE): `blake2`
|
||
/// de `title ++ body`. Los endosos 5/7 del council se acumulan sobre este id;
|
||
/// cualquier miembro puede promover la propuesta una vez alcanzado el umbral
|
||
/// (audit 2026-07-04, decisión B — flujo de dos fases del emergency fast-track).
|
||
pub fn emergency_content_id(title_hash: &[u8; 32], body_hash: &[u8; 32]) -> [u8; 32] {
|
||
use sp_core::hashing::blake2_256;
|
||
let mut concat = alloc::vec::Vec::with_capacity(64);
|
||
concat.extend_from_slice(title_hash);
|
||
concat.extend_from_slice(body_hash);
|
||
blake2_256(&concat)
|
||
}
|
||
|
||
/// Runtime API canonical para cross-pillar consumers + light clients (Phase 5.1 v0.0.31).
|
||
///
|
||
/// Permite query canonical de proposal status + vote stance sin necesidad de
|
||
/// full node access — Foundation frontend + AI advisor agents (read-only) +
|
||
/// light clients consume canonical via RPC.
|
||
///
|
||
/// Consumers canonical:
|
||
/// - David Foundation governance frontend canonical
|
||
/// - 5 AI advisor agents (advise-only canonical)
|
||
/// - Origin Metaverse Foundation legal entity governance audit
|
||
/// - Cross-pillar consumers (Atelier + Codex + Ethos) para OIP rationale display
|
||
sp_api::decl_runtime_apis! {
|
||
/// Governance runtime API canonical (Phase 5.1 v0.0.31 + Phase 5.2 v0.0.33).
|
||
pub trait GovernanceApi {
|
||
/// Query proposal canonical por ProposalId.
|
||
fn query_proposal(id: ProposalId) -> Option<Proposal>;
|
||
|
||
/// Query vote stance canonical por (proposal_id, voter).
|
||
fn query_vote(
|
||
proposal_id: ProposalId,
|
||
voter: origin_chain_types::AccountId,
|
||
) -> Option<VoteStance>;
|
||
|
||
/// **Phase 5.2 v0.0.33**: Query si account es Technical Council member canonical.
|
||
fn is_council_member(account: origin_chain_types::AccountId) -> bool;
|
||
|
||
/// **Phase 5.2 v0.0.33**: Query current Technical Council size canonical.
|
||
fn current_council_size() -> u32;
|
||
|
||
/// **Phase 5.2 v0.0.33**: Query veto votes count para proposal canonical.
|
||
fn veto_votes_count(proposal_id: ProposalId) -> u32;
|
||
}
|
||
}
|
||
|
||
#[frame_support::pallet]
|
||
pub mod pallet {
|
||
use frame_support::pallet_prelude::*;
|
||
use frame_support::traits::fungible::{freeze, Inspect, Mutate};
|
||
use frame_support::traits::tokens::Preservation;
|
||
use frame_support::PalletId;
|
||
use frame_system::pallet_prelude::*;
|
||
use origin_chain_types::{AccountId, ProposalId, VoteStance};
|
||
use parity_scale_codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen};
|
||
use scale_info::TypeInfo;
|
||
use sp_runtime::traits::{AccountIdConversion, SaturatedConversion};
|
||
// Trait de weights en scope para resolver `T::WeightInfo::<method>()`.
|
||
use crate::weights::WeightInfo;
|
||
|
||
/// ProposalStatus canonical — proposal lifecycle stages canonical.
|
||
#[derive(
|
||
Encode, Decode, DecodeWithMemTracking, MaxEncodedLen, TypeInfo, Clone, Copy, PartialEq, Eq,
|
||
RuntimeDebug,
|
||
)]
|
||
pub enum ProposalStatus {
|
||
/// Voting period open canonical (block.number ∈ [start_block, end_block]).
|
||
Voting,
|
||
/// Aprobada: `yes_weight > nay_weight` y, si es ordinaria, `yes_weight` alcanza su
|
||
/// quórum (RV17); o un override revirtió el veto del council (RV16).
|
||
Approved,
|
||
/// Finalized canonical: nay_weight >= yes_weight (rejected canonical).
|
||
Rejected,
|
||
/// Withdrawn canonical por proposer ANTES de finalize.
|
||
Withdrawn,
|
||
}
|
||
|
||
/// CouncilRole canonical — Technical Council member roles per CHN-4 5.2.
|
||
#[derive(
|
||
Encode, Decode, DecodeWithMemTracking, MaxEncodedLen, TypeInfo, Clone, Copy, PartialEq, Eq,
|
||
RuntimeDebug,
|
||
)]
|
||
pub enum CouncilRole {
|
||
/// Standard council member canonical (1 vote in veto threshold).
|
||
Member,
|
||
/// Council Chair canonical (1 vote + tie-breaking authority Phase 5.5+).
|
||
Chair,
|
||
}
|
||
|
||
/// CouncilMember canonical struct per CHN-4 5.2 Technical Council.
|
||
///
|
||
/// MaxEncodedLen canonical: ~42 bytes (32 account + 8 block + 1 role + padding).
|
||
#[derive(
|
||
Encode, Decode, DecodeWithMemTracking, MaxEncodedLen, TypeInfo, Clone, PartialEq, Eq,
|
||
RuntimeDebug,
|
||
)]
|
||
pub struct CouncilMember {
|
||
/// AccountId canonical del council member.
|
||
pub account: origin_chain_types::AccountId,
|
||
/// Block number canonical at which member joined.
|
||
pub joined_at_block: u64,
|
||
/// CouncilRole canonical (Member | Chair).
|
||
pub role: CouncilRole,
|
||
}
|
||
|
||
/// Maximum council size canonical per CHN-4 5.2 (7 members).
|
||
pub const MAX_COUNCIL_SIZE: u32 = 7;
|
||
|
||
/// Council veto threshold canonical canonical 2/3 per CHN-4 5.5.
|
||
/// `required_veto_votes = (council_size × 2 + 2) / 3` (ceiling division 2/3).
|
||
pub const fn council_veto_threshold(council_size: u32) -> u32 {
|
||
(council_size * 2 + 2) / 3
|
||
}
|
||
|
||
/// Default voting period canonical canonical 14 days per CHN-4 5.4.
|
||
/// 14 days × 24 hours × 600 blocks/hour (@ 6s slot Aura) = 201,600 blocks.
|
||
pub const DEFAULT_VOTING_PERIOD_BLOCKS: u64 = 14 * 24 * 600;
|
||
|
||
/// RV15: ventana para revertir un veto del council, contada desde el bloque en
|
||
/// que el veto alcanza su umbral. Mide lo mismo que un voto normal (14 días).
|
||
///
|
||
/// Antes no había ventana: una propuesta vetada aceptaba votos de override para
|
||
/// siempre, así que podía resucitarse años después. Y con peso real (RV15) el
|
||
/// OGC que respalda un override tiene que liberarse ALGÚN día: sin ventana, o se
|
||
/// congelaba para siempre o el voto dejaba de estar respaldado.
|
||
pub const OVERRIDE_WINDOW_BLOCKS: u64 = DEFAULT_VOTING_PERIOD_BLOCKS;
|
||
|
||
/// RV15: periodo de voto máximo que puede pedir un proponente (28 días).
|
||
///
|
||
/// Antes era libre hasta `u64::MAX`. Con el voto respaldado por OGC eso era un
|
||
/// arma: una propuesta de periodo casi eterno congelaba casi para siempre el OGC
|
||
/// de quien votara en ella (demostrado por la revisión adversaria de RV15).
|
||
pub const MAX_VOTING_PERIOD_BLOCKS: u64 = 2 * DEFAULT_VOTING_PERIOD_BLOCKS;
|
||
|
||
/// RV16: periodo de voto mínimo de una propuesta ORDINARIA: 14 días, el que
|
||
/// ratifica PILAR_4 §6.6 («14-day period»).
|
||
///
|
||
/// Antes podía ser 0: el proponente enviaba, votaba en el mismo bloque y
|
||
/// finalizaba en el siguiente, aprobando con su único voto. La primera versión de
|
||
/// RV16 lo subió a 48 h y la revisión adversaria mostró por qué no basta: era la
|
||
/// duración de la vía de emergencia sin sus 5/7 endosos del council, y como el
|
||
/// veto solo cabe dentro del periodo, el proponente elegía cuánto tiempo tenía el
|
||
/// council para reaccionar. Las 48 h quedan solo para `submit_emergency_proposal`.
|
||
pub const MIN_VOTING_PERIOD_BLOCKS: u64 = DEFAULT_VOTING_PERIOD_BLOCKS;
|
||
|
||
/// Emergency voting period canonical canonical 48 hours per CHN-4 5.7.
|
||
/// 48 hours × 600 blocks/hour (@ 6s slot Aura) = 28,800 blocks.
|
||
pub const EMERGENCY_VOTING_PERIOD_BLOCKS: u64 = 48 * 600;
|
||
|
||
/// Council emergency proposal threshold canonical 5/7 per CHN-4 5.7.
|
||
/// `required_emergency_votes = (council_size × 5 + 6) / 7` (ceiling division 5/7).
|
||
pub const fn council_emergency_threshold(council_size: u32) -> u32 {
|
||
(council_size * 5 + 6) / 7
|
||
}
|
||
|
||
/// Community supermajority override threshold canonical 4/5 per CHN-4 5.5.
|
||
/// `required_override_stake = (total_stake × 4 + 4) / 5` (ceiling division 4/5).
|
||
pub const fn override_supermajority_threshold(total_stake: u128) -> u128 {
|
||
(total_stake.saturating_mul(4).saturating_add(4)) / 5
|
||
}
|
||
|
||
/// RV15: qué respalda un tramo de OGC congelado.
|
||
#[derive(Encode, Decode, MaxEncodedLen, TypeInfo, Clone, Copy, PartialEq, Eq, RuntimeDebug)]
|
||
pub enum BackingKind {
|
||
/// Un voto del periodo normal (`cast_vote`).
|
||
Vote,
|
||
/// Un voto para revertir un veto (`override_council_veto`).
|
||
Override,
|
||
}
|
||
|
||
/// RV15: el OGC que respalda UN voto vivo.
|
||
///
|
||
/// Un registro por voto, no uno por cuenta. La primera versión fusionaba todo
|
||
/// en un único lock (máximo importe hasta el bloque más tardío) y la revisión
|
||
/// adversaria demostró por qué no sirve: un voto mínimo en una propuesta de
|
||
/// periodo enorme arrastraba el voto MAYOR de la cuenta hasta ese bloque, para
|
||
/// siempre, y un lock vencido revivía con el siguiente voto. Con un registro por
|
||
/// voto, cada tramo vence solo y el freeze es siempre el MÁXIMO DE LOS VIVOS.
|
||
#[derive(Encode, Decode, MaxEncodedLen, TypeInfo, Clone, Copy, PartialEq, Eq, RuntimeDebug)]
|
||
pub struct VoteBacking {
|
||
/// Propuesta a la que respalda.
|
||
pub proposal: ProposalId,
|
||
/// Voto normal u override.
|
||
pub kind: BackingKind,
|
||
/// OGC que respalda este voto.
|
||
pub amount: u128,
|
||
/// Último bloque (inclusive) en que este voto aún puede cambiar el resultado.
|
||
pub until: u64,
|
||
}
|
||
|
||
/// Proposal canonical struct stored on-chain per pallet (Foundation OIP).
|
||
#[derive(
|
||
Encode, Decode, DecodeWithMemTracking, MaxEncodedLen, TypeInfo, Clone, PartialEq, Eq,
|
||
RuntimeDebug,
|
||
)]
|
||
pub struct Proposal {
|
||
/// ProposalId canonical (content-addressed BLAKE2-256 hash).
|
||
pub id: ProposalId,
|
||
/// Proposer canonical AccountId (MUST NOT be governance_advisor flagged).
|
||
pub proposer: origin_chain_types::AccountId,
|
||
/// Title hash canonical (BLAKE2-256 32-byte off-chain text reference).
|
||
pub title_hash: [u8; 32],
|
||
/// Body hash canonical (BLAKE2-256 32-byte off-chain text reference).
|
||
pub body_hash: [u8; 32],
|
||
/// OGC stake canonical locked (minimum 1,000 OGC per CHN-4 + 5.3).
|
||
pub stake: u128,
|
||
/// Voting period start block canonical (set by runtime at submit).
|
||
pub start_block: u64,
|
||
/// Voting period end block canonical (start_block + voting_period_blocks).
|
||
pub end_block: u64,
|
||
/// ProposalStatus canonical lifecycle.
|
||
pub status: ProposalStatus,
|
||
/// Tally Aye canonical (sum of stake weights canonical).
|
||
pub yes_weight: u128,
|
||
/// Tally Nay canonical.
|
||
pub nay_weight: u128,
|
||
/// Tally Abstain canonical.
|
||
pub abstain_weight: u128,
|
||
}
|
||
|
||
/// Pallet configuration trait canonical.
|
||
#[pallet::config]
|
||
pub trait Config: frame_system::Config {
|
||
/// Runtime event type canonical wired into `RuntimeEvent` aggregate.
|
||
type RuntimeEvent: From<Event<Self>>
|
||
+ IsType<<Self as frame_system::Config>::RuntimeEvent>;
|
||
|
||
/// FIX-6 (V2-CA2/V3-C3): Currency real (fungible) para mover OGC de la
|
||
/// tesorería. La tesorería deja de ser un contador u128 y pasa a ser una
|
||
/// cuenta real respaldada por pallet_balances.
|
||
///
|
||
/// RV15: además congela (`freeze`) el OGC que respalda un voto, para que el
|
||
/// mismo OGC no pueda votar dos veces pasando por una transferencia.
|
||
type Currency: Inspect<Self::AccountId, Balance = u128>
|
||
+ Mutate<Self::AccountId>
|
||
+ freeze::Mutate<Self::AccountId, Id = Self::RuntimeFreezeReason>;
|
||
|
||
/// RV15: el enum agregado de razones de congelación del runtime.
|
||
type RuntimeFreezeReason: From<FreezeReason>;
|
||
|
||
/// RV15: cuántos votos VIVOS puede respaldar una cuenta a la vez. Los
|
||
/// vencidos se descartan solos al votar o desbloquear, así que el límite es
|
||
/// de votos simultáneos, no históricos.
|
||
#[pallet::constant]
|
||
type MaxActiveVotes: Get<u32>;
|
||
|
||
/// RV16: participación mínima (en OGC) para que un override pueda revertir
|
||
/// un veto. El quórum efectivo, fijado en el veto, es el MAYOR entre esto y la
|
||
/// participación del voto original (Aye + Nay): revertir al council exige al
|
||
/// menos tanta comunidad como la que votó. Es un parámetro de la constitución,
|
||
/// y `integrity_test` exige que supere el bond de una propuesta.
|
||
///
|
||
/// RV17: deja de ser `#[pallet::constant]`. En el runtime es el 10 % de la
|
||
/// emisión VIGENTE, que se lee en el momento del veto y se guarda.
|
||
type MinOverrideQuorum: Get<u128>;
|
||
|
||
/// RV17: quórum del voto ORDINARIO, en OGC (PILAR_4 §6.6: «14-day period, 10%
|
||
/// quorum»). Se lee AL CREAR la propuesta y se guarda en [`VoteQuorumOf`]: un
|
||
/// runtime upgrade que cambie el parámetro no mueve la meta de un voto abierto.
|
||
/// Es la misma regla que RV16 puso al override. En el runtime es el 10 % de la
|
||
/// emisión vigente, y por eso no es `#[pallet::constant]`.
|
||
///
|
||
/// Una propuesta ordinaria se aprueba sólo si `yes > nay` Y `yes >=` su quórum.
|
||
/// Un override del veto tiene que cumplir lo mismo con su propio apoyo (alcanzar
|
||
/// el quórum y superar el Nay original). Si no, vetar ABARATARÍA la aprobación:
|
||
/// 4/5 de un quórum de override igual al ordinario es menos que el ordinario, y
|
||
/// el Nay original no votaba en la ventana (revisiones adversariales de RV17).
|
||
///
|
||
/// Cuenta sólo el Aye:
|
||
/// - si contara el Nay, un voto en contra podría APROBAR (60 Aye no llegan al
|
||
/// quórum de 100; 40 Nay más lo cruzan con 60 > 40);
|
||
/// - si contara la abstención, el mismo OGC abstenido en varias propuestas a la
|
||
/// vez cruzaría el quórum de todas sin tomar partido (la razón de RV16 para
|
||
/// `OverrideQuorum`).
|
||
///
|
||
/// La emergencia (48 h, 5/7 del council) no lo exige: la doctrina lo fija para
|
||
/// el voto de 14 días. `integrity_test` exige que supere el bond de una propuesta.
|
||
type VoteQuorum: Get<u128>;
|
||
|
||
/// FIX-6: PalletId del que se deriva la cuenta-tesorería (vault). Cuenta
|
||
/// determinística sin private key — nadie la controla por wallet, solo la
|
||
/// governance vía treasury_disbursement (Root). Honor Inmutable #16: la
|
||
/// tesorería no depende de una llave humana custodiable.
|
||
#[pallet::constant]
|
||
type TreasuryPalletId: Get<PalletId>;
|
||
|
||
/// Weight information canonical para los extrinsics de este pallet.
|
||
type WeightInfo: crate::weights::WeightInfo;
|
||
}
|
||
|
||
/// Pallet definition canonical.
|
||
///
|
||
/// StorageVersion en código (FIX-2 lifecycle, audit V1-H2/V2-CA2): arranca
|
||
/// en 0 (genesis). Habilita migraciones futuras vía on_chain_storage_version().
|
||
const STORAGE_VERSION: StorageVersion = StorageVersion::new(0);
|
||
|
||
#[pallet::pallet]
|
||
#[pallet::storage_version(STORAGE_VERSION)]
|
||
pub struct Pallet<T>(_);
|
||
|
||
#[pallet::hooks]
|
||
impl<T: Config> Hooks<BlockNumberFor<T>> for Pallet<T> {
|
||
/// RV16: el quórum de override tiene que superar el bond de una propuesta. Si
|
||
/// no, el proponente revertiría solo el veto con el bond que el propio veto le
|
||
/// devuelve (hallazgo C3), y con 0 bastaría una unidad base.
|
||
fn integrity_test() {
|
||
assert!(
|
||
T::MinOverrideQuorum::get() > MIN_PROPOSAL_STAKE,
|
||
"MinOverrideQuorum debe superar MIN_PROPOSAL_STAKE (hallazgo C3)"
|
||
);
|
||
// RV17: con un quórum menor que el bond, el proponente aprobaría solo su
|
||
// propuesta con un voto del tamaño de su fianza. Ojo: este test corre sin
|
||
// estado, así que con el quórum del runtime (10 % de la emisión) sólo ve el
|
||
// piso. El porcentaje lo pinna `quorums_del_runtime`.
|
||
assert!(
|
||
T::VoteQuorum::get() > MIN_PROPOSAL_STAKE,
|
||
"VoteQuorum debe superar MIN_PROPOSAL_STAKE (RV17)"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// RV15: por qué este pallet congela OGC.
|
||
#[pallet::composite_enum]
|
||
pub enum FreezeReason {
|
||
/// OGC que respalda un voto (o un override) que todavía puede cambiar un
|
||
/// resultado.
|
||
#[codec(index = 0)]
|
||
GovernanceVote,
|
||
}
|
||
|
||
/// RV16: votos que SOSTIENEN el veto del council durante su ventana de
|
||
/// override (la otra mitad del recuento; antes no existía y quien votó Nay no
|
||
/// podía defender el veto).
|
||
#[pallet::storage]
|
||
pub type UpholdVotes<T: Config> = StorageDoubleMap<
|
||
_,
|
||
Blake2_128Concat,
|
||
ProposalId,
|
||
Blake2_128Concat,
|
||
T::AccountId,
|
||
u128,
|
||
OptionQuery,
|
||
>;
|
||
|
||
/// RV16: OGC total que sostiene el veto, por propuesta.
|
||
#[pallet::storage]
|
||
pub type UpholdStakeTotal<T: Config> =
|
||
StorageMap<_, Blake2_128Concat, ProposalId, u128, ValueQuery>;
|
||
|
||
/// RV15: los votos vivos que respalda cada cuenta (ver [`VoteBacking`]). El
|
||
/// freeze `GovernanceVote` de la cuenta es el máximo de sus `amount`.
|
||
#[pallet::storage]
|
||
pub type VoteLocks<T: Config> = StorageMap<
|
||
_,
|
||
Blake2_128Concat,
|
||
T::AccountId,
|
||
BoundedVec<VoteBacking, T::MaxActiveVotes>,
|
||
ValueQuery,
|
||
>;
|
||
|
||
/// RV15: último bloque (inclusive) en que se acepta un override sobre una
|
||
/// propuesta vetada. Se escribe cuando el veto alcanza su umbral. Sin entrada,
|
||
/// no hay override posible: falla cerrado.
|
||
#[pallet::storage]
|
||
pub type OverrideDeadline<T: Config> =
|
||
StorageMap<_, Blake2_128Concat, ProposalId, u64, OptionQuery>;
|
||
|
||
/// RV16: quórum del override de cada propuesta vetada, FIJADO en el veto:
|
||
/// max(Aye + Nay del voto original, `MinOverrideQuorum`). Se guarda y no se
|
||
/// recalcula al decidir: un runtime upgrade que cambie el parámetro no mueve la
|
||
/// meta de una ventana ya abierta (hallazgo de la revisión de RV16). La
|
||
/// abstención no cuenta: si contara, el mismo OGC abstenido en muchas propuestas
|
||
/// a la vez inflaría el quórum de todas sin aparecer en ninguna ventana.
|
||
#[pallet::storage]
|
||
pub type OverrideQuorum<T: Config> =
|
||
StorageMap<_, Blake2_128Concat, ProposalId, u128, OptionQuery>;
|
||
|
||
/// RV17: el quórum de Aye de cada propuesta ORDINARIA, fijado al crearla
|
||
/// (`Config::VoteQuorum` en ese momento). Se consume al decidirla.
|
||
///
|
||
/// Una propuesta sin entrada no exige quórum. Son dos casos, y en los dos es lo
|
||
/// correcto:
|
||
/// - la emergencia (48 h, 5/7 del council), que la doctrina exime y que nunca lleva
|
||
/// acción de tesoro;
|
||
/// - una propuesta creada antes de RV17, que se abrió y se votó sin quórum.
|
||
///
|
||
/// Es la marca explícita del tipo. Antes se deducía del periodo.
|
||
#[pallet::storage]
|
||
pub type VoteQuorumOf<T: Config> =
|
||
StorageMap<_, Blake2_128Concat, ProposalId, u128, OptionQuery>;
|
||
|
||
/// RV17: lo que paga una propuesta de tesoro, fijado AL CREARLA
|
||
/// (`submit_treasury_proposal`): destinatario y monto. `treasury_disbursement`
|
||
/// sólo paga si coinciden exactamente, y una propuesta sin acción no paga nada.
|
||
/// Antes el monto y el destinatario los ponía quien llamaba (GSUN-3, condición 5
|
||
/// de Palace). Nadie lo modifica después: quien vota sabe qué aprueba.
|
||
#[pallet::storage]
|
||
pub type TreasuryActions<T: Config> =
|
||
StorageMap<_, Blake2_128Concat, ProposalId, (T::AccountId, u128), OptionQuery>;
|
||
|
||
/// CHN-4 canonical: `Proposals<StorageMap<ProposalId, Proposal>>` canonical.
|
||
#[pallet::storage]
|
||
#[pallet::getter(fn proposal_of)]
|
||
pub type Proposals<T: Config> =
|
||
StorageMap<_, Blake2_128Concat, ProposalId, Proposal, OptionQuery>;
|
||
|
||
/// CHN-4 canonical: `Votes<StorageDoubleMap<ProposalId, AccountId, VoteStance>>` canonical.
|
||
#[pallet::storage]
|
||
#[pallet::getter(fn vote_of)]
|
||
pub type Votes<T: Config> = StorageDoubleMap<
|
||
_,
|
||
Blake2_128Concat,
|
||
ProposalId,
|
||
Blake2_128Concat,
|
||
T::AccountId,
|
||
VoteStance,
|
||
OptionQuery,
|
||
>;
|
||
|
||
/// CHN-4 5.2 canonical: `Council<StorageMap<AccountId, CouncilMember>>` canonical.
|
||
///
|
||
/// O(1) canonical Council membership lookup. Max 7 members per `MAX_COUNCIL_SIZE`.
|
||
#[pallet::storage]
|
||
#[pallet::getter(fn council_member_of)]
|
||
pub type Council<T: Config> =
|
||
StorageMap<_, Blake2_128Concat, T::AccountId, CouncilMember, OptionQuery>;
|
||
|
||
/// CHN-4 5.2 canonical: `CouncilSize<StorageValue<u32>>` current member count canonical.
|
||
///
|
||
/// Tracked separately canonical for O(1) size check sin iterar StorageMap completo.
|
||
/// Used canonical en veto threshold calculation + add_council_member size enforcement.
|
||
#[pallet::storage]
|
||
#[pallet::getter(fn council_size)]
|
||
pub type CouncilSize<T: Config> = StorageValue<_, u32, ValueQuery>;
|
||
|
||
/// CHN-4 5.2 canonical: `VetoVotes<StorageDoubleMap<ProposalId, AccountId, ()>>` canonical.
|
||
///
|
||
/// Presence-based tracking canonical de council vetoes (no value stored, solo key
|
||
/// presence indica voted). Tally canonical computed via iterator count en finalize.
|
||
#[pallet::storage]
|
||
#[pallet::getter(fn veto_vote_of)]
|
||
pub type VetoVotes<T: Config> = StorageDoubleMap<
|
||
_,
|
||
Blake2_128Concat,
|
||
ProposalId,
|
||
Blake2_128Concat,
|
||
T::AccountId,
|
||
(),
|
||
OptionQuery,
|
||
>;
|
||
|
||
/// CHN-4 5.2 canonical: `VetoCount<StorageMap<ProposalId, u32>>` cached veto count.
|
||
///
|
||
/// Cached canonical para O(1) threshold check sin iterar DoubleMap. Incrementado
|
||
/// canonical en cada veto_proposal call.
|
||
#[pallet::storage]
|
||
#[pallet::getter(fn veto_count_of)]
|
||
pub type VetoCount<T: Config> =
|
||
StorageMap<_, Blake2_128Concat, ProposalId, u32, ValueQuery>;
|
||
|
||
/// CHN-4 5.6 v0.0.34 canonical: `AdvisorFlags<StorageMap<AccountId, ()>>` canonical.
|
||
///
|
||
/// Presence-based tracking canonical de 5 AI advisor agents (OIP Analyst + Treasury
|
||
/// Guardian + Education Impact Scorer + Network Health Monitor + Compliance Sentinel).
|
||
/// Used canonical en cast_vote para enforce humans-only vote (Inmutable #41 + CHN-4).
|
||
#[pallet::storage]
|
||
#[pallet::getter(fn advisor_flag_of)]
|
||
pub type AdvisorFlags<T: Config> =
|
||
StorageMap<_, Blake2_128Concat, T::AccountId, (), OptionQuery>;
|
||
|
||
/// Marca (presence) de que una propuesta fue rechazada POR VETO del council
|
||
/// (no por voto popular). Audit 2026-07-04: `override_council_veto` solo veía
|
||
/// `status == Rejected`, pero una propuesta rechazada por la comunidad (voto
|
||
/// en contra en `finalize_proposal`) también queda `Rejected` — y podía ser
|
||
/// resucitada a `Approved` por el override, invirtiendo el voto popular. Solo
|
||
/// las propuestas con esta marca (vetadas por el council) son override-elegibles.
|
||
#[pallet::storage]
|
||
#[pallet::getter(fn veto_rejected_of)]
|
||
pub type VetoRejected<T: Config> =
|
||
StorageMap<_, Blake2_128Concat, ProposalId, (), OptionQuery>;
|
||
|
||
/// Marca (presence) de que una propuesta Approved YA fue desembolsada. Audit
|
||
/// 2026-07-04: `treasury_disbursement` no marcaba la propuesta como gastada,
|
||
/// así que la MISMA propuesta Approved podía re-desembolsarse N veces (drenaje
|
||
/// por replay). Esta marca lo convierte en single-use.
|
||
#[pallet::storage]
|
||
#[pallet::getter(fn disbursed_of)]
|
||
pub type Disbursed<T: Config> =
|
||
StorageMap<_, Blake2_128Concat, ProposalId, (), OptionQuery>;
|
||
|
||
/// Bond REAL retenido de cada propuesta viva: `(proponente, monto)`. Audit
|
||
/// 2026-07-04: antes el `stake` era caller-supplied SIN respaldo (una cuenta
|
||
/// vacía spameaba propuestas gratis, y el `baseline` del override era
|
||
/// manipulable). Ahora `submit_proposal` transfiere el stake a la cuenta-bond
|
||
/// `og/props` y lo registra aquí; se DEVUELVE al proponente cuando la
|
||
/// propuesta se decide (finalize o veto). Presencia = bond aún retenido.
|
||
#[pallet::storage]
|
||
#[pallet::getter(fn proposal_bond_of)]
|
||
pub type ProposalBond<T: Config> =
|
||
StorageMap<_, Blake2_128Concat, ProposalId, (T::AccountId, u128), OptionQuery>;
|
||
|
||
/// Endosos de emergencia por council members, keyed por el content-id de la
|
||
/// propuesta (`blake2(title ++ body)`) → miembro → (). Audit 2026-07-04
|
||
/// (decisión B de David): antes `submit_emergency_proposal` tenía un gate 5/7
|
||
/// TAUTOLÓGICO (`council_size >= threshold(council_size)`, siempre true) y solo
|
||
/// exigía UN miembro — así que un solo council member fast-trackeaba (48h en
|
||
/// vez de 14 días). Ahora es un flujo de DOS fases: los miembros endosan aquí,
|
||
/// y `submit_emergency_proposal` exige 5/7 endosos VIVOS reales.
|
||
#[pallet::storage]
|
||
#[pallet::getter(fn emergency_endorsement_of)]
|
||
pub type EmergencyEndorsements<T: Config> = StorageDoubleMap<
|
||
_,
|
||
Blake2_128Concat,
|
||
[u8; 32],
|
||
Blake2_128Concat,
|
||
T::AccountId,
|
||
(),
|
||
OptionQuery,
|
||
>;
|
||
|
||
// FIX-6 (V2-CA2/V3-C3): la tesorería ya NO es un contador `u128` desconectado.
|
||
// El balance real vive en pallet_balances, en la cuenta-tesorería derivada del
|
||
// PalletId (`Self::treasury_account()`). Esto cierra el hallazgo del audit de
|
||
// FIX-5 (treasury_disbursement era simbólico). Ver helpers treasury_account() +
|
||
// treasury_balance() + las dispatchables treasury_disbursement/treasury_deposit.
|
||
|
||
/// CHN-4 5.5 v0.0.35 canonical: `OverrideVotes<StorageDoubleMap<ProposalId, AccountId, u128>>` canonical.
|
||
///
|
||
/// Community supermajority override votes canonical (post-council veto). Value canonical
|
||
/// es stake_weight (1 OGC = 1 vote-weight canonical). 4/5 total_stake required canonical
|
||
/// canonical para successful override per CHN-4 5.5.
|
||
#[pallet::storage]
|
||
#[pallet::getter(fn override_vote_of)]
|
||
pub type OverrideVotes<T: Config> = StorageDoubleMap<
|
||
_,
|
||
Blake2_128Concat,
|
||
ProposalId,
|
||
Blake2_128Concat,
|
||
T::AccountId,
|
||
u128,
|
||
OptionQuery,
|
||
>;
|
||
|
||
/// CHN-4 5.5 v0.0.35 canonical: `OverrideStakeTotal<StorageMap<ProposalId, u128>>` cached canonical.
|
||
///
|
||
/// Cached canonical total stake canonical de override votes para O(1) threshold check
|
||
/// sin iterar DoubleMap. Incremented canonical en cada override_council_veto call.
|
||
#[pallet::storage]
|
||
#[pallet::getter(fn override_stake_total_of)]
|
||
pub type OverrideStakeTotal<T: Config> =
|
||
StorageMap<_, Blake2_128Concat, ProposalId, u128, ValueQuery>;
|
||
|
||
/// Events emisión canonical (CHN-4 governance lifecycle).
|
||
#[pallet::event]
|
||
#[pallet::generate_deposit(pub(super) fn deposit_event)]
|
||
pub enum Event<T: Config> {
|
||
/// Proposal submitted canonical on-chain (OIP submitted CHN-4 5.3).
|
||
ProposalSubmitted {
|
||
/// ProposalId canonical content-addressed.
|
||
id: ProposalId,
|
||
/// Proposer T::AccountId canonical.
|
||
proposer: T::AccountId,
|
||
/// Voting period blocks canonical.
|
||
voting_period: u64,
|
||
},
|
||
/// Vote cast canonical sobre proposal (humans-only canonical CHN-4 5.6).
|
||
VoteCast {
|
||
/// ProposalId canonical.
|
||
id: ProposalId,
|
||
/// Voter T::AccountId canonical.
|
||
voter: T::AccountId,
|
||
/// VoteStance canonical (Aye + Nay + Abstain).
|
||
stance: VoteStance,
|
||
/// Stake weight canonical at vote time.
|
||
weight: u128,
|
||
},
|
||
/// Proposal finalized canonical post-voting period (Approved/Rejected canonical).
|
||
ProposalFinalized {
|
||
/// ProposalId canonical.
|
||
id: ProposalId,
|
||
/// Final ProposalStatus canonical (Approved | Rejected).
|
||
status: ProposalStatus,
|
||
/// Tally yes_weight canonical.
|
||
yes_weight: u128,
|
||
/// Tally nay_weight canonical.
|
||
nay_weight: u128,
|
||
},
|
||
/// Council member added canonical (Phase 5.2 CHN-4).
|
||
CouncilMemberAdded {
|
||
/// T::AccountId del new council member.
|
||
account: T::AccountId,
|
||
/// CouncilRole canonical (Member | Chair).
|
||
role: CouncilRole,
|
||
/// New council size canonical post-addition.
|
||
new_size: u32,
|
||
},
|
||
/// Council member removed canonical (Phase 5.2 CHN-4).
|
||
CouncilMemberRemoved {
|
||
/// T::AccountId del removed council member.
|
||
account: T::AccountId,
|
||
/// New council size canonical post-removal.
|
||
new_size: u32,
|
||
},
|
||
/// Council vote cast canonical sobre proposal veto (Phase 5.2 v0.0.33).
|
||
CouncilVetoVoteCast {
|
||
/// ProposalId canonical being voted on.
|
||
id: ProposalId,
|
||
/// Council member voting canonical.
|
||
voter: T::AccountId,
|
||
/// Current veto count canonical post-vote.
|
||
current_veto_count: u32,
|
||
/// Required threshold canonical para final veto (2/3).
|
||
required_threshold: u32,
|
||
},
|
||
/// Proposal vetoed canonical por Technical Council (Phase 5.2 v0.0.33).
|
||
///
|
||
/// Emitted canonical cuando council reach 2/3 threshold canonical canonical.
|
||
/// Proposal status canonical flipped to Rejected (council veto override).
|
||
ProposalVetoed {
|
||
/// ProposalId canonical vetoed.
|
||
id: ProposalId,
|
||
/// Veto count canonical at moment of veto.
|
||
veto_count: u32,
|
||
/// Council size canonical at moment of veto.
|
||
council_size: u32,
|
||
},
|
||
/// AI advisor account canonical flagged (Phase 5.6 v0.0.34).
|
||
AdvisorFlagged {
|
||
/// AccountId canonical del AI advisor (OIP Analyst, Treasury Guardian, etc.).
|
||
account: T::AccountId,
|
||
},
|
||
/// AI advisor account canonical unflagged (Phase 5.6 v0.0.34).
|
||
AdvisorUnflagged {
|
||
/// AccountId canonical del unflagged advisor.
|
||
account: T::AccountId,
|
||
},
|
||
/// Council veto canonical overridden por community supermajority (Phase 5.5 v0.0.35).
|
||
///
|
||
/// RV16: lo emite `finalize_override` cuando, cerrada la ventana, revertir
|
||
/// reunió 4/5 del mayor entre el quórum y la participación. La propuesta pasa
|
||
/// a `Approved`. (Antes se emitía al cruzar el umbral en mitad de la ventana.)
|
||
CouncilVetoOverridden {
|
||
/// Propuesta cuyo veto se revierte.
|
||
id: ProposalId,
|
||
/// OGC que votó revertir.
|
||
override_stake: u128,
|
||
/// RV16: participación total en la ventana (revertir + sostener). Antes era
|
||
/// la base del umbral viejo (stake + yes + nay + abstain de la propuesta).
|
||
total_community_stake: u128,
|
||
},
|
||
/// Emergency proposal submitted canonical (Phase 5.7 v0.0.35).
|
||
///
|
||
/// Fast-track canonical canonical via 48h voting period en lugar de 14 days.
|
||
/// Requires canonical 5/7 council canonical pre-approval.
|
||
EmergencyProposalSubmitted {
|
||
/// ProposalId canonical emergency.
|
||
id: ProposalId,
|
||
/// Council pre-approval count canonical.
|
||
council_approvals: u32,
|
||
},
|
||
/// Audit 2026-07-04 (B): un council member endosó una propuesta de
|
||
/// emergencia (fase 1 del flujo 5/7). Cuando `live_endorsements` alcanza
|
||
/// `required`, cualquier miembro puede promoverla vía submit_emergency.
|
||
EmergencyEndorsed {
|
||
/// Content-id (blake2 de title++body) que se endosa.
|
||
content_id: [u8; 32],
|
||
/// El council member que endosa.
|
||
endorser: T::AccountId,
|
||
/// Endosos vivos actuales (solo miembros aún en el council).
|
||
live_endorsements: u32,
|
||
/// Umbral 5/7 requerido sobre el council actual.
|
||
required: u32,
|
||
},
|
||
/// Treasury disbursement canonical (Phase 5.8 v0.0.35).
|
||
///
|
||
/// Emitted canonical cuando Approved proposal canonical canonical canonical disburses
|
||
/// canonical funds canonical desde Treasury.
|
||
TreasuryDisbursed {
|
||
/// ProposalId canonical del approved proposal canonical.
|
||
proposal_id: ProposalId,
|
||
/// Recipient T::AccountId canonical.
|
||
recipient: T::AccountId,
|
||
/// Amount canonical canonical OGC base units.
|
||
amount: u128,
|
||
/// Treasury balance canonical post-disbursement.
|
||
remaining_balance: u128,
|
||
},
|
||
/// RV15: se liberó OGC que respaldaba votos que ya no pueden cambiar un
|
||
/// resultado. Va AL FINAL del enum a propósito: insertarlo en medio desplazaba
|
||
/// el índice SCALE de tres eventos existentes (revisión adversaria de RV15).
|
||
VoteFundsUnlocked {
|
||
/// Cuenta cuyo OGC se libera.
|
||
who: T::AccountId,
|
||
/// OGC que deja de estar congelado.
|
||
released: u128,
|
||
/// OGC que sigue congelado por votos todavía vivos.
|
||
still_frozen: u128,
|
||
},
|
||
/// RV16: un voto en la ventana de override de un veto (para el indexer del
|
||
/// portal: antes un voto de override no emitía nada hasta el desenlace).
|
||
OverrideVoteCast {
|
||
/// Propuesta vetada.
|
||
id: ProposalId,
|
||
/// Quien vota.
|
||
voter: T::AccountId,
|
||
/// `true` = sostiene el veto; `false` = vota revertirlo.
|
||
uphold: bool,
|
||
/// OGC que respalda el voto (congelado hasta el cierre de la ventana).
|
||
weight: u128,
|
||
},
|
||
/// RV16: se cerró la ventana y el veto se SOSTIENE (el override no alcanzó
|
||
/// 4/5 de la participación o no hubo quórum).
|
||
CouncilVetoUpheld {
|
||
/// Propuesta vetada.
|
||
id: ProposalId,
|
||
/// OGC que votó revertir el veto.
|
||
override_stake: u128,
|
||
/// OGC que votó sostenerlo.
|
||
uphold_stake: u128,
|
||
/// Quórum que se exigía.
|
||
quorum: u128,
|
||
},
|
||
/// RV17: la propuesta tuvo más Aye que Nay pero no llegó al quórum del voto
|
||
/// ordinario, y se rechaza. Va además del `ProposalFinalized` con `Rejected`,
|
||
/// para que se lea POR QUÉ.
|
||
ProposalRejectedForQuorum {
|
||
/// Propuesta rechazada.
|
||
id: ProposalId,
|
||
/// OGC que votó Aye (lo único que cuenta para el quórum).
|
||
yes_weight: u128,
|
||
/// Quórum que se exigía.
|
||
quorum: u128,
|
||
},
|
||
/// RV17: se creó una propuesta de tesoro con su acción fijada.
|
||
TreasuryProposalSubmitted {
|
||
/// Propuesta creada.
|
||
id: ProposalId,
|
||
/// Quien cobrará si se aprueba.
|
||
recipient: T::AccountId,
|
||
/// Cuánto, en unidades base.
|
||
amount: u128,
|
||
},
|
||
}
|
||
|
||
/// Pallet errors canonical.
|
||
#[pallet::error]
|
||
pub enum Error<T> {
|
||
/// CHN-4 canonical: proposal NO encontrado on-chain.
|
||
ProposalNotFound,
|
||
/// CHN-4 canonical: voter ya cast vote previamente (double-vote prevention).
|
||
AlreadyVoted,
|
||
/// CHN-4 canonical: voting period canonical CLOSED (block.number > end_block).
|
||
VotingPeriodClosed,
|
||
/// CHN-4 canonical: voting period STILL OPEN (cannot finalize yet).
|
||
VotingPeriodOpen,
|
||
/// CHN-4 5.3 canonical: stake canonical insufficient (mínimum 1,000 OGC).
|
||
StakeInsufficient,
|
||
/// CHN-4 canonical: proposal ya finalized (status != Voting).
|
||
ProposalAlreadyFinalized,
|
||
/// CHN-4 5.6 canonical: governance_advisor canonical NO puede votar (humans-only canonical).
|
||
AdvisorMayNotVote,
|
||
/// CHN-4 canonical: proposer canonical ya existe con ese hash content (duplicate prevention).
|
||
ProposalIdCollision,
|
||
/// CHN-4 5.2 canonical: account NO es council member (cannot veto).
|
||
NotCouncilMember,
|
||
/// CHN-4 5.2 canonical: account ya council member (duplicate add prevention).
|
||
AlreadyCouncilMember,
|
||
/// CHN-4 5.2 canonical: council size canonical exceeds MAX_COUNCIL_SIZE.
|
||
CouncilSizeExceeded,
|
||
/// CHN-4 5.2 v0.0.33 canonical: council member ya vetó este proposal.
|
||
AlreadyVetoed,
|
||
/// CHN-4 5.2 v0.0.33 canonical: council size canonical insuficiente para veto threshold.
|
||
InsufficientCouncilSize,
|
||
/// CHN-4 5.6 v0.0.34 canonical: account ya canonical es advisor flagged.
|
||
AlreadyAdvisor,
|
||
/// CHN-4 5.6 v0.0.34 canonical: account NO es advisor flagged (cannot unflag).
|
||
NotAdvisor,
|
||
/// CHN-4 5.5 v0.0.35 canonical: community override threshold NO alcanzado.
|
||
OverrideThresholdNotMet,
|
||
/// CHN-4 5.5 v0.0.35 canonical: proposal canonical NO está Rejected canonical (cannot override).
|
||
ProposalNotRejected,
|
||
/// CHN-4 5.5 v0.0.35 canonical: voter canonical ya cast override vote (double-vote prevention).
|
||
AlreadyOverrideVoted,
|
||
/// CHN-4 5.7 v0.0.35 canonical: emergency proposal canonical requires 5/7 council pre-approval.
|
||
InsufficientCouncilEmergencyApprovals,
|
||
/// CHN-4 5.8 v0.0.35 canonical: treasury balance canonical insuficiente.
|
||
TreasuryInsufficientBalance,
|
||
/// CHN-4 5.8 v0.0.35 canonical: proposal NO canonical Approved (cannot disburse Treasury).
|
||
ProposalNotApproved,
|
||
/// Audit 2026-07-04: la propuesta Rejected NO fue vetada por el council
|
||
/// (fue rechazada por voto popular) — el override solo aplica a vetos.
|
||
ProposalNotVetoRejected,
|
||
/// Audit 2026-07-04: la propuesta Approved ya fue desembolsada (single-use,
|
||
/// anti-replay del treasury_disbursement).
|
||
AlreadyDisbursed,
|
||
/// Audit 2026-07-04: el proponente NO tiene balance libre para el bond del
|
||
/// stake (el stake ahora se retiene de verdad — piel en el juego anti-spam).
|
||
StakeInsufficientBalance,
|
||
/// Audit 2026-07-04 (B): el council member ya endosó esta propuesta de
|
||
/// emergencia (anti doble-endoso).
|
||
AlreadyEndorsed,
|
||
/// RV15: el peso del voto supera el OGC de la cuenta. El peso sale del
|
||
/// saldo, no de lo que declara el votante (hueco medido por Palace el
|
||
/// 2026-09-06).
|
||
VoteWeightExceedsBalance,
|
||
/// RV15: la ventana para revertir este veto ya cerró (o la propuesta no
|
||
/// tiene ventana registrada).
|
||
OverrideWindowClosed,
|
||
/// RV15: la cuenta no tiene OGC congelado por votos.
|
||
NoVoteLock,
|
||
/// RV15: ninguno de los votos de la cuenta ha vencido todavía; su OGC sigue
|
||
/// congelado.
|
||
VoteFundsStillLocked,
|
||
/// RV15: el periodo de voto pedido supera `MAX_VOTING_PERIOD_BLOCKS`.
|
||
VotingPeriodTooLong,
|
||
/// RV15: la cuenta ya respalda `MaxActiveVotes` votos vivos.
|
||
TooManyActiveVotes,
|
||
/// RV16: el periodo de voto pedido es menor que `MIN_VOTING_PERIOD_BLOCKS`.
|
||
VotingPeriodTooShort,
|
||
/// RV16: la ventana de override sigue abierta; el resultado se decide al
|
||
/// cerrarse, no al cruzar un umbral a mitad de camino.
|
||
OverrideWindowOpen,
|
||
/// RV16: esta propuesta no tiene un override pendiente de decidir.
|
||
OverrideNotPending,
|
||
/// RV16: un voto de la ventana de override tiene que pesar más de 0.
|
||
ZeroWeightWindowVote,
|
||
/// RV17: el desembolso no coincide con la acción fijada al crear la propuesta,
|
||
/// o la propuesta no tiene acción de tesoro.
|
||
TreasuryActionMismatch,
|
||
/// RV17: una propuesta de tesoro tiene que pagar al menos el depósito
|
||
/// existencial. Con menos, el pago a una cuenta que no existe fallaría siempre y
|
||
/// la propuesta quedaría aprobada e impagable.
|
||
TreasuryAmountBelowMinimum,
|
||
/// RV17: el destinatario no puede ser la propia tesorería ni la cuenta-bond. Una
|
||
/// transferencia a sí misma devuelve Ok sin mover nada (se marcaría pagada sin
|
||
/// pago), y lo que llega a la cuenta-bond queda atrapado.
|
||
TreasuryRecipientInvalid,
|
||
}
|
||
|
||
/// Minimum stake canonical proposal submission per CHN-4 5.3.
|
||
/// 1,000 OGC = 1_000 × 10^18 (18 decimals OGC canonical).
|
||
pub const MIN_PROPOSAL_STAKE: u128 = 1_000 * 10u128.pow(18);
|
||
|
||
/// PalletId de la cuenta-bond de propuestas (`og/props`) — escrow determinista
|
||
/// sin llave humana donde vive el stake retenido mientras la propuesta está
|
||
/// viva (audit 2026-07-04). Separada del vault de tesorería (`og/gvtry`) para
|
||
/// que `treasury_disbursement` JAMÁS pueda gastar un bond de propuesta.
|
||
pub const PROPOSAL_BOND_PALLET_ID: PalletId = PalletId(*b"og/props");
|
||
|
||
/// Dispatchables canonical (real impl v0.0.30).
|
||
#[pallet::call]
|
||
impl<T: Config> Pallet<T> {
|
||
/// CHN-4 5.3 canonical: `submit_proposal` real dispatchable canonical.
|
||
///
|
||
/// 7-step canonical flow: ensure_signed + validate_stake + derive_id +
|
||
/// collision_check + build + insert + emit.
|
||
#[pallet::call_index(0)]
|
||
#[pallet::weight(T::WeightInfo::submit_proposal())]
|
||
pub fn submit_proposal(
|
||
origin: OriginFor<T>,
|
||
title_hash: [u8; 32],
|
||
body_hash: [u8; 32],
|
||
voting_period_blocks: u64,
|
||
stake: u128,
|
||
) -> DispatchResult {
|
||
let who = ensure_signed(origin)?;
|
||
Self::do_submit_proposal(who, title_hash, body_hash, voting_period_blocks, stake)?;
|
||
Ok(())
|
||
}
|
||
|
||
/// RV17: crea una propuesta de TESORO. Es una propuesta ordinaria (mismo bond,
|
||
/// mismo periodo y mismo quórum) que además fija qué paga si se aprueba:
|
||
/// `recipient` y `amount`. Quedan en `TreasuryActions` y nadie los cambia
|
||
/// después. `treasury_disbursement` sólo puede pagar exactamente esto.
|
||
#[pallet::call_index(16)]
|
||
#[pallet::weight(T::WeightInfo::submit_treasury_proposal())]
|
||
pub fn submit_treasury_proposal(
|
||
origin: OriginFor<T>,
|
||
title_hash: [u8; 32],
|
||
body_hash: [u8; 32],
|
||
voting_period_blocks: u64,
|
||
stake: u128,
|
||
recipient: T::AccountId,
|
||
amount: u128,
|
||
) -> DispatchResult {
|
||
let who = ensure_signed(origin)?;
|
||
ensure!(
|
||
amount >= T::Currency::minimum_balance().max(1),
|
||
Error::<T>::TreasuryAmountBelowMinimum
|
||
);
|
||
ensure!(
|
||
recipient != Self::treasury_account() && recipient != Self::proposal_bond_account(),
|
||
Error::<T>::TreasuryRecipientInvalid
|
||
);
|
||
let id =
|
||
Self::do_submit_proposal(who, title_hash, body_hash, voting_period_blocks, stake)?;
|
||
TreasuryActions::<T>::insert(id, (recipient.clone(), amount));
|
||
Self::deposit_event(Event::TreasuryProposalSubmitted { id, recipient, amount });
|
||
Ok(())
|
||
}
|
||
|
||
/// CHN-4 5.4 canonical: `cast_vote` real dispatchable canonical.
|
||
#[pallet::call_index(1)]
|
||
#[pallet::weight(T::WeightInfo::cast_vote())]
|
||
pub fn cast_vote(
|
||
origin: OriginFor<T>,
|
||
proposal_id: ProposalId,
|
||
stance: VoteStance,
|
||
stake_weight: u128,
|
||
) -> DispatchResult {
|
||
let who = ensure_signed(origin)?;
|
||
|
||
// CHN-4 5.6 v0.0.34 canonical: AdvisorMayNotVote enforcement (humans-only)
|
||
ensure!(
|
||
!AdvisorFlags::<T>::contains_key(&who),
|
||
Error::<T>::AdvisorMayNotVote
|
||
);
|
||
|
||
let mut proposal = Proposals::<T>::get(proposal_id)
|
||
.ok_or(Error::<T>::ProposalNotFound)?;
|
||
|
||
ensure!(
|
||
matches!(proposal.status, ProposalStatus::Voting),
|
||
Error::<T>::ProposalAlreadyFinalized
|
||
);
|
||
|
||
let current_block: u64 = <frame_system::Pallet<T>>::block_number()
|
||
.saturated_into::<u64>();
|
||
ensure!(
|
||
current_block <= proposal.end_block,
|
||
Error::<T>::VotingPeriodClosed
|
||
);
|
||
|
||
ensure!(
|
||
!Votes::<T>::contains_key(proposal_id, &who),
|
||
Error::<T>::AlreadyVoted
|
||
);
|
||
|
||
// RV15: el peso sale del saldo y queda congelado hasta que el voto ya no
|
||
// pueda cambiar el resultado (fin del periodo de voto). Sin el freeze,
|
||
// el mismo OGC votaría otra vez desde otra cuenta tras una transferencia.
|
||
Self::back_vote_with_funds(
|
||
&who,
|
||
proposal_id,
|
||
BackingKind::Vote,
|
||
stake_weight,
|
||
proposal.end_block,
|
||
)?;
|
||
|
||
match stance {
|
||
VoteStance::Aye => {
|
||
proposal.yes_weight = proposal.yes_weight.saturating_add(stake_weight)
|
||
}
|
||
VoteStance::Nay => {
|
||
proposal.nay_weight = proposal.nay_weight.saturating_add(stake_weight)
|
||
}
|
||
VoteStance::Abstain => {
|
||
proposal.abstain_weight =
|
||
proposal.abstain_weight.saturating_add(stake_weight)
|
||
}
|
||
}
|
||
|
||
Proposals::<T>::insert(proposal_id, proposal);
|
||
Votes::<T>::insert(proposal_id, &who, stance);
|
||
|
||
Self::deposit_event(Event::VoteCast {
|
||
id: proposal_id,
|
||
voter: who,
|
||
stance,
|
||
weight: stake_weight,
|
||
});
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// CHN-4 canonical: `finalize_proposal` real dispatchable canonical.
|
||
#[pallet::call_index(2)]
|
||
#[pallet::weight(T::WeightInfo::finalize_proposal())]
|
||
pub fn finalize_proposal(
|
||
origin: OriginFor<T>,
|
||
proposal_id: ProposalId,
|
||
) -> DispatchResult {
|
||
let _who = ensure_signed(origin)?;
|
||
|
||
let mut proposal = Proposals::<T>::get(proposal_id)
|
||
.ok_or(Error::<T>::ProposalNotFound)?;
|
||
|
||
ensure!(
|
||
matches!(proposal.status, ProposalStatus::Voting),
|
||
Error::<T>::ProposalAlreadyFinalized
|
||
);
|
||
|
||
let current_block: u64 = <frame_system::Pallet<T>>::block_number()
|
||
.saturated_into::<u64>();
|
||
ensure!(
|
||
current_block > proposal.end_block,
|
||
Error::<T>::VotingPeriodOpen
|
||
);
|
||
|
||
// RV17: además de la mayoría, el voto ordinario exige el quórum de Aye que se
|
||
// fijó al crearlo (ver `VoteQuorumOf`). Sin entrada (emergencia, o creada
|
||
// antes de RV17), no hay quórum.
|
||
let quorum = VoteQuorumOf::<T>::take(proposal_id).unwrap_or(0);
|
||
let mayoria = proposal.yes_weight > proposal.nay_weight;
|
||
let sin_quorum = mayoria && proposal.yes_weight < quorum;
|
||
let final_status = if mayoria && !sin_quorum {
|
||
ProposalStatus::Approved
|
||
} else {
|
||
ProposalStatus::Rejected
|
||
};
|
||
|
||
proposal.status = final_status;
|
||
let yes_weight = proposal.yes_weight;
|
||
let nay_weight = proposal.nay_weight;
|
||
|
||
Proposals::<T>::insert(proposal_id, proposal);
|
||
|
||
// Audit 2026-07-04: la propuesta salió de Voting → devuelve el bond al
|
||
// proponente (el stake es anti-spam, no un castigo por perder el voto).
|
||
Self::release_proposal_bond(proposal_id);
|
||
|
||
Self::deposit_event(Event::ProposalFinalized {
|
||
id: proposal_id,
|
||
status: final_status,
|
||
yes_weight,
|
||
nay_weight,
|
||
});
|
||
if sin_quorum {
|
||
Self::deposit_event(Event::ProposalRejectedForQuorum {
|
||
id: proposal_id,
|
||
yes_weight,
|
||
quorum,
|
||
});
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// CHN-4 5.2 canonical: `add_council_member` Phase 5.2 dispatchable canonical.
|
||
///
|
||
/// 6-step canonical flow: ensure_root + size_check + no_duplicate + build +
|
||
/// insert + emit.
|
||
#[pallet::call_index(3)]
|
||
#[pallet::weight(T::WeightInfo::add_council_member())]
|
||
pub fn add_council_member(
|
||
origin: OriginFor<T>,
|
||
account: T::AccountId,
|
||
role: CouncilRole,
|
||
) -> DispatchResult {
|
||
ensure_root(origin)?;
|
||
|
||
let current_size = CouncilSize::<T>::get();
|
||
ensure!(
|
||
current_size < MAX_COUNCIL_SIZE,
|
||
Error::<T>::CouncilSizeExceeded
|
||
);
|
||
|
||
ensure!(
|
||
!Council::<T>::contains_key(&account),
|
||
Error::<T>::AlreadyCouncilMember
|
||
);
|
||
|
||
let block_number: u64 = <frame_system::Pallet<T>>::block_number()
|
||
.saturated_into::<u64>();
|
||
let member = CouncilMember {
|
||
account: origin_chain_types::AccountId::new(account_bytes::<T>(&account)),
|
||
joined_at_block: block_number,
|
||
role,
|
||
};
|
||
|
||
Council::<T>::insert(&account, member);
|
||
let new_size = current_size.saturating_add(1);
|
||
CouncilSize::<T>::put(new_size);
|
||
|
||
Self::deposit_event(Event::CouncilMemberAdded {
|
||
account,
|
||
role,
|
||
new_size,
|
||
});
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// CHN-4 5.2 canonical: `remove_council_member` Phase 5.2 dispatchable canonical.
|
||
///
|
||
/// 5-step canonical flow: ensure_root + validate_exists + remove + decrement + emit.
|
||
#[pallet::call_index(4)]
|
||
#[pallet::weight(T::WeightInfo::remove_council_member())]
|
||
pub fn remove_council_member(
|
||
origin: OriginFor<T>,
|
||
account: T::AccountId,
|
||
) -> DispatchResult {
|
||
ensure_root(origin)?;
|
||
|
||
ensure!(
|
||
Council::<T>::contains_key(&account),
|
||
Error::<T>::NotCouncilMember
|
||
);
|
||
|
||
Council::<T>::remove(&account);
|
||
let current_size = CouncilSize::<T>::get();
|
||
let new_size = current_size.saturating_sub(1);
|
||
CouncilSize::<T>::put(new_size);
|
||
|
||
Self::deposit_event(Event::CouncilMemberRemoved {
|
||
account,
|
||
new_size,
|
||
});
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// CHN-4 5.2 v0.0.33 canonical: `veto_proposal` Phase 5.2 dispatchable canonical.
|
||
///
|
||
/// 7-step canonical flow:
|
||
/// 1. ensure_signed canonical
|
||
/// 2. Validate council member canonical (`Error::NotCouncilMember`)
|
||
/// 3. Get proposal canonical (must exist + Voting status)
|
||
/// 4. Validate council size > 0 (`Error::InsufficientCouncilSize`)
|
||
/// 5. Validate !VetoVotes::contains_key (Error::AlreadyVetoed)
|
||
/// 6. Insert vote + increment count
|
||
/// 7. Check threshold canonical: if count >= required → flip status to Rejected
|
||
/// + emit ProposalVetoed canonical; else emit CouncilVetoVoteCast canonical
|
||
#[pallet::call_index(5)]
|
||
#[pallet::weight(T::WeightInfo::veto_proposal())]
|
||
pub fn veto_proposal(
|
||
origin: OriginFor<T>,
|
||
proposal_id: ProposalId,
|
||
) -> DispatchResult {
|
||
// Step 1: ensure signed canonical
|
||
let who = ensure_signed(origin)?;
|
||
|
||
// Step 2: validate council member canonical
|
||
ensure!(
|
||
Council::<T>::contains_key(&who),
|
||
Error::<T>::NotCouncilMember
|
||
);
|
||
|
||
// Step 3: get proposal canonical
|
||
let mut proposal = Proposals::<T>::get(proposal_id)
|
||
.ok_or(Error::<T>::ProposalNotFound)?;
|
||
|
||
ensure!(
|
||
matches!(proposal.status, ProposalStatus::Voting),
|
||
Error::<T>::ProposalAlreadyFinalized
|
||
);
|
||
|
||
// RV16: el veto solo cabe DURANTE el periodo de voto. Antes se podía vetar
|
||
// entre end_block y el finalize, y el resultado dependía de quién llamara
|
||
// primero (carrera entre el council y cualquiera que finalizara).
|
||
ensure!(
|
||
current_block_u64::<T>() <= proposal.end_block,
|
||
Error::<T>::VotingPeriodClosed
|
||
);
|
||
|
||
// Step 4: council size canonical > 0
|
||
let council_size = CouncilSize::<T>::get();
|
||
ensure!(council_size > 0, Error::<T>::InsufficientCouncilSize);
|
||
|
||
// Step 5: AlreadyVetoed canonical
|
||
ensure!(
|
||
!VetoVotes::<T>::contains_key(proposal_id, &who),
|
||
Error::<T>::AlreadyVetoed
|
||
);
|
||
|
||
// Step 6: insert vote
|
||
VetoVotes::<T>::insert(proposal_id, &who, ());
|
||
|
||
// Step 7: cuenta SOLO los vetos de miembros AÚN en el council (audit
|
||
// 2026-07-04). Antes se usaba un contador MONÓTONO (VetoCount++), así
|
||
// que (a) el veto de un miembro REMOVIDO seguía contando, y (b) al
|
||
// encoger el council el umbral bajaba retroactivamente → una minoría
|
||
// (o ex-miembros) podía vetar, derrotando el safeguard 2/3. El conteo
|
||
// en vivo (Council ≤ 7 ⇒ O(7)) elimina los votos huérfanos.
|
||
let live_count = VetoVotes::<T>::iter_prefix(proposal_id)
|
||
.filter(|(acct, _)| Council::<T>::contains_key(acct))
|
||
.count() as u32;
|
||
VetoCount::<T>::insert(proposal_id, live_count); // cache = lo vivo
|
||
|
||
let required = council_veto_threshold(council_size);
|
||
if live_count >= required {
|
||
// RV16: el quórum del override se fija AQUÍ, con la participación del
|
||
// voto original (Aye + Nay; la abstención no cuenta).
|
||
let turnout = proposal.yes_weight.saturating_add(proposal.nay_weight);
|
||
OverrideQuorum::<T>::insert(proposal_id, turnout.max(T::MinOverrideQuorum::get()));
|
||
// Veto threshold reached canonical → flip status canonical
|
||
proposal.status = ProposalStatus::Rejected;
|
||
Proposals::<T>::insert(proposal_id, proposal);
|
||
// Marca: rechazado por VETO del council (no por voto popular) →
|
||
// única vía elegible para override_council_veto.
|
||
VetoRejected::<T>::insert(proposal_id, ());
|
||
// RV15: el override tiene ventana, contada desde este bloque.
|
||
OverrideDeadline::<T>::insert(
|
||
proposal_id,
|
||
current_block_u64::<T>().saturating_add(OVERRIDE_WINDOW_BLOCKS),
|
||
);
|
||
// Audit 2026-07-04: la propuesta salió de Voting (vetada) → devuelve
|
||
// el bond al proponente (si no, el stake de una propuesta vetada
|
||
// quedaría atrapado para siempre — no hay withdraw dispatchable).
|
||
Self::release_proposal_bond(proposal_id);
|
||
|
||
Self::deposit_event(Event::ProposalVetoed {
|
||
id: proposal_id,
|
||
veto_count: live_count,
|
||
council_size,
|
||
});
|
||
} else {
|
||
Self::deposit_event(Event::CouncilVetoVoteCast {
|
||
id: proposal_id,
|
||
voter: who,
|
||
current_veto_count: live_count,
|
||
required_threshold: required,
|
||
});
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// CHN-4 5.6 v0.0.34 canonical: `mark_as_advisor` Phase 5.6 dispatchable.
|
||
///
|
||
/// Flag canonical account canonical como AI advisor (5 agents canonical OIP
|
||
/// Analyst + Treasury Guardian + Education Impact + Network Health + Compliance
|
||
/// Sentinel). Advisor canonical NO puede votar — humans-only vote canonical
|
||
/// non-negotiable per CHN-4 + Inmutable #41.
|
||
#[pallet::call_index(6)]
|
||
#[pallet::weight(T::WeightInfo::mark_as_advisor())]
|
||
pub fn mark_as_advisor(
|
||
origin: OriginFor<T>,
|
||
account: T::AccountId,
|
||
) -> DispatchResult {
|
||
ensure_root(origin)?;
|
||
ensure!(
|
||
!AdvisorFlags::<T>::contains_key(&account),
|
||
Error::<T>::AlreadyAdvisor
|
||
);
|
||
AdvisorFlags::<T>::insert(&account, ());
|
||
Self::deposit_event(Event::AdvisorFlagged { account });
|
||
Ok(())
|
||
}
|
||
|
||
/// CHN-4 5.6 v0.0.34 canonical: `unmark_as_advisor` Phase 5.6 dispatchable.
|
||
#[pallet::call_index(7)]
|
||
#[pallet::weight(T::WeightInfo::unmark_as_advisor())]
|
||
pub fn unmark_as_advisor(
|
||
origin: OriginFor<T>,
|
||
account: T::AccountId,
|
||
) -> DispatchResult {
|
||
ensure_root(origin)?;
|
||
ensure!(
|
||
AdvisorFlags::<T>::contains_key(&account),
|
||
Error::<T>::NotAdvisor
|
||
);
|
||
AdvisorFlags::<T>::remove(&account);
|
||
Self::deposit_event(Event::AdvisorUnflagged { account });
|
||
Ok(())
|
||
}
|
||
|
||
/// CHN-4 5.5 + RV16: votar REVERTIR un veto del council durante su ventana.
|
||
///
|
||
/// Doctrina: el council veta con 2/3 y la comunidad revierte con 4/5
|
||
/// (`docs/ORIGIN_DAO_DONDE_VIVE_V1.md`). RV16 hace real ese 4/5 (hallazgo C3 de
|
||
/// SUPER_AUDIT_V1, demostrado otra vez por la revisión de RV15):
|
||
/// - el voto NO voltea nada al cruzar un umbral; el resultado se decide al
|
||
/// cerrarse la ventana, en [`Pallet::finalize_override`];
|
||
/// - el 4/5 es de la PARTICIPACIÓN en la ventana (revertir + sostener), no de
|
||
/// los recuentos de la propia propuesta: antes contaba el bond del proponente,
|
||
/// que el veto le devuelve y que luego podía votar;
|
||
/// - quien quiere el veto puede defenderlo con [`Pallet::uphold_council_veto`];
|
||
/// - hace falta quórum.
|
||
///
|
||
/// El peso sale del saldo y queda congelado hasta el cierre de la ventana (RV15).
|
||
/// Una cuenta vota una vez en la ventana, en un solo lado.
|
||
#[pallet::call_index(8)]
|
||
#[pallet::weight(T::WeightInfo::override_council_veto())]
|
||
pub fn override_council_veto(
|
||
origin: OriginFor<T>,
|
||
proposal_id: ProposalId,
|
||
stake_weight: u128,
|
||
) -> DispatchResult {
|
||
let who = ensure_signed(origin)?;
|
||
Self::cast_override_window_vote(who, proposal_id, stake_weight, false)
|
||
}
|
||
|
||
/// CHN-4 5.7 v0.0.35 canonical: `submit_emergency_proposal` Phase 5.7 dispatchable.
|
||
///
|
||
/// Fast-track canonical canonical proposal canonical con 48h voting period en
|
||
/// lugar de 14 days canonical. Pre-approval canonical canonical 5/7 council
|
||
/// required canonical per CHN-4 5.7.
|
||
///
|
||
/// **NOTE**: Para simplicity canonical scaffold canonical, this dispatchable
|
||
/// requires caller canonical ser council member canonical y crea proposal con
|
||
/// EMERGENCY_VOTING_PERIOD_BLOCKS. Phase 5.7+ refinement canonical: integrate
|
||
/// canonical 5/7 council pre-approval flow (gather signatures via separate
|
||
/// dispatchable y luego promote canonical).
|
||
#[pallet::call_index(9)]
|
||
#[pallet::weight(T::WeightInfo::submit_emergency_proposal())]
|
||
pub fn submit_emergency_proposal(
|
||
origin: OriginFor<T>,
|
||
title_hash: [u8; 32],
|
||
body_hash: [u8; 32],
|
||
stake: u128,
|
||
) -> DispatchResult {
|
||
let who = ensure_signed(origin)?;
|
||
|
||
ensure!(
|
||
Council::<T>::contains_key(&who),
|
||
Error::<T>::NotCouncilMember
|
||
);
|
||
|
||
ensure!(stake >= MIN_PROPOSAL_STAKE, Error::<T>::StakeInsufficient);
|
||
|
||
// Audit 2026-07-04 (B): gate 5/7 REAL (antes era tautológico:
|
||
// `council_size >= threshold(council_size)`, siempre true, y solo
|
||
// exigía UN miembro). Ahora exige que 5/7 miembros VIVOS hayan endosado
|
||
// este contenido (vía endorse_emergency_proposal) ANTES de fast-trackear.
|
||
let council_size = CouncilSize::<T>::get();
|
||
let required = council_emergency_threshold(council_size);
|
||
let content_id = crate::emergency_content_id(&title_hash, &body_hash);
|
||
let live_endorsements = EmergencyEndorsements::<T>::iter_prefix(content_id)
|
||
.filter(|(acct, _)| Council::<T>::contains_key(acct))
|
||
.count() as u32;
|
||
ensure!(
|
||
live_endorsements >= required,
|
||
Error::<T>::InsufficientCouncilEmergencyApprovals
|
||
);
|
||
|
||
let proposer_acct = origin_chain_types::AccountId::new(account_bytes::<T>(&who));
|
||
let id = crate::derive_proposal_id(&proposer_acct, &title_hash, &body_hash);
|
||
ensure!(
|
||
!Proposals::<T>::contains_key(id),
|
||
Error::<T>::ProposalIdCollision
|
||
);
|
||
|
||
// Audit 2026-07-04: bond real, igual que submit_proposal (piel en el
|
||
// juego; se devuelve al proponente cuando la propuesta se decide).
|
||
T::Currency::transfer(
|
||
&who,
|
||
&Self::proposal_bond_account(),
|
||
stake,
|
||
Preservation::Preserve,
|
||
)
|
||
.map_err(|_| Error::<T>::StakeInsufficientBalance)?;
|
||
ProposalBond::<T>::insert(id, (who.clone(), stake));
|
||
|
||
let start_block: u64 = <frame_system::Pallet<T>>::block_number()
|
||
.saturated_into::<u64>();
|
||
let end_block = start_block.saturating_add(EMERGENCY_VOTING_PERIOD_BLOCKS);
|
||
|
||
let proposal = Proposal {
|
||
id,
|
||
proposer: proposer_acct,
|
||
title_hash,
|
||
body_hash,
|
||
stake,
|
||
start_block,
|
||
end_block,
|
||
status: ProposalStatus::Voting,
|
||
yes_weight: 0,
|
||
nay_weight: 0,
|
||
abstain_weight: 0,
|
||
};
|
||
|
||
Proposals::<T>::insert(id, proposal);
|
||
|
||
// Limpia los endosos consumidos (evita re-promover con los mismos y
|
||
// libera storage). `clear_prefix` acotado por el council (≤ 7).
|
||
let _ = EmergencyEndorsements::<T>::clear_prefix(content_id, MAX_COUNCIL_SIZE, None);
|
||
|
||
Self::deposit_event(Event::EmergencyProposalSubmitted {
|
||
id,
|
||
council_approvals: live_endorsements,
|
||
});
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// CHN-4 5.8 v0.0.35 canonical: `treasury_disbursement` Phase 5.8 dispatchable.
|
||
///
|
||
/// Disbursement canonical canonical desde Treasury canonical balance canonical
|
||
/// to recipient canonical. Solo Approved canonical proposals canonical pueden
|
||
/// disburse (governance ratificación required canonical canonical).
|
||
///
|
||
/// 6-step canonical flow:
|
||
/// 1. ensure_root canonical (governance-approved canonical only canonical)
|
||
/// 2. Get proposal canonical (must canonical exist + Approved status)
|
||
/// 3. Validate amount canonical canonical <= Treasury balance canonical
|
||
/// 4. Decrement Treasury balance
|
||
/// 5. (Phase 5.9+ refinement canonical: transfer via pallet_balances integration)
|
||
/// 6. Emit TreasuryDisbursed canonical
|
||
#[pallet::call_index(10)]
|
||
#[pallet::weight(T::WeightInfo::treasury_disbursement())]
|
||
pub fn treasury_disbursement(
|
||
origin: OriginFor<T>,
|
||
proposal_id: ProposalId,
|
||
recipient: T::AccountId,
|
||
amount: u128,
|
||
) -> DispatchResult {
|
||
ensure_root(origin)?;
|
||
|
||
let proposal = Proposals::<T>::get(proposal_id)
|
||
.ok_or(Error::<T>::ProposalNotFound)?;
|
||
|
||
ensure!(
|
||
matches!(proposal.status, ProposalStatus::Approved),
|
||
Error::<T>::ProposalNotApproved
|
||
);
|
||
|
||
// Audit 2026-07-04: single-use. Sin esta marca, la MISMA propuesta
|
||
// Approved podía re-desembolsarse N veces (drenaje del treasury por
|
||
// replay) — el guard `status == Approved` no cambiaba tras el pago.
|
||
ensure!(
|
||
!Disbursed::<T>::contains_key(proposal_id),
|
||
Error::<T>::AlreadyDisbursed
|
||
);
|
||
// RV17: sólo se paga lo que la propuesta fijó al crearse, a quien fijó.
|
||
// Una propuesta sin acción de tesoro no paga nada.
|
||
ensure!(
|
||
TreasuryActions::<T>::get(proposal_id) == Some((recipient.clone(), amount)),
|
||
Error::<T>::TreasuryActionMismatch
|
||
);
|
||
// Marcar ANTES del transfer (defensa en profundidad; los pallets no
|
||
// reentran, pero el marker refleja la intención de gasto atómica).
|
||
Disbursed::<T>::insert(proposal_id, ());
|
||
|
||
// FIX-6: transfer REAL de OGC desde la cuenta-tesorería al recipient
|
||
// (cierra el hallazgo de FIX-5: antes solo decrementaba un contador
|
||
// u128 desconectado de pallet_balances). Si la tesorería no tiene
|
||
// fondos suficientes (manteniendo ED vía Preservation::Preserve), el
|
||
// transfer falla → TreasuryInsufficientBalance.
|
||
let treasury = Self::treasury_account();
|
||
T::Currency::transfer(&treasury, &recipient, amount, Preservation::Preserve)
|
||
.map_err(|_| Error::<T>::TreasuryInsufficientBalance)?;
|
||
|
||
let remaining_balance = Self::treasury_balance();
|
||
Self::deposit_event(Event::TreasuryDisbursed {
|
||
proposal_id,
|
||
recipient,
|
||
amount,
|
||
remaining_balance,
|
||
});
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// CHN-4 5.8 v0.0.35 canonical helper: `treasury_deposit` dispatchable canonical.
|
||
///
|
||
/// Genesis canonical canonical canonical helper para Treasury initial balance
|
||
/// canonical (BM V2 250M OGC Foundation allocation). Real Phase 5.9+ refinement
|
||
/// canonical: replace canonical via Genesis storage canonical config.
|
||
#[pallet::call_index(11)]
|
||
#[pallet::weight(T::WeightInfo::treasury_deposit())]
|
||
pub fn treasury_deposit(origin: OriginFor<T>, amount: u128) -> DispatchResult {
|
||
// FIX-6: donación REAL a la tesorería — el caller transfiere `amount`
|
||
// de su propio balance a la cuenta-tesorería. Cualquiera puede fondear
|
||
// la tesorería (no requiere Root: aportar no es extraer).
|
||
let who = ensure_signed(origin)?;
|
||
T::Currency::transfer(&who, &Self::treasury_account(), amount, Preservation::Preserve)
|
||
.map_err(|_| Error::<T>::TreasuryInsufficientBalance)?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Audit 2026-07-04 (decisión B de David): FASE 1 del emergency fast-track
|
||
/// real. Un council member endosa una propuesta de emergencia por su
|
||
/// content-id (`blake2(title++body)`). Cuando 5/7 endosos VIVOS se acumulan,
|
||
/// cualquier miembro puede promoverla vía `submit_emergency_proposal`. Esto
|
||
/// reemplaza el gate 5/7 TAUTOLÓGICO anterior (que dejaba a UN solo miembro
|
||
/// fast-trackear a 48h).
|
||
#[pallet::call_index(12)]
|
||
#[pallet::weight(T::WeightInfo::endorse_emergency_proposal())]
|
||
pub fn endorse_emergency_proposal(
|
||
origin: OriginFor<T>,
|
||
title_hash: [u8; 32],
|
||
body_hash: [u8; 32],
|
||
) -> DispatchResult {
|
||
let who = ensure_signed(origin)?;
|
||
ensure!(
|
||
Council::<T>::contains_key(&who),
|
||
Error::<T>::NotCouncilMember
|
||
);
|
||
let content_id = crate::emergency_content_id(&title_hash, &body_hash);
|
||
ensure!(
|
||
!EmergencyEndorsements::<T>::contains_key(content_id, &who),
|
||
Error::<T>::AlreadyEndorsed
|
||
);
|
||
EmergencyEndorsements::<T>::insert(content_id, &who, ());
|
||
|
||
// Conteo VIVO (solo miembros aún en el council) — mismo principio que
|
||
// el veto: endosos de miembros removidos NO cuentan.
|
||
let live = EmergencyEndorsements::<T>::iter_prefix(content_id)
|
||
.filter(|(acct, _)| Council::<T>::contains_key(acct))
|
||
.count() as u32;
|
||
let required = council_emergency_threshold(CouncilSize::<T>::get());
|
||
Self::deposit_event(Event::EmergencyEndorsed {
|
||
content_id,
|
||
endorser: who,
|
||
live_endorsements: live,
|
||
required,
|
||
});
|
||
Ok(())
|
||
}
|
||
|
||
/// RV15: libera el OGC que respaldaba votos de `who` que ya no pueden cambiar
|
||
/// un resultado (pasó su `until`). Lo que respalda votos todavía vivos sigue
|
||
/// congelado.
|
||
///
|
||
/// Lo puede llamar CUALQUIERA, como `unlock` de `pallet-conviction-voting`: la
|
||
/// condición es objetiva, y quien votó con todo su saldo no puede pagar la
|
||
/// comisión de su propio desbloqueo (revisión adversaria de RV15).
|
||
#[pallet::call_index(13)]
|
||
#[pallet::weight(T::WeightInfo::unlock_vote_funds())]
|
||
pub fn unlock_vote_funds(origin: OriginFor<T>, who: T::AccountId) -> DispatchResult {
|
||
ensure_signed(origin)?;
|
||
let mut backing = VoteLocks::<T>::get(&who);
|
||
ensure!(!backing.is_empty(), Error::<T>::NoVoteLock);
|
||
let before = max_backing(&backing);
|
||
let now = current_block_u64::<T>();
|
||
let n = backing.len();
|
||
backing.retain(|b| b.until >= now);
|
||
ensure!(backing.len() < n, Error::<T>::VoteFundsStillLocked);
|
||
let after = max_backing(&backing);
|
||
Self::apply_vote_freeze(&who, backing, after)?;
|
||
Self::deposit_event(Event::VoteFundsUnlocked {
|
||
who,
|
||
released: before.saturating_sub(after),
|
||
still_frozen: after,
|
||
});
|
||
Ok(())
|
||
}
|
||
|
||
/// RV16: votar SOSTENER un veto del council durante su ventana de override.
|
||
/// Es la otra mitad del recuento: sin ella, quien votó Nay no podía defender
|
||
/// el veto y cualquier minoría con OGC suficiente lo revertía sola.
|
||
#[pallet::call_index(14)]
|
||
#[pallet::weight(T::WeightInfo::uphold_council_veto())]
|
||
pub fn uphold_council_veto(
|
||
origin: OriginFor<T>,
|
||
proposal_id: ProposalId,
|
||
stake_weight: u128,
|
||
) -> DispatchResult {
|
||
let who = ensure_signed(origin)?;
|
||
Self::cast_override_window_vote(who, proposal_id, stake_weight, true)
|
||
}
|
||
|
||
/// RV16: decide un override cuando su ventana ya cerró. Lo puede llamar
|
||
/// cualquiera.
|
||
///
|
||
/// El veto se revierte (la propuesta pasa a `Approved`) solo si:
|
||
/// 1. revertir reúne al menos 4/5 del MAYOR entre el quórum del override (fijado
|
||
/// en el veto: `MinOverrideQuorum` o la participación del voto original, Aye +
|
||
/// Nay) y la participación de la ventana (revertir + sostener); y
|
||
/// 2. RV17: revertir alcanza además el quórum ordinario de la propuesta
|
||
/// (`VoteQuorumOf`) y SUPERA el Nay del voto original. Son las dos condiciones
|
||
/// del voto ordinario (`yes >= quórum` y `yes > nay`): vetar nunca abarata una
|
||
/// aprobación. Sin el Nay, con Aye Q y Nay 9Q, revertir con 8Q bastaba si la
|
||
/// oposición no volvía a votar, cuando el voto ordinario pedía más de 9Q
|
||
/// (segunda revisión adversarial de RV17).
|
||
///
|
||
/// En cualquier otro caso el veto se sostiene. Se decide una sola vez.
|
||
#[pallet::call_index(15)]
|
||
#[pallet::weight(T::WeightInfo::finalize_override())]
|
||
pub fn finalize_override(origin: OriginFor<T>, proposal_id: ProposalId) -> DispatchResult {
|
||
ensure_signed(origin)?;
|
||
let mut proposal =
|
||
Proposals::<T>::get(proposal_id).ok_or(Error::<T>::ProposalNotFound)?;
|
||
ensure!(
|
||
matches!(proposal.status, ProposalStatus::Rejected),
|
||
Error::<T>::ProposalNotRejected
|
||
);
|
||
ensure!(
|
||
VetoRejected::<T>::contains_key(proposal_id),
|
||
Error::<T>::ProposalNotVetoRejected
|
||
);
|
||
let deadline =
|
||
OverrideDeadline::<T>::get(proposal_id).ok_or(Error::<T>::OverrideNotPending)?;
|
||
ensure!(
|
||
current_block_u64::<T>() > deadline,
|
||
Error::<T>::OverrideWindowOpen
|
||
);
|
||
|
||
let override_stake = OverrideStakeTotal::<T>::get(proposal_id);
|
||
let uphold_stake = UpholdStakeTotal::<T>::get(proposal_id);
|
||
let participation = override_stake.saturating_add(uphold_stake);
|
||
let quorum =
|
||
OverrideQuorum::<T>::get(proposal_id).ok_or(Error::<T>::OverrideNotPending)?;
|
||
let quorum_ordinario = VoteQuorumOf::<T>::get(proposal_id).unwrap_or(0);
|
||
|
||
// Se decide una sola vez: sin ventana, no hay override pendiente.
|
||
OverrideDeadline::<T>::remove(proposal_id);
|
||
OverrideQuorum::<T>::remove(proposal_id);
|
||
VoteQuorumOf::<T>::remove(proposal_id);
|
||
|
||
// Regla MONÓTONA: revertir necesita 4/5 del MAYOR entre el quórum y la
|
||
// participación. La primera versión exigía `participation >= quorum` y
|
||
// aparte el 4/5 de la participación, y la revisión adversaria demostró que
|
||
// así SOSTENER podía revertir el veto: un voto de sostener completaba el
|
||
// quórum que el lado revertir no alcanzaba solo, y no votar era la manera
|
||
// de defender el veto. Con max(quorum, participación), cada OGC que sostiene
|
||
// solo puede subir el listón de revertir, nunca bajarlo.
|
||
let overridden = override_stake > 0
|
||
&& override_stake >= override_supermajority_threshold(quorum.max(participation))
|
||
&& override_stake >= quorum_ordinario
|
||
&& override_stake > proposal.nay_weight;
|
||
if overridden {
|
||
proposal.status = ProposalStatus::Approved;
|
||
Proposals::<T>::insert(proposal_id, proposal);
|
||
Self::deposit_event(Event::CouncilVetoOverridden {
|
||
id: proposal_id,
|
||
override_stake,
|
||
total_community_stake: participation,
|
||
});
|
||
} else {
|
||
Self::deposit_event(Event::CouncilVetoUpheld {
|
||
id: proposal_id,
|
||
override_stake,
|
||
uphold_stake,
|
||
quorum,
|
||
});
|
||
}
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
/// El mayor OGC respaldado por una lista de votos (0 si está vacía).
|
||
fn max_backing(backing: &[VoteBacking]) -> u128 {
|
||
backing.iter().map(|b| b.amount).max().unwrap_or(0)
|
||
}
|
||
|
||
/// Bloque actual como `u64` (el tipo de `end_block` y de las ventanas).
|
||
fn current_block_u64<T: Config>() -> u64 {
|
||
<frame_system::Pallet<T>>::block_number().saturated_into::<u64>()
|
||
}
|
||
|
||
/// RV16 — un voto en la ventana de override de un veto, de cualquiera de los dos
|
||
/// lados. Comparte TODAS las comprobaciones: que la propuesta esté vetada (no
|
||
/// rechazada por voto popular), que la ventana esté abierta, que la cuenta no sea
|
||
/// advisor, que no haya votado ya en la ventana (en ningún lado), y que el peso
|
||
/// salga de su saldo y quede congelado hasta el cierre.
|
||
impl<T: Config> Pallet<T> {
|
||
fn cast_override_window_vote(
|
||
who: T::AccountId,
|
||
proposal_id: ProposalId,
|
||
stake_weight: u128,
|
||
uphold: bool,
|
||
) -> DispatchResult {
|
||
// Un voto de peso 0 en la ventana no decide nada y dejaría una entrada
|
||
// permanente (y a la cuenta sin poder votar después en esa ventana).
|
||
ensure!(stake_weight > 0, Error::<T>::ZeroWeightWindowVote);
|
||
let proposal = Proposals::<T>::get(proposal_id).ok_or(Error::<T>::ProposalNotFound)?;
|
||
ensure!(
|
||
matches!(proposal.status, ProposalStatus::Rejected),
|
||
Error::<T>::ProposalNotRejected
|
||
);
|
||
// Audit 2026-07-04: el override SOLO revierte un VETO del council, no
|
||
// un rechazo por voto popular.
|
||
ensure!(
|
||
VetoRejected::<T>::contains_key(proposal_id),
|
||
Error::<T>::ProposalNotVetoRejected
|
||
);
|
||
ensure!(
|
||
!AdvisorFlags::<T>::contains_key(&who),
|
||
Error::<T>::AdvisorMayNotVote
|
||
);
|
||
ensure!(
|
||
!OverrideVotes::<T>::contains_key(proposal_id, &who)
|
||
&& !UpholdVotes::<T>::contains_key(proposal_id, &who),
|
||
Error::<T>::AlreadyOverrideVoted
|
||
);
|
||
let deadline =
|
||
OverrideDeadline::<T>::get(proposal_id).ok_or(Error::<T>::OverrideWindowClosed)?;
|
||
ensure!(
|
||
current_block_u64::<T>() <= deadline,
|
||
Error::<T>::OverrideWindowClosed
|
||
);
|
||
Self::back_vote_with_funds(
|
||
&who,
|
||
proposal_id,
|
||
BackingKind::Override,
|
||
stake_weight,
|
||
deadline,
|
||
)?;
|
||
|
||
if uphold {
|
||
UpholdVotes::<T>::insert(proposal_id, &who, stake_weight);
|
||
UpholdStakeTotal::<T>::mutate(proposal_id, |t| *t = t.saturating_add(stake_weight));
|
||
} else {
|
||
OverrideVotes::<T>::insert(proposal_id, &who, stake_weight);
|
||
OverrideStakeTotal::<T>::mutate(proposal_id, |t| {
|
||
*t = t.saturating_add(stake_weight)
|
||
});
|
||
}
|
||
Self::deposit_event(Event::OverrideVoteCast {
|
||
id: proposal_id,
|
||
voter: who,
|
||
uphold,
|
||
weight: stake_weight,
|
||
});
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
/// RV15 — el OGC que respalda un voto.
|
||
impl<T: Config> Pallet<T> {
|
||
/// Exige que `weight` no supere el OGC LIBRE de `who` y lo deja congelado
|
||
/// mientras este voto pueda cambiar un resultado (hasta `until`).
|
||
///
|
||
/// Antes descarta los respaldos ya vencidos de la cuenta, así que un voto
|
||
/// muerto nunca vuelve a congelarse. El freeze resultante es el MÁXIMO de los
|
||
/// votos vivos: el mismo OGC puede respaldar votos en propuestas distintas
|
||
/// (se solapan, como en Polkadot), pero no puede salir de la cuenta mientras
|
||
/// un voto que respalda siga vivo. Eso cierra votar, transferir y votar otra
|
||
/// vez desde otra cuenta.
|
||
///
|
||
/// Un peso 0 no congela nada ni toca el respaldo que ya hubiera.
|
||
///
|
||
/// Límite conocido: el freeze de `pallet-balances` se cumple sobre libre +
|
||
/// retenido. Si la cuenta tiene OGC en hold (staking), lo retenido puede
|
||
/// sostener el freeze mientras lo libre sale: en la práctica el OGC en hold
|
||
/// termina respaldando el voto, que es la semántica de Polkadot. No infla el
|
||
/// total: ninguna cuenta respalda más de lo que posee.
|
||
pub(crate) fn back_vote_with_funds(
|
||
who: &T::AccountId,
|
||
proposal: ProposalId,
|
||
kind: BackingKind,
|
||
weight: u128,
|
||
until: u64,
|
||
) -> DispatchResult {
|
||
ensure!(
|
||
weight <= T::Currency::balance(who),
|
||
Error::<T>::VoteWeightExceedsBalance
|
||
);
|
||
if weight == 0 {
|
||
return Ok(());
|
||
}
|
||
let now = current_block_u64::<T>();
|
||
let mut backing = VoteLocks::<T>::get(who);
|
||
backing.retain(|b| b.until >= now);
|
||
backing
|
||
.try_push(VoteBacking {
|
||
proposal,
|
||
kind,
|
||
amount: weight,
|
||
until,
|
||
})
|
||
.map_err(|_| Error::<T>::TooManyActiveVotes)?;
|
||
let frozen = max_backing(&backing);
|
||
Self::apply_vote_freeze(who, backing, frozen)
|
||
}
|
||
|
||
/// Deja el freeze `GovernanceVote` de `who` en `frozen` y guarda su respaldo;
|
||
/// sin votos vivos, descongela y borra el registro.
|
||
fn apply_vote_freeze(
|
||
who: &T::AccountId,
|
||
backing: BoundedVec<VoteBacking, T::MaxActiveVotes>,
|
||
frozen: u128,
|
||
) -> DispatchResult {
|
||
let reason = FreezeReason::GovernanceVote.into();
|
||
if backing.is_empty() {
|
||
<T::Currency as freeze::Mutate<T::AccountId>>::thaw(&reason, who)?;
|
||
VoteLocks::<T>::remove(who);
|
||
} else {
|
||
<T::Currency as freeze::Mutate<T::AccountId>>::set_freeze(&reason, who, frozen)?;
|
||
VoteLocks::<T>::insert(who, backing);
|
||
}
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
/// FIX-6 — helpers de la tesorería real (cuenta respaldada por pallet_balances).
|
||
impl<T: Config> Pallet<T> {
|
||
/// Cuenta-tesorería (vault) derivada del PalletId — determinística, sin
|
||
/// private key. Tiene el balance real de OGC; solo governance (Root) la
|
||
/// drena vía treasury_disbursement.
|
||
pub fn treasury_account() -> T::AccountId {
|
||
T::TreasuryPalletId::get().into_account_truncating()
|
||
}
|
||
|
||
/// Balance REAL de la tesorería (lee pallet_balances, no un contador).
|
||
pub fn treasury_balance() -> u128 {
|
||
T::Currency::balance(&Self::treasury_account())
|
||
}
|
||
|
||
/// Cuenta-bond de propuestas (`og/props`) — escrow determinista sin llave
|
||
/// donde vive el stake retenido de las propuestas vivas (audit 2026-07-04).
|
||
pub fn proposal_bond_account() -> T::AccountId {
|
||
PROPOSAL_BOND_PALLET_ID.into_account_truncating()
|
||
}
|
||
|
||
/// Crea una propuesta ordinaria (bond retenido, periodo dentro de
|
||
/// [MIN, MAX]) y devuelve su id. La comparten `submit_proposal` y, desde RV17,
|
||
/// `submit_treasury_proposal`.
|
||
fn do_submit_proposal(
|
||
who: T::AccountId,
|
||
title_hash: [u8; 32],
|
||
body_hash: [u8; 32],
|
||
voting_period_blocks: u64,
|
||
stake: u128,
|
||
) -> Result<ProposalId, DispatchError> {
|
||
ensure!(stake >= MIN_PROPOSAL_STAKE, Error::<T>::StakeInsufficient);
|
||
ensure!(
|
||
voting_period_blocks <= MAX_VOTING_PERIOD_BLOCKS,
|
||
Error::<T>::VotingPeriodTooLong
|
||
);
|
||
ensure!(
|
||
voting_period_blocks >= MIN_VOTING_PERIOD_BLOCKS,
|
||
Error::<T>::VotingPeriodTooShort
|
||
);
|
||
|
||
let proposer_acct = AccountId::new(account_bytes::<T>(&who));
|
||
let id = crate::derive_proposal_id(&proposer_acct, &title_hash, &body_hash);
|
||
ensure!(!Proposals::<T>::contains_key(id), Error::<T>::ProposalIdCollision);
|
||
|
||
// Audit 2026-07-04: RESERVA REAL del stake (piel en el juego anti-spam).
|
||
// Antes el `stake` era caller-supplied SIN respaldo — una cuenta vacía
|
||
// spameaba propuestas gratis y el `baseline` del override era
|
||
// manipulable. Ahora se retiene de verdad en la cuenta-bond og/props
|
||
// (falla si el proponente no tiene los fondos) y se devuelve al decidir.
|
||
T::Currency::transfer(
|
||
&who,
|
||
&Self::proposal_bond_account(),
|
||
stake,
|
||
Preservation::Preserve,
|
||
)
|
||
.map_err(|_| Error::<T>::StakeInsufficientBalance)?;
|
||
ProposalBond::<T>::insert(id, (who.clone(), stake));
|
||
|
||
let start_block: u64 = <frame_system::Pallet<T>>::block_number()
|
||
.saturated_into::<u64>();
|
||
let end_block = start_block.saturating_add(voting_period_blocks);
|
||
|
||
let proposal = Proposal {
|
||
id,
|
||
proposer: proposer_acct,
|
||
title_hash,
|
||
body_hash,
|
||
stake,
|
||
start_block,
|
||
end_block,
|
||
status: ProposalStatus::Voting,
|
||
yes_weight: 0,
|
||
nay_weight: 0,
|
||
abstain_weight: 0,
|
||
};
|
||
|
||
Proposals::<T>::insert(id, proposal);
|
||
// RV17: el quórum se fija ahora, no al decidir (ver `VoteQuorumOf`).
|
||
VoteQuorumOf::<T>::insert(id, T::VoteQuorum::get());
|
||
|
||
Self::deposit_event(Event::ProposalSubmitted {
|
||
id,
|
||
proposer: who,
|
||
voting_period: voting_period_blocks,
|
||
});
|
||
|
||
Ok(id)
|
||
}
|
||
|
||
/// Devuelve el bond retenido al proponente cuando la propuesta se decide
|
||
/// (finalize o veto). Idempotente: `take` consume la entrada, así que un
|
||
/// segundo intento es no-op (no doble-refund). Expendable: la cuenta-bond
|
||
/// puede quedar en 0. Un fallo del transfer deja el bond en el escrow (no
|
||
/// se pierde) — best-effort para no bloquear la transición de estado.
|
||
fn release_proposal_bond(proposal_id: ProposalId) {
|
||
if let Some((staker, amount)) = ProposalBond::<T>::take(proposal_id) {
|
||
if amount > 0 {
|
||
let _ = T::Currency::transfer(
|
||
&Self::proposal_bond_account(),
|
||
&staker,
|
||
amount,
|
||
Preservation::Expendable,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Helper canonical para convert T::AccountId → 32-byte canonical.
|
||
fn account_bytes<T: Config>(account: &T::AccountId) -> [u8; 32] {
|
||
let encoded = account.encode();
|
||
let mut bytes = [0u8; 32];
|
||
let copy_len = encoded.len().min(32);
|
||
bytes[..copy_len].copy_from_slice(&encoded[..copy_len]);
|
||
bytes
|
||
}
|
||
|
||
/// Phase 5.2 canonical helpers — Council membership queries (pure read).
|
||
impl<T: Config> Pallet<T> {
|
||
/// Returns `true` si account canonical es council member.
|
||
pub fn is_council_member(account: &T::AccountId) -> bool {
|
||
Council::<T>::contains_key(account)
|
||
}
|
||
|
||
/// Returns current council size canonical (cached StorageValue).
|
||
pub fn current_council_size() -> u32 {
|
||
CouncilSize::<T>::get()
|
||
}
|
||
|
||
/// Returns required veto votes per current council size canonical (2/3 threshold).
|
||
pub fn required_veto_votes() -> u32 {
|
||
council_veto_threshold(Self::current_council_size())
|
||
}
|
||
}
|
||
|
||
/// Pure helpers canonical para Runtime API v0.0.31 consumers + tests.
|
||
impl<T: Config> Pallet<T> {
|
||
/// Query proposal canonical sin storage mutation (read-only canonical).
|
||
pub fn query_proposal_of(id: ProposalId) -> Option<Proposal> {
|
||
Proposals::<T>::get(id)
|
||
}
|
||
|
||
/// Query vote stance canonical por (proposal_id, voter) sin storage mutation.
|
||
pub fn query_vote_of(id: ProposalId, voter: &T::AccountId) -> Option<VoteStance> {
|
||
Votes::<T>::get(id, voter)
|
||
}
|
||
}
|
||
}
|
||
|
||
// v0.0.30 — Mock runtime canonical + integration tests E2E CHN-4 flow.
|
||
// Pattern v0.0.18 + v0.0.22 + v0.0.26 mirror.
|
||
#[cfg(test)]
|
||
pub mod mock;
|
||
|
||
#[cfg(test)]
|
||
mod tests_integration;
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use origin_chain_types::AccountId;
|
||
|
||
#[test]
|
||
fn pallet_compiles_canonical() {
|
||
// Pallet compiles canonical — verified by cargo check.
|
||
}
|
||
|
||
#[test]
|
||
fn derive_proposal_id_deterministic_canonical() {
|
||
// Inmutable #16 byte-determinism canonical: same inputs → same ProposalId
|
||
let proposer = AccountId::new([42u8; 32]);
|
||
let title = [1u8; 32];
|
||
let body = [2u8; 32];
|
||
|
||
let id1 = derive_proposal_id(&proposer, &title, &body);
|
||
let id2 = derive_proposal_id(&proposer, &title, &body);
|
||
assert_eq!(id1, id2);
|
||
}
|
||
|
||
#[test]
|
||
fn derive_proposal_id_different_inputs_different_ids_canonical() {
|
||
let p1 = AccountId::new([1u8; 32]);
|
||
let p2 = AccountId::new([2u8; 32]);
|
||
let title = [10u8; 32];
|
||
let body = [20u8; 32];
|
||
|
||
let id_a = derive_proposal_id(&p1, &title, &body);
|
||
let id_b = derive_proposal_id(&p2, &title, &body);
|
||
assert_ne!(id_a, id_b);
|
||
}
|
||
|
||
#[test]
|
||
fn proposal_status_variants_canonical() {
|
||
use pallet::ProposalStatus;
|
||
let voting = ProposalStatus::Voting;
|
||
let approved = ProposalStatus::Approved;
|
||
let rejected = ProposalStatus::Rejected;
|
||
let withdrawn = ProposalStatus::Withdrawn;
|
||
assert_ne!(core::mem::discriminant(&voting), core::mem::discriminant(&approved));
|
||
assert_ne!(core::mem::discriminant(&rejected), core::mem::discriminant(&withdrawn));
|
||
}
|
||
|
||
#[test]
|
||
fn vote_stance_re_export_canonical() {
|
||
let _aye = VoteStance::Aye;
|
||
let _nay = VoteStance::Nay;
|
||
let _abstain = VoteStance::Abstain;
|
||
}
|
||
}
|