feat(fees): FIX-6 Stage 2a — fee unificado (cierra V2-CA3)

Pone la fórmula OCAFB-5 en el fee-path VIVO: OriginFeeAdapter
(OnChargeTransaction custom) reemplaza al FungibleAdapter tier-blind.
El firmante paga su fracción según (HardwareTier × ChainAction ×
SubscriptionTier, tiers leídos ON-CHAIN de TierProfiles — jamás
caller-supplied), y el subsidy pool og/subsy (alimentado por la
emisión Stage 1) cubre la fracción subsidiada. Inmutable #16 en cada
transacción. OU=() = burn 100% gas fees (matriz CHN-2).

unified_fee (u128, outcome tipado FeeOutcome — sin el sentinel
u64::MAX in-band): user = fórmula vendor Mathi byte-fiel (test de
paridad grid contra ce4fafb); pool = net × subsidy_bp/1e4 sobre el
COSTO BASE (anti-regresivo, sub_mult ORTOGONAL: un Ultra NO drena el
pool más que un Free — el premium lo paga el usuario, no el commons).

Hallazgos de la verificación adversarial (workflow ultracode, 4
lentes) cerrados de raíz:
- HIGH: clasificador cubre eth_instantiate_with_code (call_index 10)
  — evadía el entitlement-gate DeployContract vía fallback Write.
- MEDIUM ledger envenenable: removido el dispatchable legacy
  compute_fee_for_action (tiers caller-supplied, escribía AccountFees
  sin cobrar). AccountFees ahora escrito SOLO por el fee-path vivo
  (note_settlement). Eventos que nunca se emitían eliminados; el
  preview read-only vive en query_fee / FeesApi.
- MEDIUM drenaje del pool por sub_mult: pool sobre net, no sobre el
  precio premium (Opción B anti-regresiva).
- Dev UX: GenesisConfig siembra perfiles iniciales; dev/local
  arrancan Ultra (los Slices 3.x de contracts no se bloquean por
  Free+DeployContract); mainnet vacío (Default conservador hasta
  attestation).

Decisión de diseño (autoridad Pilar 4): replay de attestation =
RECHAZO explícito, no dedup silencioso (notificado a Ergon para el
reference impl). Orden OCAFB-5 subsidy-antes-de-entitlement preservado
byte-fiel + notificado a Mathi para el ratify 2026-06-21.

set_tier_profile Root-only interim (sunset a attestation-driven vía
Mathi PerceptionAdapter + DID binding FIX-3/FIX-4). spec_version 1→2
(formato de extrinsic intacto, TRANSACTION_VERSION=1).

Tests: 49 crate fees (paridad grid + lobo blanco ortogonalidad +
refund + pool seco + tip passthrough + entitlement blocked) + 21
suites workspace verde. Toolchain cargo +1.88.

Nota WASM: el build local del runtime WASM falla en la
post-validación del substrate-wasm-builder con UnknownOpcode(252)
— límite del toolchain (Rust 1.88 emite opcodes 0xFC que el parser
del builder de stable2509 no deserializa). Reproducido idéntico en
el HEAD intacto (v0.0.57): es ambiental, no de este cambio. El node
real + WASM artifact siguen VPS-gated (toolchain alineado /
wasm32v1-none cuando el wasm-builder lo soporte).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
davidherranandClaude Fable 5 committed 2026-06-10 20:22:14 -05:00
1 parent e6a803b54f
commit 3364994cce
10 files changed
+1435 -417

No files matched your search

