diff --git a/Cargo.lock b/Cargo.lock index 72d56de..4dd3ee8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6400,6 +6400,8 @@ dependencies = [ "frame-support", "frame-system", "origin-chain-types", + "pallet-balances", + "pallet-transaction-payment", "parity-scale-codec", "scale-info", "sp-api 39.0.0", diff --git a/crates/origin-chain-fees/Cargo.toml b/crates/origin-chain-fees/Cargo.toml index 99c0678..b96729b 100644 --- a/crates/origin-chain-fees/Cargo.toml +++ b/crates/origin-chain-fees/Cargo.toml @@ -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", ] diff --git a/crates/origin-chain-fees/src/lib.rs b/crates/origin-chain-fees/src/lib.rs index 957a2a8..1f759e0 100644 --- a/crates/origin-chain-fees/src/lib.rs +++ b/crates/origin-chain-fees/src/lib.rs @@ -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` 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> + IsType<::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; } /// 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 { - /// 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 { - /// 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 = + 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 { + /// `(account, hardware_tier, subscription_tier)` a sembrar en block 0. + pub initial_profiles: Vec<(T::AccountId, HardwareTier, SubscriptionTier)>, + } + + impl Default for GenesisConfig { + fn default() -> Self { + Self { + initial_profiles: Vec::new(), + } + } + } + + #[pallet::genesis_build] + impl BuildGenesisConfig for GenesisConfig { + fn build(&self) { + for (who, hw, sub) in &self.initial_profiles { + TierProfiles::::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 { + /// 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 { + /// 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 Pallet { - /// 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` 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, - 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::::EntitlementLocked.into()); - } - - // Step 4: update AccountFees canonical con saturating - let mut history = AccountFees::::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::::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::::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::::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::::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] diff --git a/crates/origin-chain-fees/src/mock.rs b/crates/origin-chain-fees/src/mock.rs index cc0f72a..8cbde5d 100644 --- a/crates/origin-chain-fees/src/mock.rs +++ b/crates/origin-chain-fees/src/mock.rs @@ -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 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; 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; + type AccountData = pallet_balances::AccountData; +} + +#[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 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; + +#[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; + type LengthToFee = frame_support::weights::IdentityFee; +} + +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])) diff --git a/crates/origin-chain-fees/src/tests_adapter.rs b/crates/origin-chain-fees/src/tests_adapter.rs new file mode 100644 index 0000000..e75a041 --- /dev/null +++ b/crates/origin-chain-fees/src/tests_adapter.rs @@ -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 = >::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 { + >::withdraw_fee(who, call, &info(), fee, tip) +} + +fn can_withdraw( + who: &sp_runtime::AccountId32, + call: &RuntimeCall, + fee: u128, + tip: u128, +) -> Result<(), TransactionValidityError> { + >::can_withdraw_fee(who, call, &info(), fee, tip) +} + +fn settle( + who: &sp_runtime::AccountId32, + corrected_fee: u128, + tip: u128, + li: Li, +) -> Result<(), TransactionValidityError> { + >::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 { + >::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; diff --git a/crates/origin-chain-fees/src/tests_integration.rs b/crates/origin-chain-fees/src/tests_integration.rs index 895b5d5..43f3981 100644 --- a/crates/origin-chain-fees/src/tests_integration.rs +++ b/crates/origin-chain-fees/src/tests_integration.rs @@ -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::::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::::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::::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::::EntitlementLocked - ); - - // Storage NOT updated (entitlement blocked early) - assert!(AccountFees::::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::::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::::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::::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::::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::::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::::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 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::::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})"); + } + } + } + } } diff --git a/crates/origin-chain-fees/src/unified.rs b/crates/origin-chain-fees/src/unified.rs new file mode 100644 index 0000000..166f088 --- /dev/null +++ b/crates/origin-chain-fees/src/unified.rs @@ -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 { + /// 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> { + /// Crédito retirado del firmante (fracción user + tip). `None` si 0. + user_credit: Option>, + /// Crédito retirado del subsidy pool `og/subsy`. `None` si 0 o pool seco. + pool_credit: Option>, + /// 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> Default for ChargeInfo { + 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` 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(PhantomData<(F, OU, C)>); + +impl OnChargeTransaction for OriginFeeAdapter +where + T: pallet_transaction_payment::Config + crate::Config, + F: Balanced, + OU: OnUnbalanced>, + C: ClassifyChainAction, +{ + type Balance = u128; + type LiquidityInfo = ChargeInfo; + + fn withdraw_fee( + who: &T::AccountId, + call: &T::RuntimeCall, + _dispatch_info: &DispatchInfoOf, + fee: Self::Balance, + tip: Self::Balance, + ) -> Result { + // `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::::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::::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, + 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::::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, + _post_info: &PostDispatchInfoOf, + 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::::zero()) + } else { + Debt::::zero() + }; + let adjusted_paid: Credit = 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::::subsidy_account(); + let refund_imbalance = if refund_amount > 0 { + F::deposit(&pool_account, refund_amount, Precision::BestEffort) + .unwrap_or_else(|_| Debt::::zero()) + } else { + Debt::::zero() + }; + let adjusted_pool: Credit = 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::::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 = 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 } + ); + } +} diff --git a/crates/origin-chain-runtime/src/configs.rs b/crates/origin-chain-runtime/src/configs.rs index fb18120..d934f00 100644 --- a/crates/origin-chain-runtime/src/configs.rs +++ b/crates/origin-chain-runtime/src/configs.rs @@ -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 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; + origin_chain_fees::OriginFeeAdapter; type OperationalFeeMultiplier = OperationalFeeMultiplier; type WeightToFee = frame_support::weights::IdentityFee; type LengthToFee = frame_support::weights::IdentityFee; @@ -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; } // ============================================================ diff --git a/crates/origin-chain-runtime/src/genesis_config_presets.rs b/crates/origin-chain-runtime/src/genesis_config_presets.rs index 11448b9..16e11de 100644 --- a/crates/origin-chain-runtime/src/genesis_config_presets.rs +++ b/crates/origin-chain-runtime/src/genesis_config_presets.rs @@ -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![], ) } diff --git a/crates/origin-chain-runtime/src/lib.rs b/crates/origin-chain-runtime/src/lib.rs index 62ee42a..9677c3b 100644 --- a/crates/origin-chain-runtime/src/lib.rs +++ b/crates/origin-chain-runtime/src/lib.rs @@ -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]