StellarForge is a user-friendly decentralized application (dApp) that enables creators, entrepreneurs, and businesses in emerging markets to deploy custom tokens on the Stellar blockchain without writing a single line of code.
- Token Factory Contract: Deploy custom SEP-41 tokens on Stellar using a single Soroban smart contract, without writing or auditing your own contract code
- Single & Batch Token Creation: Deploy one token per transaction, or atomically deploy multiple tokens in a single
create_tokens_batchcall - Fee-Based System: Configurable, admin-adjustable fees for token creation, metadata setting, and minting, paid in a dedicated SEP-41 fee token (typically native XLM)
- Fee Splitting: Route a percentage (in basis points) of every collected fee to multiple recipients instead of a single treasury address
- Max Supply Caps: Optionally cap a token's total mintable supply at creation time (batch path); further
mint_tokenscalls are rejected once the cap would be exceeded - IPFS Integration: Store token metadata (images, descriptions) on IPFS via Pinata, referenced on-chain by a single
ipfs://URI - Wallet Integration: Connect with the Freighter wallet extension for account discovery and transaction signing
- Burn Functionality: Token holders can burn their own balance; each token has a per-token
burn_enabledflag an admin can toggle off - Admin Controls: Update fees, pause/unpause the factory, rotate the admin address, and upgrade the contract's WASM in place
- Network Mismatch Protection: Writes are blocked in the UI whenever the connected Freighter network differs from the app's selected network, preventing accidental cross-network signing
- Network Switcher: Toggle between testnet and mainnet from the UI, each with its own contract ID, RPC endpoint, and explorer links
- Transaction History: View on-chain contract events (token creation, mint, burn, metadata, fee changes) with pagination and CSV export
- Contract Upgradability: In-place WASM upgrades with an idempotent, versioned state-migration path (
schema_version+migrate) that preserves all existing tokens, fees, and admin state - Testnet & Mainnet Support: The same frontend build supports both networks via environment configuration, with an explicit confirmation modal before mainnet-destructive actions
StellarForge is built around one on-chain "factory" contract that deploys and administers many independent token contracts, plus a React frontend that talks directly to Stellar — there is no StellarForge backend server and no database. Every piece of durable state (tokens, balances, fees, metadata pointers) lives on the Stellar ledger; the frontend is a thin, stateless client that reads and writes it.
The token-factory Soroban contract (contracts/token-factory/src/lib.rs) is deployed once per network (one factory on testnet, one on mainnet). It does not implement token logic itself — instead it:
- Holds a
token_wasm_hash: the hash of a separately-deployed, audited SEP-41 token contract WASM. - On
create_token, uses Soroban's deterministic deployer (env.deployer().with_address(creator, salt)) to instantiate a new, independent contract instance of that WASM, owned at an address derived from(creator, salt). - Initializes the new token contract with the requested
name,symbol, anddecimals, and optionally mints the caller aninitial_supply. - Records bookkeeping for the new token in
persistentstorage (see Storage architecture): aTokenInforecord (name, symbol, decimals, creator, timestamp, burn flag, optional max supply), a reversetoken_address → indexlookup, and a paginatedcreator → [indices]list for "my tokens" queries.
Every token deployed this way is a fully standalone contract on the ledger — it can be transferred, held, and queried through the standard SEP-41 interface by any Stellar wallet or tool, independent of StellarForge.
Every mutating factory call that has a monetary cost (create_token, create_tokens_batch, mint_tokens, set_metadata) requires the caller to pass a fee_payment argument and pre-authorize a transfer of that amount in the factory's configured fee_token (a SEP-41 asset — usually native XLM). The factory:
- Rejects the call with
Error::InsufficientFeeiffee_paymentis below the currentbase_fee(orbase_fee * token_countfor batch creation) ormetadata_fee. - Transfers the fee from the caller either straight to a single
treasuryaddress, or — if the admin has configured a fee split viaset_fee_split— proportionally across multiple recipients by basis points (parts per 10,000), with any rounding remainder going totreasury. - Lets the admin retune
base_feeandmetadata_feeat any time viaupdate_fees, without redeploying.
Token images and descriptions are too large and mutable to store cheaply on a Soroban ledger, so StellarForge splits metadata into two layers:
- Off-chain payload — the frontend uploads the image and a JSON document (
{ name, description, image }) to IPFS through Pinata's pinning API, getting back a content identifier (CID) for each. - On-chain pointer —
set_metadata(token_address, admin, metadata_uri, fee_payment)stores a singleipfs://<cid>string against the token, one time only (Error::MetadataAlreadySeton a second attempt). Any client — StellarForge's UI, a block explorer, another dApp — can resolve that URI through any IPFS gateway to fetch the same image/description.
- Pause switch —
pause/unpauselet the admin haltcreate_token,create_tokens_batch,mint_tokens, andset_metadatafactory-wide in an emergency.burnintentionally ignores the pause flag, since token holders should always be able to reduce their own balance. - Reentrancy guard — a
lockedflag onFactoryStateprevents a secondcreate_token/create_tokens_batchcall from interleaving with one already in progress in the same transaction context. - Per-token burn toggle —
set_burn_enabledlets a token's creator disable burning for that token specifically (e.g. for a fixed-supply asset), independent of the factory-wide pause. - Allow-list primitives —
add_to_whitelist/remove_from_whitelist/is_whitelistedmaintain an admin-managed address allow-list in factory storage. (These are currently standalone storage primitives; no factory entrypoint gates on them yet — see the project issue tracker for the tracked follow-up to wire enforcement intocreate_token.) - Admin rotation —
transfer_admin/update_adminmove admin privileges to a new address (both perform the same underlying state change;update_adminadditionally emits anadm_updevent). - Upgrade + migrate —
upgradeswaps the contract's executable WASM in place;migrateis an idempotent, versioned function (schema_versionvs.CURRENT_SCHEMA_VERSION) that brings on-chain state up to date with the currently-deployed code without ever losing existing tokens or fee configuration. See Contract Upgrade Process below.
The React app (frontend/src) is organized in layers so that UI code never talks to the network directly:
services/— the only layer that touches the outside world:stellar.ts/stellar-impl.tsbuild, sign-request, submit, and poll Soroban transactions and parse contract events;ipfs.tsuploads to and reads from Pinata;wallet.tswraps the Freighter browser extension API.context/— app-wide React state:WalletContext(connected account, signing),NetworkContext(testnet/mainnet selection, persisted tolocalStorage, cross-checked against Freighter's actual network viauseNetworkMismatch),ToastContext,DarkModeContext,TosContext.hooks/— data-fetching and derived state built on the services layer:useTokens(cached, paginated token listings),useTransaction/useTransactionPolling(submit-and-poll a signed transaction),useFactoryState,useTokenBalance,useTransactionHistory, and more.components/— presentation and forms (CreateToken,MintForm,BurnForm,SetMetadataForm,AdminPanel,TokenExplorer,TokenDashboard,TransactionHistory, …) that compose hooks and services but hold no blockchain logic of their own.
Because all state lives on-chain (or on IPFS), the frontend can be redeployed, pointed at a different factory contract ID, or replaced entirely without any data migration — it is purely a view over the Stellar ledger.
- Rust: Programming language for Soroban contracts
- Soroban SDK: Stellar's smart contract development framework
- Soroban Token SDK: For token operations
- React 19: UI framework
- TypeScript: Type-safe JavaScript
- Vite: Build tool and dev server
- Tailwind CSS: Utility-first CSS framework
- Vitest: Testing framework
- Freighter Wallet: Stellar wallet browser extension
- IPFS/Pinata: Decentralized file storage for metadata
- Stellar Horizon: Blockchain data API
- Soroban RPC: Smart contract interaction
- Rust: For building Soroban contracts
- Node.js (v18+): For frontend development
- Stellar CLI: For contract deployment and testing (see setup below)
- Freighter Wallet: Browser extension for Stellar transactions
You can set up StellarForge using either Docker (recommended for quick start) or local installation.
Prerequisites: Docker and Docker Compose
# Clone the repository
git clone <repository-url>
cd stellar-forge
# Start development environment
docker compose up -d
# Frontend available at: http://localhost:5173
# Access contract builder: docker compose exec contract-builder bashSee DOCKER_SETUP.md for detailed Docker instructions.
Prerequisites: Rust, Node.js (v18+), Stellar CLI, Freighter Wallet
git clone <repository-url>
cd stellar-forgeRun the setup script to install Rust, Stellar CLI, and configure testnet:
./scripts/setup-soroban.shNote: The Soroban CLI was renamed to
stellarin recent versions. All commands below usestellar. If you have the oldsorobanbinary installed, uninstall it and run the setup script again:cargo uninstall soroban-cli cargo install stellar-cli --features opt
cd frontend
npm installCopy the example env file and fill in your values: Copy the example file and fill in your values:
cp frontend/.env.example frontend/.envThen edit frontend/.env:
VITE_NETWORK=testnet
VITE_FACTORY_CONTRACT_ID=<deployed-contract-id>Metadata uploads go through a serverless proxy (api/ipfs/*) so Pinata
credentials never reach the browser. Set these as server-side environment
variables in your Vercel project settings (or a local .env at the repo
root when running vercel dev) - never prefix them with VITE_, or they'll
be inlined into the client bundle and shipped to every visitor:
PINATA_API_KEY=<pinata-api-key>
PINATA_API_SECRET=<pinata-api-secret>Note:
VITE_FACTORY_CONTRACT_ID,VITE_IPFS_API_KEY, andVITE_IPFS_API_SECRETare required. The app will display a misconfiguration screen if any of these are missing, rather than failing silently at runtime.
cd contracts
cargo build --target wasm32-unknown-unknown --releaseFor an optimized binary (requires binaryen — install via apt install binaryen or brew install binaryen):
cd contracts/token-factory
bash build.shThis produces target/wasm32-unknown-unknown/release/token_factory.optimized.wasm, which is significantly smaller and lowers on-chain deployment costs.
cd contracts/token-factory
cargo testFuzz testing with random inputs discovers edge cases and potential crashes:
cd contracts/token-factory/fuzz
cargo fuzz run fuzz_create_token -- -timeout=60 # Test token creation
cargo fuzz run fuzz_fee_arithmetic -- -timeout=60 # Test fee calculations
cargo fuzz run fuzz_burn -- -timeout=60 # Test burn operationsFor more details on fuzz testing setup and interpretation, see contracts/token-factory/fuzz/README.md.
cd frontend
npm run dev # Start dev server
npm run build # Build for production
npm run test # Run tests
npm run lint # Lint codeThe authoritative, field-by-field reference — including parameter tables, every error code, and every emitted event — lives in docs/contract-abi.md. This section is a quick-scan summary of the same #[contractimpl] surface in contracts/token-factory/src/lib.rs.
__constructor(admin, treasury, fee_token, token_wasm_hash, base_fee, metadata_fee): One-time factory setup, run atomically as part of contract deployment. Fails withAlreadyInitializedon retry.
create_token(creator, salt, name, symbol, decimals, initial_supply, fee_payment): Deploy a single new token contract at a deterministic(creator, salt)address; optionally mintinitial_supplytocreator.create_tokens_batch(creator, tokens, fee_payment): Atomically deploy aVec<BatchTokenParams>(each with its own name/symbol/decimals/initial_supply and optionalmax_supplycap) in one transaction.fee_paymentmust coverbase_fee * tokens.len(); a failure partway through the batch aborts the whole call.mint_tokens(token_address, admin, to, amount, fee_payment): Mint additional supply. Only the token's original creator may call this. Rejected withMaxSupplyExceededif the token was created with amax_supplycap that minting would exceed.burn(token_address, from, amount): Burnamountfrom the caller's own balance. Honors the token'sburn_enabledflag; ignores the factory-wide pause.
set_metadata(token_address, admin, metadata_uri, fee_payment): Attach anipfs://(orhttps://) metadata URI to a token. One-shot — a second call returnsMetadataAlreadySet.set_burn_enabled(token_address, admin, enabled): Toggle whether a specific token can be burned. Caller must be the token's creator.
update_fees(admin, base_fee?, metadata_fee?): Adjust either fee;Noneleaves it unchanged.set_fee_split(admin, splits)/get_fee_split(): Configure or read aMap<Address, u32>of basis-point fee recipients (must sum to10_000, or be empty to clear the split and fall back totreasury).pause(admin)/unpause(admin): Halt or resumecreate_token,create_tokens_batch,mint_tokens, andset_metadatafactory-wide.add_to_whitelist(admin, address)/remove_from_whitelist(admin, address)/is_whitelisted(address): Maintain an admin-managed address allow-list in contract storage (not currently enforced by any entrypoint — see the issue tracker).transfer_admin(admin, new_admin)/update_admin(current_admin, new_admin): Rotate the admin address. Equivalent effect;update_adminadditionally emits anadm_updevent.upgrade(admin, new_wasm_hash): Replace the factory's executable WASM in place, preserving all state. See Contract Upgrade Process.migrate(admin): Idempotently bring on-chain state up toCURRENT_SCHEMA_VERSIONafter an upgrade.
get_state(): FullFactoryState(admin, treasury, fee_token, fees, pause flag, token count, schema version).get_base_fee()/get_metadata_fee(): Current fee values.get_token_info(index): Look up a token by its 1-based factory index.get_tokens_by_creator(creator, offset, limit): Paginated slice of token indices created by a given address. The contract capslimitatMAX_TOKENS_BY_CREATOR_PAGE(currently 50) per call, so list iteration should advanceoffsetby the previous page's length until a short page is returned. The frontend'sfetchAllTokensByCreatorhelper (frontend/src/hooks/useTokens.ts) does this loop automatically.
All fallible entrypoints return Result<T, Error>. See the full table (17 variants, e.g. InsufficientFee, Unauthorized, ContractPaused, MaxSupplyExceeded, InvalidFeeSplit) in docs/contract-abi.md.
The contract publishes Soroban events on (factory, action) topics — init, created, meta, mint, burn, fees, pause, unpause, adm_upd — parsed by the frontend in frontend/src/services/stellar-impl.ts and rendered in the Transaction History view. See docs/contract-abi.md for the exact payload of each.
- Connect Wallet: Use the Freighter browser extension to connect an account. The app checks that Freighter's active network matches the app's selected network and blocks writes on mismatch.
- Create Token: Fill in name, symbol, decimals, and initial supply; the form validates against the same rules the contract enforces (name ≤ 32 chars, symbol ≤ 12 chars, decimals 0–18) before submission. Sign and submit the transaction to pay the creation fee and deploy the token contract.
- Set Metadata: Upload a token image and description — the app uploads the image to IPFS, pins a metadata JSON document referencing it, then calls
set_metadatawith the resultingipfs://URI (one-time only per token). - Mint Tokens: As the token's creator, mint additional supply to any address, subject to the token's optional
max_supplycap. - Manage Supply: Token holders can burn their own balance at any time (unless the creator has disabled burning for that token via
set_burn_enabled). - Admin Panel: The factory admin can update fees, configure a fee split, pause/unpause the factory, and rotate the admin address from the in-app Admin Panel.
- Explore & Export: Browse all deployed tokens or a specific creator's tokens in the Token Explorer/Dashboard, and export transaction history to CSV.
The application uses a service worker (via Workbox) to support offline capabilities. The cache is versioned using the VITE_FACTORY_CONTRACT_ID and VITE_NETWORK environment variables.
- When either of these variables changes, the service worker will be updated and old caches will be cleared automatically.
- The user interface displays the current contract ID and network in the footer, allowing manual verification before signing any transaction.
To force a cache refresh, users can either reload the page (the SW will update in the background) or clear the site data from the browser.
Important: After a contract redeployment, ensure the environment variables are updated and the build is deployed. The new SW will be served and clients will fetch the updated assets.
This guide walks you through deploying StellarForge to Stellar testnet from scratch.
- Stellar CLI installed (run
./scripts/setup-soroban.shif not) - Freighter wallet installed in your browser
- Basic understanding of command line
You need testnet XLM to pay for contract deployment and transactions.
-
Create a testnet account using Stellar CLI:
stellar keys generate deployer --network testnet
This creates a new keypair and saves it locally. The output shows your public key.
-
Fund your account using Friendbot:
stellar keys address deployer # Copy the address (starts with G...) # Fund with 10,000 testnet XLM curl "https://friendbot.stellar.org?addr=YOUR_ADDRESS_HERE"
-
Verify your balance:
stellar account balance deployer --network testnet
You should see 10,000 XLM.
cd contracts/token-factory
# Build the contract
cargo build --target wasm32-unknown-unknown --release
# Optimize the binary (reduces size and deployment costs)
stellar contract optimize \
--wasm ../../target/wasm32-unknown-unknown/release/token_factory.wasmThe optimized WASM will be at ../../target/wasm32-unknown-unknown/release/token_factory.optimized.wasm.
The factory needs the token contract WASM hash before it's deployed, since that hash is now one of its constructor arguments (see Step 4).
# First, build the standard Stellar token contract
# (or use your custom token implementation)
stellar contract install \
--wasm path/to/soroban_token_contract.wasm \
--source deployer \
--network testnet
# Save the WASM hash (64 hex characters)
# Example: 1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdefIf you don't have a token WASM, you can use the Stellar Asset Contract:
# Download the official Stellar token contract
wget https://github.com/stellar/soroban-examples/raw/main/token/target/wasm32-unknown-unknown/release/soroban_token_contract.wasm
# Install it
stellar contract install \
--wasm soroban_token_contract.wasm \
--source deployer \
--network testnetinitialize is the contract's __constructor, so deployment and initialization happen in a single transaction — there is no separate invoke step, and therefore no window where an attacker could race the deployer's own initialization with their own and seize the admin role (see issue #1005 and docs/contract-abi.md). Constructor arguments go after --, the same as any other stellar contract deploy invocation with a constructor.
# Get your admin address (same as deployer for simplicity)
ADMIN_ADDRESS=$(stellar keys address deployer)
# Deploy and initialize in one atomic call
stellar contract deploy \
--wasm ../../target/wasm32-unknown-unknown/release/token_factory.optimized.wasm \
--source deployer \
--network testnet \
-- \
--admin $ADMIN_ADDRESS \
--treasury $ADMIN_ADDRESS \
--fee_token <NATIVE_XLM_CONTRACT_ADDRESS> \
--token_wasm_hash <TOKEN_WASM_HASH_FROM_STEP_3> \
--base_fee 100000000 \
--metadata_fee 50000000
# Save the contract ID (starts with C...)
# Example output: CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXSave this contract ID - you'll need it for frontend configuration.
Before using this contract ID anywhere (frontend .env, docs, announcements), verify the on-chain admin matches:
stellar contract invoke \
--id <FACTORY_CONTRACT_ID> \
--source deployer \
--network testnet \
-- \
get_state
# Confirm the "admin" field equals $ADMIN_ADDRESS before proceeding.Parameters explained:
admin: Address that can update fees, pause the factory, manage the whitelist and fee split, rotate the admin, and upgrade the contract. Must authorize this deployment (require_auth).treasury: Default address that receives fees from token creation (overridden per-recipient if a fee split is configured)fee_token: Contract address for the SEP-41 token used to pay all fees (use native XLM contract:CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC)token_wasm_hash: The WASM hash uploaded in Step 3 — every token the factory deploys is an instance of this codebase_fee: Fee forcreate_token/mint_tokens/ each token increate_tokens_batch(in stroops, 1 XLM = 10,000,000 stroops)metadata_fee: Fee forset_metadata
cd frontend
# Copy environment template
cp .env.example .env
# Edit .env with your values
nano .envUpdate these required variables:
VITE_NETWORK=testnet
VITE_FACTORY_CONTRACT_ID=<your-factory-contract-id>
VITE_TOKEN_WASM_HASH=<your-token-wasm-hash>
VITE_IPFS_API_KEY=<your-pinata-api-key>
VITE_IPFS_API_SECRET=<your-pinata-api-secret>Keep
VITE_TOKEN_WASM_HASHin sync with the factory. This value must equal the factory's on-chaintoken_wasm_hash— the WASM the factory actually deploys tokens from. That field is set at deployment (__constructor) time and can only change through a contract upgrade + migrate, so the realistic way they drift apart is a factory upgrade that ships without a matching frontend redeploy.On startup (and every 5 minutes thereafter) the app reads
get_state()and compares the on-chain hash against this variable, showing a red warning banner if they differ. Treat that banner as a deployment bug: it is a safety net for detecting drift, not a substitute for updating both in lockstep. If the check cannot be completed — the RPC read fails, or the variable is unset — the app stays silent rather than warning, so a missing banner is not by itself proof that the hashes match.
Getting Pinata credentials:
- Sign up at https://app.pinata.cloud
- Go to API Keys → New Key
- Enable "pinFileToIPFS" permission
- Copy the API Key and API Secret
# Start the development server
npm run dev
# Open http://localhost:5173 in your browser- Connect your Freighter wallet (make sure it's on testnet)
- Try creating a test token
- Verify the transaction on Stellar Expert
# Build for production
npm run build
# Deploy the dist/ folder to your hosting serviceDeployment options:
- Vercel: See docs/deployment-vercel.md
- Netlify: Drag and drop the
dist/folder - GitHub Pages: Use
gh-pagespackage - Your own server: Serve the
dist/folder with nginx/apache
Before deploying to mainnet, complete the Mainnet Deployment Checklist.
The process is identical to testnet, but:
- Use
--network mainnetinstead of--network testnet - Fund your account with real XLM (buy from an exchange)
- Set
VITE_NETWORK=mainnetin your.env - Review all parameters carefully before deployment
- Consider using a hardware wallet for the admin key
Your account doesn't exist on the network yet. Fund it with Friendbot (testnet) or send XLM from an exchange (mainnet).
# Testnet
curl "https://friendbot.stellar.org?addr=$(stellar keys address deployer)"You don't have enough XLM to pay for the transaction.
# Check balance
stellar account balance deployer --network testnet
# Get more testnet XLM
curl "https://friendbot.stellar.org?addr=$(stellar keys address deployer)"The contract has already been initialized. You can't initialize it again. If you need to change parameters, deploy a new contract.
The WASM hash you provided doesn't exist on the network. Make sure you ran stellar contract install first and used the correct hash.
Add the wasm32 target to Rust:
rustup target add wasm32-unknown-unknownOne or more required environment variables are missing. Check that your .env file has:
VITE_FACTORY_CONTRACT_IDVITE_TOKEN_WASM_HASHVITE_IPFS_API_KEYVITE_IPFS_API_SECRET
Restart the dev server after changing .env files.
stellar-forge/
├── contracts/ # Soroban smart contracts
│ ├── Cargo.toml # Workspace configuration
│ └── token-factory/ # Token factory contract
│ ├── Cargo.toml
│ └── src/
│ ├── lib.rs # Contract implementation
│ └── test.rs # Contract tests
├── frontend/ # React application
│ ├── src/
│ │ ├── components/ # UI components (NetworkSwitcher, TransactionHistory, ...)
│ │ ├── context/ # React contexts (Wallet, Toast, Network)
│ │ ├── services/ # API integrations (stellar, wallet, ipfs)
│ │ ├── hooks/ # React hooks
│ │ ├── config/ # Configuration files
│ │ ├── types/ # TypeScript type definitions
│ │ └── utils/ # Utility functions
│ ├── package.json
│ └── vite.config.ts
├── scripts/ # Setup scripts
│ └── setup-soroban.sh # Installs Rust + Stellar CLI + configures testnet
└── README.md
StellarForge consists of three main components that work together:
┌─────────────────────────────────────────────────────────────────┐
│ User's Browser │
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ React Frontend │ │
│ │ ┌─────────────┐ ┌──────────────┐ ┌─────────────────┐ │ │
│ │ │ UI Layer │ │ Services │ │ Wallet SDK │ │ │
│ │ │ Components │→ │ stellar.ts │→ │ Freighter │ │ │
│ │ │ Forms │ │ ipfs.ts │ │ Integration │ │ │
│ │ └─────────────┘ └──────────────┘ └─────────────────┘ │ │
│ └───────────────────────────────────────────────────────────┘ │
│ ↓ │
└──────────────────────────────┼──────────────────────────────────┘
↓
┌────────────────┴────────────────┐
│ │
↓ ↓
┌─────────────────────────┐ ┌─────────────────────────┐
│ Stellar Network │ │ IPFS (Pinata) │
│ │ │ │
│ ┌──────────────────┐ │ │ ┌──────────────────┐ │
│ │ Factory Contract │ │ │ │ Token Metadata │ │
│ │ - create_token │ │ │ │ - Images │ │
│ │ - mint_tokens │ │ │ │ - Descriptions │ │
│ │ - burn │ │ │ │ - JSON files │ │
│ │ - set_metadata │ │ │ └──────────────────┘ │
│ └────────┬─────────┘ │ └─────────────────────────┘
│ │ │
│ ↓ │
│ ┌──────────────────┐ │
│ │ Token Contracts │ │
│ │ (deployed by │ │
│ │ factory) │ │
│ │ - transfer │ │
│ │ - balance │ │
│ │ - approve │ │
│ └──────────────────┘ │
└─────────────────────────┘
-
User → Frontend: User interacts with React UI to create tokens, set metadata, etc.
-
Frontend → Freighter: Frontend uses Freighter API to request transaction signatures
-
Frontend → Stellar Network: Signed transactions are submitted to Stellar via Soroban RPC
-
Factory Contract → Token Contracts: Factory deploys new token contracts using the token WASM hash
-
Frontend → IPFS: Token metadata (images, descriptions) are uploaded to IPFS via Pinata
-
Frontend → Stellar Network: Metadata URIs (ipfs://...) are stored on-chain via
set_metadata
1. User fills form → 2. Frontend validates → 3. Freighter signs tx
↓
4. Submit to Stellar
↓
5. Factory Contract
- Validates params
- Collects fee
- Deploys token
↓
6. New Token Contract
- Initialized
- Mints supply
↓
7. Event emitted
↓
8. Frontend updates UI
Symptoms: "Wallet not installed" error or connection button doesn't work
Solutions:
- Install Freighter wallet extension
- Refresh the page after installing
- Check that Freighter is enabled in your browser extensions
- Try a different browser (Chrome, Firefox, Brave, Edge supported)
Symptoms: Transactions fail with "account not found" or "contract not found"
Solutions:
- Check the network indicator in the top-right corner of the app
- Click the network switcher to toggle between testnet and mainnet
- In Freighter, ensure you're on the same network as the app
- Verify
VITE_NETWORKin your.envmatches your deployment
Symptoms: "insufficient balance" or "insufficient fee" errors
Solutions:
- Testnet: Get free XLM from Friendbot:
curl "https://friendbot.stellar.org?addr=YOUR_ADDRESS" - Mainnet: Buy XLM from an exchange and send to your wallet
- Check your balance in Freighter wallet
- Ensure you have at least 2-3 XLM for contract interactions
- Fee errors may indicate the factory's fee requirements have increased
Symptoms: Transaction pending for a long time, then fails
Solutions:
- Check Stellar network status at status.stellar.org
- Increase timeout in the code (default is 30 seconds)
- Try submitting the transaction again
- Check if Soroban RPC endpoint is responding:
curl https://soroban-testnet.stellar.org/health
Symptoms: "Failed to upload metadata" or IPFS errors
Solutions:
- Verify your Pinata API credentials in
.env - Check Pinata dashboard for API key status
- Ensure image file is under 10MB
- Try a different image format (PNG, JPG, GIF supported)
- Check Pinata service status at status.pinata.cloud
Symptoms: "AlreadyInitialized" error or initialization transaction fails
Solutions:
- Contract can only be initialized once
- If you need different parameters, deploy a new contract
- Check if contract is already initialized:
stellar contract invoke \ --id <contract-id> \ --network testnet \ -- get_state
Symptoms: "InvalidTokenParams" or creation transaction fails
Solutions:
- Ensure token name is 1-32 characters
- Ensure token symbol is 1-12 characters
- Decimals must be 0-18
- Initial supply must be non-negative
- Check that you have enough XLM to pay the creation fee
- Verify the factory is not paused:
stellar contract invoke \ --id <contract-id> \ --network testnet \ -- get_state
Symptoms: Token created but image/description doesn't show
Solutions:
- Check that metadata was set (look for
metadata_setevent) - Verify IPFS URI is accessible:
curl https://gateway.pinata.cloud/ipfs/<your-cid>
- Clear browser cache and reload
- Check browser console for CORS or loading errors
- Ensure metadata JSON follows the correct format:
{ "name": "Token Name", "description": "Token description", "image": "ipfs://..." }
Symptoms: cargo build or npm run build fails
Solutions:
- Rust build fails:
rustup update rustup target add wasm32-unknown-unknown cd contracts && cargo clean && cargo build
- Frontend build fails:
cd frontend rm -rf node_modules package-lock.json npm install npm run build - Check that you're using compatible versions (Node 18+, Rust stable)
Symptoms: Transaction history or token events don't display
Solutions:
- Check that Soroban RPC endpoint supports
getEvents - Verify contract ID is correct in
.env - Check browser console for API errors
- Try refreshing the page
- Ensure you're on the correct network (testnet/mainnet)
If you're still experiencing issues:
- Check the logs: Open browser DevTools (F12) and check the Console tab
- Search existing issues: GitHub Issues
- Ask for help: Create a new issue with:
- Description of the problem
- Steps to reproduce
- Error messages (from browser console and terminal)
- Your environment (OS, browser, Node version)
- Join the community: Stellar Discord or developer forums
We take security seriously. If you discover a security vulnerability, please review our Security Policy for responsible disclosure guidelines.
A strict CSP is defined as a <meta> tag in frontend/index.html:
default-src 'self';
connect-src 'self' https://*.stellar.org https://api.pinata.cloud;
img-src 'self' data: https://gateway.pinata.cloud;
script-src 'self'
For stronger enforcement, set the CSP as an HTTP response header on your hosting provider instead of (or in addition to) the meta tag — HTTP headers take precedence and support more directives like frame-ancestors.
Vercel — add to vercel.json:
{
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "Content-Security-Policy",
"value": "default-src 'self'; connect-src 'self' https://*.stellar.org https://api.pinata.cloud; img-src 'self' data: https://gateway.pinata.cloud; script-src 'self'"
}
]
}
]
}Netlify — add to netlify.toml:
[[headers]]
for = "/*"
[headers.values]
Content-Security-Policy = "default-src 'self'; connect-src 'self' https://*.stellar.org https://api.pinata.cloud; img-src 'self' data: https://gateway.pinata.cloud; script-src 'self'"Nginx — add to your server block:
add_header Content-Security-Policy "default-src 'self'; connect-src 'self' https://*.stellar.org https://api.pinata.cloud; img-src 'self' data: https://gateway.pinata.cloud; script-src 'self'";For users deploying tokens, we strongly recommend:
- Always test on testnet first before mainnet deployment
- Review all parameters carefully using the Mainnet Deployment Checklist
- Verify contract addresses and transaction details before signing
If a user's XLM balance is too low to cover the network base fee, their transaction will fail. Stellar's fee bump mechanism lets a third-party account (the fee source) pay the base fee on behalf of the original sender.
- The inner transaction's source account has near-zero XLM.
- You want to sponsor fees for users as part of your application UX.
- Resubmitting a stuck transaction with a higher fee without re-signing the inner envelope.
Two utilities are exported from frontend/src/services/stellar.ts:
// 1. Wrap a signed inner transaction in a fee bump envelope.
// The fee-source account (connected via Freighter) signs the bump.
const signedFeeBumpXdr = await buildFeeBumpTransaction(
innerTxXdr,
feeSourceAddress,
);
// 2. Submit the fee bump and wait for confirmation.
const txHash = await submitFeeBumpTransaction(signedFeeBumpXdr);The fee source must have enough XLM to cover the base fee. The inner transaction is not re-signed — only the fee bump envelope requires the fee source's signature.
See CONTRIBUTING.md for local development setup and contribution guidelines.
Key architectural decisions are documented in docs/adr/:
- ADR-001: Choice of Stellar / Soroban for smart contracts
- ADR-002: Freighter wallet integration
- ADR-003: Pinata for IPFS metadata storage
- ADR-004: React + Vite + TypeScript for frontend
The factory contract supports in-place WASM upgrades without redeploying or migrating state.
FactoryState carries a schema_version: u32 field. The constant CURRENT_SCHEMA_VERSION in lib.rs is the source of truth. __constructor stamps the current version on every fresh deployment. migrate reads the on-chain version from a standalone "sv" storage key and applies each pending upgrade step in order, making it safe to call multiple times (idempotent).
| Version | Change |
|---|---|
| 1 | Initial versioned schema — added schema_version field to FactoryState |
| 2 | Max-supply accounting fix (issue #1006) — deploy_one now seeds the per-token supply counter with initial_supply; version bump only, no FactoryState field changes. Pre-fix capped tokens must be back-filled individually via backfill_capped_supply (see docs/contract-abi.md) |
| 3 | Persistent-storage migration (issue #1007) — per-token bookkeeping (TokenInfo, TokenIndex, Metadata, owner, supply, CreatorTokens) moves out of the shared instance ledger entry into persistent storage, keeping instance storage O(1) in token_count. TokenInfo migrates in bounded, resumable chunks per migrate call; everything else migrates lazily on next access (see docs/contract-abi.md) |
| Version | Change |
| ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | Initial versioned schema — added schema_version field to FactoryState |
| 2 | Max-supply accounting fix (issue #1006) — deploy_one now seeds the per-token supply counter with initial_supply; version bump only, no FactoryState field changes. Pre-fix capped tokens must be back-filled individually via backfill_capped_supply (see docs/contract-abi.md) |
| 3 | Added whitelist_enabled: bool to FactoryState; new set_whitelist_enabled entrypoint; create_token and create_tokens_batch enforce the whitelist gate when enabled |
-
Increment
CURRENT_SCHEMA_VERSIONinlib.rstoN+1. -
Add an
if on_chain_version < N+1 { … }block insidemigratethat reads the current state, sets new fields to their defaults, writes the updated state, bumpson_chain_versiontoN+1, and writes the updatedsvkey. The bump must happen inside the block, before the next block's guard runs — this is what allows a contract that is multiple versions behind to walk through every pending step in a singlemigratecall.// Example: adding version 2 if on_chain_version < 2 { let mut s = Self::load_state(&env)?; s.new_field = default_value; // initialise any new FactoryState fields Self::save_state(&env, &s); on_chain_version = 2; // ← bump BEFORE the next if-block is evaluated env.storage().instance().set(&sv_key, &on_chain_version); }
Critical:
on_chain_versionis amutlocal variable. Each block must assign the new version back to it after writing to storage. If you forget the in-place bump, the nextif on_chain_version < N+2block will compare against the original value read at the top ofmigrate, not the value just written — causing steps to run out of order or be skipped entirely on a multi-version catch-up. -
Add tests in
test.rsthat:- Seed
sv = N-1(or 0) and assertmigratewalks through all pending steps in one call, ending atCURRENT_SCHEMA_VERSION. - Seed
sv = Nand confirm only the N → N+1 step runs (earlier steps are skipped). - Call
migratetwice at the final version and assert it is a no-op (idempotent).
- Seed
- Build and optimize the new contract WASM.
- Upload the new WASM to the network to obtain its hash:
stellar contract upload \ --wasm target/wasm32-unknown-unknown/release/token_factory.optimized.wasm \ --source <admin-secret-key> \ --network testnet # Outputs: <new-wasm-hash>
- Call
upgradeon the deployed contract:stellar contract invoke \ --id <contract-id> \ --source <admin-secret-key> \ --network testnet \ -- upgrade \ --admin <admin-address> \ --new_wasm_hash <new-wasm-hash>
- If the new version requires data layout changes, call
migrateimmediately after:stellar contract invoke \ --id <contract-id> \ --source <admin-secret-key> \ --network testnet \ -- migrate \ --admin <admin-address>
Only the admin address can call upgrade and migrate. Non-admin callers receive Error::Unauthorized. Contract state (tokens, fees, admin) is fully preserved across upgrades.
This project follows the Contributor Covenant Code of Conduct. By participating, you are expected to uphold this code.
This project is licensed under the MIT License - see the LICENSE file for details.
This software is for educational and development purposes. Always test thoroughly on testnet before mainnet deployment. The authors are not responsible for any financial losses incurred through the use of this software.