feat(gobierno): spec 18 (RV17), quorum del voto ordinario y pago del tesoro atado a la propuesta

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 <noreply@anthropic.com>
This commit is contained in:
CharonandClaude Opus 5.5 committed 2026-10-28 21:55:24 -05:00
1 parent abe680e853
commit b6d05eaf45
14 files changed
+1368 -155

No files matched your search

+221 -58
View File
@@ -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<u128>;
/// 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<u128>;
/// 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<T: Config> =
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<T: Config> =
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<T: Config> =
StorageMap<_, Blake2_128Concat, ProposalId, (T::AccountId, u128), OptionQuery>;
/// CHN-4 canonical: `Proposals<StorageMap<ProposalId, Proposal>>` 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::<T>::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<T>,
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::<T>::VotingPeriodTooLong
amount >= T::Currency::minimum_balance().max(1),
Error::<T>::TreasuryAmountBelowMinimum
);
ensure!(
voting_period_blocks >= MIN_VOTING_PERIOD_BLOCKS,
Error::<T>::VotingPeriodTooShort
recipient != Self::treasury_account() && recipient != Self::proposal_bond_account(),
Error::<T>::TreasuryRecipientInvalid
);
let proposer_acct = AccountId::new(account_bytes::<T>(&who));
let id = crate::derive_proposal_id(&proposer_acct, &title_hash, &body_hash);
ensure!(!Proposals::<T>::contains_key(id), Error::<T>::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::<T>::StakeInsufficientBalance)?;
ProposalBond::<T>::insert(id, (who.clone(), stake));
let start_block: u64 = <frame_system::Pallet<T>>::block_number()
.saturated_into::<u64>();
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::<T>::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::<T>::insert(id, (recipient.clone(), amount));
Self::deposit_event(Event::TreasuryProposalSubmitted { id, recipient, amount });
Ok(())
}
@@ -1011,7 +1077,13 @@ pub mod pallet {
Error::<T>::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::<T>::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::<T>::contains_key(proposal_id),
Error::<T>::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::<T>::get(proposal_id) == Some((recipient.clone(), amount)),
Error::<T>::TreasuryActionMismatch
);
// Marcar ANTES del transfer (defensa en profundidad; los pallets no
// reentran, pero el marker refleja la intención de gasto atómica).
Disbursed::<T>::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::<T>::get(proposal_id).ok_or(Error::<T>::OverrideNotPending)?;
let quorum_ordinario = VoteQuorumOf::<T>::get(proposal_id).unwrap_or(0);
// Se decide una sola vez: sin ventana, no hay override pendiente.
OverrideDeadline::<T>::remove(proposal_id);
OverrideQuorum::<T>::remove(proposal_id);
VoteQuorumOf::<T>::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::<T>::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<ProposalId, DispatchError> {
ensure!(stake >= MIN_PROPOSAL_STAKE, Error::<T>::StakeInsufficient);
ensure!(
voting_period_blocks <= MAX_VOTING_PERIOD_BLOCKS,
Error::<T>::VotingPeriodTooLong
);
ensure!(
voting_period_blocks >= MIN_VOTING_PERIOD_BLOCKS,
Error::<T>::VotingPeriodTooShort
);
let proposer_acct = AccountId::new(account_bytes::<T>(&who));
let id = crate::derive_proposal_id(&proposer_acct, &title_hash, &body_hash);
ensure!(!Proposals::<T>::contains_key(id), Error::<T>::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::<T>::StakeInsufficientBalance)?;
ProposalBond::<T>::insert(id, (who.clone(), stake));
let start_block: u64 = <frame_system::Pallet<T>>::block_number()
.saturated_into::<u64>();
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::<T>::insert(id, proposal);
// RV17: el quórum se fija ahora, no al decidir (ver `VoteQuorumOf`).
VoteQuorumOf::<T>::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