This document describes the public interface of the Stellar Forge token-factory Soroban contract deployed on Stellar testnet and mainnet.
The contract binary is built as token_factory.wasm (released alongside the frontend). All function names are lower_snake_case on-chain and translate to camelCase on the frontend wrapper in frontend/src/services/stellar.ts.
| Soroban | TypeScript |
|---|---|
Address |
string (Stellar G... or contract C...) |
u32 |
number |
u64 |
number (lossy above Number.MAX_SAFE_INTEGER) |
i128 |
string (decimal) |
Vec<T> |
T[] |
Option<T> |
T | undefined |
As of schema version 3 (issue #1007), per-token and per-creator bookkeeping lives in Soroban persistent storage, keyed per-entry, rather than in the single shared instance ledger entry. This matters because instance storage is one ledger entry for the whole contract, subject to the ~64 KiB ledger-entry size limit and reserialized in full on every read/write — so before this change, instance storage size (and every call's cost) grew without bound as tokens accumulated, eventually bricking the factory outright once the size limit was hit.
| Data | Storage backend |
|---|---|
FactoryState (DataKey::State) |
instance — small, fixed size |
Fee split (Map<Address, u32>, key "split") |
instance — bounded to MAX_FEE_SPLIT_RECIPIENTS (10) entries |
TokenInfo(index) |
persistent |
TokenIndex(address) |
persistent |
Metadata(address) |
persistent |
Per-token owner, supply, bkfld keys |
persistent |
Whitelist entries ("wl", address) |
persistent |
CreatorTokens(creator, page) |
persistent — paginated, ≤ MAX_TOKENS_BY_CREATOR_PAGE (50) indices per page |
CreatorTokenCount(creator) |
persistent — total token count per creator, used to compute page boundaries |
Because instance storage now only ever holds FactoryState and the (bounded) fee split, its size is O(1) in token_count — creating the 1st token and the 10,000th cost the same to read/write the shared instance entry.
TTL management: every persistent read/write goes through helpers that extend that specific key's TTL on access (Self::set_persistent, Self::migrate_addr_keyed), so one archival event no longer takes down all token bookkeeping at once (see issue #1011) — each entry's rent is tracked independently.
Migrating data written before schema version 3: a factory upgraded from an older binary has its existing TokenInfo, TokenIndex, Metadata, owner, supply, and CreatorTokens entries sitting in legacy instance storage. Two mechanisms move them to persistent storage, and both are safe to run in any order or interleaving:
migrate(admin)'s schema-v3 step walksTokenInfo(1..=token_count)inMIGRATE_TOKEN_INFO_CHUNK-sized (20) chunks per call, resuming across calls via an on-chain cursor iftoken_countis too large to finish in one invocation's resource budget.FactoryState.schema_versiononly advances to3once the cursor has caught up totoken_count.- Every mutating entrypoint that reads an address-keyed record (
TokenIndex,Metadata, theowner/supply/bkfldkeys) checkspersistentstorage first and, if absent, falls back to the legacyinstancecopy — migrating it intopersistentstorage (and removing theinstancecopy) as a side effect.CreatorTokensis migrated the same way, lazily, the first time a creator's page is next appended to, since creator addresses aren't enumerable and so can't be walked bymigratedirectly. Pure read-only view entrypoints (get_token_info,get_metadata, etc.) use the samepersistent-then-instancefallback but never migrate, so simulated read calls stay free of a write footprint.
Whitelist entries need neither mechanism: they're already address-scoped by the caller (add_to_whitelist(admin, address) / remove_from_whitelist(admin, address)), so writes go straight to persistent storage and reads fall back to instance for any pre-migration entry.
One-time setup. Fails with Error::AlreadyInitialized on retry.
| Param | Type | Description |
|---|---|---|
admin |
Address |
Authority for upgrades, fee updates, pause, and admin transfer. |
treasury |
Address |
Default recipient of factory fees. |
fee_token |
Address |
SEP-41 token used for fee payments. |
token_wasm_hash |
BytesN<32> |
Hash of the token-contract WASM deployed for each new token. |
base_fee |
i128 |
Fee charged for create_token, mint_tokens, create_tokens_batch. Must be ≥ 0. |
metadata_fee |
i128 |
Fee charged for set_metadata. Must be ≥ 0. |
Formerly a plain
initialize(...)entrypoint invoked after deployment in a separate transaction. That left a window between deployment and initialization where an attacker could race the deployer's owninitializecall with their own, passing themselves asadmin/treasuryand permanently seizing the factory (see issue #1005). It is now the contract's__constructor(Soroban SDK ≥ 22), which the host runs atomically as part of the deployment transaction itself (deploy_v2), so there is no separate transaction to race.admin.require_auth()is also now required, so the namedadminaddress must itself authorize the deployment, not just the deploying account. Deploy tooling calls this viastellar contract deploy ... -- --admin ... --treasury ...(constructor args after--) rather than a follow-upcontract invoke; seescripts/deploy-contract.shanddocs/mainnet-deployment-checklist.md. This is a naming/entrypoint and auth change only —FactoryState's field layout is unchanged, soCURRENT_SCHEMA_VERSIONwas not bumped.
One-time setup, atomic with deployment. Fails with Error::AlreadyInitialized if the factory's state already exists.
| Param | Type | Description |
|---|---|---|
admin |
Address |
Authority for upgrades, fee updates, pause, and admin transfer. Must authorize this call (require_auth). |
treasury |
Address |
Default recipient of factory fees. |
fee_token |
Address |
SEP-41 token used for fee payments. |
token_wasm_hash |
BytesN<32> |
Hash of the token-contract WASM deployed for each new token. |
base_fee |
i128 |
Fee charged for create_token, mint_tokens, create_tokens_batch. Must be ≥ 0. |
metadata_fee |
i128 |
Fee charged for set_metadata. Must be ≥ 0. |
Fee sign constraint: Both base_fee and metadata_fee must be ≥ 0. A value of 0 is explicitly permitted (free token creation is a valid use-case). Any negative value is rejected with Error::InvalidParameters before any state is written. This constraint exists because:
- A negative required fee satisfies every
fee_payment < required_feeguard trivially, making the fee gate a no-op regardless of what the caller sends. - A negative amount passed to
distribute_feeproduces a negative SEP-41transfer, whose behavior is implementation-defined on the token contract and has not been audited for this factory.
Stamps FactoryState.schema_version = CURRENT_SCHEMA_VERSION and stores the same value under the legacy sv instance key so migrate works on pre-versioned deployments.
Every fee-gated entrypoint (create_token, create_tokens_batch, mint_tokens, set_metadata) takes a fee_payment argument. fee_payment is the maximum the caller authorizes to be spent — the same pattern as amount/max_amount in DEX contracts — not the amount actually transferred. The contract:
- Rejects the call with
Error::InsufficientFeeiffee_paymentis below the currently required fee (base_fee,base_fee * tokens.len(), ormetadata_fee). - Otherwise transfers exactly the required fee — never more, regardless of how much headroom
fee_paymentincluded.
This matters because clients conventionally pad fee_payment above the currently displayed fee so the transaction still succeeds if the admin updates fees before it lands (see the fee-update race below). Before this behavior was fixed (issue #1008), the contract charged the caller's full fee_payment — any padding, unit-confused value (e.g. XLM vs. stroops), or stale cached fee was kept in full with no refund. Callers should still pass a value they consider a hard ceiling, since that's what they can lose in the worst case (e.g. if the required fee rises right up to that ceiling before the transaction lands), but any padding above the fee that's actually charged is always returned to the caller by simply never being transferred.
Fee-update race: if the admin raises the required fee between when a caller signs a transaction and when it lands, and the caller's fee_payment no longer covers the new fee, the call fails cleanly with Error::InsufficientFee and no value moves — not at the old rate, not at the new rate, not partially. The fee-gate check happens before any transfer.
Deploy a new token contract under the factory. Requires fee_payment >= base_fee; charges exactly base_fee. Returns the deployed contract address.
| Param | Type | Description |
|---|---|---|
creator |
Address |
Token creator; must authorize the call and pays the fee. |
salt |
BytesN<32> |
Deterministic-deploy salt. |
name |
String |
1–32 bytes. |
symbol |
String |
1–12 bytes. |
decimals |
u32 |
0–18. |
initial_supply |
i128 |
Amount minted to creator at creation. Must be ≥ 0 (0 mints nothing). |
max_supply |
Option<i128> |
Optional supply cap. Some(cap) requires cap > 0 and cap >= initial_supply; None creates an uncapped token. |
fee_payment |
i128 |
Caller-authorized fee upper bound (see fee semantics above). |
ABI change (issue #1022):
initial_supplywas widened/retyped fromu128toi128(matching the SDK'smintsignature and the batch path), and themax_supply: Option<i128>parameter was added so single-token creation can cap supply with the same Issue #1006 accounting as the batch path. This changes the on-chain argument list; the frontend wrapper (frontend/src/services/stellar.ts → deployToken) was updated in the same change.
The single (create_token) and batch (create_tokens_batch) paths share one validation routine and one bookkeeping routine, so a given invalid parameter set is rejected with the same error code on either path:
| Fault | Error |
|---|---|
name empty or > 32 bytes, or symbol empty or > 12 bytes |
Error::InvalidTokenParams |
decimals > 18 |
Error::InvalidDecimals |
initial_supply < 0, or max_supply ≤ 0, or max_supply < initial_supply |
Error::InvalidParameters |
Atomically deploy tokens (a Vec<BatchTokenParams>, each with the same name/symbol/decimals/initial_supply/max_supply fields validated identically to create_token). Requires fee_payment >= base_fee * tokens.len(); charges exactly base_fee * tokens.len(). All parameter validation (name, symbol, decimals, initial supply, max_supply, and total token_count arithmetic overflow checks) is front-loaded before any contract deployment or state locking begins, using the same shared validate_token_params routine as the single path — so the two entrypoints accept and reject exactly the same parameter sets with the same error codes. Furthermore, Soroban's per-invocation transaction atomicity guarantees that if any failure or host error occurs during execution, all state changes, sub-token deployments, and supply mints within the transaction are completely reverted at the ledger level.
Soroban transactions are subject to per-transaction resource budgets enforced by the ledger. Exceeding these limits causes an immediate ExceededLimit error and costs the full simulation fee — the user never gets a refund.
The table below shows measured CPU instructions and memory bytes consumed by create_tokens_batch at representative batch sizes. Numbers were obtained by running the benchmark harness in contracts/token-factory/src/bench.rs via cargo test bench_ -- --nocapture and comparing against the Soroban mainnet resource limits. Important: the Soroban native test environment underestimates real WASM CPU instruction counts (~30×) and memory (~5×) compared to an actual on-chain simulation, so the values below are used for relative regression detection — the production limits column reflects real network values.
| Batch size | Test-env CPU (M insns) | Test-env Mem (MB) | Ledger reads | Ledger writes | Within mainnet limits? |
|---|---|---|---|---|---|
| 1 | ~0.65 M | ~1.1 MB | 6 | 6 | ✅ |
| 5 | ~2.5 M | ~4.3 MB | 18 | 18 | ✅ |
| 10 | ~4.9 M | ~8.6 MB | 33 | 33 | ✅ |
| 15 | ~7.3 M | ~13 MB | 48 | 48 | ✅ |
| 20 | ~9.7 M | ~17 MB | 63 | 63 | ✅ ← recommended max |
| 25 | ~12 M | ~22 MB | 78 | 78 |
Current Soroban per-transaction limits (Stellar Protocol 21+, mainnet):
| Resource | Mainnet limit |
|---|---|
| CPU instructions | 600 000 000 (600 M) |
| Memory | 41 943 040 bytes (40 MB) |
| Ledger entries (read) | 100 per transaction |
| Ledger entries (write) | 50 per transaction |
Note: The test-harness numbers above are native Rust measurements. Actual on-chain WASM costs are ~30× higher for CPU and ~5× higher for memory. At batch size 20 the extrapolated WASM-equivalent values are approximately 290 M CPU instructions and 85 MB memory — comfortably within the 600 M CPU limit but approaching the 40 MB memory limit. This provides a margin of ~52 % on CPU and ~0 % margin on memory at the extrapolated scale, which is why 20 is the recommended cap rather than a higher number.
Protocol limits may change with network upgrades. Re-run the benchmark harness after each SDK bump and update this table. The CI job in
.github/workflows/benchmarks.ymlruns on every PR touchingcontracts/and surfaces regressions automatically.
✅ Maximum batch size: 20 tokens (contract-enforced)
This limit is enforced on-chain: create_tokens_batch rejects any call with more than MAX_BATCH_SIZE (20) entries with Error::BatchSizeExceeded, checked before any per-item validation or deployment work begins. The frontend (frontend/src/utils/validation.ts → validateBatchSize) mirrors the same value for early client-side feedback, but the contract is the source of truth and rejects oversized batches regardless of caller.
If you need to deploy more than 20 tokens, split them into multiple sequential create_tokens_batch calls, each containing ≤ 20 entries.
Mint amount of token_address to to. Requires fee_payment >= base_fee; charges exactly base_fee. Rejects when a max_supply cap would be exceeded (Error::MaxSupplyExceeded).
max_supply (set per-token via create_token's max_supply argument or create_tokens_batch's BatchTokenParams.max_supply) is enforced against a running counter stored under the persistent key (token_address, "supply"), not against the token's live balance. Every successful mint_tokens call adds amount to this counter and rejects the call if the result would exceed the cap.
What counts toward the cap: the token's initial_supply (minted at creation, before the token even has a TokenInfo entry to check against) plus every amount minted afterward via mint_tokens. As of the fix for issue #1006, the shared record_token routine (used by both creation paths) seeds the counter with initial_supply at creation time whenever max_supply is set, so a token created with initial_supply == max_supply can never be minted again — any mint_tokens call on it fails with MaxSupplyExceeded.
burn does not decrement this counter — burning tokens frees up balance for the holder but does not restore headroom under the cap. The cap therefore bounds cumulative mints (initial + all mint_tokens calls), not net circulating supply.
Back-fill for tokens created before this fix: capped tokens deployed by a factory binary older than this fix have an under-seeded (or entirely absent) supply counter — mint_tokens would have read it as 0 regardless of initial_supply, letting the cap be bypassed. The factory has no on-chain record of a pre-fix token's true initial_supply to recover it automatically (TokenInfo never stored it, and standard SEP-41 tokens don't expose a total_supply query), so this cannot be fixed by migrate() alone. Operators must:
- Reconstruct the token's true cumulative minted amount off-chain — the most reliable source is summing every
mintevent the token contract itself has emitted since deployment (queryable via RPC/Horizonget_events, independent of what the factory stored). - Call
backfill_capped_supply(admin, token_address, verified_supply)once per affected token with that reconstructed value.
backfill_capped_supply is admin-only, requires the token to have max_supply configured, rejects a verified_supply outside [0, max_supply], and can only be applied once per token (subsequent calls fail with Error::AlreadyBackfilled) — it cannot be used as a repeated backdoor to rewrite tracked supply.
Burn amount of token_address from from's balance.
token_address must be a token this factory deployed. The factory resolves it through the TokenIndex(address) mapping before making any cross-contract call; an address the factory never registered is rejected with Error::TokenNotFound and the factory never invokes it. This closes an open-proxy hole where burn would otherwise forward the call to (and emit an official-looking burn event for) an arbitrary external contract. Holders of non-factory tokens can always burn directly on those tokens' own contracts.
Because the registration lookup is mandatory, the burn_enabled gate is unconditional: burning is rejected with Error::Unauthorized whenever the token's burn_enabled flag is false, with no bypass. Other errors: Error::InvalidBurnAmount for a zero or negative amount, and Error::BurnAmountExceedsBalance when amount exceeds from's balance.
Set or update the metadata URI for an existing token. Requires fee_payment >= metadata_fee; charges exactly metadata_fee.
The contract stores the URI opaquely and does not validate the document it points at. The frontend does, and enforces length caps on name and description plus an ipfs://-only rule for image when rendering — see Token Metadata Format before pinning your own metadata.
URI validation (enforced on-chain):
| Rule | Error |
|---|---|
metadata_uri is empty |
InvalidMetadataUri |
Does not start with ipfs:// |
InvalidMetadataUri |
| No CID after the prefix | InvalidMetadataUri |
len > 128 bytes |
InvalidMetadataUri |
Mutability: Metadata is no longer write-once. A creator may update the URI up to METADATA_MAX_UPDATES (currently 5) times total. Once the update count is exhausted the URI is automatically frozen (MetadataFrozen). Creators may also explicitly freeze at any time via freeze_metadata.
Emits a meta event with (token_address, metadata_uri, version) on every successful update so the full history is auditable on-chain.
Permanently freeze a token's metadata URI so it can no longer be updated. Only the token creator may call this. Idempotent — calling on an already-frozen token is a no-op. Emits a meta_frz event.
Return true if the token's metadata has been frozen (either explicitly or by reaching the update cap).
Return the current metadata update version (0 = never set, 1 = first set, …, up to METADATA_MAX_UPDATES = 5).
Toggle the burn flag for a token.
Inspect factory configuration and aggregate counts. As of schema version 4, the returned FactoryState also includes pending_admin: Option<Address> (the proposed-but-not-yet-accepted admin, or None) and pending_admin_expiry: Option<u64> (the ledger sequence at which the proposal lapses, or None). Callers can use these fields to detect a pending rotation before it completes.
Current base fee.
Current set-metadata fee.
Look up a single token by 1-based index. Returns Error::TokenNotFound for unknown indices.
Resolve a token's 1-based storage index from its contract address, via the TokenIndex(address) mapping written at creation. Returns Error::TokenNotFound for addresses not registered with this factory. This is the authoritative address → index lookup — clients must not re-derive identity from the factory event stream, which only reflects a bounded RPC retention window.
The inverse of get_token_index: resolve a token's contract address from its 1-based creation index. Returns Error::TokenNotFound for an index that was never registered.
This is what makes the factory's token set enumerable from contract state alone — a client can walk 1..=get_state().token_count and resolve every token without depending on the RPC's event-retention window. get_token_info(index) deliberately carries no address, so before this mapping existed an off-chain indexer could only learn addresses from created events and therefore could never recover tokens older than that window (issue #943).
Tokens created by a factory binary predating this mapping return TokenNotFound; repair them with backfill_token_address.
Populate the index → address mapping for a token created before it existed, returning the index that was mapped. Idempotent: re-running for an already-mapped token is a no-op that returns the same index.
Permissionless by design, and safe because it is self-verifying. The index is never taken from the caller — it is read back from the token's own authoritative TokenIndex(address) entry, so the only mapping that can ever be written is the one the factory itself recorded at creation time. Supplying an address the factory never registered returns Error::TokenNotFound, and an index already mapped to a different token cannot be repointed. That is what makes it safe to expose to the indexer, which is the component that needs it and holds no admin key.
Return a token's full TokenInfo addressed by its contract address — equivalent to get_token_info(get_token_index(address)) in a single call. This is the source of truth for a token's name, symbol, decimals, creator and creation time; unlike event-derived data it is unaffected by RPC event retention, so a token created arbitrarily long ago still resolves correctly. Returns Error::TokenNotFound for unregistered addresses (including the case where the index mapping exists but the TokenInfo entry is missing).
Return the metadata URI set for a token via set_metadata, or None if none was set. Reads directly from Metadata(address) state instead of scanning meta events (which are subject to the same retention truncation).
Return a paginated slice of token indices owned by creator. This replaces an earlier non-paginated version that returned the full Vec<u32> (which could exceed Stellar ledger entry size limits on creators with hundreds of registered tokens).
| Param | Type | Description |
|---|---|---|
creator |
Address |
Creator whose tokens to list. |
offset |
u32 |
0-based index of the first element to return. |
limit |
u32 |
Maximum number of elements to return. Capped server-side at MAX_TOKENS_BY_CREATOR_PAGE (currently 50) so callers cannot request pathologically large pages. |
Returns: Vec<u32> of token indices, len ≤ min(limit, MAX_TOKENS_BY_CREATOR_PAGE). Use the indices with get_token_info to materialize each token's TokenInfo.
Behavior:
| Input | Output |
|---|---|
limit == 0 |
empty Vec (defensive — read-only path, no error) |
limit > MAX_TOKENS_BY_CREATOR_PAGE |
clamped down to the cap |
offset >= total_tokens_for_creator |
empty Vec (past-the-end) |
| Unknown creator | empty Vec |
| Otherwise | slice [offset, offset + min(limit, cap, remaining)) |
To iterate the full list:
- Call with
offset = 0, limit = 50. - If response.length < 50 → you're done.
- Otherwise advance
offset += response.lengthand repeat.
The frontend helper fetchAllTokensByCreator in frontend/src/hooks/useTokens.ts does this loop automatically.
Adjust either fee. None leaves the corresponding fee unchanged.
Fee sign constraint: Any Some(value) provided for base_fee or metadata_fee must be ≥ 0. Negative values are rejected with Error::InvalidParameters and the stored fees are left unchanged. The same constraint applies as for __constructor — see that section for the rationale.
Toggle factory-wide pause. create_token, create_tokens_batch, mint_tokens, and set_metadata honor the pause; burn does not (users can always burn their own balance).
Set a fee split where splits is a Map<Address, u32> of basis-point recipients summing to 10_000. Empty map clears the split (full fee goes back to treasury).
Constraints enforced at configuration time:
| Rule | Error |
|---|---|
splits.len() > 10 |
TooManyFeeSplitRecipients |
Any entry has bps == 0 |
ZeroFeeSplitEntry |
sum(bps) != 10_000 |
InvalidFeeSplit |
Cap: Maximum 10 recipients per split (MAX_FEE_SPLIT_RECIPIENTS). This bounds the number of cross-contract transfer calls per user transaction and keeps per-transaction gas predictable.
Rounding: distribute_fee uses the largest-remainder method. Each recipient's share is floor(amount * bps / 10_000). Remainder stroops (at most recipients - 1) are awarded one-at-a-time to the entries with the largest fractional parts, so the sum of all transfers always equals the full fee amount. No recipient with non-zero bps receives zero forever as long as the fee amount is ≥ 1 stroop (the largest-remainder guarantee).
Per-recipient failure isolation: distribute_fee pays each split recipient with the non-panicking try_transfer rather than transfer. If a recipient's address cannot accept the fee token (frozen account, revoked trustline, clawback-locked balance, or a misbehaving contract address), that single transfer failure does not abort the call — the recipient's share is redirected to treasury instead, and a fee_redir event is emitted naming the skipped recipient and the redirected amount, so an admin can detect and fix a broken split (via set_fee_split) without reading contract logs. This holds for both the split path and the non-split (treasury-only) path. treasury itself is the terminal fallback: if the payment (or redirect) to treasury fails there is nowhere else to send the funds, so distribute_fee returns Error::TreasuryTransferFailed and the whole call reverts.
Emits a split_set event on successful configuration and a split_clr event when the split is cleared.
Recipient cap: splits may contain at most 10 recipients (MAX_FEE_SPLIT_RECIPIENTS). Exceeding it is rejected with Error::TooManyFeeSplitRecipients before the basis-point sum is even checked. This exists because distribute_fee transfers a share to every configured recipient on every create_token, create_tokens_batch, mint_tokens, and set_metadata call — an unbounded admin-configured split would make every fee-paying call on the contract arbitrarily expensive for the caller, and risk exceeding Soroban's per-transaction resource limits outright.
The cap is conservative: typical treasury + referral + protocol-fund structures need ≤ 5 recipients, and the benchmark harness (contracts/token-factory/src/bench.rs, bench_fee_split_mint_*, bench_fee_split_at_max_within_limits) confirms distribute_fee's cost at 10 recipients stays comfortably within Soroban's per-transaction resource limits — ledger writes are the binding resource (each non-zero-share recipient writes a new SEP-41 balance entry), not CPU or memory.
Read the current split (empty map means no split).
Propose a new admin address. The current admin calls this to name a successor. The proposal is stored in FactoryState and expires after ADMIN_PROPOSAL_TTL_LEDGERS ledgers (~28 hours at 6 seconds/ledger). If a second proposal is issued before the first is accepted or cancelled, it overwrites the first, resetting the expiry.
| Param | Type | Description |
|---|---|---|
current_admin |
Address |
Current admin; must authorize this call. |
new_admin |
Address |
Proposed successor. Must differ from current_admin. |
Emits adm_prop with (current_admin, new_admin, expiry_ledger).
Errors: Unauthorized if caller is not the current admin; InvalidParameters for self-proposal.
Complete a pending rotation. The proposed admin calls this, proving they control the proposed key. Only succeeds when a live (non-expired) proposal exists for new_admin.
| Param | Type | Description |
|---|---|---|
new_admin |
Address |
The address that was named in the most recent propose_admin call. |
Emits adm_acc with (old_admin, new_admin) on success.
Errors:
NoPendingProposal(code 26) — no proposal is pending, ornew_admindoes not match the proposed address.ProposalExpired(code 27) — the proposal's expiry ledger has passed. The rejection reverts the call, so the expired proposal stays on record but can never be accepted; the current admin clears it withcancel_admin_proposalor replaces it with a newpropose_admincall.
Cancel a live proposal. Only the current admin may cancel. Idempotent when no proposal is pending. Emits adm_can with (current_admin, cancelled_address).
⚠️ Deprecated. These do not complete a rotation. Usepropose_admin+accept_admindirectly. Do not decommission the outgoing admin key untilaccept_adminhas succeeded — see issue #1159.
These names are retained so tooling built before the two-step model keeps working. Both delegate to propose_admin — neither completes the rotation on its own. Before the two-step model both performed an immediate, unverified single-step rotation; there is now no single-step rotation path in the contract. Every rotation takes two transactions: propose (current admin) and accept (new admin).
Because that downgrade would otherwise be invisible to a caller — a successful transaction that used to mean "rotated" now means "proposed" — both entrypoints report the difference twice over:
1. Return value. They return an AdminRotationReceipt rather than void:
| Field | Type | Value |
|---|---|---|
rotation_complete |
bool |
Always false — state.admin is unchanged. |
pending_admin |
Address |
The address that must accept. |
expires_at_ledger |
u64 |
Ledger at which the proposal lapses; after this only a new proposal from the current admin can restart the rotation. |
required_next_call |
Symbol |
accept_admin. |
A client that decodes the old void return fails loudly on this value instead of silently reporting success.
2. Event. They emit adm_dep with (current_admin, new_admin, expiry_ledger, deprecated_entrypoint) in addition to adm_prop. Existing adm_prop monitoring is unaffected; adm_dep is the distinct signal that some caller is still driving rotations through a pre-two-step entrypoint, which is the condition that precedes an abandoned proposal quietly expiring.
Errors and auth are identical to propose_admin.
Operational guidance for completing and monitoring a rotation lives in docs/incident-response.md §2.5 and the mainnet deployment checklist.
Step 1 of the mandatory two-step upgrade flow (issue #6). Records new_wasm_hash as the pending upgrade candidate and sets pending_upgrade_ready_at to current_ledger + UPGRADE_TIMELOCK_LEDGERS (~17,280 ledgers ≈ 28.8 hours). Emits upg_prop. Admin-only.
Issuing a second proposal overwrites the first, resetting the timelock clock.
| Param | Type | Description |
|---|---|---|
admin |
Address |
Must be the current factory admin; require_auth enforced. |
new_wasm_hash |
BytesN<32> |
Hash of the WASM to swap to. |
Step 2 of the upgrade flow. Swaps the contract's executable WASM to new_wasm_hash, but only if all of the following are true: (a) a proposal for that exact hash was previously recorded via propose_upgrade, (b) the current ledger sequence ≥ pending_upgrade_ready_at, and (c) the caller is the factory admin. Emits upg_exec. Fails with Error::NoUpgradePending (29), Error::UpgradeNotReady (28), or Error::UpgradeHashMismatch (30) otherwise.
| Param | Type | Description |
|---|---|---|
admin |
Address |
Must be the current factory admin; require_auth enforced. |
new_wasm_hash |
BytesN<32> |
Must exactly match the hash passed to propose_upgrade. |
Abort a pending upgrade proposal at any time before execution. Idempotent — calling when nothing is pending is a no-op. Emits upg_can when a live proposal is cancelled. Admin-only.
Incrementally upgrades state between schema versions. Idempotent — safe to call repeatedly, including mid-migration.
- Version 2: bumps the version marker for the issue #1006 max-supply fix — it does not automatically back-fill any capped token's supply counter (see
backfill_capped_supplybelow and "Supply cap accounting" above). - Version 3: moves
TokenInfoentries frominstancetopersistentstorage (issue #1007 — see "Storage architecture" above), walkingtoken_countin bounded chunks per call. Iftoken_countis large enough that one call can't finish the walk,schema_versionstays at 2 and a subsequentmigratecall resumes from where the last one left off; every other affected key (TokenIndex,Metadata,owner,supply,CreatorTokens) migrates lazily on next access regardless of whether this step has completed. - Version 4: adds
pending_admin: Option<Address>andpending_admin_expiry: Option<u64>toFactoryStatefor two-step admin rotation. Both fields default toNone— no behavioral change untilpropose_adminis first called. Callmigrateonce after upgrading to this version; subsequent calls are idempotent. - Version 5: adds
pending_upgrade_hash: Option<BytesN<32>>andpending_upgrade_ready_at: Option<u64>toFactoryStatefor the two-step upgrade timelock (issue #6). Both fields default toNone— no behavioral change untilpropose_upgradeis first called. Callmigrateonce after upgrading to this version; subsequent calls are idempotent.
One-time back-fill of the tracked-supply counter for a capped token created before the issue #1006 fix. See "Supply cap accounting" above for the full procedure. Admin-only; fails with Error::TokenNotFound if the token doesn't exist, Error::InvalidParameters if the token has no max_supply or verified_supply is outside [0, max_supply], and Error::AlreadyBackfilled if already applied to this token.
Add or remove an address from the factory whitelist. Emits wl_add / wl_rm events. Only the factory admin may call these.
Toggle whitelist enforcement on or off. When enabled = true, only addresses that have been added to the whitelist via add_to_whitelist may call create_token or create_tokens_batch — attempts from non-whitelisted addresses return Error::NotWhitelisted (code 20). Emits a wl_tog event.
When enabled = false (the default after initialize and after migrate), the factory is open to all creators and the whitelist contents are ignored.
Decision: mint_tokens and set_metadata are not gated.
These operations are only available to existing token creators (the owner of a deployed token contract), so they already passed the whitelist gate at creation time. Gating them again would lock out operators who created tokens before whitelisting was enabled.
Read-only: returns true if address is on the whitelist.
| Param | Type | Description |
|---|---|---|
address |
Address |
Address to query. |
| Code | Symbol | When |
|---|---|---|
| 1 | InsufficientFee |
fee_payment < required_fee |
| 2 | Unauthorized |
caller is not allowed for this operation |
| 3 | InvalidParameters |
argument out of range or malformed |
| 4 | TokenNotFound |
unknown token index or address |
| 5 | MetadataAlreadySet |
(deprecated — retained for ABI compatibility; no longer returned by set_metadata) |
| 6 | AlreadyInitialized |
double-initialize attempt |
| 7 | BurnAmountExceedsBalance |
burn > balance |
| 8 | BurnNotEnabled |
burning on a token that has been disabled |
| 9 | InvalidBurnAmount |
zero or negative burn |
| 10 | ContractPaused |
operation blocked because factory is paused |
| 11 | Reentrancy |
concurrent reentrant call detected |
| 12 | ArithmeticOverflow |
checked-op failed |
| 13 | StateNotFound |
factory not yet initialized |
| 14 | InvalidTokenParams |
name/symbol validation failed during token creation |
| 15 | InvalidDecimals |
decimals outside [0, 18] |
| 16 | MaxSupplyExceeded |
mint would exceed cap |
| 17 | InvalidFeeSplit |
set_fee_split map bps do not sum to 10_000 |
| 18 | TooManyFeeSplitRecipients |
set_fee_split map has more than MAX_FEE_SPLIT_RECIPIENTS (10) recipients |
| 19 | AlreadyBackfilled |
backfill_capped_supply already applied for this token |
| 20 | NotWhitelisted |
creator is not on the whitelist when enforcement is enabled |
| 21 | InvalidMetadataUri |
URI is empty, missing ipfs:// prefix, exceeds 128 bytes, or has no CID |
| 22 | ZeroFeeSplitEntry |
set_fee_split map contains an entry with bps == 0 |
| 23 | MetadataFrozen |
metadata is frozen (via freeze_metadata or auto-freeze after max updates) |
| 24 | TreasuryTransferFailed |
payment/redirect of a fee share to treasury itself failed (no further fallback) |
| 25 | BatchSizeExceeded |
create_tokens_batch called with more than MAX_BATCH_SIZE (20) tokens |
| 26 | NoPendingProposal |
accept_admin called with no live proposal, or the caller is not the proposed address |
| 27 | ProposalExpired |
the pending proposal's expiry ledger has passed; current admin must re-propose |
| 28 | UpgradeNotReady |
execute_upgrade called before UPGRADE_TIMELOCK_LEDGERS have elapsed since propose_upgrade |
| 29 | NoUpgradePending |
execute_upgrade called when no upgrade proposal is recorded, or cancel_upgrade called with nothing pending |
| 30 | UpgradeHashMismatch |
execute_upgrade called with a hash that does not match the previously proposed hash |
fuzz_seed_token (src/lib.rs, gated by #[cfg(feature = "testutils")]) is not a contract entrypoint — it is a plain associated function in a second, non-#[contractimpl] impl TokenFactory block, so it is compiled out of the production WASM entirely and never appears in the on-chain ABI. It exists so contracts/token-factory/fuzz's targets (which depend on this crate as an ordinary library and so cannot reach its private DataKey/TokenInfo types any other way) can register a token in factory storage — mirroring what create_token writes — without a real token WASM to install at token_wasm_hash, which cargo test/fuzzing can't provide (see mod bench's note in src/lib.rs). See contracts/token-factory/fuzz/README.md for how it's used.
The contract emits Soroban events on a (factory, action) topic. The frontend parses them via frontend/src/services/stellar-impl.ts. Events:
| Action | Payload | Trigger |
|---|---|---|
init |
(admin) |
initialize |
init |
(admin) |
__constructor |
created |
(token_address, creator, name, symbol) |
create_token / create_tokens_batch |
meta |
(token_address, metadata_uri, version) |
set_metadata (every update) |
meta_frz |
(token_address, admin) |
freeze_metadata |
mint |
(token_address, to, amount) |
mint_tokens |
burn |
(token_address, from, amount) |
burn |
fees |
(base_fee, metadata_fee) |
update_fees |
split_set |
(admin, splits) |
set_fee_split (non-empty) |
split_clr |
(admin) |
set_fee_split (empty — clears split) |
fee_redir |
(recipient, share) |
distribute_fee (recipient try_transfer failed; share redirected to treasury) |
pause |
(admin) |
pause |
unpause |
(admin) |
unpause |
adm_prop |
(current_admin, new_admin, expiry_ledger) |
propose_admin / transfer_admin / update_admin |
adm_acc |
(old_admin, new_admin) |
accept_admin |
adm_can |
(current_admin, cancelled_admin) |
cancel_admin_proposal |
adm_dep |
(current_admin, new_admin, expiry_ledger, deprecated_entrypoint) |
transfer_admin / update_admin (emitted alongside adm_prop) |
upg_prop |
(admin, new_wasm_hash, ready_at_ledger) |
propose_upgrade — upgrade scheduled; cancellable until ready_at_ledger |
upg_exec |
(admin, new_wasm_hash) |
execute_upgrade — WASM swap completed |
upg_can |
(admin, cancelled_wasm_hash) |
cancel_upgrade — pending upgrade aborted |
wl_add |
(address) |
add_to_whitelist |
wl_rm |
(address) |
remove_from_whitelist |
wl_tog |
(enabled) |
set_whitelist_enabled |
The create_tokens_batch function is exposed in the frontend when a user chooses to deploy multiple tokens in one transaction.
The frontend enforces a hard cap of 20 tokens per batch before the transaction is submitted. Attempting to submit more than 20 entries triggers a validation error:
Batch size of N exceeds the maximum recommended batch size of 20.
Please split your tokens into multiple batches of ≤ 20 to avoid
a failed on-chain transaction. Each failed submission still costs
the simulation fee.
This validation is implemented in frontend/src/utils/validation.ts (validateBatchSize) and is checked in the batch creation form before the user is allowed to sign with Freighter.
Resource numbers in the table above are generated by the benchmark harness in contracts/token-factory/src/bench.rs. The CI job in .github/workflows/benchmarks.yml runs automatically on every PR that touches contracts/ and posts a comparison report to the job summary.
To regenerate the numbers locally and compare against the baseline:
cd contracts/token-factory
cargo test bench_ -- --nocapture 2>/dev/null | python3 ../../scripts/check_benchmarks.pyTo update the baseline after an intentional resource change (e.g., a new SDK bump or refactor):
cd contracts/token-factory
cargo test bench_ -- --nocapture 2>/dev/null | \
python3 ../../scripts/check_benchmarks.py --update-baselineOr trigger it from GitHub Actions: go to Actions → Contract Benchmarks → Run workflow and set Update baseline to true.
The benchmark harness (.../src/bench.rs) also includes a sanity-check test (bench_create_token_within_limits) that asserts create_token stays below 50 % of the mainnet CPU and memory limits in the native test environment, providing an early warning for accidental bloat.