From b6d05eaf45e3ec894cf5ac1c4f8c4d125e96c396 Mon Sep 17 00:00:00 2001 From: Charon Date: Wed, 28 Oct 2026 21:55:24 -0500 Subject: [PATCH] feat(gobierno): spec 18 (RV17), quorum del voto ordinario y pago del tesoro atado a la propuesta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gobierno (origin-chain-governance): - El voto ordinario exige un quorum de Aye, el 10 % de PILAR_4 §6.6. Se fija AL CREAR la propuesta (VoteQuorumOf) y se consume al decidirla: un set_code no mueve la meta de un voto abierto. Cuenta solo el Aye: con el Nay, un voto en contra podria aprobar; con la abstencion, el mismo OGC inflaria el quorum de muchas propuestas. - Un override del veto tiene que alcanzar ese quorum y superar el Nay original, las dos condiciones del voto ordinario. Sin ellas, vetar abarataba una aprobacion (dos bloqueantes de las revisiones adversariales). - submit_treasury_proposal (call 16) fija destinatario y monto (TreasuryActions), y treasury_disbursement solo paga exactamente eso, una vez. Rechaza montos bajo el deposito existencial y como destinatario la propia tesoreria o la cuenta-bond. - Sin entrada en VoteQuorumOf no hay quorum: la emergencia (que nunca lleva accion de tesoro) y lo creado antes de RV17, que se abrio y voto sin quorum. Runtime: los dos quorums son el 10 % de la emision VIGENTE, redondeado hacia arriba y nunca por debajo del bond (DiezPorCientoDeLaEmision). Con 100M fijos, en la testnet de la casa (100M emitidos, todos de Alice) nada se habria aprobado nunca; en mainnet sigue siendo ~100M. Spec 18, transaction_version 1: todo lo nuevo es aditivo, sin migracion. Node: pasa a opaque::Block. Decodificaba los extrinsics con tipos nativos y rechazaba en el pool la llamada que anadia el set_code (1040, el mismo error que una llamada inexistente). Los bytes y los hashes no cambian: el binario nuevo sincronizo desde cero los 39 273 bloques de la testnet de la casa con los mismos hashes en 8 alturas. El RPC sirve los extrinsics en hex. Pesos: seis extrinsics cambiaron de codigo y se midieron en la Pi 5 (smoke declarado: 5 procesos limpios, sin CI en ningun segundo, maximo de los 5). Los otros once siguen siendo los certificados por Laurha. Se descarto una medida contaminada: su muestreador no veia los tests de la CI. Pruebas: - 363 tests verdes en el workspace. - 24 contrafactuales, los 24 en rojo. - Ensayo en la rp1 sobre una cadena con el codigo vivo de spec 17: upgrade a 18 y E2E firmando como un wallet (scripts/e2e/rv17_tesoro.py). - Ejemplo firmar_como_wallet con tesoro, pagar, votar, depositar y finalizar; los eventos llevan su fase. Co-Authored-By: Claude Opus 5.5 --- .../src/benchmarking.rs | 64 ++- crates/origin-chain-governance/src/lib.rs | 279 +++++++++--- crates/origin-chain-governance/src/mock.rs | 6 + .../src/tests_integration.rs | 431 +++++++++++++++++- crates/origin-chain-governance/src/weights.rs | 130 ++++-- crates/origin-chain-node/src/main.rs | 4 +- crates/origin-chain-node/src/rpc.rs | 22 +- crates/origin-chain-node/src/service.rs | 11 +- .../examples/firmar_como_wallet.rs | 65 ++- crates/origin-chain-runtime/src/configs.rs | 35 +- .../src/genesis_config_presets.rs | 68 ++- crates/origin-chain-runtime/src/lib.rs | 17 +- crates/origin-chain-runtime/src/opaque.rs | 133 +++++- scripts/e2e/rv17_tesoro.py | 258 +++++++++++ 14 files changed, 1368 insertions(+), 155 deletions(-) create mode 100644 scripts/e2e/rv17_tesoro.py diff --git a/crates/origin-chain-governance/src/benchmarking.rs b/crates/origin-chain-governance/src/benchmarking.rs index 80a9c4f..3c8f61c 100644 --- a/crates/origin-chain-governance/src/benchmarking.rs +++ b/crates/origin-chain-governance/src/benchmarking.rs @@ -105,6 +105,26 @@ mod benchmarks { assert_eq!(Proposals::::iter().count(), 1); } + #[benchmark] + fn submit_treasury_proposal() { + let caller: T::AccountId = whitelisted_caller(); + fund::(&caller); + let recipient: T::AccountId = account("recipient", 0, 0); + + #[extrinsic_call] + submit_treasury_proposal( + RawOrigin::Signed(caller), + [1u8; 32], + [2u8; 32], + MIN_VOTING_PERIOD_BLOCKS, + MIN_PROPOSAL_STAKE, + recipient, + MIN_PROPOSAL_STAKE, + ); + + assert_eq!(TreasuryActions::::iter().count(), 1); + } + #[benchmark] fn cast_vote() { let caller: T::AccountId = whitelisted_caller(); @@ -129,19 +149,38 @@ mod benchmarks { fn finalize_proposal() { let caller: T::AccountId = whitelisted_caller(); let id = ProposalId([7u8; 32]); - seed_proposal::(id, ProposalStatus::Voting, 0u64); + // 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). + seed_proposal::(id, ProposalStatus::Voting, MIN_VOTING_PERIOD_BLOCKS); + Proposals::::mutate(id, |p| { + if let Some(p) = p { + p.yes_weight = 1; + } + }); + VoteQuorumOf::::insert(id, 2u128); // Rama cara: devuelve el bond (transfer real desde la cuenta-bond). let proposer = seed_bond::(id); - // current_block debe ser > end_block(0) → avanza a 2. - frame_system::Pallet::::set_block_number(2u32.into()); + // current_block debe ser > end_block → avanza más allá del periodo. + frame_system::Pallet::::set_block_number( + ((MIN_VOTING_PERIOD_BLOCKS + 2) as u32).into(), + ); #[extrinsic_call] finalize_proposal(RawOrigin::Signed(caller), id); - assert!(!matches!( - Proposals::::get(id).unwrap().status, - ProposalStatus::Voting - )); + // 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( + ::RuntimeEvent::from(Event::::ProposalRejectedForQuorum { + id, + yes_weight: 1, + quorum: 2, + }) + .into(), + ); + assert!(!VoteQuorumOf::::contains_key(id)); assert!(!ProposalBond::::contains_key(id)); assert_eq!(T::Currency::balance(&proposer), MIN_PROPOSAL_STAKE); } @@ -277,6 +316,9 @@ mod benchmarks { MIN_PROPOSAL_STAKE.saturating_mul(10), ); + // RV17: la acción fijada al crear la propuesta, que el desembolso tiene que igualar. + TreasuryActions::::insert(id, (recipient.clone(), MIN_PROPOSAL_STAKE)); + #[extrinsic_call] treasury_disbursement(RawOrigin::Root, id, recipient, MIN_PROPOSAL_STAKE); @@ -364,7 +406,12 @@ mod benchmarks { VetoRejected::::insert(id, ()); OverrideDeadline::::insert(id, 1u64); OverrideQuorum::::insert(id, T::MinOverrideQuorum::get()); - OverrideStakeTotal::::insert(id, T::MinOverrideQuorum::get().max(1)); + // RV17: el override también lee (y consume) el quórum ordinario guardado. + VoteQuorumOf::::insert(id, T::VoteQuorum::get()); + OverrideStakeTotal::::insert( + id, + T::MinOverrideQuorum::get().max(T::VoteQuorum::get()).max(1), + ); frame_system::Pallet::::set_block_number(2u32.into()); #[extrinsic_call] @@ -375,6 +422,7 @@ mod benchmarks { ProposalStatus::Approved )); assert!(!OverrideDeadline::::contains_key(id)); + assert!(!VoteQuorumOf::::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 d0b95af..a766056 100644 --- a/crates/origin-chain-governance/src/lib.rs +++ b/crates/origin-chain-governance/src/lib.rs @@ -146,7 +146,8 @@ pub mod pallet { pub enum ProposalStatus { /// Voting period open canonical (block.number ∈ [start_block, end_block]). Voting, - /// Finalized canonical: yes_weight > nay_weight (passed canonical CHN-4). + /// 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). Approved, /// Finalized canonical: nay_weight >= yes_weight (rejected canonical). Rejected, @@ -328,9 +329,34 @@ pub mod pallet { /// participación del voto original (Aye + Nay): revertir al council exige al /// menos tanta comunidad como la que votó. Es un parámetro de la constitución, /// y `integrity_test` exige que supere el bond de una propuesta. - #[pallet::constant] + /// + /// RV17: deja de ser `#[pallet::constant]`. En el runtime es el 10 % de la + /// emisión VIGENTE, que se lee en el momento del veto y se guarda. type MinOverrideQuorum: Get; + /// RV17: quórum del voto ORDINARIO, en OGC (PILAR_4 §6.6: «14-day period, 10% + /// quorum»). Se lee AL CREAR la propuesta y se guarda en [`VoteQuorumOf`]: un + /// runtime upgrade que cambie el parámetro no mueve la meta de un voto abierto. + /// Es la misma regla que RV16 puso al override. En el runtime es el 10 % de la + /// emisión vigente, y por eso no es `#[pallet::constant]`. + /// + /// Una propuesta ordinaria se aprueba sólo si `yes > nay` Y `yes >=` su quórum. + /// Un override del veto tiene que cumplir lo mismo con su propio apoyo (alcanzar + /// el quórum y superar el Nay original). Si no, vetar ABARATARÍA la aprobación: + /// 4/5 de un quórum de override igual al ordinario es menos que el ordinario, y + /// el Nay original no votaba en la ventana (revisiones adversariales de RV17). + /// + /// Cuenta sólo el Aye: + /// - si contara el Nay, un voto en contra podría APROBAR (60 Aye no llegan al + /// quórum de 100; 40 Nay más lo cruzan con 60 > 40); + /// - si contara la abstención, el mismo OGC abstenido en varias propuestas a la + /// vez cruzaría el quórum de todas sin tomar partido (la razón de RV16 para + /// `OverrideQuorum`). + /// + /// La emergencia (48 h, 5/7 del council) no lo exige: la doctrina lo fija para + /// el voto de 14 días. `integrity_test` exige que supere el bond de una propuesta. + type VoteQuorum: Get; + /// FIX-6: PalletId del que se deriva la cuenta-tesorería (vault). Cuenta /// determinística sin private key — nadie la controla por wallet, solo la /// governance vía treasury_disbursement (Root). Honor Inmutable #16: la @@ -362,6 +388,14 @@ pub mod pallet { T::MinOverrideQuorum::get() > MIN_PROPOSAL_STAKE, "MinOverrideQuorum debe superar MIN_PROPOSAL_STAKE (hallazgo C3)" ); + // RV17: con un quórum menor que el bond, el proponente aprobaría solo su + // propuesta con un voto del tamaño de su fianza. Ojo: este test corre sin + // estado, así que con el quórum del runtime (10 % de la emisión) sólo ve el + // piso. El porcentaje lo pinna `quorums_del_runtime`. + assert!( + T::VoteQuorum::get() > MIN_PROPOSAL_STAKE, + "VoteQuorum debe superar MIN_PROPOSAL_STAKE (RV17)" + ); } } @@ -421,6 +455,29 @@ pub mod pallet { pub type OverrideQuorum = StorageMap<_, Blake2_128Concat, ProposalId, u128, OptionQuery>; + /// RV17: el quórum de Aye de cada propuesta ORDINARIA, fijado al crearla + /// (`Config::VoteQuorum` en ese momento). Se consume al decidirla. + /// + /// Una propuesta sin entrada no exige quórum. Son dos casos, y en los dos es lo + /// correcto: + /// - la emergencia (48 h, 5/7 del council), que la doctrina exime y que nunca lleva + /// acción de tesoro; + /// - una propuesta creada antes de RV17, que se abrió y se votó sin quórum. + /// + /// Es la marca explícita del tipo. Antes se deducía del periodo. + #[pallet::storage] + pub type VoteQuorumOf = + StorageMap<_, Blake2_128Concat, ProposalId, u128, OptionQuery>; + + /// RV17: lo que paga una propuesta de tesoro, fijado AL CREARLA + /// (`submit_treasury_proposal`): destinatario y monto. `treasury_disbursement` + /// sólo paga si coinciden exactamente, y una propuesta sin acción no paga nada. + /// Antes el monto y el destinatario los ponía quien llamaba (GSUN-3, condición 5 + /// de Palace). Nadie lo modifica después: quien vota sabe qué aprueba. + #[pallet::storage] + pub type TreasuryActions = + StorageMap<_, Blake2_128Concat, ProposalId, (T::AccountId, u128), OptionQuery>; + /// CHN-4 canonical: `Proposals>` canonical. #[pallet::storage] #[pallet::getter(fn proposal_of)] @@ -743,6 +800,26 @@ pub mod pallet { /// Quórum que se exigía. quorum: u128, }, + /// RV17: la propuesta tuvo más Aye que Nay pero no llegó al quórum del voto + /// ordinario, y se rechaza. Va además del `ProposalFinalized` con `Rejected`, + /// para que se lea POR QUÉ. + ProposalRejectedForQuorum { + /// Propuesta rechazada. + id: ProposalId, + /// OGC que votó Aye (lo único que cuenta para el quórum). + yes_weight: u128, + /// Quórum que se exigía. + quorum: u128, + }, + /// RV17: se creó una propuesta de tesoro con su acción fijada. + TreasuryProposalSubmitted { + /// Propuesta creada. + id: ProposalId, + /// Quien cobrará si se aprueba. + recipient: T::AccountId, + /// Cuánto, en unidades base. + amount: u128, + }, } /// Pallet errors canonical. @@ -827,6 +904,17 @@ pub mod pallet { OverrideNotPending, /// RV16: un voto de la ventana de override tiene que pesar más de 0. ZeroWeightWindowVote, + /// RV17: el desembolso no coincide con la acción fijada al crear la propuesta, + /// o la propuesta no tiene acción de tesoro. + TreasuryActionMismatch, + /// RV17: una propuesta de tesoro tiene que pagar al menos el depósito + /// existencial. Con menos, el pago a una cuenta que no existe fallaría siempre y + /// la propuesta quedaría aprobada e impagable. + TreasuryAmountBelowMinimum, + /// RV17: el destinatario no puede ser la propia tesorería ni la cuenta-bond. Una + /// transferencia a sí misma devuelve Ok sin mover nada (se marcaría pagada sin + /// pago), y lo que llega a la cuenta-bond queda atrapado. + TreasuryRecipientInvalid, } /// Minimum stake canonical proposal submission per CHN-4 5.3. @@ -856,60 +944,38 @@ pub mod pallet { stake: u128, ) -> DispatchResult { let who = ensure_signed(origin)?; - ensure!(stake >= MIN_PROPOSAL_STAKE, Error::::StakeInsufficient); + Self::do_submit_proposal(who, title_hash, body_hash, voting_period_blocks, stake)?; + Ok(()) + } + + /// RV17: crea una propuesta de TESORO. Es una propuesta ordinaria (mismo bond, + /// 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. + #[pallet::call_index(16)] + #[pallet::weight(T::WeightInfo::submit_treasury_proposal())] + pub fn submit_treasury_proposal( + origin: OriginFor, + title_hash: [u8; 32], + body_hash: [u8; 32], + voting_period_blocks: u64, + stake: u128, + recipient: T::AccountId, + amount: u128, + ) -> DispatchResult { + let who = ensure_signed(origin)?; ensure!( - voting_period_blocks <= MAX_VOTING_PERIOD_BLOCKS, - Error::::VotingPeriodTooLong + amount >= T::Currency::minimum_balance().max(1), + Error::::TreasuryAmountBelowMinimum ); ensure!( - voting_period_blocks >= MIN_VOTING_PERIOD_BLOCKS, - Error::::VotingPeriodTooShort + recipient != Self::treasury_account() && recipient != Self::proposal_bond_account(), + Error::::TreasuryRecipientInvalid ); - - let proposer_acct = AccountId::new(account_bytes::(&who)); - let id = crate::derive_proposal_id(&proposer_acct, &title_hash, &body_hash); - ensure!(!Proposals::::contains_key(id), Error::::ProposalIdCollision); - - // Audit 2026-07-04: RESERVA REAL del stake (piel en el juego anti-spam). - // Antes el `stake` era caller-supplied SIN respaldo — una cuenta vacía - // spameaba propuestas gratis y el `baseline` del override era - // manipulable. Ahora se retiene de verdad en la cuenta-bond og/props - // (falla si el proponente no tiene los fondos) y se devuelve al decidir. - T::Currency::transfer( - &who, - &Self::proposal_bond_account(), - stake, - Preservation::Preserve, - ) - .map_err(|_| Error::::StakeInsufficientBalance)?; - ProposalBond::::insert(id, (who.clone(), stake)); - - let start_block: u64 = >::block_number() - .saturated_into::(); - let end_block = start_block.saturating_add(voting_period_blocks); - - let proposal = Proposal { - id, - proposer: proposer_acct, - title_hash, - body_hash, - stake, - start_block, - end_block, - status: ProposalStatus::Voting, - yes_weight: 0, - nay_weight: 0, - abstain_weight: 0, - }; - - Proposals::::insert(id, proposal); - - Self::deposit_event(Event::ProposalSubmitted { - id, - proposer: who, - voting_period: voting_period_blocks, - }); - + let id = + Self::do_submit_proposal(who, title_hash, body_hash, voting_period_blocks, stake)?; + TreasuryActions::::insert(id, (recipient.clone(), amount)); + Self::deposit_event(Event::TreasuryProposalSubmitted { id, recipient, amount }); Ok(()) } @@ -1011,7 +1077,13 @@ pub mod pallet { Error::::VotingPeriodOpen ); - let final_status = if proposal.yes_weight > proposal.nay_weight { + // RV17: además de la mayoría, el voto ordinario exige el quórum de Aye que se + // 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); + let mayoria = proposal.yes_weight > proposal.nay_weight; + let sin_quorum = mayoria && proposal.yes_weight < quorum; + let final_status = if mayoria && !sin_quorum { ProposalStatus::Approved } else { ProposalStatus::Rejected @@ -1033,6 +1105,13 @@ pub mod pallet { yes_weight, nay_weight, }); + if sin_quorum { + Self::deposit_event(Event::ProposalRejectedForQuorum { + id: proposal_id, + yes_weight, + quorum, + }); + } Ok(()) } @@ -1413,6 +1492,12 @@ pub mod pallet { !Disbursed::::contains_key(proposal_id), Error::::AlreadyDisbursed ); + // RV17: sólo se paga lo que la propuesta fijó al crearse, a quien fijó. + // Una propuesta sin acción de tesoro no paga nada. + ensure!( + TreasuryActions::::get(proposal_id) == Some((recipient.clone(), amount)), + Error::::TreasuryActionMismatch + ); // Marcar ANTES del transfer (defensa en profundidad; los pallets no // reentran, pero el marker refleja la intención de gasto atómica). Disbursed::::insert(proposal_id, ()); @@ -1540,10 +1625,15 @@ pub mod pallet { /// cualquiera. /// /// El veto se revierte (la propuesta pasa a `Approved`) solo si: - /// 1. la participación (revertir + sostener) alcanza el quórum, que es el - /// MAYOR entre `MinOverrideQuorum` y la participación del voto original - /// (Aye + Nay + Abstain); y - /// 2. revertir reúne al menos 4/5 de esa participación. + /// 1. revertir reúne al menos 4/5 del MAYOR entre el quórum del override (fijado + /// en el veto: `MinOverrideQuorum` o la participación del voto original, Aye + + /// Nay) y la participación de la ventana (revertir + sostener); y + /// 2. RV17: revertir alcanza además el quórum ordinario de la propuesta + /// (`VoteQuorumOf`) y SUPERA el Nay del voto original. Son las dos condiciones + /// del voto ordinario (`yes >= quórum` y `yes > nay`): vetar nunca abarata una + /// 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). /// /// En cualquier otro caso el veto se sostiene. Se decide una sola vez. #[pallet::call_index(15)] @@ -1572,10 +1662,12 @@ pub mod pallet { let participation = override_stake.saturating_add(uphold_stake); let quorum = OverrideQuorum::::get(proposal_id).ok_or(Error::::OverrideNotPending)?; + let quorum_ordinario = VoteQuorumOf::::get(proposal_id).unwrap_or(0); // Se decide una sola vez: sin ventana, no hay override pendiente. OverrideDeadline::::remove(proposal_id); OverrideQuorum::::remove(proposal_id); + VoteQuorumOf::::remove(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 @@ -1585,7 +1677,9 @@ pub mod pallet { // de defender el veto. Con max(quorum, participación), cada OGC que sostiene // solo puede subir el listón de revertir, nunca bajarlo. let overridden = override_stake > 0 - && override_stake >= override_supermajority_threshold(quorum.max(participation)); + && override_stake >= override_supermajority_threshold(quorum.max(participation)) + && override_stake >= quorum_ordinario + && override_stake > proposal.nay_weight; if overridden { proposal.status = ProposalStatus::Approved; Proposals::::insert(proposal_id, proposal); @@ -1771,6 +1865,75 @@ pub mod pallet { PROPOSAL_BOND_PALLET_ID.into_account_truncating() } + /// Crea una propuesta ordinaria (bond retenido, periodo dentro de + /// [MIN, MAX]) y devuelve su id. La comparten `submit_proposal` y, desde RV17, + /// `submit_treasury_proposal`. + fn do_submit_proposal( + who: T::AccountId, + title_hash: [u8; 32], + body_hash: [u8; 32], + voting_period_blocks: u64, + stake: u128, + ) -> Result { + ensure!(stake >= MIN_PROPOSAL_STAKE, Error::::StakeInsufficient); + ensure!( + voting_period_blocks <= MAX_VOTING_PERIOD_BLOCKS, + Error::::VotingPeriodTooLong + ); + ensure!( + voting_period_blocks >= MIN_VOTING_PERIOD_BLOCKS, + Error::::VotingPeriodTooShort + ); + + let proposer_acct = AccountId::new(account_bytes::(&who)); + let id = crate::derive_proposal_id(&proposer_acct, &title_hash, &body_hash); + ensure!(!Proposals::::contains_key(id), Error::::ProposalIdCollision); + + // Audit 2026-07-04: RESERVA REAL del stake (piel en el juego anti-spam). + // Antes el `stake` era caller-supplied SIN respaldo — una cuenta vacía + // spameaba propuestas gratis y el `baseline` del override era + // manipulable. Ahora se retiene de verdad en la cuenta-bond og/props + // (falla si el proponente no tiene los fondos) y se devuelve al decidir. + T::Currency::transfer( + &who, + &Self::proposal_bond_account(), + stake, + Preservation::Preserve, + ) + .map_err(|_| Error::::StakeInsufficientBalance)?; + ProposalBond::::insert(id, (who.clone(), stake)); + + let start_block: u64 = >::block_number() + .saturated_into::(); + let end_block = start_block.saturating_add(voting_period_blocks); + + let proposal = Proposal { + id, + proposer: proposer_acct, + title_hash, + body_hash, + stake, + start_block, + end_block, + status: ProposalStatus::Voting, + yes_weight: 0, + nay_weight: 0, + abstain_weight: 0, + }; + + Proposals::::insert(id, proposal); + // RV17: el quórum se fija ahora, no al decidir (ver `VoteQuorumOf`). + VoteQuorumOf::::insert(id, T::VoteQuorum::get()); + + Self::deposit_event(Event::ProposalSubmitted { + id, + proposer: who, + voting_period: voting_period_blocks, + }); + + Ok(id) + } + /// Devuelve el bond retenido al proponente cuando la propuesta se decide /// (finalize o veto). Idempotente: `take` consume la entrada, así que un /// segundo intento es no-op (no doble-refund). Expendable: la cuenta-bond diff --git a/crates/origin-chain-governance/src/mock.rs b/crates/origin-chain-governance/src/mock.rs index 785388b..4b7dc5e 100644 --- a/crates/origin-chain-governance/src/mock.rs +++ b/crates/origin-chain-governance/src/mock.rs @@ -40,6 +40,11 @@ frame_support::parameter_types! { /// FIX-6: PalletId de la cuenta-tesorería del mock. pub const TestTreasuryPalletId: frame_support::PalletId = frame_support::PalletId(*b"og/gvtry"); + /// RV17: quórum del voto ordinario del mock. Por defecto, un bond más una unidad: + /// el mínimo que acepta `integrity_test`, para que las pruebas que no van de + /// quórum aprueben con un voto pequeño. Las que sí van de quórum lo suben con + /// `VoteQuorum::set`. + pub static VoteQuorum: u128 = crate::pallet::MIN_PROPOSAL_STAKE + 1; } impl origin_chain_governance::Config for Test { @@ -53,6 +58,7 @@ impl origin_chain_governance::Config for Test { // igual al bond, el proponente revertía solo con el bond devuelto (revisión de RV16). type MinOverrideQuorum = frame_support::traits::ConstU128<{ 2 * crate::pallet::MIN_PROPOSAL_STAKE }>; + type VoteQuorum = VoteQuorum; type TreasuryPalletId = TestTreasuryPalletId; type WeightInfo = (); } diff --git a/crates/origin-chain-governance/src/tests_integration.rs b/crates/origin-chain-governance/src/tests_integration.rs index 5c1a941..9dc5921 100644 --- a/crates/origin-chain-governance/src/tests_integration.rs +++ b/crates/origin-chain-governance/src/tests_integration.rs @@ -49,6 +49,32 @@ fn submit_default(origin: frame_system::pallet_prelude::OriginFor) -> Prop id } +/// RV17: propuesta de TESORO de Alice, con su acción fijada (quién cobra y cuánto). +fn submit_tesoro(recipient: ::AccountId, amount: u128) -> ProposalId { + let proposer = AccountId::new(alice_account_bytes()); + let id = derive_proposal_id(&proposer, &TITLE_HASH, &BODY_HASH); + assert_ok!(Governance::submit_treasury_proposal( + alice_origin(), + TITLE_HASH, + BODY_HASH, + VOTING_PERIOD, + MIN_PROPOSAL_STAKE, + recipient, + amount, + )); + id +} + +/// RV17: Bob vota Aye con el peso EXACTO del quórum, se cierra el periodo y se +/// finaliza. Deja la propuesta Approved con lo mínimo que la regla admite. +fn aprobar(id: ProposalId) { + let quorum = crate::mock::VoteQuorum::get(); + assert_ok!(Governance::cast_vote(bob_origin(), id, VoteStance::Aye, quorum)); + run_to_block(TRAS_EL_PERIODO); + assert_ok!(Governance::finalize_proposal(alice_origin(), id)); + assert!(matches!(Proposals::::get(id).unwrap().status, ProposalStatus::Approved)); +} + fn alice_account_bytes() -> [u8; 32] { use parity_scale_codec::Encode; let encoded = alice_account().encode(); @@ -186,6 +212,10 @@ fn scenario_6_finalize_voting_open_rejected_canonical() { #[test] fn scenario_7_finalize_approved_yes_majority_canonical() { new_test_ext().execute_with(|| { + // RV17: prueba de MAYORÍA. El quórum se fija igual al Aye, la frontera exacta + // (`yes >= quórum`), para que lo que se mire aquí sea yes > nay. Va antes de + // crear la propuesta: el quórum se guarda al crearla. + crate::mock::VoteQuorum::set(1000); let id = submit_default(alice_origin()); // Vote Aye 1000 + Nay 500 → Aye majority @@ -892,11 +922,9 @@ fn treasury_scenario_3_insufficient_balance_rejected_canonical() { // Treasury 100 OGC only assert_ok!(Governance::treasury_deposit(alice_origin(),100)); - // Setup approved proposal - let id = submit_default(alice_origin()); - assert_ok!(Governance::cast_vote(bob_origin(), id, VoteStance::Aye, 1000)); - run_to_block(TRAS_EL_PERIODO); - assert_ok!(Governance::finalize_proposal(alice_origin(), id)); + // RV17: propuesta de tesoro que fija 200 para Bob, aprobada. + let id = submit_tesoro(bob_account(), 200); + aprobar(id); // Try disburse 200 (exceeds 100 balance) assert_noop!( @@ -919,11 +947,9 @@ fn treasury_scenario_4_successful_disbursement_canonical() { // Treasury 1M OGC assert_ok!(Governance::treasury_deposit(alice_origin(),1_000_000)); - // Setup approved proposal - let id = submit_default(alice_origin()); - assert_ok!(Governance::cast_vote(bob_origin(), id, VoteStance::Aye, 1000)); - run_to_block(TRAS_EL_PERIODO); - assert_ok!(Governance::finalize_proposal(alice_origin(), id)); + // RV17: propuesta de tesoro que fija 300_000 para Bob, aprobada. + let id = submit_tesoro(bob_account(), 300_000); + aprobar(id); // Disburse 300_000 canonical assert_ok!(Governance::treasury_disbursement( @@ -989,10 +1015,8 @@ fn treasury_disbursement_es_single_use_anti_replay() { new_test_ext().execute_with(|| { use frame_support::dispatch::RawOrigin; assert_ok!(Governance::treasury_deposit(alice_origin(), 1_000_000)); - let id = submit_default(alice_origin()); - assert_ok!(Governance::cast_vote(bob_origin(), id, VoteStance::Aye, 1000)); - run_to_block(TRAS_EL_PERIODO); - assert_ok!(Governance::finalize_proposal(alice_origin(), id)); + let id = submit_tesoro(bob_account(), 300_000); + aprobar(id); // Primer desembolso: OK. assert_ok!(Governance::treasury_disbursement( @@ -1138,10 +1162,12 @@ fn council_scenario_7_veto_threshold_2_of_3_canonical() { #[test] fn scenario_10_lifecycle_walkthrough_canonical() { new_test_ext().execute_with(|| { + // RV17: el quórum se fija igual al Aye total (1500 + 2000), la frontera exacta, + // antes de crear la propuesta (se guarda al crearla). + crate::mock::VoteQuorum::set(3500); // Step 1: submit canonical let id = submit_default(alice_origin()); assert!(Proposals::::get(id).is_some()); - // Step 2: vote canonical (3 voters: bob Aye, charlie Aye, alice Nay = 2 Aye vs 1 Nay) assert_ok!(Governance::cast_vote(bob_origin(), id, VoteStance::Aye, 1500)); assert_ok!(Governance::cast_vote(charlie_origin(), id, VoteStance::Aye, 2000)); @@ -1915,11 +1941,15 @@ fn finalize_override_usa_el_quorum_fijado_en_el_veto() { MIN_PROPOSAL_STAKE )); // Simula un quórum guardado más bajo: si finalize recalculara, no lo vería. + // RV17: el quórum ordinario de la propuesta también está guardado, y el override + // tiene que alcanzarlo; se baja igual. crate::OverrideQuorum::::insert(id, MIN_PROPOSAL_STAKE); + crate::VoteQuorumOf::::insert(id, MIN_PROPOSAL_STAKE); run_to_block(cierre + 1); assert_ok!(Governance::finalize_override(alice_origin(), id)); assert!(matches!(estado(id), ProposalStatus::Approved)); assert!(crate::OverrideQuorum::::get(id).is_none()); + assert!(crate::VoteQuorumOf::::get(id).is_none()); }); } @@ -2192,3 +2222,374 @@ fn una_propuesta_ordinaria_dura_al_menos_14_dias() { )); }); } +// ─── RV17: quórum del voto ordinario y tesoro atado a la propuesta ───────────── + +/// Abre una propuesta ordinaria con el quórum del mock fijado a `quorum`, vota +/// (Bob Aye, Charlie Nay, Alice Abstain) y la finaliza. Devuelve su estado. +fn votar_y_finalizar(quorum: u128, aye: u128, nay: u128, abstain: u128) -> ProposalStatus { + crate::mock::VoteQuorum::set(quorum); + let id = submit_default(alice_origin()); + 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)); + Proposals::::get(id).unwrap().status +} + +#[test] +fn una_mayoria_sin_quorum_se_rechaza_y_dice_por_que() { + // Frontera exacta: con quórum Q, Q-1 de Aye no basta y Q sí. + let q = 10_000u128; + new_test_ext().execute_with(|| { + assert!(matches!(votar_y_finalizar(q, q - 1, 0, 0), ProposalStatus::Rejected)); + let id = + derive_proposal_id(&AccountId::new(alice_account_bytes()), &TITLE_HASH, &BODY_HASH); + System::assert_has_event(RuntimeEvent::Governance(Event::ProposalRejectedForQuorum { + id, + yes_weight: q - 1, + quorum: q, + })); + }); + new_test_ext().execute_with(|| { + assert!(matches!(votar_y_finalizar(q, q, 0, 0), ProposalStatus::Approved)); + assert!(!System::events().iter().any(|r| matches!( + r.event, + RuntimeEvent::Governance(Event::ProposalRejectedForQuorum { .. }) + ))); + }); +} + +#[test] +fn un_nay_nunca_ayuda_a_aprobar_y_la_abstencion_no_cuenta() { + // La paradoja del quórum de participación: 60 Aye no llegan a 100; si el Nay + // contara, 40 Nay más cruzarían el quórum con 60 > 40 y el voto EN CONTRA + // aprobaría. Con quórum de Aye, ni el Nay ni la abstención mueven el quórum. + let q = 100_000u128; + for (aye, nay, abstain) in + [(60_000, 0, 0), (60_000, 40_000, 0), (60_000, 0, 40_000), (60_000, 39_999, 40_001)] + { + new_test_ext().execute_with(|| { + assert!( + matches!(votar_y_finalizar(q, aye, nay, abstain), ProposalStatus::Rejected), + "Aye {aye} < quórum {q}: ni Nay {nay} ni Abstain {abstain} pueden aprobarla" + ); + }); + } + // Monotonía en una rejilla: añadir Nay nunca convierte Rejected en Approved, y + // añadir Aye nunca convierte Approved en Rejected. + let pasos = [0u128, 50_000, 100_000, 150_000]; + for &aye in &pasos { + for &nay in &pasos { + let base = new_test_ext().execute_with(|| votar_y_finalizar(q, aye, nay, 0)); + let mas_nay = + new_test_ext().execute_with(|| votar_y_finalizar(q, aye, nay + 50_000, 0)); + let mas_aye = + new_test_ext().execute_with(|| votar_y_finalizar(q, aye + 50_000, nay, 0)); + if matches!(base, ProposalStatus::Rejected) { + assert!( + matches!(mas_nay, ProposalStatus::Rejected), + "Nay de más aprobó (aye {aye}, nay {nay})" + ); + } + if matches!(base, ProposalStatus::Approved) { + assert!( + matches!(mas_aye, ProposalStatus::Approved), + "Aye de más rechazó (aye {aye}, nay {nay})" + ); + } + } + } +} + +#[test] +fn la_emergencia_no_exige_quorum_y_nunca_paga() { + // La doctrina fija el quórum para el voto de 14 días; la emergencia (48 h) va + // por el 5/7 del council. Con un Aye minúsculo y el quórum altísimo, se aprueba. + // Y no puede pagar: no lleva acción de tesoro. Con un council de un solo miembro, + // el 5/7 es una persona; esta invariante es la que hace inocua la emergencia. + new_test_ext().execute_with(|| { + use crate::pallet::{CouncilRole, EMERGENCY_VOTING_PERIOD_BLOCKS}; + use frame_support::dispatch::RawOrigin; + crate::mock::VoteQuorum::set(MIN_PROPOSAL_STAKE * 9); + assert_ok!(Governance::add_council_member( + RawOrigin::Root.into(), + alice_account(), + CouncilRole::Member + )); + for i in 1..7u8 { + assert_ok!(Governance::add_council_member( + RawOrigin::Root.into(), + sp_runtime::AccountId32::new([i + 50; 32]), + CouncilRole::Member, + )); + } + let miembro = |i: u8| RawOrigin::Signed(sp_runtime::AccountId32::new([i + 50; 32])).into(); + assert_ok!(Governance::endorse_emergency_proposal(alice_origin(), [10u8; 32], [20u8; 32])); + for i in 1..5u8 { + assert_ok!(Governance::endorse_emergency_proposal(miembro(i), [10u8; 32], [20u8; 32])); + } + assert_ok!(Governance::submit_emergency_proposal( + alice_origin(), + [10u8; 32], + [20u8; 32], + MIN_PROPOSAL_STAKE + )); + let id = + derive_proposal_id(&AccountId::new(alice_account_bytes()), &[10u8; 32], &[20u8; 32]); + assert!(crate::VoteQuorumOf::::get(id).is_none(), "la emergencia no guarda quórum"); + assert_ok!(Governance::cast_vote(bob_origin(), id, VoteStance::Aye, 10)); + run_to_block(1 + EMERGENCY_VOTING_PERIOD_BLOCKS + 10); + assert_ok!(Governance::finalize_proposal(alice_origin(), id)); + assert!(matches!(Proposals::::get(id).unwrap().status, ProposalStatus::Approved)); + + assert!(crate::TreasuryActions::::get(id).is_none()); + assert_ok!(Governance::treasury_deposit(alice_origin(), 1_000_000)); + assert_noop!( + Governance::treasury_disbursement(RawOrigin::Root.into(), id, bob_account(), 1), + Error::::TreasuryActionMismatch + ); + }); +} + +#[test] +#[should_panic(expected = "VoteQuorum debe superar MIN_PROPOSAL_STAKE")] +fn un_quorum_del_tamano_del_bond_no_pasa_la_integridad() { + use frame_support::traits::Hooks; + crate::mock::VoteQuorum::set(MIN_PROPOSAL_STAKE); + >::integrity_test(); +} + +#[test] +fn el_tesoro_solo_paga_lo_que_fijo_la_propuesta() { + 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); + System::assert_has_event(RuntimeEvent::Governance(Event::TreasuryProposalSubmitted { + id, + recipient: bob_account(), + amount: 300_000, + })); + assert_eq!(crate::TreasuryActions::::get(id), Some((bob_account(), 300_000))); + aprobar(id); + for (quien, cuanto) in + [(charlie_account(), 300_000), (bob_account(), 300_001), (bob_account(), 299_999)] + { + assert_noop!( + Governance::treasury_disbursement(RawOrigin::Root.into(), id, quien, cuanto), + Error::::TreasuryActionMismatch + ); + } + assert_ok!(Governance::treasury_disbursement( + RawOrigin::Root.into(), + id, + bob_account(), + 300_000 + )); + assert_eq!(Governance::treasury_balance(), 700_000); + }); +} + +#[test] +fn una_propuesta_sin_accion_de_tesoro_no_paga_nada() { + // Antes de RV17, cualquier propuesta Approved se podía desembolsar con el monto y + // el destinatario que pusiera quien llamaba. + new_test_ext().execute_with(|| { + use frame_support::dispatch::RawOrigin; + assert_ok!(Governance::treasury_deposit(alice_origin(), 1_000_000)); + let id = submit_default(alice_origin()); + aprobar(id); + assert_noop!( + Governance::treasury_disbursement(RawOrigin::Root.into(), id, bob_account(), 1), + Error::::TreasuryActionMismatch + ); + }); +} + +#[test] +fn una_propuesta_de_tesoro_no_paga_menos_del_minimo_ni_a_si_misma_y_exige_quorum() { + new_test_ext().execute_with(|| { + assert_noop!( + Governance::submit_treasury_proposal( + alice_origin(), + TITLE_HASH, + BODY_HASH, + VOTING_PERIOD, + MIN_PROPOSAL_STAKE, + bob_account(), + 0, + ), + Error::::TreasuryAmountBelowMinimum + ); + // Ni la propia tesorería ni la cuenta-bond pueden ser el destinatario. + for destino in [Governance::treasury_account(), Governance::proposal_bond_account()] { + assert_noop!( + Governance::submit_treasury_proposal( + alice_origin(), + TITLE_HASH, + BODY_HASH, + VOTING_PERIOD, + MIN_PROPOSAL_STAKE, + destino, + 500, + ), + Error::::TreasuryRecipientInvalid + ); + } + // Una propuesta de tesoro es una ordinaria: sin quórum se rechaza y no paga. + crate::mock::VoteQuorum::set(MIN_PROPOSAL_STAKE * 2); + let id = submit_tesoro(bob_account(), 500); + assert_ok!(Governance::cast_vote(bob_origin(), id, VoteStance::Aye, MIN_PROPOSAL_STAKE)); + run_to_block(TRAS_EL_PERIODO); + assert_ok!(Governance::finalize_proposal(alice_origin(), id)); + assert!(matches!(Proposals::::get(id).unwrap().status, ProposalStatus::Rejected)); + }); +} + +#[test] +fn el_quorum_se_fija_al_crear_la_propuesta() { + // Revisión adversarial de RV17: leído al decidir, un `set_code` que cambiara el + // parámetro movía la meta de los votos ya abiertos. Ahora se guarda al crear, en + // las dos direcciones. + new_test_ext().execute_with(|| { + let q = 2 * MIN_PROPOSAL_STAKE; + crate::mock::VoteQuorum::set(q); + let id = submit_default(alice_origin()); + assert_eq!(crate::VoteQuorumOf::::get(id), Some(q)); + assert_ok!(Governance::cast_vote(bob_origin(), id, VoteStance::Aye, q)); + crate::mock::VoteQuorum::set(10 * q); // «el upgrade lo sube» + run_to_block(TRAS_EL_PERIODO); + assert_ok!(Governance::finalize_proposal(alice_origin(), id)); + assert!(matches!(Proposals::::get(id).unwrap().status, ProposalStatus::Approved)); + assert!(crate::VoteQuorumOf::::get(id).is_none(), "se consume al decidir"); + }); + new_test_ext().execute_with(|| { + let q = 2 * MIN_PROPOSAL_STAKE; + crate::mock::VoteQuorum::set(q); + let id = submit_default(alice_origin()); + assert_ok!(Governance::cast_vote(bob_origin(), id, VoteStance::Aye, q - 1)); + crate::mock::VoteQuorum::set(1); // «el upgrade lo baja» + run_to_block(TRAS_EL_PERIODO); + assert_ok!(Governance::finalize_proposal(alice_origin(), id)); + assert!(matches!(Proposals::::get(id).unwrap().status, ProposalStatus::Rejected)); + }); +} + +#[test] +fn una_propuesta_anterior_a_rv17_no_exige_quorum() { + // Se abrió y se votó sin quórum: decidirla con uno sería cambiarle la regla a mitad. + new_test_ext().execute_with(|| { + crate::mock::VoteQuorum::set(5 * MIN_PROPOSAL_STAKE); + let id = submit_default(alice_origin()); + crate::VoteQuorumOf::::remove(id); // como la dejó spec 17 + assert_ok!(Governance::cast_vote(bob_origin(), id, VoteStance::Aye, 10)); + run_to_block(TRAS_EL_PERIODO); + assert_ok!(Governance::finalize_proposal(alice_origin(), id)); + assert!(matches!(Proposals::::get(id).unwrap().status, ProposalStatus::Approved)); + }); +} + +/// Abre una propuesta de tesoro con el quórum ordinario `q`, la veta el council +/// (Alice, Chair) y Charlie la revierte con `apoyo`. Devuelve el estado final y si se +/// pudo pagar. +fn vetar_y_revertir(q: u128, apoyo: u128) -> (ProposalStatus, bool) { + use crate::pallet::CouncilRole; + use frame_support::dispatch::RawOrigin; + crate::mock::VoteQuorum::set(q); + 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); + assert_ok!(Governance::veto_proposal(alice_origin(), id)); + let cierre = crate::OverrideDeadline::::get(id).unwrap(); + assert_ok!(Governance::override_council_veto(charlie_origin(), 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 vetar_nunca_abarata_la_aprobacion() { + // Revisión adversarial de RV17 (bloqueante 1): con el quórum de override igual al + // ordinario, como en el runtime, revertir pedía 4/5 de él. Una propuesta que con + // ese apoyo se rechazaba sin quórum quedaba APROBADA si el council la vetaba, y el + // tesoro pagaba. El override tiene que alcanzar también el quórum ordinario. + let q = 2 * MIN_PROPOSAL_STAKE; // = MinOverrideQuorum del mock + new_test_ext().execute_with(|| { + let (estado, pagado) = vetar_y_revertir(q, q / 5 * 4); + assert!(matches!(estado, ProposalStatus::Rejected), "4/5 del quórum no basta"); + assert!(!pagado); + }); + // Control positivo: con el quórum ordinario completo, el override revierte y paga. + new_test_ext().execute_with(|| { + let (estado, pagado) = vetar_y_revertir(q, q); + assert!(matches!(estado, ProposalStatus::Approved)); + assert!(pagado); + }); +} + +/// 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) { + use crate::pallet::CouncilRole; + use frame_support::dispatch::RawOrigin; + 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); + 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(); + assert_ok!(Governance::override_council_veto(alice_origin(), 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 vetar_no_abarata_la_aprobacion_frente_a_la_oposicion() { + // Segunda revisión adversarial de RV17. Con Aye MIN y Nay 4·MIN, el voto ordinario + // pide más de 4·MIN de Aye. Revertir pedía 4/5 de la participación original (5·MIN), + // 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. + new_test_ext().execute_with(|| { + let (estado, pagado) = vetar_con_oposicion(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); + assert!(matches!(estado, ProposalStatus::Approved)); + assert!(pagado); + }); +} diff --git a/crates/origin-chain-governance/src/weights.rs b/crates/origin-chain-governance/src/weights.rs index fe28ce5..ab7d2e3 100644 --- a/crates/origin-chain-governance/src/weights.rs +++ b/crates/origin-chain-governance/src/weights.rs @@ -21,6 +21,25 @@ // TIEMPO DE EJECUCIÓN sale de 2,5 a 3,9 veces el del Mac M3 Max (~6 veces en los que verifican // Ed25519). Lo COBRADO sube menos, entre +4 % y +56 %, porque lecturas y escrituras de storage // (RocksDbWeight) no cambian y pesan más que la ejecución (revisión adversarial, 25-oct). +// +// SPEC 18 (RV17): SEIS de estos pesos NO son de Laurha. Los midió Charon en la misma Pi el +// 2026-10-28, 21:49-21:52, porque su código cambió: `submit_proposal`, `submit_treasury_proposal`, +// `finalize_proposal`, `finalize_override`, `veto_proposal` y `treasury_disbursement`. +// Es un SMOKE DECLARADO, no un certificado: +// governor ondemand (no performance) a 2,4 GHz, núcleo 3 por taskset y SIN aislar, +// throttled 0x0, 48-64 °C. +// 5 procesos LIMPIOS: ningún job de la CI vivo en ningún segundo de cada ventana (cualquier +// proceso de gitea-runner salvo el daemon), y 30 s de calma antes de cada uno. +// frame-omni-bencher 0.15.0 · wasm sha256 539d7bf4…9311eb9 (spec 18, RV17) · steps 50 · +// repeat 20 · máximo de los 5 (max-de-n.py). +// Evidencia en la rp1: ~/charon/bench-spec18/v2 (limpios/, perfil-v3.csv, limpio.log, +// SHA256SUMS-limpio). +// Se descartó una medida anterior: su muestreador sólo contaba `rustc` y no veía los binarios de +// test de la CI. Un proceso salió 10 veces más lento y el máximo lo habría cobrado. +// Calibración: sobre los 11 que no cambian, este smoke da 0,75-1,25 veces lo certificado (el +// ruido entre procesos que midió Laurha). Esos 11 se quedan con lo de Laurha. Las lecturas, las +// escrituras y el proof de los 17 coinciden con el bench del Mac sobre el mismo wasm. Laurha +// puede certificar los 6 en su banco y sustituirlos. //! Weights AUTOGENERADOS para `origin_chain_governance` — NO editar a mano. //! @@ -102,6 +121,8 @@ pub trait WeightInfo { fn uphold_council_veto() -> Weight; /// Peso de `finalize_override` — MEDIDO sobre el WASM, no estimado a mano. fn finalize_override() -> Weight; + /// Peso de `submit_treasury_proposal` — MEDIDO sobre el WASM, no estimado a mano. + fn submit_treasury_proposal() -> Weight; } /// Weights parametrizados por el `DbWeight` real del runtime. @@ -121,17 +142,19 @@ impl WeightInfo for SubstrateWeight { /// Proof: `System::EventCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) /// Storage: `System::Events` (r:1 w:1) /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `Governance::VoteQuorumOf` (r:0 w:1) + /// Proof: `Governance::VoteQuorumOf` (`max_values`: None, `max_size`: Some(64), added: 2539, mode: `MaxEncodedLen`) /// Storage: `Governance::ProposalBond` (r:0 w:1) /// Proof: `Governance::ProposalBond` (`max_values`: None, `max_size`: Some(96), added: 2571, mode: `MaxEncodedLen`) fn submit_proposal() -> Weight { // Proof size en bytes: // Medido: `108` // Estimado: `3722` - // Tiempo minimo de ejecucion: 116_074_000 picosegundos. - Weight::from_parts(122_075_000, 0) + // Tiempo minimo de ejecucion: 120_389_000 picosegundos. + Weight::from_parts(124_315_000, 0) .saturating_add(Weight::from_parts(0, 3722)) .saturating_add(T::DbWeight::get().reads(7_u64)) - .saturating_add(T::DbWeight::get().writes(5_u64)) + .saturating_add(T::DbWeight::get().writes(6_u64)) } /// Storage: `Governance::AdvisorFlags` (r:1 w:0) /// Proof: `Governance::AdvisorFlags` (`max_values`: None, `max_size`: Some(48), added: 2523, mode: `MaxEncodedLen`) @@ -167,6 +190,8 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Governance::Proposals` (`max_values`: None, `max_size`: Some(257), added: 2732, mode: `MaxEncodedLen`) /// Storage: `System::Number` (r:1 w:0) /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Governance::VoteQuorumOf` (r:1 w:1) + /// Proof: `Governance::VoteQuorumOf` (`max_values`: None, `max_size`: Some(64), added: 2539, mode: `MaxEncodedLen`) /// Storage: `Governance::ProposalBond` (r:1 w:1) /// Proof: `Governance::ProposalBond` (`max_values`: None, `max_size`: Some(96), added: 2571, mode: `MaxEncodedLen`) /// Storage: `Balances::TotalIssuance` (r:1 w:0) @@ -181,13 +206,13 @@ impl WeightInfo for SubstrateWeight { /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) fn finalize_proposal() -> Weight { // Proof size en bytes: - // Medido: `615` + // Medido: `701` // Estimado: `6196` - // Tiempo minimo de ejecucion: 132_890_000 picosegundos. - Weight::from_parts(146_594_000, 0) + // Tiempo minimo de ejecucion: 143_834_000 picosegundos. + Weight::from_parts(147_649_000, 0) .saturating_add(Weight::from_parts(0, 6196)) - .saturating_add(T::DbWeight::get().reads(9_u64)) - .saturating_add(T::DbWeight::get().writes(6_u64)) + .saturating_add(T::DbWeight::get().reads(10_u64)) + .saturating_add(T::DbWeight::get().writes(7_u64)) } /// Storage: `Governance::CouncilSize` (r:1 w:1) /// Proof: `Governance::CouncilSize` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) @@ -243,10 +268,10 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Governance::CouncilSize` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) /// Storage: `Governance::VetoVotes` (r:6 w:1) /// Proof: `Governance::VetoVotes` (`max_values`: None, `max_size`: Some(96), added: 2571, mode: `MaxEncodedLen`) - /// Storage: `Governance::ProposalBond` (r:1 w:1) - /// Proof: `Governance::ProposalBond` (`max_values`: None, `max_size`: Some(96), added: 2571, mode: `MaxEncodedLen`) /// Storage: `Balances::TotalIssuance` (r:1 w:0) /// Proof: `Balances::TotalIssuance` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: `Governance::ProposalBond` (r:1 w:1) + /// Proof: `Governance::ProposalBond` (`max_values`: None, `max_size`: Some(96), added: 2571, mode: `MaxEncodedLen`) /// Storage: `System::Account` (r:2 w:2) /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) /// Storage: `System::ExecutionPhase` (r:1 w:0) @@ -267,8 +292,8 @@ impl WeightInfo for SubstrateWeight { // Proof size en bytes: // Medido: `1441` // Estimado: `16416` - // Tiempo minimo de ejecucion: 250_076_000 picosegundos. - Weight::from_parts(266_779_000, 0) + // Tiempo minimo de ejecucion: 250_427_000 picosegundos. + Weight::from_parts(279_612_000, 0) .saturating_add(Weight::from_parts(0, 16416)) .saturating_add(T::DbWeight::get().reads(21_u64)) .saturating_add(T::DbWeight::get().writes(11_u64)) @@ -387,6 +412,8 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Governance::Proposals` (`max_values`: None, `max_size`: Some(257), added: 2732, mode: `MaxEncodedLen`) /// Storage: `Governance::Disbursed` (r:1 w:1) /// Proof: `Governance::Disbursed` (`max_values`: None, `max_size`: Some(48), added: 2523, mode: `MaxEncodedLen`) + /// Storage: `Governance::TreasuryActions` (r:1 w:0) + /// Proof: `Governance::TreasuryActions` (`max_values`: None, `max_size`: Some(96), added: 2571, mode: `MaxEncodedLen`) /// Storage: `Balances::TotalIssuance` (r:1 w:0) /// Proof: `Balances::TotalIssuance` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) /// Storage: `System::Account` (r:2 w:2) @@ -401,12 +428,12 @@ impl WeightInfo for SubstrateWeight { /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) fn treasury_disbursement() -> Weight { // Proof size en bytes: - // Medido: `495` + // Medido: `615` // Estimado: `6196` - // Tiempo minimo de ejecucion: 126_964_000 picosegundos. - Weight::from_parts(138_038_000, 0) + // Tiempo minimo de ejecucion: 133_815_000 picosegundos. + Weight::from_parts(144_130_000, 0) .saturating_add(Weight::from_parts(0, 6196)) - .saturating_add(T::DbWeight::get().reads(9_u64)) + .saturating_add(T::DbWeight::get().reads(10_u64)) .saturating_add(T::DbWeight::get().writes(5_u64)) } /// Storage: `Balances::TotalIssuance` (r:1 w:0) @@ -533,6 +560,8 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Governance::UpholdStakeTotal` (`max_values`: None, `max_size`: Some(64), added: 2539, mode: `MaxEncodedLen`) /// Storage: `Governance::OverrideQuorum` (r:1 w:1) /// Proof: `Governance::OverrideQuorum` (`max_values`: None, `max_size`: Some(64), added: 2539, mode: `MaxEncodedLen`) + /// Storage: `Governance::VoteQuorumOf` (r:1 w:1) + /// Proof: `Governance::VoteQuorumOf` (`max_values`: None, `max_size`: Some(64), added: 2539, mode: `MaxEncodedLen`) /// Storage: `System::ExecutionPhase` (r:1 w:0) /// Proof: `System::ExecutionPhase` (`max_values`: Some(1), `max_size`: Some(5), added: 500, mode: `MaxEncodedLen`) /// Storage: `System::EventCount` (r:1 w:1) @@ -541,23 +570,53 @@ impl WeightInfo for SubstrateWeight { /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) fn finalize_override() -> Weight { // Proof size en bytes: - // Medido: `637` + // Medido: `723` // Estimado: `3722` - // Tiempo minimo de ejecucion: 73_297_000 picosegundos. - Weight::from_parts(78_037_000, 0) + // Tiempo minimo de ejecucion: 83_908_000 picosegundos. + Weight::from_parts(86_407_000, 0) .saturating_add(Weight::from_parts(0, 3722)) - .saturating_add(T::DbWeight::get().reads(10_u64)) - .saturating_add(T::DbWeight::get().writes(5_u64)) + .saturating_add(T::DbWeight::get().reads(11_u64)) + .saturating_add(T::DbWeight::get().writes(6_u64)) + } + /// Storage: `Governance::Proposals` (r:1 w:1) + /// Proof: `Governance::Proposals` (`max_values`: None, `max_size`: Some(257), added: 2732, mode: `MaxEncodedLen`) + /// Storage: `Balances::TotalIssuance` (r:1 w:0) + /// Proof: `Balances::TotalIssuance` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:1 w:1) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Storage: `System::Number` (r:1 w:0) + /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `System::ExecutionPhase` (r:1 w:0) + /// Proof: `System::ExecutionPhase` (`max_values`: Some(1), `max_size`: Some(5), added: 500, mode: `MaxEncodedLen`) + /// Storage: `System::EventCount` (r:1 w:1) + /// Proof: `System::EventCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `System::Events` (r:1 w:1) + /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `Governance::VoteQuorumOf` (r:0 w:1) + /// Proof: `Governance::VoteQuorumOf` (`max_values`: None, `max_size`: Some(64), added: 2539, mode: `MaxEncodedLen`) + /// Storage: `Governance::TreasuryActions` (r:0 w:1) + /// Proof: `Governance::TreasuryActions` (`max_values`: None, `max_size`: Some(96), added: 2571, mode: `MaxEncodedLen`) + /// Storage: `Governance::ProposalBond` (r:0 w:1) + /// Proof: `Governance::ProposalBond` (`max_values`: None, `max_size`: Some(96), added: 2571, mode: `MaxEncodedLen`) + fn submit_treasury_proposal() -> Weight { + // Proof size en bytes: + // Medido: `108` + // Estimado: `3722` + // Tiempo minimo de ejecucion: 128_000_000 picosegundos. + Weight::from_parts(132_148_000, 0) + .saturating_add(Weight::from_parts(0, 3722)) + .saturating_add(T::DbWeight::get().reads(7_u64)) + .saturating_add(T::DbWeight::get().writes(7_u64)) } } /// Impl para mocks y tests — `RocksDbWeight` directo, sin necesitar `T`. impl WeightInfo for () { fn submit_proposal() -> Weight { - Weight::from_parts(122_075_000, 0) + Weight::from_parts(124_315_000, 0) .saturating_add(Weight::from_parts(0, 3722)) .saturating_add(RocksDbWeight::get().reads(7_u64)) - .saturating_add(RocksDbWeight::get().writes(5_u64)) + .saturating_add(RocksDbWeight::get().writes(6_u64)) } fn cast_vote() -> Weight { Weight::from_parts(127_723_000, 0) @@ -566,10 +625,10 @@ impl WeightInfo for () { .saturating_add(RocksDbWeight::get().writes(6_u64)) } fn finalize_proposal() -> Weight { - Weight::from_parts(146_594_000, 0) + Weight::from_parts(147_649_000, 0) .saturating_add(Weight::from_parts(0, 6196)) - .saturating_add(RocksDbWeight::get().reads(9_u64)) - .saturating_add(RocksDbWeight::get().writes(6_u64)) + .saturating_add(RocksDbWeight::get().reads(10_u64)) + .saturating_add(RocksDbWeight::get().writes(7_u64)) } fn add_council_member() -> Weight { Weight::from_parts(39_278_000, 0) @@ -584,7 +643,7 @@ impl WeightInfo for () { .saturating_add(RocksDbWeight::get().writes(4_u64)) } fn veto_proposal() -> Weight { - Weight::from_parts(266_779_000, 0) + Weight::from_parts(279_612_000, 0) .saturating_add(Weight::from_parts(0, 16416)) .saturating_add(RocksDbWeight::get().reads(21_u64)) .saturating_add(RocksDbWeight::get().writes(11_u64)) @@ -614,9 +673,9 @@ impl WeightInfo for () { .saturating_add(RocksDbWeight::get().writes(6_u64)) } fn treasury_disbursement() -> Weight { - Weight::from_parts(138_038_000, 0) + Weight::from_parts(144_130_000, 0) .saturating_add(Weight::from_parts(0, 6196)) - .saturating_add(RocksDbWeight::get().reads(9_u64)) + .saturating_add(RocksDbWeight::get().reads(10_u64)) .saturating_add(RocksDbWeight::get().writes(5_u64)) } fn treasury_deposit() -> Weight { @@ -644,10 +703,16 @@ impl WeightInfo for () { .saturating_add(RocksDbWeight::get().writes(6_u64)) } fn finalize_override() -> Weight { - Weight::from_parts(78_037_000, 0) + Weight::from_parts(86_407_000, 0) .saturating_add(Weight::from_parts(0, 3722)) - .saturating_add(RocksDbWeight::get().reads(10_u64)) - .saturating_add(RocksDbWeight::get().writes(5_u64)) + .saturating_add(RocksDbWeight::get().reads(11_u64)) + .saturating_add(RocksDbWeight::get().writes(6_u64)) + } + fn submit_treasury_proposal() -> Weight { + Weight::from_parts(132_148_000, 0) + .saturating_add(Weight::from_parts(0, 3722)) + .saturating_add(RocksDbWeight::get().reads(7_u64)) + .saturating_add(RocksDbWeight::get().writes(7_u64)) } } @@ -692,6 +757,7 @@ mod guard { ("unlock_vote_funds", <() as WeightInfo>::unlock_vote_funds()), ("uphold_council_veto", <() as WeightInfo>::uphold_council_veto()), ("finalize_override", <() as WeightInfo>::finalize_override()), + ("submit_treasury_proposal", <() as WeightInfo>::submit_treasury_proposal()), ]; for (nombre, w) in casos { assert!( diff --git a/crates/origin-chain-node/src/main.rs b/crates/origin-chain-node/src/main.rs index eeb3312..e1e87fb 100644 --- a/crates/origin-chain-node/src/main.rs +++ b/crates/origin-chain-node/src/main.rs @@ -54,7 +54,9 @@ mod service; // las Command Line Tools declara la arquitectura `arm64e.x1`, que ld no reconoce. use clap::{Parser, Subcommand}; -use origin_chain_runtime::{genesis_config_presets, Block, IMPL_NAME, RUNTIME_VERSION, SPEC_NAME}; +use origin_chain_runtime::{ + genesis_config_presets, opaque::Block, IMPL_NAME, RUNTIME_VERSION, SPEC_NAME, +}; use sc_cli::SubstrateCli; use sp_genesis_builder::PresetId; diff --git a/crates/origin-chain-node/src/rpc.rs b/crates/origin-chain-node/src/rpc.rs index 756dad7..df66a34 100644 --- a/crates/origin-chain-node/src/rpc.rs +++ b/crates/origin-chain-node/src/rpc.rs @@ -12,16 +12,18 @@ use crate::service::FullClient; // El client es CONCRETO (`FullClient`), no un `C` genérico como en el template. // -// El template puede ser genérico porque su node trabaja sobre `opaque::Block`, que no -// menciona la cuenta. El nuestro trabaja sobre el `Block` completo del runtime (sobre -// él están los `impl_runtime_apis!`), y ese tipo lleva dentro `ChainAccountId`, que es -// una PROYECCIÓN: `<::Signer as IdentifyAccount>::AccountId`. -// Dentro de un where-clause genérico rustc no la normaliza a `AccountId32`: el bound -// `C: ProvideRuntimeApi` queda escrito con una forma del tipo y `System` lo -// pide con la otra, y el compilador informa de que un bound declarado en la propia -// firma «no se satisface» (6 errores, medido el 2026-09-23). Con el tipo concreto no -// hay nada que normalizar en el param-env y los bounds se comprueban sobre el client -// real — que es además el único que este node construye. +// Hasta el 2026-10-28 el node trabajaba sobre el `Block` completo del runtime, y ese tipo +// lleva dentro `ChainAccountId`, que es una PROYECCIÓN: +// `<::Signer as IdentifyAccount>::AccountId`. Dentro de un +// where-clause genérico rustc no la normalizaba a `AccountId32`: el bound +// `C: ProvideRuntimeApi` quedaba escrito con una forma del tipo y `System` lo pedía +// con la otra, y el compilador informaba de que un bound declarado en la propia firma «no +// se satisface» (6 errores, medido el 2026-09-23). Con el tipo concreto los bounds se +// comprueban sobre el client real, que es además el único que este node construye. +// +// Desde el 2026-10-28 el node trabaja sobre `opaque::Block` (ver +// `origin_chain_runtime::opaque`: el node decodificaba las llamadas con tipos nativos y +// rechazaba las que añadía un `set_code`). El client sigue concreto. /// Dependencias completas para construir los RPC del node. pub struct FullDeps

