Files
arkhe-origin-chain/crates/origin-chain-governance/src/benchmarking.rs
T
CharonandClaude Opus 5.5 a694cbd9f0 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>
2026-09-24 12:03:44 -05:00

337 lines
12 KiB
Rust

//! # Benchmarks para `pallet-origin-governance` (FRAME v2)
//!
//! Fuente de verdad de la forma del weight de los 13 extrinsics. Verificado por
//! `impl_benchmark_test_suite!` en el mock (`cargo test -p origin-chain-governance
//! --features runtime-benchmarks`). Números canónicos gated por HW/omni-bencher
//! (ver `weights.rs` §Estado).
//!
//! Los preconditions (proposal en un estado, membresía de council, endosos) se
//! siembran por storage directo en el setup — patrón estándar de benchmarking.
//! Los thresholds con council_size=1 son 1 (veto y emergency), así que un solo
//! miembro/endoso alcanza el umbral.
#![cfg(feature = "runtime-benchmarks")]
use super::*;
use frame_benchmarking::v2::*;
use frame_support::traits::fungible::{Inspect, Mutate};
use frame_support::traits::Get;
use frame_system::RawOrigin;
use origin_chain_types::AccountId;
/// Fondea `who` con holgura para bonds + treasury deposits.
fn fund<T: Config>(who: &T::AccountId) {
let _ = T::Currency::mint_into(who, MIN_PROPOSAL_STAKE.saturating_mul(100));
}
/// Siembra una `Proposal` en el estado/end_block dados (bypass del extrinsic).
fn seed_proposal<T: Config>(id: ProposalId, status: ProposalStatus, end_block: u64) {
let proposal = Proposal {
id,
proposer: AccountId::new([9u8; 32]),
title_hash: [1u8; 32],
body_hash: [2u8; 32],
stake: MIN_PROPOSAL_STAKE,
start_block: 0,
end_block,
status,
yes_weight: 0,
nay_weight: 0,
abstain_weight: 0,
};
Proposals::<T>::insert(id, proposal);
}
/// Añade `who` al council (size=1) por storage directo.
fn seed_council<T: Config>(who: &T::AccountId) {
let member = CouncilMember {
account: AccountId::new([0u8; 32]),
joined_at_block: 0,
role: CouncilRole::Member,
};
Council::<T>::insert(who, member);
CouncilSize::<T>::put(1u32);
}
/// RV15: siembra `n` votos vivos respaldados por `who` (propuestas distintas del
/// id de los benchmarks), con `until` dado. Es el peor caso de PoV: la lista de
/// respaldo se lee y se reescribe entera.
fn seed_backing<T: Config>(who: &T::AccountId, n: u32, until: u64) {
for i in 0..n {
Pallet::<T>::back_vote_with_funds(
who,
ProposalId([100u8.wrapping_add(i as u8); 32]),
BackingKind::Vote,
1_000u128.saturating_add(i as u128),
until,
)
.expect("respaldo sembrado");
}
}
/// Bond real de la propuesta `id`, con fondos en la cuenta-bond: la rama cara
/// del veto y del finalize es la que lo devuelve.
fn seed_bond<T: Config>(id: ProposalId) -> T::AccountId {
let proposer: T::AccountId = account("proposer", 0, 0);
let _ = T::Currency::mint_into(
&Pallet::<T>::proposal_bond_account(),
MIN_PROPOSAL_STAKE.saturating_mul(2),
);
ProposalBond::<T>::insert(id, (proposer.clone(), MIN_PROPOSAL_STAKE));
proposer
}
const EM_TITLE: [u8; 32] = [3u8; 32];
const EM_BODY: [u8; 32] = [4u8; 32];
#[benchmarks]
mod benchmarks {
use super::*;
#[benchmark]
fn submit_proposal() {
let caller: T::AccountId = whitelisted_caller();
fund::<T>(&caller);
#[extrinsic_call]
submit_proposal(RawOrigin::Signed(caller), [1u8; 32], [2u8; 32], 1_000u64, MIN_PROPOSAL_STAKE);
assert_eq!(Proposals::<T>::iter().count(), 1);
}
#[benchmark]
fn cast_vote() {
let caller: T::AccountId = whitelisted_caller();
// RV15: el peso sale del saldo. Peor caso: la cuenta ya respalda
// MaxActiveVotes - 1 votos vivos (lista y freeze que leer y reescribir).
fund::<T>(&caller);
seed_backing::<T>(
&caller,
T::MaxActiveVotes::get().saturating_sub(1),
1_000_000u64,
);
let id = ProposalId([7u8; 32]);
seed_proposal::<T>(id, ProposalStatus::Voting, 1_000_000u64);
#[extrinsic_call]
cast_vote(RawOrigin::Signed(caller.clone()), id, VoteStance::Aye, 100u128);
assert!(Votes::<T>::contains_key(id, &caller));
}
#[benchmark]
fn finalize_proposal() {
let caller: T::AccountId = whitelisted_caller();
let id = ProposalId([7u8; 32]);
seed_proposal::<T>(id, ProposalStatus::Voting, 0u64);
// Rama cara: devuelve el bond (transfer real desde la cuenta-bond).
let proposer = seed_bond::<T>(id);
// current_block debe ser > end_block(0) → avanza a 2.
frame_system::Pallet::<T>::set_block_number(2u32.into());
#[extrinsic_call]
finalize_proposal(RawOrigin::Signed(caller), id);
assert!(!matches!(
Proposals::<T>::get(id).unwrap().status,
ProposalStatus::Voting
));
assert!(!ProposalBond::<T>::contains_key(id));
assert_eq!(T::Currency::balance(&proposer), MIN_PROPOSAL_STAKE);
}
#[benchmark]
fn add_council_member() {
let acct: T::AccountId = account("member", 0, 0);
#[extrinsic_call]
add_council_member(RawOrigin::Root, acct.clone(), CouncilRole::Member);
assert!(Council::<T>::contains_key(&acct));
}
#[benchmark]
fn remove_council_member() {
let acct: T::AccountId = account("member", 0, 0);
seed_council::<T>(&acct);
#[extrinsic_call]
remove_council_member(RawOrigin::Root, acct.clone());
assert!(!Council::<T>::contains_key(&acct));
}
#[benchmark]
fn veto_proposal() {
// Rama cara: council LLENO (7), 4 vetos vivos previos, y el del caller es el
// 5.º que alcanza el umbral 2/3 → recorre todos los vetos, flipa a Rejected,
// abre la ventana de override (RV15) y devuelve el bond.
let caller: T::AccountId = whitelisted_caller();
let id = ProposalId([7u8; 32]);
seed_proposal::<T>(id, ProposalStatus::Voting, 1_000_000u64);
let proposer = seed_bond::<T>(id);
let mut miembros: alloc::vec::Vec<T::AccountId> = (0..MAX_COUNCIL_SIZE - 1)
.map(|i| account("council", i, 0))
.collect();
miembros.push(caller.clone());
for m in &miembros {
Council::<T>::insert(
m,
CouncilMember {
account: AccountId::new([0u8; 32]),
joined_at_block: 0,
role: CouncilRole::Member,
},
);
}
CouncilSize::<T>::put(MAX_COUNCIL_SIZE);
let previos = council_veto_threshold(MAX_COUNCIL_SIZE) - 1;
for m in miembros.iter().take(previos as usize) {
VetoVotes::<T>::insert(id, m, ());
}
#[extrinsic_call]
veto_proposal(RawOrigin::Signed(caller.clone()), id);
assert!(VetoVotes::<T>::contains_key(id, &caller));
assert!(matches!(
Proposals::<T>::get(id).unwrap().status,
ProposalStatus::Rejected
));
assert!(OverrideDeadline::<T>::contains_key(id));
assert_eq!(T::Currency::balance(&proposer), MIN_PROPOSAL_STAKE);
}
#[benchmark]
fn mark_as_advisor() {
let acct: T::AccountId = account("advisor", 0, 0);
#[extrinsic_call]
mark_as_advisor(RawOrigin::Root, acct.clone());
assert!(AdvisorFlags::<T>::contains_key(&acct));
}
#[benchmark]
fn unmark_as_advisor() {
let acct: T::AccountId = account("advisor", 0, 0);
AdvisorFlags::<T>::insert(&acct, ());
#[extrinsic_call]
unmark_as_advisor(RawOrigin::Root, acct.clone());
assert!(!AdvisorFlags::<T>::contains_key(&acct));
}
#[benchmark]
fn override_council_veto() {
let caller: T::AccountId = whitelisted_caller();
let id = ProposalId([7u8; 32]);
seed_proposal::<T>(id, ProposalStatus::Rejected, 0u64);
VetoRejected::<T>::insert(id, ());
// RV15: ventana abierta, peso respaldado por saldo y la lista de respaldo casi
// llena (peor caso). Y rama cara: el override ALCANZA el umbral y aprueba.
OverrideDeadline::<T>::insert(id, 1_000_000u64);
fund::<T>(&caller);
seed_backing::<T>(
&caller,
T::MaxActiveVotes::get().saturating_sub(1),
1_000_000u64,
);
#[extrinsic_call]
override_council_veto(RawOrigin::Signed(caller.clone()), id, MIN_PROPOSAL_STAKE);
assert!(OverrideVotes::<T>::contains_key(id, &caller));
assert!(matches!(
Proposals::<T>::get(id).unwrap().status,
ProposalStatus::Approved
));
}
#[benchmark]
fn submit_emergency_proposal() {
let caller: T::AccountId = whitelisted_caller();
fund::<T>(&caller);
seed_council::<T>(&caller);
// council_size=1 ⇒ emergency threshold=1; un endoso VIVO del caller basta.
let content_id = crate::emergency_content_id(&EM_TITLE, &EM_BODY);
EmergencyEndorsements::<T>::insert(content_id, &caller, ());
#[extrinsic_call]
submit_emergency_proposal(RawOrigin::Signed(caller), EM_TITLE, EM_BODY, MIN_PROPOSAL_STAKE);
assert!(Proposals::<T>::iter().count() >= 1);
}
#[benchmark]
fn treasury_disbursement() {
let id = ProposalId([7u8; 32]);
seed_proposal::<T>(id, ProposalStatus::Approved, 0u64);
let recipient: T::AccountId = account("recipient", 0, 0);
// Fondea la tesorería para que el transfer real tenga de dónde salir.
let _ = T::Currency::mint_into(
&Pallet::<T>::treasury_account(),
MIN_PROPOSAL_STAKE.saturating_mul(10),
);
#[extrinsic_call]
treasury_disbursement(RawOrigin::Root, id, recipient, MIN_PROPOSAL_STAKE);
assert!(Disbursed::<T>::contains_key(id));
}
#[benchmark]
fn treasury_deposit() {
let caller: T::AccountId = whitelisted_caller();
fund::<T>(&caller);
#[extrinsic_call]
treasury_deposit(RawOrigin::Signed(caller), MIN_PROPOSAL_STAKE);
assert!(T::Currency::balance(&Pallet::<T>::treasury_account()) >= MIN_PROPOSAL_STAKE);
}
#[benchmark]
fn endorse_emergency_proposal() {
let caller: T::AccountId = whitelisted_caller();
seed_council::<T>(&caller);
#[extrinsic_call]
endorse_emergency_proposal(RawOrigin::Signed(caller.clone()), EM_TITLE, EM_BODY);
let content_id = crate::emergency_content_id(&EM_TITLE, &EM_BODY);
assert!(EmergencyEndorsements::<T>::contains_key(content_id, &caller));
}
#[benchmark]
fn unlock_vote_funds() {
// Peor caso: lista de respaldo LLENA, la mitad vencida (until = 1) y la otra
// mitad viva → se poda, se recalcula el máximo y se reescribe la lista.
let caller: T::AccountId = whitelisted_caller();
let votante: T::AccountId = account("votante", 0, 0);
fund::<T>(&votante);
let max = T::MaxActiveVotes::get();
let vencidos = max / 2;
seed_backing::<T>(&votante, vencidos, 1u64);
for i in vencidos..max {
Pallet::<T>::back_vote_with_funds(
&votante,
ProposalId([100u8.wrapping_add(i as u8); 32]),
BackingKind::Vote,
500u128,
1_000_000u64,
)
.expect("respaldo vivo");
}
frame_system::Pallet::<T>::set_block_number(2u32.into());
#[extrinsic_call]
unlock_vote_funds(RawOrigin::Signed(caller), votante.clone());
assert_eq!(VoteLocks::<T>::get(&votante).len() as u32, max - vencidos);
}
impl_benchmark_test_suite!(Pallet, crate::mock::new_test_ext(), crate::mock::Test);
}