diff --git a/abis/oracle.ts b/abis/oracle.ts
index 264517081..b240d30f9 100644
--- a/abis/oracle.ts
+++ b/abis/oracle.ts
@@ -1,3 +1,14 @@
+/** `governor()` getter from euler-price-oracle's Governable (EulerRouter). */
+export const governableGovernorAbi = [
+ {
+ type: 'function',
+ name: 'governor',
+ inputs: [],
+ outputs: [{ name: 'governor', type: 'address' }],
+ stateMutability: 'view',
+ },
+] as const
+
export const priceOracleAbi = [
{
type: 'function',
diff --git a/abis/safe.ts b/abis/safe.ts
new file mode 100644
index 000000000..7aaec779c
--- /dev/null
+++ b/abis/safe.ts
@@ -0,0 +1,33 @@
+/**
+ * Minimal fragments for probing Safe (ex Gnosis Safe) smart accounts.
+ *
+ * `masterCopy()` is not a regular function on the Safe singleton — Safe proxy
+ * contracts (v1.1.1+) special-case the `0xa619486e` selector in their fallback
+ * and return the singleton address stored at slot 0 without delegating. An
+ * `eth_call` against any Safe proxy therefore answers it, while EOAs return
+ * empty data and non-Safe contracts revert or return garbage that fails
+ * decoding.
+ */
+export const safeAccountAbi = [
+ {
+ type: 'function',
+ name: 'masterCopy',
+ inputs: [],
+ outputs: [{ name: 'masterCopy', type: 'address' }],
+ stateMutability: 'view',
+ },
+ {
+ type: 'function',
+ name: 'getThreshold',
+ inputs: [],
+ outputs: [{ name: 'threshold', type: 'uint256' }],
+ stateMutability: 'view',
+ },
+ {
+ type: 'function',
+ name: 'getOwners',
+ inputs: [],
+ outputs: [{ name: 'owners', type: 'address[]' }],
+ stateMutability: 'view',
+ },
+] as const
diff --git a/assets/sprite/svg/safe.svg b/assets/sprite/svg/safe.svg
new file mode 100644
index 000000000..c0dd53f94
--- /dev/null
+++ b/assets/sprite/svg/safe.svg
@@ -0,0 +1 @@
+
diff --git a/components/entities/safe/SafeAccountBadge.vue b/components/entities/safe/SafeAccountBadge.vue
new file mode 100644
index 000000000..4bd600176
--- /dev/null
+++ b/components/entities/safe/SafeAccountBadge.vue
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+ ({{ safeInfo.threshold }}/{{ safeInfo.owners.length }})
+
+
+
diff --git a/components/entities/vault/overview/SecuritizeVaultOverview.vue b/components/entities/vault/overview/SecuritizeVaultOverview.vue
index 5febee351..8b4df7d51 100644
--- a/components/entities/vault/overview/SecuritizeVaultOverview.vue
+++ b/components/entities/vault/overview/SecuritizeVaultOverview.vue
@@ -5,10 +5,8 @@ import { getProductByVault, getProductKeyByVault, isVaultGovernanceLimited } fro
import { getEulerLabelEntityLogo } from '~/entities/euler/labels'
import { isVaultBlockedByCountry } from '~/composables/useGeoBlock'
import { autoLink } from '~/utils/autoLink'
-import { getExplorerLink } from '~/utils/block-explorer'
-import { getSpecialAddressLabel } from '~/utils/special-addresses'
import { formatAssetValue } from '~/utils/sdk-prices'
-import { formatNumber, compactNumber, formatUsdValue, formatCompactUsdValue, shortenAddress } from '~/utils/string-utils'
+import { formatNumber, compactNumber, formatUsdValue, formatCompactUsdValue } from '~/utils/string-utils'
import { nanoToValue } from '~/utils/crypto-utils'
import { formatMarketAvailability } from '~/utils/vault-display'
import { VaultApyModal } from '#components'
@@ -23,7 +21,6 @@ const emit = defineEmits<{
const route = useRoute()
const { enableEntityBranding: enableEntityBrandingDisplay, enableVaultType: enableVaultTypeDisplay } = useDeployConfig()
-const { chainId } = useEulerAddresses()
const { borrowList: _borrowList, isVaultGovernorVerified } = useVaults()
const { settings } = useUserSettings()
const enableIntrinsicApy = computed(() => settings.value.enableIntrinsicApy)
@@ -45,14 +42,6 @@ const isDeprecated = computed(() => {
const deprecationReason = computed(() => isDeprecated.value ? product.deprecationReason || '' : '')
const isRestricted = computed(() => isVaultBlockedByCountry(vault.address))
-const { copyToClipboard } = useClipboardCopy()
-
-const onCopyClick = (address: string) => {
- copyToClipboard(address).catch(() => {})
-}
-
-const getExplorerAddressLink = (address: string) => getExplorerLink(address, chainId.value, true)
-
// Count markets where this can be borrowed (securitize vaults cannot be borrow destinations)
const borrowCount = computed(() => 0)
@@ -330,71 +319,23 @@ const supplyCapPercentageDisplay = computed(() => {
:label="`${vault.asset.symbol} token`"
orientation="horizontal"
>
-
-
- {{ getSpecialAddressLabel(vault.asset.address) || shortenAddress(vault.asset.address) }}
-
-
-
+
-
-
- {{ getSpecialAddressLabel(vault.address) || shortenAddress(vault.address) }}
-
-
-
+
-
-
- {{ getSpecialAddressLabel(vault.governor) || shortenAddress(vault.governor) }}
-
-
-
+
diff --git a/components/entities/vault/overview/VaultOverviewAddressValue.vue b/components/entities/vault/overview/VaultOverviewAddressValue.vue
new file mode 100644
index 000000000..a4afe7e09
--- /dev/null
+++ b/components/entities/vault/overview/VaultOverviewAddressValue.vue
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+ {{ displayLabel }}
+
+
+
+
diff --git a/components/entities/vault/overview/VaultOverviewBlockAddresses.vue b/components/entities/vault/overview/VaultOverviewBlockAddresses.vue
index db98148f5..e014ca6db 100644
--- a/components/entities/vault/overview/VaultOverviewBlockAddresses.vue
+++ b/components/entities/vault/overview/VaultOverviewBlockAddresses.vue
@@ -1,15 +1,10 @@
@@ -111,24 +119,10 @@ const getExplorerAddressLink = (address: string) => getExplorerLink(address, cha
/>
-
-
- {{ getSpecialAddressLabel(infoItem.address) || shortenAddress(infoItem.address) }}
-
-
-
+
diff --git a/components/entities/vault/overview/earn/VaultOverviewEarnBlockAddresses.vue b/components/entities/vault/overview/earn/VaultOverviewEarnBlockAddresses.vue
index be700b9f7..af0d531de 100644
--- a/components/entities/vault/overview/earn/VaultOverviewEarnBlockAddresses.vue
+++ b/components/entities/vault/overview/earn/VaultOverviewEarnBlockAddresses.vue
@@ -1,11 +1,7 @@
@@ -43,24 +32,10 @@ const getExplorerAddressLink = (address: string) => getExplorerLink(address, cha
:label="infoItem.title"
orientation="horizontal"
>
-
-
- {{ getSpecialAddressLabel(infoItem.address) || shortenAddress(infoItem.address) }}
-
-
-
+
diff --git a/components/entities/vault/overview/earn/VaultOverviewEarnBlockManagement.vue b/components/entities/vault/overview/earn/VaultOverviewEarnBlockManagement.vue
index 07bc1a585..2e58b4cad 100644
--- a/components/entities/vault/overview/earn/VaultOverviewEarnBlockManagement.vue
+++ b/components/entities/vault/overview/earn/VaultOverviewEarnBlockManagement.vue
@@ -1,12 +1,8 @@
@@ -54,24 +42,10 @@ const getExplorerAddressLink = (address: string) => getExplorerLink(address, cha
:label="infoItem.title"
orientation="horizontal"
>
-
-
- {{ getSpecialAddressLabel(infoItem.address) || shortenAddress(infoItem.address) }}
-
-
-
+
([])
@@ -135,8 +133,6 @@ export const useEulerAddresses = () => {
return {
EUL: config.addresses.tokenAddrs.EUL,
rEUL: config.addresses.tokenAddrs.rEUL,
- eUSD: config.addresses.tokenAddrs.eUSD,
- seUSD: config.addresses.tokenAddrs.seUSD,
}
})
@@ -150,18 +146,11 @@ export const useEulerAddresses = () => {
capRiskStewardFactory: peripheryAddrs.capRiskStewardFactory,
escrowedCollateralPerspective: peripheryAddrs.escrowedCollateralPerspective,
eulerEarnFactoryPerspective: peripheryAddrs.eulerEarnFactoryPerspective,
- eulerEarnGovernedPerspective: peripheryAddrs.eulerEarnGovernedPerspective,
- eulerUngoverned0xPerspective: peripheryAddrs.eulerUngoverned0xPerspective,
- eulerUngovernedNzxPerspective: peripheryAddrs.eulerUngovernedNzxPerspective,
evkFactoryPerspective: peripheryAddrs.evkFactoryPerspective,
- externalVaultRegistry: peripheryAddrs.externalVaultRegistry,
feeFlowController: peripheryAddrs.feeFlowController,
- governedPerspective: peripheryAddrs.governedPerspective,
governorAccessControlEmergencyFactory: peripheryAddrs.governorAccessControlEmergencyFactory,
- irmRegistry: peripheryAddrs.irmRegistry,
kinkIRMFactory: peripheryAddrs.kinkIRMFactory,
kinkyIRMFactory: peripheryAddrs.kinkyIRMFactory,
- oracleAdapterRegistry: peripheryAddrs.oracleAdapterRegistry,
oracleRouterFactory: peripheryAddrs.oracleRouterFactory,
securitizeFactory: peripheryAddrs.securitizeFactory,
swapVerifier: peripheryAddrs.swapVerifier,
diff --git a/composables/useOracleRouterGovernor.ts b/composables/useOracleRouterGovernor.ts
new file mode 100644
index 000000000..42873e900
--- /dev/null
+++ b/composables/useOracleRouterGovernor.ts
@@ -0,0 +1,76 @@
+import { computed, toValue, watch, type MaybeRefOrGetter } from 'vue'
+import { getAddress, isAddress, type Address, type PublicClient } from 'viem'
+import { governableGovernorAbi } from '~/abis/oracle'
+import { createOnchainLookupCache } from '~/utils/onchain-lookup-cache'
+import { isTransportError } from '~/utils/viem-errors'
+
+// Router governance changes rarely; 5 min matches the app's other caches.
+const CACHE_TTL_MS = 5 * 60_000
+
+const governorCache = createOnchainLookupCache(CACHE_TTL_MS)
+
+const probeGovernor = async (
+ client: PublicClient,
+ router: Address,
+): Promise => {
+ try {
+ const governor = await client.readContract({
+ address: router,
+ abi: governableGovernorAbi,
+ functionName: 'governor',
+ authorizationList: undefined,
+ })
+ return getAddress(governor)
+ }
+ catch (err) {
+ // A flaky RPC response must not get cached as "no governor" for the TTL.
+ if (isTransportError(err)) throw err
+ // Not a Governable contract (or empty call data) — no governor to show.
+ return null
+ }
+}
+
+/**
+ * Reactive `governor()` lookup for an EulerRouter address.
+ *
+ * `governor` stays `undefined` until resolved; a resolved `null` means the
+ * contract has no readable governor (don't render a row). The zero address is
+ * passed through — it means governance was renounced, which is worth showing.
+ */
+export const useOracleRouterGovernor = (
+ routerAddress: MaybeRefOrGetter,
+) => {
+ const { chainId } = useEulerAddresses()
+ const { client } = useRpcClient()
+
+ const probeAddress = computed(() => {
+ const value = toValue(routerAddress)
+ if (!value || !isAddress(value)) return null
+ return getAddress(value)
+ })
+
+ const cacheKey = computed(() => {
+ if (!probeAddress.value || !chainId.value) return null
+ return `${chainId.value}:${probeAddress.value.toLowerCase()}`
+ })
+
+ watch(
+ [cacheKey, client],
+ ([key, rpcClient]) => {
+ // Probe client-side only — SSR output renders without the row and
+ // hydrates identically (the client cache starts empty too).
+ if (import.meta.server) return
+ const target = probeAddress.value
+ if (!key || !rpcClient || !target) return
+ governorCache.load(key, () => probeGovernor(rpcClient, target)).catch(() => {})
+ },
+ { immediate: true },
+ )
+
+ const governor = computed(() => {
+ if (!cacheKey.value) return undefined
+ return governorCache.read(cacheKey.value)
+ })
+
+ return { governor }
+}
diff --git a/composables/useSafeAddressInfo.ts b/composables/useSafeAddressInfo.ts
new file mode 100644
index 000000000..2153a915f
--- /dev/null
+++ b/composables/useSafeAddressInfo.ts
@@ -0,0 +1,109 @@
+import { computed, toValue, watch, type MaybeRefOrGetter } from 'vue'
+import { getAddress, isAddress, type Address, type PublicClient } from 'viem'
+import { safeAccountAbi } from '~/abis/safe'
+import { createOnchainLookupCache } from '~/utils/onchain-lookup-cache'
+import { getSpecialAddressLabel } from '~/utils/special-addresses'
+import { isTransportError } from '~/utils/viem-errors'
+import { resolveSafeAccountInfo, type SafeAccountInfo } from '~/utils/safe-account'
+
+// Threshold/owners can change over time; 5 min matches the app's other caches.
+const CACHE_TTL_MS = 5 * 60_000
+
+const safeInfoCache = createOnchainLookupCache(CACHE_TTL_MS)
+
+/**
+ * Probe whether an address is a Safe smart account.
+ *
+ * All three reads fire concurrently — the transport batches them into a
+ * single RPC request. EOAs return empty call data and non-Safe contracts
+ * revert on the unknown selectors, so those failures mean "not a Safe".
+ * Transport-level failures are rethrown instead — a flaky RPC response must
+ * not get cached as a definitive negative for the TTL.
+ */
+const probeSafeAccount = async (
+ client: PublicClient,
+ address: Address,
+): Promise => {
+ const results = await Promise.allSettled([
+ client.readContract({
+ address,
+ abi: safeAccountAbi,
+ functionName: 'masterCopy',
+ authorizationList: undefined,
+ }),
+ client.readContract({
+ address,
+ abi: safeAccountAbi,
+ functionName: 'getThreshold',
+ authorizationList: undefined,
+ }),
+ client.readContract({
+ address,
+ abi: safeAccountAbi,
+ functionName: 'getOwners',
+ authorizationList: undefined,
+ }),
+ ])
+
+ for (const result of results) {
+ if (result.status === 'rejected' && isTransportError(result.reason)) {
+ throw result.reason
+ }
+ }
+
+ const [masterCopy, threshold, owners] = results
+ return resolveSafeAccountInfo(
+ address,
+ masterCopy.status === 'fulfilled' ? masterCopy.value : null,
+ threshold.status === 'fulfilled' ? threshold.value : null,
+ owners.status === 'fulfilled' ? owners.value : null,
+ )
+}
+
+/**
+ * Reactive Safe detection for a displayed address.
+ *
+ * `safeInfo` is `null` until the probe resolves positively — callers just
+ * hide the badge for non-Safes, unknowns, and while loading. Probing runs
+ * client-side only; results are cached per `${chainId}:${address}` across
+ * component instances.
+ */
+export const useSafeAddressInfo = (
+ address: MaybeRefOrGetter,
+) => {
+ const { chainId } = useEulerAddresses()
+ const { client } = useRpcClient()
+
+ const probeAddress = computed(() => {
+ const value = toValue(address)
+ if (!value || !isAddress(value)) return null
+ // Sentinel addresses (zero/USD/ETH/BTC) are never Safes — skip the probe.
+ if (getSpecialAddressLabel(value)) return null
+ return getAddress(value)
+ })
+
+ const cacheKey = computed(() => {
+ if (!probeAddress.value || !chainId.value) return null
+ return `${chainId.value}:${probeAddress.value.toLowerCase()}`
+ })
+
+ watch(
+ [cacheKey, client],
+ ([key, rpcClient]) => {
+ // Probe client-side only — SSR output renders without badges and
+ // hydrates identically (the client cache starts empty too).
+ if (import.meta.server) return
+ const target = probeAddress.value
+ if (!key || !rpcClient || !target) return
+ safeInfoCache.load(key, () => probeSafeAccount(rpcClient, target)).catch(() => {})
+ },
+ { immediate: true },
+ )
+
+ const safeInfo = computed(() => {
+ if (!cacheKey.value) return null
+ return safeInfoCache.read(cacheKey.value) ?? null
+ })
+
+ return { safeInfo }
+}
diff --git a/package-lock.json b/package-lock.json
index a6ce61644..fee53fc1e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,7 +8,7 @@
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
- "@eulerxyz/euler-v2-sdk": "1.2.5",
+ "@eulerxyz/euler-v2-sdk": "2.0.0",
"@floating-ui/vue": "2.0.1",
"@gvade/nuxt3-svg-sprite": "1.0.3",
"@keyringnetwork/keyring-connect-sdk": "3.2.0",
@@ -1551,9 +1551,9 @@
}
},
"node_modules/@eulerxyz/euler-v2-sdk": {
- "version": "1.2.5",
- "resolved": "https://registry.npmjs.org/@eulerxyz/euler-v2-sdk/-/euler-v2-sdk-1.2.5.tgz",
- "integrity": "sha512-Ug9FzKyV/UdVaTzjKO5RqIhspAO9NHdvfsSrv2b8maIgsfBr9l6s4qLqjmlek9fjOOPC+4aurDEmpHT4GTUDNA==",
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@eulerxyz/euler-v2-sdk/-/euler-v2-sdk-2.0.0.tgz",
+ "integrity": "sha512-tOaLsScpVwKn3D5h78nf+Nz4xv4rd8n2/WUFQBtxIP549XoSWb9rSWCAi3o4oySgzB2RIljnKuKKauNzrCnw9w==",
"license": "MIT",
"dependencies": {
"viem": "2.48.8"
diff --git a/package.json b/package.json
index e9e4e1c92..1659f7c94 100644
--- a/package.json
+++ b/package.json
@@ -35,7 +35,7 @@
]
},
"dependencies": {
- "@eulerxyz/euler-v2-sdk": "1.2.5",
+ "@eulerxyz/euler-v2-sdk": "2.0.0",
"@floating-ui/vue": "2.0.1",
"@gvade/nuxt3-svg-sprite": "1.0.3",
"@keyringnetwork/keyring-connect-sdk": "3.2.0",
diff --git a/tests/composables/useOracleRouterGovernor.test.ts b/tests/composables/useOracleRouterGovernor.test.ts
new file mode 100644
index 000000000..3bfa4beec
--- /dev/null
+++ b/tests/composables/useOracleRouterGovernor.test.ts
@@ -0,0 +1,99 @@
+import { ref } from 'vue'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const ROUTER_ADDRESS = '0x00000000000000000000000000000000000000cc'
+const GOVERNOR_ADDRESS = '0x00000000000000000000000000000000000000dd'
+const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'
+
+const stubEnvironment = (client: unknown) => {
+ vi.stubGlobal('useEulerAddresses', () => ({ chainId: ref(1) }))
+ vi.stubGlobal('useRpcClient', () => ({ client: ref(client) }))
+}
+
+const importComposable = async () => {
+ const { useOracleRouterGovernor } = await import('~/composables/useOracleRouterGovernor')
+ return useOracleRouterGovernor
+}
+
+describe('useOracleRouterGovernor', () => {
+ beforeEach(() => {
+ vi.resetModules()
+ vi.unstubAllGlobals()
+ })
+
+ it('resolves the router governor', async () => {
+ const client = { readContract: vi.fn(async () => GOVERNOR_ADDRESS) }
+ stubEnvironment(client)
+ const useOracleRouterGovernor = await importComposable()
+
+ const { governor } = useOracleRouterGovernor(() => ROUTER_ADDRESS)
+ await vi.waitFor(() => expect(governor.value).toBeTruthy())
+ expect(governor.value?.toLowerCase()).toBe(GOVERNOR_ADDRESS)
+ })
+
+ it('passes through the zero address for renounced governance', async () => {
+ const client = { readContract: vi.fn(async () => ZERO_ADDRESS) }
+ stubEnvironment(client)
+ const useOracleRouterGovernor = await importComposable()
+
+ const { governor } = useOracleRouterGovernor(() => ROUTER_ADDRESS)
+ await vi.waitFor(() => expect(governor.value).toBe(ZERO_ADDRESS))
+ })
+
+ it('resolves null when the contract has no readable governor', async () => {
+ const client = {
+ readContract: vi.fn(async () => {
+ throw new Error('execution reverted')
+ }),
+ }
+ stubEnvironment(client)
+ const useOracleRouterGovernor = await importComposable()
+
+ const { governor } = useOracleRouterGovernor(() => ROUTER_ADDRESS)
+ await vi.waitFor(() => expect(client.readContract).toHaveBeenCalled())
+ await Promise.resolve()
+ expect(governor.value).toBeNull()
+ })
+
+ it('stays undefined without a router address and never probes', async () => {
+ const client = { readContract: vi.fn() }
+ stubEnvironment(client)
+ const useOracleRouterGovernor = await importComposable()
+
+ const { governor } = useOracleRouterGovernor(() => null)
+ await Promise.resolve()
+ expect(governor.value).toBeUndefined()
+ expect(client.readContract).not.toHaveBeenCalled()
+ })
+
+ it('does not cache transport failures as "no governor"', async () => {
+ const readContract = vi.fn()
+ .mockRejectedValueOnce(new Error('HTTP request failed'))
+ .mockResolvedValue(GOVERNOR_ADDRESS)
+ stubEnvironment({ readContract })
+ const useOracleRouterGovernor = await importComposable()
+
+ const first = useOracleRouterGovernor(() => ROUTER_ADDRESS)
+ await vi.waitFor(() => expect(readContract).toHaveBeenCalled())
+ // Macrotask flush so the failed probe fully settles and releases its
+ // in-flight slot before the retry instance is created.
+ await new Promise(resolve => setTimeout(resolve, 0))
+ expect(first.governor.value).toBeUndefined()
+
+ // A fresh instance retries because the transport failure was not cached.
+ const second = useOracleRouterGovernor(() => ROUTER_ADDRESS)
+ await vi.waitFor(() => expect(second.governor.value).toBeTruthy())
+ })
+
+ it('caches lookups across instances', async () => {
+ const client = { readContract: vi.fn(async () => GOVERNOR_ADDRESS) }
+ stubEnvironment(client)
+ const useOracleRouterGovernor = await importComposable()
+
+ const first = useOracleRouterGovernor(() => ROUTER_ADDRESS)
+ await vi.waitFor(() => expect(first.governor.value).toBeTruthy())
+ const second = useOracleRouterGovernor(() => ROUTER_ADDRESS)
+ await vi.waitFor(() => expect(second.governor.value).toBeTruthy())
+ expect(client.readContract).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/tests/composables/useSafeAddressInfo.test.ts b/tests/composables/useSafeAddressInfo.test.ts
new file mode 100644
index 000000000..c7d58c473
--- /dev/null
+++ b/tests/composables/useSafeAddressInfo.test.ts
@@ -0,0 +1,164 @@
+import { ref } from 'vue'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const SAFE_ADDRESS = '0x00000000000000000000000000000000000000aa'
+const OTHER_ADDRESS = '0x00000000000000000000000000000000000000bb'
+const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'
+const SAFE_141_SINGLETON = '0x41675C099F32341bf84BFc5382aF534df5C7461a'
+const UNKNOWN_SINGLETON = '0x1111111111111111111111111111111111111111'
+
+const OWNERS = [
+ '0x00000000000000000000000000000000000000a1',
+ '0x00000000000000000000000000000000000000a2',
+ '0x00000000000000000000000000000000000000a3',
+ '0x00000000000000000000000000000000000000a4',
+ '0x00000000000000000000000000000000000000a5',
+ '0x00000000000000000000000000000000000000a6',
+ '0x00000000000000000000000000000000000000a7',
+]
+
+type ReadContractArgs = { address: string, functionName: string }
+
+const safeClient = (singleton: string = SAFE_141_SINGLETON) => {
+ const calls: ReadContractArgs[] = []
+ return {
+ calls,
+ readContract: vi.fn(async ({ address, functionName }: ReadContractArgs) => {
+ calls.push({ address, functionName })
+ if (functionName === 'masterCopy') return singleton
+ if (functionName === 'getThreshold') return 3n
+ if (functionName === 'getOwners') return OWNERS
+ throw new Error(`unexpected function ${functionName}`)
+ }),
+ }
+}
+
+const eoaClient = () => ({
+ readContract: vi.fn(async () => {
+ throw new Error('returned no data ("0x")')
+ }),
+})
+
+const stubEnvironment = (client: unknown) => {
+ vi.stubGlobal('useEulerAddresses', () => ({ chainId: ref(1) }))
+ vi.stubGlobal('useRpcClient', () => ({ client: ref(client) }))
+}
+
+const importComposable = async () => {
+ const { useSafeAddressInfo } = await import('~/composables/useSafeAddressInfo')
+ return useSafeAddressInfo
+}
+
+describe('useSafeAddressInfo', () => {
+ beforeEach(() => {
+ vi.resetModules()
+ vi.unstubAllGlobals()
+ })
+
+ it('detects a Safe and exposes threshold and owners', async () => {
+ const client = safeClient()
+ stubEnvironment(client)
+ const useSafeAddressInfo = await importComposable()
+
+ const { safeInfo } = useSafeAddressInfo(() => SAFE_ADDRESS)
+ expect(safeInfo.value).toBeNull()
+
+ await vi.waitFor(() => expect(safeInfo.value).not.toBeNull())
+ expect(safeInfo.value).toEqual({
+ version: '1.4.1',
+ threshold: 3,
+ owners: OWNERS,
+ })
+ expect(client.calls.map(call => call.functionName).sort()).toEqual(
+ ['getOwners', 'getThreshold', 'masterCopy'],
+ )
+ })
+
+ it('reports null for a proxy pointing at an unknown singleton', async () => {
+ const client = safeClient(UNKNOWN_SINGLETON)
+ stubEnvironment(client)
+ const useSafeAddressInfo = await importComposable()
+
+ const { safeInfo } = useSafeAddressInfo(() => SAFE_ADDRESS)
+ await vi.waitFor(() => expect(client.readContract).toHaveBeenCalled())
+ await Promise.resolve()
+ expect(safeInfo.value).toBeNull()
+ })
+
+ it('reports null for EOAs and non-Safe contracts', async () => {
+ const client = eoaClient()
+ stubEnvironment(client)
+ const useSafeAddressInfo = await importComposable()
+
+ const { safeInfo } = useSafeAddressInfo(() => SAFE_ADDRESS)
+ await vi.waitFor(() => expect(client.readContract).toHaveBeenCalledTimes(3))
+ await Promise.resolve()
+ expect(safeInfo.value).toBeNull()
+ })
+
+ it('never probes sentinel or invalid addresses', async () => {
+ const client = safeClient()
+ stubEnvironment(client)
+ const useSafeAddressInfo = await importComposable()
+
+ useSafeAddressInfo(() => ZERO_ADDRESS)
+ useSafeAddressInfo(() => 'not-an-address')
+ useSafeAddressInfo(() => null)
+
+ await Promise.resolve()
+ expect(client.readContract).not.toHaveBeenCalled()
+ })
+
+ it('shares the cache between composable instances', async () => {
+ const client = safeClient()
+ stubEnvironment(client)
+ const useSafeAddressInfo = await importComposable()
+
+ const first = useSafeAddressInfo(() => SAFE_ADDRESS)
+ await vi.waitFor(() => expect(first.safeInfo.value).not.toBeNull())
+
+ const second = useSafeAddressInfo(() => SAFE_ADDRESS)
+ await vi.waitFor(() => expect(second.safeInfo.value).not.toBeNull())
+ // 3 reads for the first instance, none for the second.
+ expect(client.readContract).toHaveBeenCalledTimes(3)
+ })
+
+ it('does not cache transport failures as negatives', async () => {
+ const readContract = vi.fn()
+ .mockRejectedValueOnce(new Error('HTTP request failed'))
+ .mockRejectedValueOnce(new Error('HTTP request failed'))
+ .mockRejectedValueOnce(new Error('HTTP request failed'))
+ .mockImplementation(async ({ functionName }: { functionName: string }) => {
+ if (functionName === 'masterCopy') return SAFE_141_SINGLETON
+ if (functionName === 'getThreshold') return 3n
+ return OWNERS
+ })
+ stubEnvironment({ readContract })
+ const useSafeAddressInfo = await importComposable()
+
+ const first = useSafeAddressInfo(() => SAFE_ADDRESS)
+ await vi.waitFor(() => expect(readContract).toHaveBeenCalledTimes(3))
+ // Macrotask flush so the failed probe fully settles and releases its
+ // in-flight slot before the retry instance is created.
+ await new Promise(resolve => setTimeout(resolve, 0))
+ expect(first.safeInfo.value).toBeNull()
+
+ // A fresh instance retries because the transport failure was not cached.
+ const second = useSafeAddressInfo(() => SAFE_ADDRESS)
+ await vi.waitFor(() => expect(second.safeInfo.value).not.toBeNull())
+ })
+
+ it('probes distinct addresses independently', async () => {
+ const client = safeClient()
+ stubEnvironment(client)
+ const useSafeAddressInfo = await importComposable()
+
+ const first = useSafeAddressInfo(() => SAFE_ADDRESS)
+ const second = useSafeAddressInfo(() => OTHER_ADDRESS)
+ await vi.waitFor(() => expect(first.safeInfo.value).not.toBeNull())
+ await vi.waitFor(() => expect(second.safeInfo.value).not.toBeNull())
+
+ const probedAddresses = new Set(client.calls.map(call => call.address.toLowerCase()))
+ expect(probedAddresses).toEqual(new Set([SAFE_ADDRESS, OTHER_ADDRESS]))
+ })
+})
diff --git a/tests/utils/onchain-lookup-cache.test.ts b/tests/utils/onchain-lookup-cache.test.ts
new file mode 100644
index 000000000..51b3264a1
--- /dev/null
+++ b/tests/utils/onchain-lookup-cache.test.ts
@@ -0,0 +1,79 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { createOnchainLookupCache } from '~/utils/onchain-lookup-cache'
+
+describe('createOnchainLookupCache', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('caches probe results within the TTL', async () => {
+ const cache = createOnchainLookupCache(1_000)
+ const probe = vi.fn(async () => 'value')
+
+ await expect(cache.load('1:0xabc', probe)).resolves.toBe('value')
+ await expect(cache.load('1:0xabc', probe)).resolves.toBe('value')
+ expect(probe).toHaveBeenCalledTimes(1)
+ expect(cache.read('1:0xabc')).toBe('value')
+ })
+
+ it('re-probes after the TTL expires', async () => {
+ const cache = createOnchainLookupCache(1_000)
+ const probe = vi.fn(async () => 'value')
+
+ await cache.load('1:0xabc', probe)
+ vi.advanceTimersByTime(1_001)
+ await cache.load('1:0xabc', probe)
+ expect(probe).toHaveBeenCalledTimes(2)
+ })
+
+ it('shares one probe between concurrent loads', async () => {
+ const cache = createOnchainLookupCache(1_000)
+ let resolveProbe!: (value: string) => void
+ const probe = vi.fn(() => new Promise((resolve) => {
+ resolveProbe = resolve
+ }))
+
+ const first = cache.load('1:0xabc', probe)
+ const second = cache.load('1:0xabc', probe)
+ resolveProbe('value')
+ await expect(first).resolves.toBe('value')
+ await expect(second).resolves.toBe('value')
+ expect(probe).toHaveBeenCalledTimes(1)
+ })
+
+ it('keys entries independently', async () => {
+ const cache = createOnchainLookupCache(1_000)
+
+ await cache.load('1:0xabc', async () => 'mainnet')
+ await cache.load('8453:0xabc', async () => 'base')
+ expect(cache.read('1:0xabc')).toBe('mainnet')
+ expect(cache.read('8453:0xabc')).toBe('base')
+ })
+
+ it('does not cache probe failures and retries on the next load', async () => {
+ const cache = createOnchainLookupCache(1_000)
+ const probe = vi.fn()
+ .mockRejectedValueOnce(new Error('rpc down'))
+ .mockResolvedValueOnce('value')
+
+ await expect(cache.load('1:0xabc', probe)).resolves.toBeUndefined()
+ expect(cache.read('1:0xabc')).toBeUndefined()
+ await expect(cache.load('1:0xabc', probe)).resolves.toBe('value')
+ expect(probe).toHaveBeenCalledTimes(2)
+ })
+
+ it('serves the expired entry when a refresh probe fails', async () => {
+ const cache = createOnchainLookupCache(1_000)
+ const probe = vi.fn()
+ .mockResolvedValueOnce('stale-but-real')
+ .mockRejectedValueOnce(new Error('rpc down'))
+
+ await cache.load('1:0xabc', probe)
+ vi.advanceTimersByTime(1_001)
+ await expect(cache.load('1:0xabc', probe)).resolves.toBe('stale-but-real')
+ })
+})
diff --git a/tests/utils/safe-account.test.ts b/tests/utils/safe-account.test.ts
new file mode 100644
index 000000000..c61dd0375
--- /dev/null
+++ b/tests/utils/safe-account.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it } from 'vitest'
+import { getSafeSingletonVersion, resolveSafeAccountInfo } from '~/utils/safe-account'
+
+const SAFE_ADDRESS = '0x00000000000000000000000000000000000000aa'
+const SAFE_141_SINGLETON = '0x41675C099F32341bf84BFc5382aF534df5C7461a'
+const SAFE_130_L2_SINGLETON = '0x3E5c63644E683549055b9Be8653de26E0B4CD36E'
+const UNKNOWN_CONTRACT = '0x1111111111111111111111111111111111111111'
+
+const OWNERS = [
+ '0x00000000000000000000000000000000000000a1',
+ '0x00000000000000000000000000000000000000a2',
+ '0x00000000000000000000000000000000000000a3',
+] as const
+
+describe('getSafeSingletonVersion', () => {
+ it('recognizes canonical singletons regardless of casing', () => {
+ expect(getSafeSingletonVersion(SAFE_141_SINGLETON)).toBe('1.4.1')
+ expect(getSafeSingletonVersion(SAFE_141_SINGLETON.toLowerCase())).toBe('1.4.1')
+ expect(getSafeSingletonVersion(SAFE_130_L2_SINGLETON)).toBe('1.3.0')
+ })
+
+ it('rejects unknown addresses and empty input', () => {
+ expect(getSafeSingletonVersion(UNKNOWN_CONTRACT)).toBeUndefined()
+ expect(getSafeSingletonVersion(null)).toBeUndefined()
+ expect(getSafeSingletonVersion(undefined)).toBeUndefined()
+ })
+})
+
+describe('resolveSafeAccountInfo', () => {
+ it('resolves a valid Safe configuration', () => {
+ const info = resolveSafeAccountInfo(SAFE_ADDRESS, SAFE_141_SINGLETON, 2n, OWNERS)
+ expect(info).toEqual({
+ version: '1.4.1',
+ threshold: 2,
+ owners: OWNERS,
+ })
+ })
+
+ it('rejects an unknown singleton even with plausible threshold/owners', () => {
+ expect(resolveSafeAccountInfo(SAFE_ADDRESS, UNKNOWN_CONTRACT, 2n, OWNERS)).toBeNull()
+ })
+
+ it('rejects missing threshold or owners', () => {
+ expect(resolveSafeAccountInfo(SAFE_ADDRESS, SAFE_141_SINGLETON, null, OWNERS)).toBeNull()
+ expect(resolveSafeAccountInfo(SAFE_ADDRESS, SAFE_141_SINGLETON, 2n, null)).toBeNull()
+ })
+
+ it('rejects Safe-invariant violations from lookalikes', () => {
+ expect(resolveSafeAccountInfo(SAFE_ADDRESS, SAFE_141_SINGLETON, 0n, OWNERS)).toBeNull()
+ expect(resolveSafeAccountInfo(SAFE_ADDRESS, SAFE_141_SINGLETON, 4n, OWNERS)).toBeNull()
+ expect(resolveSafeAccountInfo(SAFE_ADDRESS, SAFE_141_SINGLETON, BigInt(Number.MAX_SAFE_INTEGER) + 1n, OWNERS)).toBeNull()
+ })
+
+ it('rejects owner lists a Safe cannot have', () => {
+ const zeroOwner = '0x0000000000000000000000000000000000000000' as const
+ const sentinelOwner = '0x0000000000000000000000000000000000000001' as const
+ const duplicateOwner = '0x00000000000000000000000000000000000000A1' as const
+ expect(resolveSafeAccountInfo(SAFE_ADDRESS, SAFE_141_SINGLETON, 1n, [...OWNERS, zeroOwner])).toBeNull()
+ expect(resolveSafeAccountInfo(SAFE_ADDRESS, SAFE_141_SINGLETON, 1n, [...OWNERS, sentinelOwner])).toBeNull()
+ expect(resolveSafeAccountInfo(SAFE_ADDRESS, SAFE_141_SINGLETON, 1n, [OWNERS[0], duplicateOwner])).toBeNull()
+ })
+
+ it('rejects self-ownership regardless of casing (OwnerManager GS203)', () => {
+ const selfOwner = SAFE_ADDRESS.toUpperCase().replace('0X', '0x') as `0x${string}`
+ expect(resolveSafeAccountInfo(SAFE_ADDRESS, SAFE_141_SINGLETON, 1n, [SAFE_ADDRESS as `0x${string}`])).toBeNull()
+ expect(resolveSafeAccountInfo(SAFE_ADDRESS, SAFE_141_SINGLETON, 1n, [...OWNERS, selfOwner])).toBeNull()
+ })
+})
diff --git a/utils/onchain-lookup-cache.ts b/utils/onchain-lookup-cache.ts
new file mode 100644
index 000000000..517f4a116
--- /dev/null
+++ b/utils/onchain-lookup-cache.ts
@@ -0,0 +1,58 @@
+import { ref } from 'vue'
+import { createInFlightDedup } from '~/utils/in-flight'
+
+export type OnchainLookupCache = {
+ /**
+ * Read the cached value for a key. Reactive — computeds calling this
+ * re-evaluate when any lookup completes. `undefined` means "not resolved
+ * yet"; a cached `null` (if T includes it) is a resolved negative.
+ */
+ read: (key: string) => T | undefined
+ /**
+ * Resolve a key via `probe`, caching the result for `ttlMs`. Concurrent
+ * calls for the same key share one probe. A throwing probe (e.g. RPC
+ * transport failure) is NOT cached, so a later call retries instead of
+ * pinning a transient failure for the TTL.
+ */
+ load: (key: string, probe: () => Promise) => Promise
+}
+
+/**
+ * Module-scoped cache for small on-chain lookups keyed by `${chainId}:${address}`.
+ * Keys embed the chain, so chain switches need no invalidation — results land
+ * under their own key and stale probes can never overwrite fresher chains.
+ */
+export const createOnchainLookupCache = (ttlMs: number): OnchainLookupCache => {
+ const entries = new Map()
+ const inFlight = createInFlightDedup()
+ // Bumped after every completed probe so reactive readers re-evaluate.
+ const version = ref(0)
+
+ const read = (key: string): T | undefined => {
+ void version.value
+ return entries.get(key)?.value
+ }
+
+ const load = (key: string, probe: () => Promise): Promise => {
+ const cached = entries.get(key)
+ if (cached && Date.now() - cached.fetchedAt < ttlMs) {
+ return Promise.resolve(cached.value)
+ }
+
+ return inFlight.run(key, async () => {
+ try {
+ const value = await probe()
+ entries.set(key, { value, fetchedAt: Date.now() })
+ version.value++
+ return value
+ }
+ catch {
+ // Serve the expired entry (if any) rather than dropping the badge on
+ // a transient RPC failure; the next load() retries the probe.
+ return entries.get(key)?.value
+ }
+ })
+ }
+
+ return { read, load }
+}
diff --git a/utils/safe-account.ts b/utils/safe-account.ts
new file mode 100644
index 000000000..4f621074e
--- /dev/null
+++ b/utils/safe-account.ts
@@ -0,0 +1,81 @@
+import { zeroAddress, type Address } from 'viem'
+
+/** Safe's OwnerManager linked-list sentinel — never a legitimate owner. */
+const SENTINEL_OWNER = '0x0000000000000000000000000000000000000001'
+
+export type SafeAccountInfo = {
+ /** Safe contract version of the singleton the proxy points at, e.g. '1.4.1'. */
+ version: string
+ /** Number of owner signatures required to execute a transaction. */
+ threshold: number
+ owners: readonly Address[]
+}
+
+/**
+ * Canonical Safe singleton (implementation) deployments, lowercased.
+ *
+ * Safe singletons are deployed deterministically at identical addresses on
+ * every chain via the Safe Singleton Factory, so a single append-only list
+ * covers all networks. Includes the "eip155" v1.3.0 variants used on chains
+ * where the canonical deployment was not possible. zkSync-VM variants are
+ * omitted — no supported chain needs them.
+ *
+ * Source: https://github.com/safe-global/safe-deployments
+ *
+ * v1.0.0 proxies predate the `masterCopy()` fallback special-case, so v1.0.0
+ * Safes fail the probe and simply get no badge.
+ */
+const SAFE_SINGLETON_VERSIONS: Record = {
+ '0x34cfac646f301356faa8b21e94227e3583fe3f5f': '1.1.1',
+ '0x6851d6fdfafd08c0295c392436245e5bc78b0185': '1.2.0',
+ '0xd9db270c1b5e3bd161e8c8503c55ceabee709552': '1.3.0',
+ '0x69f4d1788e39c87893c980c06edf4b7f686e2938': '1.3.0',
+ '0x3e5c63644e683549055b9be8653de26e0b4cd36e': '1.3.0',
+ '0xfb1bffc9d739b8d520daf37df666da4c687191ea': '1.3.0',
+ '0x41675c099f32341bf84bfc5382af534df5c7461a': '1.4.1',
+ '0x29fcb43b46531bca003ddc8fcb67ffe91900c762': '1.4.1',
+ '0xff51a5898e281db6dfc7855790607438df2ca44b': '1.5.0',
+ '0xedd160febbd92e350d4d398fb636302fccd67c7e': '1.5.0',
+}
+
+export const getSafeSingletonVersion = (
+ singleton: string | null | undefined,
+): string | undefined =>
+ singleton ? SAFE_SINGLETON_VERSIONS[singleton.toLowerCase()] : undefined
+
+/**
+ * Validate raw probe results into a SafeAccountInfo, or null when the address
+ * is not a recognizable Safe. Threshold/owner invariants mirror what the Safe
+ * contracts themselves enforce (OwnerManager forbids zero/sentinel/duplicate
+ * owners and self-ownership, GS203); anything violating them is a lookalike.
+ *
+ * This is a display heuristic: a purpose-built contract can still mimic all
+ * probed functions. Never use the result for authorization decisions.
+ */
+export const resolveSafeAccountInfo = (
+ account: string,
+ singleton: string | null | undefined,
+ threshold: bigint | null | undefined,
+ owners: readonly Address[] | null | undefined,
+): SafeAccountInfo | null => {
+ const version = getSafeSingletonVersion(singleton)
+ if (!version) return null
+ if (threshold == null || owners == null) return null
+
+ const thresholdCount = Number(threshold)
+ if (!Number.isSafeInteger(thresholdCount) || thresholdCount < 1) return null
+ if (owners.length < thresholdCount) return null
+
+ const normalizedAccount = account.toLowerCase()
+ const normalizedOwners = owners.map(owner => owner.toLowerCase())
+ if (normalizedOwners.some(owner =>
+ owner === zeroAddress || owner === SENTINEL_OWNER || owner === normalizedAccount,
+ )) return null
+ if (new Set(normalizedOwners).size !== normalizedOwners.length) return null
+
+ return {
+ version,
+ threshold: thresholdCount,
+ owners,
+ }
+}