{ diff --git a/crates/origin-chain-node/src/service.rs b/crates/origin-chain-node/src/service.rs index 3d1f5de..15fb93e 100644 --- a/crates/origin-chain-node/src/service.rs +++ b/crates/origin-chain-node/src/service.rs @@ -12,11 +12,12 @@ use sc_consensus_grandpa::SharedVoterState; use sc_service::{error::Error as ServiceError, Configuration, TaskManager, WarpSyncConfig}; use sc_telemetry::{Telemetry, TelemetryWorker}; use sc_transaction_pool_api::OffchainTransactionPoolFactory; -// El runtime implementa `impl_runtime_apis!` sobre el `Block` root (con -// UncheckedExtrinsic), no sobre un opaque block separado → el node usa ese mismo -// tipo para que `RuntimeApi` matchee. (El template solochain usa `opaque::Block` -// porque su runtime expone las APIs así; el nuestro las expone sobre root Block.) -use origin_chain_runtime::{Block, RuntimeApi}; +// El node trabaja sobre `opaque::Block`: los extrinsics son bytes y sólo el WASM los +// decodifica. Con el `Block` completo del runtime (hasta el 2026-10-28) el node decodificaba +// con sus tipos nativos y rechazaba toda llamada que un `set_code` hubiera añadido (ver +// `origin_chain_runtime::opaque`). Las runtime APIs que genera `impl_runtime_apis!` son +// genéricas en el bloque y cruzan la frontera en SCALE, así que valen igual sobre éste. +use origin_chain_runtime::{opaque::Block, RuntimeApi}; use sp_consensus_aura::sr25519::AuthorityPair as AuraPair; use std::{sync::Arc, time::Duration}; diff --git a/crates/origin-chain-runtime/examples/firmar_como_wallet.rs b/crates/origin-chain-runtime/examples/firmar_como_wallet.rs index 14ad01f..a8a8d61 100644 --- a/crates/origin-chain-runtime/examples/firmar_como_wallet.rs +++ b/crates/origin-chain-runtime/examples/firmar_como_wallet.rs @@ -13,10 +13,15 @@ //! firmar [spec tx] extrinsic firmado por //Alice, en hex //! llamadas: remark · finalize_override (propuesta 0x09…09) · uphold0 (peso 0) //! set_code: (sudo de //Alice, ensayo de upgrade sin fork) +//! tesoro: (RV17: propuesta de tesoro de //Alice que paga plancks +//! a //Bob; 14 días y el bond mínimo) · pagar:: (sudo de +//! treasury_disbursement a //Bob) · votar:: (Aye de //Alice) +//! · depositar: (treasury_deposit de //Alice al vault) +//! · finalizar: (finalize_proposal) //! spec y tx: los que da `state_getRuntimeVersion`. Por defecto, los de `VERSION` //! compilado. Tras un `set_code` hay que pasarlos: el wallet firma con la versión //! nueva que anuncia el nodo, no con la que se compiló aquí. -//! eventos decodifica los eventos de un bloque +//! eventos decodifica los eventos de un bloque, con su fase //! cuenta "" [//derivación] llave sr25519 y dirección SS58 (42) que verá un wallet //! Substrate (substrate-bip39 → mini-secret → sr25519): vectores para la wallet Origin! //! verificar pasa la firma por el check del runtime nativo @@ -44,6 +49,10 @@ fn hex(arg: &str) -> Vec { sp_core::bytes::from_hex(arg).unwrap_or_else(|e| panic!("hex inválido {arg}: {e:?}")) } +fn bob() -> sp_runtime::AccountId32 { + sr25519::Pair::from_string("//Bob", None).expect("//Bob").public().into() +} + fn llamada(nombre: &str) -> RuntimeCall { let id = origin_chain_governance::ProposalId([9u8; 32]); match nombre { @@ -59,6 +68,53 @@ fn llamada(nombre: &str) -> RuntimeCall { proposal_id: id, stake_weight: 0, }), + otra if otra.starts_with("tesoro:") => { + let amount: u128 = otra["tesoro:".len()..].parse().expect("tesoro:"); + RuntimeCall::Governance(origin_chain_governance::Call::submit_treasury_proposal { + title_hash: sp_core::blake2_256(b"e2e-rv17/titulo"), + body_hash: sp_core::blake2_256(b"e2e-rv17/cuerpo"), + voting_period_blocks: origin_chain_governance::MIN_VOTING_PERIOD_BLOCKS, + stake: origin_chain_governance::MIN_PROPOSAL_STAKE, + recipient: bob(), + amount, + }) + } + otra if otra.starts_with("votar:") => { + let (id, peso) = + otra["votar:".len()..].split_once(':').expect("votar::"); + let id: [u8; 32] = hex(id).try_into().expect("el id son 32 bytes"); + RuntimeCall::Governance(origin_chain_governance::Call::cast_vote { + proposal_id: origin_chain_governance::ProposalId(id), + stance: origin_chain_types::VoteStance::Aye, + stake_weight: peso.parse().expect("peso en plancks"), + }) + } + otra if otra.starts_with("finalizar:") => { + let id: [u8; 32] = + hex(&otra["finalizar:".len()..]).try_into().expect("el id son 32 bytes"); + RuntimeCall::Governance(origin_chain_governance::Call::finalize_proposal { + proposal_id: origin_chain_governance::ProposalId(id), + }) + } + otra if otra.starts_with("depositar:") => { + RuntimeCall::Governance(origin_chain_governance::Call::treasury_deposit { + amount: otra["depositar:".len()..].parse().expect("depositar:"), + }) + } + otra if otra.starts_with("pagar:") => { + let (id, amount) = + otra["pagar:".len()..].split_once(':').expect("pagar::"); + let id: [u8; 32] = hex(id).try_into().expect("el id son 32 bytes"); + RuntimeCall::Sudo(pallet_sudo::Call::sudo { + call: Box::new(RuntimeCall::Governance( + origin_chain_governance::Call::treasury_disbursement { + proposal_id: origin_chain_governance::ProposalId(id), + recipient: bob(), + amount: amount.parse().expect("monto en plancks"), + }, + )), + }) + } otra => match otra.strip_prefix("set_code:") { Some(ruta) => { let code = std::fs::read(ruta).unwrap_or_else(|e| panic!("{ruta}: {e}")); @@ -67,7 +123,9 @@ fn llamada(nombre: &str) -> RuntimeCall { }) } None => panic!( - "llamada desconocida: {otra} (remark · finalize_override · uphold0 · set_code:)" + "llamada desconocida: {otra} (remark · finalize_override · uphold0 · \ + set_code: · tesoro: · pagar:: · \ + votar:: · depositar: · finalizar:)" ), }, } @@ -122,8 +180,9 @@ fn main() { let bytes = hex(&args[2]); let eventos = Vec::>::decode(&mut &bytes[..]) .expect("System::Events"); + // La fase va delante: dice a qué extrinsic pertenece cada evento. for e in eventos { - println!("{:?}", e.event); + println!("{:?} · {:?}", e.phase, e.event); } } "cuenta" => { diff --git a/crates/origin-chain-runtime/src/configs.rs b/crates/origin-chain-runtime/src/configs.rs index ed0287b..3513bb4 100644 --- a/crates/origin-chain-runtime/src/configs.rs +++ b/crates/origin-chain-runtime/src/configs.rs @@ -547,6 +547,29 @@ parameter_types! { frame_support::PalletId(*b"og/gvtry"); } +/// RV17: el «10% quorum» de PILAR_4 §6.6, sobre la emisión VIGENTE, redondeado hacia arriba. +/// Nunca baja del bond de una propuesta: con menos, el proponente aprobaría solo su propuesta +/// (o revertiría solo un veto) con un voto del tamaño de su fianza. +/// +/// Hasta RV17 eran 100M OGC fijos, el 10 % de 1B. Dos razones para medirlo sobre la emisión: +/// - las comisiones se queman al 100 % (`OU = ()`) y la emisión sólo transfiere desde el pool, +/// así que el suministro BAJA, y 100M acabaría siendo más que el 10 %; +/// - en una cadena con otro génesis el número fijo no significa nada. En la testnet de la casa +/// (génesis dev: 100M emitidos, todos de Alice) ninguna propuesta ordinaria podía llegar +/// nunca a 100M de Aye (revisión adversarial de RV17). +/// +/// Mainnet (1B) da lo mismo que antes, 100M. +pub struct DiezPorCientoDeLaEmision; +impl frame_support::traits::Get for DiezPorCientoDeLaEmision { + fn get() -> u128 { + use frame_support::traits::fungible::Inspect; + let emision: u128 = >::total_issuance(); + emision + .div_ceil(10) + .max(origin_chain_governance::MIN_PROPOSAL_STAKE + 1) + } +} + impl origin_chain_governance::Config for Runtime { type RuntimeEvent = RuntimeEvent; type Currency = crate::Balances; @@ -555,11 +578,13 @@ impl origin_chain_governance::Config for Runtime { type RuntimeFreezeReason = RuntimeFreezeReason; // RV15: votos VIVOS simultáneos por cuenta (los vencidos se descartan solos). type MaxActiveVotes = frame_support::traits::ConstU32<32>; - // RV16: participación mínima para revertir un veto del council: 100M OGC, el 10% - // del suministro que ratifica PILAR_4 §6.6 como quórum («10% quorum»). Es un - // parámetro de la constitución del DAO y lo fija David; mientras, este. Lo pinna - // `quorum_de_override_del_runtime` (genesis_config_presets.rs). - type MinOverrideQuorum = frame_support::traits::ConstU128<{ 100_000_000 * OGC }>; + // RV16 y RV17: los dos quórums son el «10% quorum» de PILAR_4 §6.6, sobre la + // emisión VIGENTE (ver `DiezPorCientoDeLaEmision`). El pallet los lee en el veto y al + // crear la propuesta, y los guarda. Son parámetro de la constitución del DAO y los + // fija David; mientras, éstos. Los pinna `quorums_del_runtime` + // (genesis_config_presets.rs). + type MinOverrideQuorum = DiezPorCientoDeLaEmision; + type VoteQuorum = DiezPorCientoDeLaEmision; type TreasuryPalletId = GovernanceTreasuryPalletId; // Weights INTERIM hand-derived DB-aware (ver weights.rs). Regenerable en HW. type WeightInfo = origin_chain_governance::weights::SubstrateWeight; diff --git a/crates/origin-chain-runtime/src/genesis_config_presets.rs b/crates/origin-chain-runtime/src/genesis_config_presets.rs index 7999f00..a604142 100644 --- a/crates/origin-chain-runtime/src/genesis_config_presets.rs +++ b/crates/origin-chain-runtime/src/genesis_config_presets.rs @@ -677,21 +677,59 @@ mod tests { }); } - /// RV16: el quórum de override del runtime es el 10% del suministro que - /// ratifica PILAR_4 §6.6 (100M OGC). Sin este test, un typo como `{ 100_000_000 }` - /// sin `* OGC` (10^-10 OGC) o `ConstU128<0>` pasaba toda la suite (revisión de - /// RV16). `integrity_test` exige además que supere el bond de una propuesta. + /// RV16 y RV17: los dos quórums del runtime son el 10 % de la emisión VIGENTE, redondeado + /// hacia arriba y nunca por debajo del bond. Se miden sobre el estado que construye cada + /// preset, no sobre una constante. Sin este test pasaba toda la suite cualquiera de estos + /// errores: un typo como `{ 100_000_000 }` sin `* OGC`, `ConstU128<0>` (revisión de + /// RV16), dividir entre 100 u olvidar el piso. #[test] - fn quorum_de_override_del_runtime() { - use frame_support::traits::Get; - let quorum: u128 = - <::MinOverrideQuorum as Get>::get(); - assert_eq!(quorum, 100_000_000 * crate::configs::OGC); - assert_eq!( - quorum, - crate::configs::OGC * 1_000_000_000 / 10, - "10% del suministro" - ); - assert!(quorum > origin_chain_governance::MIN_PROPOSAL_STAKE); + fn quorums_del_runtime() { + use frame_support::traits::{fungible::Inspect, Get}; + type Gob = crate::Runtime; + let voto = || <::VoteQuorum as Get>::get(); + let veto = + || <::MinOverrideQuorum as Get>::get(); + let ogc = crate::configs::OGC; + // pallet-revive acuña un depósito existencial en su cuenta al construir el génesis + // (`mint_into(account_id, minimum_balance())`): la emisión es la suma de las + // asignaciones MÁS ese ED. + let ed = crate::configs::EXISTENTIAL_DEPOSIT; + + // Sin estado (así corre `integrity_test`): el piso, que supera el bond. + sp_io::TestExternalities::new_empty().execute_with(|| { + assert_eq!(voto(), origin_chain_governance::MIN_PROPOSAL_STAKE + 1); + assert_eq!(veto(), voto()); + // «El 10 %» se redondea hacia ARRIBA: con 10·k + 1 de emisión, k no llega al + // 10 %. Las emisiones de los presets son múltiplos de 10 y no lo verían. + let k = 10_000_000 * ogc; + pallet_balances::TotalIssuance::::put(10 * k + 1); + assert_eq!(voto(), k + 1); + assert_eq!(veto(), k + 1); + }); + for (preset, emision, quorum) in [ + ( + PRESET_MAINNET_GENESIS, + Some(1_000_000_000 * ogc + ed), + Some(100_000_000 * ogc + ed / 10), + ), + (PRESET_DEVELOPMENT, Some(100_000_000 * ogc + ed), Some(10_000_000 * ogc + ed / 10)), + (PRESET_LOCAL_TESTNET, None, None), + ] { + let json = get_preset(&PresetId::from(preset)).expect("preset"); + sp_io::TestExternalities::new_empty().execute_with(|| { + frame_support::genesis_builder_helper::build_state::(json) + .expect("el preset construye estado"); + let total = >::total_issuance(); + if let Some(e) = emision { + assert_eq!(total, e, "{preset}: emisión"); + } + assert_eq!(voto(), total.div_ceil(10), "{preset}: 10 % de la emisión"); + if let Some(q) = quorum { + assert_eq!(voto(), q, "{preset}"); + } + assert_eq!(veto(), voto(), "{preset}: los dos quórums son el mismo 10 %"); + assert!(voto() > origin_chain_governance::MIN_PROPOSAL_STAKE); + }); + } } } diff --git a/crates/origin-chain-runtime/src/lib.rs b/crates/origin-chain-runtime/src/lib.rs index 4e9af2b..6ee4ed2 100644 --- a/crates/origin-chain-runtime/src/lib.rs +++ b/crates/origin-chain-runtime/src/lib.rs @@ -373,6 +373,19 @@ pub const IMPL_NAME: &str = borrowed_str(&VERSION.impl_name); /// de cada `weights.rs`). Sustituyen a los interinos medidos en el Mac. No cambia ninguna /// 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`. +/// +/// **v18 (2026-10-28), RV17:** +/// - el voto ordinario exige un quórum de Aye. Es el 10 % de PILAR_4 §6.6 sobre la emisión +/// vigente, se fija al crear la propuesta (`VoteQuorumOf`) y lo tiene que alcanzar también +/// un override del veto; +/// - el quórum mínimo del override pasa a ser el mismo 10 % (antes, 100M fijos); +/// - el tesoro sólo paga la acción que la propuesta fijó al crearse (`submit_treasury_proposal`, +/// storage `TreasuryActions`). +/// +/// No cambia la codificación de ninguna llamada existente (transaction_version igual) ni migra +/// storage: lo nuevo es aditivo, y una propuesta anterior sin quórum guardado se decide como +/// se abrió. La llamada nueva exige un node con `opaque::Block` (ver `opaque.rs`): un node +/// que decodifica con tipos nativos la rechaza en el pool. pub const RUNTIME_VERSION: u32 = VERSION.spec_version; /// Canonical authoring version per Substrate semver canonical. @@ -1025,7 +1038,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: 17, + spec_version: 18, impl_version: 1, apis: RUNTIME_API_VERSIONS, transaction_version: 1, @@ -1186,7 +1199,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, 17); + assert_eq!(RUNTIME_VERSION, 18); assert_eq!(TRANSACTION_VERSION, 1); } diff --git a/crates/origin-chain-runtime/src/opaque.rs b/crates/origin-chain-runtime/src/opaque.rs index eaaa23d..983b45b 100644 --- a/crates/origin-chain-runtime/src/opaque.rs +++ b/crates/origin-chain-runtime/src/opaque.rs @@ -15,7 +15,24 @@ //! (puede correr cualquier runtime WASM compatible) + runtime sovereignty (puede //! evolucionar pallets sin breakar node binaries). -use sp_runtime::impl_opaque_keys; +use sp_runtime::{generic, impl_opaque_keys}; + +/// El extrinsic que ve el NODO: bytes sin interpretar. Sólo el WASM los decodifica. +/// +/// Hasta el 2026-10-28 el node trabajaba sobre el `Block` completo del runtime y decodificaba +/// cada extrinsic con los tipos NATIVOS con que se compiló. Un `set_code` que añadía una +/// llamada no servía: el pool del node rechazaba la llamada nueva («Could not decode +/// `RuntimeCall::Governance.0`: variant doesn't exist») antes de que llegara al runtime. +/// Además, un node viejo no podía importar un bloque que la llevara. Se midió en el ensayo de +/// RV17, firmando `submit_treasury_proposal` contra un node de spec 16 subido a 18. +/// +/// Los bytes son los mismos: el `UncheckedExtrinsic` ya va prefijado por su largo, que es lo +/// que lee `OpaqueExtrinsic`. Los bloques y la base de datos no cambian. +pub use sp_runtime::OpaqueExtrinsic as UncheckedExtrinsic; +/// La cabecera, idéntica a la del runtime: el hash de un bloque no depende de este cambio. +pub type Header = crate::Header; +/// El bloque que usa el node. +pub type Block = generic::Block; // Slice 2e Layer 5 fix: import Vec for no_std impl_opaque_keys macro expansion. extern crate alloc; @@ -36,3 +53,117 @@ impl_opaque_keys! { pub grandpa: crate::Grandpa, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Runtime, RuntimeCall, TxExtension}; + use parity_scale_codec::{Decode, Encode}; + use sp_core::{sr25519, Pair, H256}; + use sp_runtime::generic::{Era, SignedPayload}; + use sp_runtime::traits::{Block as BlockT, Header as HeaderT}; + use sp_runtime::{MultiAddress, MultiSignature}; + + /// Un extrinsic firmado por //Alice como lo firma un wallet, con una llamada de RV17. + fn firmado() -> (crate::UncheckedExtrinsic, Vec) { + let alice = sr25519::Pair::from_string("//Alice", None).expect("//Alice"); + let tx_ext: TxExtension = ( + frame_system::CheckNonZeroSender::::new(), + frame_system::CheckSpecVersion::::new(), + frame_system::CheckTxVersion::::new(), + frame_system::CheckGenesis::::new(), + frame_system::CheckEra::::from(Era::Immortal), + frame_system::CheckNonce::::from(0), + frame_system::CheckWeight::::new(), + pallet_transaction_payment::ChargeTransactionPayment::::from(0), + frame_metadata_hash_extension::CheckMetadataHash::::new(false), + frame_system::WeightReclaim::::new(), + ); + let genesis = H256::repeat_byte(7); + let v = crate::VERSION; + let implicito = ( + (), + v.spec_version, + v.transaction_version, + genesis, + genesis, + (), + (), + (), + None, + (), + ); + let call = + RuntimeCall::Governance(origin_chain_governance::Call::submit_treasury_proposal { + title_hash: [1u8; 32], + body_hash: [2u8; 32], + voting_period_blocks: origin_chain_governance::MIN_VOTING_PERIOD_BLOCKS, + stake: origin_chain_governance::MIN_PROPOSAL_STAKE, + recipient: sp_runtime::AccountId32::new([3u8; 32]), + amount: 5, + }); + let llamada = call.encode(); + let payload = SignedPayload::::from_raw(call, tx_ext, implicito); + let firma = payload.using_encoded(|p| alice.sign(p)); + let (call, tx_ext, _) = payload.deconstruct(); + let xt = crate::UncheckedExtrinsic::new_signed( + call, + MultiAddress::Id(alice.public().into()), + MultiSignature::Sr25519(firma), + tx_ext, + ); + (xt, llamada) + } + + /// El node lee como `opaque::Block` lo que el runtime escribió como `crate::Block`, con los + /// mismos bytes y el mismo hash. Es lo que permite cambiar el binario de un validador + /// sobre su base de datos vieja (ensayado en la rp1 el 2026-10-28: 39 273 bloques + /// sincronizados desde cero y los mismos hashes en 8 alturas). + #[test] + fn el_bloque_opaco_es_el_mismo_bloque() { + let (xt, _) = firmado(); + let header = crate::Header::new( + 7, + Default::default(), + Default::default(), + H256::repeat_byte(9), + Default::default(), + ); + let completo = crate::Block::new(header, alloc::vec![xt.clone()]); + let bytes = completo.encode(); + let opaco = Block::decode(&mut &bytes[..]).expect("el node lo lee"); + assert_eq!(opaco.encode(), bytes, "mismos bytes"); + assert_eq!( + opaco.header().hash(), + completo.header().hash(), + "mismo hash" + ); + assert_eq!(opaco.extrinsics()[0].encode(), xt.encode()); + } + + /// Y por qué hace falta: con una llamada que el runtime nativo no conoce (aquí, + /// Governance 99), los tipos nativos no decodifican el extrinsic y el opaco sí. El node + /// viejo rechazaba así, en su pool, la llamada 16 que añadía `set_code`. + #[test] + fn el_node_opaco_no_necesita_conocer_la_llamada() { + let (xt, llamada) = firmado(); + let mut bytes = xt.encode(); + let pos = bytes + .windows(llamada.len()) + .position(|w| w == llamada) + .expect("la llamada"); + assert_eq!( + &bytes[pos..pos + 2], + &[10, 16], + "Governance (10), llamada 16" + ); + assert!(crate::UncheckedExtrinsic::decode(&mut &bytes[..]).is_ok()); + bytes[pos + 1] = 99; + assert!( + crate::UncheckedExtrinsic::decode(&mut &bytes[..]).is_err(), + "los tipos nativos no conocen la llamada 99" + ); + let opaco = UncheckedExtrinsic::decode(&mut &bytes[..]).expect("el opaco no la mira"); + assert_eq!(opaco.encode(), bytes); + } +} diff --git a/scripts/e2e/rv17_tesoro.py b/scripts/e2e/rv17_tesoro.py new file mode 100644 index 0000000..fb7c702 --- /dev/null +++ b/scripts/e2e/rv17_tesoro.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +"""E2E de RV17 contra un nodo vivo, firmando como un wallet: la acción del tesoro queda atada +a la propuesta y nada la cambia después. + + ORIGIN_RPC=http://127.0.0.1:9955 python3 scripts/e2e/rv17_tesoro.py # pasos 0-5 + ORIGIN_RPC=... python3 scripts/e2e/rv17_tesoro.py sembrar # además, siembra el pago + ORIGIN_RPC=... python3 scripts/e2e/rv17_tesoro.py cerrar # a los 14 días: aprueba y paga + +Lo que NO prueba este guion: el camino feliz del pago. Exige 14 días de votación con el quórum +de la propuesta (el 10 % de la emisión: ~10M OGC en la testnet de la casa, que Alice alcanza), +y luego el sudo del pago. Ese camino lo cubren los tests del pallet, y en vivo se siembra con +una propuesta y se cierra a los 14 días. Aquí se mide lo que sólo se ve en la cadena: la +codificación de la llamada nueva, el storage por RPC, el peso que se cobra y los errores tal +como llegan. + +Comprueba, en este orden (se detiene en el primer fallo, código de salida 1): + 0. el node NO decodifica las llamadas: los bytes de una llamada que no existe (Governance 99) + los rechaza el runtime, no el cliente. Un node con el `Block` completo del runtime los + rechaza con 1040 «Client error: Could not decode», y con ese mismo error rechaza también + una llamada nueva que un `set_code` acaba de añadir (medido en la rp1 el 2026-10-28); + 1. tesoro: entra y emite TreasuryProposalSubmitted. Lo cobrado es el peso que declara + weights.rs más el de las extensiones de la transacción (nonce, fee, CheckWeight…), que es + 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; + 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 + y no mueve el saldo de Bob. +""" +import hashlib, json, os, re, subprocess, sys, time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from upgrade_en_vivo import RAIZ, TOOL, ALICE, rpc, best, eventos, falla # noqa: E402 + +GOV = os.path.join(RAIZ, "crates/origin-chain-governance/src") +MONTO = 5 * 10**18 # 5 OGC +BOB = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" +BOB_HEX = "8eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a48" + + +def xxh64(data, seed): + P1, P2, P3, P4, P5 = (11400714785074694791, 14029467366897019727, 1609587929392839161, + 9650029242287828579, 2870177450012600261) + M = (1 << 64) - 1 + rotl = lambda x, r: ((x << r) | (x >> (64 - r))) & M # noqa: E731 + + def rnd(acc, x): + return (rotl((acc + x * P2) & M, 31) * P1) & M + + n, i = len(data), 0 + if n >= 32: + v = [(seed + P1 + P2) & M, (seed + P2) & M, seed, (seed - P1) & M] + while i + 32 <= n: + for k in range(4): + v[k] = rnd(v[k], int.from_bytes(data[i + 8 * k:i + 8 * k + 8], "little")) + i += 32 + h = (rotl(v[0], 1) + rotl(v[1], 7) + rotl(v[2], 12) + rotl(v[3], 18)) & M + for k in range(4): + h = ((h ^ rnd(0, v[k])) * P1 + P4) & M + else: + h = (seed + P5) & M + h = (h + n) & M + while i + 8 <= n: + h = (rotl(h ^ rnd(0, int.from_bytes(data[i:i + 8], "little")), 27) * P1 + P4) & M + i += 8 + if i + 4 <= n: + h = (rotl(h ^ ((int.from_bytes(data[i:i + 4], "little") * P1) & M), 23) * P2 + P3) & M + i += 4 + while i < n: + h = (rotl(h ^ ((data[i] * P5) & M), 11) * P1) & M + i += 1 + h ^= h >> 33 + h = (h * P2) & M + h ^= h >> 29 + h = (h * P3) & M + return h ^ (h >> 32) + + +def twox128(s): + b = s.encode() + return (xxh64(b, 0).to_bytes(8, "little") + xxh64(b, 1).to_bytes(8, "little")).hex() + + +def clave(item, id_hex): + """StorageMap con Blake2_128Concat: twox128(pallet) ++ twox128(item) ++ blake2_128(k) ++ k.""" + k = bytes.fromhex(id_hex) + return "0x" + twox128("Governance") + twox128(item) + hashlib.blake2b(k, digest_size=16).hexdigest() + k.hex() + + +def indice_de_error(nombre): + """Índice SCALE de una variante de `Error`: su posición en el enum del pallet.""" + src = open(os.path.join(GOV, "lib.rs")).read() + cuerpo = re.search(r"pub enum Error \{(.*?)\n \}", src, re.S).group(1) + sin_comentarios = re.sub(r"//[^\n]*", "", cuerpo) + variantes = re.findall(r"^\s*([A-Z]\w*)\s*,", sin_comentarios, re.M) + return variantes.index(nombre) + + +def peso_declarado(fn): + """ref_time y proof_size que declara weights.rs, con RocksDbWeight (25 µs leer, 100 µs escribir).""" + src = open(os.path.join(GOV, "weights.rs")).read() + impl = src.split("for SubstrateWeight {")[1].split("\nimpl WeightInfo for ()")[0] + b = re.search(r"fn " + fn + r"\(\) -> Weight \{(.*?)\n\t\}", impl, re.S).group(1) + n = lambda pat: int(re.search(pat, b).group(1).replace("_", "")) # noqa: E731 + ref = n(r"from_parts\(([\d_]+), 0\)") + n(r"reads\((\d+)_u64") * 25_000_000 + n(r"writes\((\d+)_u64") * 100_000_000 + return ref, n(r"from_parts\(0, ([\d_]+)\)") + + +def firmar(llamada, nonce, genesis, v): + return subprocess.run([TOOL, "firmar", llamada, str(nonce), genesis, str(v["specVersion"]), + str(v["transactionVersion"])], capture_output=True, text=True, check=True).stdout.strip() + + +def enviar(llamada, genesis, v): + """Firma con el nonce siguiente de Alice, envía y devuelve los eventos del bloque que la incluye.""" + n0 = best() + nonce = rpc("system_accountNextIndex", [ALICE])["result"] + xt = firmar(llamada, nonce, genesis, v) + r = rpc("author_submitExtrinsic", [xt]) + if "error" in r: + falla(f"{llamada}: rechazada en el pool: {r['error']}") + t0 = time.time() + while time.time() - t0 < 60: + for n in range(n0 + 1, best() + 1): + h = rpc("chain_getBlockHash", [n])["result"] + blk = rpc("chain_getBlock", [h])["result"]["block"] + # Un node con el Block concreto entrega cada extrinsic como lista de bytes; el + # opaco, como cadena hex (lo que espera polkadot.js). Se aceptan las dos. + hexes = [x if isinstance(x, str) else "0x" + bytes(x).hex() for x in blk["extrinsics"]] + if xt in hexes: + pos = hexes.index(xt) + evs = [l for l in eventos(n).splitlines() if f"ApplyExtrinsic({pos})" in l] + return n, evs + time.sleep(2) + falla(f"{llamada}: no se incluyó en 60 s") + + +def saldo_libre(cuenta_hex): + k = "0x" + twox128("System") + twox128("Account") + hashlib.blake2b(bytes.fromhex(cuenta_hex), digest_size=16).hexdigest() + cuenta_hex + v = rpc("state_getStorage", [k])["result"] + return int.from_bytes(bytes.fromhex(v[2:])[16:32], "little") if v else 0 # AccountInfo: nonce,consumers,providers,sufficients (4×u32) y luego free + + +def main(sembrar_pago=False): + genesis = rpc("chain_getBlockHash", [0])["result"] + v = rpc("state_getRuntimeVersion")["result"] + print(f"spec {v['specVersion']} · best #{best()}") + if v["specVersion"] < 18: + falla("RV17 llega con la spec 18") + + nonce = rpc("system_accountNextIndex", [ALICE])["result"] + xt = firmar(f"tesoro:{MONTO}", nonce, genesis, v) + titulo = hashlib.blake2b(b"e2e-rv17/titulo", digest_size=32).hexdigest() + if xt.count("0a10" + titulo) != 1: + falla("no encuentro la llamada Governance 16 en el extrinsic") + r = rpc("author_submitExtrinsic", [xt.replace("0a10" + titulo, "0a63" + titulo)]) + e = r.get("error", {}) + if not e: + falla("una llamada que no existe ENTRÓ en el pool") + if e.get("code") == 1040 or "Client error: Could not decode" in e.get("message", ""): + falla(f"el node decodifica las llamadas con sus tipos nativos: {e}") + print(f"0 ✅ llamada inexistente → la rechaza el runtime, no el cliente: {e.get('code')} {e.get('message', '')[:70]!r}") + + def cobrado(evs): + ok = [e for e in evs if "ExtrinsicSuccess" in e] + if not ok: + falla("no hay ExtrinsicSuccess\n" + "\n".join(evs)) + return (int(re.search(r"ref_time: (\d+)", ok[0]).group(1)), + int(re.search(r"proof_size: (\d+)", ok[0]).group(1))) + + def residuo(evs, fn): + c, d = cobrado(evs), peso_declarado(fn) + return (c[0] - d[0], c[1] - d[1]) + + _, evs = enviar(f"depositar:{2 * MONTO}", genesis, v) + extensiones = residuo(evs, "treasury_deposit") + n, evs = enviar(f"tesoro:{MONTO}", genesis, v) + sub = [e for e in evs if "TreasuryProposalSubmitted" in e] + if not sub: + falla("tesoro: no hay TreasuryProposalSubmitted\n" + "\n".join(evs)) + id_hex = bytes(json.loads(re.search(r"ProposalId\((\[[\d, ]+\])\)", sub[0]).group(1))).hex() + if residuo(evs, "submit_treasury_proposal") != extensiones: + falla(f"cobrado {cobrado(evs)} ≠ declarado {peso_declarado('submit_treasury_proposal')} + extensiones {extensiones}") + print(f"1 ✅ #{n}: TreasuryProposalSubmitted id 0x{id_hex[:12]}… · cobrado = weights.rs " + f"{peso_declarado('submit_treasury_proposal')} + extensiones {extensiones} (las mismas que treasury_deposit)") + + if not rpc("state_getStorage", [clave("Proposals", id_hex)])["result"]: + falla("Proposals(id) no existe en el storage") + acc = rpc("state_getStorage", [clave("TreasuryActions", id_hex)])["result"] + if not acc: + falla("TreasuryActions(id) no existe en el storage") + b = bytes.fromhex(acc[2:]) + if b[:32].hex() != BOB_HEX or int.from_bytes(b[32:48], "little") != MONTO or len(b) != 48: + falla(f"TreasuryActions(id) = {acc}, no (Bob, {MONTO})") + print(f"2 ✅ storage por RPC: Proposals(id) existe · TreasuryActions(id) = (Bob, {MONTO})") + + def falla_con(llamada, error, que): + n, evs = enviar(llamada, genesis, v) + idx = indice_de_error(error) + texto = "\n".join(evs) + patron = rf"index: 10, error: \[{idx}, 0, 0, 0\]" + if not re.search(patron, texto): + falla(f"{llamada}: se esperaba {error} (índice {idx})\n{texto}") + print(f"{que} ✅ #{n}: {llamada.split(':')[0]} → {error} (Module 10, error {idx})") + + falla_con("tesoro:0", "TreasuryAmountBelowMinimum", "3") + falla_con(f"tesoro:{MONTO * 2}", "ProposalIdCollision", "4") + if rpc("state_getStorage", [clave("TreasuryActions", id_hex)])["result"] != acc: + falla("el reenvío cambió TreasuryActions(id)") + print(" ✅ TreasuryActions(id) sigue siendo (Bob, monto original)") + + antes = saldo_libre(BOB_HEX) + falla_con(f"pagar:0x{id_hex}:{MONTO}", "ProposalNotApproved", "5") + if saldo_libre(BOB_HEX) != antes: + falla("el saldo de Bob cambió") + print(f" ✅ el saldo libre de Bob no se movió ({antes})") + if sembrar_pago: + sembrar(id_hex, genesis, v) + + +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`).""" + q = int.from_bytes(bytes.fromhex(rpc("state_getStorage", [clave("VoteQuorumOf", id_hex)])["result"][2:]), "little") + n, evs = enviar(f"depositar:{2 * MONTO}", genesis, v) + # (el paso 1 ya depositó otro tanto) treasury_deposit no emite evento propio: basta el Transfer de Balances y el éxito. + if not (any("Transfer" in e for e in evs) and any("ExtrinsicSuccess" in e for e in evs)): + falla("el depósito al vault no entró\n" + "\n".join(evs)) + n, evs = enviar(f"votar:0x{id_hex}:{q}", genesis, v) + if not any("VoteCast" in e for e in evs): + falla("el voto Aye no entró\n" + "\n".join(evs)) + p = rpc("state_getStorage", [clave("Proposals", id_hex)])["result"] + print(f"S ✅ #{n}: vault fondeado con {2 * MONTO} y Aye de Alice = quórum guardado ({q / 10**18:,.6f} OGC)") + print(f" cerrar después del fin del periodo: python3 scripts/e2e/rv17_tesoro.py cerrar {id_hex}") + + +def cerrar(id_hex): + """A los 14 días: finaliza (debe quedar Approved), paga por sudo y comprueba el saldo de Bob.""" + genesis = rpc("chain_getBlockHash", [0])["result"] + v = rpc("state_getRuntimeVersion")["result"] + n, evs = enviar(f"finalizar:0x{id_hex}", genesis, v) + if not any("ProposalFinalized" in e and "Approved" in e for e in evs): + falla("la propuesta no quedó Approved\n" + "\n".join(evs)) + antes = saldo_libre(BOB_HEX) + n, evs = enviar(f"pagar:0x{id_hex}:{MONTO}", genesis, v) + if not any("Sudid { sudo_result: Ok" in e for e in evs): + falla("el pago por sudo falló\n" + "\n".join(evs)) + if saldo_libre(BOB_HEX) - antes != MONTO: + falla(f"Bob recibió {saldo_libre(BOB_HEX) - antes}, no {MONTO}") + print(f"C ✅ #{n}: Approved y pagado: Bob recibió exactamente {MONTO} plancks") + + +if __name__ == "__main__": + if len(sys.argv) == 3 and sys.argv[1] == "cerrar": + cerrar(sys.argv[2].removeprefix("0x")) + else: + main(sembrar_pago=len(sys.argv) == 2 and sys.argv[1] == "sembrar")