//! # 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>` per-proposal O(1) ✅ //! - Storage `Votes>` 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; /// Query vote stance canonical por (proposal_id, voter). fn query_vote( proposal_id: ProposalId, voter: origin_chain_types::AccountId, ) -> Option; /// **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::()`. 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> + IsType<::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 + Mutate + freeze::Mutate; /// RV15: el enum agregado de razones de congelación del runtime. type RuntimeFreezeReason: From; /// 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; /// 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; /// 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; /// 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; /// 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(_); #[pallet::hooks] impl Hooks> for Pallet { /// 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 = StorageDoubleMap< _, Blake2_128Concat, ProposalId, Blake2_128Concat, T::AccountId, u128, OptionQuery, >; /// RV16: OGC total que sostiene el veto, por propuesta. #[pallet::storage] pub type UpholdStakeTotal = 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 = StorageMap< _, Blake2_128Concat, T::AccountId, BoundedVec, 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 = 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 = 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 = 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 = StorageMap<_, Blake2_128Concat, ProposalId, (T::AccountId, u128), OptionQuery>; /// CHN-4 canonical: `Proposals>` canonical. #[pallet::storage] #[pallet::getter(fn proposal_of)] pub type Proposals = StorageMap<_, Blake2_128Concat, ProposalId, Proposal, OptionQuery>; /// CHN-4 canonical: `Votes>` canonical. #[pallet::storage] #[pallet::getter(fn vote_of)] pub type Votes = StorageDoubleMap< _, Blake2_128Concat, ProposalId, Blake2_128Concat, T::AccountId, VoteStance, OptionQuery, >; /// CHN-4 5.2 canonical: `Council>` 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 = StorageMap<_, Blake2_128Concat, T::AccountId, CouncilMember, OptionQuery>; /// CHN-4 5.2 canonical: `CouncilSize>` 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 = StorageValue<_, u32, ValueQuery>; /// CHN-4 5.2 canonical: `VetoVotes>` 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 = StorageDoubleMap< _, Blake2_128Concat, ProposalId, Blake2_128Concat, T::AccountId, (), OptionQuery, >; /// CHN-4 5.2 canonical: `VetoCount>` 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 = StorageMap<_, Blake2_128Concat, ProposalId, u32, ValueQuery>; /// CHN-4 5.6 v0.0.34 canonical: `AdvisorFlags>` 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 = 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 = 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 = 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 = 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 = 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>` 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 = StorageDoubleMap< _, Blake2_128Concat, ProposalId, Blake2_128Concat, T::AccountId, u128, OptionQuery, >; /// CHN-4 5.5 v0.0.35 canonical: `OverrideStakeTotal>` 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 = 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 { /// 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 { /// 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 Pallet { /// 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, 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, 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::::TreasuryAmountBelowMinimum ); ensure!( recipient != Self::treasury_account() && recipient != Self::proposal_bond_account(), Error::::TreasuryRecipientInvalid ); let id = Self::do_submit_proposal(who, title_hash, body_hash, voting_period_blocks, stake)?; TreasuryActions::::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, 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::::contains_key(&who), Error::::AdvisorMayNotVote ); let mut proposal = Proposals::::get(proposal_id) .ok_or(Error::::ProposalNotFound)?; ensure!( matches!(proposal.status, ProposalStatus::Voting), Error::::ProposalAlreadyFinalized ); let current_block: u64 = >::block_number() .saturated_into::(); ensure!( current_block <= proposal.end_block, Error::::VotingPeriodClosed ); ensure!( !Votes::::contains_key(proposal_id, &who), Error::::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::::insert(proposal_id, proposal); Votes::::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, proposal_id: ProposalId, ) -> DispatchResult { let _who = ensure_signed(origin)?; let mut proposal = Proposals::::get(proposal_id) .ok_or(Error::::ProposalNotFound)?; ensure!( matches!(proposal.status, ProposalStatus::Voting), Error::::ProposalAlreadyFinalized ); let current_block: u64 = >::block_number() .saturated_into::(); ensure!( current_block > proposal.end_block, Error::::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::::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::::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, account: T::AccountId, role: CouncilRole, ) -> DispatchResult { ensure_root(origin)?; let current_size = CouncilSize::::get(); ensure!( current_size < MAX_COUNCIL_SIZE, Error::::CouncilSizeExceeded ); ensure!( !Council::::contains_key(&account), Error::::AlreadyCouncilMember ); let block_number: u64 = >::block_number() .saturated_into::(); let member = CouncilMember { account: origin_chain_types::AccountId::new(account_bytes::(&account)), joined_at_block: block_number, role, }; Council::::insert(&account, member); let new_size = current_size.saturating_add(1); CouncilSize::::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, account: T::AccountId, ) -> DispatchResult { ensure_root(origin)?; ensure!( Council::::contains_key(&account), Error::::NotCouncilMember ); Council::::remove(&account); let current_size = CouncilSize::::get(); let new_size = current_size.saturating_sub(1); CouncilSize::::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, proposal_id: ProposalId, ) -> DispatchResult { // Step 1: ensure signed canonical let who = ensure_signed(origin)?; // Step 2: validate council member canonical ensure!( Council::::contains_key(&who), Error::::NotCouncilMember ); // Step 3: get proposal canonical let mut proposal = Proposals::::get(proposal_id) .ok_or(Error::::ProposalNotFound)?; ensure!( matches!(proposal.status, ProposalStatus::Voting), Error::::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::() <= proposal.end_block, Error::::VotingPeriodClosed ); // Step 4: council size canonical > 0 let council_size = CouncilSize::::get(); ensure!(council_size > 0, Error::::InsufficientCouncilSize); // Step 5: AlreadyVetoed canonical ensure!( !VetoVotes::::contains_key(proposal_id, &who), Error::::AlreadyVetoed ); // Step 6: insert vote VetoVotes::::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::::iter_prefix(proposal_id) .filter(|(acct, _)| Council::::contains_key(acct)) .count() as u32; VetoCount::::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::::insert(proposal_id, turnout.max(T::MinOverrideQuorum::get())); // Veto threshold reached canonical → flip status canonical proposal.status = ProposalStatus::Rejected; Proposals::::insert(proposal_id, proposal); // Marca: rechazado por VETO del council (no por voto popular) → // única vía elegible para override_council_veto. VetoRejected::::insert(proposal_id, ()); // RV15: el override tiene ventana, contada desde este bloque. OverrideDeadline::::insert( proposal_id, current_block_u64::().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, account: T::AccountId, ) -> DispatchResult { ensure_root(origin)?; ensure!( !AdvisorFlags::::contains_key(&account), Error::::AlreadyAdvisor ); AdvisorFlags::::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, account: T::AccountId, ) -> DispatchResult { ensure_root(origin)?; ensure!( AdvisorFlags::::contains_key(&account), Error::::NotAdvisor ); AdvisorFlags::::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, 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, title_hash: [u8; 32], body_hash: [u8; 32], stake: u128, ) -> DispatchResult { let who = ensure_signed(origin)?; ensure!( Council::::contains_key(&who), Error::::NotCouncilMember ); ensure!(stake >= MIN_PROPOSAL_STAKE, Error::::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::::get(); let required = council_emergency_threshold(council_size); let content_id = crate::emergency_content_id(&title_hash, &body_hash); let live_endorsements = EmergencyEndorsements::::iter_prefix(content_id) .filter(|(acct, _)| Council::::contains_key(acct)) .count() as u32; ensure!( live_endorsements >= required, Error::::InsufficientCouncilEmergencyApprovals ); let proposer_acct = origin_chain_types::AccountId::new(account_bytes::(&who)); let id = crate::derive_proposal_id(&proposer_acct, &title_hash, &body_hash); ensure!( !Proposals::::contains_key(id), Error::::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::::StakeInsufficientBalance)?; ProposalBond::::insert(id, (who.clone(), stake)); let start_block: u64 = >::block_number() .saturated_into::(); 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::::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::::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, proposal_id: ProposalId, recipient: T::AccountId, amount: u128, ) -> DispatchResult { ensure_root(origin)?; let proposal = Proposals::::get(proposal_id) .ok_or(Error::::ProposalNotFound)?; ensure!( matches!(proposal.status, ProposalStatus::Approved), Error::::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::::contains_key(proposal_id), Error::::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::::get(proposal_id) == Some((recipient.clone(), amount)), Error::::TreasuryActionMismatch ); // Marcar ANTES del transfer (defensa en profundidad; los pallets no // reentran, pero el marker refleja la intención de gasto atómica). Disbursed::::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::::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, 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::::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, title_hash: [u8; 32], body_hash: [u8; 32], ) -> DispatchResult { let who = ensure_signed(origin)?; ensure!( Council::::contains_key(&who), Error::::NotCouncilMember ); let content_id = crate::emergency_content_id(&title_hash, &body_hash); ensure!( !EmergencyEndorsements::::contains_key(content_id, &who), Error::::AlreadyEndorsed ); EmergencyEndorsements::::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::::iter_prefix(content_id) .filter(|(acct, _)| Council::::contains_key(acct)) .count() as u32; let required = council_emergency_threshold(CouncilSize::::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, who: T::AccountId) -> DispatchResult { ensure_signed(origin)?; let mut backing = VoteLocks::::get(&who); ensure!(!backing.is_empty(), Error::::NoVoteLock); let before = max_backing(&backing); let now = current_block_u64::(); let n = backing.len(); backing.retain(|b| b.until >= now); ensure!(backing.len() < n, Error::::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, 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, proposal_id: ProposalId) -> DispatchResult { ensure_signed(origin)?; let mut proposal = Proposals::::get(proposal_id).ok_or(Error::::ProposalNotFound)?; ensure!( matches!(proposal.status, ProposalStatus::Rejected), Error::::ProposalNotRejected ); ensure!( VetoRejected::::contains_key(proposal_id), Error::::ProposalNotVetoRejected ); let deadline = OverrideDeadline::::get(proposal_id).ok_or(Error::::OverrideNotPending)?; ensure!( current_block_u64::() > deadline, Error::::OverrideWindowOpen ); let override_stake = OverrideStakeTotal::::get(proposal_id); let uphold_stake = UpholdStakeTotal::::get(proposal_id); let participation = override_stake.saturating_add(uphold_stake); let quorum = OverrideQuorum::::get(proposal_id).ok_or(Error::::OverrideNotPending)?; let quorum_ordinario = VoteQuorumOf::::get(proposal_id).unwrap_or(0); // Se decide una sola vez: sin ventana, no hay override pendiente. OverrideDeadline::::remove(proposal_id); OverrideQuorum::::remove(proposal_id); VoteQuorumOf::::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::::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() -> u64 { >::block_number().saturated_into::() } /// 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 Pallet { 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::::ZeroWeightWindowVote); let proposal = Proposals::::get(proposal_id).ok_or(Error::::ProposalNotFound)?; ensure!( matches!(proposal.status, ProposalStatus::Rejected), Error::::ProposalNotRejected ); // Audit 2026-07-04: el override SOLO revierte un VETO del council, no // un rechazo por voto popular. ensure!( VetoRejected::::contains_key(proposal_id), Error::::ProposalNotVetoRejected ); ensure!( !AdvisorFlags::::contains_key(&who), Error::::AdvisorMayNotVote ); ensure!( !OverrideVotes::::contains_key(proposal_id, &who) && !UpholdVotes::::contains_key(proposal_id, &who), Error::::AlreadyOverrideVoted ); let deadline = OverrideDeadline::::get(proposal_id).ok_or(Error::::OverrideWindowClosed)?; ensure!( current_block_u64::() <= deadline, Error::::OverrideWindowClosed ); Self::back_vote_with_funds( &who, proposal_id, BackingKind::Override, stake_weight, deadline, )?; if uphold { UpholdVotes::::insert(proposal_id, &who, stake_weight); UpholdStakeTotal::::mutate(proposal_id, |t| *t = t.saturating_add(stake_weight)); } else { OverrideVotes::::insert(proposal_id, &who, stake_weight); OverrideStakeTotal::::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 Pallet { /// 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::::VoteWeightExceedsBalance ); if weight == 0 { return Ok(()); } let now = current_block_u64::(); let mut backing = VoteLocks::::get(who); backing.retain(|b| b.until >= now); backing .try_push(VoteBacking { proposal, kind, amount: weight, until, }) .map_err(|_| Error::::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, frozen: u128, ) -> DispatchResult { let reason = FreezeReason::GovernanceVote.into(); if backing.is_empty() { >::thaw(&reason, who)?; VoteLocks::::remove(who); } else { >::set_freeze(&reason, who, frozen)?; VoteLocks::::insert(who, backing); } Ok(()) } } /// FIX-6 — helpers de la tesorería real (cuenta respaldada por pallet_balances). impl Pallet { /// 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 { ensure!(stake >= MIN_PROPOSAL_STAKE, Error::::StakeInsufficient); ensure!( voting_period_blocks <= MAX_VOTING_PERIOD_BLOCKS, Error::::VotingPeriodTooLong ); ensure!( voting_period_blocks >= MIN_VOTING_PERIOD_BLOCKS, Error::::VotingPeriodTooShort ); let proposer_acct = AccountId::new(account_bytes::(&who)); let id = crate::derive_proposal_id(&proposer_acct, &title_hash, &body_hash); ensure!(!Proposals::::contains_key(id), Error::::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::::StakeInsufficientBalance)?; ProposalBond::::insert(id, (who.clone(), stake)); let start_block: u64 = >::block_number() .saturated_into::(); 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::::insert(id, proposal); // RV17: el quórum se fija ahora, no al decidir (ver `VoteQuorumOf`). VoteQuorumOf::::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::::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(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 Pallet { /// Returns `true` si account canonical es council member. pub fn is_council_member(account: &T::AccountId) -> bool { Council::::contains_key(account) } /// Returns current council size canonical (cached StorageValue). pub fn current_council_size() -> u32 { CouncilSize::::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 Pallet { /// Query proposal canonical sin storage mutation (read-only canonical). pub fn query_proposal_of(id: ProposalId) -> Option { Proposals::::get(id) } /// Query vote stance canonical por (proposal_id, voter) sin storage mutation. pub fn query_vote_of(id: ProposalId, voter: &T::AccountId) -> Option { Votes::::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; } }