Files
arkhe-origin-chain/crates/origin-chain-governance/src/lib.rs
T
CharonandClaude Opus 5.5 b6d05eaf45 feat(gobierno): spec 18 (RV17), quorum del voto ordinario y pago del tesoro atado a la propuesta
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>
2026-10-28 21:55:24 -05:00

2057 lines
91 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-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;
}
}