Skip to content

feat(networks): support betanet and a runtime-configurable custom network - #1071

Merged
fmsouza merged 65 commits into
mainfrom
worktree-fmsouza+multi-network-support
Jul 31, 2026
Merged

feat(networks): support betanet and a runtime-configurable custom network#1071
fmsouza merged 65 commits into
mainfrom
worktree-fmsouza+multi-network-support

Conversation

@fmsouza

@fmsouza fmsouza commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Description

Adds betanet and a developer-configured custom network alongside mainnet/testnet, selectable in Settings → Developer → Node Settings. Replaces pnpm localnet:use, which rewrote build-time config and needed a rebuild.

  • Network widens to a 4-value unionmainnet | testnet | betanet | custom. The isMainnet ? x : y ladder in network-config.ts becomes a per-network chain table, and Record<Network, …> throughout means a future network fails to compile until someone fills its row.
  • betanet carries baked chain config (algonode endpoints, pinned genesis hash, Lora explorer). Its algod/indexer tokens are deliberately empty so Pera's injected credential isn't sent to third-party public endpoints.
  • custom carries no baked config. algodUrl, indexerUrl, optional tokens, genesisHash and genesisId all come from a runtime store filled in through a bottom sheet. Fetch from node reads the genesis values off the node so they don't have to be typed.
  • The custom sheet uses deferred commit. Selecting Custom opens the sheet without switching networks; only Save and switch validates, persists and switches. Cancel, backdrop and pan-down write nothing and leave the previous network active. The sheet is hosted by the app's shared bottom-sheet manager (@modules/bottom-sheet).
  • Switching the custom slot to a different chain (detected by genesis-hash change, not URL change) clears the custom partition of the local cache, so chain A's cached balances and transactions can't surface on chain B.
  • Devices that persisted fnet or localnet under an earlier version of this branch rehydrate to the default network instead of feeding an unknown string into the chain table.

Networks without a Pera backend

betanet and custom have no Pera backend deployment.

  • The sync loop does not depend on a Pera service. SyncService.checkShouldRefresh consults the backend's should-refresh endpoint to decide whether a tick has work. On these networks that call cannot succeed, so the gate is skipped entirely and the active network syncs every tick. Without this the thrown error propagated to the tick's catch, which engaged exponential backoff — killing algod/indexer polling that needs no Pera service at all. Caught by simulator testing: a funded account showed a stale balance indefinitely.

  • getNetworkConfig returns empty Pera-service values for them, and createFetchClient refuses a backend: 'pera' request whose backendUrl is empty, throwing a typed PeraServiceUnavailableError before ky is invoked. Nothing borrows another network's backend.

  • getArc59Config and getKnownAssetId return null off the Pera lane instead of TestNet's values; the ARC-59 build sites throw rather than aiming TestNet's inbox app id at another chain.

  • Feature entry points are not gated. Swap, buy, card, staking and inbox stay visible and fail when used. Node Settings shows a callout on every non-MainNet network saying support is limited.

  • The error is excluded from crash reporting — a service that isn't deployed is expected, not exceptional.

Chain truth

algodUrl, indexerUrl, genesisHash and genesisId always come from the real active network.

  • Asset metadata. assets_node (which holds decimals, and is what the send flow reads back) is sourced from the active chain's indexer. Ids whose chain lookup fails get no row rather than a wrong one.
  • Transaction history for betanet/custom reads the chain's indexer, including a client-side balance_impacts computation (signed per-asset deltas netted across inner transactions, fee attributed per node) that the indexer has no equivalent for.
  • Genesis is resolved by getExpectedGenesisHash(network), which is synchronous and network-free: baked config for mainnet/testnet/betanet, the store for custom. Chain identity is never taken from a node response on the signing path.
  • apps/extension/src/background/index.ts reads the store rather than the raw chain table, so custom no longer advertises genesisHash: '' to dApps over ARC-0027.

Related Issues

  • No ticket yet — needs a PERA-#### before merge, and the branch should be renamed to the fmsouza/pera-XXXX convention.

Checklist

  • Have you tested your changes locally?
  • Have you reviewed the code for any potential issues?
  • Have you documented any necessary changes in the project's documentation?
  • Have you added any necessary tests for your changes?
  • Have you updated any relevant dependencies?

Additional Notes

Verified on the iOS simulator against a real AlgoKit LocalNet pointed at via the custom slot:

  • All four rows render; the callout shows on TestNet and Custom, absent on MainNet.
  • Selecting Custom opens the sheet without switching networks; Save and switch commits.
  • Fetch from node pulled LocalNet's exact genesis hash and id. The failure path also rendered correctly when the node was down, and cleared on the next success.
  • Chain reads reach the custom node — LocalNet's indexer logged the app's requests — and the balance matched the chain to the microAlgo, updating live (7 → 10 ALGO) after an on-chain payment.
  • Reopening the sheet re-seeds the saved config; Cancel discards edits without persisting.
  • Repointing the slot at a different chain (genesis hash change) clears the custom cache partition.
  • No Pera-service errors are crash-reported on Custom, versus constant 401 error spam on TestNet in the same local build.

Still unverified: Android hardware back, and a real send. Note localhost works on the simulator but resolves to the device on physical hardware — use the host's LAN address there.

Worth a reviewer's attention:

  • packages/transactions/src/api/history/indexer/schema.ts — inner transactions carry no id (verified against the live indexer). The recursive node schema makes id optional while the row-level parse still requires it; getting this wrong silently drops every app call, swap, ARC-59 send and NFT mint from history. The tx-type enum is permissive for the same reason.
  • packages/transactions/src/api/history/indexer/balance-impacts.ts — the clawback debited party is the nested sender (msgpack asnd) inside asset-transfer-transaction, not a top-level field. Netting was reconciled against 12 real accounts to the exact microAlgo.
  • packages/blockchain/src/store/network-store.ts — the persist merge spreads over current state rather than returning the guard's result (which would drop the store's actions), and refuses to rehydrate custom when no config is saved; an unconfigured custom resolves every endpoint to '' and throws TypeError: Invalid URL during render.
  • Android hardware back now only blocks while the custom sheet is open — it no longer closes the sheet. That matches every other manager-hosted sheet in the app.

Known follow-ups (non-blocking):

  • acfg asset creation/destruction is unmodelled in computeBalanceImpacts, so a creator's account is off by exactly the asset supply. Invisible today because impacts render only for appl transactions.
  • packages/arc0027/src/router.ts advertises the baked genesis hash, so custom advertises ''. Advertisement only — a dApp building from it is rejected by the signing assertion.
  • packages/card/src/api/transport/baanx-client.ts builds ky with an empty prefix and no guard, so card onboarding on betanet/custom fails with a raw URL-parse TypeError rather than PeraServiceUnavailableError. Its escrow twin already guards.
  • A developer who ran an earlier build of this branch on betanet or custom may hold locally-cached rows written while the old fallback was live. Self-healing, not a migration: decimals lives only in assets_node, and getStaleOrMissingAssetIds keys on its updatedAt against ASSET_CACHE_TTL_MS.
  • apps/mobile/src/offscreen/runOffscreenApp.ts gained 'kv:custom-network-store' in REHYDRATE_BY_KEY so the unconfigured-custom guard doesn't demote a configured slot in the extension's offscreen context. That line ships uncovered — there is no unit harness for that bootstrap module.
  • DEFAULT_NETWORK=custom now fails config validation at load. Intended, but a behaviour change for anyone with that in a local .env.
  • dispenserUrl is a half-migration: mainnet reads a new config key, testnet reads the pre-existing global, betanet is a hardcoded literal. custom's dispenserUrl and explorerUrl are empty by design.

fmsouza added 26 commits July 28, 2026 11:17
…ervice fallback

Also updates env-loader.test.ts's mock Config literal and shared's
base-types.test.ts (a passthrough re-export of Networks/Network) to match
the widened 5-value union — both broke as a direct, mechanical consequence
of this change and are needed to keep the suite green.
Replaces the six hand-built ky client instances (mainnet/testnet ×
pera/algod/indexer) in packages/shared/src/api/query-client.ts with
factories driven by getNetworkConfig, looped over all 5 Networks. Fixes
"Could not get backends for betanet" (and fnet/localnet) now that Task 1
widened the Network union.

Also adds a deduped warning (warnPeraServiceFallbackOnce) that logs once
per network+method+path when `backend: 'pera'` traffic is served by a
borrowed network's backend (betanet/fnet/localnet borrowing testnet),
keyed via hasPeraServiceFallback/resolvePeraServiceNetwork from
@perawallet/wallet-core-config.
… time

Round 1 review findings on the ky-clients-per-network change:

- CRITICAL: the module-scope for-loop called getNetworkConfig() for all 5
  networks at import time. Any consumer whose test mocks getNetworkConfig as
  a bare vi.fn() (no default return) now crashes during suite collection —
  e.g. packages/card's lsig.spec.ts, which imports bytesToHex/decodeFromBase64
  from this package and pulls in query-client.ts's top-level code regardless.
  Replaced the eager loop with ensureClientsBuilt(), called on first actual
  use instead.

- IMPORTANT: made the lazy build a single shared gate (ensureClientsBuilt),
  called from both the request path and the top of updateBackendHeaders,
  rather than a per-network build-on-miss — the latter would let
  updateBackendHeaders silently skip networks nothing has requested yet.

- IMPORTANT: strengthened the per-network tests beyond "resolves without
  throwing" — now assert exact algod/indexer prefix and token per network
  (catching a cross-network swap or a global-token regression), and that
  betanet/fnet/localnet's pera client prefix is exactly TestNet's backend
  URL. Extended the fallback-warning test to cover a second endpoint on the
  same network (separate warning) and mainnet/testnet traffic (no warning).

- IMPORTANT: added a test locking updateBackendHeaders reaching every
  network even when called before any request has been made.

packages/card's test:unit goes from 1 failed/58 passed to 59 passed.
…constants

Finding 3 follow-up: the algod-token fixture used arbitrary placeholders
with no tie to any config field, and the only assertion on them was a
mutual-inequality check between two made-up strings — real regressions
(localnetAlgodToken truncated, or a network no longer reading
config.algodApiKey) would not have been caught.

- mainnet/testnet/betanet/fnet algodToken now route through one hoisted
  mockAlgodApiKey constant, also exposed as the mocked config.algodApiKey,
  so they can't silently drift apart from each other.
- localnet's algodToken is now the real 64-character dev token
  (packages/config/src/main.ts's productionConfig localnetAlgodToken),
  written as 'a'.repeat(64) rather than a pasted blob.
- Replaced the mutual-inequality-only assertion with real equality checks
  (localnet === the 64-a token, length === 64, mainnet === config.algodApiKey)
  plus keeps the inequality check as a secondary guard.

Test-fixture only; no production source changed. Verified the new
assertions catch a regression by temporarily corrupting the fixture
(truncating the token, diverging mainnet's from config.algodApiKey) and
confirming each fails before reverting.
…net swap

Finding 3 follow-up round 2: production reads localnet's algodToken AND
indexerToken from the SAME config.localnetAlgodToken
(packages/config/src/network-config.ts:100-101). Round 2's fixture had them
differ for localnet, contradicting production.

- mainnet/testnet/betanet/fnet indexerToken now route through one hoisted
  mockIndexerApiKey constant, also exposed as the mocked config.indexerApiKey.
- localnet's indexerToken is now the SAME mockLocalnetAlgodToken constant as
  its algodToken (was a separate placeholder literal).
- Added equality assertions matching algodToken's: localnet's indexerToken
  equals the 64-a token and has length 64; mainnet's equals
  config.indexerApiKey.
- Since localnet's algod/indexer tokens are now equal by design, a
  token-value comparison alone can no longer distinguish the two clients for
  that network. Added explicit header-name-presence assertions (the algod
  client's hook must set X-Algo-API-Token and must not set
  X-Indexer-API-Token; symmetric for indexer) so a swap of the two clients
  still fails even when their token values match.

Verified with a temporary, fully-reverted production-code experiment
(swapped the two header names in createChainClients): confirmed the
pre-existing per-network check already caught it for every network
including localnet, and confirmed the new explicit header-name assertions
independently catch it too when isolated from that pre-existing check.

Test-fixture only; no production source changed in this commit.
…n and gift-card

Six totality/silent-narrowing fixes now that Network spans five networks:
- analytics: resolveEventName (exported) switches from isTestnet to isMainnet
  so betanet/fnet/localnet get the t_ prefix instead of leaking unprefixed
  events into production dashboards.
- polling: LastRefreshedRounds is now Partial<Record<Network, ...>>, which
  surfaced a real bug in packages/background's SyncService — a network
  absent from the persisted map read as "already synced" (undefined !==
  null); both read sites now normalize with `?? null`.
- extension: parseActiveNetwork/ActiveNetwork widen from a hardcoded
  'mainnet' | 'testnet' union to the full Network type via a Networks-backed
  set, so betanet/fnet/localnet are recognized instead of silently coerced
  to mainnet.
- gift-card: useBidaliTransport's network params widen to Network, using
  isMainnet to pick the USDC catalogue (Bidali only has mainnet/testnet
  catalogues; fallback networks use testnet's).
- webview: trusted Bidali iframe origins are derived from
  Object.values(Networks) instead of two hardcoded entries.
…d per network, never-synced regression test, fnet balance-key coverage

Three findings from Task 3 review:
- config/extension: genesisId was a mainnet/testnet ternary in the
  extension's discover response, pairing a wrong chain identity with betanet/
  fnet/localnet's real genesisHash. genesisId is now real per-network chain
  data in packages/config (never resolved through the Pera service
  fallback, like genesisHash), and the extension just passes it through.
- background: regression test proving both sync-service.ts fix sites are
  load-bearing — a network absent from the persisted round map must still
  force-sync (neverSynced) and must send null, not undefined, in the
  should-refresh POST body. Verified by deliberately reverting each site in
  turn and watching the new test fail for the right reason.
- gift-card: covers computeBidaliBalances's isMainnetCatalogue branch for a
  non-mainnet/non-testnet network (fnet), which had zero coverage.
…ride-store

The "stores an override per network without touching the others" test never
wrote to a second network before asserting isolation, so it couldn't catch a
mutant that replaces the whole overrides map instead of merging into it.
Wires Task 4's node-override store into both client stacks so overrides
actually take effect: AlgoKit's AlgorandClient via a new
resolveChainEndpoints() (used by getAlgorandClient and useAlgorandClient),
and the ky-based algod/indexer instances via a new updateNodeEndpoints()
setter in shared, kept in sync through a one-directional
blockchain -> shared subscription. Tokens are never overridden — only
algod/indexer URLs. Also fixes createTimeoutBoundedAlgorandClient to read
the algod/indexer token from the per-network config it's given instead of
the global config, without which LocalNet's dev token was never sent and
every request would 401.
…rrent keys

Review round 1 finding: the node-override subscription iterated
Object.keys(state.overrides), so clearOverride/resetState (which delete the
key) never re-synced that network's ky clients back to baked config,
leaving them serving the stale overridden URL until app restart. Iterate
Object.values(Networks) unconditionally instead — resolveChainEndpoints
already falls back to baked config per network, so this is correct for
every transition (set / merge / clear / reset) for free.

Also adds coverage the reviewer found missing: an end-to-end test that
clearing an override re-syncs to baked endpoints (createAlgorandClient.spec.ts),
and a test proving useAlgorandClient's memo actually rebuilds the client
when the active network's override changes (useAlgorandClient.test.ts) —
previously nothing failed if `overrides` were dropped from its deps array.
Renames a query-client.test.ts assertion that overclaimed what it checks.
…calnet

LocalNet regenerates its genesis hash on every container reset and fnet's
changes on every network reset, so a build-time-baked value goes stale and
blocks all signing on those networks. MainNet and TestNet keep their
build-time-pinned identity — the two networks holding real value must never
take their chain identity from a runtime response.

assertTransactionsMatchNetwork now takes the expected hash as a parameter
instead of reading it from config, so the caller can supply either the
baked value (MainNet/TestNet) or the runtime-resolved one (betanet/fnet/
localnet). Resolution is cached per network+algodUrl, bounded by the same
read timeout as every other chain call, and falls back to the baked hash on
a failed fetch — except for localnet, which has no baked hash and must
throw rather than silently skip the check.
…olution

Fix round 1 for Task 6, addressing an opus mutation-testing review:

- Cover the TestNet half of the MainNet/TestNet short-circuit, not just
  mainnet. Dropping isTestnet() alone left the suite green, and Task 10's
  editable algod URL inputs make that branch reachable in practice — a host
  pointed at a mainnet node would let a MainNet transaction pass under a
  TestNet identity. Also extend baked-fallback coverage to betanet (only
  fnet was exercised before).
- Lock the createStandardAnalyzer call site: assert
  assertTransactionsMatchNetwork is called with the resolved hash, not just
  that it doesn't throw. A regression that reintroduces
  getNetworkConfig(network).genesisHash there — the exact localnet-blocked
  bug this task fixes — previously shipped green.
- Make the empty-hash rejection structural instead of conventional:
  assertTransactionsMatchNetwork now rejects an empty expectedGenesisHash
  outright (previously '' as input could satisfy '' === '' against a
  transaction with no genesisHash of its own), and
  resolveExpectedGenesisHash's MainNet/TestNet short-circuit can no longer
  return '' either.
- Add a short TTL to runtime-resolved cache entries so an
  `algokit localnet reset` (same URL, new genesis) self-heals within the
  session instead of requiring an app restart. Corrected the cache comment,
  which claimed a recreated container re-resolves — it doesn't, since the
  key is unchanged; only the TTL makes that true.
- GenesisUnresolvableError now extends BlockchainError instead of bare
  Error, matching every other domain error in this package, so .metadata
  survives an unwrapped rethrow.
- Default the resolver's fetch mock to reject in every test, so a broken
  short-circuit can never fall through to a real outbound request against
  production algod from a unit test.
- Normalize a trailing slash on the algod URL before appending the API
  path, matching TimeoutHttpClient's existing base-URL handling.
…lback networks

fetchAssetFromApis spread the Pera result after the indexer result, so on a
fallback network (betanet/fnet/localnet) borrowing TestNet's Pera backend, the
Pera lane's chain-intrinsic fields (decimals foremost) silently overrode the
real chain's indexer data for the same asset id. Wrong decimals feeds
displayUnitsToBaseUnits, so a send would build a transaction for the wrong
amount that then succeeds on chain.

withChainIntrinsics re-asserts name/unitName/decimals/totalSupply/creator from
the indexer on fallback networks only; Pera still supplies peraMetadata.
MainNet/TestNet merge order is untouched.
…k merge

The fallback-network merge-precedence test only asserted 3 of the 5 fields
withChainIntrinsics re-asserts (decimals, name, unitName). Dropping the
totalSupply/creator lines from the helper left the suite green, so a future
simplification back to three fields would silently let a fallback network's
Pera-borrowed creator and total supply win again. Add toEqual/toString
assertions for both, using the existing fixture divergence (no new mock
setup needed).
Pure function that nets per-asset balance changes for an address across a
transaction and its inner transactions, matching the shape of the Pera
backend's balance_impacts field. Needed because the indexer has no
equivalent field — betanet/fnet/localnet history (which has no Pera
backend) will compute it client-side instead (wired up in a follow-up task).

Corrects two unverified assumptions found while checking this against live
mainnet/fnet/betanet indexer data:
- The clawback debited-party field is `sender` (nested under
  asset-transfer-transaction), not `asset-sender` as commonly assumed — the
  real key is confirmed against the indexer's own OpenAPI schema and
  algosdk's wire-encoding map.
- Inner transactions do carry nonzero fees in practice; the recursive
  per-node attribution already handles this correctly since an inner
  sender never coincides with an outer signer, so this only needed a
  comment fix, not a logic change.
…afe string parsing

Review round 1 (2 Important, 1 Minor, both in types/comments — algorithm
confirmed correct):

- Widen fee/amount/asset-id/close-amount fields from `number | bigint` to
  `number | string | bigint`. The client's JSON parser
  (parsePrecisionSafeJson) deliberately surfaces uint64 values above
  2^53-1 as decimal strings rather than rounding them, and the declared
  type didn't account for that wire form. BigInt(value) already handled
  strings losslessly, so this is a pure type-widening fix plus a
  regression test with a real >2^53 fnet-scale amount passed as a string.
- Rewrite the rewards comment: betanet's early history (rounds ~357-2.68M)
  genuinely has nonzero participation rewards, so "no reward-bearing era"
  was false. The real, narrower reason balanceImpacts can ignore rewards
  is that its one non-test read site only reads it for txType === 'appl',
  and rewards are already 0 on every network by the time appl
  transactions exist. Documented as a UI-gating argument that needs
  revisiting if that read site's gating ever changes.
- Rewrite the fee comment: replaced the sampling-based "senders never
  coincided in testing" justification with the actual structural
  invariant — fee pooling is already resolved into each node's own `fee`
  by the time the indexer reports it, so per-node attribution is correct
  unconditionally, including the rekey-to-app case that would defeat the
  sampling argument.
… networks

Betanet/fnet/localnet borrow TestNet's Pera backend for everything except
transaction history: borrowed history would show TestNet's transactions
instead of the account's own, so a payment just sent on fnet would never
appear. On those networks, history is now fetched directly from the active
chain's indexer and mapped onto the Pera backend's existing response shape,
so every downstream transformer, hook and screen is unchanged.
…amount precision

Adds two cases the initial commit's tests didn't discriminate: a clawback row
(nested asset-transfer-transaction.sender vs. the outer tx sender) and an
amount above 2^53 that only round-trips losslessly through BigInt, not
Number(). Both were mutation-verified to fail when the corresponding
production code is broken.
…formIndexerAssetResponse coverage gap

Reverts the tx-type enum restriction added in the prior commit: the app has
deliberate generic-fallback rendering for transaction types it doesn't
specialize for (useTransactionListItem.ts, mapHistoryItemToDisplayableTransaction.ts),
so an unrecognized type must reach the row output instead of being dropped
as unparseable — 'stpf' is a real, currently-active type outside the enum.

Also adds unit coverage for transformIndexerAssetResponse (previously
untested anywhere in the repo, including a decimals: 0 case), and corrects
two code comments that had wrongly claimed that coverage already existed.
…editor

Node settings now lists all five networks (mainnet/testnet/betanet/fnet/
localnet), lets the developer pick the active one, and edit each network's
algod/indexer endpoints — overriding the store added in Task 4, which had
no UI writer yet. Malformed URLs are rejected before they ever reach the
store. Both native and web screens share a NodeSettingsRow subcomponent;
the web hook keeps its isSwitching behaviour but now returns the same
networks[] shape.

Also fixes two pre-existing gaps this task's tests exposed (Task 4 wired
the store internally but never exposed it past the package boundary or the
mobile test harness):

- packages/blockchain/src/index.ts didn't re-export useNodeOverrideStore /
  NodeEndpointOverride at all, even though store/index.ts already barreled
  it — the store was reachable only via relative imports inside the
  blockchain package's own tests.
- apps/mobile/vitest.setup.ts's global mocks were still 2-network-only
  (Networks: {mainnet, testnet} in the wallet-core-shared mock; no
  useNodeOverrideStore in the wallet-core-blockchain mock at all), so any
  mobile test iterating all five networks or touching the override store
  would have silently only ever seen two. Networks now lists all five;
  wallet-core-blockchain's mock imports the real node-override-store
  module directly (not through the package barrel, which would trigger
  algorandClient.ts's module-level store subscription for every test in
  the suite).

Verified additive/safe: full mobile unit suite (505 files/4036 tests),
mobile integration suite (101/347), blockchain package suite (32/294), and
the full monorepo `pnpm test` (103/103 tasks) all pass with no regressions.
useNodeSettingsRow's handleSave forwarded both algod/indexer drafts on every
save, regardless of which one the developer actually edited. Since the
screen-level saveEndpoints merges into the store, an edit-one-field save was
silently re-persisting the untouched field's currently-displayed value as a
permanent override — pinning it to whatever it happened to read at that
moment. For a field still tracking the baked default, that's a real bug:
a later release bumping the baked URL (fnet resets move endpoints without a
rebuild) would go unnoticed by a device with an unrelated field overridden,
with no escape via Reset (which clears the whole network, discarding the
field the developer actually meant to keep custom).

handleSave now diffs each input against the row's current committed value
and forwards only the field(s) that changed; validation/error display is
also scoped to changed fields only (an untouched field is never
re-litigated, so it can never block a save it isn't part of). Saving with
no changes at all is now a no-op.

Also adds a hook-level test (both the native and web screen hooks) pinning
setOverride's merge-not-replace semantics for two sequential single-field
saves on the same network — previously only covered by the store's own
spec, so a hook-level regression on this exact path had no test catching it.

Both fixes mutation-verified: reverting handleSave to the old
forward-both-unconditionally behavior fails 4 of 8 useNodeSettingsRow
tests; reverting setOverride to replace-not-merge fails both new
merge-vs-replace tests (native + web).
Task 13 of the multi-network-support feature: proves the assembled
client stack (not just individual units) routes each kind of request
to the right host once every layer is wired together.

  - chain reads (algod/indexer)      -> the ACTIVE network, never the
                                         Pera-service fallback
  - Pera-service reads (assets)       -> the TestNet backend when the
                                         active network borrows services
  - transaction history               -> the chain's own indexer, never
                                         the borrowed Pera backend
  - signing                           -> rejects a transaction carrying
                                         another chain's genesis hash

Observes MSW's request:start events to assert which host was reached,
rather than rendering a screen.
Consolidated fix wave from the final cross-task review of the
betanet/fnet/localnet support branch.

- transactions: inner transactions carry no `id` on the real indexer wire
  shape, so requiring it failed the inner node's parse, which failed the
  PARENT row and silently dropped every history row with an inner
  transaction (DeFi, swaps, ARC-59, NFT mints). `id` is now optional on the
  recursive node shape and required only at the row level; a dropped row
  now logs a warning with its id and the zod issue paths.
- transactions: indexer pagination now threads `assetId`/`afterTime`/
  `beforeTime`/`limit` through `fetchMoreTransactions` ->
  `fetchMoreIndexerTransactions`, which previously dropped them — the
  indexer's opaque next-token does not encode filters the way the Pera
  path's absolute next-URL does, so a fallback-network filtered list
  silently widened back to everything from page 2 onward.
- blockchain: `algorandClient.ts` now performs an explicit initial push of
  every network's resolved endpoints into the shared ky clients, deferred
  past module evaluation (not run inline, to avoid re-introducing the
  eager-getNetworkConfig-at-import hazard already fixed once for `shared`).
  Without it, a persisted node-endpoint override from a previous session
  never reached the indexer ky client after an app restart, since
  zustand's synchronous persist hydration completes before the override
  store's change-subscriber is even registered.
- config: betanet/fnet now get an empty-string algod/indexer token instead
  of Pera's real `algodApiKey`/`indexerApiKey`, which were being sent to
  public third-party endpoints (algonode.cloud, nodely.dev) Pera does not
  control. Also closes a related gap found while verifying this:
  `TimeoutHttpClient` (unlike the other two token-consuming call sites)
  did not skip the auth header for an empty token — it now omits the
  header key entirely rather than sending an empty-string value.
- walletconnect: `expectedChainId` is now resolved through a single
  `Record<Network, AlgorandChainId>` table instead of a testnet/mainnet
  ternary, at all three call sites. Previously every non-testnet network
  fell through to MainNet's id, so a correctly-configured betanet dApp
  (real id 416003) was rejected, and an fnet session wrongly accepted a
  dApp presenting MainNet's 416001.

Cheap cleanups: deduplicated `isValidEndpoint` into one platform-neutral
module shared by the native/web screen hooks and the row hook; removed
confirmed-dead exports (`IndexerTransactionsResponse`, the `.web.ts`
`NetworkRow` export, `LABEL_KEYS`/`isValidEndpoint` exports on the native
hook, `buildSourceMap` in the guardrails test helpers); corrected the
query-client dedupe-warning comment, which claimed each borrowed endpoint
is reported "exactly once" when the key includes the concrete URL, so
growth is per distinct URL.

Each of findings 1, 2, 3 and 5's new tests was verified load-bearing by
reverting the corresponding fix and confirming the test fails.
@fmsouza
fmsouza requested a review from a team as a code owner July 29, 2026 10:30
@fmsouza
fmsouza marked this pull request as draft July 29, 2026 10:31
Comment thread apps/mobile/src/__integration__/multi-network.test.tsx Fixed
Comment thread apps/mobile/src/__integration__/multi-network.test.tsx Fixed
Replaces the fnet/localnet network members with a single runtime-configurable
`custom` slot. fnet is a sequence (fnet1, fnet2, ...) that gets replaced
periodically and LocalNet's ports vary per developer, so neither had a stable
genesis hash or endpoint worth baking into config. Both are now instances of
"point the wallet at an arbitrary node", represented by `custom`.

`custom`'s chain fields (algodUrl, indexerUrl, genesisHash, genesisId,
explorerUrl, algodToken, indexerToken, dispenserUrl) are all empty strings in
packages/config, which is a leaf package and cannot read a store; a later
task overlays real values via resolvers in packages/blockchain. Pera-service
fields still resolve through the testnet lane via PERA_SERVICE_FALLBACK.

Beyond the config package itself, this also fixes every other direct
consumer whose Record<Network, ...> tables or literal Networks.fnet/localnet
references would otherwise fail to compile or silently misbehave:
walletconnect's chain-id table, the mobile network-label map and developer
node-settings hooks (native + web), and their tests. It also updates several
tests in other packages (shared, assets, transactions) that referenced the
real Networks object directly and would otherwise regress from a real
boolean/branch to a silently-wrong one now that `fnet`/`localnet` resolve to
`undefined` instead of throwing.

packages/blockchain's node-override-store, resolveGenesisHash, and
createAlgorandClient tests are deliberately left unchanged: they exercise
genesis resolution and the node-override store that a later task (config
store + genesis handling) owns, per the task brief.
fmsouza added 2 commits July 29, 2026 22:25
…t ''

resolveActiveNetwork read the baked chain table, whose `custom` row is empty by
design, so with custom active the service worker advertised genesisHash: '' and
genesisId: '' to every dApp through ARC-0027 discover and enable. A dApp
validating the wallet's chain identity either rejects the session or proceeds
with none established, and nothing surfaces the misconfiguration.

The SW cannot import blockchain, but it already reads the network store straight
out of chrome.storage.local by key — it now reads kv:custom-network-store the
same way. An unusable custom entry degrades to mainnet, the rule
parseActiveNetwork already applies to every value it cannot use.
…test comment

Spreading undefined in an object literal is a legal no-op, so getNetworkConfig
does not crash on an out-of-union network — it returns algodUrl: undefined and
the throw lands later at client construction. The guard is still correct; only
the stated failure was wrong. Mirrors the docstring wording.
@fmsouza fmsouza changed the title feat(networks): support betanet, fnet and localnet alongside mainnet/testnet feat(networks): support betanet and a runtime-configurable custom network Jul 29, 2026
fmsouza added 2 commits July 30, 2026 09:53
Two conflicts, both additive on each side:

- packages/config/src/main.ts — main wrapped `configSchema` in
  `z.object({...}).check(...)` for the new production-staging guard,
  re-indenting every field; this branch had added the betanet chain
  entries and widened `defaultNetwork`. Kept main's structure and
  re-indented this branch's additions into it. betanet's endpoints are
  algonode.cloud and `mainnetDispenserUrl` defaults to empty, so none
  of them trip the first-party staging guard.

- packages/transactions/src/hooks/__tests__/useTransactionHistoryQuery.test.ts
  — both sides appended tests at the same spot. Kept all three.

The lockfile merge was broken (a stale `expo@57.0.7` peer key for
drizzle-orm). Rebuilt it from main's lockfile plus only this branch's two
new `packages/blockchain` deps, rather than re-resolving, which dragged in
unrelated peer-resolution drift.
Closes the two CodeQL js/incomplete-url-substring-sanitization alerts on
the multi-network integration test (#43, #44).

`url.startsWith(base)` treats a host that merely *begins* with the
expected one as a match — `https://betanet-api.algonode.cloud.example.test`
passes — so it was both the weaker assertion and the pattern CodeQL flags.
`matchesBase` parses both sides and compares `origin`, plus the base's path
prefix for bases that carry one.

The "not the fallback" negative assertion also stops sniffing for the
`testnet-api` substring and now compares against
`getNetworkConfig(Networks.testnet).algodUrl`, the same read-don't-hardcode
rule the file already applies to the TestNet backend URL. Mutation-checked:
pointing it at the active network's algod fails 2 of the 8 tests, so it
still discriminates.

Also picks up an oxfmt 0.60 rewrap in useCustomNetworkSheet.ts, which the
oxlint/oxfmt bump on main requires.
@fmsouza
fmsouza marked this pull request as ready for review July 30, 2026 11:42
@wjbeau wjbeau self-assigned this Jul 30, 2026
@fmsouza
fmsouza marked this pull request as draft July 30, 2026 11:59

@wjbeau wjbeau left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pre-emptively did a review. Looks great so far

fmsouza added 12 commits July 30, 2026 14:05
…of borrowing TestNet's

getNetworkConfig gains a per-network Pera-services table whose betanet and
custom rows are all-empty, replacing the resolvePeraServiceLane indirection.
createPeraClient turns an empty backendUrl into a typed
PeraServiceUnavailableError thrown in beforeRequest, so no socket opens and
every consumer surfaces it through its existing error path.

Record<Network, PeraServices> rather than a Partial + fallback: a fifth
network now fails TypeScript until someone decides its services, instead of
silently resolving to TestNet's deployment.

pera-service-fallback.ts still stands for its other seven consumers; they
migrate in the following commits.
persistWithBorrowedPeraServices and persistPeraOpinionFields existed to split
a batch across assets_node (chain indexer) and assets_pera (borrowed Pera
backend). With Pera fetches now failing typed on these networks there is no
opinion data to fetch, so non-Pera networks take persistChainIntrinsics alone
— no wasted request attempt per sync tick.

withChainIntrinsics in useSingleAssetDetailsQuery is deliberately KEPT, only
retargeted to !isPeraBackedNetwork. The original plan called it dead too, but
it is dead only given the ky chokepoint in another package, and
fetchAssetFromApis is exported: the failure mode if that remote invariant ever
breaks is borrowed decimals building a wrong-amount transaction that succeeds
on chain. The syncer's equivalent safety is a local routing decision, so
collapsing that half loses nothing.

Drops one pre-existing syncer test that asserted assets_pera is populated on
betanet/custom — that is the accepted regression, not a gap.
…f TestNet's app id

getArc59Config resolved through the service lane, so an inbox send on
betanet or custom targeted TestNet's app id against the real chain's algod.
Group atomicity meant no funds moved, but it failed opaquely and was
ungated. It now returns null and the three build sites throw
PeraServiceUnavailableError.
getKnownAssetId resolved through the service lane, handing back TestNet's
USDC id on betanet and custom where it identifies nothing. Now nullable, with
the six consumers treating null as 'USDC is unavailable on this network'.

Nothing user-visible changes — every one of those features is Pera-backed and
already dead on these networks — it just stops a wrong asset id circulating.
…rdrail

Nothing borrows TestNet's backend any more, so the module, its test, and the
restrict-pera-service-fallback-importers guardrail have nothing left to do.
The guardrail pinned an importer allowlist to stop silent borrowing eroding;
with no borrowing there is nothing to erode.

history/endpoints.ts flips from hasPeraServiceFallback to
!isPeraBackedNetwork — the exact negation over this union, so behaviour is
unchanged. expectedChainId's custom -> testnet mapping is unchanged too; only
its rationale, which cited the deleted table, is rewritten.
…are limited

Reuses InfoCallout, the same pattern SettingsPasskeysScreen uses, below the
network rows on both the native and extension variants. The condition lives
in the hook so it is unit-testable; module screens are covered by integration
tests.

Copy has to hold for TestNet and betanet/custom from one box, and they
differ — TestNet has real Pera services, the other two have none — hence
'limited on TestNet, and unavailable on BetaNet and custom nodes'.
ky builds the Request synchronously in its constructor, before it runs
beforeRequest. A hookless, prefix-less client therefore never reached the
throwing hook at all: a spec-compliant Request (undici, and where Expo's
fetch is heading) rejected the relative URL with a bare TypeError, which
createFetchClient's catch then normalized into the generic
PeraNetworkError('unknown') the typed error exists to avoid. It only
appeared to work on device because whatwg-fetch's Request stores the URL
string unparsed.

Move the refusal into createFetchClient, where the pera client is selected,
so no ky call happens at all for a network with an empty backendUrl.
createPeraClient now builds uniformly, and the test's isolated-stub
machinery — which existed only to keep the hookless client apart — is gone.

The integration test is the only place real ky runs, so its
`.rejects.toThrow()` also passed for the TypeError path. It now pins
PeraServiceUnavailableError by class.
isTransientNetworkError returns false for PeraServiceUnavailableError — it
is neither a PeraNetworkError nor a ky error — so QueryProvider's cache
handlers routed it to logger.error, i.e. an errorReporter non-fatal plus a
dev RedBox. useCurrenciesQuery alone is ungated and mounts on any screen
showing a fiat value, so a developer on betanet got one per query.

Adds isPeraServiceUnavailableError as a sibling predicate and skips
reporting on it in both caches. isTransientNetworkError keeps its meaning:
a missing deployment is permanent, not transient, and folding it in would
also mislead retry logic.
The stubs handed back '' (global vitest.setup) or another network's id
(resolveSwapRouteAssets, the card/onramp/bidali specs), and '' is falsy but
not null — so every one of them routed straight PAST the `=== null` guards
Task 4 added rather than merely under-covering them.

Mocks now mirror the real getKnownAssetId, and each guarded call site gets a
null-branch test: resolveSwapRouteAssets returns null (and still resolves an
explicit pair), resolveDestinationAssetId returns null for USDC off-lane,
useOnrampConfirm bails before opt-in, useCardAddFundsScreen neither deposits
nor navigates, useCardConfirmSwapScreen keeps the swap disabled, and
useBidaliTransport refuses the USDC transfer as an unsupported protocol.
asset-syncer still called the indexer path "the fallback path";
useSingleAssetDetailsQuery's withChainIntrinsics opened on a premise its
guard now contradicts (Pera services are no longer borrowed, so its value is
defence-in-depth against that invariant breaking); two test names and the
merge-precedence comment said "fallback network"; and the web iframe origin
list claimed those networks resolve to TestNet's Bidali origin when they in
fact contribute inert '' entries. No behaviour changes.

Also drops asset-syncer's inline arrow, which shadowed the outer `network`
only to hide a third argument — persistChainIntrinsics assigns directly with
no cast.
Move CustomNetworkSheet off a directly-mounted PWBottomSheet and onto the
app's shared bottom-sheet manager, fixing three problems the old code
documented in its own comments:

- `<CustomNetworkSheet sheet={sheet} />` passed live values as props,
  which the manager's contract explicitly forbids (props at request time
  must be identifiers, not live state).
- A hand-rolled BackHandler effect existed only because this sheet wasn't
  in the manager's store. The manager's useBlockHardwareBackWhileSheetOpen
  blocks hardware back from popping the screen behind any sheet it hosts,
  so that hand-rolled effect is gone — though unlike it, the manager does
  not dismiss the sheet on back (it only swallows the back press), so back
  now does nothing visible instead of acting like Cancel. That matches how
  every other manager-hosted sheet in the app already behaves, and there is
  no state-safety difference either way, since nothing commits outside
  handleSave regardless of how the component unmounts.
- The sheet no longer needs its own isOpen/open/close state — the manager
  owns visibility, and mounting the component IS the open.

useCustomNetworkSheet now seeds its draft from the persisted config via a
lazy useState initializer (the component mounts fresh per request instead
of toggling a persistent instance), and handleSave resolves a boolean so
the component can decide whether to resolve() the sheet closed. The commit
sequence in handleSave (validate -> clear cache -> persist -> switch
network -> restart sync) is unchanged.

The two screen hooks are renamed .ts -> .tsx since they now request the
sheet inline (`request({ contents: <CustomNetworkSheet /> })`). Requested
with `size: 'modal', autoCreateContainer: false`, matching the README's
guidance for a bounded sheet whose body must scroll internally, and the
existing idiom for comparably tall forms (e.g. AddParticipantContent).

sheetContent's padding is split into paddingHorizontal/paddingTop only
(dropping the bottom-affecting `padding` shorthand) so PWScrollView's own
inset-aware paddingBottom actually applies inside the sheet, per the
README's rule 2 — the flat padding was silently overriding it.
@fmsouza
fmsouza marked this pull request as ready for review July 30, 2026 16:55
fmsouza added 3 commits July 30, 2026 19:15
…, custom)

checkShouldRefresh hit the Pera backend every tick after the first, and
networks without a Pera deployment (betanet, custom) have no should-refresh
endpoint — the request threw PeraServiceUnavailableError, and the rethrow
engaged the tick's backoff up to 30s, eventually stalling sync entirely even
though algod/indexer were perfectly reachable. Short-circuit with
isPeraBackedNetwork before issuing the request, mirroring the existing
hasAuthError degradation.

Reproduced on the iOS simulator against AlgoKit LocalNet via the custom
network slot: balance stuck at 0.00 through a pull-to-refresh with zero
outbound requests over 55s, while switching to TestNet resumed polling
immediately.
Drop the bordered "card" look (rowContainer's border/padding) the row
picked up in this branch — main rendered a bare PWRadioButton directly in
the column. NodeSettingsRow keeps its unstyled wrapping PWView solely so
on-device automation retains both `node_settings_${network}_row` and
`node_settings_${network}_radio` testIDs; visually it now matches main.
Object.values(Networks) followed the testnet-first declaration order in
packages/config, so TestNet rendered above MainNet. Add an explicit
NETWORK_DISPLAY_ORDER (MainNet, TestNet, BetaNet, Custom) to both the
native and web screen hooks instead of reordering the shared Networks
enum, which other code may depend on.
One conflict, in packages/asa-inbox/src/hooks/useArc59SendTransaction.ts:
main's #1094 (PERA-4710) and this branch both edited the same import block
and the same ARC-59 config lookup.

Resolution keeps both sides:
- main's getArc59SignedFundingAmount(summary) — the single bounded funding
  total that the summary screen's display and balance-check now share, so the
  amount shown can't diverge from the amount signed.
- this branch's requireArc59Config(network), which supersedes main's inline
  `isMainnet ? config.arc59.mainnet : config.arc59.testnet`. The helper does
  the same lookup via getArc59Config and additionally throws
  PeraServiceUnavailableError on networks where the inbox app is not deployed,
  instead of aiming TestNet's app id at another chain.

The `config` import main added is consequently unused here and is dropped.

Also required `pnpm install`: main added the @perawallet/wallet-core-analytics
workspace package, which this worktree's node_modules predated, so
build:packages failed on wallet-core-device until it was linked.
@fmsouza
fmsouza merged commit bcf5d4e into main Jul 31, 2026
15 checks passed
@fmsouza
fmsouza deleted the worktree-fmsouza+multi-network-support branch July 31, 2026 09:13
fmsouza added a commit that referenced this pull request Jul 31, 2026
Brings in 12 commits, notably #1071 (betanet + runtime-configurable custom
network) and #1058 / PERA-4670 (migrated device-id telemetry).

Three textual conflicts, plus one silent semantic break that auto-merged
cleanly and would have shipped red:

1. `packages/device/src/hooks/useDevice.ts` — a genuine feature collision, not
   a textual one. Both sides rewrote the same file from the same base:
   feat/quantum migrated registration to Device API v3 (PERA-4705, #1078) and
   main added migrated-id origin tracking plus orphaning telemetry (PERA-4670,
   #1058). Kept v3's architecture and ported PERA-4670's telemetry onto its
   recreate path. Two deliberate adaptations, both commented in place:
     - PERA-4670 guarded the emit on an in-flight attempt id to be sure the
       write was still current. v3 does not need it: every registration is
       chained through `enqueueRegistration`, so one task runs per network and
       a recreate cannot be superseded mid-flight.
     - `reason` is now always `not_found`. v3's `shouldRecreateDevice` matches
       404 only, because a 400 is a push-token race that retries with the SAME
       id rather than re-creating, so `device_already_exists` can no longer
       replace an id and is no longer a replacement reason.

2. `packages/device/src/hooks/__tests__/useDevice.test.ts` — kept v3's suite
   and ported PERA-4670's telemetry tests onto v3's mocks. The two tests
   asserting `device_already_exists` as a replacement reason were dropped as
   unreachable under v3 (see above), and one was added in their place
   asserting the inverse: a push-token race leaves a migrated id and its
   origin untouched.

3. `README.md` — main removed `localnet:use`/`localnet:unset`,
   `tools/use-localnet.sh` and `.env.localnet`, replacing the env-overlay flow
   with the runtime custom-network UI. Took main's snippet, kept the quantum
   funding line (`localnet:fund --new-quantum` still exists), and rewrote the
   quantum-verification section, which still told the reader to run the
   now-deleted `pnpm localnet:use`.

4. `packages/shared/src/api/__tests__/query-client.test.ts` — NOT flagged as a
   conflict; git combined both sides' edits into a file that no longer worked.
   #1071 made client construction lazy ("NEVER at module import time", see
   `ensureClientsBuilt`) while PERA-4705 had strengthened this test to read the
   real `setStandardHeaders` off `ky.create.mock.calls[0]`. Post-merge nothing
   was created at import, so `calls[0]` was undefined. Fixed by triggering a
   request first and locating the pera client by prefix instead of call order —
   keeping the strong header assertions rather than reverting to main's
   `expect(mockKy).toHaveBeenCalled()`.

Verified on the merged tree: `turbo run build` 53/53 (incl. mobile
`tsc --noEmit`), and `turbo run test:unit --force` 100/100 uncached with zero
failures.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants