diff --git a/contracts/proxy/src/lib.rs b/contracts/proxy/src/lib.rs index 12b1276..c934492 100644 --- a/contracts/proxy/src/lib.rs +++ b/contracts/proxy/src/lib.rs @@ -3,11 +3,11 @@ mod storage; -use soroban_sdk::{Address, Env, IntoVal, String, TryFromVal, Val, Vec}; +use soroban_sdk::{Address, BytesN, Env, IntoVal, String, TryFromVal, Val, Vec}; use storage as proxy_storage; use subtrackr_types::{ - Interval, Plan, ScheduledUpgrade, StorageKey, Subscription, Timestamp, UpgradeAction, - UpgradeEvent, + ChargeCommitment, Interval, MevProtectionConfig, Plan, ScheduledUpgrade, StorageKey, + Subscription, Timestamp, UpgradeAction, UpgradeEvent, }; fn current_proxy_address(env: &Env) -> Address { @@ -377,6 +377,86 @@ impl UpgradeableProxy { ); } + pub fn configure_mev_protection(env: Env, admin: Address, config: MevProtectionConfig) { + let proxy_addr = current_proxy_address(&env); + let storage_addr = proxy_storage::storage_address(&env); + invoke_impl::<()>( + &env, + "configure_mev_protection", + soroban_sdk::vec![ + &env, + proxy_addr.into_val(&env), + storage_addr.into_val(&env), + admin.into_val(&env), + config.into_val(&env) + ], + ); + } + + pub fn commit_charge(env: Env, subscription_id: u64, commitment: BytesN<32>) { + let proxy_addr = current_proxy_address(&env); + let storage_addr = proxy_storage::storage_address(&env); + invoke_impl::<()>( + &env, + "commit_charge", + soroban_sdk::vec![ + &env, + proxy_addr.into_val(&env), + storage_addr.into_val(&env), + subscription_id.into_val(&env), + commitment.into_val(&env) + ], + ); + } + + pub fn hash_charge_commitment( + env: Env, + subscription_id: u64, + max_charge_amount: i128, + salt: BytesN<32>, + ) -> BytesN<32> { + let proxy_addr = current_proxy_address(&env); + let storage_addr = proxy_storage::storage_address(&env); + invoke_impl( + &env, + "hash_charge_commitment", + soroban_sdk::vec![ + &env, + proxy_addr.into_val(&env), + storage_addr.into_val(&env), + subscription_id.into_val(&env), + max_charge_amount.into_val(&env), + salt.into_val(&env) + ], + ) + } + + pub fn reveal_charge( + env: Env, + subscription_id: u64, + salt: BytesN<32>, + max_charge_amount: i128, + observed_gas_price: u64, + private_mempool: bool, + ) { + let proxy_addr = current_proxy_address(&env); + let storage_addr = proxy_storage::storage_address(&env); + invoke_impl::<()>( + &env, + "reveal_charge", + soroban_sdk::vec![ + &env, + proxy_addr.into_val(&env), + storage_addr.into_val(&env), + subscription_id.into_val(&env), + salt.into_val(&env), + max_charge_amount.into_val(&env), + observed_gas_price.into_val(&env), + private_mempool.into_val(&env) + ], + ); + } + pub fn create_plan( env: Env, merchant: Address, @@ -672,4 +752,39 @@ impl UpgradeableProxy { soroban_sdk::vec![&env, proxy_addr.into_val(&env), storage_addr.into_val(&env)], ) } + + pub fn get_mev_protection_config(env: Env) -> MevProtectionConfig { + let proxy_addr = current_proxy_address(&env); + let storage_addr = proxy_storage::storage_address(&env); + invoke_impl( + &env, + "get_mev_protection_config", + soroban_sdk::vec![&env, proxy_addr.into_val(&env), storage_addr.into_val(&env)], + ) + } + + pub fn get_charge_commitment(env: Env, subscription_id: u64) -> Option { + let proxy_addr = current_proxy_address(&env); + let storage_addr = proxy_storage::storage_address(&env); + invoke_impl( + &env, + "get_charge_commitment", + soroban_sdk::vec![ + &env, + proxy_addr.into_val(&env), + storage_addr.into_val(&env), + subscription_id.into_val(&env) + ], + ) + } + + pub fn get_mev_alert_count(env: Env) -> u64 { + let proxy_addr = current_proxy_address(&env); + let storage_addr = proxy_storage::storage_address(&env); + invoke_impl( + &env, + "get_mev_alert_count", + soroban_sdk::vec![&env, proxy_addr.into_val(&env), storage_addr.into_val(&env)], + ) + } } diff --git a/contracts/proxy/test_snapshots/integration_cross_contract_call_charges_subscription.1.json b/contracts/proxy/test_snapshots/integration_cross_contract_call_charges_subscription.1.json index e5de837..f90f27d 100644 --- a/contracts/proxy/test_snapshots/integration_cross_contract_call_charges_subscription.1.json +++ b/contracts/proxy/test_snapshots/integration_cross_contract_call_charges_subscription.1.json @@ -4240,6 +4240,57 @@ }, "failed_call": false }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000006", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "0000000000000000000000000000000000000000000000000000000000000005" + }, + { + "symbol": "instance_get" + } + ], + "data": { + "vec": [ + { + "symbol": "MevProtectionConfig" + } + ] + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000005", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "instance_get" + } + ], + "data": "void" + } + } + }, + "failed_call": false + }, { "event": { "ext": "v0", @@ -5709,4 +5760,4 @@ "failed_call": false } ] -} +} \ No newline at end of file diff --git a/contracts/proxy/test_snapshots/integration_multiple_contract_interactions_work.1.json b/contracts/proxy/test_snapshots/integration_multiple_contract_interactions_work.1.json index 58b112a..8bbce19 100644 --- a/contracts/proxy/test_snapshots/integration_multiple_contract_interactions_work.1.json +++ b/contracts/proxy/test_snapshots/integration_multiple_contract_interactions_work.1.json @@ -7315,6 +7315,57 @@ }, "failed_call": false }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000006", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "0000000000000000000000000000000000000000000000000000000000000005" + }, + { + "symbol": "instance_get" + } + ], + "data": { + "vec": [ + { + "symbol": "MevProtectionConfig" + } + ] + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000005", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "instance_get" + } + ], + "data": "void" + } + } + }, + "failed_call": false + }, { "event": { "ext": "v0", @@ -8729,6 +8780,57 @@ }, "failed_call": false }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000006", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "0000000000000000000000000000000000000000000000000000000000000005" + }, + { + "symbol": "instance_get" + } + ], + "data": { + "vec": [ + { + "symbol": "MevProtectionConfig" + } + ] + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000005", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "instance_get" + } + ], + "data": "void" + } + } + }, + "failed_call": false + }, { "event": { "ext": "v0", @@ -10790,4 +10892,4 @@ "failed_call": false } ] -} +} \ No newline at end of file diff --git a/contracts/proxy/test_snapshots/integration_uses_actual_token_contract_for_charges.1.json b/contracts/proxy/test_snapshots/integration_uses_actual_token_contract_for_charges.1.json index 0e04372..e4f3941 100644 --- a/contracts/proxy/test_snapshots/integration_uses_actual_token_contract_for_charges.1.json +++ b/contracts/proxy/test_snapshots/integration_uses_actual_token_contract_for_charges.1.json @@ -4283,6 +4283,57 @@ }, "failed_call": false }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000006", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "0000000000000000000000000000000000000000000000000000000000000005" + }, + { + "symbol": "instance_get" + } + ], + "data": { + "vec": [ + { + "symbol": "MevProtectionConfig" + } + ] + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000005", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "instance_get" + } + ], + "data": "void" + } + } + }, + "failed_call": false + }, { "event": { "ext": "v0", @@ -5835,4 +5886,4 @@ "failed_call": false } ] -} +} \ No newline at end of file diff --git a/contracts/proxy/tests/integration_soroban.rs b/contracts/proxy/tests/integration_soroban.rs index 53fec2a..2ada472 100644 --- a/contracts/proxy/tests/integration_soroban.rs +++ b/contracts/proxy/tests/integration_soroban.rs @@ -1,12 +1,12 @@ use soroban_sdk::{ contract, contractimpl, testutils::{Address as _, Ledger}, - token, Address, Env, String, + token, Address, BytesN, Env, String, }; use subtrackr_proxy::{UpgradeableProxy, UpgradeableProxyClient}; use subtrackr_storage::SubTrackrStorage; use subtrackr_subscription::SubTrackrSubscription; -use subtrackr_types::{Interval, SubscriptionStatus}; +use subtrackr_types::{Interval, MevProtectionConfig, SubscriptionStatus}; #[contract] pub struct ChargingBot; @@ -22,6 +22,7 @@ impl ChargingBot { struct IntegrationSetup { env: Env, proxy_id: Address, + admin: Address, merchant: Address, subscriber: Address, token_id: Address, @@ -72,6 +73,7 @@ fn setup_integration() -> IntegrationSetup { IntegrationSetup { env, proxy_id, + admin, merchant, subscriber, token_id: token_id.address(), @@ -130,6 +132,101 @@ fn integration_uses_actual_token_contract_for_charges() { assert_eq!(sub.charge_count, 1); } +fn mev_config(threshold: i128, gas_threshold: u64, private_required: bool) -> MevProtectionConfig { + MevProtectionConfig { + large_charge_threshold: threshold, + max_fee_bps: 100, + reveal_delay_secs: 30, + commit_ttl_secs: 3_600, + private_mempool_required: private_required, + gas_price_alert_threshold: gas_threshold, + } +} + +#[test] +fn integration_large_charge_uses_commit_reveal_and_fee_bounds() { + let setup = setup_integration(); + let proxy = setup.proxy(); + let token = setup.token(); + let salt = BytesN::from_array(&setup.env, &[7u8; 32]); + let commitment = proxy.hash_charge_commitment(&setup.subscription_id, &505, &salt); + + proxy.configure_mev_protection(&setup.admin, &mev_config(400, 10, false)); + setup + .env + .ledger() + .set_timestamp(1_700_000_000 + Interval::Monthly.seconds()); + proxy.commit_charge(&setup.subscription_id, &commitment); + + let pending = proxy + .get_charge_commitment(&setup.subscription_id) + .expect("commitment should be stored"); + assert_eq!(pending.subscription_id, setup.subscription_id); + assert_eq!(pending.commitment, commitment); + + setup + .env + .ledger() + .set_timestamp(1_700_000_000 + Interval::Monthly.seconds() + 31); + + let subscriber_before = token.balance(&setup.subscriber); + let merchant_before = token.balance(&setup.merchant); + + proxy.reveal_charge(&setup.subscription_id, &salt, &505, &25, &false); + + assert!(proxy + .get_charge_commitment(&setup.subscription_id) + .is_none()); + assert_eq!(token.balance(&setup.subscriber), subscriber_before - 500); + assert_eq!(token.balance(&setup.merchant), merchant_before + 500); + assert_eq!(proxy.get_mev_alert_count(), 1); + + let sub = proxy.get_subscription(&setup.subscription_id); + assert_eq!(sub.total_paid, 500); + assert_eq!(sub.charge_count, 1); +} + +#[test] +fn integration_large_direct_charge_requires_commitment() { + let setup = setup_integration(); + let proxy = setup.proxy(); + + proxy.configure_mev_protection(&setup.admin, &mev_config(400, u64::MAX, false)); + setup + .env + .ledger() + .set_timestamp(1_700_000_000 + Interval::Monthly.seconds() + 10); + + let result = proxy.try_charge_subscription(&setup.subscription_id); + assert!(result.is_err()); +} + +#[test] +fn integration_private_mempool_requirement_blocks_public_reveal() { + let setup = setup_integration(); + let proxy = setup.proxy(); + let salt = BytesN::from_array(&setup.env, &[9u8; 32]); + let commitment = proxy.hash_charge_commitment(&setup.subscription_id, &505, &salt); + + proxy.configure_mev_protection(&setup.admin, &mev_config(400, u64::MAX, true)); + setup + .env + .ledger() + .set_timestamp(1_700_000_000 + Interval::Monthly.seconds()); + proxy.commit_charge(&setup.subscription_id, &commitment); + setup + .env + .ledger() + .set_timestamp(1_700_000_000 + Interval::Monthly.seconds() + 31); + + let result = proxy.try_reveal_charge(&setup.subscription_id, &salt, &505, &1, &false); + assert!(result.is_err()); + + proxy.reveal_charge(&setup.subscription_id, &salt, &505, &1, &true); + let sub = proxy.get_subscription(&setup.subscription_id); + assert_eq!(sub.charge_count, 1); +} + #[test] fn integration_cross_contract_call_charges_subscription() { let setup = setup_integration(); diff --git a/contracts/subscription/src/lib.rs b/contracts/subscription/src/lib.rs index 75ccad9..7a15210 100644 --- a/contracts/subscription/src/lib.rs +++ b/contracts/subscription/src/lib.rs @@ -1,14 +1,23 @@ #![no_std] +mod gas_optimization; mod gas_profiler; mod gas_storage; -mod gas_optimization; -use soroban_sdk::{token, Address, Env, IntoVal, String, TryFromVal, Val, Vec}; +mod quota; +mod revenue; +mod usage; +use soroban_sdk::{token, Address, Bytes, BytesN, Env, IntoVal, String, TryFromVal, Val, Vec}; use subtrackr_types::{ - Interval, Invoice, Plan, StorageKey, Subscription, SubscriptionStatus, TimeRange, + ChargeCommitment, Interval, Invoice, MevAlert, MevProtectionConfig, Plan, StorageKey, + Subscription, SubscriptionStatus, TimeRange, }; /// Billing interval in seconds. const MAX_PAUSE_DURATION: u64 = 2_592_000; // 30 days +const DEFAULT_COMMIT_REVEAL_THRESHOLD: i128 = i128::MAX; +const DEFAULT_MAX_FEE_BPS: u32 = 100; // 1% +const DEFAULT_REVEAL_DELAY_SECS: u64 = 30; +const DEFAULT_COMMIT_TTL_SECS: u64 = 3_600; +const DEFAULT_GAS_ALERT_THRESHOLD: u64 = u64::MAX; const STORAGE_VERSION: u32 = 2; @@ -185,6 +194,204 @@ fn invoice_contract(env: &Env, storage: &Address) -> Option
{ storage_instance_get(env, storage, StorageKey::InvoiceContract) } +fn get_mev_config(env: &Env, storage: &Address) -> MevProtectionConfig { + storage_instance_get(env, storage, StorageKey::MevProtectionConfig).unwrap_or( + MevProtectionConfig { + large_charge_threshold: DEFAULT_COMMIT_REVEAL_THRESHOLD, + max_fee_bps: DEFAULT_MAX_FEE_BPS, + reveal_delay_secs: DEFAULT_REVEAL_DELAY_SECS, + commit_ttl_secs: DEFAULT_COMMIT_TTL_SECS, + private_mempool_required: false, + gas_price_alert_threshold: DEFAULT_GAS_ALERT_THRESHOLD, + }, + ) +} + +fn validate_mev_config(config: &MevProtectionConfig) { + assert!( + config.large_charge_threshold > 0, + "Large charge threshold must be positive" + ); + assert!(config.max_fee_bps <= 10_000, "Max fee bps too high"); + assert!( + config.commit_ttl_secs > config.reveal_delay_secs, + "Commit TTL must exceed reveal delay" + ); +} + +fn max_charge_with_fee_bound(price: i128, max_fee_bps: u32) -> i128 { + let fee = price + .checked_mul(max_fee_bps as i128) + .expect("Fee overflow") + .checked_div(10_000) + .expect("Fee division failed"); + price.checked_add(fee).expect("Max charge overflow") +} + +fn build_charge_commitment( + env: &Env, + subscription_id: u64, + max_charge_amount: i128, + salt: &BytesN<32>, +) -> BytesN<32> { + let mut payload = Bytes::new(env); + payload.extend_from_slice(b"SubTrackr:charge-commitment:v1"); + payload.extend_from_array(&subscription_id.to_be_bytes()); + payload.extend_from_array(&max_charge_amount.to_be_bytes()); + let salt_bytes: Bytes = salt.clone().into(); + payload.append(&salt_bytes); + env.crypto().sha256(&payload).into() +} + +fn record_mev_gas_alert( + env: &Env, + storage: &Address, + subscription_id: u64, + observed_gas_price: u64, + threshold: u64, +) { + if threshold == u64::MAX || observed_gas_price <= threshold { + return; + } + + let mut count: u64 = storage_instance_get(env, storage, StorageKey::MevAlertCount).unwrap_or(0); + count += 1; + storage_instance_set(env, storage, StorageKey::MevAlertCount, count); + let alert = MevAlert { + id: count, + subscription_id, + observed_gas_price, + threshold, + detected_at: env.ledger().timestamp(), + }; + storage_persistent_set(env, storage, StorageKey::MevAlert(count), alert.clone()); + env.events().publish( + (String::from_str(env, "mev_gas_alert"), subscription_id), + (observed_gas_price, threshold, alert.detected_at), + ); +} + +fn charge_subscription_guarded( + env: &Env, + storage: &Address, + subscription_id: u64, + max_charge_amount: i128, + observed_gas_price: u64, + private_mempool: bool, + revealed_commitment: bool, +) { + let mut sub: Subscription = + storage_persistent_get(env, storage, StorageKey::Subscription(subscription_id)) + .expect("Subscription not found"); + + if sub.subscriber != get_admin(env, storage) { + enforce_rate_limit(env, storage, &sub.subscriber, "charge_subscription"); + } + + sub.subscriber.require_auth(); + + if check_and_resume_internal(env, &mut sub) { + storage_persistent_set( + env, + storage, + StorageKey::Subscription(subscription_id), + sub.clone(), + ); + } + + assert!( + sub.status == SubscriptionStatus::Active, + "Subscription not active" + ); + + let now = env.ledger().timestamp(); + assert!(now >= sub.next_charge_at, "Payment not yet due"); + + let plan: Plan = storage_persistent_get(env, storage, StorageKey::Plan(sub.plan_id)) + .expect("Plan not found"); + let mev_config = get_mev_config(env, storage); + + if !revealed_commitment && plan.price >= mev_config.large_charge_threshold { + panic!("Commit reveal required for large charge"); + } + + if max_charge_amount != i128::MAX { + assert!(max_charge_amount >= plan.price, "Charge exceeds max bound"); + let configured_bound = max_charge_with_fee_bound(plan.price, mev_config.max_fee_bps); + assert!( + max_charge_amount <= configured_bound, + "Max fee bound exceeds configured tolerance" + ); + } + + if mev_config.private_mempool_required { + assert!(private_mempool, "Private mempool route required"); + } + + record_mev_gas_alert( + env, + storage, + subscription_id, + observed_gas_price, + mev_config.gas_price_alert_threshold, + ); + + token::Client::new(env, &plan.token).transfer(&sub.subscriber, &plan.merchant, &plan.price); + + sub.last_charged_at = now; + sub.next_charge_at = now + plan.interval.seconds(); + sub.total_paid += plan.price; + sub.total_gas_spent += 100_000; + sub.charge_count += 1; + + storage_persistent_set( + env, + storage, + StorageKey::Subscription(subscription_id), + sub.clone(), + ); + + revenue::generate_revenue_schedule( + env, + storage, + subscription_id, + sub.plan_id, + plan.price, + now, + plan.interval.seconds(), + ); + revenue::update_merchant_revenue_balances(env, storage, &plan.merchant, 0, plan.price); + revenue::track_merchant_subscription(env, storage, &plan.merchant, subscription_id); + + env.events().publish( + ( + String::from_str(env, "subscription_charged"), + subscription_id, + ), + (sub.subscriber.clone(), plan.price, 100_000u64, now), + ); + + if let Some(invoice_addr) = invoice_contract(env, storage) { + let period = TimeRange { + start: sub.last_charged_at, + end: sub.next_charge_at, + }; + let _invoice: Invoice = env.invoke_contract( + &invoice_addr, + &soroban_sdk::Symbol::new(env, "generate_invoice"), + soroban_sdk::vec![ + env, + storage.clone().into_val(env), + subscription_id.into_val(env), + period.into_val(env), + String::from_str(env, "GLOBAL").into_val(env), + String::from_str(env, "").into_val(env), + ], + ); + let _ = _invoice; + } +} + // ───────────────────────────────────────────────────────────────────────────── // Implementation Contract // ───────────────────────────────────────────────────────────────────────────── @@ -300,6 +507,145 @@ impl SubTrackrSubscription { storage_instance_remove(&env, &storage, StorageKey::RateLimit(function)); } + pub fn configure_mev_protection( + env: Env, + proxy: Address, + storage: Address, + admin: Address, + config: MevProtectionConfig, + ) { + proxy.require_auth(); + assert!(admin == get_admin(&env, &storage), "Admin mismatch"); + admin.require_auth(); + validate_mev_config(&config); + storage_instance_set( + &env, + &storage, + StorageKey::MevProtectionConfig, + config.clone(), + ); + env.events().publish( + (String::from_str(&env, "mev_configured"), admin), + ( + config.large_charge_threshold, + config.max_fee_bps, + config.private_mempool_required, + config.gas_price_alert_threshold, + ), + ); + } + + pub fn commit_charge( + env: Env, + proxy: Address, + storage: Address, + subscription_id: u64, + commitment: BytesN<32>, + ) { + proxy.require_auth(); + let sub: Subscription = + storage_persistent_get(&env, &storage, StorageKey::Subscription(subscription_id)) + .expect("Subscription not found"); + sub.subscriber.require_auth(); + + let now = env.ledger().timestamp(); + if let Some(existing) = storage_persistent_get::( + &env, + &storage, + StorageKey::ChargeCommitment(subscription_id), + ) { + assert!( + now > existing.expires_at, + "Pending charge commitment exists" + ); + } + + let config = get_mev_config(&env, &storage); + let pending = ChargeCommitment { + subscription_id, + subscriber: sub.subscriber.clone(), + commitment: commitment.clone(), + committed_at: now, + min_reveal_at: now + config.reveal_delay_secs, + expires_at: now + config.commit_ttl_secs, + }; + storage_persistent_set( + &env, + &storage, + StorageKey::ChargeCommitment(subscription_id), + pending.clone(), + ); + env.events().publish( + (String::from_str(&env, "charge_committed"), subscription_id), + ( + pending.subscriber, + pending.min_reveal_at, + pending.expires_at, + ), + ); + } + + pub fn hash_charge_commitment( + env: Env, + _proxy: Address, + _storage: Address, + subscription_id: u64, + max_charge_amount: i128, + salt: BytesN<32>, + ) -> BytesN<32> { + build_charge_commitment(&env, subscription_id, max_charge_amount, &salt) + } + + pub fn reveal_charge( + env: Env, + proxy: Address, + storage: Address, + subscription_id: u64, + salt: BytesN<32>, + max_charge_amount: i128, + observed_gas_price: u64, + private_mempool: bool, + ) { + proxy.require_auth(); + let pending: ChargeCommitment = storage_persistent_get( + &env, + &storage, + StorageKey::ChargeCommitment(subscription_id), + ) + .expect("Charge commitment not found"); + + let now = env.ledger().timestamp(); + assert!(now >= pending.min_reveal_at, "Reveal delay not met"); + assert!(now <= pending.expires_at, "Charge commitment expired"); + let revealed_commitment = + build_charge_commitment(&env, subscription_id, max_charge_amount, &salt); + assert!( + pending.commitment == revealed_commitment, + "Commitment mismatch" + ); + pending.subscriber.require_auth(); + + storage_persistent_remove( + &env, + &storage, + StorageKey::ChargeCommitment(subscription_id), + ); + env.events().publish( + (String::from_str(&env, "charge_revealed"), subscription_id), + (max_charge_amount, observed_gas_price, private_mempool, now), + ); + + charge_subscription_guarded( + &env, + &storage, + subscription_id, + max_charge_amount, + observed_gas_price, + private_mempool, + true, + ); + } + // ── Plan Management ── pub fn create_plan( @@ -616,95 +962,7 @@ impl SubTrackrSubscription { pub fn charge_subscription(env: Env, proxy: Address, storage: Address, subscription_id: u64) { proxy.require_auth(); - let mut sub: Subscription = - storage_persistent_get(&env, &storage, StorageKey::Subscription(subscription_id)) - .expect("Subscription not found"); - - if sub.subscriber != get_admin(&env, &storage) { - enforce_rate_limit(&env, &storage, &sub.subscriber, "charge_subscription"); - } - - sub.subscriber.require_auth(); - - if check_and_resume_internal(&env, &mut sub) { - storage_persistent_set( - &env, - &storage, - StorageKey::Subscription(subscription_id), - sub.clone(), - ); - } - - assert!( - sub.status == SubscriptionStatus::Active, - "Subscription not active" - ); - - let now = env.ledger().timestamp(); - assert!(now >= sub.next_charge_at, "Payment not yet due"); - - let plan: Plan = storage_persistent_get(&env, &storage, StorageKey::Plan(sub.plan_id)) - .expect("Plan not found"); - - token::Client::new(&env, &plan.token).transfer( - &sub.subscriber, - &plan.merchant, - &plan.price, - ); - - sub.last_charged_at = now; - sub.next_charge_at = now + plan.interval.seconds(); - sub.total_paid += plan.price; - sub.total_gas_spent += 100_000; - sub.charge_count += 1; - - storage_persistent_set( - &env, - &storage, - StorageKey::Subscription(subscription_id), - sub.clone(), - ); - - // Generate revenue recognition schedule and defer the full charge amount. - revenue::generate_revenue_schedule( - &env, - &storage, - subscription_id, - sub.plan_id, - plan.price, - now, - plan.interval.seconds(), - ); - revenue::update_merchant_revenue_balances(&env, &storage, &plan.merchant, 0, plan.price); - revenue::track_merchant_subscription(&env, &storage, &plan.merchant, subscription_id); - - env.events().publish( - ( - String::from_str(&env, "subscription_charged"), - subscription_id, - ), - (sub.subscriber.clone(), plan.price, 100_000u64, now), - ); - - if let Some(invoice_addr) = invoice_contract(&env, &storage) { - let period = TimeRange { - start: sub.last_charged_at, - end: sub.next_charge_at, - }; - let _invoice: Invoice = env.invoke_contract( - &invoice_addr, - &soroban_sdk::Symbol::new(&env, "generate_invoice"), - soroban_sdk::vec![ - &env, - storage.clone().into_val(&env), - subscription_id.into_val(&env), - period.into_val(&env), - String::from_str(&env, "GLOBAL").into_val(&env), - String::from_str(&env, "").into_val(&env), - ], - ); - let _ = _invoice; - } + charge_subscription_guarded(&env, &storage, subscription_id, i128::MAX, 0, false, false); } pub fn request_refund( @@ -975,6 +1233,34 @@ impl SubTrackrSubscription { storage_instance_get(&env, &storage, StorageKey::SubscriptionCount).unwrap_or(0) } + pub fn get_mev_protection_config( + env: Env, + proxy: Address, + storage: Address, + ) -> MevProtectionConfig { + proxy.require_auth(); + get_mev_config(&env, &storage) + } + + pub fn get_charge_commitment( + env: Env, + proxy: Address, + storage: Address, + subscription_id: u64, + ) -> Option { + proxy.require_auth(); + storage_persistent_get( + &env, + &storage, + StorageKey::ChargeCommitment(subscription_id), + ) + } + + pub fn get_mev_alert_count(env: Env, proxy: Address, storage: Address) -> u64 { + proxy.require_auth(); + storage_instance_get(&env, &storage, StorageKey::MevAlertCount).unwrap_or(0) + } + // ── Revenue Recognition API ── /// Set a revenue recognition rule for a plan (merchant only). diff --git a/contracts/types/src/lib.rs b/contracts/types/src/lib.rs index 86daa83..a6c8233 100644 --- a/contracts/types/src/lib.rs +++ b/contracts/types/src/lib.rs @@ -1,6 +1,6 @@ #![no_std] -use soroban_sdk::{contracttype, Address, String, Vec}; +use soroban_sdk::{contracttype, Address, BytesN, String, Vec}; /// Billing interval in seconds. #[contracttype] @@ -287,6 +287,47 @@ pub struct FraudReport { pub recent_cases: Vec, } +/// MEV protection settings for subscription charges. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct MevProtectionConfig { + /// Charges at or above this amount must use commit-reveal. + pub large_charge_threshold: i128, + /// Maximum subscriber-defined fee/price buffer in basis points. + pub max_fee_bps: u32, + /// Minimum delay between commit and reveal. + pub reveal_delay_secs: Timestamp, + /// Maximum lifetime of a pending commitment. + pub commit_ttl_secs: Timestamp, + /// Require the reveal transaction to come through the configured private path. + pub private_mempool_required: bool, + /// Gas price above this value records an MEV alert. + pub gas_price_alert_threshold: u64, +} + +/// Pending commit-reveal envelope for a subscription charge. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct ChargeCommitment { + pub subscription_id: SubscriptionId, + pub subscriber: Address, + pub commitment: BytesN<32>, + pub committed_at: Timestamp, + pub min_reveal_at: Timestamp, + pub expires_at: Timestamp, +} + +/// Monitoring record for suspicious fee/gas conditions around a charge. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct MevAlert { + pub id: u64, + pub subscription_id: SubscriptionId, + pub observed_gas_price: u64, + pub threshold: u64, + pub detected_at: Timestamp, +} + /// Storage keys for the proxy contract state. /// /// IMPORTANT: Never reorder existing variants. Append new variants only. @@ -360,4 +401,10 @@ pub enum StorageKey { PlanQuotas(u64), /// Usage record for a subscription and metric (sub_id, metric -> UsageRecord) SubscriptionUsage(u64, QuotaMetric), + + // Added for MEV-resistant subscription charging + MevProtectionConfig, + ChargeCommitment(u64), + MevAlertCount, + MevAlert(u64), } diff --git a/docs/security.md b/docs/security.md index f96308b..41c932d 100644 --- a/docs/security.md +++ b/docs/security.md @@ -28,6 +28,34 @@ The repository is monitored using several automated tools: 2. **NPM Audit**: Integrated into CI/CD to prevent merging code with high-risk dependencies. 3. **Audit-CI**: Enforces strict policy-based audits during the build process. +## MEV Threat Model for Subscription Charges + +Subscription charge transactions can be visible before inclusion. For large +charges, this creates room for ordering games, gas bidding, and sandwich-style +execution around token liquidity or merchant-side accounting hooks. + +The subscription contract exposes an opt-in MEV protection configuration: + +- `large_charge_threshold` forces charges at or above the threshold through a + commit-reveal flow instead of the direct charge path. +- `max_fee_bps` caps how loose a subscriber's reveal-time maximum charge bound + can be, protecting against stale or manipulated charge parameters. +- `private_mempool_required` lets operators require relayers/private routing for + protected reveals when public mempool exposure is unacceptable. +- `gas_price_alert_threshold` records an on-chain alert counter/event when a + reveal reports an unusually high gas price signal. + +Recommended operation: + +1. Configure conservative thresholds for high-value plans. +2. Have the subscriber derive a 32-byte commitment with + `hash_charge_commitment(subscription_id, max_charge_amount, salt)` and submit + it through `commit_charge`. +3. Reveal after the configured delay with `reveal_charge`, the salt, a strict + `max_charge_amount`, observed gas price, and private-route flag. +4. Monitor `mev_gas_alert` events and `get_mev_alert_count` for gas bidding + anomalies. + ## Patching Workflow 1. **Notification**: Dependabot or CI alert triggers a notification.