Skip to content

token_bridge: per-holder WBAL entries never TTL-extended — a dormant wrapped-token balance risks archival and traps on read #55

Description

@abayomicornelius

Overview

token_bridge's per-holder wrapped balances are stored exclusively in persistent storage with no TTL-extension call anywhere in the contract:

// token_bridge/src/lib.rs
fn get_wrapped_balance_internal(env: &Env, holder: &Address) -> i128 {
    let bal_key = (KEY_WBAL, holder.clone());
    env.storage().persistent().get(&bal_key).unwrap_or(0i128)
}

fn credit_wrapped(env: &Env, holder: &Address, amount: i128) -> Result<i128, TokenBridgeError> {
    let current = Self::get_wrapped_balance_internal(env, holder);
    let new_bal = current.checked_add(amount).ok_or(TokenBridgeError::ArithmeticOverflow)?;
    let bal_key = (KEY_WBAL, holder.clone());
    env.storage().persistent().set(&bal_key, &new_bal);
    Ok(new_bal)
}

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.

Requirements

Acceptance Criteria

Additional Notes

More precise references

Additional edge cases

  • 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.

Implementation sketch

  1. Define 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).
  2. 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).
  3. Same in unwrap's debit path, after its set.
  4. 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.

Cross-references

Metadata

Metadata

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial Campaign | FWC26Campaign: Official Campaign | FWC26Third CampaignCampaign: Third CampaignbugSomething isn't workingcontractsSmart contract logicvery hardVery difficult / senior-level bounty issue

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions