feat(governance): RV15 — el peso de un voto sale del saldo, y la revision adversaria tumbo la primera version

Cierra L-10. cast_vote y override_council_veto sumaban el stake_weight que
DECLARABA el votante (hueco medido por Palace el 2026-09-06; test en rojo
publicado en d37a278). Ahora:

- El peso no puede superar el OGC LIBRE de la cuenta, y ese OGC queda
  congelado (fungible freeze, FreezeReason::GovernanceVote) mientras el voto
  pueda cambiar un resultado: hasta end_block en un voto normal y hasta el
  cierre de la ventana en un override. El mismo OGC puede respaldar votos en
  propuestas distintas (se solapan, como en Polkadot), pero no puede salir de
  la cuenta: se cierra votar, transferir y votar otra vez desde otra cuenta.
- Un respaldo POR VOTO (VoteBacking: propuesta, tipo, importe, hasta), no uno
  por cuenta. El freeze es siempre el maximo de los votos VIVOS; los vencidos
  se descartan solos al votar o desbloquear. MaxActiveVotes = 32 en el runtime.
- unlock_vote_funds(who) lo puede llamar CUALQUIERA, como el unlock de
  conviction-voting: quien vota con todo su saldo no puede pagar la comision de
  su propio desbloqueo. Libera solo lo que ya no respalda un voto vivo.
- El override tiene ventana: 14 dias desde el veto que ALCANZA el umbral
  (antes estaba abierto para siempre). Sin ventana registrada, falla cerrado.
- voting_period_blocks tiene tope: 28 dias (MAX_VOTING_PERIOD_BLOCKS).
- VoteFundsUnlocked va al FINAL del enum de eventos: no desplaza indices SCALE.
- spec_version 14 -> 15; transaction_version sigue en 1.

La revision adversaria (4 lentes independientes sobre la primera version)
encontro tres fallos serios, y los tres eran mios:
1. El lock unico por cuenta (max importe, max bloque) mas un periodo de voto
   sin tope congelaba OGC PARA SIEMPRE: un voto de 1 unidad en una propuesta de
   periodo u64::MAX arrastraba el voto mayor de la cuenta hasta ese bloque, y ni
   el veto lo liberaba. Lo demostraron tres lentes por separado. Cura: respaldo
   por voto + tope de periodo.
2. Un lock vencido y no desbloqueado revivia con el siguiente voto pequeno.
3. Votar con todo el saldo dejaba la cuenta sin poder pagar la comision del
   unlock, y solo ella podia desbloquearse. Cura: unlock por terceros.
Y seis mutaciones sobrevivian a mi suite, entre ellas congelar el peso del
voto actual en vez del maximo (el propio ataque que el test decia cubrir).

Certificacion: 17 contrafactuales, TODOS en rojo con su pieza quitada
(freeze = peso actual, sin exigir saldo, reducible_balance, total_balance,
unlock en until, sin podar vencidos, ventana desde start_block, ventana 0,
peso 0 con thaw, evento released=0, unlock todo o nada, sin tope de periodo,
unlock solo propio, override hasta end_block, veto sin ventana, sin freeze, y
MaxFreezes=0 en el runtime). Cada test lleva control positivo. Test nuevo con
la configuracion REAL del runtime (preset local_testnet): el voto congela en
pallet-balances, lo congelado no sale, y un tercero lo libera al vencer.

Benchmarks en el PEOR caso real (la revision mostro que median la rama
barata): lista de respaldo casi llena, override que alcanza el umbral, veto de
un council de 7 que devuelve el bond, finalize que devuelve el bond, unlock
con la mitad vencida. Pesos regenerados con frame-omni-bencher (Mac; los
canonicos siguen pendientes del Pi 5, L-6). veto_proposal estaba
subdeclarado ~3x: 36M ps 10r/7w -> 112M 21r/10w, proof 6132 -> 16416.

regen-weights.sh: el blob de bench (runtime-benchmarks) se construye en
target-bench/ DENTRO del repo, nunca en target/release/wbuild, que es de donde
sale un set_code. Lo que debe coincidir con lo desplegado es el commit, no el
blob. Dentro del repo porque substrate-wasm-builder busca el Cargo.lock
subiendo desde el target: fuera no lo encuentra y resuelve dependencias
nuevas (medido).

