diff --git a/CHANGELOG.md b/CHANGELOG.md index 9446638f981..80f5b25a2b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/) ## [1.9.40] (https://github.com/rainbow-me/rainbow/releases/tag/v1.9.40) -### Fixed +### Fixed - Fixed a bug with speed up and cancel (#6133) diff --git a/src/components/asset-list/RecyclerAssetList2/Claimable.tsx b/src/components/asset-list/RecyclerAssetList2/Claimable.tsx index d543fb635ab..b9cda34cc2d 100644 --- a/src/components/asset-list/RecyclerAssetList2/Claimable.tsx +++ b/src/components/asset-list/RecyclerAssetList2/Claimable.tsx @@ -5,9 +5,13 @@ import { useClaimables } from '@/resources/addys/claimables/query'; import { FasterImageView } from '@candlefinance/faster-image'; import { ButtonPressAnimation } from '@/components/animations'; import { deviceUtils } from '@/utils'; +import Routes from '@/navigation/routesNames'; +import { ExtendedState } from './core/RawRecyclerList'; -export default function Claimable({ uniqueId }: { uniqueId: string }) { +export default React.memo(function Claimable({ uniqueId, extendedState }: { uniqueId: string; extendedState: ExtendedState }) { const { accountAddress, nativeCurrency } = useAccountSettings(); + const { navigate } = extendedState; + const { data = [] } = useClaimables( { address: accountAddress, @@ -25,6 +29,7 @@ export default function Claimable({ uniqueId }: { uniqueId: string }) { return ( navigate(Routes.CLAIM_CLAIMABLE_PANEL, { claimable })} scaleTo={0.96} paddingHorizontal="20px" justifyContent="space-between" @@ -68,4 +73,4 @@ export default function Claimable({ uniqueId }: { uniqueId: string }) { ); -} +}); diff --git a/src/components/asset-list/RecyclerAssetList2/ClaimablesListHeader.tsx b/src/components/asset-list/RecyclerAssetList2/ClaimablesListHeader.tsx index 964a58af799..9e2896b130f 100644 --- a/src/components/asset-list/RecyclerAssetList2/ClaimablesListHeader.tsx +++ b/src/components/asset-list/RecyclerAssetList2/ClaimablesListHeader.tsx @@ -90,4 +90,4 @@ ClaimablesListHeader.animationDuration = TokenFamilyHeaderAnimationDuration; ClaimablesListHeader.height = TokenFamilyHeaderHeight; -export default ClaimablesListHeader; +export default React.memo(ClaimablesListHeader); diff --git a/src/components/asset-list/RecyclerAssetList2/core/RowRenderer.tsx b/src/components/asset-list/RecyclerAssetList2/core/RowRenderer.tsx index b5805584840..e6bd18abcc3 100644 --- a/src/components/asset-list/RecyclerAssetList2/core/RowRenderer.tsx +++ b/src/components/asset-list/RecyclerAssetList2/core/RowRenderer.tsx @@ -175,7 +175,7 @@ function rowRenderer(type: CellType, { uid }: { uid: string }, _: unknown, exten case CellType.CLAIMABLE: { const { uniqueId } = data as ClaimableExtraData; - return ; + return ; } case CellType.LOADING_ASSETS: diff --git a/src/helpers/buildWalletSections.tsx b/src/helpers/buildWalletSections.tsx index a612150f20d..833b154fad7 100644 --- a/src/helpers/buildWalletSections.tsx +++ b/src/helpers/buildWalletSections.tsx @@ -1,13 +1,10 @@ import { createSelector } from 'reselect'; import { buildBriefCoinsList, buildBriefUniqueTokenList } from './assets'; import { NativeCurrencyKey, ParsedAddressAsset } from '@/entities'; -import { queryClient } from '@/react-query'; -import { positionsQueryKey } from '@/resources/defi/PositionsQuery'; import store from '@/redux/store'; import { ClaimableExtraData, PositionExtraData } from '@/components/asset-list/RecyclerAssetList2/core/ViewTypes'; import { DEFI_POSITIONS, CLAIMABLES, ExperimentalValue } from '@/config/experimental'; import { RainbowPositions } from '@/resources/defi/types'; -import { claimablesQueryKey } from '@/resources/addys/claimables/query'; import { Claimable } from '@/resources/addys/claimables/types'; import { add, convertAmountToNativeDisplay } from './utilities'; import { RainbowConfig } from '@/model/remoteConfig'; @@ -60,20 +57,24 @@ const isFetchingNftsSelector = (state: any) => state.isFetchingNfts; const listTypeSelector = (state: any) => state.listType; const remoteConfigSelector = (state: any) => state.remoteConfig; const experimentalConfigSelector = (state: any) => state.experimentalConfig; +const positionsSelector = (state: any) => state.positions; +const claimablesSelector = (state: any) => state.claimables; const buildBriefWalletSections = ( balanceSectionData: any, uniqueTokenFamiliesSection: any, remoteConfig: RainbowConfig, - experimentalConfig: Record + experimentalConfig: Record, + positions: RainbowPositions | undefined, + claimables: Claimable[] | undefined ) => { const { balanceSection, isEmpty, isLoadingUserAssets } = balanceSectionData; const positionsEnabled = experimentalConfig[DEFI_POSITIONS] && !IS_TEST; const claimablesEnabled = (remoteConfig.claimables || experimentalConfig[CLAIMABLES]) && !IS_TEST; - const positionSection = positionsEnabled ? withPositionsSection(isLoadingUserAssets) : []; - const claimablesSection = claimablesEnabled ? withClaimablesSection(isLoadingUserAssets) : []; + const positionSection = positionsEnabled ? withPositionsSection(positions, isLoadingUserAssets) : []; + const claimablesSection = claimablesEnabled ? withClaimablesSection(claimables, isLoadingUserAssets) : []; const sections = [balanceSection, claimablesSection, positionSection, uniqueTokenFamiliesSection]; const filteredSections = sections.filter(section => section.length !== 0).flat(1); @@ -84,10 +85,7 @@ const buildBriefWalletSections = ( }; }; -const withPositionsSection = (isLoadingUserAssets: boolean) => { - const { accountAddress: address, nativeCurrency: currency } = store.getState().settings; - const positionsObj: RainbowPositions | undefined = queryClient.getQueryData(positionsQueryKey({ address, currency })); - +const withPositionsSection = (positionsObj: RainbowPositions | undefined, isLoadingUserAssets: boolean) => { const result: PositionExtraData[] = []; const sortedPositions = positionsObj?.positions?.sort((a, b) => (a.totals.totals.amount > b.totals.totals.amount ? -1 : 1)); sortedPositions?.forEach((position, index) => { @@ -118,9 +116,8 @@ const withPositionsSection = (isLoadingUserAssets: boolean) => { return []; }; -const withClaimablesSection = (isLoadingUserAssets: boolean) => { - const { accountAddress: address, nativeCurrency: currency } = store.getState().settings; - const claimables: Claimable[] | undefined = queryClient.getQueryData(claimablesQueryKey({ address, currency })); +const withClaimablesSection = (claimables: Claimable[] | undefined, isLoadingUserAssets: boolean) => { + const { nativeCurrency: currency } = store.getState().settings; const result: ClaimableExtraData[] = []; let totalNativeValue = '0'; @@ -285,6 +282,13 @@ const briefBalanceSectionSelector = createSelector( ); export const buildBriefWalletSectionsSelector = createSelector( - [briefBalanceSectionSelector, (state: any) => briefUniqueTokenDataSelector(state), remoteConfigSelector, experimentalConfigSelector], + [ + briefBalanceSectionSelector, + (state: any) => briefUniqueTokenDataSelector(state), + remoteConfigSelector, + experimentalConfigSelector, + positionsSelector, + claimablesSelector, + ], buildBriefWalletSections ); diff --git a/src/hooks/useWalletSectionsData.ts b/src/hooks/useWalletSectionsData.ts index f974ee2673c..91e7298151e 100644 --- a/src/hooks/useWalletSectionsData.ts +++ b/src/hooks/useWalletSectionsData.ts @@ -14,6 +14,8 @@ import useNftSort from './useNFTsSortBy'; import useWalletsWithBalancesAndNames from './useWalletsWithBalancesAndNames'; import { useRemoteConfig } from '@/model/remoteConfig'; import { RainbowContext } from '@/helpers/RainbowContext'; +import { usePositions } from '@/resources/defi/PositionsQuery'; +import { useClaimables } from '@/resources/addys/claimables/query'; export default function useWalletSectionsData({ type, @@ -35,6 +37,8 @@ export default function useWalletSectionsData({ address: accountAddress, sortBy: nftSort, }); + const { data: positions } = usePositions({ address: accountAddress, currency: nativeCurrency }); + const { data: claimables } = useClaimables({ address: accountAddress, currency: nativeCurrency }); const walletsWithBalancesAndNames = useWalletsWithBalancesAndNames(); @@ -76,6 +80,8 @@ export default function useWalletSectionsData({ nftSort, remoteConfig, experimentalConfig, + positions, + claimables, }; const { briefSectionsData, isEmpty } = buildBriefWalletSectionsSelector(accountInfo); @@ -110,6 +116,8 @@ export default function useWalletSectionsData({ nftSort, remoteConfig, experimentalConfig, + positions, + claimables, ]); return walletSections; } diff --git a/src/navigation/Routes.android.tsx b/src/navigation/Routes.android.tsx index 007652f2302..e66ca433ef5 100644 --- a/src/navigation/Routes.android.tsx +++ b/src/navigation/Routes.android.tsx @@ -90,6 +90,7 @@ import { SwapScreen } from '@/__swaps__/screens/Swap/Swap'; import { useRemoteConfig } from '@/model/remoteConfig'; import { ControlPanel } from '@/components/DappBrowser/control-panel/ControlPanel'; import { ClaimRewardsPanel } from '@/screens/points/claim-flow/ClaimRewardsPanel'; +import { ClaimClaimablePanel } from '@/screens/claimables/ClaimClaimablePanel'; import { RootStackParamList } from './types'; const Stack = createStackNavigator(); @@ -247,6 +248,7 @@ function BSNavigator() { + {swapsV2Enabled && } diff --git a/src/navigation/Routes.ios.tsx b/src/navigation/Routes.ios.tsx index 4f1a8e96407..1ea1c9553ae 100644 --- a/src/navigation/Routes.ios.tsx +++ b/src/navigation/Routes.ios.tsx @@ -38,7 +38,6 @@ import { hardwareWalletTxNavigatorConfig, consoleSheetConfig, customGasSheetConfig, - dappBrowserControlPanelConfig, defaultScreenStackOptions, ensAdditionalRecordsSheetConfig, ensConfirmRegisterSheetConfig, @@ -50,6 +49,7 @@ import { nftOffersSheetConfig, nftSingleOfferSheetConfig, pairHardwareWalletNavigatorConfig, + panelConfig, profileConfig, profilePreviewConfig, qrScannerConfig, @@ -104,6 +104,7 @@ import { useRemoteConfig } from '@/model/remoteConfig'; import CheckIdentifierScreen from '@/screens/CheckIdentifierScreen'; import { ControlPanel } from '@/components/DappBrowser/control-panel/ControlPanel'; import { ClaimRewardsPanel } from '@/screens/points/claim-flow/ClaimRewardsPanel'; +import { ClaimClaimablePanel } from '@/screens/claimables/ClaimClaimablePanel'; import { RootStackParamList } from './types'; const Stack = createStackNavigator(); @@ -285,8 +286,9 @@ function NativeStackNavigator() { - - + + + {swapsV2Enabled && } diff --git a/src/navigation/config.tsx b/src/navigation/config.tsx index 09b893005bb..fbe583c44b8 100644 --- a/src/navigation/config.tsx +++ b/src/navigation/config.tsx @@ -248,7 +248,7 @@ export const consoleSheetConfig = { }), }; -export const dappBrowserControlPanelConfig = { +export const panelConfig = { options: ({ route: { params = {} } }) => ({ ...buildCoolModalConfig({ ...params, diff --git a/src/navigation/routesNames.ts b/src/navigation/routesNames.ts index 1e5693b4c27..b9ca655e1eb 100644 --- a/src/navigation/routesNames.ts +++ b/src/navigation/routesNames.ts @@ -108,6 +108,7 @@ const Routes = { SETTINGS_SECTION_PRIVACY: 'PrivacySection', DAPP_BROWSER_CONTROL_PANEL: 'DappBrowserControlPanel', CLAIM_REWARDS_PANEL: 'ClaimRewardsPanel', + CLAIM_CLAIMABLE_PANEL: 'ClaimClaimablePanel', } as const; export const NATIVE_ROUTES = [ diff --git a/src/raps/actions/claim.ts b/src/raps/actions/claimRewards.ts similarity index 81% rename from src/raps/actions/claim.ts rename to src/raps/actions/claimRewards.ts index 4cb029a4df8..763b1369f7f 100644 --- a/src/raps/actions/claim.ts +++ b/src/raps/actions/claimRewards.ts @@ -15,10 +15,10 @@ const DO_FAKE_CLAIM = false; // This action is used to claim the rewards of the user // by making an api call to the backend which would use a relayer // to do the claim and send the funds to the user -export async function claim({ parameters, wallet, baseNonce }: ActionProps<'claim'>) { +export async function claimRewards({ parameters, wallet, baseNonce }: ActionProps<'claimRewards'>) { const { address } = parameters; if (!address) { - throw new Error('[CLAIM]: missing address'); + throw new Error('[CLAIM-REWARDS]: missing address'); } // when DO_FAKE_CLAIM is true, we use mock data (can do as many as we want) // otherwise we do a real claim (can be done once, then backend needs to reset it) @@ -29,7 +29,7 @@ export async function claim({ parameters, wallet, baseNonce }: ActionProps<'clai if (!txHash) { // If there's no transaction hash the relayer didn't submit the transaction // so we can't contnue - throw new Error('[CLAIM]: missing tx hash from backend'); + throw new Error('[CLAIM-REWARDS]: missing tx hash from backend'); } // We need to make sure the transaction is mined @@ -37,7 +37,7 @@ export async function claim({ parameters, wallet, baseNonce }: ActionProps<'clai const claimTx = await wallet?.provider?.getTransaction(txHash); if (!claimTx) { // If we can't get the transaction we can't continue - throw new Error('[CLAIM]: tx not found'); + throw new Error('[CLAIM-REWARDS]: tx not found'); } // then we wait for the receipt of the transaction @@ -45,14 +45,14 @@ export async function claim({ parameters, wallet, baseNonce }: ActionProps<'clai const receipt = await claimTx?.wait(); if (!receipt) { // If we can't get the receipt we can't continue - throw new Error('[CLAIM]: tx not mined'); + throw new Error('[CLAIM-REWARDS]: tx not mined'); } // finally we check if the transaction was successful const success = receipt?.status === 1; if (!success) { // The transaction failed, we can't continue - throw new Error('[CLAIM]: claim tx failed onchain'); + throw new Error('[CLAIM-REWARDS]: claim rewards tx failed onchain'); } // If the transaction was successful we can return the hash diff --git a/src/raps/actions/claimBridge.ts b/src/raps/actions/claimRewardsBridge.ts similarity index 89% rename from src/raps/actions/claimBridge.ts rename to src/raps/actions/claimRewardsBridge.ts index d4f9addd1e5..b8b8c946003 100644 --- a/src/raps/actions/claimBridge.ts +++ b/src/raps/actions/claimRewardsBridge.ts @@ -16,12 +16,12 @@ import { ChainId } from '@/chains/types'; import { chainsName } from '@/chains'; // This action is used to bridge the claimed funds to another chain -export async function claimBridge({ parameters, wallet, baseNonce }: ActionProps<'claimBridge'>) { +export async function claimRewardsBridge({ parameters, wallet, baseNonce }: ActionProps<'claimRewardsBridge'>) { const { address, toChainId, sellAmount, chainId } = parameters; // Check if the address and toChainId are valid // otherwise we can't continue if (!toChainId || !address) { - throw new RainbowError('claimBridge: error getClaimBridgeQuote'); + throw new RainbowError('claimRewardsBridge: error getClaimBridgeQuote'); } let maxBridgeableAmount = sellAmount; @@ -44,7 +44,7 @@ export async function claimBridge({ parameters, wallet, baseNonce }: ActionProps // if we don't get a quote or there's an error we can't continue if (!claimBridgeQuote || (claimBridgeQuote as QuoteError)?.error) { - throw new Error('[CLAIM-BRIDGE]: error getting getClaimBridgeQuote'); + throw new Error('[CLAIM-REWARDS-BRIDGE]: error getting getClaimBridgeQuote'); } let bridgeQuote = claimBridgeQuote as CrosschainQuote; @@ -81,7 +81,7 @@ export async function claimBridge({ parameters, wallet, baseNonce }: ActionProps if (lessThan(subtract(balance.toString(), sellAmount), gasFeeInWei)) { // if the balance is less than the gas fee we can't continue if (lessThan(sellAmount, gasFeeInWei)) { - throw new Error('[CLAIM-BRIDGE]: error insufficient funds to pay gas fee'); + throw new Error('[CLAIM-REWARDS-BRIDGE]: error insufficient funds to pay gas fee'); } else { // otherwie we bridge the maximum amount we can afford maxBridgeableAmount = subtract(sellAmount, gasFeeInWei); @@ -104,7 +104,7 @@ export async function claimBridge({ parameters, wallet, baseNonce }: ActionProps }); if (!newQuote || (newQuote as QuoteError)?.error) { - throw new Error('[CLAIM-BRIDGE]: error getClaimBridgeQuote (new)'); + throw new Error('[CLAIM-REWARDS-BRIDGE]: error getClaimBridgeQuote (new)'); } bridgeQuote = newQuote as CrosschainQuote; @@ -126,7 +126,7 @@ export async function claimBridge({ parameters, wallet, baseNonce }: ActionProps } if (!gasLimit) { - throw new Error('[CLAIM-BRIDGE]: error estimating gas or using default gas limit'); + throw new Error('[CLAIM-REWARDS-BRIDGE]: error estimating gas or using default gas limit'); } // we need to bump the base nonce to next available one @@ -147,11 +147,11 @@ export async function claimBridge({ parameters, wallet, baseNonce }: ActionProps try { swap = await executeCrosschainSwap(swapParams); } catch (e) { - throw new Error('[CLAIM-BRIDGE]: crosschainSwap error'); + throw new Error('[CLAIM-REWARDS-BRIDGE]: crosschainSwap error'); } if (!swap) { - throw new Error('[CLAIM-BRIDGE]: executeCrosschainSwap returned undefined'); + throw new Error('[CLAIM-REWARDS-BRIDGE]: executeCrosschainSwap returned undefined'); } const typedAssetToBuy: ParsedAddressAsset = { diff --git a/src/raps/actions/claimTransactionClaimable.ts b/src/raps/actions/claimTransactionClaimable.ts new file mode 100644 index 00000000000..3c85149dec6 --- /dev/null +++ b/src/raps/actions/claimTransactionClaimable.ts @@ -0,0 +1,42 @@ +import { ActionPropsV2 } from '../references'; +import { sendTransaction } from '@/model/wallet'; +import { getProvider } from '@/handlers/web3'; +import { RainbowError } from '@/logger'; +import { addNewTransaction } from '@/state/pendingTransactions'; +import { NewTransaction } from '@/entities'; +import { chainsName } from '@/chains'; + +export async function claimTransactionClaimable({ parameters, wallet }: ActionPropsV2<'claimTransactionClaimableAction'>) { + const { claimTx } = parameters; + + const provider = getProvider({ chainId: claimTx.chainId }); + const result = await sendTransaction({ transaction: claimTx, existingWallet: wallet, provider }); + + if (!result?.result || !!result.error || !result.result.hash) { + throw new RainbowError('[CLAIM-TRANSACTION-CLAIMABLE]: failed to execute claim transaction'); + } + + const transaction = { + amount: result.result.value.toString(), + gasLimit: result.result.gasLimit, + from: result.result.from ?? null, + to: result.result.to ?? null, + chainId: result.result.chainId, + hash: result.result.hash, + network: chainsName[result.result.chainId], + status: 'pending', + type: 'send', + nonce: result.result.nonce, + } satisfies NewTransaction; + + addNewTransaction({ + address: claimTx.from, + chainId: claimTx.chainId, + transaction, + }); + + return { + nonce: result.result.nonce, + hash: result.result.hash, + }; +} diff --git a/src/raps/actions/index.ts b/src/raps/actions/index.ts index 5801ce411b2..6a27273e5f3 100644 --- a/src/raps/actions/index.ts +++ b/src/raps/actions/index.ts @@ -1,3 +1,3 @@ export { estimateSwapGasLimit, executeSwap, swap } from './swap'; export { assetNeedsUnlocking, estimateApprove, executeApprove, unlock } from './unlock'; -export { claim } from './claim'; +export { claimRewards } from './claimRewards'; diff --git a/src/raps/claimAndBridge.ts b/src/raps/claimRewardsAndBridge.ts similarity index 58% rename from src/raps/claimAndBridge.ts rename to src/raps/claimRewardsAndBridge.ts index 5f611eb51ad..d7580dc6c31 100644 --- a/src/raps/claimAndBridge.ts +++ b/src/raps/claimRewardsAndBridge.ts @@ -1,17 +1,17 @@ import { createNewAction, createNewRap } from './common'; -import { RapAction, RapClaimActionParameters } from './references'; +import { RapAction, RapClaimRewardsActionParameters } from './references'; -export const createClaimAndBridgeRap = async (claimParameters: RapClaimActionParameters) => { - let actions: RapAction<'claim' | 'claimBridge'>[] = []; +export const createClaimRewardsAndBridgeRap = async (claimParameters: RapClaimRewardsActionParameters) => { + let actions: RapAction<'claimRewards' | 'claimRewardsBridge'>[] = []; const { assetToSell, sellAmount, assetToBuy, meta, chainId, toChainId, address, gasParams } = claimParameters; - const claim = createNewAction('claim', claimParameters); + const claim = createNewAction('claimRewards', claimParameters); actions = actions.concat(claim); // if we need the bridge if (chainId !== toChainId && toChainId !== undefined) { // create a bridge rap - const bridge = createNewAction('claimBridge', { + const bridge = createNewAction('claimRewardsBridge', { address, chainId, toChainId, @@ -21,7 +21,7 @@ export const createClaimAndBridgeRap = async (claimParameters: RapClaimActionPar assetToBuy, quote: undefined, gasParams, - } satisfies RapClaimActionParameters); + } satisfies RapClaimRewardsActionParameters); actions = actions.concat(bridge); } diff --git a/src/raps/claimTransactionClaimable.ts b/src/raps/claimTransactionClaimable.ts new file mode 100644 index 00000000000..8cf7be94e60 --- /dev/null +++ b/src/raps/claimTransactionClaimable.ts @@ -0,0 +1,13 @@ +import { createNewActionV2, createNewRapV2 } from './common'; +import { RapActionV2, RapParameters } from './references'; + +export async function createClaimTransactionClaimableRap(parameters: Extract) { + let actions: RapActionV2<'claimTransactionClaimableAction'>[] = []; + + const claim = createNewActionV2('claimTransactionClaimableAction', parameters.claimTransactionClaimableActionParameters); + actions = actions.concat(claim); + + // create the overall rap + const newRap = createNewRapV2(actions); + return newRap; +} diff --git a/src/raps/common.ts b/src/raps/common.ts index 477a0f68b46..ed2d75c158d 100644 --- a/src/raps/common.ts +++ b/src/raps/common.ts @@ -1,5 +1,5 @@ import { MMKV } from 'react-native-mmkv'; -import { RapAction, RapActionParameterMap, RapActionTypes } from './references'; +import { RapAction, RapActionParameterMap, RapActionParameterMapV2, RapActionTypes, RapActionTypesV2, RapActionV2 } from './references'; import { STORAGE_IDS } from '@/model/mmkv'; import { logger } from '@/logger'; import { EthereumAddress, LegacyGasFeeParamsBySpeed, LegacySelectedGasFee, Records, SelectedGasFee, GasFeeParamsBySpeed } from '@/entities'; @@ -75,12 +75,27 @@ export function createNewAction(type: T, parameters: R return newAction; } +export function createNewActionV2(type: T, parameters: RapActionParameterMapV2[T]): RapActionV2 { + const newAction = { + parameters, + transaction: { confirmed: null, hash: null }, + type, + }; + return newAction; +} + export function createNewRap(actions: RapAction[]) { return { actions, }; } +export function createNewRapV2(actions: RapActionV2[]) { + return { + actions, + }; +} + export function createNewENSRap(actions: RapENSAction[]) { return { actions, diff --git a/src/raps/execute.ts b/src/raps/execute.ts index 3ee2c9ec9b4..fe1512342d4 100644 --- a/src/raps/execute.ts +++ b/src/raps/execute.ts @@ -5,11 +5,13 @@ import { Signer } from '@ethersproject/abstract-signer'; import { RainbowError, logger } from '@/logger'; -import { claim, swap, unlock } from './actions'; +import { claimRewards, swap, unlock } from './actions'; import { crosschainSwap } from './actions/crosschainSwap'; -import { claimBridge } from './actions/claimBridge'; +import { claimRewardsBridge } from './actions/claimRewardsBridge'; import { ActionProps, + ActionPropsV2, + RapResponse, Rap, RapAction, RapActionResponse, @@ -17,21 +19,29 @@ import { RapActionTypes, RapSwapActionParameters, RapTypes, + RapActionTypesV2, + RapActionV2, + RapV2, + RapParameters, + RapActionResponseV2, + RapResponseV2, } from './references'; import { createUnlockAndCrosschainSwapRap } from './unlockAndCrosschainSwap'; -import { createClaimAndBridgeRap } from './claimAndBridge'; +import { createClaimRewardsAndBridgeRap } from './claimRewardsAndBridge'; import { createUnlockAndSwapRap } from './unlockAndSwap'; import { GasFeeParamsBySpeed, LegacyGasFeeParamsBySpeed, LegacyTransactionGasParamAmounts, TransactionGasParamAmounts } from '@/entities'; import { Screens, TimeToSignOperation, performanceTracking } from '@/state/performance/performance'; import { swapsStore } from '@/state/swaps/swapsStore'; +import { createClaimTransactionClaimableRap } from './claimTransactionClaimable'; +import { claimTransactionClaimable } from './actions/claimTransactionClaimable'; export function createSwapRapByType( type: T, swapParameters: RapSwapActionParameters ): Promise<{ actions: RapAction[] }> { switch (type) { - case 'claimBridge': - return createClaimAndBridgeRap(swapParameters as RapSwapActionParameters<'claimBridge'>); + case 'claimRewardsBridge': + return createClaimRewardsAndBridgeRap(swapParameters as RapSwapActionParameters<'claimRewardsBridge'>); case 'crosschainSwap': return createUnlockAndCrosschainSwapRap(swapParameters as RapSwapActionParameters<'crosschainSwap'>); case 'swap': @@ -41,16 +51,25 @@ export function createSwapRapByType( } } +export function createRap(parameters: RapParameters): Promise<{ actions: RapActionV2[] }> { + switch (parameters.type) { + case 'claimTransactionClaimableRap': + return createClaimTransactionClaimableRap(parameters); + default: + return Promise.resolve({ actions: [] }); + } +} + function typeAction(type: T, props: ActionProps) { switch (type) { - case 'claim': - return () => claim(props as ActionProps<'claim'>); + case 'claimRewards': + return () => claimRewards(props as ActionProps<'claimRewards'>); case 'unlock': return () => unlock(props as ActionProps<'unlock'>); case 'swap': return () => swap(props as ActionProps<'swap'>); - case 'claimBridge': - return () => claimBridge(props as ActionProps<'claimBridge'>); + case 'claimRewardsBridge': + return () => claimRewardsBridge(props as ActionProps<'claimRewardsBridge'>); case 'crosschainSwap': return () => crosschainSwap(props as ActionProps<'crosschainSwap'>); default: @@ -59,6 +78,15 @@ function typeAction(type: T, props: ActionProps) { } } +function typeActionV2(type: T, props: ActionPropsV2) { + switch (type) { + case 'claimTransactionClaimableAction': + return () => claimTransactionClaimable(props as ActionPropsV2<'claimTransactionClaimableAction'>); + default: + throw new RainbowError(`[raps/execute]: typeActionV2 - unknown type ${type}`); + } +} + export async function executeAction({ action, wallet, @@ -104,11 +132,47 @@ export async function executeAction({ } } +export async function executeActionV2({ + action, + wallet, + rap, + nonceToUse, + rapName, +}: { + action: RapActionV2; + wallet: Signer; + rap: RapV2; + nonceToUse: number | undefined; + rapName: string; +}): Promise { + const { type, parameters } = action; + try { + const actionProps = { + wallet, + currentRap: rap, + parameters, + nonceToUse, + }; + const { nonce, hash } = await typeActionV2(type, actionProps)(); + return { nonce, errorMessage: null, hash }; + } catch (error) { + logger.error(new RainbowError(`[raps/execute]: ${rapName} - error execute action`), { + message: (error as Error)?.message, + }); + return { nonce: null, errorMessage: String(error), hash: null }; + } +} + function getRapFullName(actions: RapAction[]) { const actionTypes = actions.map(action => action.type); return actionTypes.join(' + '); } +function getRapFullNameV2(actions: RapActionV2[]) { + const actionTypes = actions.map(action => action.type); + return actionTypes.join(' + '); +} + const delay = (ms: number) => new Promise(res => setTimeout(res, ms)); const waitForNodeAck = async (hash: string, provider: Signer['provider']): Promise => { @@ -125,14 +189,43 @@ const waitForNodeAck = async (hash: string, provider: Signer['provider']): Promi }); }; +const executeRap = async (wallet: Signer, rap: RapV2): Promise => { + const { actions } = rap; + const rapName = getRapFullNameV2(rap.actions); + let nonceToUse: number | undefined; + + while (actions.length) { + const action = actions.shift(); + + if (!action) break; + + const { nonce, errorMessage, hash } = await executeActionV2({ + action, + wallet, + rap, + nonceToUse, + rapName, + }); + + if (errorMessage) return { errorMessage }; + + if (typeof nonce === 'number') { + actions.length >= 1 && hash && (await waitForNodeAck(hash, wallet.provider)); + nonceToUse = nonce + 1; + } + } + + return { errorMessage: null }; +}; + export const walletExecuteRap = async ( wallet: Signer, type: RapTypes, - parameters: RapSwapActionParameters<'swap' | 'crosschainSwap' | 'claimBridge'> -): Promise<{ nonce: number | undefined; errorMessage: string | null }> => { - // NOTE: We don't care to track claimBridge raps + parameters: RapSwapActionParameters<'swap' | 'crosschainSwap' | 'claimRewardsBridge'> +): Promise => { + // NOTE: We don't care to track claimRewardsBridge raps const rap = - type === 'claimBridge' + type === 'claimRewardsBridge' ? await createSwapRapByType(type, parameters) : await performanceTracking.getState().executeFn({ fn: createSwapRapByType, @@ -188,3 +281,8 @@ export const walletExecuteRap = async ( } return { nonce, errorMessage }; }; + +export async function walletExecuteRapV2(wallet: Signer, rapParameters: RapParameters): Promise { + const rap = await createRap(rapParameters); + return executeRap(wallet, rap); +} diff --git a/src/raps/references.ts b/src/raps/references.ts index 259b8fe330c..28010e693f4 100644 --- a/src/raps/references.ts +++ b/src/raps/references.ts @@ -5,6 +5,7 @@ import { Address } from 'viem'; import { ParsedAsset } from '@/__swaps__/types/assets'; import { GasFeeParamsBySpeed, LegacyGasFeeParamsBySpeed, LegacyTransactionGasParamAmounts, TransactionGasParamAmounts } from '@/entities'; import { ChainId } from '@/chains/types'; +import { TransactionRequest } from '@ethersproject/abstract-provider'; export enum SwapModalField { input = 'inputAmount', @@ -38,10 +39,10 @@ export type SwapMetadata = { export type QuoteTypeMap = { swap: Quote; crosschainSwap: CrosschainQuote; - claimBridge: undefined; + claimRewardsBridge: undefined; }; -export interface RapSwapActionParameters { +export interface RapSwapActionParameters { amount?: string | null; sellAmount: string; buyAmount?: string; @@ -67,7 +68,7 @@ export interface RapUnlockActionParameters { chainId: number; } -export interface RapClaimActionParameters { +export interface RapClaimRewardsActionParameters { address?: Address; assetToSell: ParsedAsset; sellAmount: string; @@ -79,10 +80,43 @@ export interface RapClaimActionParameters { gasParams: TransactionGasParamAmounts | LegacyTransactionGasParamAmounts; } +export type TransactionClaimableTxPayload = TransactionRequest & + ( + | { + to: string; + from: string; + nonce: number; + gasLimit: string; + maxFeePerGas: string; + maxPriorityFeePerGas: string; + data: string; + value: '0x0'; + chainId: number; + } + | { + to: string; + from: string; + nonce: number; + gasLimit: string; + gasPrice: string; + data: string; + value: '0x0'; + chainId: number; + } + ); + +export interface ClaimTransactionClaimableActionParameters { + claimTx: TransactionClaimableTxPayload; +} + +export interface ClaimTransactionClaimableRapParameters { + claim: ClaimTransactionClaimableActionParameters; +} + export type RapActionParameters = | RapSwapActionParameters<'swap'> | RapSwapActionParameters<'crosschainSwap'> - | RapClaimActionParameters + | RapClaimRewardsActionParameters | RapUnlockActionParameters; export interface RapActionTransaction { @@ -93,8 +127,21 @@ export type RapActionParameterMap = { swap: RapSwapActionParameters<'swap'>; crosschainSwap: RapSwapActionParameters<'crosschainSwap'>; unlock: RapUnlockActionParameters; - claim: RapClaimActionParameters; - claimBridge: RapClaimActionParameters; + claimRewards: RapClaimRewardsActionParameters; + claimRewardsBridge: RapClaimRewardsActionParameters; +}; + +export type RapActionParameterMapV2 = { + claimTransactionClaimableAction: ClaimTransactionClaimableActionParameters; +}; + +export type RapParameterMapV2 = { + claimTransactionClaimableRap: ClaimTransactionClaimableRapParameters; +}; + +export type RapParameters = { + type: 'claimTransactionClaimableRap'; + claimTransactionClaimableActionParameters: ClaimTransactionClaimableActionParameters; }; export interface RapAction { @@ -103,39 +150,73 @@ export interface RapAction { type: T; } +export interface RapActionV2 { + parameters: RapActionParameterMapV2[T]; + transaction: RapActionTransaction; + type: T; +} + export interface Rap { - actions: RapAction<'swap' | 'crosschainSwap' | 'unlock' | 'claim' | 'claimBridge'>[]; + actions: RapAction<'swap' | 'crosschainSwap' | 'unlock' | 'claimRewards' | 'claimRewardsBridge'>[]; +} + +export interface RapV2 { + actions: RapActionV2<'claimTransactionClaimableAction'>[]; } export enum rapActions { swap = 'swap', crosschainSwap = 'crosschainSwap', unlock = 'unlock', - claim = 'claim', - claimBridge = 'claimBridge', + claimRewards = 'claimRewards', + claimRewardsBridge = 'claimRewardsBridge', +} + +export enum rapActionsV2 { + claimTransactionClaimableAction = 'claimTransactionClaimableAction', } export type RapActionTypes = keyof typeof rapActions; +export type RapActionTypesV2 = keyof typeof rapActionsV2; + export enum rapTypes { swap = 'swap', crosschainSwap = 'crosschainSwap', - claimBridge = 'claimBridge', + claimRewardsBridge = 'claimRewardsBridge', +} + +export enum rapTypesV2 { + claimTransactionClaimableRap = 'claimTransactionClaimableRap', + claimSponsoredClaimableRap = 'claimSponsoredClaimableRap', } export type RapTypes = keyof typeof rapTypes; +export type RapTypesV2 = keyof typeof rapTypesV2; + export interface RapActionResponse { baseNonce?: number | null; errorMessage: string | null; hash?: string | null; } +export interface RapActionResponseV2 { + nonce: number | null; + errorMessage: string | null; + hash: string | null; +} + export interface RapActionResult { nonce?: number | undefined; hash?: string | undefined; } +export interface RapActionResultV2 { + nonce: number | null; + hash: string | null; +} + export interface ActionProps { baseNonce?: number; index: number; @@ -146,7 +227,23 @@ export interface ActionProps { gasFeeParamsBySpeed: GasFeeParamsBySpeed | LegacyGasFeeParamsBySpeed; } +export interface ActionPropsV2 { + nonceToUse?: number; + parameters: RapActionParameterMapV2[T]; + wallet: Signer; + currentRap: RapV2; +} + export interface WalletExecuteRapProps { - rapActionParameters: RapSwapActionParameters<'swap' | 'crosschainSwap' | 'claimBridge'>; + rapActionParameters: RapSwapActionParameters<'swap' | 'crosschainSwap' | 'claimRewardsBridge'>; type: RapTypes; } + +export interface RapResponse { + nonce: number | undefined; + errorMessage: string | null; +} + +export interface RapResponseV2 { + errorMessage: string | null; +} diff --git a/src/rapsV2/actions/claimTransactionClaimableAction.ts b/src/rapsV2/actions/claimTransactionClaimableAction.ts new file mode 100644 index 00000000000..80f3f088ca2 --- /dev/null +++ b/src/rapsV2/actions/claimTransactionClaimableAction.ts @@ -0,0 +1,42 @@ +import { ActionProps } from '../references'; +import { sendTransaction } from '@/model/wallet'; +import { getProvider } from '@/handlers/web3'; +import { RainbowError } from '@/logger'; +import { addNewTransaction } from '@/state/pendingTransactions'; +import { NewTransaction } from '@/entities'; +import { chainsName } from '@/chains'; + +export async function claimTransactionClaimable({ parameters, wallet }: ActionProps<'claimTransactionClaimableAction'>) { + const { claimTx } = parameters; + + const provider = getProvider({ chainId: claimTx.chainId }); + const result = await sendTransaction({ transaction: claimTx, existingWallet: wallet, provider }); + + if (!result?.result || !!result.error || !result.result.hash) { + throw new RainbowError('[CLAIM-TRANSACTION-CLAIMABLE]: failed to execute claim transaction'); + } + + const transaction = { + amount: result.result.value.toString(), + gasLimit: result.result.gasLimit, + from: result.result.from ?? null, + to: result.result.to ?? null, + chainId: result.result.chainId, + hash: result.result.hash, + network: chainsName[result.result.chainId], + status: 'pending', + type: 'send', + nonce: result.result.nonce, + } satisfies NewTransaction; + + addNewTransaction({ + address: claimTx.from, + chainId: claimTx.chainId, + transaction, + }); + + return { + nonce: result.result.nonce, + hash: result.result.hash, + }; +} diff --git a/src/rapsV2/actions/index.ts b/src/rapsV2/actions/index.ts new file mode 100644 index 00000000000..75323a946a5 --- /dev/null +++ b/src/rapsV2/actions/index.ts @@ -0,0 +1 @@ +export { claimTransactionClaimable } from './claimTransactionClaimableAction'; diff --git a/src/rapsV2/claimTransactionClaimableRap.ts b/src/rapsV2/claimTransactionClaimableRap.ts new file mode 100644 index 00000000000..f91b2b6950a --- /dev/null +++ b/src/rapsV2/claimTransactionClaimableRap.ts @@ -0,0 +1,13 @@ +import { createNewAction, createNewRap } from './common'; +import { RapAction, RapParameters } from './references'; + +export async function createClaimTransactionClaimableRap(parameters: Extract) { + let actions: RapAction<'claimTransactionClaimableAction'>[] = []; + + const claim = createNewAction('claimTransactionClaimableAction', parameters.claimTransactionClaimableActionParameters); + actions = actions.concat(claim); + + // create the overall rap + const newRap = createNewRap(actions); + return newRap; +} diff --git a/src/rapsV2/common.ts b/src/rapsV2/common.ts new file mode 100644 index 00000000000..506a165adcd --- /dev/null +++ b/src/rapsV2/common.ts @@ -0,0 +1,20 @@ +import { RapAction, RapActionParameterMap, RapActionTypes } from './references'; + +export interface RapActionTransaction { + hash: string | null; +} + +export function createNewAction(type: T, parameters: RapActionParameterMap[T]): RapAction { + const newAction = { + parameters, + transaction: { confirmed: null, hash: null }, + type, + }; + return newAction; +} + +export function createNewRap(actions: RapAction[]) { + return { + actions, + }; +} diff --git a/src/rapsV2/execute.ts b/src/rapsV2/execute.ts new file mode 100644 index 00000000000..cdbdc1ca8c7 --- /dev/null +++ b/src/rapsV2/execute.ts @@ -0,0 +1,121 @@ +/* eslint-disable no-await-in-loop */ +/* eslint-disable no-async-promise-executor */ +/* eslint-disable no-promise-executor-return */ +import { Signer } from '@ethersproject/abstract-signer'; + +import { RainbowError, logger } from '@/logger'; + +import { ActionProps, RapResponse, Rap, RapAction, RapActionResponse, RapActionTypes, RapParameters } from './references'; +import { createClaimTransactionClaimableRap } from './claimTransactionClaimableRap'; +import { claimTransactionClaimable } from './actions/claimTransactionClaimableAction'; +import { delay } from '@/utils/delay'; + +// get the rap by type +export function createRap(parameters: RapParameters): Promise<{ actions: RapAction[] }> { + switch (parameters.type) { + case 'claimTransactionClaimableRap': + return createClaimTransactionClaimableRap(parameters); + default: + return Promise.resolve({ actions: [] }); + } +} + +// get the action executable by type +function getActionExecutableByType(type: T, props: ActionProps) { + switch (type) { + case 'claimTransactionClaimableAction': + return () => claimTransactionClaimable(props); + default: + throw new RainbowError(`[rapsV2/execute]: T - unknown action type ${type}`); + } +} + +// executes a single action in the rap +// if the action executes a tx on-chain, it will return the nonce it used +// if an error occurs, we return the error message +export async function executeAction({ + action, + wallet, + rap, + nonceToUse, + rapName, +}: { + action: RapAction; + wallet: Signer; + rap: Rap; + nonceToUse: number | undefined; + rapName: string; +}): Promise { + const { type, parameters } = action; + try { + const actionProps = { + wallet, + currentRap: rap, + parameters, + nonceToUse, + }; + const { nonce, hash } = await getActionExecutableByType(type, actionProps)(); + return { nonce, errorMessage: null, hash }; + } catch (error) { + logger.error(new RainbowError(`[rapsV2/execute]: ${rapName} - error execute action`), { + message: (error as Error)?.message, + }); + return { nonce: null, errorMessage: String(error), hash: null }; + } +} + +function getRapFullName(actions: RapAction[]) { + const actionTypes = actions.map(action => action.type); + return actionTypes.join(' + '); +} + +const waitForNodeAck = async (hash: string, provider: Signer['provider']): Promise => { + return new Promise(async resolve => { + const tx = await provider?.getTransaction(hash); + // This means the node is aware of the tx, we're good to go + if ((tx && tx.blockNumber === null) || (tx && tx?.blockNumber && tx?.blockNumber > 0)) { + resolve(); + } else { + // Wait for 1 second and try again + await delay(1000); + return waitForNodeAck(hash, provider); + } + }); +}; + +// goes through each action in the rap and executes it +// if an action executes a tx on-chain, increment the nonceToUse for the next tx +// if an action fails, it will return the error message +const executeRap = async (wallet: Signer, rap: Rap): Promise => { + const { actions } = rap; + const rapName = getRapFullName(rap.actions); + let nonceToUse: number | undefined; + + while (actions.length) { + const action = actions.shift(); + + if (!action) break; + + const { nonce, errorMessage, hash } = await executeAction({ + action, + wallet, + rap, + nonceToUse, + rapName, + }); + + if (errorMessage) return { errorMessage }; + + if (typeof nonce === 'number') { + actions.length >= 1 && hash && (await waitForNodeAck(hash, wallet.provider)); + nonceToUse = nonce + 1; + } + } + + return { errorMessage: null }; +}; + +export async function walletExecuteRap(wallet: Signer, rapParameters: RapParameters): Promise { + const rap = await createRap(rapParameters); + return executeRap(wallet, rap); +} diff --git a/src/rapsV2/references.ts b/src/rapsV2/references.ts new file mode 100644 index 00000000000..2f3acc7007f --- /dev/null +++ b/src/rapsV2/references.ts @@ -0,0 +1,83 @@ +import { Signer } from '@ethersproject/abstract-signer'; +import { TransactionRequest } from '@ethersproject/abstract-provider'; + +// supports legacy and new gas types +export type TransactionClaimableTxPayload = TransactionRequest & + ( + | { + to: string; + from: string; + nonce: number; + gasLimit: string; + maxFeePerGas: string; + maxPriorityFeePerGas: string; + data: string; + value: '0x0'; + chainId: number; + } + | { + to: string; + from: string; + nonce: number; + gasLimit: string; + gasPrice: string; + data: string; + value: '0x0'; + chainId: number; + } + ); + +export interface ClaimTransactionClaimableActionParameters { + claimTx: TransactionClaimableTxPayload; +} + +export interface RapActionTransaction { + hash: string | null; +} + +export type RapActionParameterMap = { + claimTransactionClaimableAction: ClaimTransactionClaimableActionParameters; +}; + +export type RapParameters = { + type: 'claimTransactionClaimableRap'; + claimTransactionClaimableActionParameters: ClaimTransactionClaimableActionParameters; +}; + +export interface RapAction { + parameters: RapActionParameterMap[T]; + transaction: RapActionTransaction; + type: T; +} + +export interface Rap { + actions: RapAction<'claimTransactionClaimableAction'>[]; +} + +export enum rapActions { + claimTransactionClaimableAction = 'claimTransactionClaimableAction', +} + +export type RapActionTypes = keyof typeof rapActions; + +export enum rapTypes { + claimTransactionClaimableRap = 'claimTransactionClaimableRap', +} + +export type RapTypes = keyof typeof rapTypes; + +export interface RapActionResponse { + nonce: number | null | undefined; + errorMessage: string | null; + hash: string | null | undefined; +} + +export interface ActionProps { + nonceToUse: number | undefined; + parameters: RapActionParameterMap[T]; + wallet: Signer; +} + +export interface RapResponse { + errorMessage: string | null; +} diff --git a/src/resources/addys/claimables/query.ts b/src/resources/addys/claimables/query.ts index 10aaa2bfa16..7dda29953aa 100644 --- a/src/resources/addys/claimables/query.ts +++ b/src/resources/addys/claimables/query.ts @@ -11,8 +11,10 @@ import { CLAIMABLES, useExperimentalFlag } from '@/config'; import { IS_TEST } from '@/env'; import { SUPPORTED_CHAIN_IDS } from '@/chains'; -const addysHttp = new RainbowFetchClient({ - baseURL: 'https://addys.p.rainbow.me/v3', +export const ADDYS_BASE_URL = 'https://addys.p.rainbow.me/v3'; + +export const addysHttp = new RainbowFetchClient({ + baseURL: ADDYS_BASE_URL, headers: { Authorization: `Bearer ${ADDYS_API_KEY}`, }, diff --git a/src/resources/addys/claimables/types.ts b/src/resources/addys/claimables/types.ts index 814a4bfc869..28538833b69 100644 --- a/src/resources/addys/claimables/types.ts +++ b/src/resources/addys/claimables/types.ts @@ -1,6 +1,6 @@ -import { ChainId } from '@rainbow-me/swaps'; import { Address } from 'viem'; import { AddysAsset, AddysConsolidatedError, AddysResponseStatus } from '../types'; +import { ChainId } from '@/chains/types'; interface Colors { primary: string; @@ -28,20 +28,33 @@ interface DApp { colors: Colors; } -type ClaimableType = 'transaction' | 'sponsored'; - -export interface AddysClaimable { +interface AddysBaseClaimable { name: string; unique_id: string; - type: ClaimableType; + type: string; network: ChainId; asset: AddysAsset; amount: string; dapp: DApp; - claim_action_type?: string | null; +} + +interface AddysTransactionClaimable extends AddysBaseClaimable { + claim_action_type: 'transaction'; + claim_action: ClaimActionTransaction[]; +} + +interface AddysSponsoredClaimable extends AddysBaseClaimable { + claim_action_type: 'sponsored'; + claim_action: ClaimActionSponsored[]; +} + +interface AddysUnsupportedClaimable extends AddysBaseClaimable { + claim_action_type?: 'unknown' | null; claim_action?: ClaimAction[]; } +export type AddysClaimable = AddysTransactionClaimable | AddysSponsoredClaimable | AddysUnsupportedClaimable; + interface ConsolidatedClaimablesPayloadResponse { claimables: AddysClaimable[]; } @@ -61,8 +74,13 @@ export interface ConsolidatedClaimablesResponse { payload: ConsolidatedClaimablesPayloadResponse; } -// will add more attributes as needed -export interface Claimable { +interface BaseClaimable { + asset: { + iconUrl: string; + name: string; + symbol: string; + }; + chainId: ChainId; name: string; uniqueId: string; iconUrl: string; @@ -71,3 +89,41 @@ export interface Claimable { nativeAsset: { amount: string; display: string }; }; } + +export interface TransactionClaimable extends BaseClaimable { + type: 'transaction'; + action: { to: Address; data: string }; +} + +export interface SponsoredClaimable extends BaseClaimable { + type: 'sponsored'; + action: { url: string; method: string }; +} + +export type Claimable = TransactionClaimable | SponsoredClaimable; + +interface ClaimTransactionStatus { + network: ChainId; + transaction_hash: string; + explorer_url: string; + sponsored_status: string; +} + +interface ClaimPayloadResponse { + success: boolean; + claimable: Claimable | null; + claim_transaction_status: ClaimTransactionStatus | null; +} + +interface ClaimMetadataResponse { + address: string; + chain_id: ChainId; + currency: string; + claim_type: string; + error: string; +} + +export interface ClaimResponse { + metadata: ClaimMetadataResponse; + payload: ClaimPayloadResponse; +} diff --git a/src/resources/addys/claimables/utils.ts b/src/resources/addys/claimables/utils.ts index 30b0395cc87..71b6122a3f1 100644 --- a/src/resources/addys/claimables/utils.ts +++ b/src/resources/addys/claimables/utils.ts @@ -1,17 +1,50 @@ import { NativeCurrencyKey } from '@/entities'; import { AddysClaimable, Claimable } from './types'; -import { convertRawAmountToBalance, convertRawAmountToNativeDisplay, greaterThan, lessThan } from '@/helpers/utilities'; +import { convertRawAmountToBalance, convertRawAmountToNativeDisplay, greaterThan } from '@/helpers/utilities'; export const parseClaimables = (claimables: AddysClaimable[], currency: NativeCurrencyKey): Claimable[] => { return claimables - .map(claimable => ({ - name: claimable.name, - uniqueId: claimable.unique_id, - iconUrl: claimable.dapp.icon_url, - value: { - claimAsset: convertRawAmountToBalance(claimable.amount, claimable.asset), - nativeAsset: convertRawAmountToNativeDisplay(claimable.amount, claimable.asset.decimals, claimable.asset.price.value, currency), - }, - })) + .map(claimable => { + if ( + !(claimable.claim_action_type === 'transaction' || claimable.claim_action_type === 'sponsored') || + !claimable.claim_action?.length + ) { + return undefined; + } + + const baseClaimable = { + asset: { + iconUrl: claimable.asset.icon_url, + name: claimable.asset.name, + symbol: claimable.asset.symbol, + }, + chainId: claimable.network, + name: claimable.name, + uniqueId: claimable.unique_id, + iconUrl: claimable.dapp.icon_url, + value: { + claimAsset: convertRawAmountToBalance(claimable.amount, claimable.asset), + nativeAsset: convertRawAmountToNativeDisplay(claimable.amount, claimable.asset.decimals, claimable.asset.price.value, currency), + }, + }; + + if (claimable.claim_action_type === 'transaction') { + return { + ...baseClaimable, + type: 'transaction' as const, + action: { + to: claimable.claim_action[0].address_to, + data: claimable.claim_action[0].calldata, + }, + }; + } else if (claimable.claim_action_type === 'sponsored') { + return { + ...baseClaimable, + type: 'sponsored' as const, + action: { method: claimable.claim_action[0].method, url: claimable.claim_action[0].url }, + }; + } + }) + .filter((c): c is Claimable => !!c) .sort((a, b) => (greaterThan(a.value.claimAsset.amount ?? '0', b.value.claimAsset.amount ?? '0') ? -1 : 1)); }; diff --git a/src/resources/defi/PositionsQuery.ts b/src/resources/defi/PositionsQuery.ts index 46e935d00ed..0bd90cffb14 100644 --- a/src/resources/defi/PositionsQuery.ts +++ b/src/resources/defi/PositionsQuery.ts @@ -8,6 +8,8 @@ import { ADDYS_API_KEY } from 'react-native-dotenv'; import { AddysPositionsResponse, PositionsArgs } from './types'; import { parsePositions } from './utils'; import { SUPPORTED_CHAIN_IDS } from '@/chains'; +import { DEFI_POSITIONS, useExperimentalFlag } from '@/config'; +import { IS_TEST } from '@/env'; export const buildPositionsUrl = (address: string) => { const networkString = SUPPORTED_CHAIN_IDS.join(','); @@ -77,5 +79,9 @@ export async function fetchPositions( // Query Hook export function usePositions({ address, currency }: PositionsArgs, config: QueryConfig = {}) { - return useQuery(positionsQueryKey({ address, currency }), positionsQueryFunction, { ...config, enabled: !!address }); + const positionsEnabled = useExperimentalFlag(DEFI_POSITIONS); + return useQuery(positionsQueryKey({ address, currency }), positionsQueryFunction, { + ...config, + enabled: !!(address && positionsEnabled && !IS_TEST), + }); } diff --git a/src/screens/WalletScreen/index.tsx b/src/screens/WalletScreen/index.tsx index 7b00c9e94e7..391dae64608 100644 --- a/src/screens/WalletScreen/index.tsx +++ b/src/screens/WalletScreen/index.tsx @@ -22,13 +22,11 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { analyticsV2 } from '@/analytics'; import { AppState } from '@/redux/store'; import { addressCopiedToastAtom } from '@/recoil/addressCopiedToastAtom'; -import { usePositions } from '@/resources/defi/PositionsQuery'; import styled from '@/styled-thing'; import { IS_ANDROID } from '@/env'; import { RemoteCardsSync } from '@/state/sync/RemoteCardsSync'; import { RemotePromoSheetSync } from '@/state/sync/RemotePromoSheetSync'; import { UserAssetsSync } from '@/state/sync/UserAssetsSync'; -import { useClaimables } from '@/resources/addys/claimables/query'; import { MobileWalletProtocolListener } from '@/components/MobileWalletProtocolListener'; import { runWalletBackupStatusChecks } from '@/handlers/walletReadyEvents'; @@ -44,9 +42,7 @@ const WalletScreen: React.FC = ({ navigation, route }) => { const removeFirst = useRemoveFirst(); const [initialized, setInitialized] = useState(!!params?.initialized); const initializeWallet = useInitializeWallet(); - const { network: currentNetwork, accountAddress, appIcon, nativeCurrency } = useAccountSettings(); - usePositions({ address: accountAddress, currency: nativeCurrency }); - useClaimables({ address: accountAddress, currency: nativeCurrency }); + const { network: currentNetwork, accountAddress, appIcon } = useAccountSettings(); const loadAccountLateData = useLoadAccountLateData(); const loadGlobalLateData = useLoadGlobalLateData(); diff --git a/src/screens/claimables/ClaimClaimablePanel.tsx b/src/screens/claimables/ClaimClaimablePanel.tsx new file mode 100644 index 00000000000..8d940e09bbc --- /dev/null +++ b/src/screens/claimables/ClaimClaimablePanel.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { RouteProp, useRoute } from '@react-navigation/native'; +import { Claimable } from '@/resources/addys/claimables/types'; +import { ClaimingTransactionClaimable } from './ClaimingTransactionClaimable'; +import { ClaimingSponsoredClaimable } from './ClaimingSponsoredClaimable'; + +type RouteParams = { + ClaimClaimablePanelParams: { claimable: Claimable }; +}; + +export const ClaimClaimablePanel = () => { + const { + params: { claimable }, + } = useRoute>(); + + return claimable.type === 'transaction' ? ( + + ) : ( + + ); +}; diff --git a/src/screens/claimables/ClaimingClaimableSharedUI.tsx b/src/screens/claimables/ClaimingClaimableSharedUI.tsx new file mode 100644 index 00000000000..4f1df19f903 --- /dev/null +++ b/src/screens/claimables/ClaimingClaimableSharedUI.tsx @@ -0,0 +1,225 @@ +import React, { useMemo } from 'react'; +import { AccentColorProvider, Bleed, Box, Inline, Text, TextShadow, globalColors, useColorMode } from '@/design-system'; +import * as i18n from '@/languages'; +import { ListHeader, Panel, TapToDismiss, controlPanelStyles } from '@/components/SmoothPager/ListPanel'; +import { safeAreaInsetValues } from '@/utils'; +import { View } from 'react-native'; +import { IS_IOS } from '@/env'; +import { ButtonPressAnimation } from '@/components/animations'; +import { SponsoredClaimable, TransactionClaimable } from '@/resources/addys/claimables/types'; +import RainbowCoinIcon from '@/components/coin-icon/RainbowCoinIcon'; +import { useTheme } from '@/theme'; +import { FasterImageView } from '@candlefinance/faster-image'; +import { chainsLabel } from '@/chains'; +import { useNavigation } from '@/navigation'; +import { TextColor } from '@/design-system/color/palettes'; + +export type ClaimStatus = 'idle' | 'claiming' | 'success' | 'error'; + +export const ClaimingClaimableSharedUI = ({ + claim, + claimable, + claimStatus, + hasSufficientFunds, + isGasReady, + isTransactionReady, + nativeCurrencyGasFeeDisplay, + setClaimStatus, +}: + | { + claim: () => void; + claimable: TransactionClaimable; + claimStatus: ClaimStatus; + hasSufficientFunds: boolean; + isGasReady: boolean; + isTransactionReady: boolean; + nativeCurrencyGasFeeDisplay: string; + setClaimStatus: React.Dispatch>; + } + | { + claim: () => void; + claimable: SponsoredClaimable; + claimStatus: ClaimStatus; + hasSufficientFunds?: never; + isGasReady?: never; + isTransactionReady?: never; + nativeCurrencyGasFeeDisplay?: never; + setClaimStatus: React.Dispatch>; + }) => { + const { isDarkMode } = useColorMode(); + const theme = useTheme(); + const { goBack } = useNavigation(); + + const isButtonDisabled = + claimStatus === 'claiming' || (claimStatus !== 'success' && claimable.type === 'transaction' && !isTransactionReady); + const shouldShowClaimText = + (claimStatus === 'idle' || claimStatus === 'claiming') && (claimable.type !== 'transaction' || hasSufficientFunds); + + const buttonLabel = useMemo(() => { + switch (claimStatus) { + case 'idle': + if (shouldShowClaimText) { + return `Claim ${claimable.value.claimAsset.display}`; + } else { + return 'Insufficient Funds'; + } + case 'claiming': + return `Claim ${claimable.value.claimAsset.display}`; + case 'success': + return i18n.t(i18n.l.button.done); + case 'error': + default: + return i18n.t(i18n.l.points.points.try_again); + } + }, [claimStatus, claimable.value.claimAsset.display, shouldShowClaimText]); + + const panelTitle = useMemo(() => { + switch (claimStatus) { + case 'idle': + return 'Claim'; + case 'claiming': + return 'Claiming...'; + case 'success': + return 'Claimed!'; + case 'error': + default: + return i18n.t(i18n.l.points.points.error_claiming); + } + }, [claimStatus]); + + const panelTitleColor: TextColor = useMemo(() => { + switch (claimStatus) { + case 'idle': + case 'claiming': + return 'label'; + case 'success': + return 'green'; + case 'error': + default: + return 'red'; + } + }, [claimStatus]); + + return ( + <> + + + + + + + {panelTitle} + + + + } + showBackButton={false} + /> + + + + + + + + + + + {claimable.value.nativeAsset.display} + + + + + {/* TODO: needs shimmer when claimStatus === 'claiming' */} + { + if (claimStatus === 'idle' || claimStatus === 'error') { + setClaimStatus('claiming'); + claim(); + } else if (claimStatus === 'success') { + goBack(); + } + }} + > + + + + {shouldShowClaimText && ( + + + 􀎽 + + + )} + + + {buttonLabel} + + + + + + + {claimable.type === 'transaction' && + (isGasReady ? ( + + + 􀵟 + + + {`${nativeCurrencyGasFeeDisplay} to claim on ${chainsLabel[claimable.chainId]}`} + + + ) : ( + + Calculating gas fee... + + ))} + + + + + + + ); +}; diff --git a/src/screens/claimables/ClaimingSponsoredClaimable.tsx b/src/screens/claimables/ClaimingSponsoredClaimable.tsx new file mode 100644 index 00000000000..3ace6ca3361 --- /dev/null +++ b/src/screens/claimables/ClaimingSponsoredClaimable.tsx @@ -0,0 +1,84 @@ +import React, { useState } from 'react'; +import { useAccountSettings } from '@/hooks'; +import { ClaimResponse, SponsoredClaimable } from '@/resources/addys/claimables/types'; +import { ADDYS_BASE_URL, addysHttp, claimablesQueryKey, useClaimables } from '@/resources/addys/claimables/query'; +import { useMutation } from '@tanstack/react-query'; +import { loadWallet } from '@/model/wallet'; +import { getProvider } from '@/handlers/web3'; +import { logger, RainbowError } from '@/logger'; +import { queryClient } from '@/react-query'; +import { ClaimingClaimableSharedUI, ClaimStatus } from './ClaimingClaimableSharedUI'; + +export const ClaimingSponsoredClaimable = ({ claimable }: { claimable: SponsoredClaimable }) => { + const { accountAddress, nativeCurrency } = useAccountSettings(); + + const { refetch } = useClaimables({ address: accountAddress, currency: nativeCurrency }); + + const [claimStatus, setClaimStatus] = useState('idle'); + + const { mutate: claimClaimable } = useMutation({ + mutationFn: async () => { + const provider = getProvider({ chainId: claimable.chainId }); + const wallet = await loadWallet({ + address: accountAddress, + showErrorIfNotLoaded: true, + provider, + }); + + if (!wallet) { + // Biometrics auth failure (retry possible) + setClaimStatus('error'); + return; + } + + const path = claimable.action.url.replace(ADDYS_BASE_URL, ''); + let response: { data: ClaimResponse }; + + if (claimable.action.method === 'GET') { + try { + response = await addysHttp.get(path); + } catch (e) { + setClaimStatus('error'); + logger.error(new RainbowError('[ClaimSponsoredClaimable]: failed to execute sponsored claim api call')); + return; + } + } else { + try { + response = await addysHttp.post(path); + } catch (e) { + setClaimStatus('error'); + logger.error(new RainbowError('[ClaimSponsoredClaimable]: failed to execute sponsored claim api call')); + return; + } + } + + if (!response.data.payload.success) { + setClaimStatus('error'); + logger.warn('[ClaimSponsoredClaimable]: sponsored claim api call returned unsuccessful response'); + } else { + setClaimStatus('success'); + // Clear and refresh claimables data + queryClient.invalidateQueries(claimablesQueryKey({ address: accountAddress, currency: nativeCurrency })); + refetch(); + } + }, + onError: e => { + setClaimStatus('error'); + logger.error(new RainbowError('[ClaimingSponsoredClaimable]: Failed to claim claimable due to unhandled error'), { + message: (e as Error)?.message, + }); + }, + onSuccess: () => { + if (claimStatus === 'claiming') { + logger.error( + new RainbowError('[ClaimingSponsoredClaimable]: claim function completed but never resolved status to success or error state') + ); + setClaimStatus('error'); + } + }, + }); + + return ( + + ); +}; diff --git a/src/screens/claimables/ClaimingTransactionClaimable.tsx b/src/screens/claimables/ClaimingTransactionClaimable.tsx new file mode 100644 index 00000000000..1dd828ef27e --- /dev/null +++ b/src/screens/claimables/ClaimingTransactionClaimable.tsx @@ -0,0 +1,178 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useAccountSettings, useGas } from '@/hooks'; +import { ethereumUtils } from '@/utils'; +import { TransactionClaimable } from '@/resources/addys/claimables/types'; +import { claimablesQueryKey, useClaimables } from '@/resources/addys/claimables/query'; +import { useMutation } from '@tanstack/react-query'; +import { loadWallet } from '@/model/wallet'; +import { estimateGasWithPadding, getProvider } from '@/handlers/web3'; +import { parseGasParamsForTransaction } from '@/parsers'; +import { getNextNonce } from '@/state/nonces'; +import { needsL1SecurityFeeChains } from '@/chains'; +import { logger, RainbowError } from '@/logger'; +import { convertAmountToNativeDisplay } from '@/helpers/utilities'; +import { queryClient } from '@/react-query'; +import { TransactionClaimableTxPayload } from '@/raps/references'; +import { ClaimingClaimableSharedUI, ClaimStatus } from './ClaimingClaimableSharedUI'; +import { walletExecuteRap } from '@/rapsV2/execute'; + +export const ClaimingTransactionClaimable = ({ claimable }: { claimable: TransactionClaimable }) => { + const { accountAddress, nativeCurrency } = useAccountSettings(); + const { isGasReady, isSufficientGas, isValidGas, selectedGasFee, startPollingGasFees, stopPollingGasFees, updateTxFee } = useGas(); + + const [baseTxPayload, setBaseTxPayload] = useState< + Omit | undefined + >(); + const [txPayload, setTxPayload] = useState(); + const [claimStatus, setClaimStatus] = useState('idle'); + + const { refetch } = useClaimables({ address: accountAddress, currency: nativeCurrency }); + + const provider = useMemo(() => getProvider({ chainId: claimable.chainId }), [claimable.chainId]); + + const buildTxPayload = useCallback(async () => { + const payload = { + value: '0x0' as const, + data: claimable.action.data, + from: accountAddress, + chainId: claimable.chainId, + nonce: await getNextNonce({ address: accountAddress, chainId: claimable.chainId }), + to: claimable.action.to, + }; + + setBaseTxPayload(payload); + }, [accountAddress, claimable.action.to, claimable.action.data, claimable.chainId, setBaseTxPayload]); + + useEffect(() => { + buildTxPayload(); + }, [buildTxPayload]); + + useEffect(() => { + startPollingGasFees(); + return () => { + stopPollingGasFees(); + }; + }, [startPollingGasFees, stopPollingGasFees]); + + const estimateGas = useCallback(async () => { + if (!baseTxPayload) { + logger.error(new RainbowError('[ClaimingTransactionClaimable]: attempted to estimate gas without a tx payload')); + return; + } + + const gasParams = parseGasParamsForTransaction(selectedGasFee); + const updatedTxPayload = { ...baseTxPayload, ...gasParams }; + + const gasLimit = await estimateGasWithPadding(updatedTxPayload, null, null, provider); + + if (!gasLimit) { + updateTxFee(null, null); + logger.error(new RainbowError('[ClaimingTransactionClaimable]: Failed to estimate gas limit')); + return; + } + + if (needsL1SecurityFeeChains.includes(claimable.chainId)) { + const l1SecurityFee = await ethereumUtils.calculateL1FeeOptimism( + // @ts-expect-error - type mismatch, but this tx request structure is the same as in SendSheet.js + { + to: claimable.action.to, + from: accountAddress, + value: '0x0', + data: claimable.action.data, + }, + provider + ); + + if (!l1SecurityFee) { + updateTxFee(null, null); + logger.error(new RainbowError('[ClaimingTransactionClaimable]: Failed to calculate L1 security fee')); + return; + } + + updateTxFee(gasLimit, null, l1SecurityFee); + } else { + updateTxFee(gasLimit, null); + } + + setTxPayload({ ...updatedTxPayload, gasLimit }); + }, [baseTxPayload, selectedGasFee, provider, claimable.chainId, claimable.action.to, claimable.action.data, updateTxFee, accountAddress]); + + useEffect(() => { + if (baseTxPayload) { + estimateGas(); + } + }, [baseTxPayload, estimateGas, selectedGasFee]); + + const isTransactionReady = !!(isGasReady && isSufficientGas && isValidGas && txPayload); + + const nativeCurrencyGasFeeDisplay = useMemo( + () => convertAmountToNativeDisplay(selectedGasFee?.gasFee?.estimatedFee?.native?.value?.amount, nativeCurrency), + [nativeCurrency, selectedGasFee?.gasFee?.estimatedFee?.native?.value?.amount] + ); + + const { mutate: claimClaimable } = useMutation({ + mutationFn: async () => { + if (!txPayload) { + setClaimStatus('error'); + logger.error(new RainbowError('[ClaimingTransactionClaimable]: Failed to claim claimable due to missing tx payload')); + return; + } + + const wallet = await loadWallet({ + address: accountAddress, + showErrorIfNotLoaded: false, + provider, + }); + + if (!wallet) { + // Biometrics auth failure (retry possible) + setClaimStatus('error'); + return; + } + + const { errorMessage } = await walletExecuteRap(wallet, { + type: 'claimTransactionClaimableRap', + claimTransactionClaimableActionParameters: { claimTx: txPayload }, + }); + + if (errorMessage) { + setClaimStatus('error'); + logger.error(new RainbowError('[ClaimingTransactionClaimable]: Failed to claim claimable due to rap error'), { + message: errorMessage, + }); + } else { + setClaimStatus('success'); + // Clear and refresh claimables data + queryClient.invalidateQueries(claimablesQueryKey({ address: accountAddress, currency: nativeCurrency })); + refetch(); + } + }, + onError: e => { + setClaimStatus('error'); + logger.error(new RainbowError('[ClaimingTransactionClaimable]: Failed to claim claimable due to unhandled error'), { + message: (e as Error)?.message, + }); + }, + onSuccess: () => { + if (claimStatus === 'claiming') { + logger.error( + new RainbowError('[ClaimingTransactionClaimable]: claim function completed but never resolved status to success or error state') + ); + setClaimStatus('error'); + } + }, + }); + + return ( + + ); +}; diff --git a/src/screens/points/claim-flow/ClaimRewardsPanel.tsx b/src/screens/points/claim-flow/ClaimRewardsPanel.tsx index d39b3619b3c..576131ba849 100644 --- a/src/screens/points/claim-flow/ClaimRewardsPanel.tsx +++ b/src/screens/points/claim-flow/ClaimRewardsPanel.tsx @@ -258,7 +258,7 @@ const ClaimingRewards = ({ // @ts-expect-error - collision between old gas types and new gasFeeParamsBySpeed, gasParams, - } satisfies RapSwapActionParameters<'claimBridge'>; + } satisfies RapSwapActionParameters<'claimRewardsBridge'>; const provider = getProvider({ chainId: ChainId.optimism }); const wallet = await loadWallet({ @@ -275,13 +275,13 @@ const ClaimingRewards = ({ try { const { errorMessage, nonce: bridgeNonce } = await walletExecuteRap( wallet, - 'claimBridge', + 'claimRewardsBridge', // @ts-expect-error - collision between old gas types and new actionParams ); if (errorMessage) { - if (errorMessage.includes('[CLAIM]')) { + if (errorMessage.includes('[CLAIM-REWARDS]')) { // Claim error (retry possible) setClaimStatus('error'); } else {