From 4a2d0d5060c4f21ff0d978fbe4a77903b93ccd9f Mon Sep 17 00:00:00 2001 From: yigitcangokmen Date: Wed, 19 Aug 2026 22:58:54 +0300 Subject: [PATCH] test: pin the ledger-entry ceiling that makes MAX_HISTORY_ENTRIES unreachable MAX_HISTORY_ENTRIES is documented as preventing storage DoS by capping the history vector, but it cannot be reached. A SpendingLimitData holding 816 entries serializes to 65,592 bytes, past the mainnet contractDataEntrySizeBytes limit of 65,536, so enforce fails with an untyped host budget error and HistoryCapacityExceeded is never returned. The measured ceiling is 815. This is visible in the existing suite: enforce_history_capacity_exceeded reaches 1000 only because it calls disable_resource_limits(). That call is a normal idiom here, used in eight places across four files for tests that deliberately exceed mainnet limits, so this is not a claim that the test is wrong. It does mean no test currently covers the interaction between the constant and the platform limit. Adds two tests that pin both sides of the boundary under mainnet defaults: filling to 815 succeeds and leaves the guard untouched, and 816 fails with Error(Budget, ExceededLimit). Documents the ceiling on the constant. The constant itself is left at 1000. Lowering it would be a behavioural change for accounts currently holding more than the new value in-window, and the right value is a judgement about headroom that belongs to the maintainers. The open question is in the linked issue. Note the second test asserts a host budget error rather than a contract error code, so it does not use the usual Error(Contract, #NNNN) form. stellar-accounts: 183 passed. --- .../accounts/src/policies/spending_limit.rs | 10 ++++ .../src/policies/test/spending_limit.rs | 60 +++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/packages/accounts/src/policies/spending_limit.rs b/packages/accounts/src/policies/spending_limit.rs index 6bbaa4526..2d386e582 100644 --- a/packages/accounts/src/policies/spending_limit.rs +++ b/packages/accounts/src/policies/spending_limit.rs @@ -155,6 +155,16 @@ pub const SPENDING_LIMIT_TTL_THRESHOLD: u32 = SPENDING_LIMIT_EXTEND_AMOUNT - DAY /// Maximum number of spending entries to keep in history. /// This prevents storage DoS by capping the vector size. +/// +/// # Notes +/// +/// This cap is currently **unreachable**. A `SpendingLimitData` holding 816 +/// entries serializes to 65,592 bytes, past the mainnet +/// `contractDataEntrySizeBytes` limit of 65,536, so [`enforce`] fails with a +/// host budget error at 816 and [`SpendingLimitError::HistoryCapacityExceeded`] +/// is never returned. The measured ceiling is 815 entries. +/// `spending_history_ledger_entry_ceiling` in the test module pins both +/// boundaries. pub const MAX_HISTORY_ENTRIES: u32 = 1000; // ################## QUERY STATE ################## diff --git a/packages/accounts/src/policies/test/spending_limit.rs b/packages/accounts/src/policies/test/spending_limit.rs index 0970ae5d6..4995d26b9 100644 --- a/packages/accounts/src/policies/test/spending_limit.rs +++ b/packages/accounts/src/policies/test/spending_limit.rs @@ -905,3 +905,63 @@ fn enforce_create_contract_context_with_signers_errors() { enforce(&e, &context, &context_rule.signers, &context_rule, &smart_account); }); } + +/// The number of `SpendingEntry` items that fit in one ledger entry under the +/// mainnet `contractDataEntrySizeBytes` limit of 65,536. Measured: 816 entries +/// serialize to 65,592 bytes. +const MEASURED_ENTRY_CEILING: u32 = 815; + +/// Fill `spending_history` with `count` entries, one per ledger, through the +/// real `enforce` path and with resource limits left at their mainnet +/// defaults. +fn fill_history(e: &Env, address: &Address, smart_account: &Address, count: u32) { + let context_rule = create_context_rule(e); + let context = create_transfer_context(e, 1); + + e.as_contract(address, || { + let params = + SpendingLimitAccountParams { spending_limit: i128::MAX, period_ledgers: 1_000_000 }; + install(e, ¶ms, &context_rule, smart_account); + }); + + for i in 0..count { + e.ledger().with_mut(|li| { + li.sequence_number = 1000 + i; + }); + e.as_contract(address, || { + enforce(e, &context, &context_rule.signers, &context_rule, smart_account); + }); + } +} + +#[test] +fn spending_history_ledger_entry_ceiling() { + let e = Env::default(); + let address = e.register(MockContract, ()); + let smart_account = Address::generate(&e); + + e.mock_all_auths(); + + // Filling to the measured ceiling succeeds. This is well below + // MAX_HISTORY_ENTRIES, so the capacity guard never participates. + fill_history(&e, &address, &smart_account, MEASURED_ENTRY_CEILING); + + let data = e.as_contract(&address, || get_spending_limit_data(&e, 1, &smart_account)); + assert_eq!(data.spending_history.len(), MEASURED_ENTRY_CEILING); +} + +#[test] +#[should_panic(expected = "Error(Budget, ExceededLimit)")] +fn spending_history_one_past_the_ceiling_exceeds_the_ledger_entry_limit() { + let e = Env::default(); + let address = e.register(MockContract, ()); + let smart_account = Address::generate(&e); + + e.mock_all_auths(); + + // One entry past the ceiling, the write of the whole SpendingLimitData + // exceeds contractDataEntrySizeBytes and the host rejects it. This is the + // reason MAX_HISTORY_ENTRIES is unreachable: the failure arrives as an + // untyped budget error rather than HistoryCapacityExceeded. + fill_history(&e, &address, &smart_account, MEASURED_ENTRY_CEILING + 1); +}