Fuera de RV15, a RV16 (L-11): el override no es un 4/5 real (el bond que el
veto devuelve y el OGC Aye cuentan en el denominador y en el numerador), el
veto sigue siendo posible tras end_block, y un periodo de voto minimo.
Existian antes de RV15; los demostro la misma revision.

cargo test --workspace: 334 passed, 0 failed, 0 ignored.
cargo test -p origin-chain-governance --features runtime-benchmarks: 79 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CharonandClaude Opus 5.5 committed 2026-09-24 12:03:44 -05:00
1 parent d37a278ba6
commit a694cbd9f0
10 files changed
+1097 -106

No files matched your search

+256 -2
View File
@@ -124,7 +124,7 @@ sp_api::decl_runtime_apis! {
#[frame_support::pallet]
pub mod pallet {
use frame_support::pallet_prelude::*;
use frame_support::traits::fungible::{Inspect, Mutate};
use frame_support::traits::fungible::{freeze, Inspect, Mutate};
use frame_support::traits::tokens::Preservation;
use frame_support::PalletId;
use frame_system::pallet_prelude::*;
@@ -192,6 +192,22 @@ pub mod pallet {
/// 14 days × 24 hours × 600 blocks/hour (@ 6s slot Aura) = 201,600 blocks.
pub const DEFAULT_VOTING_PERIOD_BLOCKS: u64 = 14 * 24 * 600;
/// RV15: ventana para revertir un veto del council, contada desde el bloque en
/// que el veto alcanza su umbral. Mide lo mismo que un voto normal (14 días).
///
/// Antes no había ventana: una propuesta vetada aceptaba votos de override para
/// siempre, así que podía resucitarse años después. Y con peso real (RV15) el
/// OGC que respalda un override tiene que liberarse ALGÚN día: sin ventana, o se
/// congelaba para siempre o el voto dejaba de estar respaldado.
pub const OVERRIDE_WINDOW_BLOCKS: u64 = DEFAULT_VOTING_PERIOD_BLOCKS;
/// RV15: periodo de voto máximo que puede pedir un proponente (28 días).
///
/// Antes era libre hasta `u64::MAX`. Con el voto respaldado por OGC eso era un
/// arma: una propuesta de periodo casi eterno congelaba casi para siempre el OGC
/// de quien votara en ella (demostrado por la revisión adversaria de RV15).
pub const MAX_VOTING_PERIOD_BLOCKS: u64 = 2 * DEFAULT_VOTING_PERIOD_BLOCKS;
/// Emergency voting period canonical canonical 48 hours per CHN-4 5.7.
/// 48 hours × 600 blocks/hour (@ 6s slot Aura) = 28,800 blocks.
pub const EMERGENCY_VOTING_PERIOD_BLOCKS: u64 = 48 * 600;
@@ -208,6 +224,35 @@ pub mod pallet {
(total_stake.saturating_mul(4).saturating_add(4)) / 5
}
/// RV15: qué respalda un tramo de OGC congelado.
#[derive(Encode, Decode, MaxEncodedLen, TypeInfo, Clone, Copy, PartialEq, Eq, RuntimeDebug)]
pub enum BackingKind {
/// Un voto del periodo normal (`cast_vote`).
Vote,
/// Un voto para revertir un veto (`override_council_veto`).
Override,
}
/// RV15: el OGC que respalda UN voto vivo.
///
/// Un registro por voto, no uno por cuenta. La primera versión fusionaba todo
/// en un único lock (máximo importe hasta el bloque más tardío) y la revisión
/// adversaria demostró por qué no sirve: un voto mínimo en una propuesta de
/// periodo enorme arrastraba el voto MAYOR de la cuenta hasta ese bloque, para
/// siempre, y un lock vencido revivía con el siguiente voto. Con un registro por
/// voto, cada tramo vence solo y el freeze es siempre el MÁXIMO DE LOS VIVOS.
#[derive(Encode, Decode, MaxEncodedLen, TypeInfo, Clone, Copy, PartialEq, Eq, RuntimeDebug)]
pub struct VoteBacking {
/// Propuesta a la que respalda.
pub proposal: ProposalId,
/// Voto normal u override.
pub kind: BackingKind,
/// OGC que respalda este voto.
pub amount: u128,
/// Último bloque (inclusive) en que este voto aún puede cambiar el resultado.
pub until: u64,
}
/// Proposal canonical struct stored on-chain per pallet (Foundation OIP).
#[derive(
Encode, Decode, DecodeWithMemTracking, MaxEncodedLen, TypeInfo, Clone, PartialEq, Eq,
@@ -248,7 +293,21 @@ pub mod pallet {
/// FIX-6 (V2-CA2/V3-C3): Currency real (fungible) para mover OGC de la
/// tesorería. La tesorería deja de ser un contador u128 y pasa a ser una
/// cuenta real respaldada por pallet_balances.
type Currency: Inspect<Self::AccountId, Balance = u128> + Mutate<Self::AccountId>;
///
/// RV15: además congela (`freeze`) el OGC que respalda un voto, para que el
/// mismo OGC no pueda votar dos veces pasando por una transferencia.
type Currency: Inspect<Self::AccountId, Balance = u128>
+ Mutate<Self::AccountId>
+ freeze::Mutate<Self::AccountId, Id = Self::RuntimeFreezeReason>;
/// RV15: el enum agregado de razones de congelación del runtime.
type RuntimeFreezeReason: From<FreezeReason>;
/// RV15: cuántos votos VIVOS puede respaldar una cuenta a la vez. Los
/// vencidos se descartan solos al votar o desbloquear, así que el límite es
/// de votos simultáneos, no históricos.
#[pallet::constant]
type MaxActiveVotes: Get<u32>;
/// 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
@@ -271,6 +330,33 @@ pub mod pallet {
#[pallet::storage_version(STORAGE_VERSION)]
pub struct Pallet<T>(_);
/// RV15: por qué este pallet congela OGC.
#[pallet::composite_enum]
pub enum FreezeReason {
/// OGC que respalda un voto (o un override) que todavía puede cambiar un
/// resultado.
#[codec(index = 0)]
GovernanceVote,
}
/// RV15: los votos vivos que respalda cada cuenta (ver [`VoteBacking`]). El
/// freeze `GovernanceVote` de la cuenta es el máximo de sus `amount`.
#[pallet::storage]
pub type VoteLocks<T: Config> = StorageMap<
_,
Blake2_128Concat,
T::AccountId,
BoundedVec<VoteBacking, T::MaxActiveVotes>,
ValueQuery,
>;
/// RV15: último bloque (inclusive) en que se acepta un override sobre una
/// propuesta vetada. Se escribe cuando el veto alcanza su umbral. Sin entrada,
/// no hay override posible: falla cerrado.
#[pallet::storage]
pub type OverrideDeadline<T: Config> =
StorageMap<_, Blake2_128Concat, ProposalId, u64, OptionQuery>;
/// CHN-4 canonical: `Proposals<StorageMap<ProposalId, Proposal>>` canonical.
#[pallet::storage]
#[pallet::getter(fn proposal_of)]
@@ -557,6 +643,17 @@ pub mod pallet {
/// Treasury balance canonical post-disbursement.
remaining_balance: u128,
},
/// RV15: se liberó OGC que respaldaba votos que ya no pueden cambiar un
/// resultado. Va AL FINAL del enum a propósito: insertarlo en medio desplazaba
/// el índice SCALE de tres eventos existentes (revisión adversaria de RV15).
VoteFundsUnlocked {
/// Cuenta cuyo OGC se libera.
who: T::AccountId,
/// OGC que deja de estar congelado.
released: u128,
/// OGC que sigue congelado por votos todavía vivos.
still_frozen: u128,
},
}
/// Pallet errors canonical.
@@ -616,6 +713,22 @@ pub mod pallet {
/// Audit 2026-07-04 (B): el council member ya endosó esta propuesta de
/// emergencia (anti doble-endoso).
AlreadyEndorsed,
/// RV15: el peso del voto supera el OGC de la cuenta. El peso sale del
/// saldo, no de lo que declara el votante (hueco medido por Palace el
/// 2026-09-06).
VoteWeightExceedsBalance,
/// RV15: la ventana para revertir este veto ya cerró (o la propuesta no
/// tiene ventana registrada).
OverrideWindowClosed,
/// RV15: la cuenta no tiene OGC congelado por votos.
NoVoteLock,
/// RV15: ninguno de los votos de la cuenta ha vencido todavía; su OGC sigue
/// congelado.
VoteFundsStillLocked,
/// RV15: el periodo de voto pedido supera `MAX_VOTING_PERIOD_BLOCKS`.
VotingPeriodTooLong,
/// RV15: la cuenta ya respalda `MaxActiveVotes` votos vivos.
TooManyActiveVotes,
}
/// Minimum stake canonical proposal submission per CHN-4 5.3.
@@ -646,6 +759,10 @@ pub mod pallet {
) -> DispatchResult {
let who = ensure_signed(origin)?;
ensure!(stake >= MIN_PROPOSAL_STAKE, Error::<T>::StakeInsufficient);
ensure!(
voting_period_blocks <= MAX_VOTING_PERIOD_BLOCKS,
Error::<T>::VotingPeriodTooLong
);
let proposer_acct = AccountId::new(account_bytes::<T>(&who));
let id = crate::derive_proposal_id(&proposer_acct, &title_hash, &body_hash);
@@ -731,6 +848,17 @@ pub mod pallet {
Error::<T>::AlreadyVoted
);
// RV15: el peso sale del saldo y queda congelado hasta que el voto ya no
// pueda cambiar el resultado (fin del periodo de voto). Sin el freeze,
// el mismo OGC votaría otra vez desde otra cuenta tras una transferencia.
Self::back_vote_with_funds(
&who,
proposal_id,
BackingKind::Vote,
stake_weight,
proposal.end_block,
)?;
match stance {
VoteStance::Aye => {
proposal.yes_weight = proposal.yes_weight.saturating_add(stake_weight)
@@ -948,6 +1076,11 @@ pub mod pallet {
// Marca: rechazado por VETO del council (no por voto popular) →
// única vía elegible para override_council_veto.
VetoRejected::<T>::insert(proposal_id, ());
// RV15: el override tiene ventana, contada desde este bloque.
OverrideDeadline::<T>::insert(
proposal_id,
current_block_u64::<T>().saturating_add(OVERRIDE_WINDOW_BLOCKS),
);
// Audit 2026-07-04: la propuesta salió de Voting (vetada) → devuelve
// el bond al proponente (si no, el stake de una propuesta vetada
// quedaría atrapado para siempre — no hay withdraw dispatchable).
@@ -1060,6 +1193,22 @@ pub mod pallet {
Error::<T>::AlreadyOverrideVoted
);
// RV15: el override tiene ventana, y su peso sale del saldo y queda
// congelado hasta que la ventana cierre.
let deadline =
OverrideDeadline::<T>::get(proposal_id).ok_or(Error::<T>::OverrideWindowClosed)?;
ensure!(
current_block_u64::<T>() <= deadline,
Error::<T>::OverrideWindowClosed
);
Self::back_vote_with_funds(
&who,
proposal_id,
BackingKind::Override,
stake_weight,
deadline,
)?;
OverrideVotes::<T>::insert(proposal_id, &who, stake_weight);
let new_total = OverrideStakeTotal::<T>::get(proposal_id)
.saturating_add(stake_weight);
@@ -1296,6 +1445,111 @@ pub mod pallet {
});
Ok(())
}
/// RV15: libera el OGC que respaldaba votos de `who` que ya no pueden cambiar
/// un resultado (pasó su `until`). Lo que respalda votos todavía vivos sigue
/// congelado.
///
/// Lo puede llamar CUALQUIERA, como `unlock` de `pallet-conviction-voting`: la
/// condición es objetiva, y quien votó con todo su saldo no puede pagar la
/// comisión de su propio desbloqueo (revisión adversaria de RV15).
#[pallet::call_index(13)]
#[pallet::weight(T::WeightInfo::unlock_vote_funds())]
pub fn unlock_vote_funds(origin: OriginFor<T>, who: T::AccountId) -> DispatchResult {
ensure_signed(origin)?;
let mut backing = VoteLocks::<T>::get(&who);
ensure!(!backing.is_empty(), Error::<T>::NoVoteLock);
let before = max_backing(&backing);
let now = current_block_u64::<T>();
let n = backing.len();
backing.retain(|b| b.until >= now);
ensure!(backing.len() < n, Error::<T>::VoteFundsStillLocked);
let after = max_backing(&backing);
Self::apply_vote_freeze(&who, backing, after)?;
Self::deposit_event(Event::VoteFundsUnlocked {
who,
released: before.saturating_sub(after),
still_frozen: after,
});
Ok(())
}
}
/// El mayor OGC respaldado por una lista de votos (0 si está vacía).
fn max_backing(backing: &[VoteBacking]) -> u128 {
backing.iter().map(|b| b.amount).max().unwrap_or(0)
}
/// Bloque actual como `u64` (el tipo de `end_block` y de las ventanas).
fn current_block_u64<T: Config>() -> u64 {
<frame_system::Pallet<T>>::block_number().saturated_into::<u64>()
}
/// RV15 — el OGC que respalda un voto.
impl<T: Config> Pallet<T> {
/// Exige que `weight` no supere el OGC LIBRE de `who` y lo deja congelado
/// mientras este voto pueda cambiar un resultado (hasta `until`).
///
/// Antes descarta los respaldos ya vencidos de la cuenta, así que un voto
/// muerto nunca vuelve a congelarse. El freeze resultante es el MÁXIMO de los
/// votos vivos: el mismo OGC puede respaldar votos en propuestas distintas
/// (se solapan, como en Polkadot), pero no puede salir de la cuenta mientras
/// un voto que respalda siga vivo. Eso cierra votar, transferir y votar otra
/// vez desde otra cuenta.
///
/// Un peso 0 no congela nada ni toca el respaldo que ya hubiera.
///
/// Límite conocido: el freeze de `pallet-balances` se cumple sobre libre +
/// retenido. Si la cuenta tiene OGC en hold (staking), lo retenido puede
/// sostener el freeze mientras lo libre sale: en la práctica el OGC en hold
/// termina respaldando el voto, que es la semántica de Polkadot. No infla el
/// total: ninguna cuenta respalda más de lo que posee.
pub(crate) fn back_vote_with_funds(
who: &T::AccountId,
proposal: ProposalId,
kind: BackingKind,
weight: u128,
until: u64,
) -> DispatchResult {
ensure!(
weight <= T::Currency::balance(who),
Error::<T>::VoteWeightExceedsBalance
);
if weight == 0 {
return Ok(());
}
let now = current_block_u64::<T>();
let mut backing = VoteLocks::<T>::get(who);
backing.retain(|b| b.until >= now);
backing
.try_push(VoteBacking {
proposal,
kind,
amount: weight,
until,
})
.map_err(|_| Error::<T>::TooManyActiveVotes)?;
let frozen = max_backing(&backing);
Self::apply_vote_freeze(who, backing, frozen)
}
/// Deja el freeze `GovernanceVote` de `who` en `frozen` y guarda su respaldo;
/// sin votos vivos, descongela y borra el registro.
fn apply_vote_freeze(
who: &T::AccountId,
backing: BoundedVec<VoteBacking, T::MaxActiveVotes>,
frozen: u128,
) -> DispatchResult {
let reason = FreezeReason::GovernanceVote.into();
if backing.is_empty() {
<T::Currency as freeze::Mutate<T::AccountId>>::thaw(&reason, who)?;
VoteLocks::<T>::remove(who);
} else {
<T::Currency as freeze::Mutate<T::AccountId>>::set_freeze(&reason, who, frozen)?;
VoteLocks::<T>::insert(who, backing);
}
Ok(())
}
}
/// FIX-6 — helpers de la tesorería real (cuenta respaldada por pallet_balances).