OpenZeppelin / OpenZeppelin/stellar-contracts

🐞 [Bug]: total supply moved to persistent storage without a migration path

Open
#889 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Rust
Stars
95
Forks
68
Avg merge
4d 42m
Merged PRs (30d)
20

Description

Summary

#795 moved the fungible total supply from instance storage (FungibleStorageKey::TotalSupply) to a persistent entry (TotalSupplyStorageKey::TotalSupply). A contract deployed from an earlier version and upgraded in place keeps its balances but reads its supply as 0; the old value stays in instance storage. There is no migration helper and no note about it.

Effects after the upgrade

S is the pre-upgrade supply, A the assets a vault holds.

  • TotalSupply: total_supply() under-reports by S. burn and burn_from panic with Error(Contract, #104) (decrease_total_supply, amount > supply) until post-upgrade mints have raised the new counter above the burn amount.
  • Capped: check_cap uses the under-reported supply, so S tokens over cap can be minted.
  • RWA: same as TotalSupply for burn.
  • Vault: withdraw and redeem panic with #104 for every holder. With decimals_offset = 0, the first deposit that mints a share (#883 rejects smaller ones) is D = A + 1 and mints one share. A pre-upgrade holder can then redeem one share for floor((A + D + 1) / 2) = A + 1 assets, the whole deposit.

Migration

#[contracttype] unit variants serialize as Vec[Symbol(<variant>)], so FungibleStorageKey::TotalSupply and TotalSupplyStorageKey::TotalSupply produce the same XDR (checked with to_xdr on both types, soroban-sdk 27.0.2). 0.9 code can read the old value with e.storage().instance().get::<_, i128>(&TotalSupplyStorageKey::TotalSupply), and no 0.9 key collides with the leftover entry.

Proposal

Helper in total_supply/storage.rs
/// Moves a total supply recorded by `stellar-tokens` 0.8 or earlier from
/// `instance` storage to the `persistent` entry used by this extension and
/// returns the migrated amount, `0` when there is nothing to migrate.
///
/// # Security Warning
///
/// ⚠️ SECURITY RISK: This function has NO AUTHORIZATION CONTROLS ⚠️
///
/// Call it from an admin-restricted `migrate` entrypoint guarded by
/// `stellar_contract_utils::upgradeable::get_schema_version`.
pub fn migrate_total_supply(e: &Env) -> i128 {
    let key = TotalSupplyStorageKey::TotalSupply;
    let Some(legacy) = e.storage().instance().get::<_, i128>(&key) else {
        return 0;
    };
    increase_total_supply(e, legacy);
    e.storage().instance().remove(&key);
    legacy
}

The legacy value is added to the persistent counter rather than written over it, because mints between upgrade and migrate start that counter from 0. Removing the instance entry makes a second call a no-op.

Tests: migrated value, removed entry, additive case, no-op case, and one test asserting that a test-local copy of the 0.8 FungibleStorageKey::TotalSupply encodes to the same XDR as the new key.

examples/upgradeable/v1v2 demonstrate this migration

v1/v2 currently migrate a Config { rate }Config { rate, active } shape change, which lazy-v1/lazy-v2 and the Pattern 1 snippet in the upgradeable module docs already cover.

  • v1: a fungible token with the pre-0.9 layout on 0.9 crates: Base for balances and metadata, an owner-only mint calling Base::mint and bumping an i128 in instance under a local #[contracttype] enum LegacyStorageKey { TotalSupply }, and total_supply() reading it.
  • v2: the same token on Compose<(TotalSupply,)> + FungibleTotalSupply, plus migrate(operator) under #[only_role(operator, "migrator")], guarded by get_schema_version(e) < 2, calling migrate_total_supply then set_schema_version(e, 2).
  • Test in v1: mint 1_000, upgrade, assert total_supply() == 0 and try_burn fails with #104, migrate, assert 1_000, burn, assert a second migrate is rejected. A second test mints on v2 before migrating.
  • upgrader test switches to the token; rebuild the testdata wasm files and snapshots; update the example list at the end of the upgradeable module docs.

A real 0.8 v1 cannot be a workspace member (0.9 removed FungibleToken::total_supply and 0.8 pins another SDK line), so v1 reproduces the layout instead.

Docs
  • v0.9.0 release notes: an "Upgrading from 0.8 or earlier" section with the failure modes and the migrate_total_supply recipe.
  • total_supply/mod.rs module docs and the TotalSupply bullet in packages/tokens/README.md: point at the helper and the example.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with total_supply/storage.rs and the existing examples/upgradeable/v1 and v2 migration flow, then review the stated storage and authorization requirements. Add migration coverage for migrated, removed, additive, and no-op values, update the upgrade example and its testdata and snapshots, and revise total_supply/mod.rs, packages/tokens/README.md, and the v0.9.0 release notes.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust
Domain
blockchain
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.