|
| 1 | +/* eslint-disable camelcase */ |
| 2 | +/* eslint-disable unicorn/no-array-callback-reference */ |
| 3 | +/* eslint-disable unicorn/no-null */ |
| 4 | + |
| 5 | +import { BlockfrostClient } from '@cardano-sdk/cardano-services-client'; |
| 6 | +import { Cardano, Paginated, QueryStakePoolsArgs, StakePoolProvider, StakePoolStats } from '@cardano-sdk/core'; |
| 7 | +import { fromSerializableObject, toSerializableObject } from '@cardano-sdk/util'; |
| 8 | +import { Storage } from 'webextension-polyfill'; |
| 9 | +import type { Responses } from '@blockfrost/blockfrost-js'; |
| 10 | +import Fuse from 'fuse.js'; |
| 11 | + |
| 12 | +const BF_API_PAGE_SIZE = 100; |
| 13 | +const CACHE_KEY = 'stake-pool-service-data'; |
| 14 | +const EMPTY_TEXT_PLACEHOLDER = '\uFFFD'; |
| 15 | +const ONE_DAY = 86_400_000; // One day in milliseconds |
| 16 | + |
| 17 | +const FUZZY_SEARCH_OPTIONS = { |
| 18 | + distance: 255, |
| 19 | + fieldNormWeight: 1, |
| 20 | + ignoreFieldNorm: false, |
| 21 | + keys: [ |
| 22 | + { name: 'description', weight: 4 }, |
| 23 | + { name: 'homepage', weight: 1 }, |
| 24 | + { name: 'name', weight: 6 }, |
| 25 | + { name: 'id', weight: 1 }, |
| 26 | + { name: 'ticker', weight: 10 } |
| 27 | + ], |
| 28 | + location: 0, |
| 29 | + minMatchCharLength: 1, |
| 30 | + threshold: 0.3, |
| 31 | + useExtendedSearch: false, |
| 32 | + weights: { description: 4, homepage: 1, name: 6, poolId: 1, ticker: 10 } |
| 33 | +}; |
| 34 | + |
| 35 | +// API response actually includes more attributes than Responses['pool_list_extended'] |
| 36 | +interface BlockFrostPool { |
| 37 | + pool_id: string; |
| 38 | + hex: string; |
| 39 | + active_stake: string; |
| 40 | + live_stake: string; |
| 41 | + live_saturation: number; |
| 42 | + blocks_minted: number; |
| 43 | + margin_cost: number; |
| 44 | + fixed_cost: string; |
| 45 | + declared_pledge: string; |
| 46 | + metadata?: { |
| 47 | + hash: string; |
| 48 | + url: string; |
| 49 | + ticker: string; |
| 50 | + name: string; |
| 51 | + description: string; |
| 52 | + homepage: string; |
| 53 | + }; |
| 54 | +} |
| 55 | + |
| 56 | +interface StakePoolCachedData { |
| 57 | + lastFetchTime: number; |
| 58 | + poolDetails: Map<Cardano.PoolId, { details: Responses['pool']; lastFetchTime: number }>; |
| 59 | + stakePools: Cardano.StakePool[]; |
| 60 | + stats: StakePoolStats; |
| 61 | +} |
| 62 | + |
| 63 | +type CachedData = { [key in typeof CACHE_KEY]: StakePoolCachedData }; |
| 64 | + |
| 65 | +const toCore = (pool: BlockFrostPool): Cardano.StakePool => ({ |
| 66 | + cost: BigInt(pool.fixed_cost), |
| 67 | + hexId: pool.hex as Cardano.PoolIdHex, |
| 68 | + id: pool.pool_id as Cardano.PoolId, |
| 69 | + margin: Cardano.FractionUtils.toFraction(pool.margin_cost), |
| 70 | + metadata: pool.metadata, |
| 71 | + metrics: { |
| 72 | + blocksCreated: pool.blocks_minted, |
| 73 | + delegators: 0, |
| 74 | + livePledge: BigInt(0), |
| 75 | + saturation: pool.live_saturation, |
| 76 | + size: { active: 0, live: 0 }, |
| 77 | + stake: { active: BigInt(pool.active_stake), live: BigInt(pool.live_stake) }, |
| 78 | + lastRos: 0, |
| 79 | + ros: 0 |
| 80 | + }, |
| 81 | + owners: [], |
| 82 | + pledge: BigInt(pool.declared_pledge), |
| 83 | + relays: [], |
| 84 | + rewardAccount: '' as Cardano.RewardAccount, |
| 85 | + status: Cardano.StakePoolStatus.Active, |
| 86 | + vrf: '' as Cardano.VrfVkHex |
| 87 | +}); |
| 88 | + |
| 89 | +type IdentifierType = Required<Required<QueryStakePoolsArgs>['filters']>['identifier']; |
| 90 | + |
| 91 | +const filterByIdentifier = (identifier: IdentifierType) => (pool: Cardano.StakePool) => |
| 92 | + identifier.values.some((value) => { |
| 93 | + if (value.id) return pool.id === value.id; |
| 94 | + |
| 95 | + return value.name |
| 96 | + ? pool.metadata?.name.toLowerCase() === value.name.toLowerCase() |
| 97 | + : pool.metadata?.ticker.toLowerCase() === value.ticker?.toLowerCase(); |
| 98 | + }); |
| 99 | + |
| 100 | +const enrichStakePool = (stakePools: Cardano.StakePool[], id: Cardano.PoolId, details: Responses['pool']) => { |
| 101 | + const stakePool = stakePools.find((pool) => pool.id === id); |
| 102 | + |
| 103 | + if (stakePool?.metrics) stakePool.metrics.livePledge = BigInt(details.live_pledge); |
| 104 | +}; |
| 105 | + |
| 106 | +// eslint-disable-next-line sonarjs/cognitive-complexity, complexity |
| 107 | +const getSorter = (sort: QueryStakePoolsArgs['sort']) => { |
| 108 | + if (!sort) return null; |
| 109 | + |
| 110 | + const { field, order } = sort; |
| 111 | + |
| 112 | + if (order === 'asc') { |
| 113 | + switch (field) { |
| 114 | + case 'name': |
| 115 | + return (a: Cardano.StakePool, b: Cardano.StakePool) => { |
| 116 | + const nameA = a.metadata?.name || EMPTY_TEXT_PLACEHOLDER; |
| 117 | + const nameB = b.metadata?.name || EMPTY_TEXT_PLACEHOLDER; |
| 118 | + return nameA.localeCompare(nameB); |
| 119 | + }; |
| 120 | + case 'ticker': |
| 121 | + return (a: Cardano.StakePool, b: Cardano.StakePool) => { |
| 122 | + const tickerA = a.metadata?.ticker || EMPTY_TEXT_PLACEHOLDER; |
| 123 | + const tickerB = b.metadata?.ticker || EMPTY_TEXT_PLACEHOLDER; |
| 124 | + return tickerA.localeCompare(tickerB); |
| 125 | + }; |
| 126 | + case 'cost': |
| 127 | + return (a: Cardano.StakePool, b: Cardano.StakePool) => Number(a.cost - b.cost); |
| 128 | + case 'margin': |
| 129 | + return (a: Cardano.StakePool, b: Cardano.StakePool) => { |
| 130 | + const marginA = Cardano.FractionUtils.toNumber(a.margin); |
| 131 | + const marginB = Cardano.FractionUtils.toNumber(b.margin); |
| 132 | + return marginA - marginB; |
| 133 | + }; |
| 134 | + case 'pledge': |
| 135 | + return (a: Cardano.StakePool, b: Cardano.StakePool) => Number(a.pledge - b.pledge); |
| 136 | + case 'blocks': |
| 137 | + return (a: Cardano.StakePool, b: Cardano.StakePool) => |
| 138 | + (a.metrics?.blocksCreated || 0) - (b.metrics?.blocksCreated || 0); |
| 139 | + case 'liveStake': |
| 140 | + return (a: Cardano.StakePool, b: Cardano.StakePool) => |
| 141 | + Number((a.metrics?.stake.live || BigInt(0)) - (b.metrics?.stake.live || BigInt(0))); |
| 142 | + case 'saturation': |
| 143 | + return (a: Cardano.StakePool, b: Cardano.StakePool) => |
| 144 | + (a.metrics?.saturation || 0) - (b.metrics?.saturation || 0); |
| 145 | + } |
| 146 | + } else { |
| 147 | + switch (field) { |
| 148 | + case 'name': |
| 149 | + return (a: Cardano.StakePool, b: Cardano.StakePool) => { |
| 150 | + const nameA = a.metadata?.name || ''; |
| 151 | + const nameB = b.metadata?.name || ''; |
| 152 | + return nameB.localeCompare(nameA); |
| 153 | + }; |
| 154 | + case 'ticker': |
| 155 | + return (a: Cardano.StakePool, b: Cardano.StakePool) => { |
| 156 | + const tickerA = a.metadata?.ticker || ''; |
| 157 | + const tickerB = b.metadata?.ticker || ''; |
| 158 | + return tickerB.localeCompare(tickerA); |
| 159 | + }; |
| 160 | + case 'cost': |
| 161 | + return (a: Cardano.StakePool, b: Cardano.StakePool) => Number(b.cost - a.cost); |
| 162 | + case 'margin': |
| 163 | + return (a: Cardano.StakePool, b: Cardano.StakePool) => { |
| 164 | + const marginA = Cardano.FractionUtils.toNumber(a.margin); |
| 165 | + const marginB = Cardano.FractionUtils.toNumber(b.margin); |
| 166 | + return marginB - marginA; |
| 167 | + }; |
| 168 | + case 'pledge': |
| 169 | + return (a: Cardano.StakePool, b: Cardano.StakePool) => Number(b.pledge - a.pledge); |
| 170 | + case 'blocks': |
| 171 | + return (a: Cardano.StakePool, b: Cardano.StakePool) => |
| 172 | + (b.metrics?.blocksCreated || 0) - (a.metrics?.blocksCreated || 0); |
| 173 | + case 'liveStake': |
| 174 | + return (a: Cardano.StakePool, b: Cardano.StakePool) => |
| 175 | + Number((b.metrics?.stake.live || BigInt(0)) - (a.metrics?.stake.live || BigInt(0))); |
| 176 | + case 'saturation': |
| 177 | + return (a: Cardano.StakePool, b: Cardano.StakePool) => |
| 178 | + (b.metrics?.saturation || 0) - (a.metrics?.saturation || 0); |
| 179 | + } |
| 180 | + } |
| 181 | + |
| 182 | + return null; |
| 183 | +}; |
| 184 | + |
| 185 | +export interface StakePoolServiceProps { |
| 186 | + blockfrostClient: BlockfrostClient; |
| 187 | + extensionLocalStorage: Storage.LocalStorageArea; |
| 188 | +} |
| 189 | + |
| 190 | +export const initStakePoolService = (props: StakePoolServiceProps): StakePoolProvider => { |
| 191 | + const { blockfrostClient, extensionLocalStorage } = props; |
| 192 | + |
| 193 | + let cachedData: Promise<StakePoolCachedData>; |
| 194 | + let fetchingData = false; |
| 195 | + let healthStatus = false; |
| 196 | + let index: Fuse<{ id: Cardano.PoolId }>; |
| 197 | + let poolDetails: StakePoolCachedData['poolDetails'] = new Map(); |
| 198 | + |
| 199 | + const createIndex = (stakePools: Cardano.StakePool[]) => { |
| 200 | + const data = stakePools.map(({ id, metadata }) => { |
| 201 | + const { description, homepage, name, ticker } = metadata || {}; |
| 202 | + |
| 203 | + return { description, homepage, id, name, ticker }; |
| 204 | + }); |
| 205 | + |
| 206 | + index = new Fuse(data, FUZZY_SEARCH_OPTIONS, Fuse.createIndex(FUZZY_SEARCH_OPTIONS.keys, data)); |
| 207 | + }; |
| 208 | + |
| 209 | + const saveData = async (data: StakePoolCachedData) => { |
| 210 | + await extensionLocalStorage.set({ [CACHE_KEY]: toSerializableObject(data) }); |
| 211 | + cachedData = Promise.resolve(data); |
| 212 | + }; |
| 213 | + |
| 214 | + const fetchPages = async (firstPage = 1): Promise<Cardano.StakePool[]> => { |
| 215 | + const url = `pools/extended?count=${BF_API_PAGE_SIZE}&page=${firstPage}`; |
| 216 | + const response = await blockfrostClient.request<BlockFrostPool[]>(url); |
| 217 | + const nextPages = response.length === BF_API_PAGE_SIZE ? fetchPages(firstPage + 1) : Promise.resolve([]); |
| 218 | + const stakePools = response.map(toCore); |
| 219 | + |
| 220 | + return [...stakePools, ...(await nextPages)]; |
| 221 | + }; |
| 222 | + |
| 223 | + const fetchData = async (): Promise<StakePoolCachedData> => { |
| 224 | + fetchingData = true; |
| 225 | + |
| 226 | + let data: StakePoolCachedData; |
| 227 | + |
| 228 | + try { |
| 229 | + const stakePools = await fetchPages(); |
| 230 | + const retiringPools = await blockfrostClient.request<Responses['pool_list_retire']>('pools/retiring'); |
| 231 | + const retiringPoolIds = new Set(retiringPools.map(({ pool_id }) => pool_id)); |
| 232 | + |
| 233 | + for (const pool of stakePools) if (retiringPoolIds.has(pool.id)) pool.status = Cardano.StakePoolStatus.Retiring; |
| 234 | + for (const [poolId, { details }] of poolDetails) enrichStakePool(stakePools, poolId, details); |
| 235 | + |
| 236 | + // TODO |
| 237 | + // LW-13053 |
| 238 | + // Compute ROS |
| 239 | + |
| 240 | + const active = stakePools.length - retiringPools.length; |
| 241 | + data = { |
| 242 | + lastFetchTime: Date.now(), |
| 243 | + poolDetails, |
| 244 | + stakePools, |
| 245 | + stats: { qty: { activating: 0, active, retired: 0, retiring: retiringPools.length } } |
| 246 | + }; |
| 247 | + |
| 248 | + createIndex(stakePools); |
| 249 | + await saveData(data); |
| 250 | + healthStatus = true; |
| 251 | + } finally { |
| 252 | + fetchingData = false; |
| 253 | + } |
| 254 | + |
| 255 | + return data; |
| 256 | + }; |
| 257 | + |
| 258 | + const asyncFetchData = () => (fetchingData ? undefined : fetchData().catch(console.error)); |
| 259 | + |
| 260 | + const getCachedData = async () => { |
| 261 | + let data: StakePoolCachedData | undefined; |
| 262 | + |
| 263 | + try { |
| 264 | + data = await cachedData; |
| 265 | + |
| 266 | + for (const [poolId, { lastFetchTime }] of poolDetails) |
| 267 | + if (lastFetchTime < Date.now() - ONE_DAY) poolDetails.delete(poolId); |
| 268 | + } finally { |
| 269 | + if (!data || data.lastFetchTime < Date.now() - ONE_DAY) asyncFetchData(); |
| 270 | + } |
| 271 | + |
| 272 | + return data; |
| 273 | + }; |
| 274 | + |
| 275 | + const queryStakePools = async (args: QueryStakePoolsArgs): Promise<Paginated<Cardano.StakePool>> => { |
| 276 | + const data = await getCachedData(); |
| 277 | + const { stakePools } = data; |
| 278 | + const { filters, pagination, sort } = args; |
| 279 | + const { identifier, pledgeMet, text } = filters || {}; |
| 280 | + const sorter = getSorter(sort); |
| 281 | + |
| 282 | + if (identifier) { |
| 283 | + for (const { id } of identifier.values) |
| 284 | + if (id && !poolDetails.has(id)) { |
| 285 | + const details = await blockfrostClient.request<Responses['pool']>(`pools/${id}`); |
| 286 | + |
| 287 | + poolDetails.set(id, { details, lastFetchTime: Date.now() }); |
| 288 | + enrichStakePool(stakePools, id, details); |
| 289 | + } |
| 290 | + |
| 291 | + await saveData({ ...data, poolDetails }); |
| 292 | + } |
| 293 | + |
| 294 | + let result = identifier && !text ? stakePools.filter(filterByIdentifier(identifier)) : [...stakePools]; |
| 295 | + |
| 296 | + // This mitigates the lack of live pledge in the BF bulk API response |
| 297 | + // If the live stake is lower than the declared pledge, the pledge is not met as well |
| 298 | + if (pledgeMet) result = result.filter((pool) => pool.pledge <= (pool.metrics?.stake.live || BigInt(0))); |
| 299 | + |
| 300 | + if (text) { |
| 301 | + const fuzzy = index.search(text); |
| 302 | + const idMap = new Map(result.map((pool) => [pool.id, pool])); |
| 303 | + |
| 304 | + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion |
| 305 | + result = fuzzy.filter(({ item: { id } }) => idMap.has(id)).map(({ item: { id } }) => idMap.get(id)!); |
| 306 | + } |
| 307 | + |
| 308 | + if (sorter) result.sort(sorter); |
| 309 | + |
| 310 | + return { |
| 311 | + totalResultCount: result.length, |
| 312 | + pageResults: result.slice(pagination.startAt, pagination.startAt + pagination.limit) |
| 313 | + }; |
| 314 | + }; |
| 315 | + |
| 316 | + const init = async () => { |
| 317 | + const storageObject = (await extensionLocalStorage.get(CACHE_KEY)) as CachedData; |
| 318 | + let data = fromSerializableObject<StakePoolCachedData>(storageObject[CACHE_KEY]); |
| 319 | + |
| 320 | + if (!data) data = await fetchData(); |
| 321 | + else { |
| 322 | + if (data.lastFetchTime < Date.now() - ONE_DAY) asyncFetchData(); |
| 323 | + |
| 324 | + poolDetails = data.poolDetails; |
| 325 | + createIndex(data.stakePools); |
| 326 | + } |
| 327 | + |
| 328 | + healthStatus = true; |
| 329 | + |
| 330 | + return data; |
| 331 | + }; |
| 332 | + |
| 333 | + cachedData = init(); |
| 334 | + |
| 335 | + return { |
| 336 | + healthCheck: () => Promise.resolve({ ok: healthStatus }), |
| 337 | + queryStakePools, |
| 338 | + stakePoolStats: async () => (await getCachedData()).stats |
| 339 | + }; |
| 340 | +}; |
0 commit comments