feat(governance): flujo 5/7 de emergencia REAL — dos fases (audit B, David · RV 7→8)

Decisión B (David 2026-07-04). El audit ultra encontró que el gate 5/7 de
submit_emergency_proposal era TAUTOLÓGICO: `council_size >= threshold(council_size)`
es siempre true, y solo exigía UN council member — así que un solo miembro
fast-trackeaba una propuesta a 48h (vs 14 días) sin la pre-aprobación 5/7.

Fix (flujo de dos fases, como anticipaba el propio doc "gather signatures then
promote"):
- FASE 1: `endorse_emergency_proposal` (nueva, call_index 12) — un council member
  endosa por content-id (blake2 de title++body).
- FASE 2: `submit_emergency_proposal` ahora exige `live_endorsements >= 5/7 real`
  (conteo EN VIVO, solo miembros aún en el council — consistente con el fix de
  veto). Limpia los endosos consumidos tras promover.

+2 tests (flujo 5/7 completo con boundary 4→5, y regresión "un solo miembro NO
fast-trackea"). RUNTIME_VERSION 7→8 (call aditiva + metadata; TRANSACTION_VERSION
intacto). Workspace 284 verde.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
davidherranandClaude Fable 5 committed 2026-07-05 20:01:33 -05:00
1 parent 304072173e
commit 9f48723ca5
3 files changed
+166 -10

No files matched your search

+101 -2
View File
@@ -67,6 +67,18 @@ pub fn derive_proposal_id(
ProposalId(blake2_256(&concat)) 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). /// 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 /// Permite query canonical de proposal status + vote stance sin necesidad de
@@ -347,6 +359,25 @@ pub mod pallet {
pub type ProposalBond<T: Config> = pub type ProposalBond<T: Config> =
StorageMap<_, Blake2_128Concat, ProposalId, (T::AccountId, u128), OptionQuery>; 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. // 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 // 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 // PalletId (`Self::treasury_account()`). Esto cierra el hallazgo del audit de
@@ -486,6 +517,19 @@ pub mod pallet {
/// Council pre-approval count canonical. /// Council pre-approval count canonical.
council_approvals: u32, 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). /// Treasury disbursement canonical (Phase 5.8 v0.0.35).
/// ///
/// Emitted canonical cuando Approved proposal canonical canonical canonical disburses /// Emitted canonical cuando Approved proposal canonical canonical canonical disburses
@@ -556,6 +600,9 @@ pub mod pallet {
/// Audit 2026-07-04: el proponente NO tiene balance libre para el bond del /// 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). /// stake (el stake ahora se retiene de verdad — piel en el juego anti-spam).
StakeInsufficientBalance, StakeInsufficientBalance,
/// Audit 2026-07-04 (B): el council member ya endosó esta propuesta de
/// emergencia (anti doble-endoso).
AlreadyEndorsed,
} }
/// Minimum stake canonical proposal submission per CHN-4 5.3. /// Minimum stake canonical proposal submission per CHN-4 5.3.
@@ -1053,10 +1100,18 @@ pub mod pallet {
ensure!(stake >= MIN_PROPOSAL_STAKE, Error::<T>::StakeInsufficient); 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 council_size = CouncilSize::<T>::get();
let required = council_emergency_threshold(council_size); 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!( ensure!(
council_size >= required, live_endorsements >= required,
Error::<T>::InsufficientCouncilEmergencyApprovals Error::<T>::InsufficientCouncilEmergencyApprovals
); );
@@ -1098,9 +1153,13 @@ pub mod pallet {
Proposals::<T>::insert(id, proposal); 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 { Self::deposit_event(Event::EmergencyProposalSubmitted {
id, id,
council_approvals: council_size, council_approvals: live_endorsements,
}); });
Ok(()) Ok(())
@@ -1184,6 +1243,46 @@ pub mod pallet {
.map_err(|_| Error::<T>::TreasuryInsufficientBalance)?; .map_err(|_| Error::<T>::TreasuryInsufficientBalance)?;
Ok(()) 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(Weight::from_parts(10_000, 0))]
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(())
}
} }
/// FIX-6 — helpers de la tesorería real (cuenta respaldada por pallet_balances). /// FIX-6 — helpers de la tesorería real (cuenta respaldada por pallet_balances).
@@ -731,7 +731,31 @@ fn emergency_scenario_3_voting_period_shorter_canonical() {
} }
assert_eq!(Governance::current_council_size(), 7); assert_eq!(Governance::current_council_size(), 7);
// Alice (council Chair) submits emergency proposal canonical // Audit 2026-07-04 (B): FASE 1 — 5/7 endosos reales requeridos (required =
// ceil(7×5/7) = 5). Endosan alice + 4 miembros.
let council_origin = |i: u8| {
frame_support::dispatch::RawOrigin::Signed(
sp_runtime::AccountId32::new([i + 50; 32]),
)
.into()
};
assert_ok!(Governance::endorse_emergency_proposal(alice_origin(), [10u8; 32], [20u8; 32]));
for i in 1..4u8 {
assert_ok!(Governance::endorse_emergency_proposal(
council_origin(i),
[10u8; 32],
[20u8; 32]
));
}
// Solo 4 endosos hasta aquí (alice + 3) → aún NO alcanza 5: submit falla.
assert_noop!(
Governance::submit_emergency_proposal(alice_origin(), [10u8; 32], [20u8; 32], MIN_PROPOSAL_STAKE),
Error::<Test>::InsufficientCouncilEmergencyApprovals
);
// 5º endoso → alcanza el umbral.
assert_ok!(Governance::endorse_emergency_proposal(council_origin(4), [10u8; 32], [20u8; 32]));
// FASE 2 — ahora sí, cualquier miembro promueve la propuesta de emergencia.
assert_ok!(Governance::submit_emergency_proposal( assert_ok!(Governance::submit_emergency_proposal(
alice_origin(), alice_origin(),
[10u8; 32], [10u8; 32],
@@ -749,6 +773,31 @@ fn emergency_scenario_3_voting_period_shorter_canonical() {
}); });
} }
#[test]
fn emergency_un_solo_miembro_no_puede_fast_trackear() {
// Regresión audit 2026-07-04 (B): el gate 5/7 era TAUTOLÓGICO — un solo
// council member fast-trackeaba a 48h. Ahora, sin 5/7 endosos reales, se
// rechaza. Council de 7 (required 5); alice sola (1 endoso) NO alcanza.
new_test_ext().execute_with(|| {
use crate::pallet::{CouncilRole, MIN_PROPOSAL_STAKE};
use frame_support::dispatch::RawOrigin;
assert_ok!(Governance::add_council_member(RawOrigin::Root.into(), alice_account(), CouncilRole::Chair));
for i in 1..7u8 {
assert_ok!(Governance::add_council_member(
RawOrigin::Root.into(),
sp_runtime::AccountId32::new([i + 50; 32]),
CouncilRole::Member,
));
}
// Alice endosa (1) y trata de promover sola → rechazado (necesita 5).
assert_ok!(Governance::endorse_emergency_proposal(alice_origin(), [10u8; 32], [20u8; 32]));
assert_noop!(
Governance::submit_emergency_proposal(alice_origin(), [10u8; 32], [20u8; 32], MIN_PROPOSAL_STAKE),
Error::<Test>::InsufficientCouncilEmergencyApprovals
);
});
}
// ─── Phase 5.8 v0.0.34: Treasury disbursement canonical ────────────── // ─── Phase 5.8 v0.0.34: Treasury disbursement canonical ──────────────
#[test] #[test]
+15 -7
View File
@@ -251,7 +251,14 @@ pub const IMPL_NAME: &str = "origin-chain-charon";
/// (polvo ~10^-10 OGC) a `ConstantMultiplier` calibrado — un transfer ≈ 1 MILLI_OGC, /// (polvo ~10^-10 OGC) a `ConstantMultiplier` calibrado — un transfer ≈ 1 MILLI_OGC,
/// el #16 Pi5 0.1× = ahorro real, quema deflacionaria medible. Comportamiento ⇒ /// el #16 Pi5 0.1× = ahorro real, quema deflacionaria medible. Comportamiento ⇒
/// spec bump; TRANSACTION_VERSION intacto. /// spec bump; TRANSACTION_VERSION intacto.
pub const RUNTIME_VERSION: u32 = 7; /// v8: hardening de governance (audit ultra 2026-07-04 + decisiones A/B David) —
/// correctitud: `override_council_veto` solo revierte vetos del council (no votos
/// populares), `treasury_disbursement` single-use (anti-replay), veto con conteo
/// en vivo. (A) reserva REAL del stake de propuesta (bond `og/props`). (B) flujo
/// 5/7 de emergencia real: nueva dispatchable `endorse_emergency_proposal`
/// (call_index 12) + gate real (antes tautológico). Nuevas storage/errors/evento
/// + 1 call aditiva ⇒ spec bump; TRANSACTION_VERSION intacto (call_index nuevo).
pub const RUNTIME_VERSION: u32 = 8;
/// Canonical authoring version per Substrate semver canonical. /// Canonical authoring version per Substrate semver canonical.
pub const AUTHORING_VERSION: u32 = 1; pub const AUTHORING_VERSION: u32 = 1;
@@ -844,12 +851,13 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn runtime_version_v7_hard_cap_and_fee_teeth() { fn runtime_version_v8_governance_hardening() {
// v7 = economía Level A (David 2026-07-03): techo duro 1B (emisión = // v8 = hardening de governance (audit ultra 2026-07-04 + decisiones A/B):
// transfer del pool Ecosystem, no mint) + fees con dientes (ConstantMultiplier // correctitud (override/treasury/veto) + bond de propuesta (A) + flujo 5/7
// calibrado, transfer ≈ 1 MILLI_OGC). Cambios de comportamiento, no de // de emergencia real con endorse_emergency_proposal (B, call_index 12).
// formato de extrinsic ⇒ TRANSACTION_VERSION intacto. // Call aditiva + storage/metadata nuevos ⇒ spec bump; los extrinsics
assert_eq!(RUNTIME_VERSION, 7); // EXISTENTES no cambian formato ⇒ TRANSACTION_VERSION intacto.
assert_eq!(RUNTIME_VERSION, 8);
assert_eq!(TRANSACTION_VERSION, 1); assert_eq!(TRANSACTION_VERSION, 1);
} }