Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion ui/ts/hooks/useOnchainState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion ui/ts/hooks/useSecurityVaultOperations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}

Expand Down
13 changes: 10 additions & 3 deletions ui/ts/lib/chainBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export type CreateWriteClientCallbacks = {
onTransactionSubmitted?: (hash: Hash) => void
}

type ReadTransportMode = 'provider' | 'rpc'

export type ChainBackend = {
bootstrapError: string | undefined
bootstrapLabel: string | undefined
Expand All @@ -30,16 +32,17 @@ export type ChainBackend = {
isBootstrapping?: boolean
profile: NetworkProfile
requestAccounts(): Promise<readonly Address[]>
setReadTransportMode?: (mode: ReadTransportMode) => void
subscribe: ((handler: () => void) => () => void) | undefined
subscribeAccountsChanged(handler: () => void): () => void
subscribeChainChanged(handler: () => void): () => void
waitUntilReady?(): Promise<void>
}

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 } }),
})
}

Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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()
Expand Down
5 changes: 2 additions & 3 deletions ui/ts/lib/securityVaultGuards.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions ui/ts/lib/userCopy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
5 changes: 2 additions & 3 deletions ui/ts/lib/zoltarMigrationGuards.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
98 changes: 98 additions & 0 deletions ui/ts/tests/chainBackend.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/// <reference types="bun-types" />

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<unknown>): 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<typeof fetch>[0], init?: Parameters<typeof fetch>[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([])
})
})
Loading