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
+1440
-422
No files matched your search
Generated
+2
@@ -6400,6 +6400,8 @@ dependencies = [
|
||||
"frame-support",
|
||||
"frame-system",
|
||||
"origin-chain-types",
|
||||
"pallet-balances",
|
||||
"pallet-transaction-payment",
|
||||
"parity-scale-codec",
|
||||
"scale-info",
|
||||
"sp-api 39.0.0",
|
||||
|
||||
@@ -24,11 +24,18 @@ sp-api = { workspace = true }
|
||||
parity-scale-codec = { workspace = true }
|
||||
scale-info = { workspace = true }
|
||||
|
||||
# FIX-6 Stage 2a — fee unificado: OnChargeTransaction custom (OriginFeeAdapter)
|
||||
# + derivacion de la cuenta del subsidy pool (AccountIdConversion).
|
||||
pallet-transaction-payment = { workspace = true }
|
||||
sp-runtime = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# Mock runtime canonical para integration tests v0.0.22 — Pattern v0.0.18 mirror.
|
||||
# FIX-6 Stage 2a: + pallet-balances para los tests del OriginFeeAdapter
|
||||
# (withdraw/refund/pool reales contra balances).
|
||||
sp-core = { workspace = true, default-features = true }
|
||||
sp-io = { workspace = true, default-features = true }
|
||||
sp-runtime = { workspace = true, default-features = true }
|
||||
pallet-balances = { workspace = true, default-features = true }
|
||||
|
||||
[features]
|
||||
default = ["std"]
|
||||
@@ -39,12 +46,16 @@ std = [
|
||||
"sp-api/std",
|
||||
"parity-scale-codec/std",
|
||||
"scale-info/std",
|
||||
"pallet-transaction-payment/std",
|
||||
"sp-runtime/std",
|
||||
]
|
||||
runtime-benchmarks = [
|
||||
"frame-support/runtime-benchmarks",
|
||||
"frame-system/runtime-benchmarks",
|
||||
"pallet-transaction-payment/runtime-benchmarks",
|
||||
]
|
||||
try-runtime = [
|
||||
"frame-support/try-runtime",
|
||||
"frame-system/try-runtime",
|
||||
"pallet-transaction-payment/try-runtime",
|
||||
]
|
||||
+185
-127
@@ -50,12 +50,22 @@ pub use pallet::*;
|
||||
/// Vendor copy temporal Mathi `OitFeeSchedule` (cross-workspace boundary).
|
||||
pub mod fee_schedule;
|
||||
|
||||
/// FIX-6 Stage 2a — fee unificado: `OriginFeeAdapter` (OnChargeTransaction
|
||||
/// custom) + `unified_fee` (OCAFB-5 en u128, outcome tipado) + clasificador
|
||||
/// call→ChainAction. Pone los Inmutables #16/#37 en el fee-path VIVO y
|
||||
/// consume el subsidy pool `og/subsy` (cierra el crítico V2-CA3).
|
||||
pub mod unified;
|
||||
|
||||
/// Re-export canonical types desde origin-chain-types para consumers.
|
||||
pub use fee_schedule::{
|
||||
arc_mock, compute_fee, entitlement_unlocked, subsidy_basis_points, OitFeeSchedule,
|
||||
OitFeeScheduleMock,
|
||||
};
|
||||
pub use origin_chain_types::{ChainAction, HardwareTier, SubscriptionTier};
|
||||
pub use unified::{
|
||||
unified_fee, ChargeInfo, ClassifyChainAction, FeeOutcome, OriginFeeAdapter,
|
||||
ENTITLEMENT_BLOCKED_TX_CODE,
|
||||
};
|
||||
|
||||
/// OCAFB-7 canonical: Runtime API canonical para cross-pillar consumers + light clients.
|
||||
///
|
||||
@@ -94,23 +104,29 @@ sp_api::decl_runtime_apis! {
|
||||
|
||||
#[frame_support::pallet]
|
||||
pub mod pallet {
|
||||
use alloc::vec::Vec;
|
||||
use frame_support::pallet_prelude::*;
|
||||
use frame_system::pallet_prelude::*;
|
||||
use origin_chain_types::{ChainAction, HardwareTier, SubscriptionTier};
|
||||
|
||||
/// Pallet configuration trait canonical.
|
||||
///
|
||||
/// Per joint DC OCAFB + ADR-0057 trait-seam discipline. Real `OitFeeSchedule`
|
||||
/// integration via `Arc<dyn OitFeeSchedule>` Config associated type viene en
|
||||
/// v0.0.22 — scaffold v0.0.20 keeps minimal canonical surface.
|
||||
/// Per joint DC OCAFB + ADR-0057 trait-seam discipline. FIX-6 Stage 2a
|
||||
/// añade `SubsidyPalletId`: el adapter `OriginFeeAdapter` deriva de aquí
|
||||
/// la cuenta del subsidy pool `og/subsy` (la MISMA constante del runtime
|
||||
/// que alimenta la emisión Stage 1 — nunca re-declarar el literal).
|
||||
#[pallet::config]
|
||||
pub trait Config: frame_system::Config {
|
||||
/// Runtime event type canonical wired into `RuntimeEvent` aggregate.
|
||||
type RuntimeEvent: From<Event<Self>>
|
||||
+ IsType<<Self as frame_system::Config>::RuntimeEvent>;
|
||||
|
||||
// Future canonical v0.0.22:
|
||||
// type OitFeeSchedule: crate::OitFeeSchedule;
|
||||
/// PalletId del subsidy pool (`og/subsy`, proof-of-equity Inmutable
|
||||
/// #16). El runtime cablea la MISMA constante que usa la emisión
|
||||
/// (Index 13) para que pool-alimentación y pool-consumo apunten a la
|
||||
/// misma cuenta determinística.
|
||||
#[pallet::constant]
|
||||
type SubsidyPalletId: Get<frame_support::PalletId>;
|
||||
}
|
||||
|
||||
/// Pallet definition canonical.
|
||||
@@ -118,9 +134,10 @@ pub mod pallet {
|
||||
/// v0.0.20 scaffold: storage layout canonical + Config + Events + Errors +
|
||||
/// scaffold dispatchable. Real impl wire-up Mathi `OitFeeSchedule` trait
|
||||
/// vía Config canonical viene en v0.0.22.
|
||||
/// StorageVersion en código (FIX-2 lifecycle, audit V1-H2/V2-CA2): arranca
|
||||
/// en 0 (genesis). Habilita migraciones futuras vía on_chain_storage_version().
|
||||
const STORAGE_VERSION: StorageVersion = StorageVersion::new(0);
|
||||
/// StorageVersion en código (FIX-2 lifecycle, audit V1-H2/V2-CA2).
|
||||
/// v1: FIX-6 Stage 2a añade `TierProfiles` (ValueQuery con default —
|
||||
/// no requiere migración de datos, bump por higiene de lifecycle).
|
||||
const STORAGE_VERSION: StorageVersion = StorageVersion::new(1);
|
||||
|
||||
#[pallet::pallet]
|
||||
#[pallet::storage_version(STORAGE_VERSION)]
|
||||
@@ -163,59 +180,9 @@ pub mod pallet {
|
||||
pub action_count: u64,
|
||||
}
|
||||
|
||||
/// Events emisión canonical (OCAFB-7 anticipated extension).
|
||||
#[pallet::event]
|
||||
#[pallet::generate_deposit(pub(super) fn deposit_event)]
|
||||
pub enum Event<T: Config> {
|
||||
/// Fee charged canonical via `compute_fee_for_action` dispatchable.
|
||||
///
|
||||
/// Materializa cross-pillar economy materialized compile-detect canonical.
|
||||
FeeCharged {
|
||||
/// Account canonical que pagó el fee.
|
||||
who: T::AccountId,
|
||||
/// Action canonical taxonomized per OCAFB-2.
|
||||
action: ChainAction,
|
||||
/// Fee charged canonical en OGC base units.
|
||||
fee_charged: u64,
|
||||
},
|
||||
/// Fee refunded canonical (governance refund OR error rollback).
|
||||
///
|
||||
/// **Marihan refinement Phase 2.5 anticipated**: refund pattern para
|
||||
/// dispute resolution Tribunal canonical.
|
||||
FeeRefunded {
|
||||
/// Account canonical que recibió refund.
|
||||
who: T::AccountId,
|
||||
/// Action canonical original cuando charged.
|
||||
action: ChainAction,
|
||||
/// Amount canonical refunded en OGC base units.
|
||||
amount: u64,
|
||||
},
|
||||
/// Entitlement blocked canonical — user tried locked action.
|
||||
///
|
||||
/// Per OCAFB-4 entitlement matrix + Inmutable #37 Free perpetua enforce
|
||||
/// (e.g. Free user Compute action → blocked + event emitted).
|
||||
EntitlementBlocked {
|
||||
/// Account canonical que intentó action.
|
||||
who: T::AccountId,
|
||||
/// Action canonical bloqueada.
|
||||
action: ChainAction,
|
||||
},
|
||||
}
|
||||
|
||||
/// Pallet errors canonical.
|
||||
#[pallet::error]
|
||||
pub enum Error<T> {
|
||||
/// OCAFB-5 canonical: balance insuficiente para pagar fee canonical.
|
||||
InsufficientBalance,
|
||||
/// OCAFB-4 canonical: entitlement bloqueado (e.g. Free + Compute).
|
||||
EntitlementLocked,
|
||||
/// OCAFB-5 canonical: fee overflow (`u64::MAX` saturation reached).
|
||||
FeeOverflow,
|
||||
/// Marihan refinement Phase 2.5 anticipated canonical: account NO encontrado.
|
||||
AccountNotFound,
|
||||
}
|
||||
|
||||
/// Rejection reasons canonical (mirror Phase 2.1 PROV pattern).
|
||||
/// FIX-6 Stage 2a: perfil de tiers on-chain del account — el insumo del
|
||||
/// fee unificado. Cierra el tier-fraud del audit (V2): los tiers NUNCA son
|
||||
/// caller-supplied en el fee-path vivo; se leen de este storage.
|
||||
#[derive(
|
||||
Encode,
|
||||
Decode,
|
||||
@@ -228,70 +195,137 @@ pub mod pallet {
|
||||
Eq,
|
||||
RuntimeDebug,
|
||||
)]
|
||||
pub enum FeeRejectionReason {
|
||||
/// OCAFB-4 canonical: entitlement bloqueado per matrix.
|
||||
EntitlementLocked,
|
||||
/// OCAFB-5 canonical: balance insuficiente.
|
||||
InsufficientBalance,
|
||||
/// OCAFB-5 canonical: fee overflow saturation.
|
||||
FeeOverflow,
|
||||
pub struct TierProfile {
|
||||
/// HardwareTier atestado (eje subsidio Inmutable #16).
|
||||
pub hw: HardwareTier,
|
||||
/// SubscriptionTier vigente (eje sub_mult ORTOGONAL).
|
||||
pub sub: SubscriptionTier,
|
||||
}
|
||||
|
||||
/// Dispatchables canonical (real impl v0.0.22).
|
||||
impl Default for TierProfile {
|
||||
/// Default CONSERVADOR: `Desktop + Free` — paga fee estándar con el
|
||||
/// subsidio moderado de Desktop, sin entitlements premium. Nadie
|
||||
/// entra al subsidio fuerte (EmbeddedArm/Portable) por defecto: el
|
||||
/// upgrade requiere `set_tier_profile` (Root hoy; attestation-driven
|
||||
/// vía Mathi PerceptionAdapter + DID binding cuando aterrice — sunset
|
||||
/// documentado).
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
hw: HardwareTier::Desktop,
|
||||
sub: SubscriptionTier::Free,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// FIX-6 Stage 2a: tier profiles on-chain por account. ValueQuery →
|
||||
/// accounts sin perfil resuelven al Default conservador (Desktop+Free).
|
||||
#[pallet::storage]
|
||||
#[pallet::getter(fn tier_profile)]
|
||||
pub type TierProfiles<T: Config> =
|
||||
StorageMap<_, Blake2_128Concat, T::AccountId, TierProfile, ValueQuery>;
|
||||
|
||||
/// FIX-6 Stage 2a: perfiles iniciales sembrados en génesis. En dev/local
|
||||
/// el preset siembra las cuentas de prueba con un tier que puede desplegar
|
||||
/// contratos (Free no puede — Inmutable #37 baseline); en mainnet va vacío
|
||||
/// (todos arrancan en el Default conservador hasta que la attestation real
|
||||
/// asigne tiers). Da a la fundación control de génesis sobre quién arranca
|
||||
/// con qué subsidio.
|
||||
#[pallet::genesis_config]
|
||||
pub struct GenesisConfig<T: Config> {
|
||||
/// `(account, hardware_tier, subscription_tier)` a sembrar en block 0.
|
||||
pub initial_profiles: Vec<(T::AccountId, HardwareTier, SubscriptionTier)>,
|
||||
}
|
||||
|
||||
impl<T: Config> Default for GenesisConfig<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
initial_profiles: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[pallet::genesis_build]
|
||||
impl<T: Config> BuildGenesisConfig for GenesisConfig<T> {
|
||||
fn build(&self) {
|
||||
for (who, hw, sub) in &self.initial_profiles {
|
||||
TierProfiles::<T>::insert(who, TierProfile { hw: *hw, sub: *sub });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Events del pallet — solo los que SE EMITEN de verdad (FIX-6 Stage 2a
|
||||
/// removió `FeeCharged`/`FeeRefunded`/`EntitlementBlocked`: el dispatchable
|
||||
/// legacy `compute_fee_for_action` que los emitía/reservaba quedó superado
|
||||
/// por el fee-path vivo; el audit los marcó como "eventos que afirman un
|
||||
/// cobro que no ocurrió" / "nunca emitidos").
|
||||
#[pallet::event]
|
||||
#[pallet::generate_deposit(pub(super) fn deposit_event)]
|
||||
pub enum Event<T: Config> {
|
||||
/// FIX-6 Stage 2a: tier profile actualizado (Root hoy; attestation
|
||||
/// futura). Auditable on-chain: quién entró/salió de qué subsidio.
|
||||
TierProfileSet {
|
||||
/// Account cuyo perfil cambió.
|
||||
who: T::AccountId,
|
||||
/// HardwareTier asignado.
|
||||
hw: HardwareTier,
|
||||
/// SubscriptionTier asignado.
|
||||
sub: SubscriptionTier,
|
||||
},
|
||||
/// FIX-6 Stage 2a: settlement REAL del fee-path vivo — lo que el
|
||||
/// `OriginFeeAdapter` efectivamente cobró (a diferencia del
|
||||
/// `TransactionFeePaid` de transaction-payment, que reporta el fee
|
||||
/// SIN la transformación tier — gotcha v43 documentado).
|
||||
TransactionFeeSettled {
|
||||
/// Account que pagó su fracción.
|
||||
who: T::AccountId,
|
||||
/// Acción clasificada del extrinsic.
|
||||
action: ChainAction,
|
||||
/// Fracción pagada por el usuario (base units, SIN tip).
|
||||
user_paid: u128,
|
||||
/// Fracción cubierta por el subsidy pool `og/subsy`.
|
||||
pool_covered: u128,
|
||||
},
|
||||
}
|
||||
|
||||
/// Pallet errors.
|
||||
#[pallet::error]
|
||||
pub enum Error<T> {
|
||||
/// El `TierProfile` propuesto es inválido (reservado para validación
|
||||
/// futura cuando `set_tier_profile` migre a attestation-driven).
|
||||
InvalidTierProfile,
|
||||
}
|
||||
|
||||
/// Dispatchables.
|
||||
///
|
||||
/// FIX-6 Stage 2a removió el dispatchable legacy `compute_fee_for_action`:
|
||||
/// computaba la fórmula y escribía `AccountFees` SIN cobrar dinero y con
|
||||
/// `hw`/`sub` **caller-supplied** — el audit lo marcó como vector de
|
||||
/// envenenamiento del ledger (cualquiera inflaba su `total_paid` con
|
||||
/// entradas ficticias). Ahora el cobro real ocurre en el fee-path vivo
|
||||
/// (`OriginFeeAdapter` → `note_settlement`), que lee los tiers ON-CHAIN; el
|
||||
/// preview read-only vive en `query_fee` / `FeesApi`.
|
||||
#[pallet::call]
|
||||
impl<T: Config> Pallet<T> {
|
||||
/// OCAFB-5 canonical: `compute_fee_for_action` real dispatchable.
|
||||
/// FIX-6 Stage 2a: asigna el `TierProfile` on-chain de un account —
|
||||
/// el insumo del fee unificado (subsidio #16 + entitlements #37).
|
||||
///
|
||||
/// **Real impl v0.0.22**: Pure function call vendor `compute_fee()`
|
||||
/// + storage update `AccountFees` + event emission canonical. Mathi
|
||||
/// `OitFeeSchedule` trait consumed CLIENT-SIDE (off-chain) — tx submitter
|
||||
/// queries Mathi per-user → assembles tx con hw + sub baked → submits.
|
||||
/// Pattern análogo Phase 2.1 `attest_asset(origin, ValidationAttestation)`
|
||||
/// (ValidationAttestation pre-assembled by Noesis client-side).
|
||||
///
|
||||
/// Flow canonical 5-step per OCAFB-5:
|
||||
/// 1. `ensure_signed(origin)` canonical
|
||||
/// 2. Call vendor `compute_fee(base, hw, sub, action)` canonical (pure fn)
|
||||
/// 3. Si `fee == u64::MAX` → entitlement blocked → emit event + error
|
||||
/// 4. Update `AccountFees<StorageMap>` con saturating arithmetic
|
||||
/// 5. Emit `FeeCharged` event canonical
|
||||
/// **Root-only HOY** (vía sudo Index 12 / governance): conservador
|
||||
/// deliberado — nadie se auto-declara EmbeddedArm para capturar 90%
|
||||
/// de subsidio (cierra el tier-fraud del audit V2). **Sunset**: cuando
|
||||
/// la attestation real aterrice (Mathi PerceptionAdapter + DID binding
|
||||
/// FIX-3/FIX-4), este dispatchable migra a verificación de attestation
|
||||
/// firmada en vez de Root fiat.
|
||||
#[pallet::call_index(0)]
|
||||
#[pallet::weight(Weight::from_parts(10_000, 0))]
|
||||
pub fn compute_fee_for_action(
|
||||
pub fn set_tier_profile(
|
||||
origin: OriginFor<T>,
|
||||
action: ChainAction,
|
||||
base: u64,
|
||||
who: T::AccountId,
|
||||
hw: HardwareTier,
|
||||
sub: SubscriptionTier,
|
||||
) -> DispatchResult {
|
||||
// Step 1: ensure origin signed canonical
|
||||
let who = ensure_signed(origin)?;
|
||||
|
||||
// Step 2: compute fee canonical via vendor pure fn (OCAFB-5 formula)
|
||||
let fee = crate::compute_fee(base, hw, sub, action);
|
||||
|
||||
// Step 3: handle entitlement blocked canonical (OCAFB-4 Inmutable #37)
|
||||
// Note: NO event emission here — FRAME canonical reverts events on Err.
|
||||
// `EntitlementBlocked` event variant reserved para Phase 2.5 future
|
||||
// `with_storage_layer` pattern (preserve events on error vía dispatch
|
||||
// transaction). Caller infers blocked entitlement from `Err(EntitlementLocked)`.
|
||||
if fee == u64::MAX {
|
||||
return Err(Error::<T>::EntitlementLocked.into());
|
||||
}
|
||||
|
||||
// Step 4: update AccountFees<StorageMap> canonical con saturating
|
||||
let mut history = AccountFees::<T>::get(&who).unwrap_or_default();
|
||||
history.total_paid = history.total_paid.saturating_add(fee as u128);
|
||||
history.action_count = history.action_count.saturating_add(1);
|
||||
AccountFees::<T>::insert(&who, history);
|
||||
|
||||
// Step 5: emit FeeCharged event canonical (OCAFB-5)
|
||||
Self::deposit_event(Event::FeeCharged {
|
||||
who,
|
||||
action,
|
||||
fee_charged: fee,
|
||||
});
|
||||
|
||||
ensure_root(origin)?;
|
||||
TierProfiles::<T>::insert(&who, TierProfile { hw, sub });
|
||||
Self::deposit_event(Event::TierProfileSet { who, hw, sub });
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -315,6 +349,38 @@ pub mod pallet {
|
||||
) -> u64 {
|
||||
crate::compute_fee(base, hw, sub, action)
|
||||
}
|
||||
|
||||
/// FIX-6 Stage 2a: cuenta del subsidy pool `og/subsy` (determinística,
|
||||
/// keyless — derivada del MISMO PalletId que alimenta la emisión).
|
||||
pub fn subsidy_account() -> T::AccountId {
|
||||
use sp_runtime::traits::AccountIdConversion;
|
||||
T::SubsidyPalletId::get().into_account_truncating()
|
||||
}
|
||||
|
||||
/// FIX-6 Stage 2a: registra un settlement REAL del fee-path vivo.
|
||||
/// Llamado por `OriginFeeAdapter::correct_and_deposit_fee` post-dispatch
|
||||
/// con los montos FINALES (post-refund). Actualiza `AccountFees` (que
|
||||
/// ahora refleja dinero realmente cobrado, cerrando el hallazgo "el
|
||||
/// ledger cree que cobró cuando no cobró") + emite el evento de verdad.
|
||||
pub fn note_settlement(
|
||||
who: &T::AccountId,
|
||||
action: ChainAction,
|
||||
user_paid: u128,
|
||||
pool_covered: u128,
|
||||
) {
|
||||
if user_paid > 0 || pool_covered > 0 {
|
||||
let mut history = AccountFees::<T>::get(who).unwrap_or_default();
|
||||
history.total_paid = history.total_paid.saturating_add(user_paid);
|
||||
history.action_count = history.action_count.saturating_add(1);
|
||||
AccountFees::<T>::insert(who, history);
|
||||
}
|
||||
Self::deposit_event(Event::TransactionFeeSettled {
|
||||
who: who.clone(),
|
||||
action,
|
||||
user_paid,
|
||||
pool_covered,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,6 +394,10 @@ pub mod mock;
|
||||
#[cfg(test)]
|
||||
mod tests_integration;
|
||||
|
||||
// FIX-6 Stage 2a — tests E2E del OriginFeeAdapter contra balances reales.
|
||||
#[cfg(test)]
|
||||
mod tests_adapter;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -338,19 +408,7 @@ mod tests {
|
||||
#[test]
|
||||
fn pallet_compiles_canonical() {
|
||||
// Pallet compiles canonical — verified by cargo check.
|
||||
// Real mock runtime integration tests = `tests_integration` module v0.0.22.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fee_rejection_reason_variants_canonical() {
|
||||
use pallet::FeeRejectionReason;
|
||||
let r1 = FeeRejectionReason::EntitlementLocked;
|
||||
let r2 = FeeRejectionReason::InsufficientBalance;
|
||||
let _r3 = FeeRejectionReason::FeeOverflow;
|
||||
assert_ne!(
|
||||
core::mem::discriminant(&r1),
|
||||
core::mem::discriminant(&r2)
|
||||
);
|
||||
// Fee-path vivo E2E = `tests_adapter`; preview read-only = `tests_integration`.
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
//! # Mock runtime canonical para integration tests v0.0.22
|
||||
//! # Mock runtime canonical para integration tests
|
||||
//!
|
||||
//! Provee mock minimal `Test` runtime composition (`System` + `Fees` solo) para
|
||||
//! E2E tests dispatchable + storage + event assertions per OCAFB-5 compute_fee
|
||||
//! flow canonical:
|
||||
//! v0.0.22: mock minimal (`System` + `Fees`) para E2E del dispatchable
|
||||
//! `compute_fee_for_action` (OCAFB-5 flow).
|
||||
//!
|
||||
//! 1. ensure_signed canonical
|
||||
//! 2. Vendor compute_fee canonical (3-step formula OCAFB-5)
|
||||
//! 3. Handle entitlement blocked (u64::MAX → Error::EntitlementLocked + event)
|
||||
//! 4. AccountFees<StorageMap> update canonical saturating arithmetic
|
||||
//! 5. Emit FeeCharged event canonical
|
||||
//! FIX-6 Stage 2a (v0.0.58): extendido con `Balances` + `TransactionPayment`
|
||||
//! para los tests del `OriginFeeAdapter` (OnChargeTransaction custom): cobros
|
||||
//! reales con subsidio del pool `og/subsy`, refund path, entitlement blocked,
|
||||
//! tier profiles on-chain.
|
||||
//!
|
||||
//! Pattern v0.0.18 mirror (Phase 2.1 mock runtime integration tests). Mismo
|
||||
//! pattern construct_runtime! + derive_impl + new_test_ext + helpers.
|
||||
//! Pattern v0.0.18 mirror: construct_runtime! + derive_impl + new_test_ext.
|
||||
|
||||
#![cfg(test)]
|
||||
|
||||
use crate as origin_chain_fees;
|
||||
use frame_support::derive_impl;
|
||||
use frame_support::traits::fungible::Mutate;
|
||||
use frame_support::{derive_impl, parameter_types, PalletId};
|
||||
use origin_chain_types::ChainAction;
|
||||
use sp_runtime::BuildStorage;
|
||||
|
||||
/// Mock block type canonical para tests.
|
||||
@@ -25,6 +24,8 @@ type Block = frame_system::mocking::MockBlock<Test>;
|
||||
frame_support::construct_runtime!(
|
||||
pub enum Test {
|
||||
System: frame_system,
|
||||
Balances: pallet_balances,
|
||||
TransactionPayment: pallet_transaction_payment,
|
||||
Fees: origin_chain_fees,
|
||||
}
|
||||
);
|
||||
@@ -34,10 +35,58 @@ impl frame_system::Config for Test {
|
||||
type Block = Block;
|
||||
type AccountId = sp_runtime::AccountId32;
|
||||
type Lookup = sp_runtime::traits::IdentityLookup<Self::AccountId>;
|
||||
type AccountData = pallet_balances::AccountData<u128>;
|
||||
}
|
||||
|
||||
#[derive_impl(pallet_balances::config_preludes::TestDefaultConfig)]
|
||||
impl pallet_balances::Config for Test {
|
||||
type AccountStore = System;
|
||||
type Balance = u128;
|
||||
}
|
||||
|
||||
/// Clasificador del mock: mapea calls del Test runtime a ChainAction para
|
||||
/// ejercer las filas de la matriz OCAFB sin un runtime real:
|
||||
/// - Balances transfer* → Transfer
|
||||
/// - System::remark_with_event → Compute (mapping ARTIFICIAL de test — ejerce
|
||||
/// el entitlement gate Free+Compute=Blocked y el subsidio educacional)
|
||||
/// - todo lo demás → Write (fallback conservador, igual que el runtime real)
|
||||
pub struct MockClassifier;
|
||||
impl origin_chain_fees::ClassifyChainAction<RuntimeCall> for MockClassifier {
|
||||
fn classify(call: &RuntimeCall) -> ChainAction {
|
||||
match call {
|
||||
RuntimeCall::Balances(
|
||||
pallet_balances::Call::transfer_allow_death { .. }
|
||||
| pallet_balances::Call::transfer_keep_alive { .. }
|
||||
| pallet_balances::Call::transfer_all { .. },
|
||||
) => ChainAction::Transfer,
|
||||
RuntimeCall::System(frame_system::Call::remark_with_event { .. }) => {
|
||||
ChainAction::Compute
|
||||
}
|
||||
_ => ChainAction::Write,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// El adapter bajo test: fee unificado + burn (OU = `()` per CHN-2 gas 100%).
|
||||
pub type MockFeeAdapter = origin_chain_fees::OriginFeeAdapter<Balances, (), MockClassifier>;
|
||||
|
||||
#[derive_impl(pallet_transaction_payment::config_preludes::TestDefaultConfig)]
|
||||
impl pallet_transaction_payment::Config for Test {
|
||||
type RuntimeEvent = RuntimeEvent;
|
||||
type OnChargeTransaction = MockFeeAdapter;
|
||||
type WeightToFee = frame_support::weights::IdentityFee<u128>;
|
||||
type LengthToFee = frame_support::weights::IdentityFee<u128>;
|
||||
}
|
||||
|
||||
parameter_types! {
|
||||
/// MISMO PalletId del runtime real (og/subsy) — el literal vive una sola
|
||||
/// vez por contexto; los tests verifican contra esta cuenta derivada.
|
||||
pub const MockSubsidyPalletId: PalletId = PalletId(*b"og/subsy");
|
||||
}
|
||||
|
||||
impl origin_chain_fees::Config for Test {
|
||||
type RuntimeEvent = RuntimeEvent;
|
||||
type SubsidyPalletId = MockSubsidyPalletId;
|
||||
}
|
||||
|
||||
/// Test externalities builder canonical (pattern v0.0.18 mirror).
|
||||
@@ -55,6 +104,20 @@ pub fn new_test_ext() -> sp_io::TestExternalities {
|
||||
ext
|
||||
}
|
||||
|
||||
/// Builder FIX-6 Stage 2a: genesis + ALICE/BOB fondeadas + subsidy pool
|
||||
/// fondeado con `pool_balance` (simula la alimentación de la emisión Stage 1).
|
||||
pub fn new_funded_ext(pool_balance: u128) -> sp_io::TestExternalities {
|
||||
let mut ext = new_test_ext();
|
||||
ext.execute_with(|| {
|
||||
Balances::set_balance(&alice_account(), 1_000_000_000);
|
||||
Balances::set_balance(&bob_account(), 1_000_000_000);
|
||||
if pool_balance > 0 {
|
||||
Balances::set_balance(&Fees::subsidy_account(), pool_balance);
|
||||
}
|
||||
});
|
||||
ext
|
||||
}
|
||||
|
||||
/// Test origin account canonical (ALICE pattern, [1u8; 32] AccountId32).
|
||||
pub fn alice_origin() -> RuntimeOrigin {
|
||||
RuntimeOrigin::signed(sp_runtime::AccountId32::new([1u8; 32]))
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
//! # Tests E2E del `OriginFeeAdapter` — FIX-6 Stage 2a (V2-CA3 cerrado)
|
||||
//!
|
||||
//! Ejercitan el fee-path VIVO contra balances reales en el mock runtime:
|
||||
//! withdraw con subsidio del pool `og/subsy`, refund post-dispatch, entitlement
|
||||
//! blocked en el tx-pool, tier profiles on-chain, tip passthrough.
|
||||
|
||||
#![cfg(test)]
|
||||
|
||||
use crate::mock::*;
|
||||
use crate::pallet::TierProfile;
|
||||
use crate::ENTITLEMENT_BLOCKED_TX_CODE;
|
||||
use frame_support::dispatch::{DispatchInfo, PostDispatchInfo};
|
||||
use frame_support::traits::fungible::Inspect;
|
||||
use frame_support::{assert_noop, assert_ok};
|
||||
use origin_chain_types::{ChainAction, HardwareTier, SubscriptionTier};
|
||||
use pallet_transaction_payment::OnChargeTransaction;
|
||||
use sp_runtime::transaction_validity::{InvalidTransaction, TransactionValidityError};
|
||||
|
||||
type Adapter = MockFeeAdapter;
|
||||
type Li = <Adapter as OnChargeTransaction<Test>>::LiquidityInfo;
|
||||
|
||||
/// Wrapper cualificado — el trait es genérico sobre T (E0283 sin esto).
|
||||
fn withdraw(
|
||||
who: &sp_runtime::AccountId32,
|
||||
call: &RuntimeCall,
|
||||
fee: u128,
|
||||
tip: u128,
|
||||
) -> Result<Li, TransactionValidityError> {
|
||||
<Adapter as OnChargeTransaction<Test>>::withdraw_fee(who, call, &info(), fee, tip)
|
||||
}
|
||||
|
||||
fn can_withdraw(
|
||||
who: &sp_runtime::AccountId32,
|
||||
call: &RuntimeCall,
|
||||
fee: u128,
|
||||
tip: u128,
|
||||
) -> Result<(), TransactionValidityError> {
|
||||
<Adapter as OnChargeTransaction<Test>>::can_withdraw_fee(who, call, &info(), fee, tip)
|
||||
}
|
||||
|
||||
fn settle(
|
||||
who: &sp_runtime::AccountId32,
|
||||
corrected_fee: u128,
|
||||
tip: u128,
|
||||
li: Li,
|
||||
) -> Result<(), TransactionValidityError> {
|
||||
<Adapter as OnChargeTransaction<Test>>::correct_and_deposit_fee(
|
||||
who,
|
||||
&info(),
|
||||
&post_info(),
|
||||
corrected_fee,
|
||||
tip,
|
||||
li,
|
||||
)
|
||||
}
|
||||
|
||||
fn info() -> DispatchInfo {
|
||||
DispatchInfo::default()
|
||||
}
|
||||
|
||||
fn post_info() -> PostDispatchInfo {
|
||||
PostDispatchInfo::default()
|
||||
}
|
||||
|
||||
fn balance_of(who: &sp_runtime::AccountId32) -> u128 {
|
||||
<Balances as Inspect<_>>::balance(who)
|
||||
}
|
||||
|
||||
fn write_call() -> RuntimeCall {
|
||||
// remark → fallback Write en el MockClassifier
|
||||
RuntimeCall::System(frame_system::Call::remark { remark: alloc::vec![] })
|
||||
}
|
||||
|
||||
fn transfer_call() -> RuntimeCall {
|
||||
RuntimeCall::Balances(pallet_balances::Call::transfer_keep_alive {
|
||||
dest: bob_account(),
|
||||
value: 1,
|
||||
})
|
||||
}
|
||||
|
||||
fn compute_call() -> RuntimeCall {
|
||||
// remark_with_event → Compute (mapping artificial del mock)
|
||||
RuntimeCall::System(frame_system::Call::remark_with_event { remark: alloc::vec![] })
|
||||
}
|
||||
|
||||
fn set_profile(who: sp_runtime::AccountId32, hw: HardwareTier, sub: SubscriptionTier) {
|
||||
assert_ok!(Fees::set_tier_profile(RuntimeOrigin::root(), who, hw, sub));
|
||||
}
|
||||
|
||||
// ─── Tier profiles on-chain ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn default_profile_es_desktop_free_conservador() {
|
||||
new_test_ext().execute_with(|| {
|
||||
let profile = Fees::tier_profile(alice_account());
|
||||
assert_eq!(
|
||||
profile,
|
||||
TierProfile {
|
||||
hw: HardwareTier::Desktop,
|
||||
sub: SubscriptionTier::Free
|
||||
},
|
||||
"default conservador: nadie entra al subsidio fuerte sin Root"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_tier_profile_es_root_only() {
|
||||
new_test_ext().execute_with(|| {
|
||||
// Signed origin → BadOrigin (cierra el tier-fraud del audit V2).
|
||||
assert_noop!(
|
||||
Fees::set_tier_profile(
|
||||
alice_origin(),
|
||||
alice_account(),
|
||||
HardwareTier::EmbeddedArm,
|
||||
SubscriptionTier::Free
|
||||
),
|
||||
sp_runtime::DispatchError::BadOrigin
|
||||
);
|
||||
// Root sí puede + evento auditable.
|
||||
set_profile(
|
||||
alice_account(),
|
||||
HardwareTier::EmbeddedArm,
|
||||
SubscriptionTier::Pro,
|
||||
);
|
||||
assert_eq!(
|
||||
Fees::tier_profile(alice_account()),
|
||||
TierProfile {
|
||||
hw: HardwareTier::EmbeddedArm,
|
||||
sub: SubscriptionTier::Pro
|
||||
}
|
||||
);
|
||||
System::assert_has_event(RuntimeEvent::Fees(crate::Event::TierProfileSet {
|
||||
who: alice_account(),
|
||||
hw: HardwareTier::EmbeddedArm,
|
||||
sub: SubscriptionTier::Pro,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Withdraw: el usuario paga su fracción, el pool cubre el subsidio ──
|
||||
|
||||
#[test]
|
||||
fn desktop_free_write_usuario_75_pool_25() {
|
||||
new_funded_ext(1_000_000).execute_with(|| {
|
||||
let alice = alice_account();
|
||||
let pool = Fees::subsidy_account();
|
||||
let alice_pre = balance_of(&alice);
|
||||
let pool_pre = balance_of(&pool);
|
||||
|
||||
// Desktop+Free Write: subsidy 2500 bp → user 75%, pool 25%.
|
||||
let li = withdraw(&alice, &write_call(), 10_000, 0)
|
||||
.expect("withdraw ok");
|
||||
|
||||
assert_eq!(balance_of(&alice), alice_pre - 7_500, "usuario paga 75%");
|
||||
assert_eq!(balance_of(&pool), pool_pre - 2_500, "pool cubre 25%");
|
||||
|
||||
// Post-dispatch sin cambio de fee → settlement con los mismos montos.
|
||||
assert_ok!(settle(&alice, 10_000, 0, li));
|
||||
System::assert_has_event(RuntimeEvent::Fees(crate::Event::TransactionFeeSettled {
|
||||
who: alice_account(),
|
||||
action: ChainAction::Write,
|
||||
user_paid: 7_500,
|
||||
pool_covered: 2_500,
|
||||
}));
|
||||
// AccountFees ahora refleja dinero REALMENTE cobrado.
|
||||
let history = Fees::fee_history_of(alice_account()).expect("history");
|
||||
assert_eq!(history.total_paid, 7_500);
|
||||
assert_eq!(history.action_count, 1);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_arm_transfer_paga_10_por_ciento_inmutable_16() {
|
||||
new_funded_ext(1_000_000).execute_with(|| {
|
||||
let alice = alice_account();
|
||||
set_profile(alice.clone(), HardwareTier::EmbeddedArm, SubscriptionTier::Free);
|
||||
let alice_pre = balance_of(&alice);
|
||||
|
||||
let li = withdraw(&alice, &transfer_call(), 10_000, 0)
|
||||
.expect("withdraw ok");
|
||||
assert_eq!(
|
||||
balance_of(&alice),
|
||||
alice_pre - 1_000,
|
||||
"Pi 5 paga 0.1x — Inmutable #16 en el fee-path vivo"
|
||||
);
|
||||
assert_ok!(settle(&alice, 10_000, 0, li));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subsidio_educacional_100_gratis_para_el_usuario() {
|
||||
new_funded_ext(1_000_000).execute_with(|| {
|
||||
let alice = alice_account();
|
||||
set_profile(alice.clone(), HardwareTier::EmbeddedArm, SubscriptionTier::Free);
|
||||
let alice_pre = balance_of(&alice);
|
||||
let pool = Fees::subsidy_account();
|
||||
let pool_pre = balance_of(&pool);
|
||||
|
||||
// EmbeddedArm + Compute = subsidio 10000 bp (educacional, paso 1
|
||||
// OCAFB-5 precede al entitlement Free+Compute=blocked).
|
||||
let li = withdraw(&alice, &compute_call(), 10_000, 0)
|
||||
.expect("withdraw ok — gratis, no blocked");
|
||||
assert_eq!(balance_of(&alice), alice_pre, "usuario NO paga nada");
|
||||
assert_eq!(balance_of(&pool), pool_pre - 10_000, "el pool cubre todo");
|
||||
assert_ok!(settle(&alice, 10_000, 0, li));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pro_multiplica_hacia_arriba_ortogonal() {
|
||||
new_funded_ext(1_000_000).execute_with(|| {
|
||||
let alice = alice_account();
|
||||
let pool = Fees::subsidy_account();
|
||||
set_profile(alice.clone(), HardwareTier::Desktop, SubscriptionTier::Pro);
|
||||
let alice_pre = balance_of(&alice);
|
||||
let pool_pre = balance_of(&pool);
|
||||
|
||||
// Desktop+Pro Write: usuario 10_000 × 0.75 × 1.5 = 11_250 (el premium
|
||||
// paga MÁS). El pool cubre 10_000 × 0.25 = 2_500 — IDÉNTICO a Desktop
|
||||
// Free (anti-regresivo): el premium lo paga el usuario, NO el commons.
|
||||
let li = withdraw(&alice, &write_call(), 10_000, 0).expect("withdraw ok");
|
||||
assert_eq!(balance_of(&alice), alice_pre - 11_250, "usuario premium paga MÁS");
|
||||
assert_eq!(
|
||||
balance_of(&pool),
|
||||
pool_pre - 2_500,
|
||||
"el pool cubre lo MISMO que para un Free — ortogonal a la suscripción"
|
||||
);
|
||||
assert_ok!(settle(&alice, 10_000, 0, li));
|
||||
System::assert_has_event(RuntimeEvent::Fees(crate::Event::TransactionFeeSettled {
|
||||
who: alice_account(),
|
||||
action: ChainAction::Write,
|
||||
user_paid: 11_250,
|
||||
pool_covered: 2_500,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Entitlement gate en el tx-pool ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn free_compute_blocked_rechaza_la_tx() {
|
||||
new_funded_ext(1_000_000).execute_with(|| {
|
||||
let alice = alice_account(); // Desktop+Free default
|
||||
let expected: TransactionValidityError =
|
||||
InvalidTransaction::Custom(ENTITLEMENT_BLOCKED_TX_CODE).into();
|
||||
|
||||
let withdraw = withdraw(&alice, &compute_call(), 10_000, 0);
|
||||
assert_eq!(withdraw.err().expect("debe rechazar"), expected);
|
||||
|
||||
// can_withdraw_fee (tx-pool) — MISMA transformación, mismo rechazo.
|
||||
let can = can_withdraw(&alice, &compute_call(), 10_000, 0);
|
||||
assert_eq!(can.err().expect("debe rechazar en pool"), expected);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Pool seco: el usuario NUNCA degrada ─────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn pool_seco_no_degrada_al_usuario() {
|
||||
new_funded_ext(0).execute_with(|| {
|
||||
let alice = alice_account();
|
||||
set_profile(alice.clone(), HardwareTier::EmbeddedArm, SubscriptionTier::Free);
|
||||
let alice_pre = balance_of(&alice);
|
||||
|
||||
// Pool en 0: el usuario paga SOLO su fracción (1_000), sin error.
|
||||
let li = withdraw(&alice, &transfer_call(), 10_000, 0)
|
||||
.expect("withdraw ok aunque el pool esté seco");
|
||||
assert_eq!(balance_of(&alice), alice_pre - 1_000);
|
||||
|
||||
assert_ok!(settle(&alice, 10_000, 0, li));
|
||||
// Settlement honesto: pool_covered = 0.
|
||||
System::assert_has_event(RuntimeEvent::Fees(crate::Event::TransactionFeeSettled {
|
||||
who: alice_account(),
|
||||
action: ChainAction::Transfer,
|
||||
user_paid: 1_000,
|
||||
pool_covered: 0,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Refund path (gotcha v43: el call no viaja al refund) ────────────
|
||||
|
||||
#[test]
|
||||
fn refund_devuelve_el_sobrante_a_usuario_y_pool() {
|
||||
new_funded_ext(1_000_000).execute_with(|| {
|
||||
let alice = alice_account();
|
||||
let pool = Fees::subsidy_account();
|
||||
let alice_pre = balance_of(&alice);
|
||||
let pool_pre = balance_of(&pool);
|
||||
|
||||
// Withdraw con fee 10_000 (user 7_500 / pool 2_500)...
|
||||
let li = withdraw(&alice, &write_call(), 10_000, 0)
|
||||
.expect("withdraw ok");
|
||||
// ...el dispatch real costó 4_000 (user 3_000 / pool 1_000).
|
||||
assert_ok!(settle(&alice, 4_000, 0, li));
|
||||
assert_eq!(
|
||||
balance_of(&alice),
|
||||
alice_pre - 3_000,
|
||||
"usuario termina pagando la MISMA fracción del fee corregido"
|
||||
);
|
||||
assert_eq!(
|
||||
balance_of(&pool),
|
||||
pool_pre - 1_000,
|
||||
"el pool recupera su sobrante también"
|
||||
);
|
||||
System::assert_has_event(RuntimeEvent::Fees(crate::Event::TransactionFeeSettled {
|
||||
who: alice_account(),
|
||||
action: ChainAction::Write,
|
||||
user_paid: 3_000,
|
||||
pool_covered: 1_000,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Tip: ni se subsidia ni se multiplica ────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn tip_pasa_integro_sin_subsidio_ni_multiplicador() {
|
||||
new_funded_ext(1_000_000).execute_with(|| {
|
||||
let alice = alice_account();
|
||||
set_profile(alice.clone(), HardwareTier::EmbeddedArm, SubscriptionTier::Free);
|
||||
let alice_pre = balance_of(&alice);
|
||||
let pool = Fees::subsidy_account();
|
||||
let pool_pre = balance_of(&pool);
|
||||
|
||||
// fee 10_500 incluye tip 500 → net 10_000: user 1_000 + tip 500.
|
||||
let li = withdraw(&alice, &transfer_call(), 10_500, 500)
|
||||
.expect("withdraw ok");
|
||||
assert_eq!(
|
||||
balance_of(&alice),
|
||||
alice_pre - 1_500,
|
||||
"fracción subsidiada 1_000 + tip íntegro 500"
|
||||
);
|
||||
assert_eq!(
|
||||
balance_of(&pool),
|
||||
pool_pre - 9_000,
|
||||
"el pool cubre solo el net, jamás el tip"
|
||||
);
|
||||
assert_ok!(settle(&alice, 10_500, 500, li));
|
||||
System::assert_has_event(RuntimeEvent::Fees(crate::Event::TransactionFeeSettled {
|
||||
who: alice_account(),
|
||||
action: ChainAction::Transfer,
|
||||
user_paid: 1_000,
|
||||
pool_covered: 9_000,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Edge: fee 0 no toca cuentas (paridad FungibleAdapter) ───────────
|
||||
|
||||
#[test]
|
||||
fn fee_cero_no_toca_la_cuenta() {
|
||||
new_funded_ext(1_000).execute_with(|| {
|
||||
let alice = alice_account();
|
||||
let alice_pre = balance_of(&alice);
|
||||
|
||||
let li =
|
||||
withdraw(&alice, &write_call(), 0, 0).expect("withdraw ok");
|
||||
assert_eq!(balance_of(&alice), alice_pre, "cero retiro");
|
||||
assert_ok!(can_withdraw(&alice, &write_call(), 0, 0));
|
||||
assert_ok!(settle(&alice, 0, 0, li));
|
||||
});
|
||||
}
|
||||
|
||||
extern crate alloc;
|
||||
@@ -1,303 +1,141 @@
|
||||
//! # Integration tests canonical v0.0.22 — Mock runtime E2E OCAFB-5 flow
|
||||
//! # Integration tests — preview read-only de la fórmula OCAFB-5 (`query_fee`)
|
||||
//!
|
||||
//! 10 scenarios canonical compute_fee_for_action flow end-to-end via mock runtime:
|
||||
//!
|
||||
//! | Scenario | HW | Sub | Action | Expected outcome |
|
||||
//! |----------|----|----|--------|------------------|
|
||||
//! | scenario_1_pi5_write_subsidy_90 | EmbeddedArm | Free | Write | fee=10% base (subsidy 9000 bp) |
|
||||
//! | scenario_2_ultra_write_full_price | Ultra | Pro | Write | fee=base × 1.5 multiplier |
|
||||
//! | scenario_3_read_always_free_inmutable_37 | Desktop | Free | Read | fee=0 (subsidy 100%) |
|
||||
//! | scenario_4_free_compute_blocked_entitlement | Desktop | Free | Compute | Error::EntitlementLocked + event |
|
||||
//! | scenario_5_pro_compute_unlocked | Desktop | Pro | Compute | fee normal |
|
||||
//! | scenario_6_ultra_compute_premium | Ultra | Ultra | Compute | fee=base × 2.0 multiplier |
|
||||
//! | scenario_7_storage_accumulates | Desktop | Pro | Write × 3 | history.total_paid sum + count=3 |
|
||||
//! | scenario_8_chain_action_ordinal_stable_wire | wire format invariant | n/a | Read=0 + Write=1 + Compute=2 + Transfer=3 |
|
||||
//! | scenario_9_inmutable_16_anti_clasista_lobo_blanco | Pi5 < Ultra fee non-Read | n/a | compile-detect invariant |
|
||||
//! | scenario_10_5_step_walkthrough | EmbeddedArm | Free | Transfer | explicit Pre/Post-flight assertions |
|
||||
//!
|
||||
//! Total: 10 integration tests canonical E2E. Pattern v0.0.18 mirror.
|
||||
//! FIX-6 Stage 2a: estos tests validaban el dispatchable legacy
|
||||
//! `compute_fee_for_action` (removido — escribía `AccountFees` con tiers
|
||||
//! caller-supplied sin cobrar, un vector de envenenamiento del ledger). Ahora
|
||||
//! validan el preview read-only `Fees::query_fee` (el uso REAL de la fórmula
|
||||
//! OCAFB-5 fuera del fee-path vivo: pricing cross-pillar para Mathi AMIS /
|
||||
//! Irhis Web / Trinity / Codex / Atelier vía `FeesApi::query_fee_for_action`).
|
||||
//! El cobro real con tiers on-chain + subsidio del pool se prueba en
|
||||
//! `tests_adapter`.
|
||||
|
||||
#![cfg(test)]
|
||||
|
||||
use crate::mock::{alice_account, alice_origin, bob_account, bob_origin, new_test_ext, Fees, RuntimeEvent, System, Test};
|
||||
use crate::{AccountFees, Error, Event};
|
||||
use frame_support::{assert_noop, assert_ok};
|
||||
use crate::mock::{new_test_ext, Fees};
|
||||
use origin_chain_types::{ChainAction, HardwareTier, SubscriptionTier};
|
||||
|
||||
// ─── Scenario 1: Pi 5 Write subsidy 90% canonical (Inmutable #16) ────
|
||||
|
||||
#[test]
|
||||
fn scenario_1_pi5_write_subsidy_90_canonical() {
|
||||
new_test_ext().execute_with(|| {
|
||||
let base = 10_000u64;
|
||||
|
||||
assert_ok!(Fees::compute_fee_for_action(
|
||||
alice_origin(),
|
||||
ChainAction::Write,
|
||||
base,
|
||||
HardwareTier::EmbeddedArm,
|
||||
SubscriptionTier::Free,
|
||||
));
|
||||
|
||||
// OCAFB-5 formula: base 10000 × hw_mult 1000 × sub_mult 10000 / 10000 / 10000 = 1000
|
||||
// (subsidy 9000 bp → remaining 1000 bp × Free multiplier 10000 bp)
|
||||
let history = AccountFees::<Test>::get(&alice_account()).expect("storage updated");
|
||||
assert_eq!(history.total_paid, 1000u128); // Pi 5 paga 10% del base — Inmutable #16
|
||||
assert_eq!(history.action_count, 1);
|
||||
|
||||
System::assert_last_event(RuntimeEvent::Fees(Event::FeeCharged {
|
||||
who: alice_account(),
|
||||
action: ChainAction::Write,
|
||||
fee_charged: 1000,
|
||||
}));
|
||||
});
|
||||
/// Helper: el preview que consumen los clientes (== `FeesApi::query_fee_for_action`).
|
||||
fn quote(action: ChainAction, base: u64, hw: HardwareTier, sub: SubscriptionTier) -> u64 {
|
||||
new_test_ext().execute_with(|| Fees::query_fee(action, base, hw, sub))
|
||||
}
|
||||
|
||||
// ─── Scenario 2: Ultra Write Pro multiplier canonical ───────────────
|
||||
// ─── Inmutable #16 anti-clasista ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn scenario_2_ultra_write_full_price_canonical() {
|
||||
new_test_ext().execute_with(|| {
|
||||
let base = 1_000u64;
|
||||
|
||||
assert_ok!(Fees::compute_fee_for_action(
|
||||
alice_origin(),
|
||||
ChainAction::Write,
|
||||
base,
|
||||
HardwareTier::Ultra,
|
||||
SubscriptionTier::Pro,
|
||||
));
|
||||
|
||||
// OCAFB-5: base 1000 × hw_mult 10000 (Ultra subsidy=0 → full remaining) × sub_mult 15000 / 10000 / 10000 = 1500
|
||||
let history = AccountFees::<Test>::get(&alice_account()).expect("storage updated");
|
||||
assert_eq!(history.total_paid, 1500u128);
|
||||
assert_eq!(history.action_count, 1);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Scenario 3: Read always free canonical (Inmutable #37) ──────────
|
||||
|
||||
#[test]
|
||||
fn scenario_3_read_always_free_inmutable_37_canonical() {
|
||||
new_test_ext().execute_with(|| {
|
||||
assert_ok!(Fees::compute_fee_for_action(
|
||||
alice_origin(),
|
||||
ChainAction::Read,
|
||||
999_999,
|
||||
HardwareTier::Desktop, // even Desktop
|
||||
SubscriptionTier::Free,
|
||||
));
|
||||
|
||||
// OCAFB-3: Read always 100% subsidy → fee=0 (Inmutable #37 baseline)
|
||||
let history = AccountFees::<Test>::get(&alice_account()).expect("storage updated");
|
||||
assert_eq!(history.total_paid, 0u128);
|
||||
assert_eq!(history.action_count, 1); // count incremented even si fee=0
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Scenario 4: Free Compute blocked entitlement canonical (Inmutable #37) ──
|
||||
|
||||
#[test]
|
||||
fn scenario_4_free_compute_blocked_entitlement_canonical() {
|
||||
new_test_ext().execute_with(|| {
|
||||
// OCAFB-4 entitlement enforce: Free + Compute → blocked
|
||||
// FRAME canonical: dispatchable returns `Err(EntitlementLocked)` SIN event
|
||||
// emission (events revierten on Err per FRAME dispatch transaction).
|
||||
// `EntitlementBlocked` event variant reserved para Phase 2.5 future
|
||||
// `with_storage_layer` pattern.
|
||||
assert_noop!(
|
||||
Fees::compute_fee_for_action(
|
||||
alice_origin(),
|
||||
ChainAction::Compute,
|
||||
100,
|
||||
HardwareTier::Desktop,
|
||||
SubscriptionTier::Free, // Compute blocked per OCAFB-4
|
||||
),
|
||||
Error::<Test>::EntitlementLocked
|
||||
fn pi5_write_paga_10_por_ciento() {
|
||||
// EmbeddedArm Free Write: base 10000 × 1000bp × 10000bp / 1e8 = 1000 (10%).
|
||||
assert_eq!(
|
||||
quote(ChainAction::Write, 10_000, HardwareTier::EmbeddedArm, SubscriptionTier::Free),
|
||||
1_000
|
||||
);
|
||||
|
||||
// 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 ─────────────────────
|
||||
#[test]
|
||||
fn ultra_pro_write_multiplica_1_5() {
|
||||
// Ultra Pro Write: base 1000 × 10000bp (sin subsidio) × 15000bp / 1e8 = 1500.
|
||||
assert_eq!(
|
||||
quote(ChainAction::Write, 1_000, HardwareTier::Ultra, SubscriptionTier::Pro),
|
||||
1_500
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_5_pro_compute_unlocked_canonical() {
|
||||
new_test_ext().execute_with(|| {
|
||||
let base = 100u64;
|
||||
fn pi5_estrictamente_menor_que_ultra_lobo_blanco() {
|
||||
let pi5 = quote(ChainAction::Write, 10_000, HardwareTier::EmbeddedArm, SubscriptionTier::Free);
|
||||
let ultra = quote(ChainAction::Write, 10_000, HardwareTier::Ultra, SubscriptionTier::Ultra);
|
||||
assert!(pi5 < ultra, "Inmutable #16: Pi5 {pi5} debe ser < Ultra {ultra}");
|
||||
assert_eq!(pi5, 1_000);
|
||||
assert_eq!(ultra, 20_000);
|
||||
}
|
||||
|
||||
assert_ok!(Fees::compute_fee_for_action(
|
||||
alice_origin(),
|
||||
ChainAction::Compute,
|
||||
base,
|
||||
// ─── Inmutable #37 Free perpetua ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn read_siempre_gratis_todos_los_tiers() {
|
||||
for hw in [
|
||||
HardwareTier::EmbeddedArm,
|
||||
HardwareTier::Portable,
|
||||
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]
|
||||
fn scenario_6_ultra_compute_premium_canonical() {
|
||||
new_test_ext().execute_with(|| {
|
||||
let base = 1_000u64;
|
||||
|
||||
assert_ok!(Fees::compute_fee_for_action(
|
||||
alice_origin(),
|
||||
ChainAction::Compute,
|
||||
base,
|
||||
HardwareTier::Ultra,
|
||||
SubscriptionTier::Ultra, // EEG Compute Ultra-only (Inmutable #18)
|
||||
));
|
||||
|
||||
// OCAFB-5: base 1000 × hw_mult 10000 (Ultra subsidy=0) × sub_mult 20000 / 10000 / 10000 = 2000
|
||||
let history = AccountFees::<Test>::get(&alice_account()).expect("storage updated");
|
||||
assert_eq!(history.total_paid, 2000u128);
|
||||
assert_eq!(history.action_count, 1);
|
||||
});
|
||||
] {
|
||||
assert_eq!(quote(ChainAction::Read, 999_999, hw, SubscriptionTier::Free), 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Scenario 7: Storage accumulates over multiple writes canonical ──
|
||||
|
||||
#[test]
|
||||
fn scenario_7_storage_accumulates_canonical() {
|
||||
new_test_ext().execute_with(|| {
|
||||
let base = 1_000u64;
|
||||
|
||||
// 3 writes secuenciales
|
||||
for _ in 0..3 {
|
||||
assert_ok!(Fees::compute_fee_for_action(
|
||||
alice_origin(),
|
||||
ChainAction::Write,
|
||||
base,
|
||||
HardwareTier::Desktop,
|
||||
SubscriptionTier::Pro,
|
||||
));
|
||||
fn free_compute_blocked_devuelve_sentinel() {
|
||||
// El preview read-only reporta u64::MAX para entitlement blocked (el
|
||||
// fee-path vivo lo traduce a InvalidTransaction::Custom — ver tests_adapter).
|
||||
assert_eq!(
|
||||
quote(ChainAction::Compute, 100, HardwareTier::Desktop, SubscriptionTier::Free),
|
||||
u64::MAX
|
||||
);
|
||||
}
|
||||
|
||||
// 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 scenario_8_chain_action_ordinal_stable_wire_canonical() {
|
||||
// Wire format invariant — sin dependency mock runtime
|
||||
fn pro_compute_unlocked() {
|
||||
// Desktop Pro Compute: 100 × 7500bp × 15000bp / 1e8 = 112 (floor 112.5).
|
||||
assert_eq!(
|
||||
quote(ChainAction::Compute, 100, HardwareTier::Desktop, SubscriptionTier::Pro),
|
||||
112
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ultra_compute_premium_inmutable_18() {
|
||||
// Ultra Ultra Compute: 1000 × 10000bp × 20000bp / 1e8 = 2000.
|
||||
assert_eq!(
|
||||
quote(ChainAction::Compute, 1_000, HardwareTier::Ultra, SubscriptionTier::Ultra),
|
||||
2_000
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Subsidio educacional (paso 1 precede entitlement) ───────────────
|
||||
|
||||
#[test]
|
||||
fn pi5_compute_educacional_gratis() {
|
||||
// EmbeddedArm Compute = subsidio 100% → 0, aunque Free+Compute esté gated.
|
||||
assert_eq!(
|
||||
quote(ChainAction::Compute, 999_999, HardwareTier::EmbeddedArm, SubscriptionTier::Free),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Wire format estable ChainAction ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn chain_action_ordinal_estable_wire() {
|
||||
assert_eq!(ChainAction::Read.ordinal(), 0);
|
||||
assert_eq!(ChainAction::Write.ordinal(), 1);
|
||||
assert_eq!(ChainAction::Compute.ordinal(), 2);
|
||||
assert_eq!(ChainAction::Transfer.ordinal(), 3);
|
||||
|
||||
// Roundtrip canonical
|
||||
for action in [ChainAction::Read, ChainAction::Write, ChainAction::Compute, ChainAction::Transfer] {
|
||||
for action in [
|
||||
ChainAction::Read,
|
||||
ChainAction::Write,
|
||||
ChainAction::Compute,
|
||||
ChainAction::Transfer,
|
||||
] {
|
||||
assert_eq!(ChainAction::from_ordinal(action.ordinal()), Some(action));
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Scenario 9: Inmutable #16 anti-clasista lobo blanco canonical ───
|
||||
// ─── Coherencia preview ↔ fee-path vivo (la fracción user) ───────────
|
||||
|
||||
#[test]
|
||||
fn scenario_9_inmutable_16_anti_clasista_lobo_blanco_canonical() {
|
||||
new_test_ext().execute_with(|| {
|
||||
let base = 10_000u64;
|
||||
|
||||
// Pi 5 user (EmbeddedArm + Free)
|
||||
assert_ok!(Fees::compute_fee_for_action(
|
||||
alice_origin(),
|
||||
ChainAction::Write,
|
||||
base,
|
||||
HardwareTier::EmbeddedArm,
|
||||
SubscriptionTier::Free,
|
||||
));
|
||||
let pi5_fee = AccountFees::<Test>::get(&alice_account()).unwrap().total_paid;
|
||||
|
||||
// Ultra user (Ultra + Ultra)
|
||||
assert_ok!(Fees::compute_fee_for_action(
|
||||
bob_origin(),
|
||||
ChainAction::Write,
|
||||
base,
|
||||
HardwareTier::Ultra,
|
||||
SubscriptionTier::Ultra,
|
||||
));
|
||||
let ultra_fee = AccountFees::<Test>::get(&bob_account()).unwrap().total_paid;
|
||||
|
||||
// Lobo blanco compile-detect canonical: Pi 5 paga ESTRICTAMENTE MENOS que Ultra
|
||||
assert!(
|
||||
pi5_fee < ultra_fee,
|
||||
"Inmutable #16 anti-clasista violated: Pi 5 fee {pi5_fee} debe ser < Ultra fee {ultra_fee}"
|
||||
);
|
||||
|
||||
// Ratio canonical aprox 1:20 (Pi 5 1000 vs Ultra 20000)
|
||||
// Pi 5: 10000 × 1000 × 10000 / 10000 / 10000 = 1000
|
||||
// Ultra: 10000 × 10000 × 20000 / 10000 / 10000 = 20000
|
||||
assert_eq!(pi5_fee, 1000u128);
|
||||
assert_eq!(ultra_fee, 20000u128);
|
||||
});
|
||||
fn preview_coincide_con_la_fraccion_user_del_adapter() {
|
||||
// Para tiers donde el preview NO está blocked ni saturado, query_fee debe
|
||||
// coincidir con unified_fee.user (lo que el adapter cobra al usuario).
|
||||
use crate::unified::{unified_fee, FeeOutcome};
|
||||
for base in [1u64, 1_000, 1_000_000] {
|
||||
for hw in [HardwareTier::EmbeddedArm, HardwareTier::Desktop, HardwareTier::Ultra] {
|
||||
for sub in [SubscriptionTier::Free, SubscriptionTier::Pro, SubscriptionTier::Ultra] {
|
||||
let q = quote(ChainAction::Transfer, base, hw, sub);
|
||||
if let FeeOutcome::Charge { user, .. } =
|
||||
unified_fee(base as u128, hw, sub, ChainAction::Transfer)
|
||||
{
|
||||
assert_eq!(q as u128, user, "preview != user ({hw:?},{sub:?},base={base})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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;
|
||||
}
|
||||
|
||||
/// FIX-6 Stage 2a: clasificador call→ChainAction del fee unificado (taxonomía
|
||||
/// OCAFB-2). Vive aquí porque el runtime es el único sitio donde `RuntimeCall`
|
||||
/// es concreto. Fallback conservador: `Write` (fee estándar — un wildcard
|
||||
/// jamás regala subsidio; mismo principio que la alerta TLB de Ergon).
|
||||
pub struct OriginCallClassifier;
|
||||
|
||||
impl origin_chain_fees::ClassifyChainAction<crate::RuntimeCall> for OriginCallClassifier {
|
||||
fn classify(call: &crate::RuntimeCall) -> origin_chain_fees::ChainAction {
|
||||
use origin_chain_fees::ChainAction;
|
||||
match call {
|
||||
crate::RuntimeCall::Balances(
|
||||
pallet_balances::Call::transfer_allow_death { .. }
|
||||
| pallet_balances::Call::transfer_keep_alive { .. }
|
||||
| pallet_balances::Call::transfer_all { .. },
|
||||
) => ChainAction::Transfer,
|
||||
crate::RuntimeCall::Revive(
|
||||
pallet_revive::Call::instantiate { .. }
|
||||
| pallet_revive::Call::instantiate_with_code { .. }
|
||||
// call_index 10: el deploy vía la capa EVM (Code::Upload). Sin
|
||||
// este arm caería en el fallback Write y evadiría el
|
||||
// entitlement-gate DeployContract (HIGH del audit Stage 2a).
|
||||
| pallet_revive::Call::eth_instantiate_with_code { .. },
|
||||
) => ChainAction::DeployContract,
|
||||
crate::RuntimeCall::Revive(
|
||||
pallet_revive::Call::call { .. }
|
||||
| pallet_revive::Call::eth_call { .. }
|
||||
// eth_transact: envoltorio EVM genérico (puede ser call/deploy/
|
||||
// transfer). Lo tratamos como ejecución de contrato — su
|
||||
// entitlement (Free=false) y subsidio coinciden con CallContract;
|
||||
// refinar cuando la capa EVM se cablee plenamente.
|
||||
| pallet_revive::Call::eth_transact { .. },
|
||||
) => ChainAction::CallContract,
|
||||
crate::RuntimeCall::Revive(pallet_revive::Call::upload_code { .. }) => {
|
||||
ChainAction::UploadContractCode
|
||||
}
|
||||
_ => ChainAction::Write,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl pallet_transaction_payment::Config for Runtime {
|
||||
type RuntimeEvent = RuntimeEvent;
|
||||
/// On-charge transaction canonical: usa `pallet_transaction_payment` default
|
||||
/// charge mechanism. Phase 2.4 lo refactor canonical para integrar
|
||||
/// `OriginFees` trait (CHN-8 multi-tier fee matrix).
|
||||
/// FIX-6 Stage 2a (cierra V2-CA3): `OriginFeeAdapter` reemplaza al
|
||||
/// `FungibleAdapter` default — la fórmula OCAFB-5 entra al fee-path VIVO:
|
||||
/// el firmante paga su fracción según (HardwareTier × ChainAction ×
|
||||
/// SubscriptionTier, tiers leídos ON-CHAIN de `TierProfiles`, jamás
|
||||
/// caller-supplied), y el subsidy pool `og/subsy` (alimentado por la
|
||||
/// emisión Index 13) cubre la fracción subsidiada — Inmutable #16 en cada
|
||||
/// transacción. `OU = ()` deliberado: burn matrix CHN-2 ratificada = gas
|
||||
/// fees 100% burn (el burn total = fee constitucional completo, deflación
|
||||
/// que contrapesa la emisión Stage 1).
|
||||
type OnChargeTransaction =
|
||||
pallet_transaction_payment::FungibleAdapter<crate::Balances, ()>;
|
||||
origin_chain_fees::OriginFeeAdapter<crate::Balances, (), OriginCallClassifier>;
|
||||
type OperationalFeeMultiplier = OperationalFeeMultiplier;
|
||||
type WeightToFee = frame_support::weights::IdentityFee<Balance>;
|
||||
type LengthToFee = frame_support::weights::IdentityFee<Balance>;
|
||||
@@ -368,6 +414,10 @@ impl origin_chain_provenance::Config for Runtime {
|
||||
|
||||
impl origin_chain_fees::Config for Runtime {
|
||||
type RuntimeEvent = RuntimeEvent;
|
||||
/// FIX-6 Stage 2a: la MISMA constante que alimenta la emisión (Index 13)
|
||||
/// — pool-alimentación y pool-consumo apuntan a la misma cuenta og/subsy
|
||||
/// (jamás re-declarar el literal: un typo divergiría las cuentas).
|
||||
type SubsidyPalletId = EmissionSubsidyPalletId;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -45,6 +45,7 @@ use crate::{
|
||||
};
|
||||
use alloc::vec;
|
||||
use alloc::vec::Vec;
|
||||
use origin_chain_types::{HardwareTier, SubscriptionTier};
|
||||
use sp_genesis_builder::PresetId;
|
||||
|
||||
// ============================================================
|
||||
@@ -200,10 +201,14 @@ fn build_genesis(
|
||||
endowed_accounts: Vec<(ChainAccountId, Balance)>,
|
||||
validators: Vec<(ChainAccountId, AuraId, GrandpaId)>,
|
||||
sudo_key: ChainAccountId,
|
||||
// FIX-6 Stage 2a: perfiles de fee a sembrar en block 0. En dev/local las
|
||||
// cuentas de prueba arrancan en un tier que puede desplegar contratos (Free
|
||||
// está gated de DeployContract por Inmutable #37); en mainnet va vacío.
|
||||
initial_fee_profiles: Vec<(ChainAccountId, HardwareTier, SubscriptionTier)>,
|
||||
) -> RuntimeGenesisConfig {
|
||||
use crate::{
|
||||
AuraConfig, BalancesConfig, GrandpaConfig, ReviveConfig, SessionConfig, SudoConfig,
|
||||
SystemConfig, TransactionPaymentConfig,
|
||||
AuraConfig, BalancesConfig, FeesConfig, GrandpaConfig, ReviveConfig, SessionConfig,
|
||||
SudoConfig, SystemConfig, TransactionPaymentConfig,
|
||||
};
|
||||
RuntimeGenesisConfig {
|
||||
system: SystemConfig::default(),
|
||||
@@ -212,6 +217,10 @@ fn build_genesis(
|
||||
dev_accounts: None,
|
||||
},
|
||||
transaction_payment: TransactionPaymentConfig::default(),
|
||||
// FIX-6 Stage 2a: perfiles de tier sembrados (el fee-path vivo los lee).
|
||||
fees: FeesConfig {
|
||||
initial_profiles: initial_fee_profiles,
|
||||
},
|
||||
aura: AuraConfig {
|
||||
authorities: validators.iter().map(|(_, aura, _)| aura.clone()).collect(),
|
||||
},
|
||||
@@ -257,7 +266,11 @@ pub fn development_config_genesis() -> RuntimeGenesisConfig {
|
||||
build_genesis(
|
||||
vec![(alice_account.clone(), 100_000_000_u128 * Ogc::ONE)],
|
||||
vec![alice_authority],
|
||||
alice_account, // FIX-5: Alice = sudo key (Root) en dev
|
||||
alice_account.clone(), // FIX-5: Alice = sudo key (Root) en dev
|
||||
// FIX-6 Stage 2a: Alice arranca Ultra para poder desplegar contratos
|
||||
// out-of-the-box en --dev (los Slices 3.x de contract testing no se
|
||||
// bloquean por el entitlement-gate Free+DeployContract).
|
||||
vec![(alice_account, HardwareTier::Ultra, SubscriptionTier::Ultra)],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -279,13 +292,22 @@ pub fn local_testnet_config_genesis() -> RuntimeGenesisConfig {
|
||||
build_genesis(
|
||||
vec![
|
||||
(alice.clone(), endowment),
|
||||
(bob, endowment),
|
||||
(charlie, endowment),
|
||||
(dave, endowment),
|
||||
(eve, endowment),
|
||||
(bob.clone(), endowment),
|
||||
(charlie.clone(), endowment),
|
||||
(dave.clone(), endowment),
|
||||
(eve.clone(), endowment),
|
||||
],
|
||||
vec![alice_authority, bob_authority],
|
||||
alice, // FIX-5: Alice = sudo key (Root) en local_testnet
|
||||
alice.clone(), // FIX-5: Alice = sudo key (Root) en local_testnet
|
||||
// FIX-6 Stage 2a: las 5 cuentas dev arrancan Ultra para testing
|
||||
// completo (contratos + compute) sin upgrade manual.
|
||||
vec![
|
||||
(alice, HardwareTier::Ultra, SubscriptionTier::Ultra),
|
||||
(bob, HardwareTier::Ultra, SubscriptionTier::Ultra),
|
||||
(charlie, HardwareTier::Ultra, SubscriptionTier::Ultra),
|
||||
(dave, HardwareTier::Ultra, SubscriptionTier::Ultra),
|
||||
(eve, HardwareTier::Ultra, SubscriptionTier::Ultra),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -335,6 +357,10 @@ pub fn mainnet_genesis_config_genesis() -> RuntimeGenesisConfig {
|
||||
// (multisig humano). PRE-LAUNCH Q1 2027: David ratifica la key real +
|
||||
// sunset a governance (pallet-collective/referenda).
|
||||
foundation_treasury,
|
||||
// FIX-6 Stage 2a: mainnet arranca SIN perfiles sembrados — todos en el
|
||||
// Default conservador (Desktop+Free) hasta que la attestation real
|
||||
// (Mathi PerceptionAdapter + DID binding) asigne tiers verificados.
|
||||
vec![],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -222,7 +222,10 @@ pub const IMPL_NAME: &str = "origin-chain-charon";
|
||||
|
||||
/// Canonical runtime version canonical. Bumped cada forkless upgrade via
|
||||
/// `set_code` extrinsic governance canonical.
|
||||
pub const RUNTIME_VERSION: u32 = 1;
|
||||
/// v2: FIX-6 Stage 2a — fee unificado (OriginFeeAdapter reemplaza al
|
||||
/// FungibleAdapter: cambia el COMPORTAMIENTO del cobro de fees, no el formato
|
||||
/// de extrinsic — TRANSACTION_VERSION intacto).
|
||||
pub const RUNTIME_VERSION: u32 = 2;
|
||||
|
||||
/// Canonical authoring version per Substrate semver canonical.
|
||||
pub const AUTHORING_VERSION: u32 = 1;
|
||||
@@ -794,8 +797,11 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn runtime_version_starts_at_one_canonical() {
|
||||
assert_eq!(RUNTIME_VERSION, 1);
|
||||
fn runtime_version_v2_fee_unificado() {
|
||||
// v2 = FIX-6 Stage 2a (fee unificado). El formato de extrinsic NO
|
||||
// cambió: solo el associated type OnChargeTransaction.
|
||||
assert_eq!(RUNTIME_VERSION, 2);
|
||||
assert_eq!(TRANSACTION_VERSION, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user