@1001-digital/components 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/evm/composables/ens.ts +90 -0
- package/src/evm/composables/gasPrice.ts +36 -0
- package/src/evm/composables/priceFeed.ts +107 -0
- package/src/evm/index.ts +13 -0
- package/src/evm/utils/cache.ts +59 -0
- package/src/evm/utils/ens.ts +99 -0
- package/src/evm/utils/price.ts +15 -0
package/package.json
CHANGED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { getPublicClient } from '@wagmi/core'
|
|
2
|
+
import type { Config } from '@wagmi/vue'
|
|
3
|
+
import { ensCache, fetchEnsFromIndexer, fetchEnsFromChain, ENS_KEYS_AVATAR, ENS_KEYS_PROFILE } from '../utils/ens'
|
|
4
|
+
import type { EnsProfile } from '../utils/ens'
|
|
5
|
+
|
|
6
|
+
type EnsMode = 'indexer' | 'chain'
|
|
7
|
+
|
|
8
|
+
interface UseEnsOptions {
|
|
9
|
+
mode?: MaybeRefOrGetter<EnsMode | undefined>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface EnsRuntimeConfig {
|
|
13
|
+
ens?: { indexer1?: string, indexer2?: string, indexer3?: string }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getIndexerUrls(config: EnsRuntimeConfig): string[] {
|
|
17
|
+
if (!config.ens) return []
|
|
18
|
+
return [config.ens.indexer1, config.ens.indexer2, config.ens.indexer3].filter(Boolean) as string[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function resolve(
|
|
22
|
+
identifier: string,
|
|
23
|
+
strategies: EnsMode[],
|
|
24
|
+
indexerUrls: string[],
|
|
25
|
+
wagmi: Config,
|
|
26
|
+
chainKeys: string[],
|
|
27
|
+
): Promise<EnsProfile> {
|
|
28
|
+
for (const strategy of strategies) {
|
|
29
|
+
try {
|
|
30
|
+
if (strategy === 'indexer') {
|
|
31
|
+
if (!indexerUrls.length) continue
|
|
32
|
+
return await fetchEnsFromIndexer(identifier, indexerUrls)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (strategy === 'chain') {
|
|
36
|
+
const client = getPublicClient(wagmi, { chainId: 1 })
|
|
37
|
+
if (!client) continue
|
|
38
|
+
return await fetchEnsFromChain(identifier, client, chainKeys)
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
continue
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { address: identifier, ens: null, data: null }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function useEnsBase(
|
|
49
|
+
tier: string,
|
|
50
|
+
identifier: MaybeRefOrGetter<string | undefined>,
|
|
51
|
+
chainKeys: string[],
|
|
52
|
+
options: UseEnsOptions = {},
|
|
53
|
+
) {
|
|
54
|
+
const { $wagmi } = useNuxtApp()
|
|
55
|
+
const appConfig = useAppConfig()
|
|
56
|
+
const runtimeConfig = useRuntimeConfig()
|
|
57
|
+
|
|
58
|
+
const mode = computed<EnsMode>(() => toValue(options.mode) || appConfig.evm?.ens?.mode || 'indexer')
|
|
59
|
+
const indexerUrls = computed(() => getIndexerUrls(runtimeConfig.public.evm as EnsRuntimeConfig))
|
|
60
|
+
const cacheKey = computed(() => `ens-${tier}-${toValue(identifier)}`)
|
|
61
|
+
|
|
62
|
+
return useAsyncData(
|
|
63
|
+
cacheKey.value,
|
|
64
|
+
async () => {
|
|
65
|
+
const id = toValue(identifier)
|
|
66
|
+
if (!id) return null
|
|
67
|
+
|
|
68
|
+
const strategies: EnsMode[] = mode.value === 'indexer'
|
|
69
|
+
? ['indexer', 'chain']
|
|
70
|
+
: ['chain', 'indexer']
|
|
71
|
+
|
|
72
|
+
return ensCache.fetch(cacheKey.value, () =>
|
|
73
|
+
resolve(id, strategies, indexerUrls.value, $wagmi as Config, chainKeys),
|
|
74
|
+
)
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
watch: [() => toValue(identifier)],
|
|
78
|
+
getCachedData: () => ensCache.get(cacheKey.value) ?? undefined,
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const useEns = (identifier: MaybeRefOrGetter<string | undefined>, options?: UseEnsOptions) =>
|
|
84
|
+
useEnsBase('resolve', identifier, [], options)
|
|
85
|
+
|
|
86
|
+
export const useEnsWithAvatar = (identifier: MaybeRefOrGetter<string | undefined>, options?: UseEnsOptions) =>
|
|
87
|
+
useEnsBase('avatar', identifier, [...ENS_KEYS_AVATAR], options)
|
|
88
|
+
|
|
89
|
+
export const useEnsProfile = (identifier: MaybeRefOrGetter<string | undefined>, options?: UseEnsOptions) =>
|
|
90
|
+
useEnsBase('profile', identifier, [...ENS_KEYS_PROFILE], options)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { ref, computed, watch, type Ref, type WatchStopHandle } from 'vue'
|
|
2
|
+
import { formatEther, formatGwei } from 'viem'
|
|
3
|
+
import { getGasPrice } from '@wagmi/core'
|
|
4
|
+
import { useConfig, useBlockNumber } from '@wagmi/vue'
|
|
5
|
+
|
|
6
|
+
let priceWatcher: WatchStopHandle | null = null
|
|
7
|
+
const price: Ref<bigint> = ref(0n)
|
|
8
|
+
|
|
9
|
+
export const useGasPrice = () => {
|
|
10
|
+
const config = useConfig()
|
|
11
|
+
const { data: blockNumber } = useBlockNumber()
|
|
12
|
+
|
|
13
|
+
const updatePrice = async () => {
|
|
14
|
+
price.value = await getGasPrice(config)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (!priceWatcher) {
|
|
18
|
+
updatePrice()
|
|
19
|
+
priceWatcher = watch(blockNumber, () => updatePrice())
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const unitPrice = computed(() => ({
|
|
23
|
+
wei: price.value,
|
|
24
|
+
gwei: formatGwei(price.value),
|
|
25
|
+
eth: formatEther(price.value),
|
|
26
|
+
|
|
27
|
+
formatted: {
|
|
28
|
+
gwei: price.value > 2_000_000_000_000n
|
|
29
|
+
? Math.round(parseFloat(formatGwei(price.value)))
|
|
30
|
+
: parseFloat(formatGwei(price.value)).toFixed(1),
|
|
31
|
+
eth: formatEther(price.value),
|
|
32
|
+
},
|
|
33
|
+
}))
|
|
34
|
+
|
|
35
|
+
return unitPrice
|
|
36
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { reactive, computed } from 'vue'
|
|
2
|
+
import { readContract } from '@wagmi/core'
|
|
3
|
+
import { useConfig } from '@wagmi/vue'
|
|
4
|
+
import { parseJSON, stringifyJSON, formatPrice } from '../utils/price'
|
|
5
|
+
import { nowInSeconds } from '../../base/utils/time'
|
|
6
|
+
|
|
7
|
+
const CHAINLINK_ETH_USD_ABI = [
|
|
8
|
+
{
|
|
9
|
+
inputs: [],
|
|
10
|
+
name: 'latestRoundData',
|
|
11
|
+
outputs: [
|
|
12
|
+
{ internalType: 'uint80', name: 'roundId', type: 'uint80' },
|
|
13
|
+
{ internalType: 'int256', name: 'answer', type: 'int256' },
|
|
14
|
+
{ internalType: 'uint256', name: 'startedAt', type: 'uint256' },
|
|
15
|
+
{ internalType: 'uint256', name: 'updatedAt', type: 'uint256' },
|
|
16
|
+
{ internalType: 'uint80', name: 'answeredInRound', type: 'uint80' },
|
|
17
|
+
],
|
|
18
|
+
stateMutability: 'view',
|
|
19
|
+
type: 'function',
|
|
20
|
+
},
|
|
21
|
+
] as const
|
|
22
|
+
|
|
23
|
+
const CHAINLINK_ETH_USD = '0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419'
|
|
24
|
+
const STORAGE_KEY = 'evm:price-feed'
|
|
25
|
+
const CACHE_TTL = 3_600 // 1 hour in seconds
|
|
26
|
+
|
|
27
|
+
interface PriceFeedState {
|
|
28
|
+
ethUSDRaw: bigint | null
|
|
29
|
+
lastUpdated: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const state = reactive<PriceFeedState>({
|
|
33
|
+
ethUSDRaw: null,
|
|
34
|
+
lastUpdated: 0,
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
function loadFromStorage() {
|
|
38
|
+
if (typeof window === 'undefined') return
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const stored = localStorage.getItem(STORAGE_KEY)
|
|
42
|
+
if (!stored) return
|
|
43
|
+
|
|
44
|
+
const parsed = parseJSON(stored) as PriceFeedState
|
|
45
|
+
if (parsed.ethUSDRaw) state.ethUSDRaw = parsed.ethUSDRaw
|
|
46
|
+
if (parsed.lastUpdated) state.lastUpdated = parsed.lastUpdated
|
|
47
|
+
} catch {
|
|
48
|
+
// Ignore corrupted storage
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function saveToStorage() {
|
|
53
|
+
if (typeof window === 'undefined') return
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
localStorage.setItem(STORAGE_KEY, stringifyJSON({
|
|
57
|
+
ethUSDRaw: state.ethUSDRaw,
|
|
58
|
+
lastUpdated: state.lastUpdated,
|
|
59
|
+
}))
|
|
60
|
+
} catch {
|
|
61
|
+
// Ignore storage errors
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const usePriceFeed = () => {
|
|
66
|
+
const config = useConfig()
|
|
67
|
+
|
|
68
|
+
// Load cached data on first use
|
|
69
|
+
if (!state.lastUpdated) loadFromStorage()
|
|
70
|
+
|
|
71
|
+
const ethUSD = computed(() => state.ethUSDRaw ? state.ethUSDRaw / BigInt(1e8) : 0n)
|
|
72
|
+
const ethUSC = computed(() => state.ethUSDRaw ? state.ethUSDRaw / BigInt(1e6) : 0n)
|
|
73
|
+
const ethUSDFormatted = computed(() => formatPrice(Number(ethUSC.value) / 100, 2))
|
|
74
|
+
|
|
75
|
+
const weiToUSD = (wei: bigint) => {
|
|
76
|
+
const cents = (wei * (state.ethUSDRaw || 0n)) / (10n ** 18n) / (10n ** 6n)
|
|
77
|
+
return formatPrice(Number(cents) / 100, 2)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function fetchPrice() {
|
|
81
|
+
if (nowInSeconds() - state.lastUpdated < CACHE_TTL) return
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const [, answer] = await readContract(config, {
|
|
85
|
+
address: CHAINLINK_ETH_USD,
|
|
86
|
+
abi: CHAINLINK_ETH_USD_ABI,
|
|
87
|
+
functionName: 'latestRoundData',
|
|
88
|
+
chainId: 1,
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
state.ethUSDRaw = answer
|
|
92
|
+
state.lastUpdated = nowInSeconds()
|
|
93
|
+
saveToStorage()
|
|
94
|
+
} catch (error) {
|
|
95
|
+
console.warn('Error fetching ETH/USD price:', error)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
ethUSDRaw: computed(() => state.ethUSDRaw),
|
|
101
|
+
ethUSD,
|
|
102
|
+
ethUSC,
|
|
103
|
+
ethUSDFormatted,
|
|
104
|
+
weiToUSD,
|
|
105
|
+
fetchPrice,
|
|
106
|
+
}
|
|
107
|
+
}
|
package/src/evm/index.ts
CHANGED
|
@@ -3,9 +3,19 @@ export { EvmConfigKey, defaultEvmConfig, useEvmConfig } from './config'
|
|
|
3
3
|
export type { EvmConfig, EvmChainConfig } from './config'
|
|
4
4
|
|
|
5
5
|
// Utils
|
|
6
|
+
export { createCache } from './utils/cache'
|
|
6
7
|
export { shortAddress } from './utils/addresses'
|
|
7
8
|
export { resolveChain } from './utils/chains'
|
|
8
9
|
export { formatETH } from './utils/format-eth'
|
|
10
|
+
export {
|
|
11
|
+
ensCache,
|
|
12
|
+
fetchEnsFromIndexer,
|
|
13
|
+
fetchEnsFromChain,
|
|
14
|
+
ENS_KEYS_AVATAR,
|
|
15
|
+
ENS_KEYS_PROFILE,
|
|
16
|
+
} from './utils/ens'
|
|
17
|
+
export type { EnsProfile } from './utils/ens'
|
|
18
|
+
export { stringifyJSON, parseJSON, formatPrice } from './utils/price'
|
|
9
19
|
|
|
10
20
|
// Composables
|
|
11
21
|
export { useBaseURL } from './composables/base'
|
|
@@ -15,6 +25,9 @@ export {
|
|
|
15
25
|
useBlockExplorer,
|
|
16
26
|
useEnsureChainIdCheck,
|
|
17
27
|
} from './composables/chainId'
|
|
28
|
+
export { useEns, useEnsWithAvatar, useEnsProfile } from './composables/ens'
|
|
29
|
+
export { useGasPrice } from './composables/gasPrice'
|
|
30
|
+
export { usePriceFeed } from './composables/priceFeed'
|
|
18
31
|
|
|
19
32
|
// Components
|
|
20
33
|
export { default as EvmAccount } from './components/EvmAccount.vue'
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
interface CacheEntry<T> {
|
|
2
|
+
data: T
|
|
3
|
+
expiresAt: number
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function createCache<T>(ttl: number, max: number) {
|
|
7
|
+
const entries = new Map<string, CacheEntry<T>>()
|
|
8
|
+
const pending = new Map<string, Promise<T>>()
|
|
9
|
+
|
|
10
|
+
function prune() {
|
|
11
|
+
const now = Date.now()
|
|
12
|
+
for (const [key, entry] of entries) {
|
|
13
|
+
if (entry.expiresAt <= now) entries.delete(key)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (entries.size > max) {
|
|
17
|
+
const excess = entries.size - max
|
|
18
|
+
const keys = entries.keys()
|
|
19
|
+
for (let i = 0; i < excess; i++) {
|
|
20
|
+
entries.delete(keys.next().value!)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function get(key: string): T | undefined {
|
|
26
|
+
const entry = entries.get(key)
|
|
27
|
+
if (!entry) return undefined
|
|
28
|
+
if (entry.expiresAt <= Date.now()) {
|
|
29
|
+
entries.delete(key)
|
|
30
|
+
return undefined
|
|
31
|
+
}
|
|
32
|
+
return entry.data
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function fetch(key: string, fn: () => Promise<T>): Promise<T> {
|
|
36
|
+
const cached = get(key)
|
|
37
|
+
if (cached) return Promise.resolve(cached)
|
|
38
|
+
|
|
39
|
+
const inflight = pending.get(key)
|
|
40
|
+
if (inflight) return inflight
|
|
41
|
+
|
|
42
|
+
const promise = fn()
|
|
43
|
+
.then((result) => {
|
|
44
|
+
entries.set(key, { data: result, expiresAt: Date.now() + ttl })
|
|
45
|
+
pending.delete(key)
|
|
46
|
+
if (entries.size > max) prune()
|
|
47
|
+
return result
|
|
48
|
+
})
|
|
49
|
+
.catch((err) => {
|
|
50
|
+
pending.delete(key)
|
|
51
|
+
throw err
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
pending.set(key, promise)
|
|
55
|
+
return promise
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { get, fetch }
|
|
59
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { isAddress, type PublicClient, type Address } from 'viem'
|
|
2
|
+
import { normalize } from 'viem/ens'
|
|
3
|
+
import { createCache } from './cache'
|
|
4
|
+
|
|
5
|
+
// --- Types ---
|
|
6
|
+
|
|
7
|
+
export interface EnsProfile {
|
|
8
|
+
address: string
|
|
9
|
+
ens: string | null
|
|
10
|
+
data: {
|
|
11
|
+
avatar: string
|
|
12
|
+
header: string
|
|
13
|
+
description: string
|
|
14
|
+
links: {
|
|
15
|
+
url: string
|
|
16
|
+
email: string
|
|
17
|
+
twitter: string
|
|
18
|
+
github: string
|
|
19
|
+
}
|
|
20
|
+
} | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// --- Text record keys ---
|
|
24
|
+
|
|
25
|
+
const ALL_KEYS = ['avatar', 'header', 'description', 'url', 'email', 'com.twitter', 'com.github'] as const
|
|
26
|
+
|
|
27
|
+
export const ENS_KEYS_AVATAR = ['avatar'] as const
|
|
28
|
+
export const ENS_KEYS_PROFILE = [...ALL_KEYS]
|
|
29
|
+
|
|
30
|
+
// --- Cache ---
|
|
31
|
+
|
|
32
|
+
export const ensCache = createCache<EnsProfile>(5 * 60 * 1000, 500)
|
|
33
|
+
|
|
34
|
+
// --- Fetchers ---
|
|
35
|
+
|
|
36
|
+
export async function fetchEnsFromIndexer(
|
|
37
|
+
identifier: string,
|
|
38
|
+
urls: string[],
|
|
39
|
+
): Promise<EnsProfile> {
|
|
40
|
+
let lastError: Error | undefined
|
|
41
|
+
|
|
42
|
+
for (const url of urls) {
|
|
43
|
+
try {
|
|
44
|
+
return await $fetch<EnsProfile>(`${url}/${identifier}`)
|
|
45
|
+
} catch (err) {
|
|
46
|
+
lastError = err as Error
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
throw lastError ?? new Error('No indexer URLs provided')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function fetchEnsFromChain(
|
|
54
|
+
identifier: string,
|
|
55
|
+
client: PublicClient,
|
|
56
|
+
keys: string[] = [],
|
|
57
|
+
): Promise<EnsProfile> {
|
|
58
|
+
const isAddr = isAddress(identifier)
|
|
59
|
+
|
|
60
|
+
let address: string
|
|
61
|
+
let ens: string | null
|
|
62
|
+
|
|
63
|
+
if (isAddr) {
|
|
64
|
+
address = identifier
|
|
65
|
+
ens = await client.getEnsName({ address: identifier as Address }) ?? null
|
|
66
|
+
} else {
|
|
67
|
+
ens = identifier
|
|
68
|
+
const resolved = await client.getEnsAddress({ name: normalize(identifier) })
|
|
69
|
+
if (!resolved) return { address: '', ens, data: null }
|
|
70
|
+
address = resolved
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!ens || !keys.length) return { address, ens: ens ?? null, data: null }
|
|
74
|
+
|
|
75
|
+
const name = normalize(ens)
|
|
76
|
+
const results = await Promise.all(
|
|
77
|
+
keys.map(key => client.getEnsText({ name, key }).catch(() => null)),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return { address, ens, data: toProfileData(keys, results.map(r => r || '')) }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// --- Helpers ---
|
|
84
|
+
|
|
85
|
+
function toProfileData(keys: string[], results: string[]): EnsProfile['data'] {
|
|
86
|
+
const get = (key: string) => results[keys.indexOf(key)] || ''
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
avatar: get('avatar'),
|
|
90
|
+
header: get('header'),
|
|
91
|
+
description: get('description'),
|
|
92
|
+
links: {
|
|
93
|
+
url: get('url'),
|
|
94
|
+
email: get('email'),
|
|
95
|
+
twitter: get('com.twitter'),
|
|
96
|
+
github: get('com.github'),
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const replacer = (_: string, value: unknown) => {
|
|
2
|
+
if (typeof value === 'bigint') return value.toString() + 'n'
|
|
3
|
+
return value
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const reviver = (_: string, value: unknown) => {
|
|
7
|
+
if (typeof value === 'string' && /^\d+n$/.test(value)) return BigInt(value.slice(0, -1))
|
|
8
|
+
return value
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const stringifyJSON = (obj: unknown): string => JSON.stringify(obj, replacer)
|
|
12
|
+
export const parseJSON = (json: string): unknown => JSON.parse(json, reviver)
|
|
13
|
+
|
|
14
|
+
export const formatPrice = (num: number, digits: number = 2) =>
|
|
15
|
+
num?.toLocaleString('en-US', { maximumFractionDigits: digits })
|