feat(economía): techo duro 1B + fees con dientes (David Level A 2026-07-03, RV 6→7)

Dos decisiones económicas ratificadas por David, verificadas en fuente:

1. TECHO DURO 1B (Opción A) — la emisión TRANSFIERE del pool Ecosystem finito
   (og/ecosy, 400M pre-asignado en génesis), JAMÁS mint_into. La aserción de
   compilación de 1.000M deja de ser promesa de génesis y se vuelve ley que la
   producción de bloques obedece: el total issuance nunca crece, el pool se
   agota en ~7.6 años (10 OGC/bloque), después economía circular pura. Cuando el
   pool no cubre el reward completo, libera el remanente y emite
   EcosystemPoolDepleted una vez. Evento renombrado BlockRewardMinted →
   BlockRewardReleased (honestidad: es redistribución, no acuñación).
   - origin-chain-emission: Currency.transfer (Expendable) desde ecosystem_account;
     +EcosystemPalletId config, +DepletionAnnounced storage one-shot, +2 tests
     (hard_cap_pool_se_agota_y_para, depletion_event_se_anuncia_una_sola_vez).
   - runtime: EmissionEcosystemPalletId (og/ecosy) wired; génesis asigna el 40%
     a la cuenta PalletId determinista (sin llave humana), no al placeholder ECO!.

