@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1001-digital/components",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "type": "module",
5
5
  "sideEffects": [
6
6
  "*.css"
@@ -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 })