fix(contracts): move per-token bookkeeping to persistent storage - #1034
Merged
Ejirowebfi merged 4 commits intoJul 23, 2026
Conversation
…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
5 tasks
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
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)
|
🎉 This PR is included in version 1.3.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
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
Every piece of per-token state —
TokenInfo(index), theCreatorTokens(Address)list,TokenIndex(Address),Metadata(Address), the per-tokenowner/supplykeys, and whitelist entries — lived inenv.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 astoken_countgrew:create_token,set_metadata, evenpause) would eventually fail once cumulative instance data approached the entry-size limit, with no admin recovery path — the factory would be permanently bricked.create_tokenwould get progressively more expensive for everyone as unrelated tokens accumulated.This PR moves per-token bookkeeping to Soroban
persistentstorage, keyed per-entry, soinstancestorage stays O(1) intoken_count.Changes
TokenInfo,TokenIndex,Metadata, the per-tokenowner/supply/bkfldkeys, and whitelist entries now live inpersistentstorage. OnlyFactoryStateand the (already-bounded, ≤10-recipient) fee split remain ininstancestorage.CreatorTokens(Address) -> Vec<u32>is replaced withCreatorTokens(Address, page)buckets of ≤MAX_TOKENS_BY_CREATOR_PAGE(50) indices each, plus aCreatorTokenCount(Address)counter, so no single entry grows unboundedly as a creator registers more tokens.get_tokens_by_creatorwas rewritten to walk across page boundaries.persistentread/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.CURRENT_SCHEMA_VERSION2 → 3):TokenInfo's key space (1..=token_count) is the only migrated key space that's cheaply enumerable, somigrate's new v3 step walks it in bounded, resumable chunks (20 per call, tracked via an on-chain cursor) — safe to call repeatedly iftoken_countis too large to finish in one invocation's budget.schema_versiononly advances to 3 once the walk completes.TokenIndex,Metadata,owner,supply,bkfld) is address-keyed, not enumerable, so it migrates lazily on first mutating access instead (checkspersistentfirst, falls back to the legacyinstancecopy, 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.CreatorTokensmigrates lazily per-creator the first time a page is next appended to, since creator addresses aren't enumerable from factory state either.add_to_whitelist/remove_from_whitelisttake the address directly), so writes go straight topersistentand reads fall back toinstancefor any pre-migration entry.test_instance_storage_size_stays_flat_under_loadseeds 300 tokens for one creator (spanning 6CreatorTokenspages) and asserts (a) the instance-storedFactoryStateentry's exact serialized XDR size is unchanged, (b) sampledTokenInfoentries live inpersistentstorage, notinstance, (c) the legacy monolithicCreatorTokenskey is absent for a post-migration creator, and (d)get_tokens_by_creatorreturns correct, contiguous results across a page boundary.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_splithad a merge-conflict artifact — two overlapping cap checks with a dangling brace and unreachable code — that made the crate fail to compile outright onmain. It also left a duplicateMAX_FEE_SPLIT_RECIPIENTSconstant (10 vs. 20) and a duplicate error discriminant (TooManyFeeSplitRecipientsandAlreadyBackfilledboth= 18). Fixed by keeping the single documented cap check, removing the duplicate constant (kept at 10, matching the existing test suite's assumption), and renumberingTooManyFeeSplitRecipientsto19. This was a prerequisite — without it nothing in the crate builds or tests — and is called out indocs/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 successfullyset_fee_splitbug and the bench-suite deploy failures (dummy zero WASM hash, no real WASM registered) are pre-existing onmain/unrelated to this change — the bench job is already non-blocking (continue-on-error: true) in CInpm test(frontend, 585 tests) — untouched by this PR, ran clean via the pre-push hookCloses #1007