feat(gobierno): spec 19, el quorum es el 10 % de lo que puede votar

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 <noreply@anthropic.com>
This commit is contained in:
CharonandClaude Opus 5.5 committed 2026-10-29 09:21:54 -05:00
1 parent b6d05eaf45
commit 158060bfe4
7 files changed
+355 -101

No files matched your search

@@ -13,8 +13,9 @@
//! firmar <llamada> <nonce> <genesis_hex> [spec tx] extrinsic firmado por //Alice, en hex
//! llamadas: remark · finalize_override (propuesta 0x09…09) · uphold0 (peso 0)
//! set_code:<ruta.wasm> (sudo de //Alice, ensayo de upgrade sin fork)
//! tesoro:<monto> (RV17: propuesta de tesoro de //Alice que paga <monto> plancks
//! a //Bob; 14 días y el bond mínimo) · pagar:<id_hex>:<monto> (sudo de
//! tesoro:<monto>[:<etiqueta>] (RV17: propuesta de tesoro de //Alice que paga
//! <monto> plancks a //Bob; 14 días y el bond mínimo; la etiqueta cambia el título
//! y con él el id) · pagar:<id_hex>:<monto> (sudo de
//! treasury_disbursement a //Bob) · votar:<id_hex>:<peso> (Aye de //Alice)
//! · depositar:<monto> (treasury_deposit de //Alice al vault)
//! · finalizar:<id_hex> (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:<monto en plancks>");
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:<monto en plancks>[:<etiqueta>]");
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,
+67 -20
View File
@@ -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<u128> 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::<Runtime>::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<u128> for DiezPorCientoDeLoQuePuedeVotar {
fn get() -> u128 {
use frame_support::traits::fungible::Inspect;
let emision: u128 = <crate::Balances as Inspect<ChainAccountId>>::total_issuance();
type B = crate::Balances;
let emision: u128 = <B as Inspect<ChainAccountId>>::total_issuance();
let sin_llave: u128 = cuentas_sin_llave()
.iter()
.map(|cuenta| <B as Inspect<ChainAccountId>>::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<Runtime>;
@@ -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 = || <<Gob as origin_chain_governance::Config>::VoteQuorum as Get<u128>>::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::<crate::Runtime>::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| {
<crate::Balances as Mutate<ChainAccountId>>::mint_into(cuenta, 7 * k).expect("mint")
};
<crate::Balances as Mutate<ChainAccountId>>::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!(<crate::Balances as Inspect<ChainAccountId>>::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::<RuntimeGenesisConfig>(json)
.expect("el preset construye estado");
let total = <crate::Balances as Inspect<ChainAccountId>>::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| {
<crate::Balances as Inspect<ChainAccountId>>::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::<crate::Runtime>::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::<RuntimeGenesisConfig>(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::<RuntimeGenesisConfig>(json)
.expect("el preset construye estado");
let mut vistas = 0;
for (cuenta, _) in frame_system::Account::<crate::Runtime>::iter() {
let con_saldo =
<crate::Balances as Inspect<ChainAccountId>>::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");
});
}
}
}
+7 -2
View File
@@ -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);
}