feat(gobierno): GSUN-3, dos tercios para el tesoro (spec 21, pesos por certificar)
David, 2-nov-2026: «Sí a la súper-mayoría, dos tercios». - Una propuesta de tesoro se aprueba sólo si su Aye es al menos dos tercios del voto emitido (Aye + Nay; la abstención no cuenta), además del quórum y de la mayoría simple. - Se fija al crear la propuesta (storage TreasurySupermajorityOf) y se consume al decidirla. Las de tesoro abiertas antes de spec 21 conservan la mayoría simple con la que se abrieron. - Revertir el veto de una propuesta de tesoro exige los dos tercios frente a TODA la oposición: el Nay original más lo que sostuvo el veto en la ventana. Es el bloqueante de la revisión adversaria: con Nay 4Q y 2Q sosteniendo, 8Q pagaban cuando el voto ordinario pedía 12Q. - Evento nuevo ProposalRejectedForSupermajority, al final del enum. Sin migración y con transaction_version igual. - reaches_supermajority: exacta (aye por den >= num por emitido), monótona, sin Aye nunca aprueba y falla cerrada si desborda. Un const assert exige que la fracción sea mayor que 1/2. - Pruebas: 18 contrafactuales en rojo (17 en su aserción, 1 en compilación), 114 tests del pallet y 373 del workspace. - E2E: pasos 2c (la propuesta nueva guarda 2/3), 2d (la sembrada en spec 18 no) y 2e (la frontera del upgrade sobre todas las propuestas de tesoro). Falta antes de main: el certificado de Laurha para los tres pesos que cambian de esqueleto (submit_treasury_proposal, finalize_proposal, finalize_override), el E2E vivo y el upgrade. Sigue abierto, desde RV17: el override de una propuesta ORDINARIA no cuenta lo que sostiene. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6910f52b1e
commit
4be7da78c3
5 files changed
+486
-19
No files matched your search
@@ -123,6 +123,7 @@ mod benchmarks {
|
||||
);
|
||||
|
||||
assert_eq!(TreasuryActions::<T>::iter().count(), 1);
|
||||
assert_eq!(TreasurySupermajorityOf::<T>::iter().count(), 1);
|
||||
}
|
||||
|
||||
#[benchmark]
|
||||
@@ -149,16 +150,20 @@ mod benchmarks {
|
||||
fn finalize_proposal() {
|
||||
let caller: T::AccountId = whitelisted_caller();
|
||||
let id = ProposalId([7u8; 32]);
|
||||
// RV17: propuesta ORDINARIA (con su quórum guardado) con mayoría pero sin
|
||||
// quórum: es la rama más cara, porque consume el quórum, rechaza, devuelve el
|
||||
// bond y emite DOS eventos (ProposalFinalized + ProposalRejectedForQuorum).
|
||||
// RV17 + GSUN-3: propuesta de TESORO con mayoría simple, pero sin quórum y
|
||||
// sin los dos tercios (3 Aye, 2 Nay, quórum 4). Es la rama más cara: consume el
|
||||
// quórum y la mayoría cualificada, rechaza, devuelve el bond y emite TRES
|
||||
// eventos (ProposalFinalized, ProposalRejectedForQuorum y
|
||||
// ProposalRejectedForSupermajority).
|
||||
seed_proposal::<T>(id, ProposalStatus::Voting, MIN_VOTING_PERIOD_BLOCKS);
|
||||
Proposals::<T>::mutate(id, |p| {
|
||||
if let Some(p) = p {
|
||||
p.yes_weight = 1;
|
||||
p.yes_weight = 3;
|
||||
p.nay_weight = 2;
|
||||
}
|
||||
});
|
||||
VoteQuorumOf::<T>::insert(id, 2u128);
|
||||
VoteQuorumOf::<T>::insert(id, 4u128);
|
||||
TreasurySupermajorityOf::<T>::insert(id, TREASURY_SUPERMAJORITY);
|
||||
// Rama cara: devuelve el bond (transfer real desde la cuenta-bond).
|
||||
let proposer = seed_bond::<T>(id);
|
||||
// current_block debe ser > end_block → avanza más allá del periodo.
|
||||
@@ -172,15 +177,25 @@ mod benchmarks {
|
||||
// Que de verdad midió la rama del quórum (la revisión de RV17 vio que con
|
||||
// `!Voting` el benchmark seguía verde sin ella).
|
||||
assert!(matches!(Proposals::<T>::get(id).unwrap().status, ProposalStatus::Rejected));
|
||||
frame_system::Pallet::<T>::assert_last_event(
|
||||
frame_system::Pallet::<T>::assert_has_event(
|
||||
<T as Config>::RuntimeEvent::from(Event::<T>::ProposalRejectedForQuorum {
|
||||
id,
|
||||
yes_weight: 1,
|
||||
quorum: 2,
|
||||
yes_weight: 3,
|
||||
quorum: 4,
|
||||
})
|
||||
.into(),
|
||||
);
|
||||
frame_system::Pallet::<T>::assert_last_event(
|
||||
<T as Config>::RuntimeEvent::from(Event::<T>::ProposalRejectedForSupermajority {
|
||||
id,
|
||||
yes_weight: 3,
|
||||
nay_weight: 2,
|
||||
required: TREASURY_SUPERMAJORITY,
|
||||
})
|
||||
.into(),
|
||||
);
|
||||
assert!(!VoteQuorumOf::<T>::contains_key(id));
|
||||
assert!(!TreasurySupermajorityOf::<T>::contains_key(id));
|
||||
assert!(!ProposalBond::<T>::contains_key(id));
|
||||
assert_eq!(T::Currency::balance(&proposer), MIN_PROPOSAL_STAKE);
|
||||
}
|
||||
@@ -408,6 +423,8 @@ mod benchmarks {
|
||||
OverrideQuorum::<T>::insert(id, T::MinOverrideQuorum::get());
|
||||
// RV17: el override también lee (y consume) el quórum ordinario guardado.
|
||||
VoteQuorumOf::<T>::insert(id, T::VoteQuorum::get());
|
||||
// GSUN-3: y, si es de tesoro, su mayoría cualificada (sin Nay, se alcanza).
|
||||
TreasurySupermajorityOf::<T>::insert(id, TREASURY_SUPERMAJORITY);
|
||||
OverrideStakeTotal::<T>::insert(
|
||||
id,
|
||||
T::MinOverrideQuorum::get().max(T::VoteQuorum::get()).max(1),
|
||||
@@ -423,6 +440,7 @@ mod benchmarks {
|
||||
));
|
||||
assert!(!OverrideDeadline::<T>::contains_key(id));
|
||||
assert!(!VoteQuorumOf::<T>::contains_key(id));
|
||||
assert!(!TreasurySupermajorityOf::<T>::contains_key(id));
|
||||
}
|
||||
|
||||
impl_benchmark_test_suite!(Pallet, crate::mock::new_test_ext(), crate::mock::Test);
|
||||
|
||||
@@ -147,7 +147,8 @@ pub mod pallet {
|
||||
/// 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).
|
||||
/// quórum (RV17); si es de tesoro, además dos tercios del voto emitido (GSUN-3).
|
||||
/// O un override revirtió el veto del council (RV16).
|
||||
Approved,
|
||||
/// Finalized canonical: nay_weight >= yes_weight (rejected canonical).
|
||||
Rejected,
|
||||
@@ -239,6 +240,36 @@ pub mod pallet {
|
||||
(total_stake.saturating_mul(4).saturating_add(4)) / 5
|
||||
}
|
||||
|
||||
/// GSUN-3 (David, 2-nov-2026: «Sí a la súper-mayoría, dos tercios»): una propuesta
|
||||
/// de TESORO se aprueba sólo si su Aye es al menos dos tercios del voto emitido
|
||||
/// (Aye + Nay; la abstención no cuenta, como en el quórum). Se fija al crearla en
|
||||
/// [`TreasurySupermajorityOf`], como `(num, den)`.
|
||||
pub const TREASURY_SUPERMAJORITY: (u32, u32) = (2, 3);
|
||||
|
||||
// Una mayoría cualificada es una fracción de verdad y más exigente que la simple:
|
||||
// 0 < num <= den y num/den > 1/2. Lo comprueba el compilador.
|
||||
const _: () = assert!(
|
||||
TREASURY_SUPERMAJORITY.0 > 0
|
||||
&& TREASURY_SUPERMAJORITY.0 <= TREASURY_SUPERMAJORITY.1
|
||||
&& 2 * TREASURY_SUPERMAJORITY.0 > TREASURY_SUPERMAJORITY.1
|
||||
);
|
||||
|
||||
/// GSUN-3: `aye` alcanza la fracción `num/den` del voto emitido, sin redondeo:
|
||||
/// `aye · den >= num · (aye + nay)`. Con dos tercios equivale a `aye >= 2 · nay`.
|
||||
/// Sin ningún Aye no se alcanza nunca. Si una suma o un producto desborda, falla cerrado.
|
||||
pub const fn reaches_supermajority(aye: u128, nay: u128, ratio: (u32, u32)) -> bool {
|
||||
if aye == 0 {
|
||||
return false;
|
||||
}
|
||||
let Some(emitido) = aye.checked_add(nay) else {
|
||||
return false;
|
||||
};
|
||||
match (aye.checked_mul(ratio.1 as u128), emitido.checked_mul(ratio.0 as u128)) {
|
||||
(Some(a), Some(b)) => a >= b,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// RV15: qué respalda un tramo de OGC congelado.
|
||||
#[derive(Encode, Decode, MaxEncodedLen, TypeInfo, Clone, Copy, PartialEq, Eq, RuntimeDebug)]
|
||||
pub enum BackingKind {
|
||||
@@ -478,6 +509,18 @@ pub mod pallet {
|
||||
pub type TreasuryActions<T: Config> =
|
||||
StorageMap<_, Blake2_128Concat, ProposalId, (T::AccountId, u128), OptionQuery>;
|
||||
|
||||
/// GSUN-3: la mayoría cualificada de cada propuesta de TESORO, fijada al crearla
|
||||
/// (`submit_treasury_proposal`) como fracción `(num, den)` del voto emitido. Se
|
||||
/// consume al decidirla, en `finalize_proposal` o en `finalize_override`.
|
||||
///
|
||||
/// Sin entrada basta la mayoría simple. Es lo correcto en los dos casos:
|
||||
/// - la propuesta ordinaria, que no paga;
|
||||
/// - la de tesoro creada antes de spec 21, que se abrió y se votó con mayoría simple:
|
||||
/// exigirle dos tercios sería cambiarle la regla a mitad, como con `VoteQuorumOf`.
|
||||
#[pallet::storage]
|
||||
pub type TreasurySupermajorityOf<T: Config> =
|
||||
StorageMap<_, Blake2_128Concat, ProposalId, (u32, u32), OptionQuery>;
|
||||
|
||||
/// CHN-4 canonical: `Proposals<StorageMap<ProposalId, Proposal>>` canonical.
|
||||
#[pallet::storage]
|
||||
#[pallet::getter(fn proposal_of)]
|
||||
@@ -820,6 +863,19 @@ pub mod pallet {
|
||||
/// Cuánto, en unidades base.
|
||||
amount: u128,
|
||||
},
|
||||
/// GSUN-3: la propuesta de tesoro tuvo más Aye que Nay, pero no llegó a la
|
||||
/// mayoría cualificada que se fijó al crearla. Va además del `ProposalFinalized`
|
||||
/// con `Rejected`, para que se lea POR QUÉ.
|
||||
ProposalRejectedForSupermajority {
|
||||
/// Propuesta rechazada.
|
||||
id: ProposalId,
|
||||
/// OGC que votó Aye.
|
||||
yes_weight: u128,
|
||||
/// OGC que votó Nay.
|
||||
nay_weight: u128,
|
||||
/// La fracción del voto emitido (Aye + Nay) que se exigía, `(num, den)`.
|
||||
required: (u32, u32),
|
||||
},
|
||||
}
|
||||
|
||||
/// Pallet errors canonical.
|
||||
@@ -952,6 +1008,9 @@ pub mod pallet {
|
||||
/// 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.
|
||||
///
|
||||
/// GSUN-3: para aprobarse exige además dos tercios del voto emitido, fijados
|
||||
/// ahora en `TreasurySupermajorityOf`.
|
||||
#[pallet::call_index(16)]
|
||||
#[pallet::weight(T::WeightInfo::submit_treasury_proposal())]
|
||||
pub fn submit_treasury_proposal(
|
||||
@@ -975,6 +1034,7 @@ pub mod pallet {
|
||||
let id =
|
||||
Self::do_submit_proposal(who, title_hash, body_hash, voting_period_blocks, stake)?;
|
||||
TreasuryActions::<T>::insert(id, (recipient.clone(), amount));
|
||||
TreasurySupermajorityOf::<T>::insert(id, TREASURY_SUPERMAJORITY);
|
||||
Self::deposit_event(Event::TreasuryProposalSubmitted { id, recipient, amount });
|
||||
Ok(())
|
||||
}
|
||||
@@ -1081,9 +1141,18 @@ pub mod pallet {
|
||||
// fijó al crearlo (ver `VoteQuorumOf`). Sin entrada (emergencia, o creada
|
||||
// antes de RV17), no hay quórum.
|
||||
let quorum = VoteQuorumOf::<T>::take(proposal_id).unwrap_or(0);
|
||||
// GSUN-3: la de tesoro exige además la mayoría cualificada que se fijó al
|
||||
// crearla (ver `TreasurySupermajorityOf`). Sin entrada, basta la simple.
|
||||
let cualificada = TreasurySupermajorityOf::<T>::take(proposal_id);
|
||||
let mayoria = proposal.yes_weight > proposal.nay_weight;
|
||||
let sin_quorum = mayoria && proposal.yes_weight < quorum;
|
||||
let final_status = if mayoria && !sin_quorum {
|
||||
let sin_cualificada = match cualificada {
|
||||
Some(r) => {
|
||||
mayoria && !reaches_supermajority(proposal.yes_weight, proposal.nay_weight, r)
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
let final_status = if mayoria && !sin_quorum && !sin_cualificada {
|
||||
ProposalStatus::Approved
|
||||
} else {
|
||||
ProposalStatus::Rejected
|
||||
@@ -1112,6 +1181,14 @@ pub mod pallet {
|
||||
quorum,
|
||||
});
|
||||
}
|
||||
if let (true, Some(required)) = (sin_cualificada, cualificada) {
|
||||
Self::deposit_event(Event::ProposalRejectedForSupermajority {
|
||||
id: proposal_id,
|
||||
yes_weight,
|
||||
nay_weight,
|
||||
required,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1634,6 +1711,13 @@ pub mod pallet {
|
||||
/// 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).
|
||||
/// 3. GSUN-3: si es de tesoro, revertir alcanza además la mayoría cualificada que
|
||||
/// se fijó al crearla (dos tercios) frente a TODA la oposición: el Nay del voto
|
||||
/// original MÁS lo que sostuvo el veto en la ventana. Contra cada parte por
|
||||
/// separado no basta: con Nay 4Q y 2Q sosteniendo, 8Q revertían (cumplían 2·Nay y
|
||||
/// los 4/5 de la ventana) cuando el voto ordinario pedía 12Q (revisión adversarial
|
||||
/// de GSUN-3). Quien votó Nay y además sostiene cuenta dos veces: sólo sube el
|
||||
/// listón de revertir al council, que ya es difícil a propósito.
|
||||
///
|
||||
/// En cualquier otro caso el veto se sostiene. Se decide una sola vez.
|
||||
#[pallet::call_index(15)]
|
||||
@@ -1668,6 +1752,7 @@ pub mod pallet {
|
||||
OverrideDeadline::<T>::remove(proposal_id);
|
||||
OverrideQuorum::<T>::remove(proposal_id);
|
||||
VoteQuorumOf::<T>::remove(proposal_id);
|
||||
let cualificada = TreasurySupermajorityOf::<T>::take(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
|
||||
@@ -1679,7 +1764,15 @@ pub mod pallet {
|
||||
let overridden = override_stake > 0
|
||||
&& override_stake >= override_supermajority_threshold(quorum.max(participation))
|
||||
&& override_stake >= quorum_ordinario
|
||||
&& override_stake > proposal.nay_weight;
|
||||
&& override_stake > proposal.nay_weight
|
||||
&& match cualificada {
|
||||
Some(r) => reaches_supermajority(
|
||||
override_stake,
|
||||
proposal.nay_weight.saturating_add(uphold_stake),
|
||||
r,
|
||||
),
|
||||
None => true,
|
||||
};
|
||||
if overridden {
|
||||
proposal.status = ProposalStatus::Approved;
|
||||
Proposals::<T>::insert(proposal_id, proposal);
|
||||
|
||||
@@ -2542,9 +2542,10 @@ fn vetar_nunca_abarata_la_aprobacion() {
|
||||
});
|
||||
}
|
||||
|
||||
/// Propuesta de tesoro con oposición: Bob vota Aye `MIN` y Charlie Nay `4·MIN`. El council
|
||||
/// (Alice, Chair) la veta y Alice la revierte con `apoyo`.
|
||||
fn vetar_con_oposicion(apoyo: u128) -> (ProposalStatus, bool) {
|
||||
/// Propuesta con oposición: Bob vota Aye `MIN` y Charlie Nay `4·MIN`. El council (Alice,
|
||||
/// Chair) la veta y Alice la revierte con `apoyo`. Si `tesoro`, es de tesoro (paga 300 000 a
|
||||
/// Bob); si no, ordinaria, y no puede pagar nada.
|
||||
fn vetar_con_oposicion(tesoro: bool, apoyo: u128) -> (ProposalStatus, bool) {
|
||||
use crate::pallet::CouncilRole;
|
||||
use frame_support::dispatch::RawOrigin;
|
||||
crate::mock::VoteQuorum::set(2 * MIN_PROPOSAL_STAKE);
|
||||
@@ -2554,7 +2555,8 @@ fn vetar_con_oposicion(apoyo: u128) -> (ProposalStatus, bool) {
|
||||
alice_account(),
|
||||
CouncilRole::Chair
|
||||
));
|
||||
let id = submit_tesoro(bob_account(), 300_000);
|
||||
let id =
|
||||
if tesoro { submit_tesoro(bob_account(), 300_000) } else { submit_default(alice_origin()) };
|
||||
assert_ok!(Governance::cast_vote(bob_origin(), id, VoteStance::Aye, MIN_PROPOSAL_STAKE));
|
||||
assert_ok!(Governance::cast_vote(
|
||||
charlie_origin(),
|
||||
@@ -2581,15 +2583,287 @@ fn vetar_no_abarata_la_aprobacion_frente_a_la_oposicion() {
|
||||
// o sea 4·MIN justos, si Charlie no volvía a votar para sostener. Un veto convertía
|
||||
// una propuesta perdida en un pago más barato que ganarla. Ahora el override tiene
|
||||
// que SUPERAR el Nay original, como el voto ordinario.
|
||||
//
|
||||
// Se mide en una ordinaria: en una de tesoro manda ya la mayoría cualificada de GSUN-3,
|
||||
// que pide el doble del Nay (lo mide el test siguiente).
|
||||
new_test_ext().execute_with(|| {
|
||||
let (estado, pagado) = vetar_con_oposicion(4 * MIN_PROPOSAL_STAKE);
|
||||
let (estado, pagado) = vetar_con_oposicion(false, 4 * MIN_PROPOSAL_STAKE);
|
||||
assert!(matches!(estado, ProposalStatus::Rejected), "igualar al Nay no basta");
|
||||
assert!(!pagado);
|
||||
});
|
||||
// Control positivo: superarlo por una unidad basta (y cumple los 4/5 y el quórum).
|
||||
new_test_ext().execute_with(|| {
|
||||
let (estado, pagado) = vetar_con_oposicion(4 * MIN_PROPOSAL_STAKE + 1);
|
||||
let (estado, pagado) = vetar_con_oposicion(false, 4 * MIN_PROPOSAL_STAKE + 1);
|
||||
assert!(matches!(estado, ProposalStatus::Approved));
|
||||
assert!(!pagado, "una ordinaria no paga aunque se apruebe");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revertir_el_veto_de_una_propuesta_de_tesoro_exige_dos_tercios_frente_al_nay() {
|
||||
// GSUN-3. El voto ordinario de tesoro pide Aye >= 2·Nay. Si revertir el veto sólo
|
||||
// pidiera superar el Nay, un veto abarataría el pago: con Nay 4·MIN, 4·MIN + 1 de
|
||||
// apoyo bastaría donde el voto ordinario pide 8·MIN. Frontera exacta en 8·MIN.
|
||||
for apoyo in [4 * MIN_PROPOSAL_STAKE + 1, 8 * MIN_PROPOSAL_STAKE - 1] {
|
||||
new_test_ext().execute_with(|| {
|
||||
let (estado, pagado) = vetar_con_oposicion(true, apoyo);
|
||||
assert!(matches!(estado, ProposalStatus::Rejected), "apoyo {apoyo} < 2·Nay");
|
||||
assert!(!pagado);
|
||||
let id = derive_proposal_id(
|
||||
&AccountId::new(alice_account_bytes()),
|
||||
&TITLE_HASH,
|
||||
&BODY_HASH,
|
||||
);
|
||||
assert!(
|
||||
crate::TreasurySupermajorityOf::<Test>::get(id).is_none(),
|
||||
"se consume también cuando el veto se sostiene"
|
||||
);
|
||||
});
|
||||
}
|
||||
new_test_ext().execute_with(|| {
|
||||
let (estado, pagado) = vetar_con_oposicion(true, 8 * MIN_PROPOSAL_STAKE);
|
||||
assert!(matches!(estado, ProposalStatus::Approved));
|
||||
assert!(pagado);
|
||||
let id =
|
||||
derive_proposal_id(&AccountId::new(alice_account_bytes()), &TITLE_HASH, &BODY_HASH);
|
||||
assert!(crate::TreasurySupermajorityOf::<Test>::get(id).is_none(), "se consume");
|
||||
});
|
||||
}
|
||||
|
||||
// ─── GSUN-3: dos tercios para el tesoro ────────────────────────────────────────
|
||||
|
||||
/// Abre una propuesta de TESORO (paga 300 000 a Bob) con el quórum del mock por
|
||||
/// defecto (MIN + 1), vota (Bob Aye, Charlie Nay, Alice Abstain), la finaliza e intenta
|
||||
/// pagarla. Devuelve el estado y si pagó.
|
||||
fn votar_tesoro(aye: u128, nay: u128, abstain: u128) -> (ProposalStatus, bool) {
|
||||
use frame_support::dispatch::RawOrigin;
|
||||
assert_ok!(Governance::treasury_deposit(alice_origin(), 1_000_000));
|
||||
let id = submit_tesoro(bob_account(), 300_000);
|
||||
if aye > 0 {
|
||||
assert_ok!(Governance::cast_vote(bob_origin(), id, VoteStance::Aye, aye));
|
||||
}
|
||||
if nay > 0 {
|
||||
assert_ok!(Governance::cast_vote(charlie_origin(), id, VoteStance::Nay, nay));
|
||||
}
|
||||
if abstain > 0 {
|
||||
assert_ok!(Governance::cast_vote(alice_origin(), id, VoteStance::Abstain, abstain));
|
||||
}
|
||||
run_to_block(TRAS_EL_PERIODO);
|
||||
assert_ok!(Governance::finalize_proposal(alice_origin(), id));
|
||||
let estado = Proposals::<Test>::get(id).unwrap().status;
|
||||
let pagado =
|
||||
Governance::treasury_disbursement(RawOrigin::Root.into(), id, bob_account(), 300_000)
|
||||
.is_ok();
|
||||
(estado, pagado)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn una_propuesta_de_tesoro_exige_dos_tercios_y_dice_por_que() {
|
||||
// Frontera exacta: con Nay N, 2N de Aye son justo dos tercios y se aprueba; 2N - 1
|
||||
// tiene mayoría simple y quórum, pero no los dos tercios.
|
||||
let n = MIN_PROPOSAL_STAKE;
|
||||
new_test_ext().execute_with(|| {
|
||||
let (estado, pagado) = votar_tesoro(2 * n - 1, n, 0);
|
||||
assert!(matches!(estado, ProposalStatus::Rejected));
|
||||
assert!(!pagado);
|
||||
let id =
|
||||
derive_proposal_id(&AccountId::new(alice_account_bytes()), &TITLE_HASH, &BODY_HASH);
|
||||
System::assert_has_event(RuntimeEvent::Governance(
|
||||
Event::ProposalRejectedForSupermajority {
|
||||
id,
|
||||
yes_weight: 2 * n - 1,
|
||||
nay_weight: n,
|
||||
required: (2, 3),
|
||||
},
|
||||
));
|
||||
// Tenía quórum: el rechazo es sólo por los dos tercios.
|
||||
assert!(!System::events().iter().any(|r| matches!(
|
||||
r.event,
|
||||
RuntimeEvent::Governance(Event::ProposalRejectedForQuorum { .. })
|
||||
)));
|
||||
});
|
||||
new_test_ext().execute_with(|| {
|
||||
let (estado, pagado) = votar_tesoro(2 * n, n, 0);
|
||||
assert!(matches!(estado, ProposalStatus::Approved));
|
||||
assert!(pagado);
|
||||
assert!(!System::events().iter().any(|r| matches!(
|
||||
r.event,
|
||||
RuntimeEvent::Governance(Event::ProposalRejectedForSupermajority { .. })
|
||||
)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn la_abstencion_no_cuenta_para_los_dos_tercios() {
|
||||
// Si la abstención contara en el voto emitido, abstenerse sería votar en contra
|
||||
// (subiría el listón) o, contada como Aye, aprobaría sin tomar partido.
|
||||
let n = MIN_PROPOSAL_STAKE;
|
||||
new_test_ext().execute_with(|| {
|
||||
let (estado, _) = votar_tesoro(2 * n, n, 5 * n);
|
||||
assert!(matches!(estado, ProposalStatus::Approved), "abstenerse no sube el listón");
|
||||
});
|
||||
new_test_ext().execute_with(|| {
|
||||
let (estado, _) = votar_tesoro(2 * n - 1, n, 5 * n);
|
||||
assert!(matches!(estado, ProposalStatus::Rejected), "abstenerse no aprueba");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn una_propuesta_ordinaria_sigue_con_mayoria_simple() {
|
||||
// Los dos tercios son sólo del tesoro: el mismo voto que rechaza un pago aprueba una
|
||||
// ordinaria.
|
||||
let n = MIN_PROPOSAL_STAKE;
|
||||
new_test_ext().execute_with(|| {
|
||||
let q = crate::mock::VoteQuorum::get();
|
||||
assert!(matches!(votar_y_finalizar(q, 2 * n - 1, n, 0), ProposalStatus::Approved));
|
||||
let id =
|
||||
derive_proposal_id(&AccountId::new(alice_account_bytes()), &TITLE_HASH, &BODY_HASH);
|
||||
assert!(crate::TreasurySupermajorityOf::<Test>::get(id).is_none());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn los_dos_tercios_se_fijan_al_crear_y_no_alcanzan_a_una_propuesta_anterior() {
|
||||
let n = MIN_PROPOSAL_STAKE;
|
||||
// Se fijan al crear y se consumen al decidir.
|
||||
new_test_ext().execute_with(|| {
|
||||
let id = submit_tesoro(bob_account(), 300_000);
|
||||
assert_eq!(crate::TreasurySupermajorityOf::<Test>::get(id), Some((2, 3)));
|
||||
run_to_block(TRAS_EL_PERIODO);
|
||||
assert_ok!(Governance::finalize_proposal(alice_origin(), id));
|
||||
assert!(crate::TreasurySupermajorityOf::<Test>::get(id).is_none(), "se consume");
|
||||
});
|
||||
// Una de tesoro creada antes de spec 21 se abrió y se votó con mayoría simple:
|
||||
// decidirla con dos tercios sería cambiarle la regla a mitad. Es el pago sembrado
|
||||
// en la testnet (fe92182e…, spec 18).
|
||||
new_test_ext().execute_with(|| {
|
||||
use frame_support::dispatch::RawOrigin;
|
||||
assert_ok!(Governance::treasury_deposit(alice_origin(), 1_000_000));
|
||||
let id = submit_tesoro(bob_account(), 300_000);
|
||||
crate::TreasurySupermajorityOf::<Test>::remove(id); // como la dejó spec 20
|
||||
assert_ok!(Governance::cast_vote(bob_origin(), id, VoteStance::Aye, 2 * n - 1));
|
||||
assert_ok!(Governance::cast_vote(charlie_origin(), id, VoteStance::Nay, n));
|
||||
run_to_block(TRAS_EL_PERIODO);
|
||||
assert_ok!(Governance::finalize_proposal(alice_origin(), id));
|
||||
assert!(matches!(Proposals::<Test>::get(id).unwrap().status, ProposalStatus::Approved));
|
||||
assert_ok!(Governance::treasury_disbursement(
|
||||
RawOrigin::Root.into(),
|
||||
id,
|
||||
bob_account(),
|
||||
300_000
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reaches_supermajority_es_exacta_monotona_y_falla_cerrada() {
|
||||
use crate::pallet::{reaches_supermajority, TREASURY_SUPERMAJORITY as R};
|
||||
// Exacta: dos tercios es Aye >= 2·Nay, sin redondeo.
|
||||
for nay in [1u128, 2, 3, 7, MIN_PROPOSAL_STAKE, 10u128.pow(27)] {
|
||||
assert!(reaches_supermajority(2 * nay, nay, R), "2·{nay} frente a {nay}");
|
||||
assert!(!reaches_supermajority(2 * nay - 1, nay, R), "2·{nay} - 1 frente a {nay}");
|
||||
}
|
||||
assert!(reaches_supermajority(1, 0, R));
|
||||
assert!(!reaches_supermajority(0, 0, R), "sin ningún Aye no se alcanza");
|
||||
// Monótona: más Aye nunca la pierde; más Nay nunca la gana.
|
||||
for aye in 0u128..40 {
|
||||
for nay in 0u128..40 {
|
||||
let base = reaches_supermajority(aye, nay, R);
|
||||
if base {
|
||||
assert!(reaches_supermajority(aye + 1, nay, R), "Aye de más la perdió");
|
||||
} else {
|
||||
assert!(!reaches_supermajority(aye, nay + 1, R), "Nay de más la ganó");
|
||||
}
|
||||
}
|
||||
}
|
||||
// Falla cerrada si desborda: nunca aprueba por saturación.
|
||||
assert!(!reaches_supermajority(u128::MAX, 1, R));
|
||||
assert!(!reaches_supermajority(u128::MAX / 2, 0, R));
|
||||
}
|
||||
|
||||
/// GSUN-3 · revisión adversarial. Propuesta de tesoro con Aye `MIN` (Bob) y Nay `4·MIN`
|
||||
/// (Charlie). El council (Alice, Chair) la veta. En la ventana, Dave (que no había votado)
|
||||
/// sostiene el veto con `sostener` y Eve lo revierte con `apoyo`. Si `sin_marca`, la
|
||||
/// propuesta es como la dejó spec 20: sin `TreasurySupermajorityOf`.
|
||||
fn vetar_con_oposicion_repartida(sostener: u128, apoyo: u128, sin_marca: bool) -> (ProposalStatus, bool) {
|
||||
use crate::pallet::CouncilRole;
|
||||
use frame_support::dispatch::RawOrigin;
|
||||
use frame_support::traits::fungible::Mutate;
|
||||
crate::mock::VoteQuorum::set(2 * MIN_PROPOSAL_STAKE);
|
||||
assert_ok!(Governance::treasury_deposit(alice_origin(), 1_000_000));
|
||||
assert_ok!(Governance::add_council_member(
|
||||
RawOrigin::Root.into(),
|
||||
alice_account(),
|
||||
CouncilRole::Chair
|
||||
));
|
||||
let id = submit_tesoro(bob_account(), 300_000);
|
||||
if sin_marca {
|
||||
crate::TreasurySupermajorityOf::<Test>::remove(id);
|
||||
}
|
||||
assert_ok!(Governance::cast_vote(bob_origin(), id, VoteStance::Aye, MIN_PROPOSAL_STAKE));
|
||||
assert_ok!(Governance::cast_vote(
|
||||
charlie_origin(),
|
||||
id,
|
||||
VoteStance::Nay,
|
||||
4 * MIN_PROPOSAL_STAKE
|
||||
));
|
||||
assert_ok!(Governance::veto_proposal(alice_origin(), id));
|
||||
let cierre = crate::OverrideDeadline::<Test>::get(id).unwrap();
|
||||
if sostener > 0 {
|
||||
let dave = sp_runtime::AccountId32::new([71u8; 32]);
|
||||
assert_ok!(Balances::mint_into(&dave, sostener + INITIAL_BALANCE));
|
||||
assert_ok!(Governance::uphold_council_veto(RuntimeOrigin::signed(dave), id, sostener));
|
||||
}
|
||||
let eve = sp_runtime::AccountId32::new([72u8; 32]);
|
||||
assert_ok!(Balances::mint_into(&eve, apoyo + INITIAL_BALANCE));
|
||||
assert_ok!(Governance::override_council_veto(RuntimeOrigin::signed(eve), id, apoyo));
|
||||
run_to_block(cierre + 1);
|
||||
assert_ok!(Governance::finalize_override(bob_origin(), id));
|
||||
let estado = Proposals::<Test>::get(id).unwrap().status;
|
||||
let pagado =
|
||||
Governance::treasury_disbursement(RawOrigin::Root.into(), id, bob_account(), 300_000)
|
||||
.is_ok();
|
||||
(estado, pagado)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revertir_el_veto_de_tesoro_mide_los_dos_tercios_frente_a_toda_la_oposicion() {
|
||||
// Revisión adversarial de GSUN-3 (bloqueante). Con Nay 4·MIN y 2·MIN sosteniendo en la
|
||||
// ventana, 8·MIN cumplían el doble del Nay y los 4/5 de la ventana, y el tesoro PAGABA. Sin
|
||||
// veto, esos mismos votantes (9·MIN de Aye frente a 6·MIN de Nay) se rechazan: hacen falta
|
||||
// 12·MIN. Revertir mide los dos tercios contra el Nay original MÁS lo que sostiene.
|
||||
let m = MIN_PROPOSAL_STAKE;
|
||||
for apoyo in [8 * m, 12 * m - 1] {
|
||||
new_test_ext().execute_with(|| {
|
||||
let (estado, pagado) = vetar_con_oposicion_repartida(2 * m, apoyo, false);
|
||||
assert!(matches!(estado, ProposalStatus::Rejected), "apoyo {apoyo} < 2·(4M + 2M)");
|
||||
assert!(!pagado);
|
||||
});
|
||||
}
|
||||
// Control positivo: la frontera exacta, 2·(Nay + sostener).
|
||||
new_test_ext().execute_with(|| {
|
||||
let (estado, pagado) = vetar_con_oposicion_repartida(2 * m, 12 * m, false);
|
||||
assert!(matches!(estado, ProposalStatus::Approved));
|
||||
assert!(pagado);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revertir_el_veto_de_una_propuesta_de_tesoro_anterior_no_exige_dos_tercios() {
|
||||
// Es el camino que tomaría el pago sembrado en spec 18 si lo vetaran después de spec 21:
|
||||
// se abrió con mayoría simple, y revertir su veto sigue la regla de entonces (superar el
|
||||
// Nay original, con los 4/5 y el quórum). Sin este test, exigirle dos tercios por el
|
||||
// override pasaba en verde (revisión adversarial, mutación M5).
|
||||
let m = MIN_PROPOSAL_STAKE;
|
||||
new_test_ext().execute_with(|| {
|
||||
let (estado, pagado) = vetar_con_oposicion_repartida(0, 4 * m + 1, true);
|
||||
assert!(matches!(estado, ProposalStatus::Approved));
|
||||
assert!(pagado);
|
||||
});
|
||||
// Y con la marca, el mismo apoyo no basta.
|
||||
new_test_ext().execute_with(|| {
|
||||
let (estado, pagado) = vetar_con_oposicion_repartida(0, 4 * m + 1, false);
|
||||
assert!(matches!(estado, ProposalStatus::Rejected));
|
||||
assert!(!pagado);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user