diff --git a/quicklendx-contracts/src/audit.rs b/quicklendx-contracts/src/audit.rs index d8604d3f..0cad4a52 100644 --- a/quicklendx-contracts/src/audit.rs +++ b/quicklendx-contracts/src/audit.rs @@ -34,6 +34,7 @@ //! See `src/test_audit.rs` for comprehensive integrity tests. use crate::errors::QuickLendXError; +use crate::storage::extend_instance_ttl; use crate::types::{Invoice, InvoiceStatus}; use soroban_sdk::{ contracttype, symbol_short, xdr::ToXdr, Address, Bytes, BytesN, Env, String, Symbol, Vec, @@ -541,6 +542,25 @@ impl AuditStorage { .unwrap_or_else(|| Vec::new(env)) } + pub fn extend_invoice_audit_ttl(env: &Env, invoice_id: &BytesN<32>) -> u32 { + let trail = Self::get_invoice_audit_trail(env, invoice_id); + if trail.len() == 0 { + return 0; + } + + let mut refreshed = 1u32; + for audit_id in trail.iter() { + if Self::get_audit_entry(env, &audit_id).is_some() { + refreshed = refreshed.saturating_add(1); + } + } + + // Audit data uses instance storage, whose TTL is extended for the whole + // instance rather than one key at a time. + extend_instance_ttl(env); + refreshed + } + /// Get audit entries by operation type pub fn get_audit_entries_by_operation( env: &Env, diff --git a/quicklendx-contracts/src/bid.rs b/quicklendx-contracts/src/bid.rs index 7e7b085f..0539528f 100644 --- a/quicklendx-contracts/src/bid.rs +++ b/quicklendx-contracts/src/bid.rs @@ -243,6 +243,36 @@ impl BidStorage { bids } + pub fn extend_invoice_bid_ttl(env: &Env, invoice_id: &BytesN<32>) -> u32 { + let count_key = Self::invoice_bid_count_key(invoice_id); + let count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); + let mut refreshed = 0u32; + + if count > 0 { + extend_persistent_ttl(env, &count_key); + refreshed = refreshed.saturating_add(1); + } + + let mut idx: u32 = 0; + while idx < count { + let entry_key = Self::invoice_bid_entry_key(invoice_id, idx); + let bid_id: Option> = env.storage().persistent().get(&entry_key); + if let Some(bid_id) = bid_id { + extend_persistent_ttl(env, &entry_key); + refreshed = refreshed.saturating_add(1); + + let bid: Option = env.storage().persistent().get(&bid_id); + if bid.is_some() { + extend_persistent_ttl(env, &bid_id); + refreshed = refreshed.saturating_add(1); + } + } + idx += 1; + } + + refreshed + } + pub fn get_active_bid_count(env: &Env, invoice_id: &BytesN<32>) -> u32 { let _ = Self::refresh_expired_bids(env, invoice_id); let bid_ids = Self::get_bids_for_invoice(env, invoice_id); diff --git a/quicklendx-contracts/src/investment.rs b/quicklendx-contracts/src/investment.rs index 0b2e8147..984d1d95 100644 --- a/quicklendx-contracts/src/investment.rs +++ b/quicklendx-contracts/src/investment.rs @@ -396,6 +396,29 @@ impl InvestmentStorage { .filter(|inv| inv.invoice_id == *invoice_id) } + pub fn extend_invoice_investment_ttl(env: &Env, invoice_id: &BytesN<32>) -> u32 { + let index_key = Self::invoice_index_key(invoice_id); + let investment_id: Option> = env.storage().persistent().get(&index_key); + let mut refreshed = 0u32; + + if let Some(investment_id) = investment_id { + extend_persistent_ttl(env, &index_key); + refreshed = refreshed.saturating_add(1); + + let investment: Option = env.storage().persistent().get(&investment_id); + if investment + .as_ref() + .map(|investment| investment.invoice_id == invoice_id.clone()) + .unwrap_or(false) + { + extend_persistent_ttl(env, &investment_id); + refreshed = refreshed.saturating_add(1); + } + } + + refreshed + } + /// Update an investment, enforcing the transition guard and maintaining the /// active-investment index so no orphan `Active` records can accumulate. /// diff --git a/quicklendx-contracts/src/lib.rs b/quicklendx-contracts/src/lib.rs index a6d54345..21bd895d 100644 --- a/quicklendx-contracts/src/lib.rs +++ b/quicklendx-contracts/src/lib.rs @@ -526,6 +526,15 @@ impl QuickLendXContract { maintenance::MaintenanceControl::extend_protocol_ttl(&env, &admin) } + /// Admin-only: extends TTL for one invoice and its associated storage keys. + pub fn extend_invoice_ttl( + env: Env, + admin: Address, + invoice_id: BytesN<32>, + ) -> Result { + maintenance::MaintenanceControl::extend_invoice_ttl(&env, &admin, &invoice_id) + } + /// Admin-gated protocol heartbeat. Authenticates `admin` as the stored protocol /// admin, then runs every composed invariant check read-only. pub fn invariant_self_check( diff --git a/quicklendx-contracts/src/maintenance.rs b/quicklendx-contracts/src/maintenance.rs index 94db903d..89b3e608 100644 --- a/quicklendx-contracts/src/maintenance.rs +++ b/quicklendx-contracts/src/maintenance.rs @@ -7,13 +7,15 @@ //! - Admin operations that toggle maintenance mode itself are always allowed. use crate::admin::AdminStorage; +use crate::audit::AuditStorage; use crate::bid::BidStorage; use crate::currency::CurrencyWhitelist; use crate::errors::QuickLendXError; use crate::investment::InvestmentStorage; use crate::payments::EscrowStorage; +use crate::settlement; use crate::storage::{extend_persistent_ttl, DataKey, InvoiceStorage}; -use soroban_sdk::{contracttype, symbol_short, Address, Env, String, Symbol}; +use soroban_sdk::{contracttype, symbol_short, Address, BytesN, Env, String, Symbol}; /// Storage key for the maintenance mode boolean flag. pub const MAINTENANCE_MODE_KEY: Symbol = symbol_short!("maint"); @@ -37,6 +39,9 @@ pub const MAX_REASON_LEN: u32 = 256; /// * `escrows_refreshed` — Number of escrow records extended. /// * `currencies_refreshed` — Number of whitelisted currency entries extended. /// +/// The targeted invoice path also fills payment and audit counts when those +/// record sets exist. +/// /// # Idempotency /// Calling `extend_protocol_ttl` multiple times within the same ledger is safe: /// `extend_ttl` is itself idempotent, and the report always reflects the current @@ -53,6 +58,10 @@ pub struct ExtendReport { pub investments_refreshed: u32, /// Number of escrow records whose TTL was extended. pub escrows_refreshed: u32, + /// Number of payment storage keys whose TTL was extended. + pub payments_refreshed: u32, + /// Number of audit storage keys covered by an instance TTL extension. + pub audit_refreshed: u32, /// Number of whitelisted currency entries whose TTL was extended. pub currencies_refreshed: u32, } @@ -60,6 +69,34 @@ pub struct ExtendReport { pub struct MaintenanceControl; impl MaintenanceControl { + fn empty_report() -> ExtendReport { + ExtendReport { + invoices_refreshed: 0, + bids_refreshed: 0, + investments_refreshed: 0, + escrows_refreshed: 0, + payments_refreshed: 0, + audit_refreshed: 0, + currencies_refreshed: 0, + } + } + + fn emit_ttl_event(env: &Env, kind: &str, count: u32) { + if count > 0 { + crate::events::emit_ttl_extended(env, &String::from_str(env, kind), count); + } + } + + fn emit_extend_report_events(env: &Env, report: &ExtendReport) { + Self::emit_ttl_event(env, "invoice", report.invoices_refreshed); + Self::emit_ttl_event(env, "bid", report.bids_refreshed); + Self::emit_ttl_event(env, "investment", report.investments_refreshed); + Self::emit_ttl_event(env, "escrow", report.escrows_refreshed); + Self::emit_ttl_event(env, "payment", report.payments_refreshed); + Self::emit_ttl_event(env, "audit", report.audit_refreshed); + Self::emit_ttl_event(env, "currency", report.currencies_refreshed); + } + /// Return `true` if the protocol is currently in maintenance mode. pub fn is_maintenance_mode(env: &Env) -> bool { env.storage() @@ -147,13 +184,7 @@ impl MaintenanceControl { ) -> Result { AdminStorage::require_admin(env, admin)?; - let mut report = ExtendReport { - invoices_refreshed: 0, - bids_refreshed: 0, - investments_refreshed: 0, - escrows_refreshed: 0, - currencies_refreshed: 0, - }; + let mut report = Self::empty_report(); // 1. Extend Invoices for invoice_id in InvoiceStorage::get_all_invoice_ids(env).iter() { @@ -187,42 +218,33 @@ impl MaintenanceControl { report.currencies_refreshed += 1; } - // Emit events for each kind that was refreshed - if report.invoices_refreshed > 0 { - crate::events::emit_ttl_extended( - env, - &String::from_str(env, "invoice"), - report.invoices_refreshed, - ); - } - if report.bids_refreshed > 0 { - crate::events::emit_ttl_extended( - env, - &String::from_str(env, "bid"), - report.bids_refreshed, - ); - } - if report.investments_refreshed > 0 { - crate::events::emit_ttl_extended( - env, - &String::from_str(env, "investment"), - report.investments_refreshed, - ); - } - if report.escrows_refreshed > 0 { - crate::events::emit_ttl_extended( - env, - &String::from_str(env, "escrow"), - report.escrows_refreshed, - ); - } - if report.currencies_refreshed > 0 { - crate::events::emit_ttl_extended( - env, - &String::from_str(env, "currency"), - report.currencies_refreshed, - ); - } + Self::emit_extend_report_events(env, &report); + + Ok(report) + } + + /// Admin-only: extends TTL for one invoice and its associated storage keys. + pub fn extend_invoice_ttl( + env: &Env, + admin: &Address, + invoice_id: &BytesN<32>, + ) -> Result { + AdminStorage::require_admin(env, admin)?; + InvoiceStorage::get_invoice(env, invoice_id).ok_or(QuickLendXError::InvoiceNotFound)?; + + let mut report = Self::empty_report(); + + extend_persistent_ttl(env, &DataKey::Invoice(invoice_id.clone())); + report.invoices_refreshed = 1; + + report.bids_refreshed = BidStorage::extend_invoice_bid_ttl(env, invoice_id); + report.investments_refreshed = + InvestmentStorage::extend_invoice_investment_ttl(env, invoice_id); + report.escrows_refreshed = EscrowStorage::extend_invoice_escrow_ttl(env, invoice_id); + report.payments_refreshed = settlement::extend_invoice_payment_ttl(env, invoice_id); + report.audit_refreshed = AuditStorage::extend_invoice_audit_ttl(env, invoice_id); + + Self::emit_extend_report_events(env, &report); Ok(report) } diff --git a/quicklendx-contracts/src/payments.rs b/quicklendx-contracts/src/payments.rs index 8421649d..c84ef769 100644 --- a/quicklendx-contracts/src/payments.rs +++ b/quicklendx-contracts/src/payments.rs @@ -331,6 +331,29 @@ impl EscrowStorage { } } + pub fn extend_invoice_escrow_ttl(env: &Env, invoice_id: &BytesN<32>) -> u32 { + let invoice_key = (symbol_short!("escrow"), invoice_id); + let escrow_id: Option> = env.storage().persistent().get(&invoice_key); + let mut refreshed = 0u32; + + if let Some(escrow_id) = escrow_id { + extend_persistent_ttl(env, &invoice_key); + refreshed = refreshed.saturating_add(1); + + let escrow: Option = env.storage().persistent().get(&escrow_id); + if escrow + .as_ref() + .map(|escrow| escrow.invoice_id == invoice_id.clone()) + .unwrap_or(false) + { + extend_persistent_ttl(env, &escrow_id); + refreshed = refreshed.saturating_add(1); + } + } + + refreshed + } + pub fn update_escrow(env: &Env, escrow: &Escrow) { env.storage().persistent().set(&escrow.escrow_id, escrow); extend_persistent_ttl(env, &escrow.escrow_id); @@ -491,8 +514,7 @@ pub fn create_escrow( /// * [`QuickLendXError::TokenTransferFailed`] - the token contract panicked; escrow status is /// **not** updated so the release can be safely retried. pub fn release_escrow(env: &Env, invoice_id: &BytesN<32>) -> Result<(), QuickLendXError> { - let mut escrow = EscrowStorage::get_escrow_by_invoice(env, invoice_id) - .unwrap(); + let mut escrow = EscrowStorage::get_escrow_by_invoice(env, invoice_id).unwrap(); if escrow.status != EscrowStatus::Held { // Prevents repeated release (idempotency) @@ -547,8 +569,7 @@ pub fn release_escrow(env: &Env, invoice_id: &BytesN<32>) -> Result<(), QuickLen /// * [`QuickLendXError::TokenTransferFailed`] - the token contract panicked; escrow status is /// **not** updated so the refund can be safely retried. pub fn refund_escrow(env: &Env, invoice_id: &BytesN<32>) -> Result<(), QuickLendXError> { - let mut escrow = EscrowStorage::get_escrow_by_invoice(env, invoice_id) - .unwrap(); + let mut escrow = EscrowStorage::get_escrow_by_invoice(env, invoice_id).unwrap(); if escrow.status != EscrowStatus::Held { return Err(QuickLendXError::InvalidStatus); @@ -946,4 +967,4 @@ mod payments_tests { }); assert_eq!(r2, Err(QuickLendXError::InvoiceAlreadyFunded)); } -} \ No newline at end of file +} diff --git a/quicklendx-contracts/src/settlement.rs b/quicklendx-contracts/src/settlement.rs index aa0077b5..6c97dbd8 100644 --- a/quicklendx-contracts/src/settlement.rs +++ b/quicklendx-contracts/src/settlement.rs @@ -83,7 +83,7 @@ use crate::errors::QuickLendXError; use crate::events::{emit_invoice_settled, emit_partial_payment}; use crate::investment::InvestmentStorage; use crate::payments::transfer_funds; -use crate::storage::InvoiceStorage; +use crate::storage::{extend_persistent_ttl, InvoiceStorage}; use crate::types::InvestmentStatus; use crate::types::{Invoice, InvoiceStatus, PaymentRecord as InvoicePaymentRecord}; use soroban_sdk::{contracttype, symbol_short, Address, BytesN, Env, String, Vec}; @@ -415,8 +415,7 @@ pub fn settle_invoice( .checked_add(applied_preview) .ok_or(QuickLendXError::InvalidAmount)?; - let investment = InvestmentStorage::get_investment_by_invoice(env, invoice_id) - .unwrap(); + let investment = InvestmentStorage::get_investment_by_invoice(env, invoice_id).unwrap(); if projected_total < invoice.amount || projected_total < investment.amount { return Err(QuickLendXError::PaymentTooLow); @@ -522,6 +521,39 @@ pub fn get_payment_records( Ok(records) } +pub fn extend_invoice_payment_ttl(env: &Env, invoice_id: &BytesN<32>) -> u32 { + let count_key = SettlementDataKey::PaymentCount(invoice_id.clone()); + let count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); + let mut refreshed = 0u32; + + if count > 0 { + extend_persistent_ttl(env, &count_key); + refreshed = refreshed.saturating_add(1); + } + + let mut idx = 0u32; + while idx < count { + let payment_key = SettlementDataKey::Payment(invoice_id.clone(), idx); + let payment: Option = env.storage().persistent().get(&payment_key); + if payment.is_some() { + extend_persistent_ttl(env, &payment_key); + refreshed = refreshed.saturating_add(1); + } + idx += 1; + } + + let finalized_key = SettlementDataKey::Finalized(invoice_id.clone()); + let finalized: Option = env.storage().persistent().get(&finalized_key); + if finalized.is_some() { + extend_persistent_ttl(env, &finalized_key); + refreshed = refreshed.saturating_add(1); + } + + // PaymentNonce keys are replay markers keyed by caller-provided nonce and + // are not enumerable in the current storage layout. + refreshed +} + /// Returns whether an invoice has been finalized (settlement completed). pub fn is_invoice_finalized(env: &Env, invoice_id: &BytesN<32>) -> Result { ensure_invoice_exists(env, invoice_id)?; @@ -542,8 +574,7 @@ fn settle_invoice_internal(env: &Env, invoice_id: &BytesN<32>) -> Result<(), Qui InvoiceStorage::get_invoice(env, invoice_id).ok_or(QuickLendXError::InvoiceNotFound)?; ensure_payable_status(&invoice)?; - let investment = InvestmentStorage::get_investment_by_invoice(env, invoice_id) - .unwrap(); + let investment = InvestmentStorage::get_investment_by_invoice(env, invoice_id).unwrap(); if invoice.total_paid < invoice.amount || invoice.total_paid < investment.amount { return Err(QuickLendXError::PaymentTooLow); diff --git a/quicklendx-contracts/src/storage.rs b/quicklendx-contracts/src/storage.rs index ac7fe0f7..0f2be7fd 100644 --- a/quicklendx-contracts/src/storage.rs +++ b/quicklendx-contracts/src/storage.rs @@ -22,6 +22,11 @@ where env.storage().persistent().extend_ttl(key, ttl_u32, ttl_u32); } +pub fn extend_instance_ttl(env: &Env) { + let ttl_u32: u32 = PERSISTENT_TTL_THRESHOLD.try_into().unwrap_or(0); + env.storage().instance().extend_ttl(ttl_u32, ttl_u32); +} + pub fn bump_persistent(env: &Env, key: &T) where T: soroban_sdk::IntoVal, diff --git a/quicklendx-contracts/src/test_maintenance.rs b/quicklendx-contracts/src/test_maintenance.rs index 6b7cd9cf..b486b59e 100644 --- a/quicklendx-contracts/src/test_maintenance.rs +++ b/quicklendx-contracts/src/test_maintenance.rs @@ -25,19 +25,27 @@ use crate::events::TtlExtended; use crate::invoice::{InvoiceCategory, InvoiceStatus}; use crate::maintenance::{ExtendReport, MaintenanceControl, MAX_REASON_LEN}; use crate::{QuickLendXContract, QuickLendXContractClient}; -use soroban_sdk::{testutils::Address as _, Address, Env, String, Vec}; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token, Address, BytesN, Env, String, Vec, +}; // ============================================================================ // Helpers // ============================================================================ fn setup(env: &Env) -> (QuickLendXContractClient<'static>, Address) { + let (client, admin, _) = setup_with_id(env); + (client, admin) +} + +fn setup_with_id(env: &Env) -> (QuickLendXContractClient<'static>, Address, Address) { env.mock_all_auths(); let contract_id = env.register(QuickLendXContract, ()); let client = QuickLendXContractClient::new(env, &contract_id); let admin = Address::generate(env); client.initialize_admin(&admin); - (client, admin) + (client, admin, contract_id) } fn reason(env: &Env, msg: &str) -> String { @@ -62,6 +70,55 @@ fn make_invoice( ) } +fn init_currency_for_test( + env: &Env, + contract_id: &Address, + business: &Address, + investor: &Address, +) -> Address { + let token_admin = Address::generate(env); + let currency = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); + let token_client = token::Client::new(env, ¤cy); + let sac_client = token::StellarAssetClient::new(env, ¤cy); + let initial_balance = 10_000i128; + + sac_client.mint(business, &initial_balance); + sac_client.mint(investor, &initial_balance); + + let expiration = env.ledger().sequence() + 1_000; + token_client.approve(investor, contract_id, &initial_balance, &expiration); + token_client.approve(business, contract_id, &initial_balance, &expiration); + + currency +} + +fn make_funded_invoice_with_payment( + env: &Env, + client: &QuickLendXContractClient, + admin: &Address, + business: &Address, + investor: &Address, + currency: &Address, +) -> BytesN<32> { + client.set_admin(admin); + client.add_currency(admin, currency); + + client.submit_kyc_application(business, &String::from_str(env, "Business KYC")); + client.verify_business(admin, business); + client.submit_investor_kyc(investor, &String::from_str(env, "Investor KYC")); + client.verify_investor(investor, &10_000i128); + + let invoice_id = make_invoice(env, client, business, currency); + client.verify_invoice(&invoice_id); + let bid_id = client.place_bid(investor, &invoice_id, &500i128, &1_000i128); + client.accept_bid(&invoice_id, &bid_id); + client.process_partial_payment(&invoice_id, &100i128, &String::from_str(env, "pmt-1")); + + invoice_id +} + // ============================================================================ // 1. Toggle // ============================================================================ @@ -481,6 +538,8 @@ fn test_extend_ttl_empty_indexes() { bids_refreshed: 0, investments_refreshed: 0, escrows_refreshed: 0, + payments_refreshed: 0, + audit_refreshed: 0, currencies_refreshed: 0, }, "report must be all-zero when no data exists" @@ -567,6 +626,8 @@ fn test_extend_ttl_emits_events() { + (report.bids_refreshed > 0) as usize + (report.investments_refreshed > 0) as usize + (report.escrows_refreshed > 0) as usize + + (report.payments_refreshed > 0) as usize + + (report.audit_refreshed > 0) as usize + (report.currencies_refreshed > 0) as usize; assert_eq!( @@ -576,6 +637,118 @@ fn test_extend_ttl_emits_events() { ); } +#[test] +fn test_extend_invoice_ttl_unknown_invoice_rejected() { + let env = Env::default(); + let (client, admin) = setup(&env); + let unknown_invoice = BytesN::from_array(&env, &[9u8; 32]); + + let result = client.try_extend_invoice_ttl(&admin, &unknown_invoice); + + assert_eq!( + result.unwrap_err().unwrap(), + QuickLendXError::InvoiceNotFound, + "unknown invoice IDs must be rejected with a typed error" + ); +} + +#[test] +fn test_extend_invoice_ttl_non_admin_rejected() { + let env = Env::default(); + let (client, _admin) = setup(&env); + let business = Address::generate(&env); + let currency = Address::generate(&env); + let invoice_id = make_invoice(&env, &client, &business, ¤cy); + let attacker = Address::generate(&env); + + let result = client.try_extend_invoice_ttl(&attacker, &invoice_id); + + assert_eq!( + result.unwrap_err().unwrap(), + QuickLendXError::NotAdmin, + "non-admin must receive NotAdmin" + ); +} + +#[test] +fn test_extend_invoice_ttl_invoice_only() { + let env = Env::default(); + let (client, admin) = setup(&env); + let business = Address::generate(&env); + let currency = Address::generate(&env); + let invoice_id = make_invoice(&env, &client, &business, ¤cy); + + let before = count_ttl_extended_events(&env); + let report = client.extend_invoice_ttl(&admin, &invoice_id); + let after = count_ttl_extended_events(&env); + + assert_eq!(report.invoices_refreshed, 1); + assert_eq!(report.bids_refreshed, 0); + assert_eq!(report.investments_refreshed, 0); + assert_eq!(report.escrows_refreshed, 0); + assert_eq!(report.payments_refreshed, 0); + assert_eq!(report.audit_refreshed, 0); + assert_eq!(report.currencies_refreshed, 0); + assert_eq!( + after - before, + 1, + "only invoice TTL event should be emitted" + ); +} + +#[test] +fn test_extend_invoice_ttl_full_record_set() { + let env = Env::default(); + let (client, admin, contract_id) = setup_with_id(&env); + let business = Address::generate(&env); + let investor = Address::generate(&env); + let currency = init_currency_for_test(&env, &contract_id, &business, &investor); + let invoice_id = + make_funded_invoice_with_payment(&env, &client, &admin, &business, &investor, ¤cy); + let invoice = client.get_invoice(&invoice_id); + + env.as_contract(&contract_id, || { + crate::audit::log_invoice_uploaded( + &env, + invoice_id.clone(), + business.clone(), + invoice.amount, + ); + }); + + let before = count_ttl_extended_events(&env); + let report = client.extend_invoice_ttl(&admin, &invoice_id); + let after = count_ttl_extended_events(&env); + + assert_eq!(report.invoices_refreshed, 1); + assert_eq!( + report.bids_refreshed, 3, + "bid count key, invoice bid entry key, and bid record" + ); + assert_eq!( + report.investments_refreshed, 2, + "invoice investment index and investment record" + ); + assert_eq!( + report.escrows_refreshed, 2, + "invoice escrow index and escrow record" + ); + assert_eq!( + report.payments_refreshed, 2, + "payment count key and one payment record" + ); + assert_eq!( + report.audit_refreshed, 2, + "invoice audit trail key and one audit entry" + ); + assert_eq!(report.currencies_refreshed, 0); + assert_eq!( + after - before, + 6, + "one event should be emitted for each non-zero targeted kind" + ); +} + #[test] fn test_extend_ttl_no_events_when_empty() { let env = Env::default();