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:
1 parent
f3de48b1b8
commit
304072173e
3 files changed
+149
-7
No files matched your search
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user