From 158060bfe4d436a6f4cf1b4d76c39154d04b337f Mon Sep 17 00:00:00 2001 From: Charon Date: Thu, 29 Oct 2026 09:21:54 -0500 Subject: [PATCH] feat(gobierno): spec 19, el quorum es el 10 % de lo que puede votar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decision de David, 2026-10-29: «Si, del 10 % de lo que puede votar». Que puede votar: - Es la emision vigente menos el saldo que nadie puede votar ni recuperar: el pool de emision, el de subsidios y el vault de gobierno (cuentas_sin_llave en configs.rs). - Los bonds (og/props) y los depositos de pallet-revive NO se restan, porque vuelven enteros a una cuenta con llave. La revision adversaria de spec 19 lo demostro con sonda: restarlos dejaba bajar el quorum un 14 % en el genesis de mainnet, aparcando un bond en una propuesta de relleno y recuperandolo despues. Efecto: - En mainnet el quorum pasa de ~100M a ~35,1M OGC (10 % de 351M). - En la testnet de la casa apenas cambia. - Las propuestas abiertas conservan el quorum que guardaron al crearse. Pruebas: - quorums_del_runtime clasifica cada cuenta de pallet por su nombre. - aparcar_un_bond_no_baja_el_quorum reproduce la sonda con la configuracion real. - toda_cuenta_de_pallet_con_saldo_esta_clasificada vigila las cuentas modl del genesis. - 10 contrafactuales en rojo. - El E2E (paso 2b) descubre las cuentas de pallet recorriendo el storage, no copia la lista, y exige el quorum exacto. La comision se quema despues de despachar, y eso se mide en el BurnedDebt de la propia transaccion. Pesos: submit_proposal, submit_treasury_proposal y veto_proposal leen 3 cuentas mas. Medidos en la Pi: 5 procesos limpios, el nucleo calentado 3 s antes de cada uno, a 2,4 GHz. Operacion de la rp1, arrancar.sh: - en cada encendido desde el 26-oct el nodo moria al segundo porque la red no estaba; ahora espera a la ruta por defecto; - un cerrojo flock impide dos instancias; - el pid vivo se reconoce por su linea de comandos. Co-Authored-By: Claude Opus 5.5 --- crates/origin-chain-governance/src/weights.rs | 88 ++++----- .../examples/firmar_como_wallet.rs | 14 +- crates/origin-chain-runtime/src/configs.rs | 87 ++++++--- .../src/genesis_config_presets.rs | 170 +++++++++++++++--- crates/origin-chain-runtime/src/lib.rs | 9 +- scripts/e2e/rv17_tesoro.py | 62 ++++++- scripts/rp1/arrancar.sh | 26 ++- 7 files changed, 355 insertions(+), 101 deletions(-) diff --git a/crates/origin-chain-governance/src/weights.rs b/crates/origin-chain-governance/src/weights.rs index ab7d2e3..5931210 100644 --- a/crates/origin-chain-governance/src/weights.rs +++ b/crates/origin-chain-governance/src/weights.rs @@ -22,24 +22,26 @@ // 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. +// SPECS 18 Y 19 (RV17): SEIS de estos pesos NO son de Laurha. Son de Charon, medidos en la misma +// Pi porque su código cambió. Es un SMOKE DECLARADO, no un certificado: governor ondemand, núcleo +// 3 por taskset y SIN aislar, 5 procesos LIMPIOS (ningún proceso de gitea-runner salvo el daemon +// en ningún segundo de cada ventana, y 30 s de calma antes), steps 50, repeat 20, y el máximo de +// los 5 (max-de-n.py). frame-omni-bencher 0.15.0. +// - `finalize_proposal`, `finalize_override` y `treasury_disbursement`: el 2026-10-28, +// 21:49-21:52, wasm de spec 18 `539d7bf4…9311eb9`, 48-64 °C. Evidencia en +// ~/charon/bench-spec18/v2 de la rp1. +// - `submit_proposal`, `submit_treasury_proposal` y `veto_proposal`: el 2026-10-29, +// 09:15-09:19, wasm de spec 19 `c817695a…f96b23a3`. Leen los pools y el vault para el quórum. +// El núcleo se calentó 3 s antes de cada proceso: 2,4 GHz en todas las muestras, 59-64 °C. +// Evidencia en ~/charon/bench-spec19/v2. +// Sin calentar, ondemand arrancaba cada proceso a 1,5-2,1 GHz y el máximo salía hasta un 10 % +// más alto (~/charon/bench-spec19/sin-calentar, sobre un wasm anterior). +// Se descartó una medida del 28-oct: su muestreador sólo contaba `rustc` y no veía los tests de +// la CI. Un proceso salió 10 veces más lento. +// Calibración: sobre los que no cambian, estos smokes dan 0,75-1,24 veces lo certificado (el ruido +// entre procesos que midió Laurha). Esos once 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 seis en su banco y sustituirlos. //! Weights AUTOGENERADOS para `origin_chain_governance` — NO editar a mano. //! @@ -132,7 +134,7 @@ impl WeightInfo for SubstrateWeight { /// 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) + /// Storage: `System::Account` (r:4 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`) @@ -149,11 +151,11 @@ impl WeightInfo for SubstrateWeight { fn submit_proposal() -> Weight { // Proof size en bytes: // Medido: `108` - // Estimado: `3722` - // 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)) + // Estimado: `11402` + // Tiempo minimo de ejecucion: 133_445_000 picosegundos. + Weight::from_parts(135_242_000, 0) + .saturating_add(Weight::from_parts(0, 11402)) + .saturating_add(T::DbWeight::get().reads(10_u64)) .saturating_add(T::DbWeight::get().writes(6_u64)) } /// Storage: `Governance::AdvisorFlags` (r:1 w:0) @@ -270,10 +272,10 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Governance::VetoVotes` (`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:5 w:2) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, 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) /// Proof: `System::ExecutionPhase` (`max_values`: Some(1), `max_size`: Some(5), added: 500, mode: `MaxEncodedLen`) /// Storage: `System::EventCount` (r:1 w:1) @@ -292,10 +294,10 @@ impl WeightInfo for SubstrateWeight { // Proof size en bytes: // Medido: `1441` // Estimado: `16416` - // Tiempo minimo de ejecucion: 250_427_000 picosegundos. - Weight::from_parts(279_612_000, 0) + // Tiempo minimo de ejecucion: 263_724_000 picosegundos. + Weight::from_parts(269_483_000, 0) .saturating_add(Weight::from_parts(0, 16416)) - .saturating_add(T::DbWeight::get().reads(21_u64)) + .saturating_add(T::DbWeight::get().reads(24_u64)) .saturating_add(T::DbWeight::get().writes(11_u64)) } /// Storage: `Governance::AdvisorFlags` (r:1 w:1) @@ -582,7 +584,7 @@ impl WeightInfo for SubstrateWeight { /// 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) + /// Storage: `System::Account` (r:4 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`) @@ -601,11 +603,11 @@ impl WeightInfo for SubstrateWeight { 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)) + // Estimado: `11402` + // Tiempo minimo de ejecucion: 140_537_000 picosegundos. + Weight::from_parts(143_630_000, 0) + .saturating_add(Weight::from_parts(0, 11402)) + .saturating_add(T::DbWeight::get().reads(10_u64)) .saturating_add(T::DbWeight::get().writes(7_u64)) } } @@ -613,9 +615,9 @@ impl WeightInfo for SubstrateWeight { /// Impl para mocks y tests — `RocksDbWeight` directo, sin necesitar `T`. impl WeightInfo for () { fn submit_proposal() -> Weight { - Weight::from_parts(124_315_000, 0) - .saturating_add(Weight::from_parts(0, 3722)) - .saturating_add(RocksDbWeight::get().reads(7_u64)) + Weight::from_parts(135_242_000, 0) + .saturating_add(Weight::from_parts(0, 11402)) + .saturating_add(RocksDbWeight::get().reads(10_u64)) .saturating_add(RocksDbWeight::get().writes(6_u64)) } fn cast_vote() -> Weight { @@ -643,9 +645,9 @@ impl WeightInfo for () { .saturating_add(RocksDbWeight::get().writes(4_u64)) } fn veto_proposal() -> Weight { - Weight::from_parts(279_612_000, 0) + Weight::from_parts(269_483_000, 0) .saturating_add(Weight::from_parts(0, 16416)) - .saturating_add(RocksDbWeight::get().reads(21_u64)) + .saturating_add(RocksDbWeight::get().reads(24_u64)) .saturating_add(RocksDbWeight::get().writes(11_u64)) } fn mark_as_advisor() -> Weight { @@ -709,9 +711,9 @@ impl WeightInfo for () { .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)) + Weight::from_parts(143_630_000, 0) + .saturating_add(Weight::from_parts(0, 11402)) + .saturating_add(RocksDbWeight::get().reads(10_u64)) .saturating_add(RocksDbWeight::get().writes(7_u64)) } } diff --git a/crates/origin-chain-runtime/examples/firmar_como_wallet.rs b/crates/origin-chain-runtime/examples/firmar_como_wallet.rs index a8a8d61..1caae20 100644 --- a/crates/origin-chain-runtime/examples/firmar_como_wallet.rs +++ b/crates/origin-chain-runtime/examples/firmar_como_wallet.rs @@ -13,8 +13,9 @@ //! 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 +//! tesoro:[:] (RV17: propuesta de tesoro de //Alice que paga +//! plancks a //Bob; 14 días y el bond mínimo; la etiqueta cambia el título +//! y con él el id) · pagar:: (sudo de //! treasury_disbursement a //Bob) · votar:: (Aye de //Alice) //! · depositar: (treasury_deposit de //Alice al vault) //! · finalizar: (finalize_proposal) @@ -69,9 +70,14 @@ fn llamada(nombre: &str) -> RuntimeCall { stake_weight: 0, }), otra if otra.starts_with("tesoro:") => { - let amount: u128 = otra["tesoro:".len()..].parse().expect("tesoro:"); + let (monto, etiqueta) = match otra["tesoro:".len()..].split_once(':') { + Some((m, e)) => (m, format!("/{e}")), + None => (&otra["tesoro:".len()..], String::new()), + }; + let amount: u128 = monto.parse().expect("tesoro:[:]"); + let titulo = [b"e2e-rv17/titulo".as_slice(), etiqueta.as_bytes()].concat(); RuntimeCall::Governance(origin_chain_governance::Call::submit_treasury_proposal { - title_hash: sp_core::blake2_256(b"e2e-rv17/titulo"), + title_hash: sp_core::blake2_256(&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, diff --git a/crates/origin-chain-runtime/src/configs.rs b/crates/origin-chain-runtime/src/configs.rs index 3513bb4..570d3c4 100644 --- a/crates/origin-chain-runtime/src/configs.rs +++ b/crates/origin-chain-runtime/src/configs.rs @@ -547,24 +547,72 @@ 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. +/// El OGC que NO puede votar: el de cuentas que se derivan de un `PalletId`, que nadie puede +/// firmar, y del que nadie recupera nada por su cuenta. Son el pool de emisión, el de subsidios y +/// el vault de gobierno. Lo que entra ahí no vuelve a quien lo puso: sale por la emisión o por una +/// propuesta de tesoro aprobada, que a su vez tiene que pasar el quórum. /// -/// 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). +/// NO entran dos cuentas de pallet que también tienen saldo: +/// - `og/props`, el escrow de los bonds. Ese OGC es del proponente y le vuelve entero al decidirse +/// la propuesta. Restarlo dejaba BAJAR el quórum aparcando un bond enorme en una propuesta de +/// relleno: la revisión adversaria de spec 19 lo bajó un 14 % en el génesis de mainnet sin coste. +/// - la cuenta de `pallet-revive`, que guarda los depósitos de subida de código. Se devuelven a +/// quien subió el código, así que pasa lo mismo, aunque en pequeño. Su ED de génesis cuenta como +/// votable: endurece el quórum en 0,0001 OGC. /// -/// Mainnet (1B) da lo mismo que antes, 100M. -pub struct DiezPorCientoDeLaEmision; -impl frame_support::traits::Get for DiezPorCientoDeLaEmision { +/// Si una pallet nueva guarda OGC en una cuenta propia, se decide aquí, o en +/// [`cuentas_de_pallet_que_si_cuentan`], y se dice por qué. Lo vigila +/// `toda_cuenta_de_pallet_con_saldo_esta_clasificada` (genesis_config_presets.rs). +pub fn cuentas_sin_llave() -> [ChainAccountId; 3] { + use sp_runtime::traits::AccountIdConversion; + [ + EmissionEcosystemPalletId::get().into_account_truncating(), + EmissionSubsidyPalletId::get().into_account_truncating(), + GovernanceTreasuryPalletId::get().into_account_truncating(), + ] +} + +/// Cuentas de pallet cuyo OGC SÍ cuenta como votable, porque vuelve a una cuenta con llave (ver +/// [`cuentas_sin_llave`]). +pub fn cuentas_de_pallet_que_si_cuentan() -> [ChainAccountId; 2] { + use sp_runtime::traits::AccountIdConversion; + [ + origin_chain_governance::PROPOSAL_BOND_PALLET_ID.into_account_truncating(), + pallet_revive::Pallet::::account_id(), + ] +} + +/// RV17 (spec 19): el «10% quorum» de PILAR_4 §6.6 sobre lo que PUEDE VOTAR. Es la emisión vigente +/// menos el saldo que nadie puede votar ni recuperar ([`cuentas_sin_llave`]), con el 10 % +/// 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. +/// +/// David, 2026-10-29: «Sí, del 10 % de lo que puede votar». En el génesis de mainnet el pool de +/// emisión (400M) y el vault (249M) no votan, así que el quórum es el 10 % de unos 351M, ~35,1M +/// OGC. Medido sobre la emisión total habría sido 100M, ~28 % de lo votable. +/// +/// Historia: +/// - Hasta spec 18 eran 100M OGC fijos (RV16) y luego el 10 % de la emisión total (spec 18). +/// - El número fijo no significaba nada en otro génesis: en la testnet de la casa (100M, todos de +/// Alice) nada podía aprobarse. +/// - Las comisiones se queman, así que la emisión baja. +/// +/// Lo que NO resta: el OGC retenido (`hold`) en cuentas con llave, los bonds, los depósitos de +/// revive y las cuentas de contratos. Una parte de ese OGC no puede votar en este momento, pero +/// vuelve a quien sí puede: restarlo abriría una palanca reversible sobre el quórum. El quórum +/// resulta algo más exigente que el 10 % exacto, nunca menos. +pub struct DiezPorCientoDeLoQuePuedeVotar; +impl frame_support::traits::Get for DiezPorCientoDeLoQuePuedeVotar { fn get() -> u128 { use frame_support::traits::fungible::Inspect; - let emision: u128 = >::total_issuance(); + type B = crate::Balances; + let emision: u128 = >::total_issuance(); + let sin_llave: u128 = cuentas_sin_llave() + .iter() + .map(|cuenta| >::total_balance(cuenta)) + .fold(0, u128::saturating_add); emision + .saturating_sub(sin_llave) .div_ceil(10) .max(origin_chain_governance::MIN_PROPOSAL_STAKE + 1) } @@ -578,13 +626,12 @@ 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 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; + // RV16 y RV17: los dos quórums son el «10% quorum» de PILAR_4 §6.6 sobre lo que PUEDE + // VOTAR (ver `DiezPorCientoDeLoQuePuedeVotar`; David, 29-oct). 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. Los pinna `quorums_del_runtime` (genesis_config_presets.rs). + type MinOverrideQuorum = DiezPorCientoDeLoQuePuedeVotar; + type VoteQuorum = DiezPorCientoDeLoQuePuedeVotar; 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 a604142..f2389a5 100644 --- a/crates/origin-chain-runtime/src/genesis_config_presets.rs +++ b/crates/origin-chain-runtime/src/genesis_config_presets.rs @@ -677,14 +677,23 @@ mod tests { }); } - /// 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. + /// Una cuenta de pallet, por su `PalletId`, NOMBRADA aquí y no sacada de las listas del + /// runtime: así un error en las listas no se esconde detrás de sí mismo. + fn de_pallet(id: &[u8; 8]) -> ChainAccountId { + frame_support::PalletId(*id).into_account_truncating() + } + + /// RV16 y RV17: los dos quórums del runtime son el 10 % de lo que PUEDE VOTAR, redondeado + /// hacia arriba y nunca por debajo del bond. Lo que puede votar es la emisión menos los pools y + /// el vault: lo que nadie puede firmar ni recuperar. Se mide sobre el estado que construye cada + /// preset, no sobre una constante. Sin este test pasaba la suite cualquiera de estos errores: + /// - un typo como `{ 100_000_000 }` sin `* OGC`, o `ConstU128<0>` (revisión de RV16); + /// - dividir entre 100, u olvidar el piso; + /// - volver a medir sobre la emisión total; + /// - restar los bonds o los depósitos de revive. #[test] fn quorums_del_runtime() { - use frame_support::traits::{fungible::Inspect, Get}; + use frame_support::traits::{fungible::Inspect, fungible::Mutate, Get}; type Gob = crate::Runtime; let voto = || <::VoteQuorum as Get>::get(); let veto = @@ -692,44 +701,157 @@ mod tests { 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. + // asignaciones MÁS ese ED, y ese ED cuenta como votable (ver `cuentas_sin_llave`). 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. + // «El 10 %» se redondea hacia ARRIBA: con 10·k + 1 de emisión, k no llega al 10 %. 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), + // Cada cuenta de pallet, por su nombre: las que no vuelven a nadie se restan, y las que + // vuelven a una cuenta con llave (bonds y depósitos de revive) cuentan como votables. + sp_io::TestExternalities::new_empty().execute_with(|| { + let k = 10_000_000 * ogc; + let mint = |cuenta: &ChainAccountId| { + >::mint_into(cuenta, 7 * k).expect("mint") + }; + >::mint_into( + &account_from_sr25519(ALICE_SR25519), + 10 * k, + ) + .expect("mint"); + for no_vota in [b"og/ecosy", b"og/subsy", b"og/gvtry"] { + mint(&de_pallet(no_vota)); + } + for vuelve in [de_pallet(b"og/props"), de_pallet(b"py/reviv")] { + mint(&vuelve); + } + assert_eq!(>::total_issuance(), 45 * k); + assert_eq!(voto(), 24 * k / 10, "10 + 7 + 7 votan; los 21 de pools y vault, no"); + assert_eq!(veto(), voto()); + }); + + for (preset, votable, quorum) in [ + // 1B + ED emitidos; no votan el pool (400M) ni el vault (249M). + (PRESET_MAINNET_GENESIS, 351_000_000 * ogc + ed, 35_100_000 * ogc + ed / 10), + // 100M de Alice + el ED de revive. + (PRESET_DEVELOPMENT, 100_000_000 * ogc + ed, 10_000_000 * ogc + ed / 10), ] { 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}"); - } + let no_votan: u128 = [b"og/ecosy", b"og/subsy", b"og/gvtry"] + .iter() + .map(|id| { + >::total_balance(&de_pallet(id)) + }) + .sum(); + assert_eq!(total - no_votan, votable, "{preset}: lo que puede votar"); + assert_eq!(voto(), quorum, "{preset}"); assert_eq!(veto(), voto(), "{preset}: los dos quórums son el mismo 10 %"); assert!(voto() > origin_chain_governance::MIN_PROPOSAL_STAKE); }); } } + + /// Revisión adversaria de spec 19: si los bonds se restaran, una cuenta grande aparcaba OGC en + /// una propuesta de relleno y el quórum de las que se crearan mientras tanto BAJABA, y luego + /// recuperaba su bond entero. Se mide con la configuración real sobre el génesis de mainnet. + /// Aparcar un bond no mueve el quórum de nadie; lo que entra en el vault, que no vuelve, sí. + #[test] + fn aparcar_un_bond_no_baja_el_quorum() { + use crate::{Governance, RuntimeOrigin, System}; + use frame_support::assert_ok; + use origin_chain_governance::{derive_proposal_id, VoteQuorumOf, MIN_VOTING_PERIOD_BLOCKS}; + let ogc = crate::configs::OGC; + let placeholder = |tag: &[u8]| { + let mut b = [0u8; 32]; + b[..tag.len()].copy_from_slice(tag); + b + }; + let (liqd, sale) = (placeholder(b"LIQD"), placeholder(b"SALE")); + // Crea, desde `quien`, una propuesta con `stake` de bond y devuelve su quórum guardado. + let proponer = |quien: [u8; 32], titulo: u8, stake: u128| { + assert_ok!(Governance::submit_proposal( + RuntimeOrigin::signed(account_from_sr25519(quien)), + [titulo; 32], + [0u8; 32], + MIN_VOTING_PERIOD_BLOCKS, + stake, + )); + let id = derive_proposal_id( + &origin_chain_types::AccountId::new(quien), + &[titulo; 32], + &[0u8; 32], + ); + VoteQuorumOf::::get(id).expect("quórum guardado") + }; + let medir = |antes: &dyn Fn()| { + let json = get_preset(&PresetId::from(PRESET_MAINNET_GENESIS)).expect("preset"); + let mut ext = sp_io::TestExternalities::new_empty(); + ext.execute_with(|| { + frame_support::genesis_builder_helper::build_state::(json) + .expect("el preset construye estado"); + System::set_block_number(1); + antes(); + proponer(sale, 2, origin_chain_governance::MIN_PROPOSAL_STAKE) + }) + }; + let control = medir(&|| {}); + let con_bond_aparcado = medir(&|| { + proponer(liqd, 1, 49_900_000 * ogc); + }); + assert_eq!(con_bond_aparcado, control, "un bond aparcado bajó el quórum"); + // Control positivo: lo que entra en el vault no vuelve, y sí baja el quórum (X/10). + let con_deposito = medir(&|| { + assert_ok!(Governance::treasury_deposit( + RuntimeOrigin::signed(account_from_sr25519(liqd)), + 10_000_000 * ogc, + )); + }); + assert_eq!(control - con_deposito, 1_000_000 * ogc); + } + + /// Toda cuenta derivada de un `PalletId` («modl» + id) con saldo tiene que estar clasificada: + /// o no vota (`cuentas_sin_llave`) o su OGC vuelve a una cuenta con llave + /// (`cuentas_de_pallet_que_si_cuentan`). Mira el génesis de los tres presets. Una pallet que + /// acumule OGC sólo en ejecución no la vería: para eso está la nota de `cuentas_sin_llave`. + #[test] + fn toda_cuenta_de_pallet_con_saldo_esta_clasificada() { + use frame_support::traits::fungible::Inspect; + let sin_llave = crate::configs::cuentas_sin_llave(); + let cuentan = crate::configs::cuentas_de_pallet_que_si_cuentan(); + let todas: alloc::collections::BTreeSet<_> = + sin_llave.iter().chain(cuentan.iter()).collect(); + assert_eq!(todas.len(), sin_llave.len() + cuentan.len(), "una cuenta en dos listas"); + for preset in preset_names() { + let json = get_preset(&preset).expect("preset"); + sp_io::TestExternalities::new_empty().execute_with(|| { + frame_support::genesis_builder_helper::build_state::(json) + .expect("el preset construye estado"); + let mut vistas = 0; + for (cuenta, _) in frame_system::Account::::iter() { + let con_saldo = + >::total_balance(&cuenta) > 0; + let es_de_pallet = AsRef::<[u8]>::as_ref(&cuenta).starts_with(b"modl"); + if con_saldo && es_de_pallet { + vistas += 1; + assert!( + todas.contains(&cuenta), + "{preset:?}: {cuenta} es de pallet y no está clasificada" + ); + } + } + assert!(vistas >= 1, "{preset:?}: al menos la cuenta de revive tiene saldo"); + }); + } + } } diff --git a/crates/origin-chain-runtime/src/lib.rs b/crates/origin-chain-runtime/src/lib.rs index 6ee4ed2..024acc7 100644 --- a/crates/origin-chain-runtime/src/lib.rs +++ b/crates/origin-chain-runtime/src/lib.rs @@ -374,6 +374,11 @@ pub const IMPL_NAME: &str = borrowed_str(&VERSION.impl_name); /// lógica ni ningún storage, sólo lo que cuesta cada extrinsic. Es el primer upgrade que se /// aplica en vivo a la testnet de la casa (rp1), por `set_code`. /// +/// **v19 (2026-10-29), RV17 decidido por David:** el quórum es el 10 % de lo que PUEDE VOTAR (la +/// emisión menos las cuentas sin llave: pools, vault, bonds y revive). «Sí, del 10 % de lo que +/// puede votar» (David, 29-oct). En mainnet pasa de ~100M a ~35,1M OGC; en la testnet de la casa +/// apenas cambia (todo es de Alice). Las propuestas abiertas conservan el quórum que guardaron. +/// /// **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 @@ -1038,7 +1043,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: 18, + spec_version: 19, impl_version: 1, apis: RUNTIME_API_VERSIONS, transaction_version: 1, @@ -1199,7 +1204,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, 18); + assert_eq!(RUNTIME_VERSION, 19); assert_eq!(TRANSACTION_VERSION, 1); } diff --git a/scripts/e2e/rv17_tesoro.py b/scripts/e2e/rv17_tesoro.py index fb7c702..7ed9f44 100644 --- a/scripts/e2e/rv17_tesoro.py +++ b/scripts/e2e/rv17_tesoro.py @@ -34,6 +34,16 @@ 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") +# ORIGIN_E2E_ETIQUETA cambia el título de la propuesta, y con él su id: hace falta para repetir el +# E2E en una cadena que ya tiene la propuesta de una corrida anterior. +ETIQUETA = os.environ.get("ORIGIN_E2E_ETIQUETA", "") +TESORO = "tesoro:{}" + (f":{ETIQUETA}" if ETIQUETA else "") +TITULO = hashlib.blake2b(b"e2e-rv17/titulo" + (f"/{ETIQUETA}".encode() if ETIQUETA else b""), digest_size=32).hexdigest() +# Cuentas de pallet («modl» ++ PalletId ++ ceros) cuyo OGC vuelve a una cuenta con llave y por eso +# SÍ cuenta como votable: los bonds y los depósitos de revive (configs.rs). Las demás cuentas de +# pallet no votan. El E2E las DESCUBRE recorriendo las cuentas de la cadena, no copia la lista del +# runtime: una omisión compartida pasaría en los dos. +VUELVEN = {(b"modl" + pid).ljust(32, b"\0").hex() for pid in (b"og/props", b"py/reviv")} MONTO = 5 * 10**18 # 5 OGC BOB = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" BOB_HEX = "8eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a48" @@ -136,6 +146,26 @@ def enviar(llamada, genesis, v): falla(f"{llamada}: no se incluyó en 60 s") +def cuentas_de_pallet(bloque): + """Todas las cuentas «modl…» con entrada en System::Account en `bloque`, leídas del storage.""" + pref = "0x" + twox128("System") + twox128("Account") + cuentas, desde = [], pref + while True: + ks = rpc("state_getKeysPaged", [pref, 1000, desde, bloque])["result"] + cuentas += [k[-64:] for k in ks if k[-64:].startswith(b"modl".hex())] + if len(ks) < 1000: + return cuentas + desde = ks[-1] + + +def saldo_total(cuenta_hex, bloque=None): + """free + reserved de una cuenta, como `total_balance` del runtime.""" + k = "0x" + twox128("System") + twox128("Account") + hashlib.blake2b(bytes.fromhex(cuenta_hex), digest_size=16).hexdigest() + cuenta_hex + v = rpc("state_getStorage", [k] + ([bloque] if bloque else []))["result"] + d = bytes.fromhex(v[2:]) if v else b"" + return int.from_bytes(d[16:32], "little") + int.from_bytes(d[32:48], "little") if d else 0 + + 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"] @@ -150,8 +180,8 @@ def main(sembrar_pago=False): 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() + xt = firmar(TESORO.format(MONTO), nonce, genesis, v) + titulo = TITULO 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)]) @@ -175,7 +205,7 @@ def main(sembrar_pago=False): _, evs = enviar(f"depositar:{2 * MONTO}", genesis, v) extensiones = residuo(evs, "treasury_deposit") - n, evs = enviar(f"tesoro:{MONTO}", genesis, v) + n, evs = enviar(TESORO.format(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)) @@ -195,6 +225,28 @@ def main(sembrar_pago=False): falla(f"TreasuryActions(id) = {acc}, no (Bob, {MONTO})") print(f"2 ✅ storage por RPC: Proposals(id) existe · TreasuryActions(id) = (Bob, {MONTO})") + # 2b · el quórum guardado es el 10 % de lo que PUEDE votar (David, 29-oct): la emisión menos + # las cuentas sin llave, al despachar. Se reconstruye desde el estado final del bloque más lo + # que quemó la propia transacción DESPUÉS de despacharla: la comisión se retira antes + # (`Withdraw`) pero se quema al final (`BurnedDebt`), así que al leer el quórum la emisión + # todavía la incluye (medido en el ensayo de spec 19: la diferencia era BurnedDebt / 10). + h = rpc("chain_getBlockHash", [n])["result"] + leer = lambda k: rpc("state_getStorage", [k, h])["result"] # noqa: E731 + emision = int.from_bytes(bytes.fromhex(leer("0x" + twox128("Balances") + twox128("TotalIssuance"))[2:]), "little") + sin_llave = sum(saldo_total(c, h) for c in cuentas_de_pallet(h) if c not in VUELVEN) + guardado = int.from_bytes(bytes.fromhex(rpc("state_getStorage", [clave("VoteQuorumOf", id_hex)])["result"][2:]), "little") + quemado = sum(int(m) for e in evs for m in re.findall(r"BurnedDebt \{ amount: (\d+)", e)) + if not quemado: + falla("no encuentro el BurnedDebt de la comisión\n" + "\n".join(evs)) + emision += quemado + esperado = -(-(emision - sin_llave) // 10) + if guardado != esperado: + falla(f"quórum guardado {guardado} ≠ 10 % de lo votable {esperado}") + if v["specVersion"] >= 19 and guardado == -(-emision // 10): + falla("el quórum es el 10 % de la emisión TOTAL, no de lo que puede votar") + print(f"2b ✅ quórum guardado {guardado / 10**18:,.6f} OGC = 10 % de lo que puede votar " + f"({(emision - sin_llave) / 10**18:,.4f} OGC; las cuentas sin llave guardan {sin_llave / 10**18:,.4f})") + def falla_con(llamada, error, que): n, evs = enviar(llamada, genesis, v) idx = indice_de_error(error) @@ -204,8 +256,8 @@ def main(sembrar_pago=False): 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") + falla_con(TESORO.format(0), "TreasuryAmountBelowMinimum", "3") + falla_con(TESORO.format(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)") diff --git a/scripts/rp1/arrancar.sh b/scripts/rp1/arrancar.sh index cdef041..af5680b 100755 --- a/scripts/rp1/arrancar.sh +++ b/scripts/rp1/arrancar.sh @@ -22,10 +22,30 @@ BIN="$BASE/bin/origin-chain-node" PIDF="$BASE/nodo.pid" mkdir -p "$BASE/data" "$BASE/logs" -if [ -f "$PIDF" ] && kill -0 "$(cat "$PIDF")" 2>/dev/null; then +# Una sola instancia a la vez. Desde que se espera a la red (más abajo, hasta 90 s), un tick del +# vigilante (*/5) puede caer en medio de un @reboot. Sin el cerrojo arrancaban dos nodos: el segundo +# muere por el lock de RocksDB, pero podía quedarse con nodo.pid, y entonces medir.sh medía un pid +# muerto y «matar el pid» para volver atrás no paraba el nodo real (revisión de spec 19). +exec 9>"$BASE/arrancar.lock" +flock -n 9 || exit 0 + +# ¿Vive YA un nodo? Se mira la línea de comandos del pid, no sólo que exista: tras un reinicio, +# nodo.pid es viejo y su número puede tenerlo otro proceso, y entonces `kill -0` acierta y el nodo +# no arrancaría nunca. +if [ -f "$PIDF" ] && tr '\0' ' ' <"/proc/$(cat "$PIDF")/cmdline" 2>/dev/null | grep -q "origin-chain-node run"; then exit 0 fi +# Al encender la Pi, @reboot llega ANTES que la red: litep2p no levanta su transporte («transport +# service closed» miles de veces) y el nodo se cierra solo en un segundo. Pasó en CADA reinicio +# desde el 26-oct (medido en arranques.log y en los logs del nodo) y lo rescataba el vigilante +# hasta 5 min después. Se espera a tener ruta por defecto, como mucho 90 s. Se lee +# /proc/net/route y no `ip`, que con el PATH de cron puede no estar. +espera=0 +while [ $espera -lt 90 ] && ! awk 'NR > 1 && $2 == "00000000" { hay = 1 } END { exit !hay }' /proc/net/route; do + sleep 1; espera=$((espera + 1)) +done + LOG="$BASE/logs/nodo-$(date +%Y%m%d-%H%M%S).log" # Un validador fuera de --dev exige una llave de red estable, y este CLI no trae `key`. Se # genera UNA vez (la vía 4 del propio nodo) y queda en el base-path; en los arranques @@ -49,7 +69,7 @@ nohup taskset -c 0-2 nice -n 10 ionice -c2 -n7 "$BIN" run \ --no-telemetry --no-prometheus "${EXTRA[@]}" \ --db-cache 128 \ --log info,txpool=warn,basic-authorship=warn,sc_basic_authorship=warn,aura=warn \ - >>"$LOG" 2>&1 & + >>"$LOG" 2>&1 9>&- & echo $! >"$PIDF" ln -sfn "$LOG" "$BASE/logs/actual.log" -echo "$(date -Is) arrancado pid $(cat "$PIDF") log $LOG" >>"$BASE/logs/arranques.log" +echo "$(date -Is) arrancado pid $(cat "$PIDF") log $LOG (red esperada ${espera}s)" >>"$BASE/logs/arranques.log"