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:
1 parent
e6a803b54f
commit
3364994cce
10 files changed
+1435
-417
No files matched your search
Generated
+2
@@ -6400,6 +6400,8 @@ dependencies = [
|
|||||||
"frame-support",
|
"frame-support",
|
||||||
"frame-system",
|
"frame-system",
|
||||||
"origin-chain-types",
|
"origin-chain-types",
|
||||||
|
"pallet-balances",
|
||||||
|
"pallet-transaction-payment",
|
||||||
"parity-scale-codec",
|
"parity-scale-codec",
|
||||||
"scale-info",
|
"scale-info",
|
||||||
"sp-api 39.0.0",
|
"sp-api 39.0.0",
|
||||||
|
|||||||
@@ -24,11 +24,18 @@ sp-api = { workspace = true }
|
|||||||
parity-scale-codec = { workspace = true }
|
parity-scale-codec = { workspace = true }
|
||||||
scale-info = { 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]
|
[dev-dependencies]
|
||||||
# Mock runtime canonical para integration tests v0.0.22 — Pattern v0.0.18 mirror.
|
# 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-core = { workspace = true, default-features = true }
|
||||||
sp-io = { 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]
|
[features]
|
||||||
default = ["std"]
|
default = ["std"]
|
||||||
@@ -39,12 +46,16 @@ std = [
|
|||||||
"sp-api/std",
|
"sp-api/std",
|
||||||
"parity-scale-codec/std",
|
"parity-scale-codec/std",
|
||||||
"scale-info/std",
|
"scale-info/std",
|
||||||
|
"pallet-transaction-payment/std",
|
||||||
|
"sp-runtime/std",
|
||||||
]
|
]
|
||||||
runtime-benchmarks = [
|
runtime-benchmarks = [
|
||||||
"frame-support/runtime-benchmarks",
|
"frame-support/runtime-benchmarks",
|
||||||
"frame-system/runtime-benchmarks",
|
"frame-system/runtime-benchmarks",
|
||||||
|
"pallet-transaction-payment/runtime-benchmarks",
|
||||||
]
|
]
|
||||||
try-runtime = [
|
try-runtime = [
|
||||||
"frame-support/try-runtime",
|
"frame-support/try-runtime",
|
||||||
"frame-system/try-runtime",
|
"frame-system/try-runtime",
|
||||||
|
"pallet-transaction-payment/try-runtime",
|
||||||
]
|
]
|
||||||
+185
-127
@@ -50,12 +50,22 @@ pub use pallet::*;
|
|||||||
/// Vendor copy temporal Mathi `OitFeeSchedule` (cross-workspace boundary).
|
/// Vendor copy temporal Mathi `OitFeeSchedule` (cross-workspace boundary).
|
||||||
pub mod fee_schedule;
|
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.
|
/// Re-export canonical types desde origin-chain-types para consumers.
|
||||||
pub use fee_schedule::{
|
pub use fee_schedule::{
|
||||||
arc_mock, compute_fee, entitlement_unlocked, subsidy_basis_points, OitFeeSchedule,
|
arc_mock, compute_fee, entitlement_unlocked, subsidy_basis_points, OitFeeSchedule,
|
||||||
OitFeeScheduleMock,
|
OitFeeScheduleMock,
|
||||||
};
|
};
|
||||||
pub use origin_chain_types::{ChainAction, HardwareTier, SubscriptionTier};
|
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.
|
/// OCAFB-7 canonical: Runtime API canonical para cross-pillar consumers + light clients.
|
||||||
///
|
///
|
||||||
@@ -94,23 +104,29 @@ sp_api::decl_runtime_apis! {
|
|||||||
|
|
||||||
#[frame_support::pallet]
|
#[frame_support::pallet]
|
||||||
pub mod pallet {
|
pub mod pallet {
|
||||||
|
use alloc::vec::Vec;
|
||||||
use frame_support::pallet_prelude::*;
|
use frame_support::pallet_prelude::*;
|
||||||
use frame_system::pallet_prelude::*;
|
use frame_system::pallet_prelude::*;
|
||||||
use origin_chain_types::{ChainAction, HardwareTier, SubscriptionTier};
|
use origin_chain_types::{ChainAction, HardwareTier, SubscriptionTier};
|
||||||
|
|
||||||
/// Pallet configuration trait canonical.
|
/// Pallet configuration trait canonical.
|
||||||
///
|
///
|
||||||
/// Per joint DC OCAFB + ADR-0057 trait-seam discipline. Real `OitFeeSchedule`
|
/// Per joint DC OCAFB + ADR-0057 trait-seam discipline. FIX-6 Stage 2a
|
||||||
/// integration via `Arc<dyn OitFeeSchedule>` Config associated type viene en
|
/// añade `SubsidyPalletId`: el adapter `OriginFeeAdapter` deriva de aquí
|
||||||
/// v0.0.22 — scaffold v0.0.20 keeps minimal canonical surface.
|
/// 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]
|
#[pallet::config]
|
||||||
pub trait Config: frame_system::Config {
|
pub trait Config: frame_system::Config {
|
||||||
/// Runtime event type canonical wired into `RuntimeEvent` aggregate.
|
/// Runtime event type canonical wired into `RuntimeEvent` aggregate.
|
||||||
type RuntimeEvent: From<Event<Self>>
|
type RuntimeEvent: From<Event<Self>>
|
||||||
+ IsType<<Self as frame_system::Config>::RuntimeEvent>;
|
+ IsType<<Self as frame_system::Config>::RuntimeEvent>;
|
||||||
|
|
||||||
// Future canonical v0.0.22:
|
/// PalletId del subsidy pool (`og/subsy`, proof-of-equity Inmutable
|
||||||
// type OitFeeSchedule: crate::OitFeeSchedule;
|
/// #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.
|
/// Pallet definition canonical.
|
||||||
@@ -118,9 +134,10 @@ pub mod pallet {
|
|||||||
/// v0.0.20 scaffold: storage layout canonical + Config + Events + Errors +
|
/// v0.0.20 scaffold: storage layout canonical + Config + Events + Errors +
|
||||||
/// scaffold dispatchable. Real impl wire-up Mathi `OitFeeSchedule` trait
|
/// scaffold dispatchable. Real impl wire-up Mathi `OitFeeSchedule` trait
|
||||||
/// vía Config canonical viene en v0.0.22.
|
/// vía Config canonical viene en v0.0.22.
|
||||||
/// StorageVersion en código (FIX-2 lifecycle, audit V1-H2/V2-CA2): arranca
|
/// StorageVersion en código (FIX-2 lifecycle, audit V1-H2/V2-CA2).
|
||||||
/// en 0 (genesis). Habilita migraciones futuras vía on_chain_storage_version().
|
/// v1: FIX-6 Stage 2a añade `TierProfiles` (ValueQuery con default —
|
||||||
const STORAGE_VERSION: StorageVersion = StorageVersion::new(0);
|
/// no requiere migración de datos, bump por higiene de lifecycle).
|
||||||
|
const STORAGE_VERSION: StorageVersion = StorageVersion::new(1);
|
||||||
|
|
||||||
#[pallet::pallet]
|
#[pallet::pallet]
|
||||||
#[pallet::storage_version(STORAGE_VERSION)]
|
#[pallet::storage_version(STORAGE_VERSION)]
|
||||||
@@ -163,59 +180,9 @@ pub mod pallet {
|
|||||||
pub action_count: u64,
|
pub action_count: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Events emisión canonical (OCAFB-7 anticipated extension).
|
/// FIX-6 Stage 2a: perfil de tiers on-chain del account — el insumo del
|
||||||
#[pallet::event]
|
/// fee unificado. Cierra el tier-fraud del audit (V2): los tiers NUNCA son
|
||||||
#[pallet::generate_deposit(pub(super) fn deposit_event)]
|
/// caller-supplied en el fee-path vivo; se leen de este storage.
|
||||||
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).
|
|
||||||
#[derive(
|
#[derive(
|
||||||
Encode,
|
Encode,
|
||||||
Decode,
|
Decode,
|
||||||
@@ -228,70 +195,137 @@ pub mod pallet {
|
|||||||
Eq,
|
Eq,
|
||||||
RuntimeDebug,
|
RuntimeDebug,
|
||||||
)]
|
)]
|
||||||
pub enum FeeRejectionReason {
|
pub struct TierProfile {
|
||||||
/// OCAFB-4 canonical: entitlement bloqueado per matrix.
|
/// HardwareTier atestado (eje subsidio Inmutable #16).
|
||||||
EntitlementLocked,
|
pub hw: HardwareTier,
|
||||||
/// OCAFB-5 canonical: balance insuficiente.
|
/// SubscriptionTier vigente (eje sub_mult ORTOGONAL).
|
||||||
InsufficientBalance,
|
pub sub: SubscriptionTier,
|
||||||
/// OCAFB-5 canonical: fee overflow saturation.
|
|
||||||
FeeOverflow,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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]
|
#[pallet::call]
|
||||||
impl<T: Config> Pallet<T> {
|
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()`
|
/// **Root-only HOY** (vía sudo Index 12 / governance): conservador
|
||||||
/// + storage update `AccountFees` + event emission canonical. Mathi
|
/// deliberado — nadie se auto-declara EmbeddedArm para capturar 90%
|
||||||
/// `OitFeeSchedule` trait consumed CLIENT-SIDE (off-chain) — tx submitter
|
/// de subsidio (cierra el tier-fraud del audit V2). **Sunset**: cuando
|
||||||
/// queries Mathi per-user → assembles tx con hw + sub baked → submits.
|
/// la attestation real aterrice (Mathi PerceptionAdapter + DID binding
|
||||||
/// Pattern análogo Phase 2.1 `attest_asset(origin, ValidationAttestation)`
|
/// FIX-3/FIX-4), este dispatchable migra a verificación de attestation
|
||||||
/// (ValidationAttestation pre-assembled by Noesis client-side).
|
/// firmada en vez de Root fiat.
|
||||||
///
|
|
||||||
/// 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
|
|
||||||
#[pallet::call_index(0)]
|
#[pallet::call_index(0)]
|
||||||
#[pallet::weight(Weight::from_parts(10_000, 0))]
|
#[pallet::weight(Weight::from_parts(10_000, 0))]
|
||||||
pub fn compute_fee_for_action(
|
pub fn set_tier_profile(
|
||||||
origin: OriginFor<T>,
|
origin: OriginFor<T>,
|
||||||
action: ChainAction,
|
who: T::AccountId,
|
||||||
base: u64,
|
|
||||||
hw: HardwareTier,
|
hw: HardwareTier,
|
||||||
sub: SubscriptionTier,
|
sub: SubscriptionTier,
|
||||||
) -> DispatchResult {
|
) -> DispatchResult {
|
||||||
// Step 1: ensure origin signed canonical
|
ensure_root(origin)?;
|
||||||
let who = ensure_signed(origin)?;
|
TierProfiles::<T>::insert(&who, TierProfile { hw, sub });
|
||||||
|
Self::deposit_event(Event::TierProfileSet { who, hw, sub });
|
||||||
// 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,
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,6 +349,38 @@ pub mod pallet {
|
|||||||
) -> u64 {
|
) -> u64 {
|
||||||
crate::compute_fee(base, hw, sub, action)
|
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)]
|
#[cfg(test)]
|
||||||
mod tests_integration;
|
mod tests_integration;
|
||||||
|
|
||||||
|
// FIX-6 Stage 2a — tests E2E del OriginFeeAdapter contra balances reales.
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests_adapter;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -338,19 +408,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn pallet_compiles_canonical() {
|
fn pallet_compiles_canonical() {
|
||||||
// Pallet compiles canonical — verified by cargo check.
|
// Pallet compiles canonical — verified by cargo check.
|
||||||
// Real mock runtime integration tests = `tests_integration` module v0.0.22.
|
// Fee-path vivo E2E = `tests_adapter`; preview read-only = `tests_integration`.
|
||||||
}
|
|
||||||
|
|
||||||
#[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)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -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
|
//! v0.0.22: mock minimal (`System` + `Fees`) para E2E del dispatchable
|
||||||
//! E2E tests dispatchable + storage + event assertions per OCAFB-5 compute_fee
|
//! `compute_fee_for_action` (OCAFB-5 flow).
|
||||||
//! flow canonical:
|
|
||||||
//!
|
//!
|
||||||
//! 1. ensure_signed canonical
|
//! FIX-6 Stage 2a (v0.0.58): extendido con `Balances` + `TransactionPayment`
|
||||||
//! 2. Vendor compute_fee canonical (3-step formula OCAFB-5)
|
//! para los tests del `OriginFeeAdapter` (OnChargeTransaction custom): cobros
|
||||||
//! 3. Handle entitlement blocked (u64::MAX → Error::EntitlementLocked + event)
|
//! reales con subsidio del pool `og/subsy`, refund path, entitlement blocked,
|
||||||
//! 4. AccountFees<StorageMap> update canonical saturating arithmetic
|
//! tier profiles on-chain.
|
||||||
//! 5. Emit FeeCharged event canonical
|
|
||||||
//!
|
//!
|
||||||
//! Pattern v0.0.18 mirror (Phase 2.1 mock runtime integration tests). Mismo
|
//! Pattern v0.0.18 mirror: construct_runtime! + derive_impl + new_test_ext.
|
||||||
//! pattern construct_runtime! + derive_impl + new_test_ext + helpers.
|
|
||||||
|
|
||||||
#![cfg(test)]
|
#![cfg(test)]
|
||||||
|
|
||||||
use crate as origin_chain_fees;
|
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;
|
use sp_runtime::BuildStorage;
|
||||||
|
|
||||||
/// Mock block type canonical para tests.
|
/// Mock block type canonical para tests.
|
||||||
@@ -25,6 +24,8 @@ type Block = frame_system::mocking::MockBlock<Test>;
|
|||||||
frame_support::construct_runtime!(
|
frame_support::construct_runtime!(
|
||||||
pub enum Test {
|
pub enum Test {
|
||||||
System: frame_system,
|
System: frame_system,
|
||||||
|
Balances: pallet_balances,
|
||||||
|
TransactionPayment: pallet_transaction_payment,
|
||||||
Fees: origin_chain_fees,
|
Fees: origin_chain_fees,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -34,10 +35,58 @@ impl frame_system::Config for Test {
|
|||||||
type Block = Block;
|
type Block = Block;
|
||||||
type AccountId = sp_runtime::AccountId32;
|
type AccountId = sp_runtime::AccountId32;
|
||||||
type Lookup = sp_runtime::traits::IdentityLookup<Self::AccountId>;
|
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 {
|
impl origin_chain_fees::Config for Test {
|
||||||
type RuntimeEvent = RuntimeEvent;
|
type RuntimeEvent = RuntimeEvent;
|
||||||
|
type SubsidyPalletId = MockSubsidyPalletId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test externalities builder canonical (pattern v0.0.18 mirror).
|
/// Test externalities builder canonical (pattern v0.0.18 mirror).
|
||||||
@@ -55,6 +104,20 @@ pub fn new_test_ext() -> sp_io::TestExternalities {
|
|||||||
ext
|
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).
|
/// Test origin account canonical (ALICE pattern, [1u8; 32] AccountId32).
|
||||||
pub fn alice_origin() -> RuntimeOrigin {
|
pub fn alice_origin() -> RuntimeOrigin {
|
||||||
RuntimeOrigin::signed(sp_runtime::AccountId32::new([1u8; 32]))
|
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;
|
||||||
@@ -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:
|
//! FIX-6 Stage 2a: estos tests validaban el dispatchable legacy
|
||||||
//!
|
//! `compute_fee_for_action` (removido — escribía `AccountFees` con tiers
|
||||||
//! | Scenario | HW | Sub | Action | Expected outcome |
|
//! 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
|
||||||
//! | scenario_1_pi5_write_subsidy_90 | EmbeddedArm | Free | Write | fee=10% base (subsidy 9000 bp) |
|
//! OCAFB-5 fuera del fee-path vivo: pricing cross-pillar para Mathi AMIS /
|
||||||
//! | scenario_2_ultra_write_full_price | Ultra | Pro | Write | fee=base × 1.5 multiplier |
|
//! Irhis Web / Trinity / Codex / Atelier vía `FeesApi::query_fee_for_action`).
|
||||||
//! | scenario_3_read_always_free_inmutable_37 | Desktop | Free | Read | fee=0 (subsidy 100%) |
|
//! El cobro real con tiers on-chain + subsidio del pool se prueba en
|
||||||
//! | scenario_4_free_compute_blocked_entitlement | Desktop | Free | Compute | Error::EntitlementLocked + event |
|
//! `tests_adapter`.
|
||||||
//! | 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.
|
|
||||||
|
|
||||||
#![cfg(test)]
|
#![cfg(test)]
|
||||||
|
|
||||||
use crate::mock::{alice_account, alice_origin, bob_account, bob_origin, new_test_ext, Fees, RuntimeEvent, System, Test};
|
use crate::mock::{new_test_ext, Fees};
|
||||||
use crate::{AccountFees, Error, Event};
|
|
||||||
use frame_support::{assert_noop, assert_ok};
|
|
||||||
use origin_chain_types::{ChainAction, HardwareTier, SubscriptionTier};
|
use origin_chain_types::{ChainAction, HardwareTier, SubscriptionTier};
|
||||||
|
|
||||||
// ─── Scenario 1: Pi 5 Write subsidy 90% canonical (Inmutable #16) ────
|
/// Helper: el preview que consumen los clientes (== `FeesApi::query_fee_for_action`).
|
||||||
|
fn quote(action: ChainAction, base: u64, hw: HardwareTier, sub: SubscriptionTier) -> u64 {
|
||||||
#[test]
|
new_test_ext().execute_with(|| Fees::query_fee(action, base, hw, sub))
|
||||||
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,
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Scenario 2: Ultra Write Pro multiplier canonical ───────────────
|
// ─── Inmutable #16 anti-clasista ─────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scenario_2_ultra_write_full_price_canonical() {
|
fn pi5_write_paga_10_por_ciento() {
|
||||||
new_test_ext().execute_with(|| {
|
// EmbeddedArm Free Write: base 10000 × 1000bp × 10000bp / 1e8 = 1000 (10%).
|
||||||
let base = 1_000u64;
|
assert_eq!(
|
||||||
|
quote(ChainAction::Write, 10_000, HardwareTier::EmbeddedArm, SubscriptionTier::Free),
|
||||||
assert_ok!(Fees::compute_fee_for_action(
|
1_000
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Scenario 3: Read always free canonical (Inmutable #37) ──────────
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scenario_3_read_always_free_inmutable_37_canonical() {
|
fn ultra_pro_write_multiplica_1_5() {
|
||||||
new_test_ext().execute_with(|| {
|
// Ultra Pro Write: base 1000 × 10000bp (sin subsidio) × 15000bp / 1e8 = 1500.
|
||||||
assert_ok!(Fees::compute_fee_for_action(
|
assert_eq!(
|
||||||
alice_origin(),
|
quote(ChainAction::Write, 1_000, HardwareTier::Ultra, SubscriptionTier::Pro),
|
||||||
ChainAction::Read,
|
1_500
|
||||||
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
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Scenario 4: Free Compute blocked entitlement canonical (Inmutable #37) ──
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scenario_4_free_compute_blocked_entitlement_canonical() {
|
fn pi5_estrictamente_menor_que_ultra_lobo_blanco() {
|
||||||
new_test_ext().execute_with(|| {
|
let pi5 = quote(ChainAction::Write, 10_000, HardwareTier::EmbeddedArm, SubscriptionTier::Free);
|
||||||
// OCAFB-4 entitlement enforce: Free + Compute → blocked
|
let ultra = quote(ChainAction::Write, 10_000, HardwareTier::Ultra, SubscriptionTier::Ultra);
|
||||||
// FRAME canonical: dispatchable returns `Err(EntitlementLocked)` SIN event
|
assert!(pi5 < ultra, "Inmutable #16: Pi5 {pi5} debe ser < Ultra {ultra}");
|
||||||
// emission (events revierten on Err per FRAME dispatch transaction).
|
assert_eq!(pi5, 1_000);
|
||||||
// `EntitlementBlocked` event variant reserved para Phase 2.5 future
|
assert_eq!(ultra, 20_000);
|
||||||
// `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).
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Scenario 5: Pro Compute unlocked canonical ─────────────────────
|
// ─── Inmutable #37 Free perpetua ─────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scenario_5_pro_compute_unlocked_canonical() {
|
fn read_siempre_gratis_todos_los_tiers() {
|
||||||
new_test_ext().execute_with(|| {
|
for hw in [
|
||||||
let base = 100u64;
|
HardwareTier::EmbeddedArm,
|
||||||
|
HardwareTier::Portable,
|
||||||
assert_ok!(Fees::compute_fee_for_action(
|
HardwareTier::Desktop,
|
||||||
alice_origin(),
|
HardwareTier::Ultra,
|
||||||
ChainAction::Compute,
|
] {
|
||||||
base,
|
assert_eq!(quote(ChainAction::Read, 999_999, hw, SubscriptionTier::Free), 0);
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Scenario 6: Ultra Compute premium canonical (Inmutable #18) ────
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scenario_6_ultra_compute_premium_canonical() {
|
fn free_compute_blocked_devuelve_sentinel() {
|
||||||
new_test_ext().execute_with(|| {
|
// El preview read-only reporta u64::MAX para entitlement blocked (el
|
||||||
let base = 1_000u64;
|
// fee-path vivo lo traduce a InvalidTransaction::Custom — ver tests_adapter).
|
||||||
|
assert_eq!(
|
||||||
assert_ok!(Fees::compute_fee_for_action(
|
quote(ChainAction::Compute, 100, HardwareTier::Desktop, SubscriptionTier::Free),
|
||||||
alice_origin(),
|
u64::MAX
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Scenario 7: Storage accumulates over multiple writes canonical ──
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scenario_7_storage_accumulates_canonical() {
|
fn pro_compute_unlocked() {
|
||||||
new_test_ext().execute_with(|| {
|
// Desktop Pro Compute: 100 × 7500bp × 15000bp / 1e8 = 112 (floor 112.5).
|
||||||
let base = 1_000u64;
|
assert_eq!(
|
||||||
|
quote(ChainAction::Compute, 100, HardwareTier::Desktop, SubscriptionTier::Pro),
|
||||||
// 3 writes secuenciales
|
112
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 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]
|
#[test]
|
||||||
fn scenario_8_chain_action_ordinal_stable_wire_canonical() {
|
fn pi5_compute_educacional_gratis() {
|
||||||
// Wire format invariant — sin dependency mock runtime
|
// 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::Read.ordinal(), 0);
|
||||||
assert_eq!(ChainAction::Write.ordinal(), 1);
|
assert_eq!(ChainAction::Write.ordinal(), 1);
|
||||||
assert_eq!(ChainAction::Compute.ordinal(), 2);
|
assert_eq!(ChainAction::Compute.ordinal(), 2);
|
||||||
assert_eq!(ChainAction::Transfer.ordinal(), 3);
|
assert_eq!(ChainAction::Transfer.ordinal(), 3);
|
||||||
|
for action in [
|
||||||
// Roundtrip canonical
|
ChainAction::Read,
|
||||||
for action in [ChainAction::Read, ChainAction::Write, ChainAction::Compute, ChainAction::Transfer] {
|
ChainAction::Write,
|
||||||
|
ChainAction::Compute,
|
||||||
|
ChainAction::Transfer,
|
||||||
|
] {
|
||||||
assert_eq!(ChainAction::from_ordinal(action.ordinal()), Some(action));
|
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]
|
#[test]
|
||||||
fn scenario_9_inmutable_16_anti_clasista_lobo_blanco_canonical() {
|
fn preview_coincide_con_la_fraccion_user_del_adapter() {
|
||||||
new_test_ext().execute_with(|| {
|
// Para tiers donde el preview NO está blocked ni saturado, query_fee debe
|
||||||
let base = 10_000u64;
|
// coincidir con unified_fee.user (lo que el adapter cobra al usuario).
|
||||||
|
use crate::unified::{unified_fee, FeeOutcome};
|
||||||
// Pi 5 user (EmbeddedArm + Free)
|
for base in [1u64, 1_000, 1_000_000] {
|
||||||
assert_ok!(Fees::compute_fee_for_action(
|
for hw in [HardwareTier::EmbeddedArm, HardwareTier::Desktop, HardwareTier::Ultra] {
|
||||||
alice_origin(),
|
for sub in [SubscriptionTier::Free, SubscriptionTier::Pro, SubscriptionTier::Ultra] {
|
||||||
ChainAction::Write,
|
let q = quote(ChainAction::Transfer, base, hw, sub);
|
||||||
base,
|
if let FeeOutcome::Charge { user, .. } =
|
||||||
HardwareTier::EmbeddedArm,
|
unified_fee(base as u128, hw, sub, ChainAction::Transfer)
|
||||||
SubscriptionTier::Free,
|
{
|
||||||
));
|
assert_eq!(q as u128, user, "preview != user ({hw:?},{sub:?},base={base})");
|
||||||
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,
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -159,13 +159,59 @@ parameter_types! {
|
|||||||
pub const OperationalFeeMultiplier: u8 = 5;
|
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 {
|
impl pallet_transaction_payment::Config for Runtime {
|
||||||
type RuntimeEvent = RuntimeEvent;
|
type RuntimeEvent = RuntimeEvent;
|
||||||
/// On-charge transaction canonical: usa `pallet_transaction_payment` default
|
/// FIX-6 Stage 2a (cierra V2-CA3): `OriginFeeAdapter` reemplaza al
|
||||||
/// charge mechanism. Phase 2.4 lo refactor canonical para integrar
|
/// `FungibleAdapter` default — la fórmula OCAFB-5 entra al fee-path VIVO:
|
||||||
/// `OriginFees` trait (CHN-8 multi-tier fee matrix).
|
/// 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 =
|
type OnChargeTransaction =
|
||||||
pallet_transaction_payment::FungibleAdapter<crate::Balances, ()>;
|
origin_chain_fees::OriginFeeAdapter<crate::Balances, (), OriginCallClassifier>;
|
||||||
type OperationalFeeMultiplier = OperationalFeeMultiplier;
|
type OperationalFeeMultiplier = OperationalFeeMultiplier;
|
||||||
type WeightToFee = frame_support::weights::IdentityFee<Balance>;
|
type WeightToFee = frame_support::weights::IdentityFee<Balance>;
|
||||||
type LengthToFee = 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 {
|
impl origin_chain_fees::Config for Runtime {
|
||||||
type RuntimeEvent = RuntimeEvent;
|
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;
|
||||||
use alloc::vec::Vec;
|
use alloc::vec::Vec;
|
||||||
|
use origin_chain_types::{HardwareTier, SubscriptionTier};
|
||||||
use sp_genesis_builder::PresetId;
|
use sp_genesis_builder::PresetId;
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -200,10 +201,14 @@ fn build_genesis(
|
|||||||
endowed_accounts: Vec<(ChainAccountId, Balance)>,
|
endowed_accounts: Vec<(ChainAccountId, Balance)>,
|
||||||
validators: Vec<(ChainAccountId, AuraId, GrandpaId)>,
|
validators: Vec<(ChainAccountId, AuraId, GrandpaId)>,
|
||||||
sudo_key: ChainAccountId,
|
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 {
|
) -> RuntimeGenesisConfig {
|
||||||
use crate::{
|
use crate::{
|
||||||
AuraConfig, BalancesConfig, GrandpaConfig, ReviveConfig, SessionConfig, SudoConfig,
|
AuraConfig, BalancesConfig, FeesConfig, GrandpaConfig, ReviveConfig, SessionConfig,
|
||||||
SystemConfig, TransactionPaymentConfig,
|
SudoConfig, SystemConfig, TransactionPaymentConfig,
|
||||||
};
|
};
|
||||||
RuntimeGenesisConfig {
|
RuntimeGenesisConfig {
|
||||||
system: SystemConfig::default(),
|
system: SystemConfig::default(),
|
||||||
@@ -212,6 +217,10 @@ fn build_genesis(
|
|||||||
dev_accounts: None,
|
dev_accounts: None,
|
||||||
},
|
},
|
||||||
transaction_payment: TransactionPaymentConfig::default(),
|
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 {
|
aura: AuraConfig {
|
||||||
authorities: validators.iter().map(|(_, aura, _)| aura.clone()).collect(),
|
authorities: validators.iter().map(|(_, aura, _)| aura.clone()).collect(),
|
||||||
},
|
},
|
||||||
@@ -257,7 +266,11 @@ pub fn development_config_genesis() -> RuntimeGenesisConfig {
|
|||||||
build_genesis(
|
build_genesis(
|
||||||
vec![(alice_account.clone(), 100_000_000_u128 * Ogc::ONE)],
|
vec![(alice_account.clone(), 100_000_000_u128 * Ogc::ONE)],
|
||||||
vec![alice_authority],
|
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(
|
build_genesis(
|
||||||
vec![
|
vec![
|
||||||
(alice.clone(), endowment),
|
(alice.clone(), endowment),
|
||||||
(bob, endowment),
|
(bob.clone(), endowment),
|
||||||
(charlie, endowment),
|
(charlie.clone(), endowment),
|
||||||
(dave, endowment),
|
(dave.clone(), endowment),
|
||||||
(eve, endowment),
|
(eve.clone(), endowment),
|
||||||
],
|
],
|
||||||
vec![alice_authority, bob_authority],
|
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 +
|
// (multisig humano). PRE-LAUNCH Q1 2027: David ratifica la key real +
|
||||||
// sunset a governance (pallet-collective/referenda).
|
// sunset a governance (pallet-collective/referenda).
|
||||||
foundation_treasury,
|
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![],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -222,7 +222,10 @@ pub const IMPL_NAME: &str = "origin-chain-charon";
|
|||||||
|
|
||||||
/// Canonical runtime version canonical. Bumped cada forkless upgrade via
|
/// Canonical runtime version canonical. Bumped cada forkless upgrade via
|
||||||
/// `set_code` extrinsic governance canonical.
|
/// `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.
|
/// Canonical authoring version per Substrate semver canonical.
|
||||||
pub const AUTHORING_VERSION: u32 = 1;
|
pub const AUTHORING_VERSION: u32 = 1;
|
||||||
@@ -794,8 +797,11 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_version_starts_at_one_canonical() {
|
fn runtime_version_v2_fee_unificado() {
|
||||||
assert_eq!(RUNTIME_VERSION, 1);
|
// 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]
|
#[test]
|
||||||
|
|||||||
Reference in new issue
Block a user