You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Neither wrap, unwrap, nor get_wrapped_balance ever calls env.storage().persistent().extend_ttl(...). Soroban persistent storage entries have a finite, rent-bounded TTL and are archived once that TTL expires without being bumped — a subsequent read of an archived key does not return the old value, it fails the transaction (or, for get_wrapped_balance's use of .get(...).unwrap_or(0i128), the specific failure mode here is worse: env.storage().persistent().get() on a genuinely archived key traps the whole transaction rather than returning None, so unwrap_or(0i128) never actually gets a chance to run — a long-dormant wrapped-token holder's very first attempted read after archival aborts, not silently zeroes).
This is exactly the same class of problem already reported for the other three contracts — escrow (#34), fee_collector (#39), and stellar_send's Subscription records (#43) — but none of those cover token_bridge, and this contract is the one where the consequence is arguably worst: a wrap() deposit with no further activity (the single most likely usage pattern — a user wraps once and holds) is precisely the "long period of inactivity" pattern that Soroban's TTL/archival design exists to reclaim, and every one of those balances is real, user-owned, previously-transferred-in principal, not just accounting metadata.
get_wrapped_balance (a read-only query, potentially called far more often than wrap/unwrap by any UI or integrator) should itself opportunistically extend the TTL on read for entries it successfully retrieves, so simply checking a balance keeps it alive even with zero wrap/unwrap activity — consistent with how a "wallet balance" is conventionally expected to behave (always queryable, never silently expiring from under a passive holder).
Add a config constant for the extension threshold/amount (ledger counts), matching whatever convention is adopted for the other three contracts' equivalent fix, so all four contracts share one TTL policy rather than four independently-chosen ones.
Acceptance Criteria
wrap and unwrap extend the TTL of the holder's (KEY_WBAL, holder) entry on every write.
get_wrapped_balance extends the TTL of the entry on every successful read.
No change to wrap/unwrap's external behavior (amounts, events, error codes) for the non-archival path.
Additional Notes
More precise references
token_bridge/src/lib.rs — get_wrapped_balance_internal (helper backing both get_wrapped_balance and the balance check inside unwrap) and credit_wrapped (backing wrap): confirmed both interact with (KEY_WBAL, holder) via plain .get()/.set() with no extend_ttl call anywhere in the file — grepped the entire file for extend_ttl/bump with zero matches.
unwrap's balance check (if current_bal < amount) calls get_wrapped_balance_internal first — if that read traps due to archival rather than returning a low/zero balance, a holder attempting to unwrap after their entry has archived gets an opaque host trap instead of any of TokenBridgeError's typed variants, which is a poor failure mode on top of the underlying data-loss risk; the TTL fix directly prevents this from being reachable in the first place, but it's worth the implementer confirming (via the reproduction test) that this is in fact the current failure shape, not an already-graceful one.
wrap's credit_wrapped reads-then-writes the same key; if the entry is already archived at the moment wrap is called (as opposed to unwrap, which is a debit on an existing holder), the very first wrap call for a returning holder after a long gap would itself trap on the read, meaning even topping up an existing wrapped position can't recover from archival without this fix — the TTL extension must happen on every write, not just be checked/lazily-repaired on read.
In credit_wrapped, after env.storage().persistent().set(&bal_key, &new_bal), call env.storage().persistent().extend_ttl(&bal_key, WBAL_TTL_THRESHOLD, WBAL_TTL_EXTEND_TO).
Same in unwrap's debit path, after its set.
In get_wrapped_balance_internal, after a successful .get() that returns Some(_), extend the TTL before returning — only for the existing-entry case (a None/never-wrapped holder has no entry to extend).
Test/reproduction plan
Using the Soroban test harness's ledger-manipulation helpers (env.ledger().set(...) with a sequence number advanced past the entry's TTL, matching whatever pattern the test suite already uses elsewhere, e.g. farming-pool-style TTL tests in the sibling SmartDropLabs org's contracts repo for a directly comparable example), create a wrapped balance via wrap, advance the ledger past the default persistent-entry TTL without touching the entry, then call get_wrapped_balance and confirm it does not trap and correctly still reflects the balance (proving the fix's read-path extension worked on the previous write, or exercising the intentionally-expired case pre-fix to document the failure mode as a regression test).
A second case: same setup, but call wrap again (topping up) instead of reading, confirming the write path does not trap either.
Overview
token_bridge's per-holder wrapped balances are stored exclusively in persistent storage with no TTL-extension call anywhere in the contract:Neither
wrap,unwrap, norget_wrapped_balanceever callsenv.storage().persistent().extend_ttl(...). Soroban persistent storage entries have a finite, rent-bounded TTL and are archived once that TTL expires without being bumped — a subsequent read of an archived key does not return the old value, it fails the transaction (or, forget_wrapped_balance's use of.get(...).unwrap_or(0i128), the specific failure mode here is worse:env.storage().persistent().get()on a genuinely archived key traps the whole transaction rather than returningNone, sounwrap_or(0i128)never actually gets a chance to run — a long-dormant wrapped-token holder's very first attempted read after archival aborts, not silently zeroes).This is exactly the same class of problem already reported for the other three contracts —
escrow(#34),fee_collector(#39), andstellar_send'sSubscriptionrecords (#43) — but none of those covertoken_bridge, and this contract is the one where the consequence is arguably worst: awrap()deposit with no further activity (the single most likely usage pattern — a user wraps once and holds) is precisely the "long period of inactivity" pattern that Soroban's TTL/archival design exists to reclaim, and every one of those balances is real, user-owned, previously-transferred-in principal, not just accounting metadata.Requirements
(KEY_WBAL, holder)entry (inwrap'scredit_wrappedandunwrap's debit path) must also extend that entry's TTL to a configured minimum (e.g. viaenv.storage().persistent().extend_ttl(&bal_key, threshold, extend_to)), following the same pattern that should be applied consistently once escrow: persistent Escrow records are never TTL-extended, risking archival before release/refund #34/fee_collector: persistent per-token totals are never TTL-extended, and get_total_collected silently masks archival as zero #39/stellar_send: persistent Subscription records are never TTL-extended, risking archival for exactly the long-interval payments the feature exists to serve #43 are fixed.get_wrapped_balance(a read-only query, potentially called far more often thanwrap/unwrapby any UI or integrator) should itself opportunistically extend the TTL on read for entries it successfully retrieves, so simply checking a balance keeps it alive even with zero wrap/unwrap activity — consistent with how a "wallet balance" is conventionally expected to behave (always queryable, never silently expiring from under a passive holder).Acceptance Criteria
wrapandunwrapextend the TTL of the holder's(KEY_WBAL, holder)entry on every write.get_wrapped_balanceextends the TTL of the entry on every successful read.wrap/unwrap/get_wrapped_balanceno longer traps.wrap/unwrap's external behavior (amounts, events, error codes) for the non-archival path.Additional Notes
More precise references
token_bridge/src/lib.rs—get_wrapped_balance_internal(helper backing bothget_wrapped_balanceand the balance check insideunwrap) andcredit_wrapped(backingwrap): confirmed both interact with(KEY_WBAL, holder)via plain.get()/.set()with noextend_ttlcall anywhere in the file — grepped the entire file forextend_ttl/bumpwith zero matches.token_bridge'sKEY_WBALentries.Additional edge cases
unwrap's balance check (if current_bal < amount) callsget_wrapped_balance_internalfirst — if that read traps due to archival rather than returning a low/zero balance, a holder attempting to unwrap after their entry has archived gets an opaque host trap instead of any ofTokenBridgeError's typed variants, which is a poor failure mode on top of the underlying data-loss risk; the TTL fix directly prevents this from being reachable in the first place, but it's worth the implementer confirming (via the reproduction test) that this is in fact the current failure shape, not an already-graceful one.wrap'scredit_wrappedreads-then-writes the same key; if the entry is already archived at the momentwrapis called (as opposed tounwrap, which is a debit on an existing holder), the very firstwrapcall for a returning holder after a long gap would itself trap on the read, meaning even topping up an existing wrapped position can't recover from archival without this fix — the TTL extension must happen on every write, not just be checked/lazily-repaired on read.Implementation sketch
const WBAL_TTL_THRESHOLD: u32/const WBAL_TTL_EXTEND_TO: u32(or reuse whatever names the fix to escrow: persistent Escrow records are never TTL-extended, risking archival before release/refund #34/fee_collector: persistent per-token totals are never TTL-extended, and get_total_collected silently masks archival as zero #39/stellar_send: persistent Subscription records are never TTL-extended, risking archival for exactly the long-interval payments the feature exists to serve #43 introduces).credit_wrapped, afterenv.storage().persistent().set(&bal_key, &new_bal), callenv.storage().persistent().extend_ttl(&bal_key, WBAL_TTL_THRESHOLD, WBAL_TTL_EXTEND_TO).unwrap's debit path, after itsset.get_wrapped_balance_internal, after a successful.get()that returnsSome(_), extend the TTL before returning — only for the existing-entry case (aNone/never-wrapped holder has no entry to extend).Test/reproduction plan
env.ledger().set(...)with a sequence number advanced past the entry's TTL, matching whatever pattern the test suite already uses elsewhere, e.g.farming-pool-style TTL tests in the siblingSmartDropLabsorg's contracts repo for a directly comparable example), create a wrapped balance viawrap, advance the ledger past the default persistent-entry TTL without touching the entry, then callget_wrapped_balanceand confirm it does not trap and correctly still reflects the balance (proving the fix's read-path extension worked on the previous write, or exercising the intentionally-expired case pre-fix to document the failure mode as a regression test).wrapagain (topping up) instead of reading, confirming the write path does not trap either.Cross-references
escrow,fee_collector, andstellar_send'sSubscriptionrecords respectively — this issue is the missing fourth instance, specific totoken_bridge. Worth sequencing all four together (or at least adopting one shared TTL-policy constant/module) rather than landing four independent, possibly-inconsistent fixes.