π Description
The app treats the Stellar network as configurable. NEXT_PUBLIC_STELLAR_NETWORK is validated as one of PUBLIC | FUTURENET | TESTNET | LOCAL | STANDALONE (apps/web-app/src/lib/env.client.ts:26-33), the README documents PUBLIC as "Stellar mainnet (production)", and the token config genuinely honours it.
Protocol contracts do not. Across 52 call sites the lending, oracle and backstop contract IDs are read from a hardcoded testnet literal, with no network branch at all. Combined with a committed .env.example that selects mainnet, the default path a new developer or deployment follows produces an app that talks to mainnet RPC using testnet contract IDs β with every configuration validator passing.
π The inconsistency
What works β assets.config.ts is network-aware
apps/web-app/src/lib/constants/assets.config.ts does exactly the right thing. It keys contracts by network and resolves at call time:
type NetworkId = "testnet" | "standalone" | "mainnet";
const ASSETS_BY_NETWORK: Record<NetworkId, Record<string, AssetConfig>> = {
testnet: {
XLM: { contract: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", β¦ },
USDC: { contract: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", β¦ },
β¦
},
mainnet: {
XLM: { contract: "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA", β¦ },
USDC: { contract: "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75", β¦ },
β¦
},
standalone: { β¦ },
};
function getNetworkId(): NetworkId {
const network = stellarNetwork?.toLowerCase() || "testnet";
if (network === "local" || network === "standalone") return "standalone";
if (network === "public" || network === "mainnet") return "mainnet";
return "testnet";
}
export function getAssetsConfig(): Record<string, AssetConfig> {
const networkId = getNetworkId();
return ASSETS_BY_NETWORK[networkId] ?? ASSETS_BY_NETWORK.testnet;
}
This is the pattern the rest of the codebase should follow. It already exists and already works.
What does not β 52 hardcoded testnet references
$ grep -rn "networks\.testnet" apps/web-app/src | wc -l
52
Representative sites:
// features/borrowing/hooks/useHealthFactor.ts:16
contractId: networks.testnet.pool1ContractId,
// features/borrowing/hooks/useUserPosition.ts:21
contractId: networks.testnet.pool1ContractId,
// features/lending/hooks/useLendingPools.ts:135
contractId: networks.testnet.pool1ContractId,
// features/admin/constants.ts:3-4
export const POOL1_CONTRACT_ID = networks.testnet.pool1ContractId;
export const POOL2_CONTRACT_ID = networks.testnet.pool2ContractId;
// lib/clients/oracle.ts:10
contractId: Client.networks.testnet.contractId,
// lib/orchestrator/adapters/NekoLendingAdapter.ts:72-73
? networks.testnet.pool2ContractId
: networks.testnet.pool1ContractId;
lib/helpers/stellar/lending.ts and lib/services/lending.service.ts account for the bulk of them, mostly as default parameter values β roughly 30 occurrences of:
contractId: string = networks.testnet.contractId
A default parameter is the worst place for this. It is invisible at the call site, so nothing in the calling code hints that a network assumption is being made.
The generated packages have no mainnet entry to select
Even a call site that wanted to branch could not:
// packages/contracts/lending/src/index.ts:33
export const networks = {
testnet: {
networkPassphrase: "Test SDF Network ; September 2015",
contractId: "CAPTJPAXEURYXEWGDSCCU3SZDYIP3U7KTS7VQM2EYLLK2JKIR4JIVWIH",
pool1ContractId: "CAPTJPAXEURYXEWGDSCCU3SZDYIP3U7KTS7VQM2EYLLK2JKIR4JIVWIH",
pool2ContractId: "CD3HUT3N6LLUS4XZUWRIIJZUKN2B3UDDMEH2IMK6S3UKAHTZ6VKKBTYQ",
},
} as const;
// packages/contracts/oracle/src/index.ts:33
export const networks = {
testnet: {
networkPassphrase: "Test SDF Network ; September 2015",
contractId: "CDJVAFSJTERWPYEZQJGN2N5N4BMXGMG6A2AWQK4C3V36MRYB4PRSNM2S",
},
} as const;
testnet is the only key. @neko/backstop and @neko/defindex-vault are the same.
Bare literals outside the packages
Beyond networks.testnet, contract IDs are inlined as string literals in several places with no network dimension:
| File |
Count |
Contents |
lib/constants/contracts.ts |
5 |
lending, pool2, oracle, backstop, backstop pool2 |
features/vault/const/vaults.ts |
7 |
vault, pools, strategies |
app/api/vault/invest/route.ts |
5 |
vault + 3 strategies |
app/api/vault/apy/route.ts |
6 |
vault, pools, strategies |
lib/constants/faucet.ts |
6 |
faucet tokens |
lib/orchestrator/adapters/blend-config.ts |
2 |
Blend pools |
lib/strategy/definitions.ts |
1 |
vault |
features/vault/hooks/useVaultData.ts |
1 |
vault |
features/vault/hooks/useVaultBalance.ts |
1 |
vault |
features/vault/hooks/useVaultAction.ts |
1 |
vault |
features/vault/components/ui/VaultActionModal.tsx |
1 |
vault |
lib/config/stellar.config.ts |
1 |
XLM_TESTNET_ADDRESS |
For example:
// apps/web-app/src/lib/constants/contracts.ts:14-30
export const LENDING_CONTRACT_ID =
"CAPTJPAXEURYXEWGDSCCU3SZDYIP3U7KTS7VQM2EYLLK2JKIR4JIVWIH";
export const LENDING_POOL2_CONTRACT_ID =
"CD3HUT3N6LLUS4XZUWRIIJZUKN2B3UDDMEH2IMK6S3UKAHTZ6VKKBTYQ";
export const ORACLE_CONTRACT_ID =
"CDJVAFSJTERWPYEZQJGN2N5N4BMXGMG6A2AWQK4C3V36MRYB4PRSNM2S";
export const BACKSTOP_CONTRACT_ID =
"CCSNXCONDIFZADRJZZRHMRXDGTC6EMI7ZAYAJPJMXSDHBMOPBLBX5XQX";
export const BACKSTOP_POOL2_CONTRACT_ID =
"CADKADCLXIZTU7Q3WS5M37HUF5BX3WCSTCM2ESN2JCJK5FBBH57FKSAV";
And the native-wrapper address is hardcoded as testnet and compared unconditionally:
// apps/web-app/src/lib/config/stellar.config.ts:38
export const XLM_TESTNET_ADDRESS =
"CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC";
// apps/web-app/src/hooks/useTokenBalance.ts:28
if (tokenAddress === tokens.XLM || tokenAddress === XLM_TESTNET_ADDRESS) {
On mainnet the native wrapper is CAS3J7GYβ¦OWMA β as assets.config.ts already knows β so this comparison silently stops matching.
π£ Why this is likely to bite
The committed apps/web-app/.env.example selects mainnet:
# Stellar Network Configuration (mainnet value must be PUBLIC, not MAINNET)
NEXT_PUBLIC_STELLAR_NETWORK=PUBLIC
NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE="Public Global Stellar Network ; September 2015"
NEXT_PUBLIC_STELLAR_RPC_URL=https://rpc.stellar.org
NEXT_PUBLIC_STELLAR_HORIZON_URL=https://horizon.stellar.org
Meanwhile CLAUDE.md and the README setup snippet both say:
NEXT_PUBLIC_STELLAR_NETWORK=TESTNET
NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE="Test SDF Network ; September 2015"
NEXT_PUBLIC_STELLAR_RPC_URL=https://soroban-testnet.stellar.org
NEXT_PUBLIC_STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org
The two disagree. Copying .env.example β the normal thing to do, and what the filename invites β produces an app that:
- points RPC, Horizon and the wallet signing passphrase at mainnet,
- renders mainnet token contracts from
assets.config.ts,
- and calls testnet lending, oracle, backstop and vault contract IDs.
Nothing catches it. The zod env schema is satisfied β PUBLIC is a valid enum member and the URLs parse. No code cross-checks a contract ID against the selected network. The app boots cleanly and looks correct.
π Impact
Silent, confusing breakage. Reads against a contract ID that does not exist on mainnet fail at the RPC layer. The UI surfaces this as empty pool lists, null health factors, and failed simulations β with no indication that the root cause is a network mismatch. This is the kind of bug that costs days because every individual symptom points somewhere else.
Mixed-network transactions. Flows combining a mainnet token address from assets.config.ts with a testnet pool ID build transactions that cannot succeed β but only after the user has been prompted to sign. Users signing transactions that are structurally invalid is a trust problem as much as a technical one.
Address-collision risk. Soroban contract IDs are derived per network, so a testnet ID has no guaranteed meaning on mainnet. It is also not guaranteed to be unallocated. Sending real value to an address chosen for a different network is not a risk to carry into a mainnet launch.
Mainnet is currently unreachable. There is no value of NEXT_PUBLIC_STELLAR_NETWORK that produces a working mainnet deployment. This is a hard blocker on launch, not just a latent bug.
Wallet passphrase mismatch. NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE feeds the Stellar Wallets Kit. With the .env.example defaults, wallets are asked to sign against the mainnet passphrase for transactions built around testnet contracts.
π Secondary finding β duplicated network module
apps/web-app/src/lib/constants/network.ts and apps/web-app/src/lib/config/stellar.config.ts are near-identical copies of the same module. Both export stellarNetwork, networkPassphrase, rpcUrl, horizonUrl, labPrefix, networkToId and network, with the same stellarEncode helper and the same switch statement. They differ only in that stellar.config.ts adds XLM_TESTNET_ADDRESS and omits allowHttpForSoroban.
Both are imported from live code. This duplication is plausibly how the network logic drifted out of sync in the first place, and it doubles the surface for the next drift. It should be collapsed as part of this work.
π Related β divergent oracle IDs
The operational scripts and the app point at two different oracle contracts:
// push-prices.cjs:10 and deploy-full.cjs:15
const ORACLE_ID = "CCL7MHNSFAG7537O3OJ7Z42YVSNXTYZCGPAHN5FI5RQHLJW43O3L7LLS";
// apps/web-app/src/lib/constants/contracts.ts:24
export const ORACLE_CONTRACT_ID =
"CDJVAFSJTERWPYEZQJGN2N5N4BMXGMG6A2AWQK4C3V36MRYB4PRSNM2S";
Either the scripts are stale, or the app reads an oracle that nothing is updating. Worth resolving while consolidating contract configuration.
π Proposed fix
Step 1 β add mainnet entries to the generated packages
// packages/contracts/lending/src/index.ts
export const networks = {
testnet: {
networkPassphrase: "Test SDF Network ; September 2015",
contractId: "CAPTJPAXβ¦IR4JIVWIH",
pool1ContractId: "CAPTJPAXβ¦IR4JIVWIH",
pool2ContractId: "CD3HUT3Nβ¦Z6VKKBTYQ",
},
mainnet: {
networkPassphrase: "Public Global Stellar Network ; September 2015",
contractId: "Cβ¦", // TODO: populate at mainnet deploy
pool1ContractId: "Cβ¦",
pool2ContractId: "Cβ¦",
},
} as const;
If mainnet contracts are not deployed yet, omit the key rather than filling it with testnet values, and let the registry below fail loudly. A missing entry is a good error; a wrong entry is a silent disaster.
Step 2 β one network-aware contract registry
Mirror the ASSETS_BY_NETWORK shape that already works:
// apps/web-app/src/lib/constants/contractsByNetwork.ts (new)
import { getCurrentNetworkId, type NetworkId } from "./assets.config";
interface ProtocolContracts {
lending: string;
lendingPool1: string;
lendingPool2: string;
oracle: string;
backstop: string;
backstopPool2: string;
vault: string;
nativeWrapper: string;
strategies: { neko: string; aquarius: string; soroswap: string };
}
const CONTRACTS_BY_NETWORK: Partial<Record<NetworkId, ProtocolContracts>> = {
testnet: { /* current values */ },
// mainnet intentionally absent until contracts are deployed.
};
/**
* Resolve the protocol contract set for the configured network.
*
* Throws when the selected network has no contract set. This is deliberate:
* silently falling back to testnet IDs on mainnet is how real value gets
* sent to an address that means nothing on the target network.
*/
export function getContracts(): ProtocolContracts {
const networkId = getCurrentNetworkId();
const contracts = CONTRACTS_BY_NETWORK[networkId];
if (!contracts) {
throw new Error(
`No protocol contracts configured for network "${networkId}".\n` +
`Deploy the contracts and add a "${networkId}" entry to ` +
`CONTRACTS_BY_NETWORK in lib/constants/contractsByNetwork.ts.`
);
}
return contracts;
}
Fail-fast is the core of the fix. Everything else is mechanical.
Step 3 β migrate the call sites
Remove the default parameters entirely rather than re-pointing them, so the network decision is explicit at the boundary:
// Before β apps/web-app/src/lib/services/lending.service.ts
async function getPoolState(
contractId: string = networks.testnet.contractId
) { β¦ }
// After β caller must supply it; the registry is the only source
async function getPoolState(contractId: string) { β¦ }
// Before β apps/web-app/src/features/borrowing/hooks/useHealthFactor.ts
const POOLS = [
{ key: "pool1", contractId: networks.testnet.pool1ContractId, label: "Pool 1" },
{ key: "pool2", contractId: networks.testnet.pool2ContractId, label: "Pool 2" },
] as const;
// After
const { lendingPool1, lendingPool2 } = getContracts();
const POOLS = [
{ key: "pool1", contractId: lendingPool1, label: "Pool 1" },
{ key: "pool2", contractId: lendingPool2, label: "Pool 2" },
] as const;
Step 4 β network-aware native wrapper
// Before
if (tokenAddress === tokens.XLM || tokenAddress === XLM_TESTNET_ADDRESS) {
// After
const { nativeWrapper } = getContracts();
if (tokenAddress === tokens.XLM || tokenAddress === nativeWrapper) {
Step 5 β align the env documentation
Set .env.example to TESTNET, matching the README and CLAUDE.md. Add a comment explaining that switching to PUBLIC also requires mainnet contract entries in the registry.
Step 6 β guard against regression
// apps/web-app/eslint.config.mjs
rules: {
"no-restricted-syntax": [
"error",
{
selector:
"MemberExpression[object.name='networks'][property.name='testnet']",
message:
"Do not read networks.testnet directly. Use getContracts() from " +
"lib/constants/contractsByNetwork.ts so the network is honoured.",
},
],
},
Plus a CI grep for bare contract literals outside the registry and the generated packages:
- name: No hardcoded contract IDs
run: |
if grep -rnoE '"C[A-Z2-7]{55}"' apps/web-app/src \
--exclude-dir=constants \
| grep -v 'contractsByNetwork.ts\|assets.config.ts'; then
echo "::error::Hardcoded contract ID outside the registry"
exit 1
fi
Step 7 β collapse the duplicated network module
Keep lib/constants/network.ts, delete lib/config/stellar.config.ts, move XLM_TESTNET_ADDRESS into the registry as nativeWrapper, and update all importers.
β
Requirements
- Add
mainnet (and standalone where meaningful) entries to the networks export of @neko/lending, @neko/oracle, @neko/backstop and @neko/defindex-vault β or explicitly omit them so the registry fails loudly.
- Introduce a single network-aware contract registry as the one source of truth for every protocol contract ID.
- Replace all 52
networks.testnet.* references with registry lookups.
- Replace the bare literals in
lib/constants/contracts.ts, features/vault/const/vaults.ts, lib/orchestrator/adapters/blend-config.ts, lib/strategy/definitions.ts, the vault hooks and components, and both vault API routes.
- Remove the ~30
= networks.testnet.contractId default parameters in lending.service.ts and helpers/stellar/lending.ts; require the caller to pass a resolved ID.
- Replace
XLM_TESTNET_ADDRESS with a network-aware nativeWrapper and update hooks/useTokenBalance.ts.
- Fail fast at startup when the selected network has no contract set, naming the network and pointing at the registry.
- Set
.env.example to TESTNET and reconcile it with the README and CLAUDE.md.
- Add an ESLint rule banning
networks.testnet outside the registry.
- Add a CI check failing on bare
C[A-Z2-7]{55} literals outside the registry.
- Collapse
lib/constants/network.ts and lib/config/stellar.config.ts into one module.
- Reconcile the two divergent oracle contract IDs.
π― Acceptance Criteria
π Expected files to change/structure
New
apps/web-app/src/lib/constants/contractsByNetwork.ts β registry + fail-fast resolution
apps/web-app/src/lib/constants/__tests__/contractsByNetwork.test.ts
Generated packages
packages/contracts/lending/src/index.ts
packages/contracts/oracle/src/index.ts
packages/contracts/backstop/src/index.ts
packages/contracts/defindex-vault/src/index.ts
Core config
apps/web-app/src/lib/constants/contracts.ts β re-export from the registry, drop bare literals
apps/web-app/src/lib/constants/network.ts β absorb stellar.config.ts
apps/web-app/src/lib/config/stellar.config.ts β delete
apps/web-app/src/lib/constants/faucet.ts β network-aware token list
Services and helpers
apps/web-app/src/lib/services/lending.service.ts β remove ~20 default params
apps/web-app/src/lib/helpers/stellar/lending.ts β remove ~10 default params
apps/web-app/src/lib/clients/oracle.ts
apps/web-app/src/lib/orchestrator/adapters/NekoLendingAdapter.ts
apps/web-app/src/lib/orchestrator/adapters/blend-config.ts
apps/web-app/src/lib/strategy/definitions.ts
Features
apps/web-app/src/features/borrowing/hooks/{useHealthFactor,useUserPosition,useBorrowPools,useBadDebt}.ts
apps/web-app/src/features/lending/hooks/useLendingPools.ts
apps/web-app/src/features/admin/constants.ts
apps/web-app/src/features/vault/const/vaults.ts
apps/web-app/src/features/vault/hooks/{useVaultData,useVaultBalance,useVaultAction}.ts
apps/web-app/src/features/vault/components/ui/VaultActionModal.tsx
apps/web-app/src/hooks/useTokenBalance.ts
API routes
apps/web-app/src/app/api/vault/invest/route.ts
apps/web-app/src/app/api/vault/apy/route.ts
Config and docs
apps/web-app/.env.example β default to TESTNET
apps/web-app/eslint.config.mjs β no-restricted-syntax rule
.github/workflows/ci.yml β hardcoded-contract-ID check
README.md, CLAUDE.md β align the documented default
π§ͺ Test plan
- Registry unit tests. Resolution for
TESTNET, PUBLIC, LOCAL, STANDALONE. Assert the unconfigured-network path throws, and that the message names the network.
- Parity test. Assert
getContracts() and getAssetsConfig() resolve via the same getNetworkId() β a regression test against the two drifting apart again.
- Snapshot the testnet set. Assert every resolved testnet ID equals the value currently hardcoded, so the migration is provably behaviour-preserving.
- Existing suite. The full vitest run must pass unchanged with
TESTNET.
- Native wrapper.
useTokenBalance recognises the wrapper on testnet and on mainnet.
- Lint rule. A fixture file using
networks.testnet fails lint.
- CI check. A fixture with a bare contract literal fails the CI grep.
- Manual smoke. Boot with
PUBLIC and confirm the startup error is clear and actionable rather than a downstream RPC failure.
π Rollout
- Land the registry with testnet only. Behaviour-identical, fully covered by the snapshot test. Low risk.
- Migrate call sites in batches by feature β borrowing, then lending, then vault, then orchestrator. Each batch keeps the suite green.
- Remove the default parameters last within each batch; that is the change most likely to surface a call site nobody remembered.
- Add the lint rule and CI check once the tree is clean, so they never fail on legacy code.
- Fix
.env.example early β it is a one-line change and it stops new developers walking into the mismatch while the rest is in flight.
- Populate mainnet entries when contracts are actually deployed. Until then, the fail-fast path is the correct behaviour and should be left in place.
π References
- Stellar docs β contract IDs are derived per network and are not portable across networks
apps/web-app/src/lib/constants/assets.config.ts β the in-repo pattern this issue generalises
- CWE-1188: Initialization of a Resource with an Insecure Default
Thank you for taking this issue! You are helping us make RWAs consumer friendly on Stellar.
π Description
The app treats the Stellar network as configurable.
NEXT_PUBLIC_STELLAR_NETWORKis validated as one ofPUBLIC | FUTURENET | TESTNET | LOCAL | STANDALONE(apps/web-app/src/lib/env.client.ts:26-33), the README documentsPUBLICas "Stellar mainnet (production)", and the token config genuinely honours it.Protocol contracts do not. Across 52 call sites the lending, oracle and backstop contract IDs are read from a hardcoded testnet literal, with no network branch at all. Combined with a committed
.env.examplethat selects mainnet, the default path a new developer or deployment follows produces an app that talks to mainnet RPC using testnet contract IDs β with every configuration validator passing.π The inconsistency
What works β
assets.config.tsis network-awareapps/web-app/src/lib/constants/assets.config.tsdoes exactly the right thing. It keys contracts by network and resolves at call time:This is the pattern the rest of the codebase should follow. It already exists and already works.
What does not β 52 hardcoded testnet references
Representative sites:
lib/helpers/stellar/lending.tsandlib/services/lending.service.tsaccount for the bulk of them, mostly as default parameter values β roughly 30 occurrences of:A default parameter is the worst place for this. It is invisible at the call site, so nothing in the calling code hints that a network assumption is being made.
The generated packages have no mainnet entry to select
Even a call site that wanted to branch could not:
testnetis the only key.@neko/backstopand@neko/defindex-vaultare the same.Bare literals outside the packages
Beyond
networks.testnet, contract IDs are inlined as string literals in several places with no network dimension:lib/constants/contracts.tsfeatures/vault/const/vaults.tsapp/api/vault/invest/route.tsapp/api/vault/apy/route.tslib/constants/faucet.tslib/orchestrator/adapters/blend-config.tslib/strategy/definitions.tsfeatures/vault/hooks/useVaultData.tsfeatures/vault/hooks/useVaultBalance.tsfeatures/vault/hooks/useVaultAction.tsfeatures/vault/components/ui/VaultActionModal.tsxlib/config/stellar.config.tsXLM_TESTNET_ADDRESSFor example:
And the native-wrapper address is hardcoded as testnet and compared unconditionally:
On mainnet the native wrapper is
CAS3J7GYβ¦OWMAβ asassets.config.tsalready knows β so this comparison silently stops matching.π£ Why this is likely to bite
The committed
apps/web-app/.env.exampleselects mainnet:Meanwhile
CLAUDE.mdand the README setup snippet both say:The two disagree. Copying
.env.exampleβ the normal thing to do, and what the filename invites β produces an app that:assets.config.ts,Nothing catches it. The zod env schema is satisfied β
PUBLICis a valid enum member and the URLs parse. No code cross-checks a contract ID against the selected network. The app boots cleanly and looks correct.π Impact
Silent, confusing breakage. Reads against a contract ID that does not exist on mainnet fail at the RPC layer. The UI surfaces this as empty pool lists,
nullhealth factors, and failed simulations β with no indication that the root cause is a network mismatch. This is the kind of bug that costs days because every individual symptom points somewhere else.Mixed-network transactions. Flows combining a mainnet token address from
assets.config.tswith a testnet pool ID build transactions that cannot succeed β but only after the user has been prompted to sign. Users signing transactions that are structurally invalid is a trust problem as much as a technical one.Address-collision risk. Soroban contract IDs are derived per network, so a testnet ID has no guaranteed meaning on mainnet. It is also not guaranteed to be unallocated. Sending real value to an address chosen for a different network is not a risk to carry into a mainnet launch.
Mainnet is currently unreachable. There is no value of
NEXT_PUBLIC_STELLAR_NETWORKthat produces a working mainnet deployment. This is a hard blocker on launch, not just a latent bug.Wallet passphrase mismatch.
NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASEfeeds the Stellar Wallets Kit. With the.env.exampledefaults, wallets are asked to sign against the mainnet passphrase for transactions built around testnet contracts.π Secondary finding β duplicated network module
apps/web-app/src/lib/constants/network.tsandapps/web-app/src/lib/config/stellar.config.tsare near-identical copies of the same module. Both exportstellarNetwork,networkPassphrase,rpcUrl,horizonUrl,labPrefix,networkToIdandnetwork, with the samestellarEncodehelper and the same switch statement. They differ only in thatstellar.config.tsaddsXLM_TESTNET_ADDRESSand omitsallowHttpForSoroban.Both are imported from live code. This duplication is plausibly how the network logic drifted out of sync in the first place, and it doubles the surface for the next drift. It should be collapsed as part of this work.
π Related β divergent oracle IDs
The operational scripts and the app point at two different oracle contracts:
Either the scripts are stale, or the app reads an oracle that nothing is updating. Worth resolving while consolidating contract configuration.
π Proposed fix
Step 1 β add mainnet entries to the generated packages
If mainnet contracts are not deployed yet, omit the key rather than filling it with testnet values, and let the registry below fail loudly. A missing entry is a good error; a wrong entry is a silent disaster.
Step 2 β one network-aware contract registry
Mirror the
ASSETS_BY_NETWORKshape that already works:Fail-fast is the core of the fix. Everything else is mechanical.
Step 3 β migrate the call sites
Remove the default parameters entirely rather than re-pointing them, so the network decision is explicit at the boundary:
Step 4 β network-aware native wrapper
Step 5 β align the env documentation
Set
.env.exampletoTESTNET, matching the README andCLAUDE.md. Add a comment explaining that switching toPUBLICalso requires mainnet contract entries in the registry.Step 6 β guard against regression
Plus a CI grep for bare contract literals outside the registry and the generated packages:
Step 7 β collapse the duplicated network module
Keep
lib/constants/network.ts, deletelib/config/stellar.config.ts, moveXLM_TESTNET_ADDRESSinto the registry asnativeWrapper, and update all importers.β Requirements
mainnet(andstandalonewhere meaningful) entries to thenetworksexport of@neko/lending,@neko/oracle,@neko/backstopand@neko/defindex-vaultβ or explicitly omit them so the registry fails loudly.networks.testnet.*references with registry lookups.lib/constants/contracts.ts,features/vault/const/vaults.ts,lib/orchestrator/adapters/blend-config.ts,lib/strategy/definitions.ts, the vault hooks and components, and both vault API routes.= networks.testnet.contractIddefault parameters inlending.service.tsandhelpers/stellar/lending.ts; require the caller to pass a resolved ID.XLM_TESTNET_ADDRESSwith a network-awarenativeWrapperand updatehooks/useTokenBalance.ts..env.exampletoTESTNETand reconcile it with the README andCLAUDE.md.networks.testnetoutside the registry.C[A-Z2-7]{55}literals outside the registry.lib/constants/network.tsandlib/config/stellar.config.tsinto one module.π― Acceptance Criteria
grep -rn "networks\.testnet" apps/web-app/srcreturns matches only inside the registry.grep -rnoE '"C[A-Z2-7]{55}"' apps/web-app/srcreturns matches only insidecontractsByNetwork.tsandassets.config.ts.NEXT_PUBLIC_STELLAR_NETWORK=TESTNET, every protocol contract resolves to its current testnet ID and behaviour is unchanged β verified against the existing test suite.NEXT_PUBLIC_STELLAR_NETWORK=PUBLIC, the app either resolves every protocol contract to a mainnet ID or fails at startup with an explicit "no protocol contracts configured for network mainnet" error. It never silently uses testnet IDs.assets.config.tsand the contract registry always resolve the same network β they sharegetNetworkId, so a mismatch is impossible by construction.useTokenBalancerecognises the native wrapper on both testnet and mainnet.lending.service.tsorhelpers/stellar/lending.tshas a contract ID default parameter..env.example,README.mdandCLAUDE.mdall specify the same default network.lib/config/stellar.config.tsis deleted and no importer references it.networks.testnetreference fails lint.TESTNET,PUBLIC,LOCALand an unconfigured network (throw path).π Expected files to change/structure
New
apps/web-app/src/lib/constants/contractsByNetwork.tsβ registry + fail-fast resolutionapps/web-app/src/lib/constants/__tests__/contractsByNetwork.test.tsGenerated packages
packages/contracts/lending/src/index.tspackages/contracts/oracle/src/index.tspackages/contracts/backstop/src/index.tspackages/contracts/defindex-vault/src/index.tsCore config
apps/web-app/src/lib/constants/contracts.tsβ re-export from the registry, drop bare literalsapps/web-app/src/lib/constants/network.tsβ absorbstellar.config.tsapps/web-app/src/lib/config/stellar.config.tsβ deleteapps/web-app/src/lib/constants/faucet.tsβ network-aware token listServices and helpers
apps/web-app/src/lib/services/lending.service.tsβ remove ~20 default paramsapps/web-app/src/lib/helpers/stellar/lending.tsβ remove ~10 default paramsapps/web-app/src/lib/clients/oracle.tsapps/web-app/src/lib/orchestrator/adapters/NekoLendingAdapter.tsapps/web-app/src/lib/orchestrator/adapters/blend-config.tsapps/web-app/src/lib/strategy/definitions.tsFeatures
apps/web-app/src/features/borrowing/hooks/{useHealthFactor,useUserPosition,useBorrowPools,useBadDebt}.tsapps/web-app/src/features/lending/hooks/useLendingPools.tsapps/web-app/src/features/admin/constants.tsapps/web-app/src/features/vault/const/vaults.tsapps/web-app/src/features/vault/hooks/{useVaultData,useVaultBalance,useVaultAction}.tsapps/web-app/src/features/vault/components/ui/VaultActionModal.tsxapps/web-app/src/hooks/useTokenBalance.tsAPI routes
apps/web-app/src/app/api/vault/invest/route.tsapps/web-app/src/app/api/vault/apy/route.tsConfig and docs
apps/web-app/.env.exampleβ default toTESTNETapps/web-app/eslint.config.mjsβno-restricted-syntaxrule.github/workflows/ci.ymlβ hardcoded-contract-ID checkREADME.md,CLAUDE.mdβ align the documented defaultπ§ͺ Test plan
TESTNET,PUBLIC,LOCAL,STANDALONE. Assert the unconfigured-network path throws, and that the message names the network.getContracts()andgetAssetsConfig()resolve via the samegetNetworkId()β a regression test against the two drifting apart again.TESTNET.useTokenBalancerecognises the wrapper on testnet and on mainnet.networks.testnetfails lint.PUBLICand confirm the startup error is clear and actionable rather than a downstream RPC failure.π Rollout
.env.exampleearly β it is a one-line change and it stops new developers walking into the mismatch while the rest is in flight.π References
apps/web-app/src/lib/constants/assets.config.tsβ the in-repo pattern this issue generalisesThank you for taking this issue! You are helping us make RWAs consumer friendly on Stellar.