From 7467e47c539ceee1797e9dabff107a5464cc425e Mon Sep 17 00:00:00 2001 From: KillariDev <13102010+KillariDev@users.noreply.github.com> Date: Tue, 19 May 2026 15:29:49 +0000 Subject: [PATCH] Add read-only RPC fallback for injected wallet backend - Route injected read clients through shared RPC when wallet reads are disabled - Update wallet and migration guards to show read-only friendly copy - Add tests covering provider vs RPC read transport selection --- ui/ts/hooks/useOnchainState.ts | 3 +- ui/ts/hooks/useSecurityVaultOperations.ts | 2 +- ui/ts/lib/chainBackend.ts | 13 ++- ui/ts/lib/securityVaultGuards.ts | 5 +- ui/ts/lib/userCopy.ts | 4 +- ui/ts/lib/zoltarMigrationGuards.ts | 5 +- ui/ts/tests/chainBackend.test.ts | 98 +++++++++++++++++++++++ 7 files changed, 117 insertions(+), 13 deletions(-) create mode 100644 ui/ts/tests/chainBackend.test.ts diff --git a/ui/ts/hooks/useOnchainState.ts b/ui/ts/hooks/useOnchainState.ts index b6f5537a..0335d362 100644 --- a/ui/ts/hooks/useOnchainState.ts +++ b/ui/ts/hooks/useOnchainState.ts @@ -105,6 +105,7 @@ export function useOnchainState() { const isCurrent = nextRefresh() hasInjectedWallet.value = backend.hasWallet() errorMessage.value = undefined + backend.setReadTransportMode?.('rpc') if (backend.isBootstrapped === false) { deploymentStatusesLoaded.value = false @@ -181,7 +182,7 @@ export function useOnchainState() { const connectWallet = async () => { const backend = getActiveBackend() if (!backend.hasWallet()) { - errorMessage.value = 'Connect wallet to continue.' + errorMessage.value = 'No wallet detected. Read-only mode is available until a wallet is installed.' return } if (isConnectingWallet.value) return diff --git a/ui/ts/hooks/useSecurityVaultOperations.ts b/ui/ts/hooks/useSecurityVaultOperations.ts index ed93d278..ce07d026 100644 --- a/ui/ts/hooks/useSecurityVaultOperations.ts +++ b/ui/ts/hooks/useSecurityVaultOperations.ts @@ -35,7 +35,7 @@ export function useSecurityVaultOperations({ accountAddress, enabled, onTransact const nextSecurityVaultLoad = useRequestGuard() const resolveSelectedVaultAddress = () => { - const selectedVaultAddress = requireDefined(getSelectedVaultAddress(securityVaultForm.value.selectedVaultAddress, accountAddress), 'Connect a wallet before loading a security vault') + const selectedVaultAddress = requireDefined(getSelectedVaultAddress(securityVaultForm.value.selectedVaultAddress, accountAddress), 'Enter a vault address or connect a wallet before loading a security vault') return parseAddressInput(selectedVaultAddress, 'Selected vault address') } diff --git a/ui/ts/lib/chainBackend.ts b/ui/ts/lib/chainBackend.ts index 1dfe8012..d059275d 100644 --- a/ui/ts/lib/chainBackend.ts +++ b/ui/ts/lib/chainBackend.ts @@ -15,6 +15,8 @@ export type CreateWriteClientCallbacks = { onTransactionSubmitted?: (hash: Hash) => void } +type ReadTransportMode = 'provider' | 'rpc' + export type ChainBackend = { bootstrapError: string | undefined bootstrapLabel: string | undefined @@ -30,16 +32,17 @@ export type ChainBackend = { isBootstrapping?: boolean profile: NetworkProfile requestAccounts(): Promise + setReadTransportMode?: (mode: ReadTransportMode) => void subscribe: ((handler: () => void) => () => void) | undefined subscribeAccountsChanged(handler: () => void): () => void subscribeChainChanged(handler: () => void): () => void waitUntilReady?(): Promise } -function createReadClientForProfile(profile: NetworkProfile, ethereum?: InjectedEthereum): ReadClient { +function createReadClientForProfile(profile: NetworkProfile, transportMode: ReadTransportMode, ethereum?: InjectedEthereum): ReadClient { return createPublicClient({ chain: profile.chain, - transport: ethereum !== undefined ? custom(ethereum) : http(DEFAULT_RPC_URL, { batch: { wait: 100 } }), + transport: transportMode === 'provider' && ethereum !== undefined ? custom(ethereum) : http(DEFAULT_RPC_URL, { batch: { wait: 100 } }), }) } @@ -77,12 +80,13 @@ export function normalizeAccount(value: unknown): Address | undefined { export function createInjectedBackend(): ChainBackend { const getProvider = () => getInjectedEthereum() + let readTransportMode: ReadTransportMode = 'provider' return { bootstrapError: undefined, bootstrapLabel: undefined, bootstrapProgress: undefined, - createReadClient: () => createReadClientForProfile(MAINNET_NETWORK_PROFILE, getProvider()), + createReadClient: () => createReadClientForProfile(MAINNET_NETWORK_PROFILE, readTransportMode, getProvider()), createWriteClient: (accountAddress, callbacks = {}) => { const ethereum = getProvider() if (ethereum === undefined) throw new Error('No injected wallet found') @@ -119,6 +123,9 @@ export function createInjectedBackend(): ChainBackend { if (!Array.isArray(result)) return [] return result.map(normalizeAccount).filter((address): address is Address => address !== undefined) }, + setReadTransportMode: mode => { + readTransportMode = mode + }, subscribe: undefined, subscribeAccountsChanged: handler => { const ethereum = getProvider() diff --git a/ui/ts/lib/securityVaultGuards.ts b/ui/ts/lib/securityVaultGuards.ts index b5dca699..7f6b867e 100644 --- a/ui/ts/lib/securityVaultGuards.ts +++ b/ui/ts/lib/securityVaultGuards.ts @@ -1,11 +1,10 @@ import type { Address } from 'viem' import { formatCurrencyBalance } from './formatters.js' import { MIN_SECURITY_BOND_ALLOWANCE, MIN_SECURITY_VAULT_REP_DEPOSIT } from './securityVault.js' -import { getWalletPresentation } from './userCopy.js' export function getVaultApprovalGuardMessage({ accountAddress, isMainnet, selectedVaultDetailsLoaded, selectedVaultIsOwnedByAccount }: { accountAddress: Address | undefined; isMainnet: boolean; selectedVaultDetailsLoaded: boolean; selectedVaultIsOwnedByAccount: boolean }) { - const walletPresentation = getWalletPresentation({ accountAddress, isMainnet }) - if (walletPresentation !== undefined) return walletPresentation.detail + if (accountAddress === undefined) return 'Connect wallet to continue.' + if (!isMainnet) return 'Switch to Ethereum mainnet.' if (!selectedVaultIsOwnedByAccount) return 'Select your own vault to approve REP.' if (!selectedVaultDetailsLoaded) return 'Refresh the vault first.' return undefined diff --git a/ui/ts/lib/userCopy.ts b/ui/ts/lib/userCopy.ts index bd25b6a5..7b901647 100644 --- a/ui/ts/lib/userCopy.ts +++ b/ui/ts/lib/userCopy.ts @@ -130,14 +130,14 @@ export function getWalletPresentation({ accountAddress, hasInjectedWallet, hasWa return createPresentation('wallet_disconnected', { badgeLabel: 'Connect wallet', badgeTone: 'blocked', - detail: 'Install or enable a wallet to continue.', + detail: 'Install or enable a wallet to send transactions. Read-only data stays available.', }) } if (accountAddress === undefined) { return createPresentation('wallet_disconnected', { badgeLabel: 'Connect wallet', badgeTone: 'blocked', - detail: 'Connect wallet to continue.', + detail: 'Connect a wallet to send transactions. Read-only data stays available.', }) } if (!supportedChain) { diff --git a/ui/ts/lib/zoltarMigrationGuards.ts b/ui/ts/lib/zoltarMigrationGuards.ts index afb120a4..d4b2223c 100644 --- a/ui/ts/lib/zoltarMigrationGuards.ts +++ b/ui/ts/lib/zoltarMigrationGuards.ts @@ -1,10 +1,9 @@ import type { Address } from 'viem' import type { ZoltarUniverseSummary } from '../types/contracts.js' -import { getWalletPresentation } from './userCopy.js' export function getMigrationGuardMessage(accountAddress: Address | undefined, isMainnet: boolean, rootUniverse: ZoltarUniverseSummary | undefined, loadingZoltarForkAccess: boolean, hasForked: boolean, loadingZoltarUniverse: boolean, notForkedAction: string): string | undefined { - const walletPresentation = getWalletPresentation({ accountAddress, isMainnet }) - if (walletPresentation !== undefined) return walletPresentation.detail + if (accountAddress === undefined) return 'Connect wallet to continue.' + if (!isMainnet) return 'Switch to Ethereum mainnet.' if (rootUniverse === undefined) return loadingZoltarUniverse ? undefined : 'Refresh universe first.' if (loadingZoltarForkAccess) return undefined if (!hasForked) return notForkedAction diff --git a/ui/ts/tests/chainBackend.test.ts b/ui/ts/tests/chainBackend.test.ts new file mode 100644 index 00000000..59ce6c9b --- /dev/null +++ b/ui/ts/tests/chainBackend.test.ts @@ -0,0 +1,98 @@ +/// + +import { afterEach, describe, expect, test } from 'bun:test' +import { zeroAddress } from 'viem' +import { createInjectedBackend } from '../lib/chainBackend.js' +import type { InjectedEthereum } from '../injectedEthereum.js' + +type RequestParameters = { + method: string + params?: unknown +} + +function ensureWindowObject() { + const globalWindow = globalThis as typeof globalThis & { window?: Window } + if (globalWindow.window === undefined) { + globalWindow.window = globalThis as Window & typeof globalThis + } + return globalWindow.window +} + +function createMockInjectedEthereum(requestHandler: (parameters: RequestParameters) => Promise): InjectedEthereum { + return { + on: () => undefined, + removeListener: () => undefined, + request: requestHandler as InjectedEthereum['request'], + } +} + +function getRpcId(value: unknown) { + if (typeof value !== 'object' || value === null || !('id' in value)) return undefined + return value.id +} + +describe('injected backend read transport', () => { + const originalFetch = globalThis.fetch + const originalEthereum = ensureWindowObject().ethereum + + afterEach(() => { + globalThis.fetch = originalFetch + const windowObject = ensureWindowObject() + if (originalEthereum === undefined) { + delete windowObject.ethereum + return + } + windowObject.ethereum = originalEthereum + }) + + test('uses the injected provider for reads by default', async () => { + const requestCalls: string[] = [] + ensureWindowObject().ethereum = createMockInjectedEthereum(async parameters => { + requestCalls.push(parameters.method) + return '0x' + }) + + let fetchCalled = false + globalThis.fetch = (async () => { + fetchCalled = true + throw new Error('fetch should not be called while provider reads are enabled') + }) as unknown as typeof fetch + + const backend = createInjectedBackend() + const code = await backend.createReadClient().getCode({ address: zeroAddress }) + + expect(code).toBeUndefined() + expect(requestCalls).toEqual(['eth_getCode']) + expect(fetchCalled).toBe(false) + }) + + test('switches injected reads to the shared RPC backend when requested', async () => { + const requestCalls: string[] = [] + ensureWindowObject().ethereum = createMockInjectedEthereum(async parameters => { + requestCalls.push(parameters.method) + return '0x' + }) + + const fetchCalls: string[] = [] + globalThis.fetch = (async (input: Parameters[0], init?: Parameters[1]) => { + const url = input instanceof Request ? input.url : String(input) + fetchCalls.push(url) + const rawBody = input instanceof Request ? await input.clone().text() : typeof init?.body === 'string' ? init.body : undefined + const body = rawBody === undefined || rawBody === '' ? undefined : JSON.parse(rawBody) + const responseBody = Array.isArray(body) ? body.map(item => ({ id: getRpcId(item), jsonrpc: '2.0', result: '0x' })) : { id: getRpcId(body), jsonrpc: '2.0', result: '0x' } + return new Response(JSON.stringify(responseBody), { + headers: { + 'content-type': 'application/json', + }, + }) + }) as unknown as typeof fetch + + const backend = createInjectedBackend() + backend.setReadTransportMode?.('rpc') + const code = await backend.createReadClient().getCode({ address: zeroAddress }) + + expect(code).toBeUndefined() + expect(fetchCalls).toEqual(['https://ethereum.dark.florist']) + expect(requestCalls).toEqual([]) + }) +})