StarForge's configuration lives in ~/.config/starforge/ (a local SQLite store,
with config.toml still read for backwards compatibility). This document
covers the parts a developer or CI author needs: the pure API, the overlay
merge rules, and what makes a configuration invalid.
Everything in this list is a pure function — no filesystem, no database, no
environment. That is what makes configuration handling testable and what keeps
cargo test from mutating the developer's real config.
| Function | Purpose |
|---|---|
config::parse_config_str(&str) |
Parse a configuration from TOML |
config::parse_config_json(&str) |
Parse a configuration from JSON |
config::to_toml_string(&Config) |
Serialize to TOML |
config::to_json_string(&Config) |
Serialize to JSON |
config::parse_overlay_str(&str) |
Parse a partial overlay from TOML |
config::merge_configs(base, overlay) |
Layer an overlay onto a base and validate |
config::validate_config(&Config) |
Validate a whole configuration |
config::validate_network_exists(&Config, &str) |
Check a network reference against that config |
Round trips are exact in both formats: parsing what was serialized yields an
equal Config, and crossing formats (TOML → JSON → TOML) is stable. This is
enforced by tests/config_property_tests.rs
over hundreds of generated configurations.
Field order matters in
Config. TOML requires a table's scalar values to be emitted before its sub-tables. All scalars are declared first, then tables, then arrays of tables. Moving a scalar below a table makesto_toml_stringfail at runtime. Deserialization is by key, so the order can change without breaking existing files.
A ConfigOverlay is a partial configuration layered on top of a base — for a
project-local file, a CI environment, or a named profile.
# overlay.toml
network = "mainnet"
telemetry_enabled = false
[networks.staging]
horizon_url = "https://horizon-staging.example.com"
soroban_rpc_url = "https://rpc-staging.example.com"For settings a whole team should share, StarForge supports a committed,
non-secret project lockfile named starforge-project.toml (see
starforge-project.example.toml at the repository root). Place it anywhere in
your repository — any starforge command run inside the repository tree picks
it up by walking upwards to the repository root.
# starforge-project.toml — commit this
network = "testnet"
telemetry_enabled = false
[networks.project-futurenet]
horizon_url = "https://horizon-futurenet.stellar.org"
soroban_rpc_url = "https://soroban-futurenet.stellar.org"Team workflow:
- Commit
starforge-project.tomlwith the networks, feature flags, AI telemetry, and plugin-trust settings your team agrees on. - Each member keeps personal settings (wallets, encryption) in their own
~/.starforgeconfig as usual. - Commands resolve the effective config = user config with project overrides applied — project wins over user, so committed team settings beat personal defaults.
Security model:
- The lockfile schema has no secret-bearing fields: there is no way to
express wallets or wallet key material, and
deny_unknown_fieldsrejects sections like[wallets]or[wallet_encryption]at load time instead of silently ignoring them. - A broken or typo'd lockfile (
feautre_flags = ...) is a hard load error, never a silent no-op. - Per-install identity (
version,install_id) always stays with the user config; the project layer cannot forge it. starforge config showtells you which lockfile (if any) is participating, so an override is never mistaken for a personal setting.
The lockfile can also declare [[smoke_tests]], which starforge deploy --execute runs after a successful deploy. These entries are not config
overrides. See SMOKE_TESTS.md for the schema.
Commands that only read configuration use the effective config. Commands that write configuration still operate on the user config only — project overrides are an input, never something persisted back.
| Field | Rule |
|---|---|
network, telemetry_enabled, wallet_encryption |
Overlay wins when set; base kept otherwise |
feature_flags, plugin_trust, ai_telemetry |
Replaced wholesale when present |
networks |
Merged by key; an overlay entry replaces the base entry of that name |
wallets |
Appended; a duplicate name is an error |
version, install_id |
Always from the base — an overlay may not forge either |
feature_flags, plugin_trust, and ai_telemetry are replaced rather than
field-merged on purpose: a partially specified trust policy that silently
inherits half of the base allowlist is a security footgun.
Wallets are appended rather than overwritten because they hold key material. An overlay whose wallet name collides with the base is rejected:
Overlay wallet 'deployer' already exists in the base configuration; rename it
or remove it from the overlay
Guaranteed, and tested over generated inputs:
- Identity — merging an empty overlay changes nothing.
- Idempotence — applying the same overlay twice adds nothing the first application did not already add.
- Validation —
merge_configsvalidates the result, so a merge can never produce a configuration thatsavewould reject.
ConfigOverlay uses deny_unknown_fields. A typo is a hard error:
$ starforge ... # with `netwrok = "mainnet"` in the overlay
Failed to parse configuration overlay TOML: unknown field `netwrok`A silently ignored netwrok key would leave a deploy pointed at the wrong
network. The main Config parser stays lenient about unknown keys so a file
written by a newer StarForge still loads.
validate_config rejects these combinations — each value may be well-formed
on its own:
| Rejected | Why |
|---|---|
Empty version |
Schema version is required for migration |
Empty or whitespace network |
No active network |
Active network not in networks and not built in |
Dangling reference |
Empty networks map |
Nothing to connect to |
A non-http(s) endpoint URL |
Only HTTP(S) endpoints are supported |
| A wallet on an unknown network | Dangling reference |
| An invalid wallet name, public key, or secret key | Malformed entry |
| Two wallets with the same name | Ambiguous reference |
| An invalid plugin trust source | Malformed allowlist entry |
Built-in networks (testnet, mainnet, docker-testnet) always resolve, even
if they are absent from the networks map.
Each wallet may carry an independent policy, enforced immediately before local
or hardware signing. Empty allowlists and an omitted fee cap leave that
dimension unrestricted; max_fee is expressed in stroops. A configured fee
cap requires a fee estimate at signing time, and an unavailable estimate blocks
signing. allowed_contracts applies to contract transactions; deployments are
blocked when the wallet has a contract allowlist because the new contract ID is
not known before deployment.
[[wallets]]
name = "mainnet-admin"
public_key = "G..."
network = "mainnet"
created_at = "2026-09-28T00:00:00Z"
funded = true
allowed_networks = ["mainnet"]
max_fee = 500000
allowed_contracts = ["C..."]
require_confirmation = truerequire_confirmation = true always prompts at signing time, including when
the command was started with --yes. Policy failures are written to the audit
trail as wallet_policy_violation entries. Errors include one of these codes:
| Code | Meaning |
|---|---|
WALLET_POLICY_NETWORK_DENIED |
Requested network is not allowlisted |
WALLET_POLICY_FEE_EXCEEDED |
Estimated transaction fee exceeds max_fee |
WALLET_POLICY_FEE_UNKNOWN |
A fee cap is set but no fee estimate is available |
WALLET_POLICY_CONTRACT_DENIED |
Contract ID is not allowlisted |
WALLET_POLICY_CONTRACT_UNKNOWN |
Contract target is missing while an allowlist is set |
WALLET_POLICY_CONFIRMATION_DECLINED |
User declined the required signing confirmation |
validate_network_exists used to fall back to loading the on-disk
configuration when a network was missing from the Config it was handed. That
made validation depend on — and, through load(), write to — global state:
the same in-memory Config could validate differently on two machines, and
validating a value in a test opened and migrated the developer's real database.
It is now pure: it consults the supplied Config and the built-in names, and
nothing else.
If you relied on the old behaviour (calling validate_network_exists with a
partially populated Config and expecting it to consult the saved config),
load the configuration explicitly first and pass that in. validate_network,
which does read from disk by design, is unchanged.
validate_config also now rejects duplicate wallet names. A configuration that
already contains duplicates will fail to save until one is renamed — previously
the duplicate silently shadowed the other on lookup.
Wallet secrets can be moved out of the configuration and into an OS-native secret store (macOS Keychain, Windows Credential Manager, or the freedesktop Secret Service):
$ starforge wallet migrate --to keychain- The backend is opt-in at build time via the
keychaincargo feature (no new dependency; it shells out to the platform tool) and at runtime via the command above. - After migration the configuration stores only a
keychain:<key>reference per wallet; the secret itself lives in the OS store.validate_configaccepts those references. - Migration is idempotent and never drops a key: re-running it reports the wallets as already migrated.
- Headless-CI fallback. When the
keychainfeature is disabled or the platform tool is missing, the command writes secrets to a permission-restrictedsecrets.jsonnext to the config (0600 on Unix) and prints a notice, so CI jobs still migrate without a live keychain.
- Wallet secrets in a configuration are stored either as plaintext StrKeys,
encrypted bundles, or
keychain:references created by a keychain migration;validate_configaccepts all shapes but never logs any of them. Error messages quote the wallet name, not the key. - An overlay cannot replace an existing wallet, so a hostile overlay file cannot swap out a deployer key.
- An overlay cannot set
install_id, which is used for deterministic feature-flag bucketing.
Every config-directory file StarForge writes by itself — migration backups
(config.backup.v*.toml), a restored config.toml, and save_config_file
exports — goes through config::atomic_write:
- Bytes are written to a uniquely named temporary file in the same
directory as the destination (
.starforge-<pid>-<nanos>-<n>.tmp), never to a temp dir on another filesystem. - The temporary file is flushed and fsynced.
- The temporary file is renamed over the destination, which is atomic on the same filesystem: a reader (or a crashed process) sees either the old complete file or the new complete file, never a truncated one.
- On Unix the parent directory is fsynced afterwards so the rename itself is
durable. On any error the temporary file is removed, so no stray
.tmpfiles accumulate.
- Windows has no safe, portable way to fsync a directory handle, so the
directory fsync is skipped there; the per-file fsync still guarantees no torn
contents can be observed.
std::fs::renamenormally replaces an existing destination on Windows (MOVEFILE_REPLACE_EXISTING); for a locked or read-only destination StarForge falls back to remove-then-rename rather than failing the save. - The TOML config file is a legacy/export format. The primary persistence
path is the SQLite store (
save()); onlysave_config_file, migration backups, androllback_configwriteconfig.tomlitself. atomic_writecreates missing parent directories and fails cleanly when a path's parent is actually a file, leaving the previous file untouched.
rollback_configpreviously restored backups with a non-atomicfs::copy. It now reads the backup and writes it throughatomic_write, so an interrupted rollback can no longer leave a half-writtenconfig.toml.write_config_backuppreviously wrote backups with a non-atomicfs::write. It now usesatomic_write. Existing backup files remain valid and unchanged.
- docs/COMMAND_REFERENCE.md — the
configcommand - FUZZING_GUIDE.md — running the property suites
- tests/config_property_tests.rs