feat(networks): support betanet and a runtime-configurable custom network - #1071
Merged
Conversation
…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
marked this pull request as draft
July 29, 2026 10:31
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.
…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.
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
marked this pull request as ready for review
July 30, 2026 11:42
fmsouza
marked this pull request as draft
July 30, 2026 11:59
wjbeau
reviewed
Jul 30, 2026
wjbeau
left a comment
Contributor
There was a problem hiding this comment.
Pre-emptively did a review. Looks great so far
…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
marked this pull request as ready for review
July 30, 2026 16:55
…, 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.
wjbeau
approved these changes
Jul 30, 2026
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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.Networkwidens to a 4-value union —mainnet | testnet | betanet | custom. TheisMainnet ? x : yladder innetwork-config.tsbecomes a per-network chain table, andRecord<Network, …>throughout means a future network fails to compile until someone fills its row.customcarries no baked config.algodUrl,indexerUrl, optional tokens,genesisHashandgenesisIdall 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.@modules/bottom-sheet).custompartition of the local cache, so chain A's cached balances and transactions can't surface on chain B.fnetorlocalnetunder 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
customhave no Pera backend deployment.The sync loop does not depend on a Pera service.
SyncService.checkShouldRefreshconsults the backend'sshould-refreshendpoint 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'scatch, 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.getNetworkConfigreturns empty Pera-service values for them, andcreateFetchClientrefuses abackend: 'pera'request whosebackendUrlis empty, throwing a typedPeraServiceUnavailableErrorbefore ky is invoked. Nothing borrows another network's backend.getArc59ConfigandgetKnownAssetIdreturnnulloff 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,genesisHashandgenesisIdalways come from the real active network.assets_node(which holdsdecimals, 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.customreads the chain's indexer, including a client-sidebalance_impactscomputation (signed per-asset deltas netted across inner transactions, fee attributed per node) that the indexer has no equivalent for.getExpectedGenesisHash(network), which is synchronous and network-free: baked config for mainnet/testnet/betanet, the store forcustom. Chain identity is never taken from a node response on the signing path.apps/extension/src/background/index.tsreads the store rather than the raw chain table, socustomno longer advertisesgenesisHash: ''to dApps over ARC-0027.Related Issues
PERA-####before merge, and the branch should be renamed to thefmsouza/pera-XXXXconvention.Checklist
Additional Notes
Verified on the iOS simulator against a real AlgoKit LocalNet pointed at via the
customslot:customcache partition.Still unverified: Android hardware back, and a real send. Note
localhostworks 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 noid(verified against the live indexer). The recursive node schema makesidoptional 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. Thetx-typeenum is permissive for the same reason.packages/transactions/src/api/history/indexer/balance-impacts.ts— the clawback debited party is the nestedsender(msgpackasnd) insideasset-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 persistmergespreads over current state rather than returning the guard's result (which would drop the store's actions), and refuses to rehydratecustomwhen no config is saved; an unconfiguredcustomresolves every endpoint to''and throwsTypeError: Invalid URLduring render.Known follow-ups (non-blocking):
acfgasset creation/destruction is unmodelled incomputeBalanceImpacts, so a creator's account is off by exactly the asset supply. Invisible today because impacts render only forappltransactions.packages/arc0027/src/router.tsadvertises the baked genesis hash, socustomadvertises''. Advertisement only — a dApp building from it is rejected by the signing assertion.packages/card/src/api/transport/baanx-client.tsbuilds ky with an empty prefix and no guard, so card onboarding on betanet/customfails with a raw URL-parseTypeErrorrather thanPeraServiceUnavailableError. Its escrow twin already guards.custommay hold locally-cached rows written while the old fallback was live. Self-healing, not a migration:decimalslives only inassets_node, andgetStaleOrMissingAssetIdskeys on itsupdatedAtagainstASSET_CACHE_TTL_MS.apps/mobile/src/offscreen/runOffscreenApp.tsgained'kv:custom-network-store'inREHYDRATE_BY_KEYso the unconfigured-customguard 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=customnow fails config validation at load. Intended, but a behaviour change for anyone with that in a local.env.dispenserUrlis a half-migration: mainnet reads a new config key, testnet reads the pre-existing global, betanet is a hardcoded literal.custom'sdispenserUrlandexplorerUrlare empty by design.