+12 -1
View File
@@ -24,11 +24,18 @@ sp-api = { workspace = true }
parity-scale-codec = { workspace = true }
scale-info = { workspace = true }
# FIX-6 Stage 2a — fee unificado: OnChargeTransaction custom (OriginFeeAdapter)
# + derivacion de la cuenta del subsidy pool (AccountIdConversion).
pallet-transaction-payment = { workspace = true }
sp-runtime = { workspace = true }
[dev-dependencies]
# Mock runtime canonical para integration tests v0.0.22 — Pattern v0.0.18 mirror.
# FIX-6 Stage 2a: + pallet-balances para los tests del OriginFeeAdapter
# (withdraw/refund/pool reales contra balances).
sp-core = { workspace = true, default-features = true }
sp-io = { workspace = true, default-features = true }
sp-runtime = { workspace = true, default-features = true }
pallet-balances = { workspace = true, default-features = true }
[features]
default = ["std"]
@@ -39,12 +46,16 @@ std = [
"sp-api/std",
"parity-scale-codec/std",
"scale-info/std",
"pallet-transaction-payment/std",
"sp-runtime/std",
]
runtime-benchmarks = [
"frame-support/runtime-benchmarks",
"frame-system/runtime-benchmarks",
"pallet-transaction-payment/runtime-benchmarks",
]
try-runtime = [
"frame-support/try-runtime",
"frame-system/try-runtime",
"pallet-transaction-payment/try-runtime",
]
+185 -127
View File
@@ -50,12 +50,22 @@ pub use pallet::*;
/// Vendor copy temporal Mathi `OitFeeSchedule` (cross-workspace boundary).
pub mod fee_schedule;
/// FIX-6 Stage 2a — fee unificado: `OriginFeeAdapter` (OnChargeTransaction
/// custom) + `unified_fee` (OCAFB-5 en u128, outcome tipado) + clasificador
/// call→ChainAction. Pone los Inmutables #16/#37 en el fee-path VIVO y
/// consume el subsidy pool `og/subsy` (cierra el crítico V2-CA3).
pub mod unified;
/// Re-export canonical types desde origin-chain-types para consumers.
pub use fee_schedule::{
arc_mock, compute_fee, entitlement_unlocked, subsidy_basis_points, OitFeeSchedule,
OitFeeScheduleMock,
};
pub use origin_chain_types::{ChainAction, HardwareTier, SubscriptionTier};
pub use unified::{
unified_fee, ChargeInfo, ClassifyChainAction, FeeOutcome, OriginFeeAdapter,
ENTITLEMENT_BLOCKED_TX_CODE,
};
/// OCAFB-7 canonical: Runtime API canonical para cross-pillar consumers + light clients.
///
@@ -94,23 +104,29 @@ sp_api::decl_runtime_apis! {
#[frame_support::pallet]
pub mod pallet {
use alloc::vec::Vec;
use frame_support::pallet_prelude::*;
use frame_system::pallet_prelude::*;
use origin_chain_types::{ChainAction, HardwareTier, SubscriptionTier};
/// Pallet configuration trait canonical.
///
/// Per joint DC OCAFB + ADR-0057 trait-seam discipline. Real `OitFeeSchedule`
/// integration via `Arc<dyn OitFeeSchedule>` Config associated type viene en
/// v0.0.22 — scaffold v0.0.20 keeps minimal canonical surface.
/// Per joint DC OCAFB + ADR-0057 trait-seam discipline. FIX-6 Stage 2a
/// añade `SubsidyPalletId`: el adapter `OriginFeeAdapter` deriva de aquí
/// la cuenta del subsidy pool `og/subsy` (la MISMA constante del runtime
/// que alimenta la emisión Stage 1 — nunca re-declarar el literal).
#[pallet::config]
pub trait Config: frame_system::Config {
/// Runtime event type canonical wired into `RuntimeEvent` aggregate.
type RuntimeEvent: From<Event<Self>>
+ IsType<<Self as frame_system::Config>::RuntimeEvent>;
// Future canonical v0.0.22:
// type OitFeeSchedule: crate::OitFeeSchedule;
/// PalletId del subsidy pool (`og/subsy`, proof-of-equity Inmutable
/// #16). El runtime cablea la MISMA constante que usa la emisión
/// (Index 13) para que pool-alimentación y pool-consumo apunten a la
/// misma cuenta determinística.
#[pallet::constant]
type SubsidyPalletId: Get<frame_support::PalletId>;
}
/// Pallet definition canonical.
@@ -118,9 +134,10 @@ pub mod pallet {
/// v0.0.20 scaffold: storage layout canonical + Config + Events + Errors +
/// scaffold dispatchable. Real impl wire-up Mathi `OitFeeSchedule` trait
/// vía Config canonical viene en v0.0.22.
/// StorageVersion en código (FIX-2 lifecycle, audit V1-H2/V2-CA2): arranca
/// en 0 (genesis). Habilita migraciones futuras vía on_chain_storage_version().
const STORAGE_VERSION: StorageVersion = StorageVersion::new(0);
/// StorageVersion en código (FIX-2 lifecycle, audit V1-H2/V2-CA2).
/// v1: FIX-6 Stage 2a añade `TierProfiles` (ValueQuery con default —
/// no requiere migración de datos, bump por higiene de lifecycle).
const STORAGE_VERSION: StorageVersion = StorageVersion::new(1);
#[pallet::pallet]
#[pallet::storage_version(STORAGE_VERSION)]
@@ -163,59 +180,9 @@ pub mod pallet {
pub action_count: u64,
}
/// Events emisión canonical (OCAFB-7 anticipated extension).
#[pallet::event]
#[pallet::generate_deposit(pub(super) fn deposit_event)]
pub enum Event<T: Config> {
/// Fee charged canonical via `compute_fee_for_action` dispatchable.
///
/// Materializa cross-pillar economy materialized compile-detect canonical.
FeeCharged {
/// Account canonical que pagó el fee.
who: T::AccountId,
/// Action canonical taxonomized per OCAFB-2.
action: ChainAction,
/// Fee charged canonical en OGC base units.
fee_charged: u64,
},
/// Fee refunded canonical (governance refund OR error rollback).
///
/// **Marihan refinement Phase 2.5 anticipated**: refund pattern para
/// dispute resolution Tribunal canonical.
FeeRefunded {
/// Account canonical que recibió refund.
who: T::AccountId,
/// Action canonical original cuando charged.
action: ChainAction,
/// Amount canonical refunded en OGC base units.
amount: u64,
},
/// Entitlement blocked canonical — user tried locked action.
///
/// Per OCAFB-4 entitlement matrix + Inmutable #37 Free perpetua enforce
/// (e.g. Free user Compute action → blocked + event emitted).
EntitlementBlocked {
/// Account canonical que intentó action.
who: T::AccountId,
/// Action canonical bloqueada.
action: ChainAction,
},
}
/// Pallet errors canonical.
#[pallet::error]
pub enum Error<T> {
/// OCAFB-5 canonical: balance insuficiente para pagar fee canonical.
InsufficientBalance,
/// OCAFB-4 canonical: entitlement bloqueado (e.g. Free + Compute).
EntitlementLocked,
/// OCAFB-5 canonical: fee overflow (`u64::MAX` saturation reached).
FeeOverflow,
/// Marihan refinement Phase 2.5 anticipated canonical: account NO encontrado.
AccountNotFound,
}
/// Rejection reasons canonical (mirror Phase 2.1 PROV pattern).
/// FIX-6 Stage 2a: perfil de tiers on-chain del account — el insumo del
/// fee unificado. Cierra el tier-fraud del audit (V2): los tiers NUNCA son
/// caller-supplied en el fee-path vivo; se leen de este storage.
#[derive(
Encode,
Decode,
@@ -228,70 +195,137 @@ pub mod pallet {
Eq,
RuntimeDebug,
)]
pub enum FeeRejectionReason {
/// OCAFB-4 canonical: entitlement bloqueado per matrix.
EntitlementLocked,
/// OCAFB-5 canonical: balance insuficiente.
InsufficientBalance,
/// OCAFB-5 canonical: fee overflow saturation.
FeeOverflow,
pub struct TierProfile {
/// HardwareTier atestado (eje subsidio Inmutable #16).
pub hw: HardwareTier,
/// SubscriptionTier vigente (eje sub_mult ORTOGONAL).
pub sub: SubscriptionTier,
}
/// Dispatchables canonical (real impl v0.0.22).
impl Default for TierProfile {
/// Default CONSERVADOR: `Desktop + Free` — paga fee estándar con el
/// subsidio moderado de Desktop, sin entitlements premium. Nadie
/// entra al subsidio fuerte (EmbeddedArm/Portable) por defecto: el
/// upgrade requiere `set_tier_profile` (Root hoy; attestation-driven
/// vía Mathi PerceptionAdapter + DID binding cuando aterrice — sunset
/// documentado).
fn default() -> Self {
Self {
hw: HardwareTier::Desktop,
sub: SubscriptionTier::Free,
}
}
}
/// FIX-6 Stage 2a: tier profiles on-chain por account. ValueQuery →
/// accounts sin perfil resuelven al Default conservador (Desktop+Free).
#[pallet::storage]
#[pallet::getter(fn tier_profile)]
pub type TierProfiles<T: Config> =
StorageMap<_, Blake2_128Concat, T::AccountId, TierProfile, ValueQuery>;
/// FIX-6 Stage 2a: perfiles iniciales sembrados en génesis. En dev/local
/// el preset siembra las cuentas de prueba con un tier que puede desplegar
/// contratos (Free no puede — Inmutable #37 baseline); en mainnet va vacío
/// (todos arrancan en el Default conservador hasta que la attestation real
/// asigne tiers). Da a la fundación control de génesis sobre quién arranca
/// con qué subsidio.
#[pallet::genesis_config]
pub struct GenesisConfig<T: Config> {
/// `(account, hardware_tier, subscription_tier)` a sembrar en block 0.
pub initial_profiles: Vec<(T::AccountId, HardwareTier, SubscriptionTier)>,
}
impl<T: Config> Default for GenesisConfig<T> {
fn default() -> Self {
Self {
initial_profiles: Vec::new(),
}
}
}
#[pallet::genesis_build]
impl<T: Config> BuildGenesisConfig for GenesisConfig<T> {
fn build(&self) {
for (who, hw, sub) in &self.initial_profiles {
TierProfiles::<T>::insert(who, TierProfile { hw: *hw, sub: *sub });
}
}
}
/// Events del pallet — solo los que SE EMITEN de verdad (FIX-6 Stage 2a
/// removió `FeeCharged`/`FeeRefunded`/`EntitlementBlocked`: el dispatchable
/// legacy `compute_fee_for_action` que los emitía/reservaba quedó superado
/// por el fee-path vivo; el audit los marcó como "eventos que afirman un
/// cobro que no ocurrió" / "nunca emitidos").
#[pallet::event]
#[pallet::generate_deposit(pub(super) fn deposit_event)]
pub enum Event<T: Config> {
/// FIX-6 Stage 2a: tier profile actualizado (Root hoy; attestation
/// futura). Auditable on-chain: quién entró/salió de qué subsidio.
TierProfileSet {
/// Account cuyo perfil cambió.
who: T::AccountId,
/// HardwareTier asignado.
hw: HardwareTier,
/// SubscriptionTier asignado.
sub: SubscriptionTier,
},
/// FIX-6 Stage 2a: settlement REAL del fee-path vivo — lo que el
/// `OriginFeeAdapter` efectivamente cobró (a diferencia del
/// `TransactionFeePaid` de transaction-payment, que reporta el fee
/// SIN la transformación tier — gotcha v43 documentado).
TransactionFeeSettled {
/// Account que pagó su fracción.
who: T::AccountId,
/// Acción clasificada del extrinsic.
action: ChainAction,
/// Fracción pagada por el usuario (base units, SIN tip).
user_paid: u128,
/// Fracción cubierta por el subsidy pool `og/subsy`.
pool_covered: u128,
},
}
/// Pallet errors.
#[pallet::error]
pub enum Error<T> {
/// El `TierProfile` propuesto es inválido (reservado para validación
/// futura cuando `set_tier_profile` migre a attestation-driven).
InvalidTierProfile,
}
/// Dispatchables.
///
/// FIX-6 Stage 2a removió el dispatchable legacy `compute_fee_for_action`:
/// computaba la fórmula y escribía `AccountFees` SIN cobrar dinero y con
/// `hw`/`sub` **caller-supplied** — el audit lo marcó como vector de
/// envenenamiento del ledger (cualquiera inflaba su `total_paid` con
/// entradas ficticias). Ahora el cobro real ocurre en el fee-path vivo
/// (`OriginFeeAdapter` → `note_settlement`), que lee los tiers ON-CHAIN; el
/// preview read-only vive en `query_fee` / `FeesApi`.
#[pallet::call]
impl<T: Config> Pallet<T> {
/// OCAFB-5 canonical: `compute_fee_for_action` real dispatchable.
/// FIX-6 Stage 2a: asigna el `TierProfile` on-chain de un account —
/// el insumo del fee unificado (subsidio #16 + entitlements #37).
///
/// **Real impl v0.0.22**: Pure function call vendor `compute_fee()`
/// + storage update `AccountFees` + event emission canonical. Mathi
/// `OitFeeSchedule` trait consumed CLIENT-SIDE (off-chain) — tx submitter
/// queries Mathi per-user → assembles tx con hw + sub baked → submits.
/// Pattern análogo Phase 2.1 `attest_asset(origin, ValidationAttestation)`
/// (ValidationAttestation pre-assembled by Noesis client-side).
///
/// Flow canonical 5-step per OCAFB-5:
/// 1. `ensure_signed(origin)` canonical
/// 2. Call vendor `compute_fee(base, hw, sub, action)` canonical (pure fn)
/// 3. Si `fee == u64::MAX` → entitlement blocked → emit event + error
/// 4. Update `AccountFees<StorageMap>` con saturating arithmetic
/// 5. Emit `FeeCharged` event canonical
/// **Root-only HOY** (vía sudo Index 12 / governance): conservador
/// deliberado — nadie se auto-declara EmbeddedArm para capturar 90%
/// de subsidio (cierra el tier-fraud del audit V2). **Sunset**: cuando
/// la attestation real aterrice (Mathi PerceptionAdapter + DID binding
/// FIX-3/FIX-4), este dispatchable migra a verificación de attestation
/// firmada en vez de Root fiat.
#[pallet::call_index(0)]
#[pallet::weight(Weight::from_parts(10_000, 0))]
pub fn compute_fee_for_action(
pub fn set_tier_profile(
origin: OriginFor<T>,
action: ChainAction,
base: u64,
who: T::AccountId,
hw: HardwareTier,
sub: SubscriptionTier,
) -> DispatchResult {
// Step 1: ensure origin signed canonical
let who = ensure_signed(origin)?;
// Step 2: compute fee canonical via vendor pure fn (OCAFB-5 formula)
let fee = crate::compute_fee(base, hw, sub, action);
// Step 3: handle entitlement blocked canonical (OCAFB-4 Inmutable #37)
// Note: NO event emission here — FRAME canonical reverts events on Err.
// `EntitlementBlocked` event variant reserved para Phase 2.5 future
// `with_storage_layer` pattern (preserve events on error vía dispatch
// transaction). Caller infers blocked entitlement from `Err(EntitlementLocked)`.
if fee == u64::MAX {
return Err(Error::<T>::EntitlementLocked.into());
}
// Step 4: update AccountFees<StorageMap> canonical con saturating
let mut history = AccountFees::<T>::get(&who).unwrap_or_default();
history.total_paid = history.total_paid.saturating_add(fee as u128);
history.action_count = history.action_count.saturating_add(1);
AccountFees::<T>::insert(&who, history);
// Step 5: emit FeeCharged event canonical (OCAFB-5)
Self::deposit_event(Event::FeeCharged {
who,
action,
fee_charged: fee,
});
ensure_root(origin)?;
TierProfiles::<T>::insert(&who, TierProfile { hw, sub });
Self::deposit_event(Event::TierProfileSet { who, hw, sub });
Ok(())
}
}
@@ -315,6 +349,38 @@ pub mod pallet {
) -> u64 {
crate::compute_fee(base, hw, sub, action)
}
/// FIX-6 Stage 2a: cuenta del subsidy pool `og/subsy` (determinística,
/// keyless — derivada del MISMO PalletId que alimenta la emisión).
pub fn subsidy_account() -> T::AccountId {
use sp_runtime::traits::AccountIdConversion;
T::SubsidyPalletId::get().into_account_truncating()
}
/// FIX-6 Stage 2a: registra un settlement REAL del fee-path vivo.
/// Llamado por `OriginFeeAdapter::correct_and_deposit_fee` post-dispatch
/// con los montos FINALES (post-refund). Actualiza `AccountFees` (que
/// ahora refleja dinero realmente cobrado, cerrando el hallazgo "el
/// ledger cree que cobró cuando no cobró") + emite el evento de verdad.
pub fn note_settlement(
who: &T::AccountId,
action: ChainAction,
user_paid: u128,
pool_covered: u128,
) {
if user_paid > 0 || pool_covered > 0 {
let mut history = AccountFees::<T>::get(who).unwrap_or_default();
history.total_paid = history.total_paid.saturating_add(user_paid);
history.action_count = history.action_count.saturating_add(1);
AccountFees::<T>::insert(who, history);
}
Self::deposit_event(Event::TransactionFeeSettled {
who: who.clone(),
action,
user_paid,
pool_covered,
});
}
}
}
@@ -328,6 +394,10 @@ pub mod mock;
#[cfg(test)]
mod tests_integration;
// FIX-6 Stage 2a — tests E2E del OriginFeeAdapter contra balances reales.
#[cfg(test)]
mod tests_adapter;
#[cfg(test)]
mod tests {
use super::*;
@@ -338,19 +408,7 @@ mod tests {
#[test]
fn pallet_compiles_canonical() {
// Pallet compiles canonical — verified by cargo check.
// Real mock runtime integration tests = `tests_integration` module v0.0.22.
}
#[test]
fn fee_rejection_reason_variants_canonical() {
use pallet::FeeRejectionReason;
let r1 = FeeRejectionReason::EntitlementLocked;
let r2 = FeeRejectionReason::InsufficientBalance;
let _r3 = FeeRejectionReason::FeeOverflow;
assert_ne!(
core::mem::discriminant(&r1),
core::mem::discriminant(&r2)
);
// Fee-path vivo E2E = `tests_adapter`; preview read-only = `tests_integration`.
}
#[test]
+75 -12
View File
@@ -1,22 +1,21 @@
//! # Mock runtime canonical para integration tests v0.0.22
//! # Mock runtime canonical para integration tests
//!
//! Provee mock minimal `Test` runtime composition (`System` + `Fees` solo) para
//! E2E tests dispatchable + storage + event assertions per OCAFB-5 compute_fee
//! flow canonical:
//! v0.0.22: mock minimal (`System` + `Fees`) para E2E del dispatchable
//! `compute_fee_for_action` (OCAFB-5 flow).
//!
//! 1. ensure_signed canonical
//! 2. Vendor compute_fee canonical (3-step formula OCAFB-5)
//! 3. Handle entitlement blocked (u64::MAX → Error::EntitlementLocked + event)
//! 4. AccountFees<StorageMap> update canonical saturating arithmetic
//! 5. Emit FeeCharged event canonical
//! FIX-6 Stage 2a (v0.0.58): extendido con `Balances` + `TransactionPayment`
//! para los tests del `OriginFeeAdapter` (OnChargeTransaction custom): cobros
//! reales con subsidio del pool `og/subsy`, refund path, entitlement blocked,
//! tier profiles on-chain.
//!
//! Pattern v0.0.18 mirror (Phase 2.1 mock runtime integration tests). Mismo
//! pattern construct_runtime! + derive_impl + new_test_ext + helpers.
//! Pattern v0.0.18 mirror: construct_runtime! + derive_impl + new_test_ext.
#![cfg(test)]
use crate as origin_chain_fees;
use frame_support::derive_impl;
use frame_support::traits::fungible::Mutate;
use frame_support::{derive_impl, parameter_types, PalletId};
use origin_chain_types::ChainAction;
use sp_runtime::BuildStorage;
/// Mock block type canonical para tests.
@@ -25,6 +24,8 @@ type Block = frame_system::mocking::MockBlock<Test>;
frame_support::construct_runtime!(
pub enum Test {
System: frame_system,
Balances: pallet_balances,
TransactionPayment: pallet_transaction_payment,
Fees: origin_chain_fees,
}
);
@@ -34,10 +35,58 @@ impl frame_system::Config for Test {
type Block = Block;
type AccountId = sp_runtime::AccountId32;
type Lookup = sp_runtime::traits::IdentityLookup<Self::AccountId>;
type AccountData = pallet_balances::AccountData<u128>;
}
#[derive_impl(pallet_balances::config_preludes::TestDefaultConfig)]
impl pallet_balances::Config for Test {
type AccountStore = System;
type Balance = u128;
}
/// Clasificador del mock: mapea calls del Test runtime a ChainAction para
/// ejercer las filas de la matriz OCAFB sin un runtime real:
/// - Balances transfer* → Transfer
/// - System::remark_with_event → Compute (mapping ARTIFICIAL de test — ejerce
/// el entitlement gate Free+Compute=Blocked y el subsidio educacional)
/// - todo lo demás → Write (fallback conservador, igual que el runtime real)
pub struct MockClassifier;
impl origin_chain_fees::ClassifyChainAction<RuntimeCall> for MockClassifier {
fn classify(call: &RuntimeCall) -> ChainAction {
match call {
RuntimeCall::Balances(
pallet_balances::Call::transfer_allow_death { .. }
| pallet_balances::Call::transfer_keep_alive { .. }
| pallet_balances::Call::transfer_all { .. },
) => ChainAction::Transfer,
RuntimeCall::System(frame_system::Call::remark_with_event { .. }) => {
ChainAction::Compute
}
_ => ChainAction::Write,
}
}
}
/// El adapter bajo test: fee unificado + burn (OU = `()` per CHN-2 gas 100%).
pub type MockFeeAdapter = origin_chain_fees::OriginFeeAdapter<Balances, (), MockClassifier>;
#[derive_impl(pallet_transaction_payment::config_preludes::TestDefaultConfig)]
impl pallet_transaction_payment::Config for Test {
type RuntimeEvent = RuntimeEvent;
type OnChargeTransaction = MockFeeAdapter;
type WeightToFee = frame_support::weights::IdentityFee<u128>;
type LengthToFee = frame_support::weights::IdentityFee<u128>;
}
parameter_types! {
/// MISMO PalletId del runtime real (og/subsy) — el literal vive una sola
/// vez por contexto; los tests verifican contra esta cuenta derivada.
pub const MockSubsidyPalletId: PalletId = PalletId(*b"og/subsy");
}
impl origin_chain_fees::Config for Test {
type RuntimeEvent = RuntimeEvent;
type SubsidyPalletId = MockSubsidyPalletId;
}
/// Test externalities builder canonical (pattern v0.0.18 mirror).
@@ -55,6 +104,20 @@ pub fn new_test_ext() -> sp_io::TestExternalities {
ext
}
/// Builder FIX-6 Stage 2a: genesis + ALICE/BOB fondeadas + subsidy pool
/// fondeado con `pool_balance` (simula la alimentación de la emisión Stage 1).
pub fn new_funded_ext(pool_balance: u128) -> sp_io::TestExternalities {
let mut ext = new_test_ext();
ext.execute_with(|| {
Balances::set_balance(&alice_account(), 1_000_000_000);
Balances::set_balance(&bob_account(), 1_000_000_000);
if pool_balance > 0 {
Balances::set_balance(&Fees::subsidy_account(), pool_balance);
}
});
ext
}
/// Test origin account canonical (ALICE pattern, [1u8; 32] AccountId32).
pub fn alice_origin() -> RuntimeOrigin {
RuntimeOrigin::signed(sp_runtime::AccountId32::new([1u8; 32]))
@@ -0,0 +1,366 @@
//! # Tests E2E del `OriginFeeAdapter` — FIX-6 Stage 2a (V2-CA3 cerrado)
//!
//! Ejercitan el fee-path VIVO contra balances reales en el mock runtime:
//! withdraw con subsidio del pool `og/subsy`, refund post-dispatch, entitlement
//! blocked en el tx-pool, tier profiles on-chain, tip passthrough.
#![cfg(test)]
use crate::mock::*;
use crate::pallet::TierProfile;
use crate::ENTITLEMENT_BLOCKED_TX_CODE;
use frame_support::dispatch::{DispatchInfo, PostDispatchInfo};
use frame_support::traits::fungible::Inspect;
use frame_support::{assert_noop, assert_ok};
use origin_chain_types::{ChainAction, HardwareTier, SubscriptionTier};
use pallet_transaction_payment::OnChargeTransaction;
use sp_runtime::transaction_validity::{InvalidTransaction, TransactionValidityError};
type Adapter = MockFeeAdapter;
type Li = <Adapter as OnChargeTransaction<Test>>::LiquidityInfo;
/// Wrapper cualificado — el trait es genérico sobre T (E0283 sin esto).
fn withdraw(
who: &sp_runtime::AccountId32,
call: &RuntimeCall,
fee: u128,
tip: u128,
) -> Result<Li, TransactionValidityError> {
<Adapter as OnChargeTransaction<Test>>::withdraw_fee(who, call, &info(), fee, tip)
}
fn can_withdraw(
who: &sp_runtime::AccountId32,
call: &RuntimeCall,
fee: u128,
tip: u128,
) -> Result<(), TransactionValidityError> {
<Adapter as OnChargeTransaction<Test>>::can_withdraw_fee(who, call, &info(), fee, tip)
}
fn settle(
who: &sp_runtime::AccountId32,
corrected_fee: u128,
tip: u128,
li: Li,
) -> Result<(), TransactionValidityError> {
<Adapter as OnChargeTransaction<Test>>::correct_and_deposit_fee(
who,
&info(),
&post_info(),
corrected_fee,
tip,
li,
)
}
fn info() -> DispatchInfo {
DispatchInfo::default()
}
fn post_info() -> PostDispatchInfo {
PostDispatchInfo::default()
}
fn balance_of(who: &sp_runtime::AccountId32) -> u128 {
<Balances as Inspect<_>>::balance(who)
}
fn write_call() -> RuntimeCall {
// remark → fallback Write en el MockClassifier
RuntimeCall::System(frame_system::Call::remark { remark: alloc::vec![] })
}
fn transfer_call() -> RuntimeCall {
RuntimeCall::Balances(pallet_balances::Call::transfer_keep_alive {
dest: bob_account(),
value: 1,
})
}
fn compute_call() -> RuntimeCall {
// remark_with_event → Compute (mapping artificial del mock)
RuntimeCall::System(frame_system::Call::remark_with_event { remark: alloc::vec![] })
}
fn set_profile(who: sp_runtime::AccountId32, hw: HardwareTier, sub: SubscriptionTier) {
assert_ok!(Fees::set_tier_profile(RuntimeOrigin::root(), who, hw, sub));
}
// ─── Tier profiles on-chain ──────────────────────────────────────────
#[test]
fn default_profile_es_desktop_free_conservador() {
new_test_ext().execute_with(|| {
let profile = Fees::tier_profile(alice_account());
assert_eq!(
profile,
TierProfile {
hw: HardwareTier::Desktop,
sub: SubscriptionTier::Free
},
"default conservador: nadie entra al subsidio fuerte sin Root"
);
});
}
#[test]
fn set_tier_profile_es_root_only() {
new_test_ext().execute_with(|| {
// Signed origin → BadOrigin (cierra el tier-fraud del audit V2).
assert_noop!(
Fees::set_tier_profile(
alice_origin(),
alice_account(),
HardwareTier::EmbeddedArm,
SubscriptionTier::Free
),
sp_runtime::DispatchError::BadOrigin
);
// Root sí puede + evento auditable.
set_profile(
alice_account(),
HardwareTier::EmbeddedArm,
SubscriptionTier::Pro,
);
assert_eq!(
Fees::tier_profile(alice_account()),
TierProfile {
hw: HardwareTier::EmbeddedArm,
sub: SubscriptionTier::Pro
}
);
System::assert_has_event(RuntimeEvent::Fees(crate::Event::TierProfileSet {
who: alice_account(),
hw: HardwareTier::EmbeddedArm,
sub: SubscriptionTier::Pro,
}));
});
}
// ─── Withdraw: el usuario paga su fracción, el pool cubre el subsidio ──
#[test]
fn desktop_free_write_usuario_75_pool_25() {
new_funded_ext(1_000_000).execute_with(|| {
let alice = alice_account();
let pool = Fees::subsidy_account();
let alice_pre = balance_of(&alice);
let pool_pre = balance_of(&pool);
// Desktop+Free Write: subsidy 2500 bp → user 75%, pool 25%.
let li = withdraw(&alice, &write_call(), 10_000, 0)
.expect("withdraw ok");
assert_eq!(balance_of(&alice), alice_pre - 7_500, "usuario paga 75%");
assert_eq!(balance_of(&pool), pool_pre - 2_500, "pool cubre 25%");
// Post-dispatch sin cambio de fee → settlement con los mismos montos.
assert_ok!(settle(&alice, 10_000, 0, li));
System::assert_has_event(RuntimeEvent::Fees(crate::Event::TransactionFeeSettled {
who: alice_account(),
action: ChainAction::Write,
user_paid: 7_500,
pool_covered: 2_500,
}));
// AccountFees ahora refleja dinero REALMENTE cobrado.
let history = Fees::fee_history_of(alice_account()).expect("history");
assert_eq!(history.total_paid, 7_500);
assert_eq!(history.action_count, 1);
});
}
#[test]
fn embedded_arm_transfer_paga_10_por_ciento_inmutable_16() {
new_funded_ext(1_000_000).execute_with(|| {
let alice = alice_account();
set_profile(alice.clone(), HardwareTier::EmbeddedArm, SubscriptionTier::Free);
let alice_pre = balance_of(&alice);
let li = withdraw(&alice, &transfer_call(), 10_000, 0)
.expect("withdraw ok");
assert_eq!(
balance_of(&alice),
alice_pre - 1_000,
"Pi 5 paga 0.1x — Inmutable #16 en el fee-path vivo"
);
assert_ok!(settle(&alice, 10_000, 0, li));
});
}
#[test]
fn subsidio_educacional_100_gratis_para_el_usuario() {
new_funded_ext(1_000_000).execute_with(|| {
let alice = alice_account();
set_profile(alice.clone(), HardwareTier::EmbeddedArm, SubscriptionTier::Free);
let alice_pre = balance_of(&alice);
let pool = Fees::subsidy_account();
let pool_pre = balance_of(&pool);
// EmbeddedArm + Compute = subsidio 10000 bp (educacional, paso 1
// OCAFB-5 precede al entitlement Free+Compute=blocked).
let li = withdraw(&alice, &compute_call(), 10_000, 0)
.expect("withdraw ok — gratis, no blocked");
assert_eq!(balance_of(&alice), alice_pre, "usuario NO paga nada");
assert_eq!(balance_of(&pool), pool_pre - 10_000, "el pool cubre todo");
assert_ok!(settle(&alice, 10_000, 0, li));
});
}
#[test]
fn pro_multiplica_hacia_arriba_ortogonal() {
new_funded_ext(1_000_000).execute_with(|| {
let alice = alice_account();
let pool = Fees::subsidy_account();
set_profile(alice.clone(), HardwareTier::Desktop, SubscriptionTier::Pro);
let alice_pre = balance_of(&alice);
let pool_pre = balance_of(&pool);
// Desktop+Pro Write: usuario 10_000 × 0.75 × 1.5 = 11_250 (el premium
// paga MÁS). El pool cubre 10_000 × 0.25 = 2_500 — IDÉNTICO a Desktop
// Free (anti-regresivo): el premium lo paga el usuario, NO el commons.
let li = withdraw(&alice, &write_call(), 10_000, 0).expect("withdraw ok");
assert_eq!(balance_of(&alice), alice_pre - 11_250, "usuario premium paga MÁS");
assert_eq!(
balance_of(&pool),
pool_pre - 2_500,
"el pool cubre lo MISMO que para un Free — ortogonal a la suscripción"
);
assert_ok!(settle(&alice, 10_000, 0, li));
System::assert_has_event(RuntimeEvent::Fees(crate::Event::TransactionFeeSettled {
who: alice_account(),
action: ChainAction::Write,
user_paid: 11_250,
pool_covered: 2_500,
}));
});
}
// ─── Entitlement gate en el tx-pool ──────────────────────────────────
#[test]
fn free_compute_blocked_rechaza_la_tx() {
new_funded_ext(1_000_000).execute_with(|| {
let alice = alice_account(); // Desktop+Free default
let expected: TransactionValidityError =
InvalidTransaction::Custom(ENTITLEMENT_BLOCKED_TX_CODE).into();
let withdraw = withdraw(&alice, &compute_call(), 10_000, 0);
assert_eq!(withdraw.err().expect("debe rechazar"), expected);
// can_withdraw_fee (tx-pool) — MISMA transformación, mismo rechazo.
let can = can_withdraw(&alice, &compute_call(), 10_000, 0);
assert_eq!(can.err().expect("debe rechazar en pool"), expected);
});
}
// ─── Pool seco: el usuario NUNCA degrada ─────────────────────────────
#[test]
fn pool_seco_no_degrada_al_usuario() {
new_funded_ext(0).execute_with(|| {
let alice = alice_account();
set_profile(alice.clone(), HardwareTier::EmbeddedArm, SubscriptionTier::Free);
let alice_pre = balance_of(&alice);
// Pool en 0: el usuario paga SOLO su fracción (1_000), sin error.
let li = withdraw(&alice, &transfer_call(), 10_000, 0)
.expect("withdraw ok aunque el pool esté seco");
assert_eq!(balance_of(&alice), alice_pre - 1_000);
assert_ok!(settle(&alice, 10_000, 0, li));
// Settlement honesto: pool_covered = 0.
System::assert_has_event(RuntimeEvent::Fees(crate::Event::TransactionFeeSettled {
who: alice_account(),
action: ChainAction::Transfer,
user_paid: 1_000,
pool_covered: 0,
}));
});
}
// ─── Refund path (gotcha v43: el call no viaja al refund) ────────────
#[test]
fn refund_devuelve_el_sobrante_a_usuario_y_pool() {
new_funded_ext(1_000_000).execute_with(|| {
let alice = alice_account();
let pool = Fees::subsidy_account();
let alice_pre = balance_of(&alice);
let pool_pre = balance_of(&pool);
// Withdraw con fee 10_000 (user 7_500 / pool 2_500)...
let li = withdraw(&alice, &write_call(), 10_000, 0)
.expect("withdraw ok");
// ...el dispatch real costó 4_000 (user 3_000 / pool 1_000).
assert_ok!(settle(&alice, 4_000, 0, li));
assert_eq!(
balance_of(&alice),
alice_pre - 3_000,
"usuario termina pagando la MISMA fracción del fee corregido"
);
assert_eq!(
balance_of(&pool),
pool_pre - 1_000,
"el pool recupera su sobrante también"
);
System::assert_has_event(RuntimeEvent::Fees(crate::Event::TransactionFeeSettled {
who: alice_account(),
action: ChainAction::Write,
user_paid: 3_000,
pool_covered: 1_000,
}));
});
}
// ─── Tip: ni se subsidia ni se multiplica ────────────────────────────
#[test]
fn tip_pasa_integro_sin_subsidio_ni_multiplicador() {
new_funded_ext(1_000_000).execute_with(|| {
let alice = alice_account();
set_profile(alice.clone(), HardwareTier::EmbeddedArm, SubscriptionTier::Free);
let alice_pre = balance_of(&alice);
let pool = Fees::subsidy_account();
let pool_pre = balance_of(&pool);
// fee 10_500 incluye tip 500 → net 10_000: user 1_000 + tip 500.
let li = withdraw(&alice, &transfer_call(), 10_500, 500)
.expect("withdraw ok");
assert_eq!(
balance_of(&alice),
alice_pre - 1_500,
"fracción subsidiada 1_000 + tip íntegro 500"
);
assert_eq!(
balance_of(&pool),
pool_pre - 9_000,
"el pool cubre solo el net, jamás el tip"
);
assert_ok!(settle(&alice, 10_500, 500, li));
System::assert_has_event(RuntimeEvent::Fees(crate::Event::TransactionFeeSettled {
who: alice_account(),
action: ChainAction::Transfer,
user_paid: 1_000,
pool_covered: 9_000,
}));
});
}
// ─── Edge: fee 0 no toca cuentas (paridad FungibleAdapter) ───────────
#[test]
fn fee_cero_no_toca_la_cuenta() {
new_funded_ext(1_000).execute_with(|| {
let alice = alice_account();
let alice_pre = balance_of(&alice);
let li =
withdraw(&alice, &write_call(), 0, 0).expect("withdraw ok");
assert_eq!(balance_of(&alice), alice_pre, "cero retiro");
assert_ok!(can_withdraw(&alice, &write_call(), 0, 0));
assert_ok!(settle(&alice, 0, 0, li));
});
}
extern crate alloc;
+100 -262
View File
@@ -1,303 +1,141 @@
//! # Integration tests canonical v0.0.22 — Mock runtime E2E OCAFB-5 flow
//! # Integration tests — preview read-only de la fórmula OCAFB-5 (`query_fee`)
//!
//! 10 scenarios canonical compute_fee_for_action flow end-to-end via mock runtime:
//!
//! | Scenario | HW | Sub | Action | Expected outcome |
//! |----------|----|----|--------|------------------|
//! | scenario_1_pi5_write_subsidy_90 | EmbeddedArm | Free | Write | fee=10% base (subsidy 9000 bp) |
//! | scenario_2_ultra_write_full_price | Ultra | Pro | Write | fee=base × 1.5 multiplier |
//! | scenario_3_read_always_free_inmutable_37 | Desktop | Free | Read | fee=0 (subsidy 100%) |
//! | scenario_4_free_compute_blocked_entitlement | Desktop | Free | Compute | Error::EntitlementLocked + event |
//! | scenario_5_pro_compute_unlocked | Desktop | Pro | Compute | fee normal |
//! | scenario_6_ultra_compute_premium | Ultra | Ultra | Compute | fee=base × 2.0 multiplier |
//! | scenario_7_storage_accumulates | Desktop | Pro | Write × 3 | history.total_paid sum + count=3 |
//! | scenario_8_chain_action_ordinal_stable_wire | wire format invariant | n/a | Read=0 + Write=1 + Compute=2 + Transfer=3 |
//! | scenario_9_inmutable_16_anti_clasista_lobo_blanco | Pi5 < Ultra fee non-Read | n/a | compile-detect invariant |
//! | scenario_10_5_step_walkthrough | EmbeddedArm | Free | Transfer | explicit Pre/Post-flight assertions |
//!
//! Total: 10 integration tests canonical E2E. Pattern v0.0.18 mirror.
//! FIX-6 Stage 2a: estos tests validaban el dispatchable legacy
//! `compute_fee_for_action` (removido — escribía `AccountFees` con tiers
//! caller-supplied sin cobrar, un vector de envenenamiento del ledger). Ahora
//! validan el preview read-only `Fees::query_fee` (el uso REAL de la fórmula
//! OCAFB-5 fuera del fee-path vivo: pricing cross-pillar para Mathi AMIS /
//! Irhis Web / Trinity / Codex / Atelier vía `FeesApi::query_fee_for_action`).
//! El cobro real con tiers on-chain + subsidio del pool se prueba en
//! `tests_adapter`.
#![cfg(test)]
use crate::mock::{alice_account, alice_origin, bob_account, bob_origin, new_test_ext, Fees, RuntimeEvent, System, Test};
use crate::{AccountFees, Error, Event};
use frame_support::{assert_noop, assert_ok};
use crate::mock::{new_test_ext, Fees};
use origin_chain_types::{ChainAction, HardwareTier, SubscriptionTier};
// ─── Scenario 1: Pi 5 Write subsidy 90% canonical (Inmutable #16) ────
#[test]
fn scenario_1_pi5_write_subsidy_90_canonical() {
new_test_ext().execute_with(|| {
let base = 10_000u64;
assert_ok!(Fees::compute_fee_for_action(
alice_origin(),
ChainAction::Write,
base,
HardwareTier::EmbeddedArm,
SubscriptionTier::Free,
));
// OCAFB-5 formula: base 10000 × hw_mult 1000 × sub_mult 10000 / 10000 / 10000 = 1000
// (subsidy 9000 bp → remaining 1000 bp × Free multiplier 10000 bp)
let history = AccountFees::<Test>::get(&alice_account()).expect("storage updated");
assert_eq!(history.total_paid, 1000u128); // Pi 5 paga 10% del base — Inmutable #16
assert_eq!(history.action_count, 1);
System::assert_last_event(RuntimeEvent::Fees(Event::FeeCharged {
who: alice_account(),
action: ChainAction::Write,
fee_charged: 1000,
}));
});
/// Helper: el preview que consumen los clientes (== `FeesApi::query_fee_for_action`).
fn quote(action: ChainAction, base: u64, hw: HardwareTier, sub: SubscriptionTier) -> u64 {
new_test_ext().execute_with(|| Fees::query_fee(action, base, hw, sub))
}
// ─── Scenario 2: Ultra Write Pro multiplier canonical ───────────────
// ─── Inmutable #16 anti-clasista ─────────────────────────────────────
#[test]
fn scenario_2_ultra_write_full_price_canonical() {
new_test_ext().execute_with(|| {
let base = 1_000u64;
assert_ok!(Fees::compute_fee_for_action(
alice_origin(),
ChainAction::Write,
base,
HardwareTier::Ultra,
SubscriptionTier::Pro,
));
// OCAFB-5: base 1000 × hw_mult 10000 (Ultra subsidy=0 → full remaining) × sub_mult 15000 / 10000 / 10000 = 1500
let history = AccountFees::<Test>::get(&alice_account()).expect("storage updated");
assert_eq!(history.total_paid, 1500u128);
assert_eq!(history.action_count, 1);
});
fn pi5_write_paga_10_por_ciento() {
// EmbeddedArm Free Write: base 10000 × 1000bp × 10000bp / 1e8 = 1000 (10%).
assert_eq!(
quote(ChainAction::Write, 10_000, HardwareTier::EmbeddedArm, SubscriptionTier::Free),
1_000
);
}
// ─── Scenario 3: Read always free canonical (Inmutable #37) ──────────
#[test]
fn scenario_3_read_always_free_inmutable_37_canonical() {
new_test_ext().execute_with(|| {
assert_ok!(Fees::compute_fee_for_action(
alice_origin(),
ChainAction::Read,
999_999,
HardwareTier::Desktop, // even Desktop
SubscriptionTier::Free,
));
// OCAFB-3: Read always 100% subsidy → fee=0 (Inmutable #37 baseline)
let history = AccountFees::<Test>::get(&alice_account()).expect("storage updated");
assert_eq!(history.total_paid, 0u128);
assert_eq!(history.action_count, 1); // count incremented even si fee=0
});
fn ultra_pro_write_multiplica_1_5() {
// Ultra Pro Write: base 1000 × 10000bp (sin subsidio) × 15000bp / 1e8 = 1500.
assert_eq!(
quote(ChainAction::Write, 1_000, HardwareTier::Ultra, SubscriptionTier::Pro),
1_500
);
}
// ─── Scenario 4: Free Compute blocked entitlement canonical (Inmutable #37) ──
#[test]
fn scenario_4_free_compute_blocked_entitlement_canonical() {
new_test_ext().execute_with(|| {
// OCAFB-4 entitlement enforce: Free + Compute → blocked
// FRAME canonical: dispatchable returns `Err(EntitlementLocked)` SIN event
// emission (events revierten on Err per FRAME dispatch transaction).
// `EntitlementBlocked` event variant reserved para Phase 2.5 future
// `with_storage_layer` pattern.
assert_noop!(
Fees::compute_fee_for_action(
alice_origin(),
ChainAction::Compute,
100,
HardwareTier::Desktop,
SubscriptionTier::Free, // Compute blocked per OCAFB-4
),
Error::<Test>::EntitlementLocked
);
// Storage NOT updated (entitlement blocked early)
assert!(AccountFees::<Test>::get(&alice_account()).is_none());
// Caller infers blocked entitlement from `Err(EntitlementLocked)` return,
// no event canonical (FRAME revierte events on Err scaffold v0.0.22).
});
fn pi5_estrictamente_menor_que_ultra_lobo_blanco() {
let pi5 = quote(ChainAction::Write, 10_000, HardwareTier::EmbeddedArm, SubscriptionTier::Free);
let ultra = quote(ChainAction::Write, 10_000, HardwareTier::Ultra, SubscriptionTier::Ultra);
assert!(pi5 < ultra, "Inmutable #16: Pi5 {pi5} debe ser < Ultra {ultra}");
assert_eq!(pi5, 1_000);
assert_eq!(ultra, 20_000);
}
// ─── Scenario 5: Pro Compute unlocked canonical ─────────────────────
// ─── Inmutable #37 Free perpetua ─────────────────────────────────────
#[test]
fn scenario_5_pro_compute_unlocked_canonical() {
new_test_ext().execute_with(|| {
let base = 100u64;
assert_ok!(Fees::compute_fee_for_action(
alice_origin(),
ChainAction::Compute,
base,
HardwareTier::Desktop,
SubscriptionTier::Pro,
));
// OCAFB-5: base 100 × hw_mult 7500 (Desktop Compute subsidy 2500 → 7500 remaining) × sub_mult 15000 / 10000 / 10000 = 112
let history = AccountFees::<Test>::get(&alice_account()).expect("storage updated");
assert_eq!(history.total_paid, 112u128); // floor division round-down (112.5 → 112)
assert_eq!(history.action_count, 1);
});
fn read_siempre_gratis_todos_los_tiers() {
for hw in [
HardwareTier::EmbeddedArm,
HardwareTier::Portable,
HardwareTier::Desktop,
HardwareTier::Ultra,
] {
assert_eq!(quote(ChainAction::Read, 999_999, hw, SubscriptionTier::Free), 0);
}
}
// ─── Scenario 6: Ultra Compute premium canonical (Inmutable #18) ────
#[test]
fn scenario_6_ultra_compute_premium_canonical() {
new_test_ext().execute_with(|| {
let base = 1_000u64;
assert_ok!(Fees::compute_fee_for_action(
alice_origin(),
ChainAction::Compute,
base,
HardwareTier::Ultra,
SubscriptionTier::Ultra, // EEG Compute Ultra-only (Inmutable #18)
));
// OCAFB-5: base 1000 × hw_mult 10000 (Ultra subsidy=0) × sub_mult 20000 / 10000 / 10000 = 2000
let history = AccountFees::<Test>::get(&alice_account()).expect("storage updated");
assert_eq!(history.total_paid, 2000u128);
assert_eq!(history.action_count, 1);
});
fn free_compute_blocked_devuelve_sentinel() {
// El preview read-only reporta u64::MAX para entitlement blocked (el
// fee-path vivo lo traduce a InvalidTransaction::Custom — ver tests_adapter).
assert_eq!(
quote(ChainAction::Compute, 100, HardwareTier::Desktop, SubscriptionTier::Free),
u64::MAX
);
}
// ─── Scenario 7: Storage accumulates over multiple writes canonical ──
#[test]
fn scenario_7_storage_accumulates_canonical() {
new_test_ext().execute_with(|| {
let base = 1_000u64;
// 3 writes secuenciales
for _ in 0..3 {
assert_ok!(Fees::compute_fee_for_action(
alice_origin(),
ChainAction::Write,
base,
HardwareTier::Desktop,
SubscriptionTier::Pro,
));
}
// OCAFB-5: base 1000 × hw_mult 7500 × sub_mult 15000 / 10000 / 10000 = 1125 per write
// After 3 writes: total_paid = 3 × 1125 = 3375, action_count = 3
let history = AccountFees::<Test>::get(&alice_account()).expect("storage updated");
assert_eq!(history.total_paid, 3375u128);
assert_eq!(history.action_count, 3);
});
fn pro_compute_unlocked() {
// Desktop Pro Compute: 100 × 7500bp × 15000bp / 1e8 = 112 (floor 112.5).
assert_eq!(
quote(ChainAction::Compute, 100, HardwareTier::Desktop, SubscriptionTier::Pro),
112
);
}
// ─── Scenario 8: ChainAction ordinal stable wire format canonical ────
#[test]
fn ultra_compute_premium_inmutable_18() {
// Ultra Ultra Compute: 1000 × 10000bp × 20000bp / 1e8 = 2000.
assert_eq!(
quote(ChainAction::Compute, 1_000, HardwareTier::Ultra, SubscriptionTier::Ultra),
2_000
);
}
// ─── Subsidio educacional (paso 1 precede entitlement) ───────────────
#[test]
fn scenario_8_chain_action_ordinal_stable_wire_canonical() {
// Wire format invariant — sin dependency mock runtime
fn pi5_compute_educacional_gratis() {
// EmbeddedArm Compute = subsidio 100% → 0, aunque Free+Compute esté gated.
assert_eq!(
quote(ChainAction::Compute, 999_999, HardwareTier::EmbeddedArm, SubscriptionTier::Free),
0
);
}
// ─── Wire format estable ChainAction ─────────────────────────────────
#[test]
fn chain_action_ordinal_estable_wire() {
assert_eq!(ChainAction::Read.ordinal(), 0);
assert_eq!(ChainAction::Write.ordinal(), 1);
assert_eq!(ChainAction::Compute.ordinal(), 2);
assert_eq!(ChainAction::Transfer.ordinal(), 3);
// Roundtrip canonical
for action in [ChainAction::Read, ChainAction::Write, ChainAction::Compute, ChainAction::Transfer] {
for action in [
ChainAction::Read,
ChainAction::Write,
ChainAction::Compute,
ChainAction::Transfer,
] {
assert_eq!(ChainAction::from_ordinal(action.ordinal()), Some(action));
}
}
// ─── Scenario 9: Inmutable #16 anti-clasista lobo blanco canonical ───
// ─── Coherencia preview ↔ fee-path vivo (la fracción user) ───────────
#[test]
fn scenario_9_inmutable_16_anti_clasista_lobo_blanco_canonical() {
new_test_ext().execute_with(|| {
let base = 10_000u64;
// Pi 5 user (EmbeddedArm + Free)
assert_ok!(Fees::compute_fee_for_action(
alice_origin(),
ChainAction::Write,
base,
HardwareTier::EmbeddedArm,
SubscriptionTier::Free,
));
let pi5_fee = AccountFees::<Test>::get(&alice_account()).unwrap().total_paid;
// Ultra user (Ultra + Ultra)
assert_ok!(Fees::compute_fee_for_action(
bob_origin(),
ChainAction::Write,
base,
HardwareTier::Ultra,
SubscriptionTier::Ultra,
));
let ultra_fee = AccountFees::<Test>::get(&bob_account()).unwrap().total_paid;
// Lobo blanco compile-detect canonical: Pi 5 paga ESTRICTAMENTE MENOS que Ultra
assert!(
pi5_fee < ultra_fee,
"Inmutable #16 anti-clasista violated: Pi 5 fee {pi5_fee} debe ser < Ultra fee {ultra_fee}"
);
// Ratio canonical aprox 1:20 (Pi 5 1000 vs Ultra 20000)
// Pi 5: 10000 × 1000 × 10000 / 10000 / 10000 = 1000
// Ultra: 10000 × 10000 × 20000 / 10000 / 10000 = 20000
assert_eq!(pi5_fee, 1000u128);
assert_eq!(ultra_fee, 20000u128);
});
}
// ─── Scenario 10: 5-step OCAFB-5 walkthrough explicit canonical ──────
#[test]
fn scenario_10_5_step_walkthrough_canonical() {
new_test_ext().execute_with(|| {
let base = 1_000u64;
let action = ChainAction::Transfer;
let hw = HardwareTier::EmbeddedArm;
let sub = SubscriptionTier::Free;
// Pre-flight assertions canonical
assert!(
AccountFees::<Test>::get(&alice_account()).is_none(),
"PRE-flight OCAFB-2: storage canonical empty"
);
// Step 1: ensure_signed canonical (implicit via dispatchable)
// Step 2: vendor compute_fee canonical OCAFB-5 (pure fn call)
// Step 3: entitlement check (Transfer Free allowed, no block)
// Step 4: AccountFees<StorageMap> update saturating canonical
// Step 5: emit FeeCharged event canonical
assert_ok!(Fees::compute_fee_for_action(
alice_origin(),
action,
base,
hw,
sub,
));
// Post-flight assertions canonical
let history = AccountFees::<Test>::get(&alice_account())
.expect("POST-flight OCAFB-2: AccountFees persisted canonical");
// OCAFB-5: base 1000 × hw_mult 1000 (EmbeddedArm Transfer subsidy 9000 → 1000 remaining) × sub_mult 10000 / 10000 / 10000 = 100
assert_eq!(
history.total_paid, 100u128,
"POST-flight: total_paid canonical Pi 5 anti-clasista"
);
assert_eq!(
history.action_count, 1,
"POST-flight: action_count canonical incremented"
);
System::assert_last_event(RuntimeEvent::Fees(Event::FeeCharged {
who: alice_account(),
action,
fee_charged: 100,
}));
});
fn preview_coincide_con_la_fraccion_user_del_adapter() {
// Para tiers donde el preview NO está blocked ni saturado, query_fee debe
// coincidir con unified_fee.user (lo que el adapter cobra al usuario).
use crate::unified::{unified_fee, FeeOutcome};
for base in [1u64, 1_000, 1_000_000] {
for hw in [HardwareTier::EmbeddedArm, HardwareTier::Desktop, HardwareTier::Ultra] {
for sub in [SubscriptionTier::Free, SubscriptionTier::Pro, SubscriptionTier::Ultra] {
let q = quote(ChainAction::Transfer, base, hw, sub);
if let FeeOutcome::Charge { user, .. } =
unified_fee(base as u128, hw, sub, ChainAction::Transfer)
{
assert_eq!(q as u128, user, "preview != user ({hw:?},{sub:?},base={base})");
}
}
}
}
}
+598
View File
@@ -0,0 +1,598 @@
//! # Fee unificado — FIX-6 Stage 2a (cierra el crítico V2-CA3)
//!
//! El audit V2 encontró el sistema con TRES semánticas de fee sin reconciliar:
//!
//! - **A** — `origin_chain_types::fees::compute_fee` (CHN-8 hw×quality): dead
//! code en producción (cero implementadores de `FeeCalculator`/`OriginFees`).
//! - **B** — `fee_schedule::compute_fee` (OCAFB-5 hw×sub×action, vendor Mathi):
//! económicamente inerte (el dispatchable registra pero no cobra).
//! - **C** — `pallet_transaction_payment` + `FungibleAdapter` + `IdentityFee`:
//! el ÚNICO path que movía dinero — tier-blind, constitución-blind.
//!
//! Este módulo los unifica: el [`OriginFeeAdapter`] reemplaza al
//! `FungibleAdapter` en el runtime y pone la fórmula OCAFB-5 (la semántica
//! ganadora en los 4 ejes constitucionales del audit) en el **fee-path vivo**:
//!
//! 1. El fee base lo computa `pallet_transaction_payment` (weight + length).
//! 2. El adapter clasifica el extrinsic → [`ChainAction`] (vía
//! [`ClassifyChainAction`], impl en el runtime donde `RuntimeCall` es
//! concreto) y lee el [`TierProfile`](crate::pallet::TierProfile) on-chain
//! del firmante (default conservador `Desktop + Free` = cero subsidio extra;
//! NUNCA caller-supplied — cierra el tier-fraud del audit).
//! 3. Aplica [`unified_fee`] (OCAFB-5 en u128, outcome tipado sin sentinel):
//! el usuario paga su fracción, el **subsidy pool `og/subsy`** (alimentado
//! por la emisión Stage 1) cubre la fracción subsidiada — Inmutable #16 en
//! cada transacción.
//! 4. Ambas fracciones van al handler `OnUnbalanced` del runtime. Per la burn
//! matrix CHN-2 ratificada (gas fees = 100% burn), el runtime usa `()` →
//! burn deflacionario; el burn total = fee constitucional completo aunque el
//! usuario subsidiado pague solo su fracción.
//!
//! ## Orden OCAFB-5 preservado byte-fiel (decisión documentada)
//!
//! El paso 1 (subsidy 100% → gratis) corre ANTES del paso 2 (entitlement):
//! precedencia constitucional — el subsidio de hardware (Inmutable #16, p.ej.
//! EmbeddedArm + Compute educacional) corto-circuita el gating comercial de
//! suscripción (#37/#18). Es el orden textual de OCAFB-5 (DC
//! `origin_chain_amis_fee_bridge_v1`, vendor Mathi `ce4fafb`); la implicación
//! (entitlement gate hardware-dependiente) queda notificada a Mathi para el
//! ratify 2026-06-21 — NO se "corrige" unilateralmente aquí.
//!
//! ## sub_mult ORTOGONAL (David 2026-06-10)
//!
//! `subsidy_basis_points(hw, action)` NO recibe la suscripción: el premium
//! Pro/Ultra multiplica el precio HACIA ARRIBA sin tocar el subsidio de
//! hardware. Lobo blanco test en este módulo lo fuerza compile-detect.
//!
//! ## Qué pasa con el tip
//!
//! El tip es voluntario: ni se subsidia ni se multiplica — pasa íntegro.
use core::marker::PhantomData;
use frame_support::traits::fungible::{Balanced, Credit, Debt};
use frame_support::traits::tokens::{Fortitude, Precision, Preservation, WithdrawConsequence};
use frame_support::traits::{Imbalance, OnUnbalanced};
use frame_support::unsigned::TransactionValidityError;
use pallet_transaction_payment::OnChargeTransaction;
use sp_runtime::traits::{DispatchInfoOf, PostDispatchInfoOf, Zero};
use sp_runtime::transaction_validity::InvalidTransaction;
use crate::fee_schedule::{entitlement_unlocked, subsidy_basis_points};
use origin_chain_types::{ChainAction, HardwareTier, SubscriptionTier};
/// Código `InvalidTransaction::Custom` cuando el entitlement OCAFB-4 bloquea
/// la acción (Inmutable #37 enforcement en el tx-pool — la tx ni entra).
/// 37 = guiño al Inmutable que enforcea.
pub const ENTITLEMENT_BLOCKED_TX_CODE: u8 = 37;
/// Outcome tipado del fee unificado — reemplaza el sentinel in-band
/// `u64::MAX` del vendor `compute_fee` (riesgo flagged por el audit V2/V3:
/// un consumer naïf intentaría cobrar `u64::MAX`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FeeOutcome {
/// Cobrar: `user` al firmante + `pool` cubierto por el subsidy pool
/// `og/subsy` (= `net × subsidy_bp/10_000`, sobre el costo base — ortogonal
/// a la suscripción). `user == 0` ⇔ subsidio 100% (paso 1 OCAFB-5).
Charge {
/// Fracción que paga el firmante (base units OGC, fórmula vendor).
user: u128,
/// Fracción que cubre el subsidy pool (base units OGC).
pool: u128,
},
/// Entitlement bloqueado (paso 2 OCAFB-5, matriz OCAFB-4).
Blocked,
}
/// Fórmula OCAFB-5 en u128 con outcome tipado — la ÚNICA fuente de verdad del
/// fee-path vivo.
///
/// `net` = fee base SIN tip (weight + length fee de transaction-payment).
///
/// **`user`**: lo que paga el firmante. Paridad numérica EXACTA con el vendor
/// [`compute_fee`](crate::fee_schedule::compute_fee) (mismo orden mul→mul→div→div
/// — test de paridad grid abajo). La fórmula de Mathi (DC OCAFB) es la fuente
/// de verdad de este campo y queda intacta byte-fiel.
///
/// **`pool`**: lo que cubre el subsidy pool `og/subsy` (Inmutable #16) =
/// `net × subsidy_bp / 10_000`. **CLAVE — anti-regresivo**: el subsidio se
/// computa sobre el COSTO BASE `net`, NO sobre el precio premium
/// (`net × sub_mult`). Consecuencia: el commons subsidia EXACTAMENTE lo mismo a
/// un Pi 5 sea Free o Ultra; el multiplicador de suscripción (Pro/Ultra) lo
/// paga ÍNTEGRO el usuario premium como deflación, jamás el commons. Esta es la
/// lectura fiel de "sub_mult ORTOGONAL" (David 2026-06-10): el subsidio de
/// hardware y la suscripción son dimensiones independientes — el premium no
/// erosiona NI infla la carga del pool. (Cierra el hallazgo MEDIUM "sub_mult
/// amplifica el drenaje del pool" del audit Stage 2a: la definición previa
/// `pool = reference − user` hacía que un Ultra drenara el pool 2× — regresivo.)
///
/// Invariante: `pool == net × subsidy_bp / 10_000` (independiente de `sub`) y
/// `user == vendor compute_fee`. El burn total = `user + pool`.
pub fn unified_fee(
net: u128,
hw: HardwareTier,
sub: SubscriptionTier,
action: ChainAction,
) -> FeeOutcome {
let subsidy_bp = subsidy_basis_points(hw, action) as u128;
let sub_mult = sub.multiplier_basis_points() as u128;
// El subsidio del pool SIEMPRE sobre el costo base (ortogonal a la
// suscripción). saturating_mul antes de dividir para preservar precisión.
let pool = net.saturating_mul(subsidy_bp) / 10_000;
// Paso 1 OCAFB-5: subsidio 100% — Inmutable #16 strong (precedencia
// constitucional sobre el entitlement comercial; ver doc del módulo).
if subsidy_bp >= 10_000 {
return FeeOutcome::Charge { user: 0, pool };
}
// Paso 2 OCAFB-5: entitlement bloqueado — Inmutable #37/#18.
if !entitlement_unlocked(sub, action) {
return FeeOutcome::Blocked;
}
// Paso 3 OCAFB-5: lo que paga el usuario (fórmula vendor Mathi, byte-fiel).
let user = net
.saturating_mul(10_000u128.saturating_sub(subsidy_bp))
.saturating_mul(sub_mult)
/ 10_000
/ 10_000;
FeeOutcome::Charge { user, pool }
}
/// Clasifica un `RuntimeCall` concreto → [`ChainAction`] (taxonomía OCAFB-2).
///
/// Se implementa en el runtime (único sitio donde `RuntimeCall` es concreto)
/// y se inyecta al adapter como type param. Fallback conservador: `Write`
/// (paga fee estándar — nunca un wildcard que regale subsidio).
pub trait ClassifyChainAction<Call> {
/// Mapea el call al `ChainAction` que gobierna su fila en la matriz
/// `subsidy_basis_points` × `entitlement_unlocked`.
fn classify(call: &Call) -> ChainAction;
}
/// `LiquidityInfo` del adapter: transporta los créditos retirados + los
/// factores aplicados, porque `correct_and_deposit_fee` NO recibe el `call`
/// (gotcha del trait v43) — el refund re-aplica la MISMA transformación.
pub struct ChargeInfo<AccountId, F: Balanced<AccountId>> {
/// Crédito retirado del firmante (fracción user + tip). `None` si 0.
user_credit: Option<Credit<AccountId, F>>,
/// Crédito retirado del subsidy pool `og/subsy`. `None` si 0 o pool seco.
pool_credit: Option<Credit<AccountId, F>>,
/// Acción clasificada en withdraw (para el settlement event).
action: ChainAction,
/// Subsidy bp aplicado (re-usado en el refund path).
subsidy_bp: u32,
/// sub_mult bp aplicado (re-usado en el refund path).
sub_mult: u64,
}
impl<AccountId, F: Balanced<AccountId>> Default for ChargeInfo<AccountId, F> {
fn default() -> Self {
Self {
user_credit: None,
pool_credit: None,
action: ChainAction::Write,
subsidy_bp: 0,
sub_mult: 10_000,
}
}
}
/// Adapter `OnChargeTransaction` del fee unificado — el reemplazo del
/// `FungibleAdapter<Balances, ()>` en el runtime (FIX-6 Stage 2a).
///
/// - `F`: backend fungible (pallet_balances) con `Balance = u128`.
/// - `OU`: destino de los créditos finales. CHN-2 burn matrix: gas fees 100%
/// burn → el runtime pasa `()`. Recibe (fee, tip) en ese orden.
/// - `C`: clasificador call→ChainAction (impl en el runtime).
pub struct OriginFeeAdapter<F, OU, C>(PhantomData<(F, OU, C)>);
impl<T, F, OU, C> OnChargeTransaction<T> for OriginFeeAdapter<F, OU, C>
where
T: pallet_transaction_payment::Config + crate::Config,
F: Balanced<T::AccountId, Balance = u128>,
OU: OnUnbalanced<Credit<T::AccountId, F>>,
C: ClassifyChainAction<T::RuntimeCall>,
{
type Balance = u128;
type LiquidityInfo = ChargeInfo<T::AccountId, F>;
fn withdraw_fee(
who: &T::AccountId,
call: &T::RuntimeCall,
_dispatch_info: &DispatchInfoOf<T::RuntimeCall>,
fee: Self::Balance,
tip: Self::Balance,
) -> Result<Self::LiquidityInfo, TransactionValidityError> {
// `fee` ya incluye el tip (contrato del trait). El tip pasa íntegro:
// ni se subsidia ni se multiplica.
let net = fee.saturating_sub(tip);
let profile = crate::Pallet::<T>::tier_profile(who);
let action = C::classify(call);
let (user, pool) = match unified_fee(net, profile.hw, profile.sub, action) {
FeeOutcome::Blocked => {
return Err(InvalidTransaction::Custom(ENTITLEMENT_BLOCKED_TX_CODE).into())
}
FeeOutcome::Charge { user, pool } => (user, pool),
};
let user_total = user.saturating_add(tip);
let user_credit = if user_total.is_zero() {
// Gotcha v43: fee 0 → NO tocar la cuenta (paridad FungibleAdapter).
None
} else {
Some(
F::withdraw(
who,
user_total,
Precision::Exact,
Preservation::Preserve,
Fortitude::Polite,
)
.map_err(|_| TransactionValidityError::from(InvalidTransaction::Payment))?,
)
};
// El pool cubre best-effort: si og/subsy no alcanza, se cubre lo que
// haya — el usuario NUNCA paga más que su fracción constitucional
// (anti-clasista no degrada para el usuario; el burn total absorbe el
// shortfall). La emisión re-fondea el pool en on_initialize ANTES de
// las extrinsics del bloque.
let pool_credit = if pool.is_zero() {
None
} else {
F::withdraw(
&crate::Pallet::<T>::subsidy_account(),
pool,
Precision::BestEffort,
Preservation::Preserve,
Fortitude::Polite,
)
.ok()
.filter(|c| !c.peek().is_zero())
};
Ok(ChargeInfo {
user_credit,
pool_credit,
action,
subsidy_bp: subsidy_basis_points(profile.hw, action),
sub_mult: profile.sub.multiplier_basis_points(),
})
}
fn can_withdraw_fee(
who: &T::AccountId,
call: &T::RuntimeCall,
_dispatch_info: &DispatchInfoOf<T::RuntimeCall>,
fee: Self::Balance,
tip: Self::Balance,
) -> Result<(), TransactionValidityError> {
// MISMA transformación que withdraw_fee (gotcha v43: si el tx-pool
// chequea el fee sin subsidio, cuentas pobres serían rechazadas
// aunque puedan pagar el fee subsidiado).
let net = fee.saturating_sub(tip);
let profile = crate::Pallet::<T>::tier_profile(who);
let action = C::classify(call);
let user = match unified_fee(net, profile.hw, profile.sub, action) {
FeeOutcome::Blocked => {
return Err(InvalidTransaction::Custom(ENTITLEMENT_BLOCKED_TX_CODE).into())
}
FeeOutcome::Charge { user, .. } => user,
};
let user_total = user.saturating_add(tip);
if user_total.is_zero() {
return Ok(());
}
match F::can_withdraw(who, user_total) {
WithdrawConsequence::Success => Ok(()),
_ => Err(InvalidTransaction::Payment.into()),
}
}
fn correct_and_deposit_fee(
who: &T::AccountId,
_dispatch_info: &DispatchInfoOf<T::RuntimeCall>,
_post_info: &PostDispatchInfoOf<T::RuntimeCall>,
corrected_fee: Self::Balance,
tip: Self::Balance,
already_withdrawn: Self::LiquidityInfo,
) -> Result<(), TransactionValidityError> {
let ChargeInfo {
user_credit,
pool_credit,
action,
subsidy_bp,
sub_mult,
} = already_withdrawn;
// Re-aplica la transformación con los factores transportados (el call
// no está disponible aquí — gotcha v43 del trait). MISMA semántica que
// unified_fee: pool sobre el costo base, user = fórmula vendor.
let corrected_net = corrected_fee.saturating_sub(tip);
let sub_mult_u128 = sub_mult as u128;
let subsidy_bp_u128 = subsidy_bp as u128;
let pool_target = corrected_net.saturating_mul(subsidy_bp_u128) / 10_000;
let user_target = if subsidy_bp >= 10_000 {
0
} else {
corrected_net
.saturating_mul(10_000u128.saturating_sub(subsidy_bp_u128))
.saturating_mul(sub_mult_u128)
/ 10_000
/ 10_000
};
let user_target_total = user_target.saturating_add(tip);
let mut user_paid_final: u128 = 0;
let mut pool_covered_final: u128 = 0;
// ── Fracción del usuario: refund del weight sobrante + split tip ──
if let Some(paid) = user_credit {
let refund_amount = paid.peek().saturating_sub(user_target_total);
// Paridad FungibleAdapter: refund solo si la cuenta sigue viva;
// BestEffort con fallback Debt::zero (un Exact aquí puede tumbar
// la tx entera en post_dispatch).
let refund_imbalance = if refund_amount > 0 && F::total_balance(who) > Zero::zero() {
F::deposit(who, refund_amount, Precision::BestEffort)
.unwrap_or_else(|_| Debt::<T::AccountId, F>::zero())
} else {
Debt::<T::AccountId, F>::zero()
};
let adjusted_paid: Credit<T::AccountId, F> = paid
.offset(refund_imbalance)
.same()
.map_err(|_| TransactionValidityError::Invalid(InvalidTransaction::Payment))?;
user_paid_final = adjusted_paid.peek().saturating_sub(tip);
// (fee, tip) en ese orden — contrato OnUnbalanced del adapter.
let (tip_credit, fee_credit) = adjusted_paid.split(tip);
OU::on_unbalanceds(Some(fee_credit).into_iter().chain(Some(tip_credit)));
}
// ── Fracción del pool: refund del sobrante a og/subsy ──
if let Some(paid) = pool_credit {
let refund_amount = paid.peek().saturating_sub(pool_target);
let pool_account = crate::Pallet::<T>::subsidy_account();
let refund_imbalance = if refund_amount > 0 {
F::deposit(&pool_account, refund_amount, Precision::BestEffort)
.unwrap_or_else(|_| Debt::<T::AccountId, F>::zero())
} else {
Debt::<T::AccountId, F>::zero()
};
let adjusted_pool: Credit<T::AccountId, F> = paid
.offset(refund_imbalance)
.same()
.map_err(|_| TransactionValidityError::Invalid(InvalidTransaction::Payment))?;
pool_covered_final = adjusted_pool.peek();
OU::on_unbalanceds(Some(adjusted_pool).into_iter());
}
// Telemetría REAL: el ledger AccountFees + evento reflejan lo COBRADO
// (el Event::TransactionFeePaid de transaction-payment reporta el fee
// sin transformar — gotcha v43 documentado; este evento es la verdad).
crate::Pallet::<T>::note_settlement(who, action, user_paid_final, pool_covered_final);
Ok(())
}
#[cfg(feature = "runtime-benchmarks")]
fn endow_account(who: &T::AccountId, amount: Self::Balance) {
let _ = F::deposit(who, amount, Precision::BestEffort);
}
#[cfg(feature = "runtime-benchmarks")]
fn minimum_balance() -> Self::Balance {
F::minimum_balance()
}
}
// ─── Tests de la fórmula pura (paridad + invariantes constitucionales) ───
#[cfg(test)]
mod tests {
use super::*;
use crate::fee_schedule::compute_fee as vendor_compute_fee;
const ALL_HW: [HardwareTier; 4] = [
HardwareTier::EmbeddedArm,
HardwareTier::Portable,
HardwareTier::Desktop,
HardwareTier::Ultra,
];
const ALL_SUB: [SubscriptionTier; 3] = [
SubscriptionTier::Free,
SubscriptionTier::Pro,
SubscriptionTier::Ultra,
];
const ALL_ACTION: [ChainAction; 7] = [
ChainAction::Read,
ChainAction::Write,
ChainAction::Compute,
ChainAction::Transfer,
ChainAction::DeployContract,
ChainAction::CallContract,
ChainAction::UploadContractCode,
];
/// Paridad grid EXACTA contra el vendor OCAFB-5 (Mathi ce4fafb): para todo
/// (hw, sub, action) × bases representativas, `unified_fee.user` == vendor
/// `compute_fee`, con Blocked ⇔ u64::MAX y Free ⇔ 0. Si Mathi cambia la
/// fórmula en el swap post-ratify, este test lo detecta.
#[test]
fn paridad_grid_contra_vendor_ocafb5() {
for base in [0u64, 1, 999, 10_000, 1_000_000, 9_999_999_999] {
for hw in ALL_HW {
for sub in ALL_SUB {
for action in ALL_ACTION {
let vendor = vendor_compute_fee(base, hw, sub, action);
let unified = unified_fee(base as u128, hw, sub, action);
match unified {
FeeOutcome::Blocked => assert_eq!(
vendor,
u64::MAX,
"divergencia Blocked en ({hw:?},{sub:?},{action:?})"
),
FeeOutcome::Charge { user, .. } => assert_eq!(
user,
vendor as u128,
"divergencia user en base={base} ({hw:?},{sub:?},{action:?})"
),
}
}
}
}
}
}
/// Invariante del pool (Opción B anti-regresiva): `pool == net × bp/1e4`,
/// EXACTAMENTE el subsidio constitucional del costo base — independiente de
/// la suscripción. Y `user == vendor compute_fee`.
#[test]
fn invariante_pool_sobre_costo_base() {
for base in [1u128, 1_000, 123_456_789] {
for hw in ALL_HW {
for sub in ALL_SUB {
for action in ALL_ACTION {
if let FeeOutcome::Charge { user, pool } =
unified_fee(base, hw, sub, action)
{
let expected_pool = base
.saturating_mul(
crate::fee_schedule::subsidy_basis_points(hw, action) as u128,
)
/ 10_000;
assert_eq!(
pool, expected_pool,
"pool != net×bp/1e4 en base={base} ({hw:?},{sub:?},{action:?})"
);
let vendor = vendor_compute_fee(base as u64, hw, sub, action);
if vendor != u64::MAX {
assert_eq!(user, vendor as u128, "user != vendor");
}
}
}
}
}
}
}
/// Lobo blanco sub_mult ORTOGONAL (David 2026-06-10): la carga del pool
/// (subsidio de hardware #16) es IDÉNTICA across subscripciones para cada
/// (hw, action). La suscripción premium multiplica SOLO lo que paga el
/// usuario; JAMÁS infla ni erosiona el subsidio del commons. (Refuta la
/// regresividad del audit: un Ultra NO drena el pool más que un Free.)
#[test]
fn lobo_blanco_pool_ortogonal_a_suscripcion_inmutable_16() {
let base = 1_000_000u128;
for hw in ALL_HW {
for action in ALL_ACTION {
let mut pool_seen: Option<u128> = None;
for sub in ALL_SUB {
if let FeeOutcome::Charge { pool, .. } = unified_fee(base, hw, sub, action) {
match pool_seen {
None => pool_seen = Some(pool),
Some(prev) => assert_eq!(
pool, prev,
"ORTOGONALIDAD VIOLADA: el subsidio del pool de \
({hw:?},{action:?}) cambió con la suscripción {sub:?} \
({prev} → {pool})"
),
}
}
}
}
}
}
/// Inmutable #16: ratio Pi5/Ultra = 0.1× exacto en el fee-path vivo
/// (la promesa textual de tiers.rs que la legacy CHN-8 violaba — 0.143×).
#[test]
fn inmutable_16_ratio_pi5_ultra_exacto() {
let base = 1_000_000u128;
let pi5 = unified_fee(
base,
HardwareTier::EmbeddedArm,
SubscriptionTier::Free,
ChainAction::Write,
);
let ultra = unified_fee(
base,
HardwareTier::Ultra,
SubscriptionTier::Free,
ChainAction::Write,
);
match (pi5, ultra) {
(
FeeOutcome::Charge { user: pi5_user, .. },
FeeOutcome::Charge { user: ultra_user, .. },
) => {
assert_eq!(pi5_user * 10, ultra_user, "ratio 0.1x Pi5/Ultra (Write)");
}
other => panic!("outcomes inesperados: {other:?}"),
}
}
/// El subsidio 100% (paso 1) precede al entitlement (paso 2) — orden
/// OCAFB-5 textual: EmbeddedArm+Free+Compute = GRATIS (educacional #16),
/// mientras Desktop+Free+Compute = Blocked (#37). Documentado y notificado
/// a Mathi; si el ratify 2026-06-21 cambia el orden, este test lo fuerza.
#[test]
fn orden_ocafb5_subsidio_precede_entitlement() {
assert_eq!(
unified_fee(
1_000,
HardwareTier::EmbeddedArm,
SubscriptionTier::Free,
ChainAction::Compute
),
FeeOutcome::Charge {
user: 0,
pool: 1_000
},
"EmbeddedArm educacional: gratis aunque Free+Compute esté gated"
);
assert_eq!(
unified_fee(
1_000,
HardwareTier::Desktop,
SubscriptionTier::Free,
ChainAction::Compute
),
FeeOutcome::Blocked,
"Desktop+Free+Compute: entitlement gate activo"
);
}
/// Free outcome: Read es gratis para TODOS los tiers (Inmutable #37) y el
/// pool cubre el reference completo.
#[test]
fn read_gratis_todos_los_tiers_inmutable_37() {
for hw in ALL_HW {
for sub in ALL_SUB {
match unified_fee(5_000, hw, sub, ChainAction::Read) {
FeeOutcome::Charge { user, .. } => {
assert_eq!(user, 0, "Read nunca se cobra ({hw:?},{sub:?})")
}
FeeOutcome::Blocked => panic!("Read jamás bloqueado ({hw:?},{sub:?})"),
}
}
}
}
/// net == 0 → Charge {0,0} (paridad con el early-return del adapter).
#[test]
fn fee_cero_outcome_cero() {
assert_eq!(
unified_fee(
0,
HardwareTier::Ultra,
SubscriptionTier::Ultra,
ChainAction::Write
),
FeeOutcome::Charge { user: 0, pool: 0 }
);
}
}
+54 -4
View File
@@ -159,13 +159,59 @@ parameter_types! {
pub const OperationalFeeMultiplier: u8 = 5;
}
/// FIX-6 Stage 2a: clasificador call→ChainAction del fee unificado (taxonomía
/// OCAFB-2). Vive aquí porque el runtime es el único sitio donde `RuntimeCall`
/// es concreto. Fallback conservador: `Write` (fee estándar — un wildcard
/// jamás regala subsidio; mismo principio que la alerta TLB de Ergon).
pub struct OriginCallClassifier;
impl origin_chain_fees::ClassifyChainAction<crate::RuntimeCall> for OriginCallClassifier {
fn classify(call: &crate::RuntimeCall) -> origin_chain_fees::ChainAction {
use origin_chain_fees::ChainAction;
match call {
crate::RuntimeCall::Balances(
pallet_balances::Call::transfer_allow_death { .. }
| pallet_balances::Call::transfer_keep_alive { .. }
| pallet_balances::Call::transfer_all { .. },
) => ChainAction::Transfer,
crate::RuntimeCall::Revive(
pallet_revive::Call::instantiate { .. }
| pallet_revive::Call::instantiate_with_code { .. }
// call_index 10: el deploy vía la capa EVM (Code::Upload). Sin
// este arm caería en el fallback Write y evadiría el
// entitlement-gate DeployContract (HIGH del audit Stage 2a).
| pallet_revive::Call::eth_instantiate_with_code { .. },
) => ChainAction::DeployContract,
crate::RuntimeCall::Revive(
pallet_revive::Call::call { .. }
| pallet_revive::Call::eth_call { .. }
// eth_transact: envoltorio EVM genérico (puede ser call/deploy/
// transfer). Lo tratamos como ejecución de contrato — su
// entitlement (Free=false) y subsidio coinciden con CallContract;
// refinar cuando la capa EVM se cablee plenamente.
| pallet_revive::Call::eth_transact { .. },
) => ChainAction::CallContract,
crate::RuntimeCall::Revive(pallet_revive::Call::upload_code { .. }) => {
ChainAction::UploadContractCode
}
_ => ChainAction::Write,
}
}
}
impl pallet_transaction_payment::Config for Runtime {
type RuntimeEvent = RuntimeEvent;
/// On-charge transaction canonical: usa `pallet_transaction_payment` default
/// charge mechanism. Phase 2.4 lo refactor canonical para integrar
/// `OriginFees` trait (CHN-8 multi-tier fee matrix).
/// FIX-6 Stage 2a (cierra V2-CA3): `OriginFeeAdapter` reemplaza al
/// `FungibleAdapter` default — la fórmula OCAFB-5 entra al fee-path VIVO:
/// el firmante paga su fracción según (HardwareTier × ChainAction ×
/// SubscriptionTier, tiers leídos ON-CHAIN de `TierProfiles`, jamás
/// caller-supplied), y el subsidy pool `og/subsy` (alimentado por la
/// emisión Index 13) cubre la fracción subsidiada — Inmutable #16 en cada
/// transacción. `OU = ()` deliberado: burn matrix CHN-2 ratificada = gas
/// fees 100% burn (el burn total = fee constitucional completo, deflación
/// que contrapesa la emisión Stage 1).
type OnChargeTransaction =
pallet_transaction_payment::FungibleAdapter<crate::Balances, ()>;
origin_chain_fees::OriginFeeAdapter<crate::Balances, (), OriginCallClassifier>;
type OperationalFeeMultiplier = OperationalFeeMultiplier;
type WeightToFee = frame_support::weights::IdentityFee<Balance>;
type LengthToFee = frame_support::weights::IdentityFee<Balance>;
@@ -368,6 +414,10 @@ impl origin_chain_provenance::Config for Runtime {
impl origin_chain_fees::Config for Runtime {
type RuntimeEvent = RuntimeEvent;
/// FIX-6 Stage 2a: la MISMA constante que alimenta la emisión (Index 13)
/// — pool-alimentación y pool-consumo apuntan a la misma cuenta og/subsy
/// (jamás re-declarar el literal: un typo divergiría las cuentas).
type SubsidyPalletId = EmissionSubsidyPalletId;
}
// ============================================================
@@ -45,6 +45,7 @@ use crate::{
};
use alloc::vec;
use alloc::vec::Vec;
use origin_chain_types::{HardwareTier, SubscriptionTier};
use sp_genesis_builder::PresetId;
// ============================================================
@@ -200,10 +201,14 @@ fn build_genesis(
endowed_accounts: Vec<(ChainAccountId, Balance)>,
validators: Vec<(ChainAccountId, AuraId, GrandpaId)>,
sudo_key: ChainAccountId,
// FIX-6 Stage 2a: perfiles de fee a sembrar en block 0. En dev/local las
// cuentas de prueba arrancan en un tier que puede desplegar contratos (Free
// está gated de DeployContract por Inmutable #37); en mainnet va vacío.
initial_fee_profiles: Vec<(ChainAccountId, HardwareTier, SubscriptionTier)>,
) -> RuntimeGenesisConfig {
use crate::{
AuraConfig, BalancesConfig, GrandpaConfig, ReviveConfig, SessionConfig, SudoConfig,
SystemConfig, TransactionPaymentConfig,
AuraConfig, BalancesConfig, FeesConfig, GrandpaConfig, ReviveConfig, SessionConfig,
SudoConfig, SystemConfig, TransactionPaymentConfig,
};
RuntimeGenesisConfig {
system: SystemConfig::default(),
@@ -212,6 +217,10 @@ fn build_genesis(
dev_accounts: None,
},
transaction_payment: TransactionPaymentConfig::default(),
// FIX-6 Stage 2a: perfiles de tier sembrados (el fee-path vivo los lee).
fees: FeesConfig {
initial_profiles: initial_fee_profiles,
},
aura: AuraConfig {
authorities: validators.iter().map(|(_, aura, _)| aura.clone()).collect(),
},
@@ -257,7 +266,11 @@ pub fn development_config_genesis() -> RuntimeGenesisConfig {
build_genesis(
vec![(alice_account.clone(), 100_000_000_u128 * Ogc::ONE)],
vec![alice_authority],
alice_account, // FIX-5: Alice = sudo key (Root) en dev
alice_account.clone(), // FIX-5: Alice = sudo key (Root) en dev
// FIX-6 Stage 2a: Alice arranca Ultra para poder desplegar contratos
// out-of-the-box en --dev (los Slices 3.x de contract testing no se
// bloquean por el entitlement-gate Free+DeployContract).
vec![(alice_account, HardwareTier::Ultra, SubscriptionTier::Ultra)],
)
}
@@ -279,13 +292,22 @@ pub fn local_testnet_config_genesis() -> RuntimeGenesisConfig {
build_genesis(
vec![
(alice.clone(), endowment),
(bob, endowment),
(charlie, endowment),
(dave, endowment),
(eve, endowment),
(bob.clone(), endowment),
(charlie.clone(), endowment),
(dave.clone(), endowment),
(eve.clone(), endowment),
],
vec![alice_authority, bob_authority],
alice, // FIX-5: Alice = sudo key (Root) en local_testnet
alice.clone(), // FIX-5: Alice = sudo key (Root) en local_testnet
// FIX-6 Stage 2a: las 5 cuentas dev arrancan Ultra para testing
// completo (contratos + compute) sin upgrade manual.
vec![
(alice, HardwareTier::Ultra, SubscriptionTier::Ultra),
(bob, HardwareTier::Ultra, SubscriptionTier::Ultra),
(charlie, HardwareTier::Ultra, SubscriptionTier::Ultra),
(dave, HardwareTier::Ultra, SubscriptionTier::Ultra),
(eve, HardwareTier::Ultra, SubscriptionTier::Ultra),
],
)
}
@@ -335,6 +357,10 @@ pub fn mainnet_genesis_config_genesis() -> RuntimeGenesisConfig {
// (multisig humano). PRE-LAUNCH Q1 2027: David ratifica la key real +
// sunset a governance (pallet-collective/referenda).
foundation_treasury,
// FIX-6 Stage 2a: mainnet arranca SIN perfiles sembrados — todos en el
// Default conservador (Desktop+Free) hasta que la attestation real
// (Mathi PerceptionAdapter + DID binding) asigne tiers verificados.
vec![],
)
}
+9 -3
View File
@@ -222,7 +222,10 @@ pub const IMPL_NAME: &str = "origin-chain-charon";
/// Canonical runtime version canonical. Bumped cada forkless upgrade via
/// `set_code` extrinsic governance canonical.
pub const RUNTIME_VERSION: u32 = 1;
/// v2: FIX-6 Stage 2a — fee unificado (OriginFeeAdapter reemplaza al
/// FungibleAdapter: cambia el COMPORTAMIENTO del cobro de fees, no el formato
/// de extrinsic — TRANSACTION_VERSION intacto).
pub const RUNTIME_VERSION: u32 = 2;
/// Canonical authoring version per Substrate semver canonical.
pub const AUTHORING_VERSION: u32 = 1;
@@ -794,8 +797,11 @@ mod tests {
use super::*;
#[test]
fn runtime_version_starts_at_one_canonical() {
assert_eq!(RUNTIME_VERSION, 1);
fn runtime_version_v2_fee_unificado() {
// v2 = FIX-6 Stage 2a (fee unificado). El formato de extrinsic NO
// cambió: solo el associated type OnChargeTransaction.
assert_eq!(RUNTIME_VERSION, 2);
assert_eq!(TRANSACTION_VERSION, 1);
}
#[test]