Skip to content

fix(contracts): move per-token bookkeeping to persistent storage - #1034

Merged
Ejirowebfi merged 4 commits into
Favourorg:mainfrom
Darkdante9:fix/1007-persistent-storage
Jul 23, 2026
Merged

fix(contracts): move per-token bookkeeping to persistent storage#1034
Ejirowebfi merged 4 commits into
Favourorg:mainfrom
Darkdante9:fix/1007-persistent-storage

Conversation

@Darkdante9

Copy link
Copy Markdown
Contributor

Summary

Every piece of per-token state — TokenInfo(index), the CreatorTokens(Address) list, TokenIndex(Address), Metadata(Address), the per-token owner/supply keys, and whitelist entries — lived in env.storage().instance(), a single ledger entry shared with the contract instance itself. That entry is capped at ~64 KiB and is loaded/reserialized in full on every invocation, so as token_count grew:

  1. Every state-writing call (create_token, set_metadata, even pause) would eventually fail once cumulative instance data approached the entry-size limit, with no admin recovery path — the factory would be permanently bricked.
  2. Every invocation would pay read/write fees proportional to the entire instance entry, so create_token would get progressively more expensive for everyone as unrelated tokens accumulated.
  3. A single TTL/archival event would take down all bookkeeping at once.

This PR moves per-token bookkeeping to Soroban persistent storage, keyed per-entry, so instance storage stays O(1) in token_count.

Changes

  • Storage split: TokenInfo, TokenIndex, Metadata, the per-token owner/supply/bkfld keys, and whitelist entries now live in persistent storage. Only FactoryState and the (already-bounded, ≤10-recipient) fee split remain in instance storage.
  • Paginated creator lists: the monolithic CreatorTokens(Address) -> Vec<u32> is replaced with CreatorTokens(Address, page) buckets of ≤ MAX_TOKENS_BY_CREATOR_PAGE (50) indices each, plus a CreatorTokenCount(Address) counter, so no single entry grows unboundedly as a creator registers more tokens. get_tokens_by_creator was rewritten to walk across page boundaries.
  • TTL per key: every persistent read/write goes through helpers (set_persistent, migrate_addr_keyed, read_addr_keyed) that extend that specific key's TTL on access, rather than one shared instance TTL.
  • Migration (CURRENT_SCHEMA_VERSION 2 → 3):
    • TokenInfo's key space (1..=token_count) is the only migrated key space that's cheaply enumerable, so migrate's new v3 step walks it in bounded, resumable chunks (20 per call, tracked via an on-chain cursor) — safe to call repeatedly if token_count is too large to finish in one invocation's budget. schema_version only advances to 3 once the walk completes.
    • Every other migrated key (TokenIndex, Metadata, owner, supply, bkfld) is address-keyed, not enumerable, so it migrates lazily on first mutating access instead (checks persistent first, falls back to the legacy instance copy, and moves it over as a side effect). Pure view entrypoints (get_token_info, get_metadata, etc.) use the same fallback but never write, so simulated read calls stay free of a write footprint.
    • CreatorTokens migrates lazily per-creator the first time a page is next appended to, since creator addresses aren't enumerable from factory state either.
    • Whitelist entries need no migration mechanism at all — they're already address-scoped by the caller (add_to_whitelist/remove_from_whitelist take the address directly), so writes go straight to persistent and reads fall back to instance for any pre-migration entry.
    • Both the chunked and lazy paths are idempotent and safe in any order/interleaving.
  • Stress test: test_instance_storage_size_stays_flat_under_load seeds 300 tokens for one creator (spanning 6 CreatorTokens pages) and asserts (a) the instance-stored FactoryState entry's exact serialized XDR size is unchanged, (b) sampled TokenInfo entries live in persistent storage, not instance, (c) the legacy monolithic CreatorTokens key is absent for a post-migration creator, and (d) get_tokens_by_creator returns correct, contiguous results across a page boundary.
  • Docs: added a "Storage architecture" section to docs/contract-abi.md (storage-backend table, TTL behavior, migration mechanics) and updated the README's schema-versioning table and factory-pattern description.

