// Copyright 2017-2026 @pezkuwi/app-teyrchains authors & contributors // SPDX-License-Identifier: Apache-2.0 import type { Option, StorageKey } from '@pezkuwi/types'; import type { BlockNumber, WinningData } from '@pezkuwi/types/interfaces'; import type { AuctionInfo, WinnerData, Winning } from './types.js'; import { useEffect, useRef, useState } from 'react'; import { createNamedHook, useApi, useBestNumber, useCall, useEventTrigger, useIsMountedRef } from '@pezkuwi/react-hooks'; import { BN, BN_ONE, BN_ZERO, u8aEq } from '@pezkuwi/util'; import { CROWD_PREFIX } from './constants.js'; import { useLeaseRanges } from './useLeaseRanges.js'; const FIRST_PARAM = [0]; function isNewWinners (a: WinnerData[], b: WinnerData[]): boolean { return JSON.stringify({ w: a }) !== JSON.stringify({ w: b }); } function isNewOrdering (a: WinnerData[], b: WinnerData[]): boolean { return a.length !== b.length || a.some(({ firstSlot, lastSlot, paraId }, index) => !paraId.eq(b[index].paraId) || !firstSlot.eq(b[index].firstSlot) || !lastSlot.eq(b[index].lastSlot) ); } function extractWinners (ranges: [number, number][], auctionInfo: AuctionInfo, optData: Option): WinnerData[] { return optData.isNone ? [] : optData.unwrap().reduce((winners, optEntry, index): WinnerData[] => { if (optEntry.isSome) { const [accountId, paraId, value] = optEntry.unwrap(); const period = auctionInfo.leasePeriod || BN_ZERO; const [first, last] = ranges[index]; winners.push({ accountId: accountId.toString(), firstSlot: period.addn(first), isCrowdloan: u8aEq(CROWD_PREFIX, accountId.subarray(0, CROWD_PREFIX.length)), key: paraId.toString(), lastSlot: period.addn(last), paraId, value }); } return winners; }, []); } function createWinning ({ endBlock }: AuctionInfo, blockOffset: BN | null | undefined, winners: WinnerData[]): Winning { return { blockNumber: endBlock && blockOffset ? blockOffset.add(endBlock) : blockOffset || BN_ZERO, blockOffset: blockOffset || BN_ZERO, total: winners.reduce((total, { value }) => total.iadd(value), new BN(0)), winners }; } function extractData (ranges: [number, number][], auctionInfo: AuctionInfo, values: [StorageKey<[BlockNumber]>, Option][]): Winning[] { return values .sort(([{ args: [a] }], [{ args: [b] }]) => a.cmp(b)) .reduce((all: Winning[], [{ args: [blockOffset] }, optData]): Winning[] => { const winners = extractWinners(ranges, auctionInfo, optData); winners.length && ( all.length === 0 || isNewWinners(winners, all[all.length - 1].winners) ) && all.push(createWinning(auctionInfo, blockOffset, winners)); return all; }, []) .reverse(); } function mergeCurrent (ranges: [number, number][], auctionInfo: AuctionInfo, prev: Winning[] | undefined, optCurrent: Option, blockOffset: BN): Winning[] | undefined { const current = createWinning(auctionInfo, blockOffset, extractWinners(ranges, auctionInfo, optCurrent)); if (current.winners.length) { if (!prev?.length) { return [current]; } if (isNewWinners(current.winners, prev[0].winners)) { if (isNewOrdering(current.winners, prev[0].winners)) { return [current, ...prev]; } prev[0] = current; return [...prev]; } } return prev; } function mergeFirst (ranges: [number, number][], auctionInfo: AuctionInfo, prev: Winning[] | undefined, optFirstData: Option): Winning[] | undefined { if (prev && prev.length <= 1) { const updated: Winning[] = prev || []; const firstEntry = createWinning(auctionInfo, null, extractWinners(ranges, auctionInfo, optFirstData)); if (!firstEntry.winners.length) { return updated; } else if (!updated.length) { return [firstEntry]; } updated[updated.length - 1] = firstEntry; return updated.slice(); } return prev; } function useWinningDataImpl (auctionInfo?: AuctionInfo): Winning[] | undefined { const { api } = useApi(); const mountedRef = useIsMountedRef(); const ranges = useLeaseRanges(); const [result, setResult] = useState(); const bestNumber = useBestNumber(); const trigger = useEventTrigger([api.events.auctions?.BidAccepted]); const triggerRef = useRef(trigger); const initialEntries = useCall<[StorageKey<[BlockNumber]>, Option][]>(api.query.auctions?.winning.entries); const optFirstData = useCall>(api.query.auctions?.winning, FIRST_PARAM); // should be fired once, all entries as an initial round useEffect((): void => { mountedRef.current && auctionInfo && initialEntries && setResult( extractData(ranges, auctionInfo, initialEntries) ); }, [auctionInfo, initialEntries, mountedRef, ranges]); // when block 0 changes, update (typically in non-ending-period, static otherwise) useEffect((): void => { mountedRef.current && auctionInfo && optFirstData && setResult((prev) => mergeFirst(ranges, auctionInfo, prev, optFirstData) ); }, [auctionInfo, optFirstData, mountedRef, ranges]); // on a bid event, get the new entry (assuming the event really triggered, i.e. not just a block) // and add it to the list when not duplicated. Additionally we cleanup after ourselves when endBlock // gets cleared useEffect((): void => { if (auctionInfo?.endBlock && bestNumber && bestNumber.gt(auctionInfo.endBlock) && triggerRef.current !== trigger) { const blockOffset = bestNumber.sub(auctionInfo.endBlock).iadd(BN_ONE); triggerRef.current = trigger; api.query.auctions ?.winning>(blockOffset) .then((optCurrent) => mountedRef.current && setResult((prev) => mergeCurrent(ranges, auctionInfo, prev, optCurrent, blockOffset) ) ) .catch(console.error); } }, [api, bestNumber, auctionInfo, mountedRef, ranges, trigger, triggerRef]); return result; } export default createNamedHook('useWinningData', useWinningDataImpl);