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))
}
/// 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
@@ -347,6 +359,25 @@ pub mod pallet {
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
@@ -486,6 +517,19 @@ pub mod pallet {
/// 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
@@ -556,6 +600,9 @@ pub mod pallet {
/// 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,
}
/// 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);
// 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!(
council_size >= required,
live_endorsements >= required,
Error::<T>::InsufficientCouncilEmergencyApprovals
);
@@ -1098,9 +1153,13 @@ pub mod pallet {
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: council_size,
council_approvals: live_endorsements,
});
Ok(())
@@ -1184,6 +1243,46 @@ pub mod pallet {
.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(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).
@@ -731,7 +731,31 @@ fn emergency_scenario_3_voting_period_shorter_canonical() {
}
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(
alice_origin(),
[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 ──────────────
#[test]