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:
CharonandClaude Opus 5.5 committed 2026-11-03 23:03:52 -05:00
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);
+96 -3
View File
@@ -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);
});
}
+10 -2
View File
@@ -374,6 +374,14 @@ pub const IMPL_NAME: &str = borrowed_str(&VERSION.impl_name);
/// lógica ni ningún storage, sólo lo que cuesta cada extrinsic. Es el primer upgrade que se
/// aplica en vivo a la testnet de la casa (rp1), por `set_code`.
///
/// **v21 (2026-11-02), GSUN-3 decidido por David:** una propuesta de TESORO se aprueba sólo con
/// dos tercios del voto emitido (Aye + Nay; la abstención no cuenta), además del quórum y de la
/// mayoría simple. «Sí a la súper-mayoría, dos tercios» (David, 2-nov). Se fija al crear la
/// propuesta (storage `TreasurySupermajorityOf`) y lo tiene que alcanzar también un override del
/// veto frente al Nay original. Las propuestas de tesoro abiertas antes conservan la mayoría
/// simple con la que se abrieron. Aditivo: un storage y un evento nuevos, sin migración y sin
/// cambiar la codificación de ninguna llamada.
///
/// **v20 (2026-11-02), sólo pesos:** los 17 extrinsics del gobierno cobran lo que certificó Laurha
/// en el banco de referencia de la rp1 (ver la cabecera de `origin_chain_governance::weights`). Ya
/// no queda ningún smoke. No cambia el código, ni el storage, ni la codificación.
@@ -1047,7 +1055,7 @@ pub const VERSION: sp_version::RuntimeVersion = sp_version::RuntimeVersion {
spec_name: alloc::borrow::Cow::Borrowed("origin-chain"),
impl_name: alloc::borrow::Cow::Borrowed("origin-chain-charon"),
authoring_version: 1,
spec_version: 20,
spec_version: 21,
impl_version: 1,
apis: RUNTIME_API_VERSIONS,
transaction_version: 1,
@@ -1208,7 +1216,7 @@ mod tests {
/// mismo commit en que la version subio a 12.
#[test]
fn spec_version_vigente_y_transaction_version_intacta() {
assert_eq!(RUNTIME_VERSION, 20);
assert_eq!(RUNTIME_VERSION, 21);
assert_eq!(TRANSACTION_VERSION, 1);
}
+74
View File
@@ -23,6 +23,15 @@ Comprueba, en este orden (se detiene en el primer fallo, código de salida 1):
el mismo para toda llamada: se mide con un treasury_deposit y el residuo tiene que
coincidir. Prueba el cableado; que el número sea el coste real lo prueba el bench;
2. Proposals(id) existe y TreasuryActions(id) = (Bob, monto), leído del storage por RPC;
2b. el quórum guardado es el 10 % de lo que puede votar (spec 19);
2c. desde spec 21 (GSUN-3), TreasurySupermajorityOf(id) = (2, 3): la propuesta de tesoro
nueva guarda sus dos tercios. Contra spec 20 este paso reprueba;
2d. la propuesta sembrada en spec 18 (SEMBRADA), si existe en esta cadena, NO tiene esa
entrada: se decide con la mayoría simple con la que se abrió;
2e. la frontera del upgrade: B es el primer bloque cuyo estado ya dice spec 21 (el del
set_code, que aún ejecutó el runtime anterior). Toda propuesta de tesoro con
start_block <= B no tiene marca, y toda la posterior la tiene. Se enumeran TODAS las
de TreasuryActions, no sólo la sembrada (revisión adversarial de GSUN-3);
3. tesoro:0 falla con TreasuryAmountBelowMinimum;
4. reenviar la misma propuesta falla con ProposalIdCollision y TreasuryActions no cambia;
5. sudo(treasury_disbursement) de esa propuesta, aún en votación, devuelve ProposalNotApproved
@@ -45,6 +54,10 @@ TITULO = hashlib.blake2b(b"e2e-rv17/titulo" + (f"/{ETIQUETA}".encode() if ETIQUE
# runtime: una omisión compartida pasaría en los dos.
VUELVEN = {(b"modl" + pid).ljust(32, b"\0").hex() for pid in (b"og/props", b"py/reviv")}
MONTO = 5 * 10**18 # 5 OGC
# El pago sembrado en la testnet de la casa el 28-oct (spec 18), que se cierra hacia el 26-nov.
SEMBRADA = "fe92182ecff4df083b42da838a509c82aab7a4d0213a902e5a5a89729bd06ad0"
# GSUN-3: la mayoría cualificada del tesoro, (num, den) como dos u32 en little endian.
DOS_TERCIOS = "0x" + (2).to_bytes(4, "little").hex() + (3).to_bytes(4, "little").hex()
BOB = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty"
BOB_HEX = "8eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a48"
@@ -247,6 +260,24 @@ def main(sembrar_pago=False):
print(f"2b ✅ quórum guardado {guardado / 10**18:,.6f} OGC = 10 % de lo que puede votar "
f"({(emision - sin_llave) / 10**18:,.4f} OGC; las cuentas sin llave guardan {sin_llave / 10**18:,.4f})")
# ORIGIN_E2E_EXIGE_SPEC=21 hace el paso aunque la cadena diga una spec anterior: es el
# contrafactual (contra spec 20 tiene que reprobar en el storage, no en la versión).
if v["specVersion"] >= 21 or os.environ.get("ORIGIN_E2E_EXIGE_SPEC", "") == "21":
cualificada = rpc("state_getStorage", [clave("TreasurySupermajorityOf", id_hex)])["result"]
if cualificada != DOS_TERCIOS:
falla(f"TreasurySupermajorityOf(id) = {cualificada}, no (2, 3)")
print("2c ✅ TreasurySupermajorityOf(id) = (2, 3): la propuesta de tesoro guarda sus dos tercios")
if rpc("state_getStorage", [clave("Proposals", SEMBRADA)])["result"]:
vieja = rpc("state_getStorage", [clave("TreasurySupermajorityOf", SEMBRADA)])["result"]
if vieja is not None:
falla(f"la propuesta sembrada en spec 18 tiene TreasurySupermajorityOf = {vieja}")
if not rpc("state_getStorage", [clave("TreasuryActions", SEMBRADA)])["result"]:
falla("la propuesta sembrada perdió su TreasuryActions")
print(f"2d ✅ la sembrada 0x{SEMBRADA[:12]}… (spec 18) no tiene dos tercios: se decide con mayoría simple")
else:
print("2d — esta cadena no tiene la propuesta sembrada; no aplica")
frontera_del_upgrade()
def falla_con(llamada, error, que):
n, evs = enviar(llamada, genesis, v)
idx = indice_de_error(error)
@@ -271,6 +302,49 @@ def main(sembrar_pago=False):
sembrar(id_hex, genesis, v)
def frontera_del_upgrade():
"""2e · B = primer bloque cuyo estado dice spec >= 21 (búsqueda binaria). Una propuesta de
tesoro con start_block <= B la creó el runtime anterior y no lleva marca; una posterior,
sí. Cualquier otra combinación reprueba."""
def spec(n):
return rpc("state_getRuntimeVersion", [rpc("chain_getBlockHash", [n])["result"]])["result"]["specVersion"]
lo, hi = 0, best()
if spec(hi) < 21:
falla("2e: la cadena no llegó a spec 21")
while lo < hi:
mid = (lo + hi) // 2
if spec(mid) >= 21:
hi = mid
else:
lo = mid + 1
b = lo
pref = "0x" + twox128("Governance") + twox128("TreasuryActions")
claves, desde = [], None
while True:
lote = rpc("state_getKeysPaged", [pref, 500, desde] if desde else [pref, 500])["result"]
claves += lote
if len(lote) < 500:
break
desde = lote[-1]
antes, despues = [], []
for k in claves:
pid = k[-64:]
p = rpc("state_getStorage", [clave("Proposals", pid)])["result"]
if not p:
falla(f"2e: TreasuryActions sin Proposals para 0x{pid[:12]}…")
inicio = int.from_bytes(bytes.fromhex(p[2:])[144:152], "little")
marca = rpc("state_getStorage", [clave("TreasurySupermajorityOf", pid)])["result"]
if inicio <= b and marca is not None:
falla(f"2e: 0x{pid[:12]}… es de antes del upgrade (#{inicio} <= #{b}) y tiene marca {marca}")
if inicio > b and marca != DOS_TERCIOS:
# Una ya decidida consumió su marca: sólo se exige en las que siguen en votación.
if bytes.fromhex(p[2:])[160] == 0:
falla(f"2e: 0x{pid[:12]}… es de después del upgrade (#{inicio} > #{b}), sigue en votación y no tiene (2, 3)")
(antes if inicio <= b else despues).append(f"0x{pid[:8]}…@#{inicio}")
print(f"2e ✅ frontera del upgrade en #{b}: {len(antes)} de antes sin marca ({', '.join(antes)}) · "
f"{len(despues)} de después con (2, 3)")
def sembrar(id_hex, genesis, v):
"""Deja la propuesta lista para cerrar el camino feliz en vivo: fondea el vault y vota Aye
con EXACTAMENTE el quórum guardado (la frontera `yes >= quórum`)."""