feat(governance): reserva REAL del stake de propuesta — bond og/props (audit A, David)

Decisión A (David 2026-07-04). El audit ultra encontró [HIGH] que el "mínimo
1.000 OGC" de submit_proposal era un número caller-supplied SIN respaldo: una
cuenta vacía podía spamear propuestas ilimitadas, y ese `stake` fabricado era el
`baseline` denominador del override_council_veto (manipulable).

Fix (sin esperar a NPoS — el stake de propuesta es piel-en-el-juego anti-spam,
distinto del voto pesado por stake): el stake se RETIENE de verdad en una cuenta-
bond determinista `og/props` (transfer, Preservation::Preserve → falla si el
proponente no tiene fondos) y se DEVUELVE al proponente cuando la propuesta se
decide. Clave del lifecycle: el release ocurre en AMBAS transiciones fuera de
Voting — finalize_proposal Y veto_proposal — o el bond de una propuesta vetada
quedaría atrapado (no hay withdraw dispatchable). `og/props` separada del vault
para que treasury_disbursement jamás toque un bond.

Enfoque escrow-account: usa solo Currency::transfer (ya en el bound) — cero
cambios de Config/runtime-wiring. submit_proposal + submit_emergency_proposal
ambos bond. +3 regression tests (retiene+devuelve al finalizar, devuelve al
vetar, rechaza si no alcanza). Mock refondeado >= MIN_PROPOSAL_STAKE. 48 gov
tests, workspace 283 verde.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
davidherranandClaude Fable 5 committed 2026-07-05 19:55:39 -05:00
1 parent f3de48b1b8
commit 304072173e
3 files changed
+149 -7

No files matched your search

+77
View File
@@ -336,6 +336,17 @@ pub mod pallet {
pub type Disbursed<T: Config> =
StorageMap<_, Blake2_128Concat, ProposalId, (), OptionQuery>;
/// Bond REAL retenido de cada propuesta viva: `(proponente, monto)`. Audit
/// 2026-07-04: antes el `stake` era caller-supplied SIN respaldo (una cuenta
/// vacía spameaba propuestas gratis, y el `baseline` del override era
/// manipulable). Ahora `submit_proposal` transfiere el stake a la cuenta-bond
/// `og/props` y lo registra aquí; se DEVUELVE al proponente cuando la
/// propuesta se decide (finalize o veto). Presencia = bond aún retenido.
#[pallet::storage]
#[pallet::getter(fn proposal_bond_of)]
pub type ProposalBond<T: Config> =
StorageMap<_, Blake2_128Concat, ProposalId, (T::AccountId, u128), OptionQuery>;
// FIX-6 (V2-CA2/V3-C3): la tesorería ya NO es un contador `u128` desconectado.
// El balance real vive en pallet_balances, en la cuenta-tesorería derivada del
// PalletId (`Self::treasury_account()`). Esto cierra el hallazgo del audit de
@@ -542,12 +553,21 @@ pub mod pallet {
/// Audit 2026-07-04: la propuesta Approved ya fue desembolsada (single-use,
/// anti-replay del treasury_disbursement).
AlreadyDisbursed,
/// Audit 2026-07-04: el proponente NO tiene balance libre para el bond del
/// stake (el stake ahora se retiene de verdad — piel en el juego anti-spam).
StakeInsufficientBalance,
}
/// Minimum stake canonical proposal submission per CHN-4 5.3.
/// 1,000 OGC = 1_000 × 10^18 (18 decimals OGC canonical).
pub const MIN_PROPOSAL_STAKE: u128 = 1_000 * 10u128.pow(18);
/// PalletId de la cuenta-bond de propuestas (`og/props`) — escrow determinista
/// sin llave humana donde vive el stake retenido mientras la propuesta está
/// viva (audit 2026-07-04). Separada del vault de tesorería (`og/gvtry`) para
/// que `treasury_disbursement` JAMÁS pueda gastar un bond de propuesta.
pub const PROPOSAL_BOND_PALLET_ID: PalletId = PalletId(*b"og/props");
/// Dispatchables canonical (real impl v0.0.30).
#[pallet::call]
impl<T: Config> Pallet<T> {
@@ -571,6 +591,20 @@ pub mod pallet {
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);
@@ -699,6 +733,10 @@ pub mod pallet {
Proposals::<T>::insert(proposal_id, proposal);
// Audit 2026-07-04: la propuesta salió de Voting → devuelve el bond al
// proponente (el stake es anti-spam, no un castigo por perder el voto).
Self::release_proposal_bond(proposal_id);
Self::deposit_event(Event::ProposalFinalized {
id: proposal_id,
status: final_status,
@@ -850,6 +888,10 @@ 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, ());
// 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).
Self::release_proposal_bond(proposal_id);
Self::deposit_event(Event::ProposalVetoed {
id: proposal_id,
@@ -1025,6 +1067,17 @@ pub mod pallet {
Error::<T>::ProposalIdCollision
);
// Audit 2026-07-04: bond real, igual que submit_proposal (piel en el
// juego; se devuelve al proponente cuando la propuesta se decide).
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(EMERGENCY_VOTING_PERIOD_BLOCKS);
@@ -1146,6 +1199,30 @@ pub mod pallet {
pub fn treasury_balance() -> u128 {
T::Currency::balance(&Self::treasury_account())
}
/// Cuenta-bond de propuestas (`og/props`) — escrow determinista sin llave
/// donde vive el stake retenido de las propuestas vivas (audit 2026-07-04).
pub fn proposal_bond_account() -> T::AccountId {
PROPOSAL_BOND_PALLET_ID.into_account_truncating()
}
/// 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
/// puede quedar en 0. Un fallo del transfer deja el bond en el escrow (no
/// se pierde) — best-effort para no bloquear la transición de estado.
fn release_proposal_bond(proposal_id: ProposalId) {
if let Some((staker, amount)) = ProposalBond::<T>::take(proposal_id) {
if amount > 0 {
let _ = T::Currency::transfer(
&Self::proposal_bond_account(),
&staker,
amount,
Preservation::Expendable,
);
}
}
}
}
/// Helper canonical para convert T::AccountId → 32-byte canonical.