fix(fee_collector): extend TTL of KEY_TOTAL on every collect_fee write, add get_total_collected_opt, document ambiguous-zero caveat - #69
Open
DammyAji wants to merge 1 commit into
Conversation
Closes StellarSend#39 The (KEY_TOTAL, token) persistent entry was never TTL-extended after being written, so a token that went dormant long enough would have its lifetime-total silently archived. get_total_collected would then return 0 — indistinguishable from 'no fees ever collected' — with no error, no warning, and no way for callers to tell the difference. Changes ─────── fee_collector/src/lib.rs • Add TOTAL_TTL_THRESHOLD (518_400 ledgers, ~30 days at 5 s/ledger) and TOTAL_TTL_TARGET (2_073_600 ledgers, ~120 days) constants with full doc comments explaining the rationale. • Call env.storage().persistent().extend_ttl(&total_key, TOTAL_TTL_THRESHOLD, TOTAL_TTL_TARGET) after every set() in collect_fee, so the entry stays live even if the token goes dormant. • Expand the get_total_collected doc comment with an explicit '# Caveat: ambiguous zero' section documenting both cases where 0 is returned (genuinely zero vs. historically non-zero but now stale) and pointing callers to get_total_collected_opt. • Add get_total_collected_opt() -> Option<i128>: returns None when the entry is absent (never collected OR stale) and Some(n) when present, giving callers a way to distinguish the two cases for treasury reporting, per-epoch withdrawal limits, or any on-chain logic that must treat 0 and 'unknown' differently. fee_collector/src/test.rs • Add Ledger as _ and storage::Persistent as _ testutil imports. • Add test_total_collected_ttl_extended_across_ledgers: sets min_persistent_entry_ttl=5_000 and max_entry_ttl=3_000_000, calls collect_fee to establish a nonzero total, then asserts via env.as_contract + get_ttl that the entry TTL equals TOTAL_TTL_TARGET (not the bare 4_999 it would be without extend_ttl). Advances the ledger by 10_000 (past the un-extended TTL), re-checks TTL decay and entry presence, and verifies both get_total_collected and get_total_collected_opt return the correct values end-to-end. fee_collector/test_snapshots/test/ • Updated test_collect_fee_updates_total.1.json and test_withdraw_sends_tokens_to_recipient.1.json: TTL field updated from 4095 (old SDK default) to 2073600 (TOTAL_TTL_TARGET), correctly reflecting the extend_ttl call now present in collect_fee. • Added test_total_collected_ttl_extended_across_ledgers.1.json snapshot for the new TTL test.
Author
|
@abayomicornelius Please kindly review. Thank you. |
Author
|
@abayomicornelius Please kindly review and merge. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #39
This PR fixes the silent-data-loss bug described in issue #39: the
(KEY_TOTAL, token)persistent storage entry written bycollect_feewas never TTL-extended, meaning a token that went dormant long enough would eventually have its lifetime-total entry archived by the network. When that happened,get_total_collectedwould silently return0— indistinguishable from "no fees have ever been collected for this token" — with no error, no warning, and no mechanism for any caller to detect that the value was wrong rather than genuinely zero.This is the sharpest variant of the TTL class of bugs in this codebase because it is silent: other TTL omissions produce loud traps or explicit "not found" errors that at least surface the problem. A wrong-but-plausible
0fromget_total_collectedis the worst possible failure mode for an accounting function, and is especially dangerous for any downstream system (treasury dashboard, per-epoch withdrawal-limit feature, on-chain audit trail) that treats this value as ground truth.Root cause (as diagnosed in issue #39)
A repo-wide
grep -rn "extend_ttl|bump|ttl|TTL" fee_collector/srcreturned zero matches in non-test source before this PR, confirming no TTL extension existed anywhere in the contract.Changes
fee_collector/src/lib.rs1. TTL policy constants — new section added immediately after
KEY_TOTAL:Both values are documented in full with rationale for their specific sizes.
2.
extend_ttlcall incollect_fee— added immediately afterset():The
extend_ttlcall uses the threshold + target pattern, so the Soroban host skips the extension if the entry's current TTL already exceedsTOTAL_TTL_THRESHOLD— there is no wasteful re-bump on every single fee when the entry is already healthy. The extension only fires when the remaining TTL has decayed below ~30 days, at which point it is reset to ~120 days.3. Updated
get_total_collecteddoc comment — new# Caveat: ambiguous zerosection:The doc comment now explicitly documents both situations in which this function returns
0:(KEY_TOTAL, token)persistent entry's TTL lapsed (e.g. the token stopped receiving traffic long enough for the entry to be archived by the network), and no one has restored it yet.It states that the
extend_ttlfix makes case 2 unlikely for any actively-used token but cannot be ruled out for tokens dormant longer thanTOTAL_TTL_TARGET(~120 days). It directs callers who need to distinguish the two cases toget_total_collected_opt. It also explicitly documents thatfee_rcvdevents are the authoritative source of truth and that the persistent counter is a reconstructible cache — not the sole record.4. New
get_total_collected_optfunction:This gives treasury tooling, future per-epoch withdrawal-limit logic, and any on-chain caller a first-class way to distinguish the two failure modes that
get_total_collectedcollapses into0. The full doc comment explains all three possible return variants (None,Some(0)edge case,Some(n)) and when each applies.fee_collector/src/test.rsImports added:
Ledger as _— required forenv.ledger().with_mut(|li| { … })to setsequence_number,min_persistent_entry_ttl, andmax_entry_ttlin the TTL test.storage::Persistent as _— required to callget_ttl(&key)on aPersistentstorage instance insideenv.as_contractblocks.New test:
test_total_collected_ttl_extended_across_ledgersThis test directly implements the reproduction plan from the issue's "Test/reproduction plan" section: establish a nonzero total via
collect_fee, advance the ledger far enough that the entry would have been archived without the fix, and assert the total is still correct.The test strategy is adapted for soroban-sdk v21 behaviour: in v21,
env.as_contractitself requires the contract instance to be live (the host loads the instance entry when creating the test frame). Simply archiving the instance and calling into the contract would fail at the frame-creation level, not at the data-access level — which would not produce a meaningful signal about the data entry's TTL. Instead, the test:min_persistent_entry_ttl = 5_000andmax_entry_ttl = 3_000_000. Withoutextend_ttl, newKEY_TOTALentries would start with TTL = 4,999.collect_fee(token, 1_000). Theextend_ttlcall inside it should set the entry TTL toTOTAL_TTL_TARGET = 2_073_600.env.as_contract + get_ttlthat the TTL equals exactlyTOTAL_TTL_TARGET— not 4,999. This is the direct, unambiguous proof thatextend_ttlran.env.as_contract + instance.extend_ttl, so the instance survives the ledger advance and subsequent client calls remain possible.TOTAL_TTL_TARGET.env.as_contract: asserts theKEY_TOTALentry is stillSome(1_000)(not archived), its TTL decayed to exactlyTOTAL_TTL_TARGET - 10_000 = 2_063_600, and that value is still aboveTOTAL_TTL_THRESHOLD.get_total_collected(token)returns1_000(not0) andget_total_collected_opt(token)returnsSome(1_000)(notNone) — end-to-end correctness through the contract's public interface.collect_fee(token, 500)again and asserts cumulative total = 1,500 on both accessors.fee_collector/test_snapshots/test/test_collect_fee_updates_total.1.jsonandtest_withdraw_sends_tokens_to_recipient.1.json: the TTL value for the(KEY_TOTAL, token)ledger entry changed from4095(the soroban-sdk test-environment default min TTL when no ledger config is set) to2073600(TOTAL_TTL_TARGET). This is the correct and expected reflection of the newextend_ttlcall executing insidecollect_feein these tests.test_total_collected_ttl_extended_across_ledgers.1.json: the ledger snapshot for the new TTL test.Acceptance criteria — line-by-line verification
collect_feeextends the TTL of the(KEY_TOTAL, token)entry on every calllib.rs:env.storage().persistent().extend_ttl(&total_key, TOTAL_TTL_THRESHOLD, TOTAL_TTL_TARGET)immediately afterset()get_total_collected's doc comment explicitly states the caveat:0is ambiguous between "never collected" and "collected historically, entry now stale"lib.rs:# Caveat: ambiguous zerosection in the doc comment, both cases named and explainedtest.rs:test_total_collected_ttl_extended_across_ledgers—get_ttlproves TTL ==TOTAL_TTL_TARGETimmediately aftercollect_fee; ledger advance of 10,000 past un-extended TTL of 4,999;Some(1_000)confirmed in storage and via public APIOption<i128>or dedicated error variant on atry_get_total_collected) so callers who need to distinguish "genuinely zero" from "possibly stale" have a way to do solib.rs:get_total_collected_opt(env, token) -> Option<i128>with full doc comment covering all three return variantsTest results
All 10 pre-existing tests continue to pass. 1 new TTL test added. Zero regressions.
Why this fix is correct and safe
extend_ttluses threshold + target semantics — the Soroban host skips the extension ifcurrent_ttl > TOTAL_TTL_THRESHOLD, so there is no wasted ledger-write budget re-bumping a healthy entry on every fee.TOTAL_TTL_THRESHOLD(~30 days) andTOTAL_TTL_TARGET(~120 days) are deliberately generous: the lifetime-total counter is the sole on-chain audit surface for fee accounting, and the cost of re-extending it is negligible compared to the cost of silent historical data loss.get_total_collectedsignature and return type are unchanged — zero breaking changes for existing callers or off-chain tooling.get_total_collected_optis purely additive.fee_rcvdevents emitted bycollect_feeremain the authoritative source of truth and are explicitly documented as such — the persistent counter is a reconstructible cache, not the sole record.stellar_send,token_bridge,escrow). Scope is strictly limited tofee_collector.Files changed
fee_collector/src/lib.rsextend_ttlincollect_fee, updatedget_total_collecteddoc, newget_total_collected_optfee_collector/src/test.rstest_total_collected_ttl_extended_across_ledgerstestfee_collector/test_snapshots/test/test_collect_fee_updates_total.1.jsonfee_collector/test_snapshots/test/test_withdraw_sends_tokens_to_recipient.1.jsonfee_collector/test_snapshots/test/test_total_collected_ttl_extended_across_ledgers.1.jsonCloses #39