Also fixed (pre-existing, compile-blocking)

Found while starting this work: set_fee_split had a merge-conflict artifact — two overlapping cap checks with a dangling brace and unreachable code — that made the crate fail to compile outright on main. It also left a duplicate MAX_FEE_SPLIT_RECIPIENTS constant (10 vs. 20) and a duplicate error discriminant (TooManyFeeSplitRecipients and AlreadyBackfilled both = 18). Fixed by keeping the single documented cap check, removing the duplicate constant (kept at 10, matching the existing test suite's assumption), and renumbering TooManyFeeSplitRecipients to 19. This was a prerequisite — without it nothing in the crate builds or tests — and is called out in docs/contract-abi.md's error table.

Test plan

  • cargo test -p token-factory — 120/120 pass (119 pre-existing + the new stress test)
  • cargo clippy -p token-factory --all-targets (and --features bench) — clean (only pre-existing, unrelated warnings)
  • cargo build -p token-factory --release --target wasm32v1-none — release WASM builds successfully
  • Verified via a scratch revert that the compile-blocking set_fee_split bug and the bench-suite deploy failures (dummy zero WASM hash, no real WASM registered) are pre-existing on main/unrelated to this change — the bench job is already non-blocking (continue-on-error: true) in CI
  • npm test (frontend, 585 tests) — untouched by this PR, ran clean via the pre-push hook

Closes #1007

…ourorg#1007)

Every piece of per-token state (TokenInfo, TokenIndex, Metadata, the
per-token owner/supply keys, and CreatorTokens) lived in a single
instance storage ledger entry shared with the contract instance
itself. That entry is capped at ~64 KiB and is loaded/reserialized in
full on every invocation, so cumulative token data would eventually
brick the factory outright and made every call progressively more
expensive as token_count grew.

Move all of that into persistent storage, keyed per-entry:

- TokenInfo, TokenIndex, Metadata, owner, supply, and whitelist keys
  now live in persistent storage; only FactoryState and the (already
  bounded) fee split remain in instance storage, so instance storage
  size is O(1) in token_count.
- The monolithic CreatorTokens Vec<u32> is replaced with paginated
  buckets (CreatorTokens(Address, page), <= MAX_TOKENS_BY_CREATOR_PAGE
  entries each) plus a CreatorTokenCount(Address) counter, so no
  single entry grows unboundedly as a creator registers more tokens.
- Every persistent read/write extends that specific key's TTL on
  access, so one archival event no longer takes down all bookkeeping
  at once.
- CURRENT_SCHEMA_VERSION bumps to 3. migrate's new step walks
  TokenInfo(1..=token_count) in bounded, resumable chunks (cursor
  tracked on-chain), since that's the only migrated key space that's
  cheaply enumerable. Every other affected key migrates lazily on
  first mutating access instead, since token/creator addresses aren't
  enumerable from factory state; pure view entrypoints read with the
  same persistent-then-legacy-instance fallback but never migrate, so
  simulated read calls stay free of a write footprint. Both paths are
  idempotent and safe in any order.
- Added a stress test that seeds 300 tokens for one creator and
  asserts the instance-stored FactoryState entry's serialized size is
  unchanged, per-token records land in persistent storage, and
  cross-page pagination in get_tokens_by_creator is still correct.
- Updated the storage-architecture documentation in
  docs/contract-abi.md and the README schema-versioning table.

Also fixes a pre-existing merge conflict artifact in set_fee_split
(a duplicated, unreachable oversized-map check with a dangling brace)
that made the crate fail to compile outright, and the resulting
duplicate MAX_FEE_SPLIT_RECIPIENTS constant and duplicate
TooManyFeeSplitRecipients/AlreadyBackfilled error discriminant (both
= 18). TooManyFeeSplitRecipients is renumbered to 19; the surviving
recipient cap is 10, matching the pre-existing test suite's
assumption. Without this fix the crate did not build at all, so it
was a prerequisite for this change and for running the test suite.

Closes Favourorg#1007
Darkdante9 and others added 3 commits July 23, 2026 00:19
Contract Build: the default release profile (opt-level = 3, no LTO,
unstripped) pushed the WASM past the 64 KiB Soroban contract-data size
limit enforced by scripts/check-wasm-size.mjs once the persistent-storage
migration added its helper functions and migration logic (69,749 bytes vs.
the 65,536 limit; the pre-migration baseline was already at 61,162 bytes,
just 7% of headroom). Add the standard Soroban size-optimized
[profile.release] to contracts/Cargo.toml (opt-level = "z", lto = true,
strip = "symbols", codegen-units = 1, panic = "abort") — this only affects
--release builds, not `cargo test`. Brings the WASM down to 35,131 bytes.

Rust Formatting: `cargo fmt -- --check` was failing on lib.rs/test.rs —
apply `cargo fmt`.

npm audit: frontend/package-lock.json pinned fast-uri@3.1.3 (transitive,
via vite-plugin-pwa -> workbox-build -> ajv), vulnerable to
GHSA-v2hh-gcrm-f6hx (host confusion via literal backslash authority
delimiter). Add a `fast-uri` entry to package.json's existing `overrides`
map (same pattern already used for serialize-javascript,
@rollup/plugin-terser, and vite) pinning >=3.1.4, which resolves within
ajv's existing ^3.0.1 dependency range.

Frontend prettier check: src/types/index.ts had a formatting drift
unrelated to any change in this branch — run `prettier --write`.
The root .lintstagedrc.js ran a bare `prettier --write` for
frontend/**/*.{js,jsx,ts,tsx} files, which resolves to the repo root's
prettier (3.8.1, pinned ^3.3.3) rather than frontend's own (3.9.6,
pinned ^3.9.6). Both pick up frontend/.prettierrc correctly (config
resolution is file-path-based, not cwd-based), but the two versions
disagree on how to wrap a union type exceeding printWidth — so a
commit's pre-commit hook could "fix" a file into a shape that CI's
`npm run format:check` (which always runs frontend's own prettier)
then rejects, as happened to src/types/index.ts's ContractEventType.

Explicitly `cd frontend` and invoke `./node_modules/.bin/prettier`,
mirroring the eslint step already in the same lint-staged entry, so
the formatting decisions made at commit time always match CI.
@Ejirowebfi
Ejirowebfi merged commit 51c52bd into Favourorg:main Jul 23, 2026
8 of 13 checks passed
Ejirowebfi added a commit that referenced this pull request Jul 23, 2026
main has not compiled since #1033/#1034/#1035 landed: two conflict
resolutions kept both sides.

- Error enum declared TooManyFeeSplitRecipients twice (= 19 and = 18),
  which also collided with AlreadyBackfilled = 19. Keep = 18, matching
  the canonical ABI table and the numbering #1033 established
  (18/19/20 = TooManyFeeSplitRecipients/AlreadyBackfilled/NotWhitelisted).
- get_token_info_by_address kept both the new read_addr_keyed lookup
  from #1034 and the superseded instance-storage lookup, leaving a
  syntactically invalid duplicated let binding. Keep the persistent-
  storage form.
- docs/contract-abi.md had three concatenated error tables from the same
  bad merges. Collapse to the single complete table, fix the recipient
  cap typo (20 -> 10), and correct the NotWhitelisted prose (code 18 -> 20).
- Allow clippy::identity_op on the deliberate 1-bps test formula from
  #1025, which the clippy job could not reach while the crate was broken.

cargo fmt, cargo clippy -D warnings, and 146 contract tests all pass.
Ejirowebfi added a commit to Bigg770/Stellar-forge that referenced this pull request Jul 23, 2026
Resolve conflicts between the fee-split/metadata-URI PR and main's
persistent-storage (Favourorg#1034), exact-fee (Favourorg#1035), whitelist (Favourorg#1033) and
merge-repair (Favourorg#1041) changes:

- Error enum: keep main's published 18/19/20 discriminants and append the
  PR's new variants as InvalidMetadataUri=21, ZeroFeeSplitEntry=22,
  MetadataFrozen=23.
- set_metadata: keep the PR's URI validation and versioned-update/freeze
  semantics on top of main's persistent storage (migrate_addr_keyed /
  set_persistent), reentrancy lock, and charge-exactly-metadata_fee rule.
- Rewrite the ipfs:// prefix check with String::copy_into_slice — the
  PR's String::get(i) does not exist in soroban-sdk 27.
- freeze_metadata / views: migrate-aware persistent reads.
- distribute_fee: keep the PR's largest-remainder allocation; update the
  Favourorg#918 tests to the new remainder policy (remainder goes to the
  largest-frac recipient, not treasury) and drop the PR's duplicate
  test_set_fee_split_too_many_recipients_rejected.
- fuzz_fee_arithmetic: keep main's harness and invariants; rewrite
  model_distribute_fee to mirror the largest-remainder algorithm.
- Fix the PR's uri-too-long fixture (was 115 bytes, comment claimed 129).
- docs/contract-abi.md: merge both sides' set_metadata/set_fee_split
  sections, unified error table (18-23), union event table.

cargo fmt / clippy -D warnings clean; 158 contract tests pass; fuzz
workspace compiles.
github-actions Bot pushed a commit that referenced this pull request Jul 27, 2026
# [1.3.0](v1.2.0...v1.3.0) (2026-07-27)

### Bug Fixes

* **#1024,#1023:** fee-split edge cases and metadata URI validation ([#1036](#1036)) ([e3174cb](e3174cb)), closes [#1024](#1024) [#1023](#1023) [#1024](#1024) [#1023](#1023)
* **#1024,#1023:** fee-split edge cases and metadata URI validation ([#1037](#1037)) ([116d025](116d025)), closes [#1024](#1024) [#1023](#1023) [#1024](#1024) [#1023](#1023)
* align adm_upd topic and add CI drift detection ([#1032](#1032)) ([872baa9](872baa9))
* **contracts:** add distribute_fee test coverage at realistic split sizes ([#918](#918)) ([#1025](#1025)) ([f9ec953](f9ec953)), closes [#919](#919)
* **contracts:** charge exactly the required fee, not fee_payment ([#1035](#1035)) ([8654767](8654767)), closes [#1007](#1007) [#1007](#1007) [#1008](#1008) [#1008](#1008)
* **contracts:** make token-factory initialize atomic with deployment ([#1029](#1029)) ([3e12de2](3e12de2)), closes [#1005](#1005)
* **contracts:** move per-token bookkeeping to persistent storage ([#1034](#1034)) ([51c52bd](51c52bd)), closes [#1007](#1007) [#1007](#1007)
* **contracts:** prove multi-step migrate pattern with synthetic v2 tests ([#919](#919)) ([#1026](#1026)) ([f9ef384](f9ef384)), closes [#918](#918) [#918](#918)
* **contracts:** repair broken merge artifacts on main ([#1041](#1041)) ([6524e58](6524e58)), closes [1034/#1035](#1035) [#1033](#1033) [#1034](#1034) [#1025](#1025)
* **contracts:** seed max-supply counter with initial_supply ([#1006](#1006)) ([#1031](#1031)) ([4a3e90d](4a3e90d))
* **frontend:** resolve token identity from contract, not events ([#1018](#1018)) ([#1030](#1030)) ([66bcb19](66bcb19))
* **security:** validate uploaded image content, not client-declared M… ([#1055](#1055)) ([ab1f253](ab1f253)), closes [#1002](#1002) [#1002](#1002) [#1056](#1056) [#1057](#1057)
* **token-factory:** bound fee-split recipient count to prevent resource-exhaustion griefing ([#1027](#1027)) ([becd28f](becd28f))

### Features

* **frontend:** implement paginated global token listing (getAllTokens) ([#1028](#1028)) ([0e1b51e](0e1b51e)), closes [#1017](#1017)
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 1.3.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

🔴 All factory bookkeeping lives in instance storage — the factory will eventually brick itself

2 participants