From 728f8271f0c22abe8c302624ae89785d2b4b037f Mon Sep 17 00:00:00 2001 From: Noah Prince <83885631+ChewingGlass@users.noreply.github.com> Date: Thu, 10 Aug 2023 09:01:47 -0500 Subject: [PATCH] feat(#415): Fix chunking of claims of large #s of hotspots, and add a progress bar (#418) * feat(#415): Add progress bar to bulk claims and fix blockhash expiration * feat(#415): Add progress bar to bulk claims and fix blockhash expiration * feat(#415): Add progress bar to bulk claims and fix blockhash expiration * Feature complete * Add missing file --- src/components/ProgressBar.tsx | 68 ++++++ .../collectables/ClaimAllRewardsScreen.tsx | 65 +++++- .../collectables/ClaimingRewardsScreen.tsx | 23 +- .../collectables/CollectablesTopTabs.tsx | 1 + src/features/collectables/HotspotList.tsx | 8 +- src/hooks/useEntityKey.ts | 13 +- src/hooks/useHntSolConvert.ts | 4 +- src/hooks/useKeyToAsset.ts | 21 ++ src/hooks/useMetaplexMetadata.ts | 9 +- src/hooks/useSubmitTxn.ts | 26 +-- src/locales/en.ts | 6 +- src/navigation/TabBarNavigator.tsx | 1 + src/solana/SolanaProvider.tsx | 38 +++- src/store/slices/solanaSlice.ts | 206 ++++++++++++++++-- src/types/solana.ts | 3 + src/utils/solanaUtils.ts | 100 ++------- 16 files changed, 445 insertions(+), 147 deletions(-) create mode 100644 src/components/ProgressBar.tsx create mode 100644 src/hooks/useKeyToAsset.ts diff --git a/src/components/ProgressBar.tsx b/src/components/ProgressBar.tsx new file mode 100644 index 000000000..bdc2f4710 --- /dev/null +++ b/src/components/ProgressBar.tsx @@ -0,0 +1,68 @@ +import { BoxProps } from '@shopify/restyle' +import { Theme } from '@theme/theme' +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { LayoutChangeEvent, LayoutRectangle } from 'react-native' +import { + useAnimatedStyle, + useSharedValue, + withSpring, +} from 'react-native-reanimated' +import { ReAnimatedBox } from './AnimatedBox' +import Box from './Box' + +const ProgressBar = ({ + progress: progressIn, + ...rest +}: BoxProps & { progress: number }) => { + const HEIGHT = 15 + + const [progressRect, setProgressRect] = useState() + + const handleLayout = useCallback((e: LayoutChangeEvent) => { + e.persist() + + setProgressRect(e.nativeEvent.layout) + }, []) + + const PROGRESS_WIDTH = useMemo( + () => (progressRect ? progressRect.width : 0), + [progressRect], + ) + + const width = useSharedValue(0) + + useEffect(() => { + // withRepeat to repeat the animation + width.value = withSpring((progressIn / 100) * PROGRESS_WIDTH) + }, [PROGRESS_WIDTH, width, progressIn]) + + const progress = useAnimatedStyle(() => { + return { + width: width.value, + } + }) + + return ( + + + + + + ) +} + +export default ProgressBar diff --git a/src/features/collectables/ClaimAllRewardsScreen.tsx b/src/features/collectables/ClaimAllRewardsScreen.tsx index 43d03f559..e398de7d6 100644 --- a/src/features/collectables/ClaimAllRewardsScreen.tsx +++ b/src/features/collectables/ClaimAllRewardsScreen.tsx @@ -6,6 +6,14 @@ import CircleLoader from '@components/CircleLoader' import { DelayedFadeIn } from '@components/FadeInOut' import RewardItem from '@components/RewardItem' import Text from '@components/Text' +import { + IOT_MINT, + MOBILE_MINT, + sendAndConfirmWithRetry, + toNumber, +} from '@helium/spl-utils' +import useAlert from '@hooks/useAlert' +import { useHntSolConvert } from '@hooks/useHntSolConvert' import useHotspots from '@hooks/useHotspots' import useSubmitTxn from '@hooks/useSubmitTxn' import { useNavigation } from '@react-navigation/native' @@ -13,9 +21,9 @@ import { IOT_LAZY_KEY, MOBILE_LAZY_KEY } from '@utils/constants' import BN from 'bn.js' import React, { memo, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { IOT_MINT, MOBILE_MINT, toNumber } from '@helium/spl-utils' -import { CollectableNavigationProp } from './collectablesTypes' +import { useSolana } from '../../solana/SolanaProvider' import { BalanceChange } from '../../solana/walletSignBottomSheetTypes' +import { CollectableNavigationProp } from './collectablesTypes' const ClaimAllRewardsScreen = () => { const { t } = useTranslation() @@ -23,6 +31,44 @@ const ClaimAllRewardsScreen = () => { const [redeeming, setRedeeming] = useState(false) const [claimError, setClaimError] = useState() const { submitClaimAllRewards } = useSubmitTxn() + const { + hntEstimateLoading, + hntSolConvertTransaction, + hntEstimate, + hasEnoughSol, + } = useHntSolConvert() + const { showOKCancelAlert } = useAlert() + const { anchorProvider } = useSolana() + const showHNTConversionAlert = useCallback(async () => { + if (!anchorProvider || !hntSolConvertTransaction) return + + const decision = await showOKCancelAlert({ + title: t('browserScreen.insufficientSolToPayForFees'), + message: t('browserScreen.wouldYouLikeToConvert', { + amount: hntEstimate, + ticker: 'HNT', + }), + }) + + if (!decision) return + const signed = await anchorProvider.wallet.signTransaction( + hntSolConvertTransaction, + ) + await sendAndConfirmWithRetry( + anchorProvider.connection, + signed.serialize(), + { + skipPreflight: true, + }, + 'confirmed', + ) + }, [ + anchorProvider, + hntSolConvertTransaction, + showOKCancelAlert, + t, + hntEstimate, + ]) const { hotspots, @@ -45,6 +91,9 @@ const ClaimAllRewardsScreen = () => { try { setClaimError(undefined) setRedeeming(true) + if (!hasEnoughSol) { + await showHNTConversionAlert() + } const balanceChanges: BalanceChange[] = [] @@ -78,11 +127,13 @@ const ClaimAllRewardsScreen = () => { setRedeeming(false) } }, [ - navigation, + hasEnoughSol, + pendingIotRewards, + pendingMobileRewards, submitClaimAllRewards, hotspotsWithMeta, - pendingMobileRewards, - pendingIotRewards, + navigation, + showHNTConversionAlert, ]) const addAllToAccountDisabled = useMemo(() => { @@ -161,7 +212,9 @@ const ClaimAllRewardsScreen = () => { titleColor="black" marginHorizontal="l" onPress={onClaimRewards} - disabled={addAllToAccountDisabled || redeeming} + disabled={ + addAllToAccountDisabled || redeeming || hntEstimateLoading + } TrailingComponent={ redeeming ? ( diff --git a/src/features/collectables/ClaimingRewardsScreen.tsx b/src/features/collectables/ClaimingRewardsScreen.tsx index 4f4fb7784..af7222bc0 100644 --- a/src/features/collectables/ClaimingRewardsScreen.tsx +++ b/src/features/collectables/ClaimingRewardsScreen.tsx @@ -5,6 +5,7 @@ import Box from '@components/Box' import ButtonPressable from '@components/ButtonPressable' import { DelayedFadeIn } from '@components/FadeInOut' import IndeterminateProgressBar from '@components/IndeterminateProgressBar' +import ProgressBar from '@components/ProgressBar' import Text from '@components/Text' import { useSolOwnedAmount } from '@helium/helium-react-hooks' import { useBN } from '@hooks/useBN' @@ -191,7 +192,27 @@ const ClaimingRewardsScreen = () => { {t('collectablesScreen.claimingRewardsBody')} - + {typeof solanaPayment.progress !== 'undefined' ? ( + + + + {solanaPayment.progress.text} + + + ) : ( + + )} )} diff --git a/src/features/collectables/CollectablesTopTabs.tsx b/src/features/collectables/CollectablesTopTabs.tsx index f45a4317f..394cf8c5e 100644 --- a/src/features/collectables/CollectablesTopTabs.tsx +++ b/src/features/collectables/CollectablesTopTabs.tsx @@ -31,6 +31,7 @@ const CollectablesTopTabs = () => { const screenOpts = useCallback( ({ route }: { route: RouteProp }) => ({ + lazy: true, headerShown: false, tabBarLabelStyle: { fontFamily: Font.medium, diff --git a/src/features/collectables/HotspotList.tsx b/src/features/collectables/HotspotList.tsx index f9e90729f..a08c9c5ce 100644 --- a/src/features/collectables/HotspotList.tsx +++ b/src/features/collectables/HotspotList.tsx @@ -183,12 +183,12 @@ const HotspotList = () => { hasPressedState={false} /> diff --git a/src/hooks/useEntityKey.ts b/src/hooks/useEntityKey.ts index 2d3eb26c6..d6e134d46 100644 --- a/src/hooks/useEntityKey.ts +++ b/src/hooks/useEntityKey.ts @@ -1,14 +1,9 @@ -import { useEffect, useState } from 'react' +import { decodeEntityKey } from '@helium/helium-entity-manager-sdk' import { HotspotWithPendingRewards } from '../types/solana' +import { useKeyToAsset } from './useKeyToAsset' export const useEntityKey = (hotspot: HotspotWithPendingRewards) => { - const [entityKey, setEntityKey] = useState() + const { info: kta } = useKeyToAsset(hotspot?.id) - useEffect(() => { - if (hotspot) { - setEntityKey(hotspot.content.json_uri.split('/').slice(-1)[0]) - } - }, [hotspot, setEntityKey]) - - return entityKey + return kta ? decodeEntityKey(kta.entityKey, kta.keySerialization) : undefined } diff --git a/src/hooks/useHntSolConvert.ts b/src/hooks/useHntSolConvert.ts index a352c0a82..4494e5565 100644 --- a/src/hooks/useHntSolConvert.ts +++ b/src/hooks/useHntSolConvert.ts @@ -41,11 +41,11 @@ export function useHntSolConvert() { }, [baseUrl]) const hasEnoughSol = useMemo(() => { - if (!hntBalance || !solBalance || !hntEstimate) return true + if (!hntBalance || !hntEstimate) return true if (hntBalance.lt(hntEstimate)) return true - return solBalance.gt(new BN(0.02 * LAMPORTS_PER_SOL)) + return (solBalance || new BN(0)).gt(new BN(0.02 * LAMPORTS_PER_SOL)) }, [hntBalance, solBalance, hntEstimate]) const { diff --git a/src/hooks/useKeyToAsset.ts b/src/hooks/useKeyToAsset.ts new file mode 100644 index 000000000..354fff239 --- /dev/null +++ b/src/hooks/useKeyToAsset.ts @@ -0,0 +1,21 @@ +import { + mobileInfoKey, + rewardableEntityConfigKey, +} from '@helium/helium-entity-manager-sdk' +import { useAnchorAccount } from '@helium/helium-react-hooks' +import { HeliumEntityManager } from '@helium/idls/lib/types/helium_entity_manager' +import { MOBILE_SUB_DAO_KEY } from '@utils/constants' + +const type = 'keyToAssetV0' + +export const useKeyToAsset = (entityKey: string | undefined) => { + const [mobileConfigKey] = rewardableEntityConfigKey( + MOBILE_SUB_DAO_KEY, + 'MOBILE', + ) + const [mobileInfo] = mobileInfoKey(mobileConfigKey, entityKey || '') + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + return useAnchorAccount(mobileInfo, type) +} diff --git a/src/hooks/useMetaplexMetadata.ts b/src/hooks/useMetaplexMetadata.ts index 9e69e9185..bdd965ca2 100644 --- a/src/hooks/useMetaplexMetadata.ts +++ b/src/hooks/useMetaplexMetadata.ts @@ -14,17 +14,16 @@ import { useAsync } from 'react-async-hook' const MPL_PID = new PublicKey('metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s') // eslint-disable-next-line @typescript-eslint/no-explicit-any -const cache: Record = {} +const cache: Record> = {} // eslint-disable-next-line @typescript-eslint/no-explicit-any -async function getMetadata(uri: string | undefined): Promise { +export function getMetadata(uri: string | undefined): Promise { if (uri) { if (!cache[uri]) { - const res = await fetch(uri) - const json = await res.json() - cache[uri] = json + cache[uri] = fetch(uri).then((res) => res.json()) } return cache[uri] } + return Promise.resolve(undefined) } export const METADATA_PARSER: TypedAccountParser = ( diff --git a/src/hooks/useSubmitTxn.ts b/src/hooks/useSubmitTxn.ts index 3bae6ba07..bdbeef351 100644 --- a/src/hooks/useSubmitTxn.ts +++ b/src/hooks/useSubmitTxn.ts @@ -310,33 +310,11 @@ export default () => { throw new Error(t('errors.account')) } - const txns = await solUtils.claimAllRewardsTxns( - anchorProvider, - lazyDistributors, - hotspots, - ) - - const serializedTxs = txns.map((txn) => - txn.serialize({ - requireAllSignatures: false, - }), - ) - - const decision = await walletSignBottomSheetRef.show({ - type: WalletStandardMessageTypes.signTransaction, - url: '', - additionalMessage: t('transactions.signClaimAllRewardsTxn'), - serializedTxs: serializedTxs.map(Buffer.from), - }) - - if (!decision) { - throw new Error('User rejected transaction') - } - dispatch( claimAllRewards({ account: currentAccount, - txns, + lazyDistributors, + hotspots, anchorProvider, cluster, }), diff --git a/src/locales/en.ts b/src/locales/en.ts index 036cee8d2..d024e0c69 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -176,7 +176,7 @@ export default { claimingRewardsBody: 'You can exit this screen while you wait. We’ll update your Wallet momentarily.', claimComplete: 'Rewards Claimed!', - claimCompleteBody: 'We’ve added your tokens to your wallet.', + claimCompleteBody: 'Your tokens have been added to your wallet.', claimError: 'Claim failed. Please try again later.', transferCollectableAlertTitle: 'Are you sure you will like to transfer your collectable?', @@ -219,7 +219,7 @@ export default { 'Warning: Load times may be affected when showing all hotspots per page.', twenty: '20', fifty: '50', - all: 'All', + thousand: '1000', copyEccCompact: 'Copy Hotspot Key', assertLocation: 'Assert Location', antennaSetup: 'Antenna Setup', @@ -332,7 +332,7 @@ export default { chooseTokenToSwap: 'Choose a token to swap', chooseTokenToReceive: 'Choose a token to receive', swapComplete: 'Tokens swapped!', - swapCompleteBody: 'We’ve updated the tokens on your wallet.', + swapCompleteBody: 'The tokens in your wallet have been updated.', swappingTokens: 'Swapping your tokens...', swappingTokensBody: 'You can exit this screen while you wait. We’ll update your Wallet momentarily.', diff --git a/src/navigation/TabBarNavigator.tsx b/src/navigation/TabBarNavigator.tsx index 94716f85f..2134934bc 100644 --- a/src/navigation/TabBarNavigator.tsx +++ b/src/navigation/TabBarNavigator.tsx @@ -201,6 +201,7 @@ const TabBarNavigator = () => { tabBar={(props: BottomTabBarProps) => } screenOptions={{ headerShown: false, + lazy: true, }} sceneContainerStyle={{ paddingBottom: NavBarHeight + bottom, diff --git a/src/solana/SolanaProvider.tsx b/src/solana/SolanaProvider.tsx index 92cdae432..238fd564b 100644 --- a/src/solana/SolanaProvider.tsx +++ b/src/solana/SolanaProvider.tsx @@ -5,7 +5,14 @@ import { init as initHem } from '@helium/helium-entity-manager-sdk' import { init as initHsd } from '@helium/helium-sub-daos-sdk' import { init as initLazy } from '@helium/lazy-distributor-sdk' import { DC_MINT, HNT_MINT } from '@helium/spl-utils' -import { Cluster, Transaction } from '@solana/web3.js' +import { + AccountInfo, + Cluster, + Commitment, + PublicKey, + RpcResponseAndContext, + Transaction, +} from '@solana/web3.js' import React, { ReactNode, createContext, @@ -85,13 +92,40 @@ const useSolanaHook = () => { const cache = useMemo(() => { if (!connection) return - return new AccountFetchCache({ + const c = new AccountFetchCache({ connection, delay: 100, commitment: 'confirmed', missingRefetchDelay: 60 * 1000, extendConnection: true, }) + const oldGetAccountinfoAndContext = + connection.getAccountInfoAndContext.bind(connection) + + // Anchor uses this call on .fetch and .fetchNullable even though it doesn't actually need the context. Add caching. + connection.getAccountInfoAndContext = async ( + publicKey: PublicKey, + com?: Commitment, + ): Promise | null>> => { + if ( + (com || connection.commitment) === 'confirmed' || + typeof (com || connection.commitment) === 'undefined' + ) { + const [result, dispose] = await c.searchAndWatch(publicKey) + setTimeout(dispose, 30 * 1000) // cache for 30s + return { + value: result?.account || null, + context: { + slot: 0, + }, + } + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return oldGetAccountinfoAndContext!(publicKey, com) + } + + return c }, [connection]) useEffect(() => { // Don't sub to hnt or dc they change a bunch diff --git a/src/store/slices/solanaSlice.ts b/src/store/slices/solanaSlice.ts index 75106a7d0..0b0d6dcae 100644 --- a/src/store/slices/solanaSlice.ts +++ b/src/store/slices/solanaSlice.ts @@ -1,22 +1,40 @@ +/* eslint-disable no-restricted-syntax */ +/* eslint-disable no-await-in-loop */ import { AnchorProvider } from '@coral-xyz/anchor' +import { + formBulkTransactions, + getBulkRewards, +} from '@helium/distributor-oracle' +import { + decodeEntityKey, + init, + keyToAssetForAsset, +} from '@helium/helium-entity-manager-sdk' +import * as lz from '@helium/lazy-distributor-sdk' import { bulkSendRawTransactions, bulkSendTransactions, + chunks, sendAndConfirmWithRetry, } from '@helium/spl-utils' import { + PayloadAction, SerializedError, createAsyncThunk, createSlice, } from '@reduxjs/toolkit' import { Cluster, + PublicKey, SignaturesForAddressOptions, Transaction, } from '@solana/web3.js' +import BN from 'bn.js' +import bs58 from 'bs58' import { first, last } from 'lodash' import { CSAccount } from '../../storage/cloudStorage' import { Activity } from '../../types/activity' +import { HotspotWithPendingRewards } from '../../types/solana' import * as Logger from '../../utils/logger' import * as solUtils from '../../utils/solanaUtils' import { postPayment } from '../../utils/walletApiV2' @@ -38,6 +56,7 @@ export type SolanaState = { error?: SerializedError success?: boolean signature?: string + progress?: { percent: number; text: string } // 0-100 } activity: { loading?: boolean @@ -80,7 +99,8 @@ type ClaimRewardInput = { type ClaimAllRewardsInput = { account: CSAccount - txns: Transaction[] + lazyDistributors: PublicKey[] + hotspots: HotspotWithPendingRewards[] anchorProvider: AnchorProvider cluster: Cluster } @@ -254,12 +274,7 @@ export const claimRewards = createAsyncThunk( { dispatch }, ) => { try { - const signed = await anchorProvider.wallet.signAllTransactions(txns) - - const signatures = await bulkSendRawTransactions( - anchorProvider.connection, - signed.map((s) => s.serialize()), - ) + const signatures = await bulkSendTransactions(anchorProvider, txns) postPayment({ signatures, cluster }) @@ -276,20 +291,170 @@ export const claimRewards = createAsyncThunk( }, ) +const CHUNK_SIZE = 25 export const claimAllRewards = createAsyncThunk( 'solana/claimAllRewards', async ( - { account, anchorProvider, cluster, txns }: ClaimAllRewardsInput, + { + account, + anchorProvider, + cluster, + lazyDistributors, + hotspots, + }: ClaimAllRewardsInput, { dispatch }, ) => { try { - const signed = await anchorProvider.wallet.signAllTransactions(txns) - - // eslint-disable-next-line no-await-in-loop - await bulkSendRawTransactions( - anchorProvider.connection, - signed.map((s) => s.serialize()), + const ret: string[] = [] + let triesRemaining = 10 + const program = await lz.init(anchorProvider) + const hemProgram = await init(anchorProvider) + + const mints = await Promise.all( + lazyDistributors.map(async (d) => { + return (await program.account.lazyDistributorV0.fetch(d)).rewardsMint + }), + ) + const ldToMint = lazyDistributors.reduce((acc, ld, index) => { + acc[ld.toBase58()] = mints[index] + return acc + }, {} as Record) + // One tx per hotspot per mint/lazy dist + const totalTxns = hotspots.reduce((acc, hotspot) => { + mints.forEach((mint) => { + if ( + hotspot.pendingRewards && + hotspot.pendingRewards[mint.toString()] && + new BN(hotspot.pendingRewards[mint.toString()]).gt(new BN(0)) + ) + acc += 1 + }) + return acc + }, 0) + dispatch( + solanaSlice.actions.setPaymentProgress({ + percent: 0, + text: 'Preparing transactions...', + }), ) + for (const lazyDistributor of lazyDistributors) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const mint = ldToMint[lazyDistributor.toBase58()]! + const hotspotsWithRewards = hotspots.filter( + (hotspot) => + hotspot.pendingRewards && + new BN(hotspot.pendingRewards[mint.toBase58()]).gt(new BN(0)), + ) + for (let chunk of chunks(hotspotsWithRewards, CHUNK_SIZE)) { + const thisRet: string[] = [] + // Continually send in bulk while resetting blockhash until we send them all + // eslint-disable-next-line no-constant-condition + while (true) { + dispatch( + solanaSlice.actions.setPaymentProgress({ + percent: ((ret.length + thisRet.length) * 100) / totalTxns, + text: `Preparing batch of ${chunk.length} transactions.\n${ + totalTxns - ret.length + } total transactions remaining.`, + }), + ) + const recentBlockhash = + // eslint-disable-next-line no-await-in-loop + await anchorProvider.connection.getLatestBlockhash('confirmed') + + const keyToAssets = chunk.map((h) => + keyToAssetForAsset(solUtils.toAsset(h)), + ) + const ktaAccs = await Promise.all( + keyToAssets.map((kta) => + hemProgram.account.keyToAssetV0.fetch(kta), + ), + ) + const entityKeys = ktaAccs.map( + (kta) => + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + decodeEntityKey(kta.entityKey, kta.keySerialization)!, + ) + + const rewards = await getBulkRewards( + program, + lazyDistributor, + entityKeys, + ) + dispatch( + solanaSlice.actions.setPaymentProgress({ + percent: ((ret.length + thisRet.length) * 100) / totalTxns, + text: `Sending batch of ${chunk.length} transactions.\n${ + totalTxns - ret.length + } total transactions remaining.`, + }), + ) + + const txns = await formBulkTransactions({ + program, + rewards, + assets: chunk.map((h) => new PublicKey(h.id)), + compressionAssetAccs: chunk.map(solUtils.toAsset), + lazyDistributor, + assetEndpoint: anchorProvider.connection.rpcEndpoint, + wallet: anchorProvider.wallet.publicKey, + }) + const signedTxs = await anchorProvider.wallet.signAllTransactions( + txns, + ) + // eslint-disable-next-line @typescript-eslint/no-loop-func + const txsWithSigs = signedTxs.map((tx, index) => { + return { + transaction: chunk[index], + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + sig: bs58.encode(tx.signatures[0]!.signature!), + } + }) + // eslint-disable-next-line no-await-in-loop + const confirmedTxs = await bulkSendRawTransactions( + anchorProvider.connection, + signedTxs.map((s) => s.serialize()), + ({ totalProgress }) => + dispatch( + solanaSlice.actions.setPaymentProgress({ + percent: + ((totalProgress + ret.length + thisRet.length) * 100) / + totalTxns, + text: `Confiming ${txns.length - totalProgress}/${ + txns.length + } transactions.\n${ + totalTxns - ret.length - thisRet.length + } total transactions remaining`, + }), + ), + recentBlockhash.lastValidBlockHeight, + // Hail mary, try with preflight enabled. Sometimes this causes + // errors that wouldn't otherwise happen + triesRemaining !== 1, + ) + thisRet.push(...confirmedTxs) + if (confirmedTxs.length === signedTxs.length) { + break + } + + const retSet = new Set(thisRet) + + chunk = txsWithSigs + .filter(({ sig }) => !retSet.has(sig)) + .map(({ transaction }) => transaction) + + triesRemaining -= 1 + if (triesRemaining <= 0) { + throw new Error( + `Failed to submit all txs after blockhashes expired, ${ + signedTxs.length - confirmedTxs.length + } remain`, + ) + } + } + ret.push(...thisRet) + } + } // If the claim is successful, we need to update the hotspots so pending rewards are updated. dispatch(fetchHotspots({ account, anchorProvider, cluster })) @@ -388,12 +553,21 @@ const solanaSlice = createSlice({ name: 'solana', initialState, reducers: { + setPaymentProgress: ( + state, + action: PayloadAction<{ percent: number; text: string }>, + ) => { + if (state.payment) { + state.payment.progress = action.payload + } + }, resetPayment: (state) => { state.payment = { success: false, loading: false, error: undefined, signature: undefined, + progress: undefined, } }, }, @@ -429,6 +603,7 @@ const solanaSlice = createSlice({ success: true, loading: false, error: undefined, + progress: undefined, } }) builder.addCase(claimRewards.rejected, (state, action) => { @@ -437,6 +612,7 @@ const solanaSlice = createSlice({ loading: false, error: action.error, signature: undefined, + progress: undefined, } }) builder.addCase(claimRewards.pending, (state, _action) => { @@ -445,6 +621,7 @@ const solanaSlice = createSlice({ loading: true, error: undefined, signature: undefined, + progress: undefined, } }) builder.addCase(claimRewards.fulfilled, (state, _action) => { @@ -454,6 +631,7 @@ const solanaSlice = createSlice({ loading: false, error: undefined, signature: signatures[0], + progress: undefined, } }) builder.addCase(sendAnchorTxn.rejected, (state, action) => { diff --git a/src/types/solana.ts b/src/types/solana.ts index 02d78188f..0d3efdd89 100644 --- a/src/types/solana.ts +++ b/src/types/solana.ts @@ -10,12 +10,15 @@ import { init as initHsd } from '@helium/helium-sub-daos-sdk' import { init as initDc } from '@helium/data-credits-sdk' import { init as initHem } from '@helium/helium-entity-manager-sdk' import { init as initLazy } from '@helium/lazy-distributor-sdk' +import { BulkRewards } from '@helium/distributor-oracle' import { TokenAmount } from '@solana/web3.js' import { Creator } from '@metaplex-foundation/mpl-bubblegum' export type HotspotWithPendingRewards = CompressedNFT & { // mint id to pending rewards pendingRewards: Record | undefined + // mint id to rewards + rewards: Record | undefined } export type HemProgram = Awaited> diff --git a/src/utils/solanaUtils.ts b/src/utils/solanaUtils.ts index 6a943279a..63c78af73 100644 --- a/src/utils/solanaUtils.ts +++ b/src/utils/solanaUtils.ts @@ -5,20 +5,19 @@ import { delegatedDataCreditsKey, escrowAccountKey, } from '@helium/data-credits-sdk' -import { - formBulkTransactions, - getBulkRewards, - getPendingRewards, -} from '@helium/distributor-oracle' +import { getPendingRewards } from '@helium/distributor-oracle' import { PROGRAM_ID as FanoutProgramId, fanoutKey, membershipCollectionKey, } from '@helium/fanout-sdk' import { + decodeEntityKey, entityCreatorKey, + init, init as initHem, iotInfoKey, + keyToAssetForAsset, keyToAssetKey, mobileInfoKey, rewardableEntityConfigKey, @@ -38,7 +37,6 @@ import { searchAssets, sendAndConfirmWithRetry, toBN, - truthy, } from '@helium/spl-utils' import * as tm from '@helium/treasury-management-sdk' import { @@ -46,7 +44,11 @@ import { registrarCollectionKey, registrarKey, } from '@helium/voter-stake-registry-sdk' -import { METADATA_PARSER, getMetadataId } from '@hooks/useMetaplexMetadata' +import { + METADATA_PARSER, + getMetadata, + getMetadataId, +} from '@hooks/useMetaplexMetadata' import { JsonMetadata, Metadata, Metaplex } from '@metaplex-foundation/js' import { PROGRAM_ID as BUBBLEGUM_PROGRAM_ID, @@ -1059,9 +1061,7 @@ export const getCompressedNFTMetadata = async ( const collectablesWithMetadata = await Promise.all( collectables.map(async (col) => { try { - const { data } = await axios.get(col.content.json_uri, { - timeout: 3000, - }) + const { data } = await getMetadata(col.content.json_uri) return { ...col, content: { @@ -1091,10 +1091,19 @@ export async function annotateWithPendingRewards( hotspots: CompressedNFT[], ): Promise { const program = await lz.init(provider) + const hemProgram = await init(provider) const dao = DAO_KEY - const entityKeys = hotspots.map((h) => { - return h.content.json_uri.split('/').slice(-1)[0] - }) + const keyToAssets = hotspots.map((h) => + keyToAssetForAsset(toAsset(h as CompressedNFT)), + ) + const ktaAccs = await Promise.all( + keyToAssets.map((kta) => hemProgram.account.keyToAssetV0.fetch(kta)), + ) + const entityKeys = ktaAccs.map( + (kta) => + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + decodeEntityKey(kta.entityKey, kta.keySerialization)!, + ) const mobileRewards = await getPendingRewards( program, @@ -1695,12 +1704,7 @@ export const updateEntityInfoTxn = async ({ } } -const chunks = (array: T[], size: number): T[][] => - Array.apply(0, new Array(Math.ceil(array.length / size))).map((_, index) => - array.slice(index * size, (index + 1) * size), - ) - -function toAsset(hotspot: HotspotWithPendingRewards): Asset { +export function toAsset(hotspot: CompressedNFT): Asset { return { ...hotspot, id: new PublicKey(hotspot.id), @@ -1726,61 +1730,3 @@ function toAsset(hotspot: HotspotWithPendingRewards): Asset { }, } } - -export async function claimAllRewardsTxns( - anchorProvider: AnchorProvider, - lazyDistributors: PublicKey[], - hotspots: HotspotWithPendingRewards[], -) { - try { - const { connection } = anchorProvider - const { publicKey: payer } = anchorProvider.wallet - const lazyProgram = await lz.init(anchorProvider) - let txns: Transaction[] | undefined - - // Use for loops to linearly order promises - // eslint-disable-next-line no-restricted-syntax - for (const lazyDistributor of lazyDistributors) { - const lazyDistributorAcc = - // eslint-disable-next-line no-await-in-loop - await lazyProgram.account.lazyDistributorV0.fetch(lazyDistributor) - // eslint-disable-next-line no-restricted-syntax - for (const chunk of chunks(hotspots, 25)) { - const entityKeys = chunk.map( - (h) => h.content.json_uri.split('/').slice(-1)[0], - ) - - // eslint-disable-next-line no-await-in-loop - const rewards = await getBulkRewards( - lazyProgram, - lazyDistributor, - entityKeys, - ) - - // eslint-disable-next-line no-await-in-loop - const txs = await formBulkTransactions({ - program: lazyProgram, - rewards, - assets: chunk.map((h) => new PublicKey(h.id)), - compressionAssetAccs: chunk.map(toAsset), - lazyDistributor, - lazyDistributorAcc, - assetEndpoint: connection.rpcEndpoint, - wallet: payer, - }) - - const validTxns = txs.filter(truthy) - txns = [...(txns || []), ...validTxns] - } - } - - if (!txns) { - throw new Error('Unable to form transactions') - } - - return txns - } catch (e) { - Logger.error(e) - throw e as Error - } -}