2. FEES CON DIENTES — WeightToFee/LengthToFee de IdentityFee (polvo ~10^-10 OGC,
   #16 = adorno) a ConstantMultiplier calibrado: un transfer simple ≈ 1 MILLI_OGC
   (0.001 OGC). Consecuencias: el descuento Pi5 (0.1×) = ahorro real (0.0001 OGC);
   la quema del 100% de gas es deflación medible desde el testnet; el spam cuesta
   OGC real. Factor 7e6 (weight) + 1e11 (length). Test de regresión
   weight_to_fee_transfer_tiene_dientes_no_polvo blinda contra revertir a polvo.
   Calibrado a pesos placeholder — re-tunear con benchmarks reales (leftover FIX-2).

RUNTIME_VERSION 6→7. Workspace 275 tests verde, 0 fallos. TRANSACTION_VERSION intacto.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
davidherranandClaude Fable 5 committed 2026-07-03 11:58:55 -05:00
1 parent 7b8863fd47
commit 1fdc47e859
4 files changed
+282 -65

No files matched your search

+200 -46
View File
@@ -1,16 +1,26 @@
//! # pallet-origin-emission — FIX-6 Stage 1: emisión que paga a los validadores //! # pallet-origin-emission — FIX-6 Stage 1: emisión que paga a los validadores
//! //!
//! Cierra el crítico V2-CA2 del audit: la cadena NO tenía emisión (Aura PoA //! Cierra el crítico V2-CA2 del audit: la cadena NO tenía emisión (Aura PoA
//! mintea 0), así que los validadores ganaban **0 OGC**. Ahora, en cada bloque, //! mintea 0), así que los validadores ganaban **0 OGC**. En cada bloque se
//! se mintea un `BlockReward` y se reparte: //! **libera** un `BlockReward` **desde el pool Ecosystem** (el 40% = 400M
//! pre-asignado en génesis) y se reparte:
//! //!
//! - **Autor** (el validador Aura que produjo el bloque) — el grueso. //! - **Autor** (el validador Aura que produjo el bloque) — el grueso.
//! - **Treasury** (la cuenta-tesorería de governance, og/gvtry) — financia DAO. //! - **Treasury** (la cuenta-tesorería de governance, og/gvtry) — financia DAO.
//! - **Subsidy pool** (proof-of-equity, og/subsy) — financia el subsidio de //! - **Subsidy pool** (proof-of-equity, og/subsy) — financia el subsidio de
//! hardware anti-clasista (Inmutable #16). //! hardware anti-clasista (Inmutable #16).
//! //!
//! La inflación resultante (~5% anual con el reward calibrado) materializa la //! ## TECHO DURO 1B (David ratificó 2026-07-03, Opción A)
//! emisión del BM V2 (40% Ecosystem distribuido en 10-15 años). //!
//! La emisión **TRANSFIERE del pool Ecosystem finito, NUNCA `mint_into`**. La
//! aserción de compilación fija la oferta en 1.000M OGC; mintear violaría esa
//! constitución matemática. Por eso las "recompensas" son en realidad una
//! **liberación programada y finita**: el pool de 400M se agota matemáticamente
//! en ~10-15 años (10 OGC/bloque × ~5.256M bloques/año ≈ 52.6M/año), y a partir
//! de ahí los validadores se sostienen puramente del reciclaje de la economía
//! circular (fees, marketplace). El total issuance JAMÁS crece — el BM V2 se
//! cumple a rajatabla. Cuando el pool no cubre el reward completo, se libera lo
//! que quede y se emite `EcosystemPoolDepleted` una vez.
//! //!
//! ## sub_mult ORTOGONAL (ratificado David 2026-06-10) //! ## sub_mult ORTOGONAL (ratificado David 2026-06-10)
//! //!
@@ -44,7 +54,8 @@ extern crate alloc;
#[frame_support::pallet] #[frame_support::pallet]
pub mod pallet { pub mod pallet {
use frame_support::pallet_prelude::*; use frame_support::pallet_prelude::*;
use frame_support::traits::fungible::Mutate; use frame_support::traits::fungible::{Inspect, Mutate};
use frame_support::traits::tokens::{Fortitude, Preservation};
use frame_support::traits::FindAuthor; use frame_support::traits::FindAuthor;
use frame_support::PalletId; use frame_support::PalletId;
use frame_system::pallet_prelude::*; use frame_system::pallet_prelude::*;
@@ -60,17 +71,26 @@ pub mod pallet {
type RuntimeEvent: From<Event<Self>> type RuntimeEvent: From<Event<Self>>
+ IsType<<Self as frame_system::Config>::RuntimeEvent>; + IsType<<Self as frame_system::Config>::RuntimeEvent>;
/// Currency que se mintea (la emisión aumenta el total issuance). /// Currency de la que se LIBERA (transfer desde el pool Ecosystem). El
/// bound `Mutate` da `transfer`; el pallet JAMÁS llama `mint_into` — el
/// techo duro de 1B se preserva porque la emisión mueve OGC existentes,
/// no acuña nuevos (David 2026-07-03, Opción A).
type Currency: Mutate<Self::AccountId, Balance = u128>; type Currency: Mutate<Self::AccountId, Balance = u128>;
/// Cómo encontrar el autor del bloque. En el runtime se cablea con la /// Cómo encontrar el autor del bloque. En el runtime se cablea con la
/// FindAuthor de Aura (pallet_session::FindAccountFromAuthorIndex). /// FindAuthor de Aura (pallet_session::FindAccountFromAuthorIndex).
type FindAuthor: FindAuthor<Self::AccountId>; type FindAuthor: FindAuthor<Self::AccountId>;
/// Recompensa por bloque en base units OGC (calibra la inflación ~5%/año). /// Recompensa por bloque en base units OGC (calibra el ritmo de
/// liberación; el pool finito lo agota en ~10-15 años).
#[pallet::constant] #[pallet::constant]
type BlockReward: Get<u128>; type BlockReward: Get<u128>;
/// PalletId del pool Ecosystem (40% = 400M pre-asignado en génesis). La
/// fuente FINITA de la que se liberan las recompensas de bloque.
#[pallet::constant]
type EcosystemPalletId: Get<PalletId>;
/// Share del autor en basis points (10_000 = 100%). E.g. 7_000 = 70%. /// Share del autor en basis points (10_000 = 100%). E.g. 7_000 = 70%.
#[pallet::constant] #[pallet::constant]
type AuthorShareBps: Get<u16>; type AuthorShareBps: Get<u16>;
@@ -94,11 +114,21 @@ pub mod pallet {
#[pallet::storage_version(STORAGE_VERSION)] #[pallet::storage_version(STORAGE_VERSION)]
pub struct Pallet<T>(_); pub struct Pallet<T>(_);
/// Total OGC emitido acumulado (tracking de inflación on-chain). /// Total OGC liberado acumulado del pool Ecosystem (tracking on-chain del
/// avance hacia el agotamiento del techo duro; NO es inflación — es
/// redistribución de OGC pre-existentes).
#[pallet::storage] #[pallet::storage]
#[pallet::getter(fn total_emitted)] #[pallet::getter(fn total_emitted)]
pub type TotalEmitted<T> = StorageValue<_, u128, ValueQuery>; pub type TotalEmitted<T> = StorageValue<_, u128, ValueQuery>;
/// Flag one-shot: `true` una vez que el pool Ecosystem dejó de cubrir un
/// `BlockReward` completo (techo duro alcanzado). Evita re-emitir el evento
/// `EcosystemPoolDepleted` cada bloque. Default `false` (Rust bool default,
/// sin migración — como `EmissionPaused`).
#[pallet::storage]
#[pallet::getter(fn depletion_announced)]
pub type DepletionAnnounced<T> = StorageValue<_, bool, ValueQuery>;
/// Kill-switch de governance (Root) para la emisión per-block. `false` = /// Kill-switch de governance (Root) para la emisión per-block. `false` =
/// emisión ACTIVA (default — el Rust default de `bool` lo da gratis, sin /// emisión ACTIVA (default — el Rust default de `bool` lo da gratis, sin
/// `OnEmpty` ni migración). `true` = pausada. La activación de NPoS lo pone en /// `OnEmpty` ni migración). `true` = pausada. La activación de NPoS lo pone en
@@ -113,18 +143,27 @@ pub mod pallet {
#[pallet::event] #[pallet::event]
#[pallet::generate_deposit(pub(super) fn deposit_event)] #[pallet::generate_deposit(pub(super) fn deposit_event)]
pub enum Event<T: Config> { pub enum Event<T: Config> {
/// Block reward minteado y repartido autor/treasury/subsidy. /// Block reward LIBERADO del pool Ecosystem y repartido autor/treasury/
BlockRewardMinted { /// subsidy (transfer, no mint — techo duro 1B intacto).
BlockRewardReleased {
/// El validador Aura autor del bloque. /// El validador Aura autor del bloque.
author: T::AccountId, author: T::AccountId,
/// Monto minteado al autor. /// Monto liberado al autor.
author_amount: u128, author_amount: u128,
/// Monto minteado al treasury. /// Monto liberado al treasury.
treasury_amount: u128, treasury_amount: u128,
/// Monto minteado al subsidy pool. /// Monto liberado al subsidy pool.
subsidy_amount: u128, subsidy_amount: u128,
}, },
/// El kill-switch de emisión cambió (Root). `paused=true` detiene el mint. /// El pool Ecosystem se agotó (techo duro alcanzado): dejó de cubrir un
/// `BlockReward` completo. Se emite UNA vez. A partir de aquí los
/// validadores se sostienen de la economía circular (fees/marketplace).
EcosystemPoolDepleted {
/// Total liberado del pool a lo largo de su vida (~400M).
total_released: u128,
},
/// El kill-switch de emisión cambió (Root). `paused=true` detiene la
/// liberación por bloque.
EmissionPauseChanged { EmissionPauseChanged {
/// Nuevo estado: `true` = emisión pausada, `false` = activa. /// Nuevo estado: `true` = emisión pausada, `false` = activa.
paused: bool, paused: bool,
@@ -145,7 +184,7 @@ pub mod pallet {
/// mintea el BlockReward y lo reparte. Si no hay autor (e.g. bloque de /// mintea el BlockReward y lo reparte. Si no hay autor (e.g. bloque de
/// inherentes sin pre-digest), no mintea. /// inherentes sin pre-digest), no mintea.
fn on_initialize(_n: BlockNumberFor<T>) -> Weight { fn on_initialize(_n: BlockNumberFor<T>) -> Weight {
// Kill-switch (governance): si la emisión está pausada, no mintea nada. // Kill-switch (governance): si la emisión está pausada, no libera nada.
// Lee 1 storage value; default `false` ⇒ activa (cero regresión). // Lee 1 storage value; default `false` ⇒ activa (cero regresión).
if EmissionPaused::<T>::get() { if EmissionPaused::<T>::get() {
return T::DbWeight::get().reads(1); return T::DbWeight::get().reads(1);
@@ -153,7 +192,16 @@ pub mod pallet {
let digest = <frame_system::Pallet<T>>::digest(); let digest = <frame_system::Pallet<T>>::digest();
let pre_runtime = digest.logs.iter().filter_map(|d| d.as_pre_runtime()); let pre_runtime = digest.logs.iter().filter_map(|d| d.as_pre_runtime());
if let Some(author) = T::FindAuthor::find_author(pre_runtime) { if let Some(author) = T::FindAuthor::find_author(pre_runtime) {
let reward = T::BlockReward::get(); let pool = Self::ecosystem_account();
// TECHO DURO 1B: liberamos del pool FINITO, jamás minteamos.
// Expendable = el pool puede vaciarse hasta 0 (es su destino en
// ~10-15 años). Liberamos hasta lo que quede disponible.
let available = T::Currency::reducible_balance(
&pool,
Preservation::Expendable,
Fortitude::Polite,
);
let reward = T::BlockReward::get().min(available);
if reward > 0 { if reward > 0 {
let author_amount = let author_amount =
reward.saturating_mul(T::AuthorShareBps::get() as u128) / 10_000; reward.saturating_mul(T::AuthorShareBps::get() as u128) / 10_000;
@@ -164,36 +212,58 @@ pub mod pallet {
.saturating_sub(author_amount) .saturating_sub(author_amount)
.saturating_sub(treasury_amount); .saturating_sub(treasury_amount);
// mint_into aumenta el total issuance (emisión real). Puede // transfer NO cambia el total issuance (redistribuye OGC
// fallar (e.g. destino por debajo del existential deposit): // existentes del pool). Una fracción no entregable (e.g.
// TotalEmitted y el evento SOLO registran lo que de verdad // destino bajo ED) se QUEDA en el pool — no se pierde ni se
// se acuñó, o la contabilidad miente (audit ultra 2026-07-02). // acuña. TotalEmitted y el evento registran SOLO lo movido.
let author_minted = let author_moved = T::Currency::transfer(
T::Currency::mint_into(&author, author_amount).unwrap_or_default(); &pool,
let treasury_minted = &author,
T::Currency::mint_into(&Self::treasury_account(), treasury_amount) author_amount,
.unwrap_or_default(); Preservation::Expendable,
let subsidy_minted = )
T::Currency::mint_into(&Self::subsidy_account(), subsidy_amount) .unwrap_or_default();
.unwrap_or_default(); let treasury_moved = T::Currency::transfer(
&pool,
&Self::treasury_account(),
treasury_amount,
Preservation::Expendable,
)
.unwrap_or_default();
let subsidy_moved = T::Currency::transfer(
&pool,
&Self::subsidy_account(),
subsidy_amount,
Preservation::Expendable,
)
.unwrap_or_default();
let total_minted = author_minted let released = author_moved
.saturating_add(treasury_minted) .saturating_add(treasury_moved)
.saturating_add(subsidy_minted); .saturating_add(subsidy_moved);
if total_minted > 0 { if released > 0 {
TotalEmitted::<T>::mutate(|t| *t = t.saturating_add(total_minted)); TotalEmitted::<T>::mutate(|t| *t = t.saturating_add(released));
Self::deposit_event(Event::BlockRewardMinted { Self::deposit_event(Event::BlockRewardReleased {
author, author,
author_amount: author_minted, author_amount: author_moved,
treasury_amount: treasury_minted, treasury_amount: treasury_moved,
subsidy_amount: subsidy_minted, subsidy_amount: subsidy_moved,
}); });
} }
} }
// Anuncio one-shot: el pool ya no cubre un BlockReward completo
// (techo duro alcanzado). A partir de aquí, economía circular.
if available < T::BlockReward::get() && !DepletionAnnounced::<T>::get() {
DepletionAnnounced::<T>::put(true);
Self::deposit_event(Event::EcosystemPoolDepleted {
total_released: TotalEmitted::<T>::get(),
});
}
} }
// Placeholder de minteo (pendiente benchmarking real, leftover FIX-2) + // Placeholder de weight (pendiente benchmarking real, leftover FIX-2):
// la lectura de EmissionPaused que el kill-switch añadió a este path. // reads = EmissionPaused + pool balance + DepletionAnnounced; los 3
Weight::from_parts(30_000, 0).saturating_add(T::DbWeight::get().reads(1)) // transfers dominan (read+write ×2 cuentas c/u) + TotalEmitted.
T::DbWeight::get().reads_writes(3, 6)
} }
} }
@@ -219,6 +289,12 @@ pub mod pallet {
} }
impl<T: Config> Pallet<T> { impl<T: Config> Pallet<T> {
/// Cuenta del pool Ecosystem (40% = 400M en génesis) — la fuente FINITA
/// de la que se liberan las recompensas de bloque (techo duro 1B).
pub fn ecosystem_account() -> T::AccountId {
T::EcosystemPalletId::get().into_account_truncating()
}
/// Cuenta-tesorería (vault) — la MISMA que governance (og/gvtry). /// Cuenta-tesorería (vault) — la MISMA que governance (og/gvtry).
pub fn treasury_account() -> T::AccountId { pub fn treasury_account() -> T::AccountId {
T::TreasuryPalletId::get().into_account_truncating() T::TreasuryPalletId::get().into_account_truncating()
@@ -240,6 +316,7 @@ mod tests {
use frame_support::{ use frame_support::{
assert_noop, assert_ok, construct_runtime, derive_impl, parameter_types, PalletId, assert_noop, assert_ok, construct_runtime, derive_impl, parameter_types, PalletId,
}; };
use sp_runtime::traits::AccountIdConversion;
use sp_runtime::{AccountId32, BuildStorage, DispatchError}; use sp_runtime::{AccountId32, BuildStorage, DispatchError};
type Block = frame_system::mocking::MockBlock<Test>; type Block = frame_system::mocking::MockBlock<Test>;
@@ -281,6 +358,7 @@ mod tests {
pub const BlockRewardCfg: u128 = 1_000; pub const BlockRewardCfg: u128 = 1_000;
pub const AuthorShareBpsCfg: u16 = 7_000; // 70% pub const AuthorShareBpsCfg: u16 = 7_000; // 70%
pub const TreasuryShareBpsCfg: u16 = 2_000; // 20% (subsidy = 10% residual) pub const TreasuryShareBpsCfg: u16 = 2_000; // 20% (subsidy = 10% residual)
pub const EcosystemPid: PalletId = PalletId(*b"og/ecosy");
pub const TreasuryPid: PalletId = PalletId(*b"og/gvtry"); pub const TreasuryPid: PalletId = PalletId(*b"og/gvtry");
pub const SubsidyPid: PalletId = PalletId(*b"og/subsy"); pub const SubsidyPid: PalletId = PalletId(*b"og/subsy");
} }
@@ -292,53 +370,83 @@ mod tests {
type BlockReward = BlockRewardCfg; type BlockReward = BlockRewardCfg;
type AuthorShareBps = AuthorShareBpsCfg; type AuthorShareBps = AuthorShareBpsCfg;
type TreasuryShareBps = TreasuryShareBpsCfg; type TreasuryShareBps = TreasuryShareBpsCfg;
type EcosystemPalletId = EcosystemPid;
type TreasuryPalletId = TreasuryPid; type TreasuryPalletId = TreasuryPid;
type SubsidyPalletId = SubsidyPid; type SubsidyPalletId = SubsidyPid;
} }
fn new_test_ext() -> sp_io::TestExternalities { fn ecosystem_account() -> AccountId32 {
let storage = frame_system::GenesisConfig::<Test>::default() EcosystemPid::get().into_account_truncating()
}
/// Fondea el pool Ecosystem con `pool_balance` en génesis (la fuente finita
/// de la que se libera; sin esto, transfer no mueve nada — techo duro).
fn new_test_ext_with_pool(pool_balance: u128) -> sp_io::TestExternalities {
let mut storage = frame_system::GenesisConfig::<Test>::default()
.build_storage() .build_storage()
.expect("genesis builds"); .expect("genesis builds");
pallet_balances::GenesisConfig::<Test> {
balances: vec![(ecosystem_account(), pool_balance)],
..Default::default()
}
.assimilate_storage(&mut storage)
.expect("balances genesis");
let mut ext: sp_io::TestExternalities = storage.into(); let mut ext: sp_io::TestExternalities = storage.into();
ext.execute_with(|| System::set_block_number(1)); ext.execute_with(|| System::set_block_number(1));
ext ext
} }
/// Default: pool amplio (1M) — cubre miles de bloques sin agotarse.
fn new_test_ext() -> sp_io::TestExternalities {
new_test_ext_with_pool(1_000_000)
}
fn author() -> AccountId32 { fn author() -> AccountId32 {
AccountId32::new([1u8; 32]) AccountId32::new([1u8; 32])
} }
#[test] #[test]
fn block_reward_minted_and_split_correctly() { fn block_reward_released_and_split_correctly() {
new_test_ext().execute_with(|| { new_test_ext().execute_with(|| {
let pool = Emission::ecosystem_account();
let treasury = Emission::treasury_account(); let treasury = Emission::treasury_account();
let subsidy = Emission::subsidy_account(); let subsidy = Emission::subsidy_account();
// Pre: autor/treasury/subsidy en 0 (no genesis-funded). // Pre: pool 1M, autor/treasury/subsidy en 0.
assert_eq!(<Balances as Inspect<_>>::balance(&pool), 1_000_000);
assert_eq!(<Balances as Inspect<_>>::balance(&author()), 0); assert_eq!(<Balances as Inspect<_>>::balance(&author()), 0);
assert_eq!(Emission::total_emitted(), 0); assert_eq!(Emission::total_emitted(), 0);
let issuance_before = <Balances as Inspect<_>>::total_issuance();
// Un bloque: mintea 1000 → autor 700, treasury 200, subsidy 100. // Un bloque: libera 1000 del pool → autor 700, treasury 200, subsidy 100.
<Emission as Hooks<u64>>::on_initialize(1); <Emission as Hooks<u64>>::on_initialize(1);
assert_eq!(<Balances as Inspect<_>>::balance(&author()), 700); assert_eq!(<Balances as Inspect<_>>::balance(&author()), 700);
assert_eq!(<Balances as Inspect<_>>::balance(&treasury), 200); assert_eq!(<Balances as Inspect<_>>::balance(&treasury), 200);
assert_eq!(<Balances as Inspect<_>>::balance(&subsidy), 100); assert_eq!(<Balances as Inspect<_>>::balance(&subsidy), 100);
assert_eq!(Emission::total_emitted(), 1000); assert_eq!(Emission::total_emitted(), 1000);
// TECHO DURO: el pool bajó 1000; el total issuance NO cambió (transfer).
assert_eq!(<Balances as Inspect<_>>::balance(&pool), 999_000);
assert_eq!(
<Balances as Inspect<_>>::total_issuance(),
issuance_before,
"transfer no acuña: issuance constante"
);
}); });
} }
#[test] #[test]
fn emission_accumulates_over_blocks() { fn emission_accumulates_over_blocks() {
new_test_ext().execute_with(|| { new_test_ext().execute_with(|| {
let pool = Emission::ecosystem_account();
<Emission as Hooks<u64>>::on_initialize(1); <Emission as Hooks<u64>>::on_initialize(1);
<Emission as Hooks<u64>>::on_initialize(2); <Emission as Hooks<u64>>::on_initialize(2);
<Emission as Hooks<u64>>::on_initialize(3); <Emission as Hooks<u64>>::on_initialize(3);
// 3 bloques × 1000 = 3000 emitido; autor acumula 3 × 700 = 2100. // 3 bloques × 1000 = 3000 liberado; autor acumula 3 × 700 = 2100.
assert_eq!(Emission::total_emitted(), 3_000); assert_eq!(Emission::total_emitted(), 3_000);
assert_eq!(<Balances as Inspect<_>>::balance(&author()), 2_100); assert_eq!(<Balances as Inspect<_>>::balance(&author()), 2_100);
assert_eq!(<Balances as Inspect<_>>::balance(&pool), 997_000);
}); });
} }
@@ -356,6 +464,52 @@ mod tests {
}); });
} }
// ─── Techo duro 1B: el pool finito se agota y la emisión para ──────────
#[test]
fn hard_cap_pool_se_agota_y_para() {
// Pool = 2500 = 2.5 rewards. Bloque 1: 1000 (pool 1500). Bloque 2: 1000
// (pool 500). Bloque 3: solo 500 disponible → libera 500 (pool 0) +
// anuncia agotamiento. Bloque 4: 0 disponible → nada.
new_test_ext_with_pool(2_500).execute_with(|| {
let pool = Emission::ecosystem_account();
let issuance = <Balances as Inspect<_>>::total_issuance();
<Emission as Hooks<u64>>::on_initialize(1);
assert_eq!(<Balances as Inspect<_>>::balance(&pool), 1_500);
<Emission as Hooks<u64>>::on_initialize(2);
assert_eq!(<Balances as Inspect<_>>::balance(&pool), 500);
assert!(!Emission::depletion_announced());
// Bloque 3: libera el remanente parcial (500) y anuncia agotamiento.
<Emission as Hooks<u64>>::on_initialize(3);
assert_eq!(<Balances as Inspect<_>>::balance(&pool), 0);
assert!(Emission::depletion_announced());
// Bloque 4+: pool seco, cero liberación adicional.
<Emission as Hooks<u64>>::on_initialize(4);
assert_eq!(<Balances as Inspect<_>>::balance(&pool), 0);
// TECHO DURO: total liberado == pool inicial, NUNCA más; issuance intacto.
assert_eq!(Emission::total_emitted(), 2_500);
assert_eq!(<Balances as Inspect<_>>::total_issuance(), issuance);
});
}
#[test]
fn depletion_event_se_anuncia_una_sola_vez() {
new_test_ext_with_pool(500).execute_with(|| {
// Pool < 1 reward desde el inicio: bloque 1 libera 500 y anuncia.
<Emission as Hooks<u64>>::on_initialize(1);
assert!(Emission::depletion_announced());
let emitted = Emission::total_emitted();
// Bloques siguientes: flag ya en true ⇒ no re-anuncia, no re-libera.
<Emission as Hooks<u64>>::on_initialize(2);
<Emission as Hooks<u64>>::on_initialize(3);
assert_eq!(Emission::total_emitted(), emitted, "pool seco: sin más liberación");
});
}
// ─── Kill-switch de governance (FIX-6 Stage 2b — anti-doble-emisión) ── // ─── Kill-switch de governance (FIX-6 Stage 2b — anti-doble-emisión) ──
#[test] #[test]
+41 -10
View File
@@ -167,6 +167,23 @@ parameter_types! {
/// benchmarking real (leftover FIX-2). /// benchmarking real (leftover FIX-2).
pub FeeMultiplierOne: pallet_transaction_payment::Multiplier = pub FeeMultiplierOne: pallet_transaction_payment::Multiplier =
pallet_transaction_payment::Multiplier::from_u32(1); pallet_transaction_payment::Multiplier::from_u32(1);
/// Factor WeightToFee (base units OGC por unidad de ref_time). Calibrado
/// (David 2026-07-03) para que OCAFB-5 tenga DIENTES: con IdentityFee el fee
/// base de un transfer era ~2×10^-10 OGC (polvo cósmico) y el #16 anti-
/// clasista era un adorno. Con este factor, un transfer simple (ExtrinsicBase
/// 108.157M + transfer_keep_alive ~39M ≈ 1.47×10^8 ref_time) cuesta:
/// 1.47×10^8 × 7×10^6 ≈ 1.03×10^15 base units ≈ **1 MILLI_OGC (0.001 OGC)**.
/// Consecuencias: el descuento Pi5 (0.1×) = ahorro real (0.0001 OGC); la
/// quema del 100% de gas es deflación medible desde el testnet; el spam
/// cuesta OGC real. Calibrado a pesos placeholder — re-tunear con benchmarks
/// reales (leftover FIX-2). `ConstantMultiplier` usa solo ref_time.
pub const WeightFeeFactor: Balance = 7_000_000;
/// Factor LengthToFee: 10^11 base units por byte ⇒ 1 KiB ≈ 0.0001 OGC. Da
/// costo real (anti-bloat) a extrinsics grandes (código de contrato, remarks)
/// sin dominar el fee de un tx normal (150 bytes ≈ 0.000015 OGC).
pub const LengthFeeFactor: Balance = 100_000_000_000;
} }
/// FIX-6 Stage 2a: clasificador call→ChainAction del fee unificado (taxonomía /// FIX-6 Stage 2a: clasificador call→ChainAction del fee unificado (taxonomía
@@ -223,8 +240,11 @@ impl pallet_transaction_payment::Config for Runtime {
type OnChargeTransaction = type OnChargeTransaction =
origin_chain_fees::OriginFeeAdapter<crate::Balances, (), OriginCallClassifier>; origin_chain_fees::OriginFeeAdapter<crate::Balances, (), OriginCallClassifier>;
type OperationalFeeMultiplier = OperationalFeeMultiplier; type OperationalFeeMultiplier = OperationalFeeMultiplier;
type WeightToFee = frame_support::weights::IdentityFee<Balance>; /// Calibrado (David 2026-07-03): un transfer simple ≈ 1 MILLI_OGC. NUNCA
type LengthToFee = frame_support::weights::IdentityFee<Balance>; /// `IdentityFee` — con 18 decimales el fee base era polvo (~10^-10 OGC) y
/// anulaba el #16. Ver `WeightFeeFactor`.
type WeightToFee = frame_support::weights::ConstantMultiplier<Balance, WeightFeeFactor>;
type LengthToFee = frame_support::weights::ConstantMultiplier<Balance, LengthFeeFactor>;
/// NUNCA `()` aquí: colapsa `NextFeeMultiplier` a 0 (ver `FeeMultiplierOne`). /// NUNCA `()` aquí: colapsa `NextFeeMultiplier` a 0 (ver `FeeMultiplierOne`).
type FeeMultiplierUpdate = pallet_transaction_payment::ConstFeeMultiplier<FeeMultiplierOne>; type FeeMultiplierUpdate = pallet_transaction_payment::ConstFeeMultiplier<FeeMultiplierOne>;
type WeightInfo = pallet_transaction_payment::weights::SubstrateWeight<Runtime>; type WeightInfo = pallet_transaction_payment::weights::SubstrateWeight<Runtime>;
@@ -481,22 +501,31 @@ impl origin_chain_governance::Config for Runtime {
// origin_chain_emission::Config — FIX-6 Stage 1 (audit V2-CA2) // origin_chain_emission::Config — FIX-6 Stage 1 (audit V2-CA2)
// ============================================================ // ============================================================
// //
// En cada bloque mintea EmissionBlockReward y lo reparte: 70% al autor (el // En cada bloque LIBERA EmissionBlockReward del pool Ecosystem (og/ecosy, 400M)
// validador Aura), 20% al treasury (og/gvtry, la MISMA cuenta de governance), // y lo reparte: 70% al autor (el validador Aura), 20% al treasury (og/gvtry, la
// 10% al subsidy pool (og/subsy, proof-of-equity que financia el subsidio de // MISMA cuenta de governance), 10% al subsidy pool (og/subsy, proof-of-equity
// hardware #16 — ortogonal a la suscripción, David 2026-06-10). Cierra "los // que financia el subsidio de hardware #16 — ortogonal a la suscripción, David
// validadores ganan 0 OGC". FindAuthor = el mapeo Aura→validador de session. // 2026-06-10). Cierra "los validadores ganan 0 OGC". FindAuthor = el mapeo
// Aura→validador de session.
// //
// BlockReward 10 OGC × ~5.256M bloques/año ≈ 52.6M OGC/año ≈ 5.26% inflación // TECHO DURO 1B (David 2026-07-03, Opción A): transfer del pool finito, NUNCA
// sobre el genesis de 1B (materializa la emisión del 40% Ecosystem BM V2). // mint. BlockReward 10 OGC × ~5.256M bloques/año ≈ 52.6M OGC/año ⇒ el pool de
// 400M se agota en ~7.6 años; después, economía circular. El total issuance
// JAMÁS crece — la aserción de compilación de 1B se cumple a rajatabla.
// Stage 2 introduce pallet_staking NPoS sobre este substrate. // Stage 2 introduce pallet_staking NPoS sobre este substrate.
parameter_types! { parameter_types! {
/// Recompensa por bloque: 10 OGC (~5% inflación anual). /// Recompensa por bloque: 10 OGC. NO es inflación — es el ritmo de
/// LIBERACIÓN del pool Ecosystem finito (400M): 10 × ~5.256M bloques/año ≈
/// 52.6M/año ⇒ el pool se agota en ~7.6 años (techo duro 1B, David 2026-07-03).
pub const EmissionBlockReward: Balance = 10 * OGC; pub const EmissionBlockReward: Balance = 10 * OGC;
/// 70% al autor (validador Aura). /// 70% al autor (validador Aura).
pub const EmissionAuthorShareBps: u16 = 7_000; pub const EmissionAuthorShareBps: u16 = 7_000;
/// 20% al treasury (el residual 10% va al subsidy pool). /// 20% al treasury (el residual 10% va al subsidy pool).
pub const EmissionTreasuryShareBps: u16 = 2_000; pub const EmissionTreasuryShareBps: u16 = 2_000;
/// PalletId del pool Ecosystem (40% = 400M en génesis) — la FUENTE finita de
/// las recompensas. La emisión transfiere de aquí, jamás mintea (techo duro).
pub const EmissionEcosystemPalletId: frame_support::PalletId =
frame_support::PalletId(*b"og/ecosy");
/// PalletId del subsidy pool (proof-of-equity, #16 anti-clasista). /// PalletId del subsidy pool (proof-of-equity, #16 anti-clasista).
pub const EmissionSubsidyPalletId: frame_support::PalletId = pub const EmissionSubsidyPalletId: frame_support::PalletId =
frame_support::PalletId(*b"og/subsy"); frame_support::PalletId(*b"og/subsy");
@@ -510,6 +539,8 @@ impl origin_chain_emission::Config for Runtime {
type BlockReward = EmissionBlockReward; type BlockReward = EmissionBlockReward;
type AuthorShareBps = EmissionAuthorShareBps; type AuthorShareBps = EmissionAuthorShareBps;
type TreasuryShareBps = EmissionTreasuryShareBps; type TreasuryShareBps = EmissionTreasuryShareBps;
// La FUENTE finita: el pool Ecosystem (og/ecosy) = 400M pre-asignado en génesis.
type EcosystemPalletId = EmissionEcosystemPalletId;
// Reusa el MISMO vault de governance (og/gvtry) para el treasury. // Reusa el MISMO vault de governance (og/gvtry) para el treasury.
type TreasuryPalletId = GovernanceTreasuryPalletId; type TreasuryPalletId = GovernanceTreasuryPalletId;
type SubsidyPalletId = EmissionSubsidyPalletId; type SubsidyPalletId = EmissionSubsidyPalletId;
@@ -39,7 +39,7 @@
#![cfg(feature = "std")] #![cfg(feature = "std")]
use crate::{ use crate::{
configs::{AuraId, GovernanceTreasuryPalletId, GrandpaId}, configs::{AuraId, EmissionEcosystemPalletId, GovernanceTreasuryPalletId, GrandpaId},
opaque::SessionKeys, opaque::SessionKeys,
Balance, ChainAccountId, Ogc, RuntimeGenesisConfig, Balance, ChainAccountId, Ogc, RuntimeGenesisConfig,
}; };
@@ -360,7 +360,12 @@ pub fn mainnet_genesis_config_genesis() -> RuntimeGenesisConfig {
account_from_sr25519(bytes) account_from_sr25519(bytes)
}; };
let ecosystem_pool = make_placeholder(b"ECO!"); // Ecosystem/Rewards Pool canonical // El 40% Ecosystem (400M) se asigna a la cuenta PalletId `og/ecosy` — la
// MISMA de la que `pallet-origin-emission` LIBERA las recompensas de bloque
// (transfer, techo duro 1B; David 2026-07-03). No es un placeholder key-
// controlado: es la cuenta determinista del pool, sin llave humana.
let ecosystem_pool: ChainAccountId =
EmissionEcosystemPalletId::get().into_account_truncating();
let foundation_treasury = make_placeholder(b"FNDT"); // Foundation multisig = sudo_key interim let foundation_treasury = make_placeholder(b"FNDT"); // Foundation multisig = sudo_key interim
let public_sale = make_placeholder(b"SALE"); // Public Sale canonical let public_sale = make_placeholder(b"SALE"); // Public Sale canonical
let founding_team = make_placeholder(b"TEAM"); // Founding Team canonical (4yr vesting) let founding_team = make_placeholder(b"TEAM"); // Founding Team canonical (4yr vesting)
+34 -7
View File
@@ -243,7 +243,15 @@ pub const IMPL_NAME: &str = "origin-chain-charon";
/// `TotalEmitted` + evento registran SOLO lo realmente acuñado (resultados de /// `TotalEmitted` + evento registran SOLO lo realmente acuñado (resultados de
/// `mint_into` ya no se descartan). Cambia comportamiento ⇒ spec bump; /// `mint_into` ya no se descartan). Cambia comportamiento ⇒ spec bump;
/// TRANSACTION_VERSION intacto. /// TRANSACTION_VERSION intacto.
pub const RUNTIME_VERSION: u32 = 6; /// v7: economía Level A (David 2026-07-03, 2 decisiones) — (a) **TECHO DURO 1B**:
/// la emisión TRANSFIERE del pool Ecosystem finito (og/ecosy, 400M) en vez de
/// `mint_into`; el total issuance jamás crece, el pool se agota en ~7.6 años,
/// después economía circular (evento `BlockRewardReleased` + `EcosystemPoolDepleted`
/// one-shot). (b) **fees con dientes**: `WeightToFee`/`LengthToFee` de `IdentityFee`
/// (polvo ~10^-10 OGC) a `ConstantMultiplier` calibrado — un transfer ≈ 1 MILLI_OGC,
/// el #16 Pi5 0.1× = ahorro real, quema deflacionaria medible. Comportamiento ⇒
/// spec bump; TRANSACTION_VERSION intacto.
pub const RUNTIME_VERSION: u32 = 7;
/// Canonical authoring version per Substrate semver canonical. /// Canonical authoring version per Substrate semver canonical.
pub const AUTHORING_VERSION: u32 = 1; pub const AUTHORING_VERSION: u32 = 1;
@@ -835,12 +843,12 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn runtime_version_v6_ultra_audit_fixes() { fn runtime_version_v7_hard_cap_and_fee_teeth() {
// v6 = 3 fixes del audit ultra 2026-07-02: ConstFeeMultiplier (el `()` // v7 = economía Level A (David 2026-07-03): techo duro 1B (emisión =
// colapsaba NextFeeMultiplier a 0), NativeToEthRatio=1 (OGC 18 dec), // transfer del pool Ecosystem, no mint) + fees con dientes (ConstantMultiplier
// y emisión con contabilidad honesta de mint_into. Cambios de // calibrado, transfer ≈ 1 MILLI_OGC). Cambios de comportamiento, no de
// comportamiento, no de formato de extrinsic ⇒ TRANSACTION_VERSION intacto. // formato de extrinsic ⇒ TRANSACTION_VERSION intacto.
assert_eq!(RUNTIME_VERSION, 6); assert_eq!(RUNTIME_VERSION, 7);
assert_eq!(TRANSACTION_VERSION, 1); assert_eq!(TRANSACTION_VERSION, 1);
} }
@@ -952,6 +960,25 @@ mod tests {
assert!(!next.is_zero()); assert!(!next.is_zero());
} }
#[test]
fn weight_to_fee_transfer_tiene_dientes_no_polvo() {
// Calibración David 2026-07-03 (v7): un transfer simple debe costar ~1
// MILLI_OGC, NO el polvo ~10^-10 OGC de IdentityFee (que anulaba el #16).
// Guard de regresión: si alguien revierte a IdentityFee, el fee cae a
// ~1.5×10^8 base units (muy por debajo de MILLI_OGC/2) y esto rompe.
use frame_support::weights::{Weight, WeightToFee as _};
// transfer ≈ ExtrinsicBaseWeight (108.157M) + transfer_keep_alive (~39M).
let transfer_weight = Weight::from_parts(108_157_000 + 39_051_000, 0);
let fee = <<Runtime as pallet_transaction_payment::Config>::WeightToFee>::weight_to_fee(
&transfer_weight,
);
assert!(
fee >= MILLI_OGC / 2 && fee <= MILLI_OGC * 3,
"fee de transfer = {fee} base units; esperado ~1 MILLI_OGC ({MILLI_OGC}), \
ni polvo ni exorbitante"
);
}
#[test] #[test]
fn revive_deposit_per_byte_canonical_milli_ogc() { fn revive_deposit_per_byte_canonical_milli_ogc() {
// Asset Hub pattern adapted OGC tokenomics — 1 MILLI_OGC per byte canonical. // Asset Hub pattern adapted OGC tokenomics — 1 MILLI_OGC per byte canonical.