Skip to content

Commit 30d54ce

Browse files
committed
feat: add stake pools service
1 parent 26b1fe4 commit 30d54ce

File tree

4 files changed

+351
-2
lines changed

4 files changed

+351
-2
lines changed

packages/cardano/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
"buffer": "6.0.3",
5959
"classnames": "2.3.1",
6060
"dayjs": "1.10.7",
61+
"fuse.js": "^7.1.0",
6162
"graphql": "^15.6.1",
6263
"graphql-request": "3.5.0",
6364
"lodash": "4.17.21",

packages/cardano/src/wallet/lib/providers.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import {
2020
import {
2121
CardanoWsClient,
2222
CreateHttpProviderConfig,
23-
stakePoolHttpProvider,
2423
TxSubmitApiProvider,
2524
BlockfrostClientConfig,
2625
RateLimiter,
@@ -39,6 +38,7 @@ import { BlockfrostAddressDiscovery } from '@wallet/lib/blockfrost-address-disco
3938
import { WalletProvidersDependencies } from './cardano-wallet';
4039
import { BlockfrostInputResolver } from './blockfrost-input-resolver';
4140
import { initHandleService } from './handleService';
41+
import { initStakePoolService } from './stakePoolService';
4242

4343
const createTxSubmitProvider = (
4444
blockfrostClient: BlockfrostClient,
@@ -159,7 +159,7 @@ export const createProviders = ({
159159
logger
160160
});
161161
const rewardsProvider = new BlockfrostRewardsProvider(blockfrostClient, logger);
162-
const stakePoolProvider = stakePoolHttpProvider(httpProviderConfig);
162+
const stakePoolProvider = initStakePoolService({ blockfrostClient, extensionLocalStorage });
163163
const txSubmitProvider = createTxSubmitProvider(blockfrostClient, httpProviderConfig, customSubmitTxUrl);
164164
const dRepProvider = new BlockfrostDRepProvider(blockfrostClient, logger);
165165

Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
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+
};

yarn.lock

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12566,6 +12566,7 @@ __metadata:
1256612566
buffer: 6.0.3
1256712567
classnames: 2.3.1
1256812568
dayjs: 1.10.7
12569+
fuse.js: ^7.1.0
1256912570
graphql: ^15.6.1
1257012571
graphql-request: 3.5.0
1257112572
lodash: 4.17.21
@@ -37019,6 +37020,13 @@ __metadata:
3701937020
languageName: node
3702037021
linkType: hard
3702137022

37023+
"fuse.js@npm:^7.1.0":
37024+
version: 7.1.0
37025+
resolution: "fuse.js@npm:7.1.0"
37026+
checksum: e0c7d6833d336f9facd9359a888170b90c418b2f46c6cb389fd7dbc708712a2d1c5f30b5fea13b634a090a21f69d746eb0ceaff545b11ca088d3e5058c2a8e15
37027+
languageName: node
37028+
linkType: hard
37029+
3702237030
"fx-runner@npm:1.4.0":
3702337031
version: 1.4.0
3702437032
resolution: "fx-runner@npm:1.4.0"

0 commit comments

Comments
 (0)