Skip to content

fix(web-app): protocol contract IDs hardcoded to testnet while the network is configurable β€” .env.example ships mainnetΒ #281

Description

@aguilar1x

πŸ“ 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

  • grep -rn "networks\.testnet" apps/web-app/src returns matches only inside the registry.
  • grep -rnoE '"C[A-Z2-7]{55}"' apps/web-app/src returns matches only inside contractsByNetwork.ts and assets.config.ts.
  • With NEXT_PUBLIC_STELLAR_NETWORK=TESTNET, every protocol contract resolves to its current testnet ID and behaviour is unchanged β€” verified against the existing test suite.
  • With 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.ts and the contract registry always resolve the same network β€” they share getNetworkId, so a mismatch is impossible by construction.
  • useTokenBalance recognises the native wrapper on both testnet and mainnet.
  • No function in lending.service.ts or helpers/stellar/lending.ts has a contract ID default parameter.
  • .env.example, README.md and CLAUDE.md all specify the same default network.
  • lib/config/stellar.config.ts is deleted and no importer references it.
  • A newly introduced networks.testnet reference fails lint.
  • A newly introduced bare contract literal fails CI.
  • Unit tests cover contract resolution for TESTNET, PUBLIC, LOCAL and an unconfigured network (throw path).
  • The scripts and the app reference the same oracle contract ID.

πŸ“ 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

  1. Registry unit tests. Resolution for TESTNET, PUBLIC, LOCAL, STANDALONE. Assert the unconfigured-network path throws, and that the message names the network.
  2. Parity test. Assert getContracts() and getAssetsConfig() resolve via the same getNetworkId() β€” a regression test against the two drifting apart again.
  3. Snapshot the testnet set. Assert every resolved testnet ID equals the value currently hardcoded, so the migration is provably behaviour-preserving.
  4. Existing suite. The full vitest run must pass unchanged with TESTNET.
  5. Native wrapper. useTokenBalance recognises the wrapper on testnet and on mainnet.
  6. Lint rule. A fixture file using networks.testnet fails lint.
  7. CI check. A fixture with a bare contract literal fails the CI grep.
  8. Manual smoke. Boot with PUBLIC and confirm the startup error is clear and actionable rather than a downstream RPC failure.

πŸš€ Rollout

  1. Land the registry with testnet only. Behaviour-identical, fully covered by the snapshot test. Low risk.
  2. Migrate call sites in batches by feature β€” borrowing, then lending, then vault, then orchestrator. Each batch keeps the suite green.
  3. Remove the default parameters last within each batch; that is the change most likely to surface a call site nobody remembered.
  4. Add the lint rule and CI check once the tree is clean, so they never fail on legacy code.
  5. 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.
  6. 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.

Metadata

Metadata

Assignees

Labels

Stellar WaveIssues in the Stellar wave programbugSomething isn't workingrefactor

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions