feat(frontend): custom evm networks rpc provider#12498
Draft
feat(frontend): custom evm networks rpc provider#12498
Conversation
Introduces the data contract for user-added EVM chains as the first step toward MetaMask-style "Add custom network" support. The `CustomEvmNetwork` type is kept separate from `EthereumNetwork` because custom chains take a different runtime path (generic JSON-RPC, no Infura/Alchemy, no CoinGecko/onramp integration). The `Evm` naming reinforces the distinction at call sites. Two shapes are defined: `CustomEvmNetwork` (runtime, with a branded `NetworkId` symbol and `bigint` chainId) and `PersistedCustomEvmNetwork` (localStorage-serializable, with a stringified chainId and no symbol). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Writable Svelte store backed by localStorage, exposing add / update / remove / reset for user-added EVM networks. Serialization drops the non-JSON `NetworkId` symbol and re-derives it deterministically from `chainId` on load via a module-level cache, keeping symbol identity stable across subscribers within a session. Malformed or schema-incompatible persisted values fall back to an empty list rather than throwing, matching the defensive posture of the existing `initStorageStore` pattern. `add` rejects duplicate chainIds; `update` throws when the chainId is not present; `remove` is a no-op on missing entries. Part of the MetaMask-style custom EVM networks feature (storage layer only — UI and provider wiring come in follow-up PRs). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Covers initialization (empty / hydrated / malformed / schema-invalid), stable `NetworkId` symbol derivation across reloads, and the full CRUD surface (add / update / remove / reset) including duplicate rejection and persistence shape. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Provider class mirroring the method surface of `InfuraProvider` (balance, getFeeData, estimateGas / safeEstimateGas, sendTransaction, getTransactionCount, getBlockNumber) but backed by a generic `ethers.JsonRpcProvider` — no Infura/Alchemy integration. The provider is constructed with `staticNetwork` from the user-supplied `chainId`, avoiding an `eth_chainId` probe on every request; chain-ID verification is done once at add time via a separate service (introduced in a follow-up commit). Also adds a module-level factory `customRpcProviders(network)` that caches providers per `(chainId, rpcUrl)` pair, so edits to either field produce a cache miss and stale providers never serve traffic. Part of the MetaMask-style custom EVM networks feature (provider layer only — UI wiring comes in a follow-up PR). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Covers constructor wiring (URL, static Network, chainId), delegation of each method to the underlying `JsonRpcProvider`, `safeEstimateGas` happy and error paths (including the analytics event emitted on failure), and factory caching semantics — same `(chainId, rpcUrl)` returns a cached instance, either field changing produces a fresh provider. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Probes an RPC endpoint with `eth_chainId` (via ethers' `getNetwork`) and compares against the value the user entered on the "Add network" form. Mirrors the MetaMask safety check that prevents a phishing RPC from masquerading as a known chain. Returns a discriminated union (`ok` / `mismatch` / `unreachable`) so the UI can surface each failure mode distinctly. A throwaway provider is constructed without `staticNetwork` (so ethers actually issues the probe) and destroyed in `finally` to release network resources. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Covers the three result variants (`ok`, `mismatch`, `unreachable`) including the case where constructing the provider itself throws. Also verifies the provider is destroyed when the probe completes and is not created (so nothing to destroy) when construction fails. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Adds runtime support for user-defined EVM networks by introducing a JSON-RPC-backed provider and an RPC safety check that validates eth_chainId against the user-entered chain ID, alongside the persisted custom-network contract/store needed to supply that runtime config.
Changes:
- Added
CustomRpcProvider+customRpcProviders(network)factory with(chainId, rpcUrl)caching for custom-network JSON-RPC calls. - Added
verifyChainId({ rpcUrl, expectedChainId })returningok/mismatch/unreachablefor UI-friendly RPC validation. - Introduced custom EVM network schema/types + localStorage-backed store, with unit tests for store/provider/service behavior.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| src/frontend/src/eth/providers/custom-rpc.providers.ts | Implements JSON-RPC provider wrapper for custom chains and a cached factory. |
| src/frontend/src/eth/services/chain-id-verification.services.ts | Adds RPC chain-id probe to prevent mismatched/phishing endpoints. |
| src/frontend/src/eth/schema/custom-network.schema.ts | Defines runtime and persisted Zod schemas for custom EVM networks. |
| src/frontend/src/eth/types/custom-network.ts | Exposes inferred TS types for custom network shapes. |
| src/frontend/src/eth/stores/custom-evm-networks.store.ts | Adds localStorage-backed Svelte store for custom networks. |
| src/frontend/src/tests/eth/providers/custom-rpc.providers.spec.ts | Unit tests for provider wiring, delegation, safeEstimateGas, and cache behavior. |
| src/frontend/src/tests/eth/services/chain-id-verification.services.spec.ts | Unit tests for verifyChainId result modes and provider cleanup. |
| src/frontend/src/tests/eth/stores/custom-evm-networks.store.spec.ts | Unit tests for store hydration/CRUD/persistence semantics. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Runtime schema requires a positive chainId but the persisted schema previously accepted "0", letting invalid data sneak through hydration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…change When the chainId is not present, the filter is a no-op; short-circuit so we do not issue a redundant localStorage write on every removal attempt. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
loadFromStorage drops the entire persisted list when schema validation fails, so an invalid write from add/update would wipe all custom networks on the next reload. Run CustomEvmNetworkSchema.safeParse on the computed entry and throw on failure, preserving the invariant that persisted entries are always schema-valid. Also align the add persist assertion with the remove one by including the undefined iconUrl key in the expected payload. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
loadFromStorage previously returned all persisted entries verbatim. If localStorage ever contained duplicate chainIds (manual edit, stale data from an older buggy write) the resulting store would violate its internal uniqueness invariant: update would only patch the first match, remove would drop all matches, and add would reject the chainId permanently. Dedupe deterministically (keep first) during hydration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
add previously computed and cached a NetworkId via toNetworkId before schema validation. An invalid chainId (e.g. 0n) would throw after the cache entry was populated, leaving a stale symbol in networkIdCache forever. Add CustomEvmNetworkInputSchema (CustomEvmNetworkSchema without the derived id) and run it against the raw input; only compute the NetworkId once validation passes, so the cache never gains entries for inputs that will be rejected. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Match the repo pattern (e.g. UtxosFeeStore, FeeRatePercentilesStore) by having CustomEvmNetworksStore extend Readable<CustomEvmNetwork[]>. The hand-rolled subscribe signature narrowed the type by omitting Svelte's optional invalidate callback and made the store harder to pass to helpers expecting a standard Readable. Also document why add validates the pre-id input schema rather than the full CustomEvmNetworkSchema: the derived id is deterministic given a valid chainId, so a second pass would be redundant. The pre-id check keeps the networkIdCache from accumulating entries for inputs that will be rejected. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
CustomEvmNetworkInput is already Omit<CustomEvmNetwork, 'id'>; the extra Omit was a no-op that only obscured the expected shape. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…etworksStore Two defense-in-depth changes on the store's write paths: - add and update now build the stored entry from parsed.data rather than the raw input/merged object. Zod strips unknown keys by default, so parsed.data is the canonical schema shape; the prior code left any caller-supplied extra properties in the in-memory state. - update explicitly pins id and chainId to the existing entry's values before validation. TypeScript already excludes these from CustomEvmNetworkPatch, but an `as any` caller could previously sneak them in; the schema would not catch an id that no longer corresponds to its chainId. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…istence The generic UrlSchema permits the ipfs: protocol because it also validates asset URLs (icons, metadata). An ipfs URL is not a valid JSON-RPC endpoint, so the schema previously accepted values that would inevitably fail at the first eth_* call. Add a dedicated RpcUrlSchema that only allows https:/wss: and use it for rpcUrl in both the runtime and persisted schemas. Other URL fields (explorerUrl, iconUrl) keep the more permissive UrlSchema. Also extend the update CRUD test to assert the modified list is persisted to localStorage, not just mutated in memory. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…protocols The docstring and test name referred to "ws(s)" but the schema only allows wss:. Plain ws: is deliberately rejected (matching the existing no-plain-http policy); update the wording to reflect that rather than loosen the schema. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Collaborator
Author
|
Recommendation: land (c)+(d) together (they're the same "cache lifecycle" story), and (e) separately as a small safety fix. |
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.
Motivation
Stacked on top of #12497.
The custom EVM networks feature needs a runtime RPC path that does not depend on Infura/Alchemy, plus a safety check that rejects RPC endpoints whose
eth_chainIddoes not match what the user entered (same behavior MetaMask applies on "Add network"). This PR adds both — pure runtime wiring, no UI yet.Changes
CustomRpcProvider(src/frontend/src/eth/providers/custom-rpc.providers.ts) mirroring the method surface ofInfuraProvider(balance, getFeeData, estimateGas / safeEstimateGas, sendTransaction, getTransactionCount, getBlockNumber), backed byethers.JsonRpcProviderwith astaticNetworkbuilt from the user-suppliedchainId.customRpcProviders(network)factory with a(chainId, rpcUrl)-keyed cache so edits to either field produce a cache miss and stale providers never serve traffic.verifyChainId({ rpcUrl, expectedChainId })(src/frontend/src/eth/services/chain-id-verification.services.ts) returning a discriminated union (ok/mismatch/unreachable) so the UI can surface each failure mode distinctly.Tests
Unit tests for
CustomRpcProvider(constructor wiring, method delegation,safeEstimateGassuccess and error paths including analytics event, factory cache semantics) and forverifyChainId(match / mismatch / probe failure / constructor failure, including provider cleanup).