From 4be7da78c3e8e4d651fd4abbb7e6488b99549f00 Mon Sep 17 00:00:00 2001 From: Charon Date: Mon, 2 Nov 2026 19:40:06 -0500 Subject: [PATCH] feat(gobierno): GSUN-3, dos tercios para el tesoro (spec 21, pesos por certificar) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/benchmarking.rs | 34 ++- crates/origin-chain-governance/src/lib.rs | 99 +++++- .../src/tests_integration.rs | 286 +++++++++++++++++- crates/origin-chain-runtime/src/lib.rs | 12 +- scripts/e2e/rv17_tesoro.py | 74 +++++ 5 files changed, 486 insertions(+), 19 deletions(-) diff --git a/crates/origin-chain-governance/src/benchmarking.rs b/crates/origin-chain-governance/src/benchmarking.rs index 3c8f61c..fce385c 100644 --- a/crates/origin-chain-governance/src/benchmarking.rs +++ b/crates/origin-chain-governance/src/benchmarking.rs @@ -123,6 +123,7 @@ mod benchmarks { ); assert_eq!(TreasuryActions::::iter().count(), 1); + assert_eq!(TreasurySupermajorityOf::::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::(id, ProposalStatus::Voting, MIN_VOTING_PERIOD_BLOCKS); Proposals::::mutate(id, |p| { if let Some(p) = p { - p.yes_weight = 1; + p.yes_weight = 3; + p.nay_weight = 2; } }); - VoteQuorumOf::::insert(id, 2u128); + VoteQuorumOf::::insert(id, 4u128); + TreasurySupermajorityOf::::insert(id, TREASURY_SUPERMAJORITY); // Rama cara: devuelve el bond (transfer real desde la cuenta-bond). let proposer = seed_bond::(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::::get(id).unwrap().status, ProposalStatus::Rejected)); - frame_system::Pallet::::assert_last_event( + frame_system::Pallet::::assert_has_event( ::RuntimeEvent::from(Event::::ProposalRejectedForQuorum { id, - yes_weight: 1, - quorum: 2, + yes_weight: 3, + quorum: 4, + }) + .into(), + ); + frame_system::Pallet::::assert_last_event( + ::RuntimeEvent::from(Event::::ProposalRejectedForSupermajority { + id, + yes_weight: 3, + nay_weight: 2, + required: TREASURY_SUPERMAJORITY, }) .into(), ); assert!(!VoteQuorumOf::::contains_key(id)); + assert!(!TreasurySupermajorityOf::::contains_key(id)); assert!(!ProposalBond::::contains_key(id)); assert_eq!(T::Currency::balance(&proposer), MIN_PROPOSAL_STAKE); } @@ -408,6 +423,8 @@ mod benchmarks { OverrideQuorum::::insert(id, T::MinOverrideQuorum::get()); // RV17: el override también lee (y consume) el quórum ordinario guardado. VoteQuorumOf::::insert(id, T::VoteQuorum::get()); + // GSUN-3: y, si es de tesoro, su mayoría cualificada (sin Nay, se alcanza). + TreasurySupermajorityOf::::insert(id, TREASURY_SUPERMAJORITY); OverrideStakeTotal::::insert( id, T::MinOverrideQuorum::get().max(T::VoteQuorum::get()).max(1), @@ -423,6 +440,7 @@ mod benchmarks { )); assert!(!OverrideDeadline::::contains_key(id)); assert!(!VoteQuorumOf::::contains_key(id)); + assert!(!TreasurySupermajorityOf::::contains_key(id)); } impl_benchmark_test_suite!(Pallet, crate::mock::new_test_ext(), crate::mock::Test); diff --git a/crates/origin-chain-governance/src/lib.rs b/crates/origin-chain-governance/src/lib.rs index a766056..11d7b77 100644 --- a/crates/origin-chain-governance/src/lib.rs +++ b/crates/origin-chain-governance/src/lib.rs @@ -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 = 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 = + StorageMap<_, Blake2_128Concat, ProposalId, (u32, u32), OptionQuery>; + /// CHN-4 canonical: `Proposals>` 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::::insert(id, (recipient.clone(), amount)); + TreasurySupermajorityOf::::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::::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::::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::::remove(proposal_id); OverrideQuorum::::remove(proposal_id); VoteQuorumOf::::remove(proposal_id); + let cualificada = TreasurySupermajorityOf::::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::::insert(proposal_id, proposal); diff --git a/crates/origin-chain-governance/src/tests_integration.rs b/crates/origin-chain-governance/src/tests_integration.rs index 9dc5921..27514ca 100644 --- a/crates/origin-chain-governance/src/tests_integration.rs +++ b/crates/origin-chain-governance/src/tests_integration.rs @@ -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::::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::::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::::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::::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::::get(id), Some((2, 3))); + run_to_block(TRAS_EL_PERIODO); + assert_ok!(Governance::finalize_proposal(alice_origin(), id)); + assert!(crate::TreasurySupermajorityOf::::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::::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::::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::::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::::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::::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); + }); +} diff --git a/crates/origin-chain-runtime/src/lib.rs b/crates/origin-chain-runtime/src/lib.rs index 327dd82..39b7f51 100644 --- a/crates/origin-chain-runtime/src/lib.rs +++ b/crates/origin-chain-runtime/src/lib.rs @@ -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); } diff --git a/scripts/e2e/rv17_tesoro.py b/scripts/e2e/rv17_tesoro.py index 7ed9f44..9103d18 100644 --- a/scripts/e2e/rv17_tesoro.py +++ b/scripts/e2e/rv17_tesoro.py @@ -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`)."""