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:
1 parent
304072173e
commit
9f48723ca5
3 files changed
+166
-10
No files matched your search
@@ -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]
|
||||
|
||||
@@ -251,7 +251,14 @@ pub const IMPL_NAME: &str = "origin-chain-charon";
|
||||
/// (polvo ~10^-10 OGC) a `ConstantMultiplier` calibrado — un transfer ≈ 1 MILLI_OGC,
|
||||
/// el #16 Pi5 0.1× = ahorro real, quema deflacionaria medible. Comportamiento ⇒
|
||||
/// 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.
|
||||
pub const AUTHORING_VERSION: u32 = 1;
|
||||
@@ -844,12 +851,13 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn runtime_version_v7_hard_cap_and_fee_teeth() {
|
||||
// v7 = economía Level A (David 2026-07-03): techo duro 1B (emisión =
|
||||
// transfer del pool Ecosystem, no mint) + fees con dientes (ConstantMultiplier
|
||||
// calibrado, transfer ≈ 1 MILLI_OGC). Cambios de comportamiento, no de
|
||||
// formato de extrinsic ⇒ TRANSACTION_VERSION intacto.
|
||||
assert_eq!(RUNTIME_VERSION, 7);
|
||||
fn runtime_version_v8_governance_hardening() {
|
||||
// v8 = hardening de governance (audit ultra 2026-07-04 + decisiones A/B):
|
||||
// correctitud (override/treasury/veto) + bond de propuesta (A) + flujo 5/7
|
||||
// de emergencia real con endorse_emergency_proposal (B, call_index 12).
|
||||
// Call aditiva + storage/metadata nuevos ⇒ spec bump; los extrinsics
|
||||
// EXISTENTES no cambian formato ⇒ TRANSACTION_VERSION intacto.
|
||||
assert_eq!(RUNTIME_VERSION, 8);
|
||||
assert_eq!(TRANSACTION_VERSION, 1);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user