diff --git a/ios/Podfile.lock b/ios/Podfile.lock index fe8991b43..a20062ed9 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -59,7 +59,7 @@ PODS: - ReactCommon/turbomodule/core (= 0.71.5) - fmt (6.2.1) - glog (0.3.5) - - helium-react-native-sdk (3.0.4): + - helium-react-native-sdk (3.0.5): - React-Core - hermes-engine (0.71.5): - hermes-engine/Pre-built (= 0.71.5) @@ -858,7 +858,7 @@ SPEC CHECKSUMS: FBReactNativeSpec: 627fd07f1b9d498c9fa572e76d7f1a6b1ee9a444 fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9 glog: 04b94705f318337d7ead9e6d17c019bd9b1f6b1b - helium-react-native-sdk: bd924fa4ff053a6afb2b2febf03c3dfacd7dd671 + helium-react-native-sdk: 54928dcd95ea131437ac6e269cf5bb2d473bf57f hermes-engine: 0784cadad14b011580615c496f77e0ae112eed75 libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913 lottie-ios: e047b1d2e6239b787cc5e9755b988869cf190494 diff --git a/src/components/AutoGasBanner.tsx b/src/components/AutoGasBanner.tsx index bca749324..d789b7342 100644 --- a/src/components/AutoGasBanner.tsx +++ b/src/components/AutoGasBanner.tsx @@ -120,7 +120,7 @@ const Banner = ({ onLayout, ...rest }: BannerProps) => { const decision = await walletSignBottomSheetRef.show({ type: WalletStandardMessageTypes.signTransaction, url: '', - additionalMessage: t('transactions.autoGasConvert', { symbol }), + message: t('transactions.autoGasConvert', { symbol }), serializedTxs: [serializedTx], header: t('transactions.autoGasConvertHeader'), }) diff --git a/src/features/governance/PositionCard.tsx b/src/features/governance/PositionCard.tsx index a1c3ebe2e..0d6bf5309 100644 --- a/src/features/governance/PositionCard.tsx +++ b/src/features/governance/PositionCard.tsx @@ -43,6 +43,7 @@ import { useCreateOpacity } from '@theme/themeHooks' import { MAX_TRANSACTIONS_PER_SIGNATURE_BATCH } from '@utils/constants' import { daysToSecs, + getFormattedStringFromDays, getMinDurationFmt, getTimeLeftFromNowFmt, secsToDays, @@ -53,6 +54,7 @@ import React, { useCallback, useMemo, useRef, useState } from 'react' import { useAsync } from 'react-async-hook' import { useTranslation } from 'react-i18next' import { FadeIn, FadeOut } from 'react-native-reanimated' +import { MessagePreview } from '../../solana/MessagePreview' import { useSolana } from '../../solana/SolanaProvider' import { useWalletSign } from '../../solana/WalletSignProvider' import { WalletStandardMessageTypes } from '../../solana/walletSignBottomSheetTypes' @@ -147,11 +149,17 @@ export const PositionCard = ({ const { anchorProvider } = useSolana() - const decideAndExecute = async ( - header: string, - instructions: TransactionInstruction[], - sigs: Keypair[] = [], - ) => { + const decideAndExecute = async ({ + header, + message, + instructions, + sigs = [], + }: { + header: string + message: string + instructions: TransactionInstruction[] + sigs?: Keypair[] + }) => { if (!anchorProvider || !walletSignBottomSheetRef) return const transactions = await batchInstructionsToTxsWithPriorityFee( @@ -172,6 +180,7 @@ export const PositionCard = ({ type: WalletStandardMessageTypes.signTransaction, url: '', header, + renderer: () => , serializedTxs: txs.map((t) => Buffer.from(t.serialize())), }) @@ -348,7 +357,11 @@ export const PositionCard = ({ await closePosition({ position, onInstructions: async (ixs) => { - await decideAndExecute(t('gov.transactions.closePosition'), ixs) + await decideAndExecute({ + header: t('gov.transactions.closePosition'), + message: t('gov.positions.closeMessage'), + instructions: ixs, + }) if (!closingError) { refetchState() } @@ -360,12 +373,18 @@ export const PositionCard = ({ await flipPositionLockupKind({ position, onInstructions: async (ixs) => { - await decideAndExecute( - isConstant + await decideAndExecute({ + header: isConstant ? t('gov.transactions.unpauseLockup') : t('gov.transactions.pauseLockup'), - ixs, - ) + message: t('gov.positions.flipLockupMessage', { + amount: lockedTokens, + symbol, + status: isConstant ? 'paused' : 'decaying', + action: isConstant ? 'let it decay' : 'pause it', + }), + instructions: ixs, + }) if (!flippingError) { refetchState() @@ -379,7 +398,19 @@ export const PositionCard = ({ position, lockupPeriodsInDays: values.lockupPeriodInDays, onInstructions: async (ixs) => { - await decideAndExecute(t('gov.transactions.extendPosition'), ixs) + await decideAndExecute({ + header: t('gov.transactions.extendPosition'), + message: t('gov.positions.extendMessage', { + existing: isConstant + ? getMinDurationFmt( + position.lockup.startTs, + position.lockup.endTs, + ) + : getTimeLeftFromNowFmt(position.lockup.endTs), + new: getFormattedStringFromDays(values.lockupPeriodInDays), + }), + instructions: ixs, + }) if (!extendingError) { refetchState() } @@ -394,7 +425,18 @@ export const PositionCard = ({ lockupKind: values.lockupKind.value, lockupPeriodsInDays: values.lockupPeriodInDays, onInstructions: async (ixs, sigs) => { - await decideAndExecute(t('gov.transactions.splitPosition'), ixs, sigs) + await decideAndExecute({ + header: t('gov.transactions.splitPosition'), + message: t('gov.positions.splitMessage', { + amount: values.amount, + symbol, + lockupKind: values.lockupKind.display.toLocaleLowerCase(), + duration: getFormattedStringFromDays(values.lockupPeriodInDays), + }), + instructions: ixs, + sigs, + }) + if (!splitingError) { refetchState() } @@ -411,7 +453,20 @@ export const PositionCard = ({ amount, targetPosition, onInstructions: async (ixs) => { - await decideAndExecute(t('gov.transactions.transferPosition'), ixs) + await decideAndExecute({ + header: t('gov.transactions.transferPosition'), + message: t('gov.positions.transferMessage', { + amount, + symbol, + targetAmount: humanReadable( + targetPosition.amountDepositedNative, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + mintAcc!.decimals, + ), + }), + instructions: ixs, + }) + if (!transferingError) { refetchState() } @@ -424,7 +479,16 @@ export const PositionCard = ({ position, subDao, onInstructions: async (ixs) => { - await decideAndExecute(t('gov.transactions.delegatePosition'), ixs) + await decideAndExecute({ + header: t('gov.transactions.delegatePosition'), + message: t('gov.positions.delegateMessage', { + amount: lockedTokens, + symbol, + subDao: subDao.dntMetadata.name, + }), + instructions: ixs, + }) + if (!delegatingError) { refetchState() } @@ -439,11 +503,22 @@ export const PositionCard = ({ const undelegate = ixs[ixs.length - 1] const claims = ixs.slice(0, ixs.length - 1) if (claims.length > 0) { - await decideAndExecute(t('gov.transactions.claimRewards'), claims) + await decideAndExecute({ + header: t('gov.transactions.claimRewards'), + message: t('gov.transactions.claimRewards'), + instructions: claims, + }) } - await decideAndExecute(t('gov.transactions.undelegatePosition'), [ - undelegate, - ]) + + await decideAndExecute({ + header: t('gov.transactions.undelegatePosition'), + message: t('gov.positions.undelegateMessage', { + amount: lockedTokens, + symbol, + }), + instructions: [undelegate], + }) + if (!undelegatingError) { refetchState() } @@ -456,7 +531,12 @@ export const PositionCard = ({ position, organization, onInstructions: async (ixs) => { - await decideAndExecute(t('gov.transactions.relinquishPosition'), ixs) + await decideAndExecute({ + header: t('gov.transactions.relinquishPosition'), + message: t('gov.positions.relinquishVotesMessage'), + instructions: ixs, + }) + if (!relinquishingError) { refetchState() } @@ -502,7 +582,7 @@ export const PositionCard = ({ <> { setActionsOpen(false) if (hasActiveVotes) { @@ -519,7 +599,7 @@ export const PositionCard = ({ /> { setActionsOpen(false) if (hasActiveVotes) { @@ -548,8 +628,8 @@ export const PositionCard = ({ key="pause" title={ isConstant - ? t('gov.transactions.unpauseLockup') - : t('gov.transactions.pauseLockup') + ? t('gov.positions.unpause') + : t('gov.positions.pause') } onPress={async () => { setActionsOpen(false) diff --git a/src/features/governance/ProposalScreen.tsx b/src/features/governance/ProposalScreen.tsx index ef8cade9a..18be2901b 100644 --- a/src/features/governance/ProposalScreen.tsx +++ b/src/features/governance/ProposalScreen.tsx @@ -42,6 +42,7 @@ import { useTranslation } from 'react-i18next' import { ScrollView } from 'react-native' import Markdown from 'react-native-markdown-display' import { Edge } from 'react-native-safe-area-context' +import { MessagePreview } from '../../solana/MessagePreview' import { useSolana } from '../../solana/SolanaProvider' import { useWalletSign } from '../../solana/WalletSignProvider' import { WalletStandardMessageTypes } from '../../solana/walletSignBottomSheetTypes' @@ -183,10 +184,15 @@ export const ProposalScreen = () => { [proposal], ) - const decideAndExecute = async ( - header: string, - instructions: TransactionInstruction[], - ) => { + const decideAndExecute = async ({ + header, + message, + instructions, + }: { + header: string + message: string + instructions: TransactionInstruction[] + }) => { if (!anchorProvider || !walletSignBottomSheetRef) return const transactions = await batchInstructionsToTxsWithPriorityFee( @@ -207,6 +213,7 @@ export const ProposalScreen = () => { type: WalletStandardMessageTypes.signTransaction, url: '', header, + renderer: () => , serializedTxs: txs.map((transaction) => Buffer.from(transaction.serialize()), ), @@ -232,7 +239,11 @@ export const ProposalScreen = () => { await vote({ choice: choice.index, onInstructions: (ixs) => - decideAndExecute(t('gov.transactions.castVote'), ixs), + decideAndExecute({ + header: t('gov.transactions.castVote'), + message: t('gov.proposals.castVoteFor', { choice: choice.name }), + instructions: ixs, + }), }) } } @@ -243,7 +254,13 @@ export const ProposalScreen = () => { relinquishVote({ choice: choice.index, onInstructions: async (instructions) => - decideAndExecute(t('gov.transactions.relinquishVote'), instructions), + decideAndExecute({ + header: t('gov.transactions.relinquishVote'), + message: t('gov.proposals.relinquishVoteFor', { + choice: choice.name, + }), + instructions, + }), }) } } diff --git a/src/features/governance/VotingPowerScreen.tsx b/src/features/governance/VotingPowerScreen.tsx index 5f9fcad13..d06f9b019 100644 --- a/src/features/governance/VotingPowerScreen.tsx +++ b/src/features/governance/VotingPowerScreen.tsx @@ -6,16 +6,17 @@ import { DelayedFadeIn } from '@components/FadeInOut' import Text from '@components/Text' import { useOwnedAmount } from '@helium/helium-react-hooks' import { + HELIUM_COMMON_LUT, + HELIUM_COMMON_LUT_DEVNET, HNT_MINT, Status, batchInstructionsToTxsWithPriorityFee, bulkSendTransactions, + humanReadable, sendAndConfirmWithRetry, toBN, toNumber, toVersionedTx, - HELIUM_COMMON_LUT_DEVNET, - HELIUM_COMMON_LUT, } from '@helium/spl-utils' import { calcLockupMultiplier, @@ -23,18 +24,20 @@ import { useCreatePosition, } from '@helium/voter-stake-registry-hooks' import { useCurrentWallet } from '@hooks/useCurrentWallet' +import { useMetaplexMetadata } from '@hooks/useMetaplexMetadata' import { RouteProp, useRoute } from '@react-navigation/native' import { Keypair, PublicKey, TransactionInstruction } from '@solana/web3.js' import { useGovernance } from '@storage/GovernanceProvider' import globalStyles from '@theme/globalStyles' import { MAX_TRANSACTIONS_PER_SIGNATURE_BATCH } from '@utils/constants' -import { daysToSecs } from '@utils/dateTools' +import { daysToSecs, getFormattedStringFromDays } from '@utils/dateTools' import { getBasePriorityFee } from '@utils/walletApiV2' import BN from 'bn.js' import React, { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { ScrollView } from 'react-native' import { Edge } from 'react-native-safe-area-context' +import { MessagePreview } from '../../solana/MessagePreview' import { useSolana } from '../../solana/SolanaProvider' import { useWalletSign } from '../../solana/WalletSignProvider' import { WalletStandardMessageTypes } from '../../solana/walletSignBottomSheetTypes' @@ -62,6 +65,7 @@ export const VotingPowerScreen = () => { positions, setMint, } = useGovernance() + const { symbol } = useMetaplexMetadata(mint) const { amount: ownedAmount, decimals } = useOwnedAmount(wallet, mint) const { error: createPositionError, createPosition } = useCreatePosition() const { @@ -121,13 +125,21 @@ export const VotingPowerScreen = () => { const { anchorProvider } = useSolana() - const decideAndExecute = async ( - header: string, - instructions: TransactionInstruction[], - sigs: Keypair[] = [], - onProgress: (status: Status) => void = () => {}, + const decideAndExecute = async ({ + header, + message, + instructions, + sigs = [], + onProgress = () => {}, sequentially = false, - ) => { + }: { + header: string + message: string + instructions: TransactionInstruction[] + sigs?: Keypair[] + onProgress?: (status: Status) => void + sequentially?: boolean + }) => { if (!anchorProvider || !walletSignBottomSheetRef) return const transactions = await batchInstructionsToTxsWithPriorityFee( @@ -152,6 +164,7 @@ export const VotingPowerScreen = () => { Buffer.from(transaction.serialize()), ), suppressWarnings: sequentially, + renderer: () => , }) if (decision) { @@ -196,7 +209,7 @@ export const VotingPowerScreen = () => { const handleLockTokens = async (values: LockTokensModalFormValues) => { const { amount, lockupPeriodInDays, lockupKind, subDao } = values - if (decimals && walletSignBottomSheetRef) { + if (decimals && walletSignBottomSheetRef && symbol) { const amountToLock = toBN(amount, decimals) await createPosition({ @@ -206,13 +219,17 @@ export const VotingPowerScreen = () => { mint, subDao, onInstructions: (ixs, sigs) => - decideAndExecute( - t('gov.transactions.lockTokens'), - ixs, + decideAndExecute({ + header: t('gov.transactions.lockTokens'), + message: t('gov.votingPower.lockYourTokens', { + amount: humanReadable(amountToLock, decimals), + symbol, + duration: getFormattedStringFromDays(lockupPeriodInDays), + }), + instructions: ixs, sigs, - undefined, - !!subDao, - ), + sequentially: !!subDao, + }), }) refetchState() @@ -224,12 +241,12 @@ export const VotingPowerScreen = () => { await claimAllPositionsRewards({ positions: positionsWithRewards, onInstructions: (ixs) => - decideAndExecute( - t('gov.transactions.claimRewards'), - ixs, - undefined, - setStatusOfClaim, - ), + decideAndExecute({ + header: t('gov.transactions.claimRewards'), + message: 'Approve this transaction to claim your rewards', + instructions: ixs, + onProgress: setStatusOfClaim, + }), }) if (!claimingAllRewardsError) { diff --git a/src/features/swaps/SwapScreen.tsx b/src/features/swaps/SwapScreen.tsx index b73701854..e86562824 100644 --- a/src/features/swaps/SwapScreen.tsx +++ b/src/features/swaps/SwapScreen.tsx @@ -591,7 +591,7 @@ const SwapScreen = () => { ) const handleSwapTokens = useCallback(async () => { - if (connection && currentAccount?.solanaAddress) { + if (connection && currentAccount?.solanaAddress && inputMint) { try { setSwapping(true) @@ -624,7 +624,15 @@ const SwapScreen = () => { if (!swapTxn) { throw new Error(t('errors.swap.tx')) } - await submitJupiterSwap(swapTxn) + + await submitJupiterSwap({ + inputMint, + inputAmount, + outputMint, + outputAmount, + minReceived, + swapTxn, + }) } setSwapping(false) @@ -646,6 +654,7 @@ const SwapScreen = () => { inputAmount, outputMint, outputAmount, + minReceived, navigation, submitTreasurySwap, submitMintDataCredits, @@ -815,7 +824,7 @@ const SwapScreen = () => { onPress={handleSwapTokens} TrailingComponent={ swapping ? ( - + ) : undefined } /> diff --git a/src/hooks/useSimulatedTransaction.ts b/src/hooks/useSimulatedTransaction.ts deleted file mode 100644 index 398f3dde9..000000000 --- a/src/hooks/useSimulatedTransaction.ts +++ /dev/null @@ -1,341 +0,0 @@ -import { useSolOwnedAmount } from '@helium/helium-react-hooks' -import { toNumber, truthy } from '@helium/spl-utils' -import { AccountLayout, NATIVE_MINT, TOKEN_PROGRAM_ID } from '@solana/spl-token' -import { - AddressLookupTableAccount, - Connection, - ParsedAccountData, - PublicKey, - SimulatedTransactionAccountInfo, - SystemProgram, - VersionedTransaction, -} from '@solana/web3.js' -import { useAppStorage } from '@storage/AppStorageProvider' -import { useModal } from '@storage/ModalsProvider' -import { useBalance } from '@utils/Balance' -import { MIN_BALANCE_THRESHOLD } from '@utils/constants' -import { getCollectableByMint, isInsufficientBal } from '@utils/solanaUtils' -import BN from 'bn.js' -import { useMemo, useState } from 'react' -import { useAsync } from 'react-async-hook' -import { useSolana } from '../solana/SolanaProvider' -import * as logger from '../utils/logger' -import { useBN } from './useBN' - -type BalanceChange = { - nativeChange?: number - mint?: PublicKey - symbol?: string - type?: 'send' | 'receive' -} -type BalanceChanges = BalanceChange[] | null - -export type SimulatedTransactionResult = { - loading: boolean - solFee?: number - priorityFee?: number - estimateFeeErr?: Error | undefined - simulationError: boolean - insufficientFunds: boolean - balanceChanges?: BalanceChanges -} -export function useSimulatedTransaction( - serializedTx: Buffer | undefined, - wallet: PublicKey | undefined, -): SimulatedTransactionResult { - const { connection, anchorProvider } = useSolana() - const { tokenAccounts } = useBalance() - const { showModal } = useModal() - const solBalance = useBN(useSolOwnedAmount(wallet).amount) - const hasEnoughSol = useMemo(() => { - return (solBalance || new BN(0)).gt(new BN(MIN_BALANCE_THRESHOLD)) - }, [solBalance]) - const { autoGasManagementToken } = useAppStorage() - - const [simulationError, setSimulationError] = useState(false) - const [insufficientFunds, setInsufficientFunds] = useState(false) - - const transaction = useMemo(() => { - if (!serializedTx) return undefined - try { - const tx = VersionedTransaction.deserialize(serializedTx) - return tx - } catch (err) { - logger.error(err) - } - }, [serializedTx]) - - const { - result: { solFee, priorityFee } = { solFee: 5000, priorityFee: 0 }, - loading: loadingFee, - error: estimateFeeErr, - } = useAsync( - async ( - c: Connection | undefined, - t: VersionedTransaction | undefined, - ): Promise<{ solFee: number; priorityFee: number }> => { - const sFee = (t?.signatures.length || 1) * 5000 - let pFee = 0 - - if (!c || !t) { - return Promise.resolve({ solFee: sFee, priorityFee: pFee }) - } - - try { - const fee = - (await c?.getFeeForMessage(t.message, 'confirmed')).value || solFee - pFee = fee - sFee - return { - priorityFee: pFee, - solFee: sFee, - } - } catch (err) { - logger.error(err) - } - - return { solFee: sFee, priorityFee: pFee } - }, - [connection, transaction], - ) - - const { loading: loadingSim, result: simulatedTxnResult } = - useAsync(async () => { - if (!connection || !transaction || !wallet) return undefined - - setSimulationError(false) - setInsufficientFunds(false) - - try { - const addressLookupTableAccounts: Array = [] - const { addressTableLookups } = transaction.message - if (addressTableLookups.length > 0) { - // eslint-disable-next-line no-restricted-syntax - for (const addressTableLookup of addressTableLookups) { - // eslint-disable-next-line no-await-in-loop - const result = await connection?.getAddressLookupTable( - addressTableLookup.accountKey, - ) - if (result?.value) { - addressLookupTableAccounts.push(result?.value) - } - } - } - const accountKeys = transaction.message.getAccountKeys({ - addressLookupTableAccounts, - }) - - const simulationAccounts = [ - ...new Set( - accountKeys.staticAccountKeys.concat( - accountKeys.accountKeysFromLookups - ? // Only writable accounts will contribute to balance changes - accountKeys.accountKeysFromLookups.writable - : [], - ), - ), - ] - - const { blockhash } = await connection?.getLatestBlockhash() - transaction.message.recentBlockhash = blockhash - return { - simulationAccounts, - simulatedTxn: await connection?.simulateTransaction(transaction, { - accounts: { - encoding: 'base64', - addresses: - simulationAccounts?.map((account) => account.toBase58()) || [], - }, - }), - } - } catch (err) { - console.warn('err', err) - return undefined - } - }, [connection, transaction, anchorProvider, wallet]) - - const { loading: loadingBal, result: estimatedBalanceChanges } = - useAsync(async () => { - if (!simulatedTxnResult || !tokenAccounts || !connection || !wallet) { - return - } - try { - const { simulatedTxn: result, simulationAccounts } = simulatedTxnResult - if (result?.value.err) { - console.warn('failed to simulate', result?.value.err) - console.warn(result?.value.logs?.join('\n')) - if (!hasEnoughSol || isInsufficientBal(result?.value.err)) { - if (!hasEnoughSol && !autoGasManagementToken) { - showModal({ - type: 'InsufficientSolConversion', - onCancel: async () => { - setInsufficientFunds(true) - }, - onSuccess: async () => { - setInsufficientFunds(false) - setSimulationError(false) - }, - }) - } else { - setInsufficientFunds(true) - } - } - setSimulationError(true) - return undefined - } - - const accounts = result?.value.accounts - - if (!accounts) return undefined - - const balanceChanges = await Promise.all( - accounts.map( - async ( - account: SimulatedTransactionAccountInfo | null, - index: number, - ) => { - if (!account) return null - - // Token changes - const isToken = account.owner === TOKEN_PROGRAM_ID.toString() - const isNativeSol = - account.owner === SystemProgram.programId.toBase58() - - if (isToken || isNativeSol) { - try { - let accountNativeBalance: BN - let tokenMint: PublicKey - let existingNativeBalance: BN - - // Parse token accounts for change in balances - if (isToken) { - try { - const tokenAccount = AccountLayout.decode( - Buffer.from( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - account.data[0] as any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - account.data[1] as any, - ), - ) - - if (!new PublicKey(tokenAccount.owner).equals(wallet)) { - return null - } - accountNativeBalance = new BN( - tokenAccount.amount.toString(), - ) - // Standard token mint - tokenMint = new PublicKey(tokenAccount.mint) - } catch (error) { - // Decoding of token account failed, not a token account - return null - } - - // Find the existing token account - const existingTokenAccount = tokenAccounts.find((t) => - new PublicKey(t.mint).equals(tokenMint), - ) - - existingNativeBalance = existingTokenAccount - ? new BN(existingTokenAccount.balance) - : new BN(0) - } else { - // Not interested in SOL balance changes for accounts that - // are not the current address - if ( - simulationAccounts && - !simulationAccounts[index].equals(wallet) - ) { - return null - } - accountNativeBalance = new BN(account.lamports.toString()) - // Faux mint for native SOL - tokenMint = new PublicKey(NATIVE_MINT) - existingNativeBalance = new BN( - ( - await connection.getAccountInfo( - simulationAccounts[index], - ) - )?.lamports || 0, - ) - // Don't include fees here - // First account is feePayer, if we made it here feePayer is wallet - if (index === 0) { - accountNativeBalance = accountNativeBalance.add( - new BN(solFee || 5000), - ) - } - } - - const token = await connection.getParsedAccountInfo(tokenMint) - - const { decimals } = (token.value?.data as ParsedAccountData) - .parsed.info - - const tokenMetadata = await getCollectableByMint( - tokenMint, - connection, - ) - - const type = accountNativeBalance.lt(existingNativeBalance) - ? 'send' - : 'receive' - - // Filter out zero change - if (!accountNativeBalance.eq(existingNativeBalance)) { - let nativeChange: BN - if (type === 'send') { - nativeChange = - existingNativeBalance.sub(accountNativeBalance) - } else { - nativeChange = accountNativeBalance.sub( - existingNativeBalance, - ) - } - return { - nativeChange: Math.abs(toNumber(nativeChange, decimals)), - decimals, - mint: tokenMint, - symbol: tokenMint.equals(NATIVE_MINT) - ? 'SOL' - : tokenMetadata?.symbol, - type, - } as BalanceChange - } - } catch (err) { - // ignore, probably not a token account or some other - // failure, we don't want to fail displaying the popup - console.warn('failed to get balance changes', err) - return null - } - } - return null - }, - ), - ) - - return balanceChanges.filter(truthy) - } catch (err) { - console.warn('err', err) - return undefined - } - }, [ - simulatedTxnResult, - connection, - tokenAccounts, - hasEnoughSol, - anchorProvider, - wallet, - autoGasManagementToken, - ]) - - return { - loading: loadingBal || loadingFee || loadingSim, - simulationError, - insufficientFunds, - balanceChanges: estimatedBalanceChanges, - solFee: solFee || 5000, - priorityFee, - estimateFeeErr, - } -} diff --git a/src/hooks/useSubmitTxn.ts b/src/hooks/useSubmitTxn.tsx similarity index 84% rename from src/hooks/useSubmitTxn.ts rename to src/hooks/useSubmitTxn.tsx index cc6c3bd62..3e1d3b40e 100644 --- a/src/hooks/useSubmitTxn.ts +++ b/src/hooks/useSubmitTxn.tsx @@ -2,6 +2,7 @@ import { NetworkType } from '@helium/onboarding' import { useOnboarding } from '@helium/react-native-sdk' import { chunks, + DC_MINT, populateMissingDraftInfo, sendAndConfirmWithRetry, toVersionedTx, @@ -11,7 +12,12 @@ import { useAccountStorage } from '@storage/AccountStorageProvider' import i18n from '@utils/i18n' import * as solUtils from '@utils/solanaUtils' import BN from 'bn.js' -import { useCallback } from 'react' +import React, { useCallback } from 'react' +import { ellipsizeAddress } from '@utils/accountUtils' +import { SwapPreview } from '../solana/SwapPreview' +import { CollectablePreview } from '../solana/CollectablePreview' +import { MessagePreview } from '../solana/MessagePreview' +import { PaymentPreivew } from '../solana/PaymentPreview' import { useSolana } from '../solana/SolanaProvider' import { useWalletSign } from '../solana/WalletSignProvider' import { WalletStandardMessageTypes } from '../solana/walletSignBottomSheetTypes' @@ -75,13 +81,17 @@ export default () => { }), ) + const serializedTxs = txns.map((tx) => + Buffer.from(toVersionedTx(tx).serialize()), + ) + const decision = await walletSignBottomSheetRef.show({ type: WalletStandardMessageTypes.signTransaction, url: '', - additionalMessage: t('transactions.signPaymentTxn'), - serializedTxs: txns.map((tx) => - Buffer.from(toVersionedTx(tx).serialize()), - ), + header: t('transactions.sendTokens'), + message: t('transactions.signPaymentTxn'), + serializedTxs, + renderer: () => , }) if (!decision) { @@ -141,10 +151,12 @@ export default () => { ).serialize() const decision = await walletSignBottomSheetRef.show({ - type: WalletStandardMessageTypes.signTransaction, url: '', - additionalMessage: t('transactions.signTransferCollectableTxn'), + type: WalletStandardMessageTypes.signTransaction, + header: t('transactions.transferCollectable'), + message: t('transactions.signTransferCollectableTxn'), serializedTxs: [Buffer.from(serializedTx)], + renderer: () => , }) if (!decision) { @@ -171,7 +183,21 @@ export default () => { ) const submitJupiterSwap = useCallback( - async (swapTxn: VersionedTransaction) => { + async ({ + inputMint, + inputAmount, + outputMint, + outputAmount, + minReceived, + swapTxn, + }: { + inputMint: PublicKey + inputAmount: number + outputMint: PublicKey + outputAmount: number + minReceived: number + swapTxn: VersionedTransaction + }) => { if (!currentAccount || !anchorProvider || !walletSignBottomSheetRef) { throw new Error(t('errors.account')) } @@ -181,9 +207,21 @@ export default () => { const decision = await walletSignBottomSheetRef.show({ type: WalletStandardMessageTypes.signTransaction, url: '', - additionalMessage: t('transactions.signSwapTxn'), + header: t('swapsScreen.swapTokens'), + message: t('transactions.signSwapTxn'), serializedTxs: [Buffer.from(serializedTx)], suppressWarnings: true, + renderer: () => ( + + ), }) if (!decision) { @@ -234,7 +272,8 @@ export default () => { type: WalletStandardMessageTypes.signTransaction, url: '', warning: recipientExists ? '' : t('transactions.recipientNonExistent'), - additionalMessage: t('transactions.signSwapTxn'), + header: t('transactions.swapTokens'), + message: t('transactions.signSwapTxn'), serializedTxs: [Buffer.from(serializedTx)], }) @@ -279,8 +318,12 @@ export default () => { const decision = await walletSignBottomSheetRef.show({ type: WalletStandardMessageTypes.signTransaction, url: '', - additionalMessage: t('transactions.signClaimRewardsTxn'), + header: t('transactions.claimRewards'), + message: t('transactions.signClaimRewardsTxn'), serializedTxs: serializedTxs.map(Buffer.from), + renderer: () => ( + + ), }) if (!decision) { @@ -369,7 +412,7 @@ export default () => { type: WalletStandardMessageTypes.signTransaction, url: '', warning: recipientExists ? '' : t('transactions.recipientNonExistent'), - additionalMessage: t('transactions.signMintDataCreditsTxn'), + message: t('transactions.signMintDataCreditsTxn'), serializedTxs: [Buffer.from(serializedTx)], }) @@ -422,8 +465,22 @@ export default () => { const decision = await walletSignBottomSheetRef.show({ type: WalletStandardMessageTypes.signTransaction, url: '', - additionalMessage: t('transactions.signDelegateDCTxn'), + header: t('transactions.delegateDC'), + message: t('transactions.signDelegateDCTxn'), serializedTxs: [Buffer.from(serializedTx)], + renderer: () => ( + + ), }) if (!decision) { @@ -490,6 +547,7 @@ export default () => { maker: { address: currentAccount.address, }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, }) @@ -500,8 +558,16 @@ export default () => { const decision = await walletSignBottomSheetRef.show({ type: WalletStandardMessageTypes.signTransaction, url: '', - additionalMessage: t('transactions.signAssertLocationTxn'), + header: t('collectablesScreen.hotspots.assertLocation'), + message: t('transactions.signAssertLocationTxn'), serializedTxs, + renderer: () => ( + + ), }) if (!decision) { @@ -600,8 +666,16 @@ export default () => { warning: destinationExists ? '' : t('transactions.recipientNonExistent'), - additionalMessage: t('transactions.signPaymentTxn'), + header: t('transactions.updateRecipient'), + message: t('transactions.signPaymentTxn'), serializedTxs: [Buffer.from(toVersionedTx(txn).serialize())], + renderer: () => ( + + ), }) if (!decision) { diff --git a/src/locales/en.ts b/src/locales/en.ts index 892977f4a..c816bbc3e 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -1137,6 +1137,7 @@ export default { description: 'Last updated {{date}}. Tap for info.', }, transactions: { + updateRecipient: 'Update Recipient', added: 'Hotspot Added to Blockchain', addToAddressBook: { message: 'Would you like to add this wallet to your address book?', @@ -1192,13 +1193,19 @@ export default { transfer: 'Hotspot Transfer', transferBuy: 'Transfer Hotspot (Buy)', transferSell: 'Transfer Hotspot (Sell)', + delegateDC: 'Delegate data credits', transferValidator: 'Transfer Stake', txnFee: 'Transaction Fee', txnFeePaidBy: 'Transaction Fee paid by {{feePayer}}', unstakeValidator: 'Unstake {{ticker}}', validator: 'Validator', delegated: 'Delegated', + claimRewards: 'Claim Rewards', + sendTokens: 'Send Tokens', + transferCollectable: 'Transfer Collectable', signPaymentTxn: 'Sign this transaction to send your payment.', + simulateTxn: 'Simulate Transaction', + signTxn: 'Sign Transaction', signTransferCollectableTxn: 'Sign this transaction to transfer your collectable.', signSwapTxn: 'Sign this transaction to swap your tokens.', @@ -1254,16 +1261,21 @@ export default { locked: '{{symbol}} Locked', youHave: 'You have {{amount}} more {{symbol}} available to lock.', increase: 'Increase your voting power by locking tokens.', + lockYourTokens: 'Lock {{amount}} {{symbol}} for {{duration}}?', }, positions: { relinquish: 'Relinquish Votes', lockedAmount: 'Locked Amount {{amount}} {{symbol}}', constant: 'Constant', decaying: 'Decaying', - delegate: 'Delegate', - undelegate: 'Undelegate', - extend: 'Extend', - close: 'Close', + delegate: 'Delegate position', + undelegate: 'Undelegate position', + extend: 'Extend position', + close: 'Close position', + split: 'Split position', + transfer: 'Transfer between positions', + unpause: 'Unlock to start decaying', + pause: 'Pause to stop decaying', unableToClose: 'Unable to close', unableToSplit: 'Unable to split', unableToTransfer: 'Unable to transfer', @@ -1306,6 +1318,19 @@ export default { selectTransfer: 'Select position to transfer too.', selectSubDao: 'Select a existing SubNetwork to delegate to.', fetchingSubDaos: 'Fetching SubDaos...', + closeMessage: 'Close this position?', + flipLockupMesage: + "Your current position of {{amount}} {{symbol}} is {{status}}, please confirm whether you'd like to {{action}} or not?", + extendMessage: + 'Extend this positions lockup from {{existing}} to {{new}}?', + splitMessage: + 'Split {{amount}} {{symbol}} into a new position with {{lockupKind}} lockup of {{duration}}?', + transferMessage: + 'Transfer {{amount}} {{symbol}} to the position with {{targetAmount}} {{symbol}}?', + delegateMessage: + 'delegate {{amount}} {{symbol}} to the {{subdao}} subdao?', + undelegateMessage: 'Undelegate {{amount}} {{symbol}}?', + relinquishVotesMessage: 'Relinquish this positions votes?', }, proposals: { overviewTitle: 'Proposal Overview', @@ -1319,6 +1344,8 @@ export default { failed: 'Failed', cancelled: 'Cancelled', votes: 'Votes', + castVoteFor: 'Cast your vote for {{choice}}?', + relinquishVoteFor: 'Relinquish your vote for {{choice}}?', toVote: 'To vote, click on any option. To remove your vote, click the option again. Vote for up to {{maxChoicesPerVoter}} of {{choicesLength}} options.', }, diff --git a/src/solana/CollapsibleWritableAccountPreview.tsx b/src/solana/CollapsibleWritableAccountPreview.tsx index ad2578e95..1e3c7d004 100644 --- a/src/solana/CollapsibleWritableAccountPreview.tsx +++ b/src/solana/CollapsibleWritableAccountPreview.tsx @@ -94,6 +94,7 @@ const NativeSolBalanceChange = ({ /> ) } + function accountExists(account: AccountInfo | null): boolean { return account ? account.lamports > 0 : false } diff --git a/src/solana/CollectablePreview.tsx b/src/solana/CollectablePreview.tsx new file mode 100644 index 000000000..1d3811978 --- /dev/null +++ b/src/solana/CollectablePreview.tsx @@ -0,0 +1,70 @@ +import Send from '@assets/images/send.svg' +import Box from '@components/Box' +import { Pill } from '@components/Pill' +import Text from '@components/Text' +import React, { useMemo } from 'react' +import ImageBox from '@components/ImageBox' +import { ellipsizeAddress } from '@utils/accountUtils' +import { Collectable, CompressedNFT, isCompressedNFT } from '../types/solana' + +interface ICollectablePreviewProps { + collectable: CompressedNFT | Collectable + payee: string +} + +export const CollectablePreview = ({ + collectable, + payee, +}: ICollectablePreviewProps) => { + const metadata = useMemo(() => { + if (isCompressedNFT(collectable)) { + return collectable.content.metadata + } + + return collectable.metadata + }, [collectable]) + + return ( + + + + {metadata && ( + + + + )} + {ellipsizeAddress(payee)} + + + + + + + ) +} diff --git a/src/solana/MessagePreview.tsx b/src/solana/MessagePreview.tsx new file mode 100644 index 000000000..601c58676 --- /dev/null +++ b/src/solana/MessagePreview.tsx @@ -0,0 +1,29 @@ +import Box from '@components/Box' +import Text from '@components/Text' +import React from 'react' + +interface IMransactionPreviewProps { + message?: string + warning?: string +} + +export const MessagePreview = ({ + warning, + message, +}: IMransactionPreviewProps) => ( + + {message && {message}} + {warning && ( + + {warning} + + )} + +) diff --git a/src/solana/PaymentPreview.tsx b/src/solana/PaymentPreview.tsx new file mode 100644 index 000000000..f24468c55 --- /dev/null +++ b/src/solana/PaymentPreview.tsx @@ -0,0 +1,60 @@ +import Send from '@assets/images/send.svg' +import Box from '@components/Box' +import { Pill } from '@components/Pill' +import Text from '@components/Text' +import TokenIcon from '@components/TokenIcon' +import { useMint } from '@helium/helium-react-hooks' +import { useMetaplexMetadata } from '@hooks/useMetaplexMetadata' +import { PublicKey } from '@solana/web3.js' +import { ellipsizeAddress } from '@utils/accountUtils' +import { humanReadable } from '@utils/solanaUtils' +import BN from 'bn.js' +import React from 'react' + +interface IPaymentPreviewProps { + payments: { + payee: string + balanceAmount: BN + max?: boolean + }[] + mint: PublicKey +} + +export const PaymentPreivew = ({ mint, payments }: IPaymentPreviewProps) => { + const decimals = useMint(mint)?.info?.decimals + const { symbol, json } = useMetaplexMetadata(mint) + + return ( + + {payments.map(({ payee, balanceAmount }, index) => ( + + + {json?.image ? : null} + {symbol} + {ellipsizeAddress(payee)} + + + + + + ))} + + ) +} diff --git a/src/solana/SwapPreview.tsx b/src/solana/SwapPreview.tsx new file mode 100644 index 000000000..ad20201d4 --- /dev/null +++ b/src/solana/SwapPreview.tsx @@ -0,0 +1,101 @@ +import Send from '@assets/images/send.svg' +import Receive from '@assets/images/receive.svg' +import Box from '@components/Box' +import CircleLoader from '@components/CircleLoader' +import { Pill } from '@components/Pill' +import Text from '@components/Text' +import TokenIcon from '@components/TokenIcon' +import { useMint } from '@helium/helium-react-hooks' +import { useMetaplexMetadata } from '@hooks/useMetaplexMetadata' +import { PublicKey } from '@solana/web3.js' +import React from 'react' + +interface ISwapPreviewProps { + inputMint: PublicKey + inputAmount: number + outputMint: PublicKey + outputAmount: number + minReceived: number +} + +export const SwapPreview = ({ + inputMint, + inputAmount, + outputMint, + outputAmount, + minReceived, +}: ISwapPreviewProps) => { + const outputDecimals = useMint(outputMint)?.info?.decimals + const { + loading: loadingInputMintMetadata, + symbol: inputMintSymbol, + json: inputMintJson, + } = useMetaplexMetadata(inputMint) + const { + loading: loadingOutputMintMetadata, + symbol: outputMintSymbol, + json: outputMintJson, + } = useMetaplexMetadata(outputMint) + + return ( + + {loadingInputMintMetadata || loadingOutputMintMetadata ? ( + + ) : ( + <> + + + {inputMintJson ? ( + + ) : null} + {inputMintSymbol} + + + + + + + + {outputMintJson ? ( + + ) : null} + {outputMintSymbol} + + + + + + + Min Received due to slippage: + + {`~${minReceived.toFixed(outputDecimals)}`} + + + + )} + + ) +} diff --git a/src/solana/WalletSIgnBottomSheetSimulated.tsx b/src/solana/WalletSIgnBottomSheetSimulated.tsx new file mode 100644 index 000000000..7037e0f29 --- /dev/null +++ b/src/solana/WalletSIgnBottomSheetSimulated.tsx @@ -0,0 +1,536 @@ +import Checkmark from '@assets/images/checkmark.svg' +import IndentArrow from '@assets/images/indentArrow.svg' +import InfoIcon from '@assets/images/info.svg' +import CancelIcon from '@assets/images/remixCancel.svg' +import ChevronDown from '@assets/images/remixChevronDown.svg' +import ChevronUp from '@assets/images/remixChevronUp.svg' +import Box from '@components/Box' +import ButtonPressable from '@components/ButtonPressable' +import CircleLoader from '@components/CircleLoader' +import SubmitButton from '@components/SubmitButton' +import Text from '@components/Text' +import TouchableOpacityBox from '@components/TouchableOpacityBox' +import { WarningPill } from '@components/WarningPill' +import { entityCreatorKey } from '@helium/helium-entity-manager-sdk' +import { useSolOwnedAmount } from '@helium/helium-react-hooks' +import { humanReadable } from '@helium/spl-utils' +import { sus } from '@helium/sus' +import { useBN } from '@hooks/useBN' +import { useCurrentWallet } from '@hooks/useCurrentWallet' +import { useRentExempt } from '@hooks/useRentExempt' +import { DAO_KEY } from '@utils/constants' +import axios from 'axios' +import BN from 'bn.js' +import React, { useCallback, useMemo, useState } from 'react' +import { useAsync } from 'react-async-hook' +import { useTranslation } from 'react-i18next' +import { ScrollView } from 'react-native-gesture-handler' +import { useSolana } from './SolanaProvider' +import WalletSignBottomSheetTransaction from './WalletSignBottomSheetTransaction' +import { + WalletSignOpts, + WalletStandardMessageTypes, +} from './walletSignBottomSheetTypes' + +const WELL_KNOWN_CANOPY_URL = + 'https://shdw-drive.genesysgo.net/6tcnBSybPG7piEDShBcrVtYJDPSvGrDbVvXmXKpzBvWP/merkles.json' +let wellKnownCanopyCache: Record | undefined + +type IWalletSignBottomSheetSimulatedProps = WalletSignOpts & { + onCancel: () => void + onAccept: () => void +} + +export const WalletSignBottomSheetSimulated = ({ + url, + type, + header, + warning, + serializedTxs, + suppressWarnings, + message, + onAccept, + onCancel, +}: IWalletSignBottomSheetSimulatedProps) => { + const { t } = useTranslation() + const { connection, cluster } = useSolana() + const wallet = useCurrentWallet() + const solBalance = useBN(useSolOwnedAmount(wallet).amount) + const { rentExempt } = useRentExempt() + + const [infoVisible, setInfoVisible] = useState(false) + const [writableInfoVisible, setWritableInfoVisible] = useState(false) + const [feesExpanded, setFeesExpanded] = useState(false) + const [currentPage, setCurrentPage] = useState(1) + + const { result: accountBlacklist } = useAsync(async () => { + if (!wellKnownCanopyCache) + wellKnownCanopyCache = await (await axios.get(WELL_KNOWN_CANOPY_URL)).data + + if (wellKnownCanopyCache) { + return new Set(Object.keys(wellKnownCanopyCache)) + } + + return new Set([]) + }, []) + + const { + result: simulationResults, + error, + loading, + } = useAsync(async () => { + if (connection && wallet && serializedTxs && accountBlacklist) { + return sus({ + connection, + wallet, + serializedTransactions: serializedTxs, + checkCNfts: true, + cluster, + extraSearchAssetParams: { + creatorVerified: true, + creatorAddress: entityCreatorKey(DAO_KEY)[0].toBase58(), + }, + accountBlacklist, + }) + } + }, [cluster, connection, wallet, accountBlacklist, serializedTxs]) + + const [currentTxs, hasMore] = useMemo(() => { + if (!simulationResults) return [[], false] + + let scopedTxs + const itemsPerPage = 5 + const pages = Math.ceil((simulationResults.length || 0) / itemsPerPage) + const more = currentPage < pages + + if (simulationResults) { + const endIndex = currentPage * itemsPerPage + scopedTxs = simulationResults.slice(0, endIndex) + } + + return [scopedTxs, more] + }, [simulationResults, currentPage]) + + const handleLoadMore = useCallback(() => { + setCurrentPage((page) => page + 1) + }, [setCurrentPage]) + + const estimatedTotalLamports = useMemo( + () => + simulationResults?.reduce( + (a, b) => a + b.solFee + (b.priorityFee || 0), + 0, + ) || 0, + [simulationResults], + ) + + const estimatedTotalSol = useMemo( + () => (loading ? '...' : humanReadable(new BN(estimatedTotalLamports), 9)), + [estimatedTotalLamports, loading], + ) + + const estimatedTotalBaseFee = useMemo( + () => + humanReadable( + new BN(simulationResults?.reduce((a, b) => a + b.solFee, 0) || 0), + 9, + ), + [simulationResults], + ) + + const estimatedTotalPriorityFee = useMemo( + () => + humanReadable( + new BN( + simulationResults?.reduce((a, b) => a + (b.priorityFee || 0), 0) || 0, + ), + 9, + ), + [simulationResults], + ) + + const totalWarnings = useMemo( + () => + simulationResults?.reduce( + (a, b) => + a + + (b.warnings.filter((w) => w.severity === 'critical').length > 0 + ? 1 + : 0), + 0, + ), + [simulationResults], + ) + + const worstSeverity = useMemo(() => { + if (simulationResults) { + return simulationResults?.reduce((a, b) => { + if (a === 'critical') { + return 'critical' + } + if (b.warnings.some((w) => w.severity === 'critical')) { + return 'critical' + } + + return a + }, 'warning') + } + return 'critical' + }, [simulationResults]) + + const insufficientRentExempt = useMemo(() => { + if (solBalance) { + return new BN(solBalance.toString()) + .sub(new BN(estimatedTotalLamports)) + .lt(new BN(rentExempt || 0)) + } + }, [solBalance, estimatedTotalLamports, rentExempt]) + + const insufficientFunds = useMemo( + () => + new BN(estimatedTotalLamports).gt( + new BN(solBalance?.toString() || '0'), + ) || simulationResults?.some((r) => r.insufficientFunds), + [solBalance, estimatedTotalLamports, simulationResults], + ) + + const Chevron = feesExpanded ? ChevronUp : ChevronDown + const showWarnings = + totalWarnings && !suppressWarnings && worstSeverity === 'critical' + + return ( + + {header || url ? ( + + {header ? ( + + {header} + + ) : null} + {url ? ( + + {url || ''} + + ) : null} + + ) : null} + {type === WalletStandardMessageTypes.connect && ( + + + + + + {t('browserScreen.connectBullet1')} + + + + + + {t('browserScreen.connectBullet2')} + + + + + + {t('browserScreen.connectToWebsitesYouTrust')} + + + + )} + {(type === WalletStandardMessageTypes.signMessage || + type === WalletStandardMessageTypes.signAndSendTransaction || + type === WalletStandardMessageTypes.signTransaction) && ( + + {warning && ( + + + {warning} + + + )} + + + + + + {t('browserScreen.estimatedChanges')} + + setInfoVisible((prev) => !prev)} + > + + + + {infoVisible && ( + + {t('browserScreen.estimatedChangesDescription')} + + )} + + {!(insufficientFunds || insufficientRentExempt) && message && ( + + {message} + + )} + + {showWarnings ? ( + + + + ) : null} + + {(insufficientFunds || insufficientRentExempt) && ( + + + + )} + + + + + {t('browserScreen.writableAccounts')} + + setWritableInfoVisible((prev) => !prev)} + > + + + + + {t('browserScreen.transactions', { + num: simulationResults?.length || 1, + })} + + + {writableInfoVisible && ( + + {t('browserScreen.writableAccountsDescription')} + + )} + + + {loading && } + {error ? ( + + + + + {error.message || error.toString()} + + + + + ) : null} + {currentTxs && ( + <> + {currentTxs.map((tx, idx) => ( + + ))} + {hasMore && ( + + )} + + )} + + + {(type === WalletStandardMessageTypes.signAndSendTransaction || + type === WalletStandardMessageTypes.signTransaction) && ( + + setFeesExpanded(!feesExpanded)} + marginTop="s" + flexDirection="row" + justifyContent="space-between" + > + + {t('browserScreen.totalNetworkFee')} + + + + {`~${estimatedTotalSol} SOL`} + + + + + {feesExpanded ? ( + + + + + + {t('browserScreen.totalBaseFee')} + + + + + {`~${estimatedTotalBaseFee} SOL`} + + + + + + + {t('browserScreen.totalPriorityFee')} + + + + + {`~${estimatedTotalPriorityFee} SOL`} + + + + ) : null} + + )} + + + )} + {showWarnings ? ( + + + + + + + ) : ( + + + + + + )} + + ) +} diff --git a/src/solana/WalletSignBottomSheet.tsx b/src/solana/WalletSignBottomSheet.tsx index 15f9f123a..15823a9ac 100644 --- a/src/solana/WalletSignBottomSheet.tsx +++ b/src/solana/WalletSignBottomSheet.tsx @@ -1,33 +1,11 @@ -import Checkmark from '@assets/images/checkmark.svg' -import IndentArrow from '@assets/images/indentArrow.svg' -import InfoIcon from '@assets/images/info.svg' -import CancelIcon from '@assets/images/remixCancel.svg' -import ChevronDown from '@assets/images/remixChevronDown.svg' -import ChevronUp from '@assets/images/remixChevronUp.svg' import Box from '@components/Box' -import ButtonPressable from '@components/ButtonPressable' -import CircleLoader from '@components/CircleLoader' -import SubmitButton from '@components/SubmitButton' -import Text from '@components/Text' -import TouchableOpacityBox from '@components/TouchableOpacityBox' -import { WarningPill } from '@components/WarningPill' import { BottomSheetBackdrop, BottomSheetModal, BottomSheetModalProvider, BottomSheetScrollView, } from '@gorhom/bottom-sheet' -import { entityCreatorKey } from '@helium/helium-entity-manager-sdk' -import { useSolOwnedAmount } from '@helium/helium-react-hooks' -import { sus } from '@helium/sus' -import { useBN } from '@hooks/useBN' -import { useCurrentWallet } from '@hooks/useCurrentWallet' -import { useRentExempt } from '@hooks/useRentExempt' import { useColors, useOpacity } from '@theme/themeHooks' -import { DAO_KEY } from '@utils/constants' -import { humanReadable } from '@utils/solanaUtils' -import axios from 'axios' -import BN from 'bn.js' import React, { Ref, forwardRef, @@ -39,13 +17,9 @@ import React, { useRef, useState, } from 'react' -import { useAsync } from 'react-async-hook' -import { useTranslation } from 'react-i18next' -import { TouchableOpacity } from 'react-native' -import { ScrollView } from 'react-native-gesture-handler' import { useSharedValue } from 'react-native-reanimated' -import { useSolana } from './SolanaProvider' -import WalletSignBottomSheetTransaction from './WalletSignBottomSheetTransaction' +import { WalletSignBottomSheetSimulated } from './WalletSIgnBottomSheetSimulated' +import { WalletSignBottomSheetCompact } from './WalletSignBottomSheetCompact' import { WalletSignBottomSheetProps, WalletSignBottomSheetRef, @@ -54,217 +28,49 @@ import { } from './walletSignBottomSheetTypes' let promiseResolve: (value: boolean | PromiseLike) => void - -const WELL_KNOWN_CANOPY_URL = - 'https://shdw-drive.genesysgo.net/6tcnBSybPG7piEDShBcrVtYJDPSvGrDbVvXmXKpzBvWP/merkles.json' -let wellKnownCanopyCache: Record | undefined - const WalletSignBottomSheet = forwardRef( ( { onClose, children }: WalletSignBottomSheetProps, ref: Ref, ) => { useImperativeHandle(ref, () => ({ show, hide })) - const { rentExempt } = useRentExempt() - const { backgroundStyle } = useOpacity('surfaceSecondary', 1) const { secondaryText } = useColors() - const { t } = useTranslation() - const wallet = useCurrentWallet() - const solBalance = useBN(useSolOwnedAmount(wallet).amount) + const { backgroundStyle } = useOpacity('surfaceSecondary', 1) + const animatedContentHeight = useSharedValue(0) + const bottomSheetModalRef = useRef(null) - const [isVisible, setIsVisible] = useState(false) - const [infoVisible, setInfoVisible] = useState(false) - const [writableInfoVisible, setWritableInfoVisible] = useState(false) + const [simulated, setSimulated] = useState(false) const [walletSignOpts, setWalletSignOpts] = useState({ type: WalletStandardMessageTypes.connect, - url: '', - additionalMessage: '', + url: undefined, + message: '', serializedTxs: undefined, header: undefined, suppressWarnings: false, }) - const { connection, cluster } = useSolana() - const { result: accountBlacklist } = useAsync(async () => { - if (!wellKnownCanopyCache) - wellKnownCanopyCache = await ( - await axios.get(WELL_KNOWN_CANOPY_URL) - ).data - - if (wellKnownCanopyCache) { - return new Set(Object.keys(wellKnownCanopyCache)) - } - - return new Set([]) - }, []) - const [feesExpanded, setFeesExpanded] = useState(false) - const Chevron = feesExpanded ? ChevronUp : ChevronDown - - const itemsPerPage = 5 - const [currentPage, setCurrentPage] = useState(1) - const { - result: simulationResults, - error, - loading, - } = useAsync(async () => { - if ( - connection && - wallet && - walletSignOpts.serializedTxs && - accountBlacklist - ) { - return sus({ - connection, - wallet, - serializedTransactions: walletSignOpts.serializedTxs, - checkCNfts: true, - cluster, - extraSearchAssetParams: { - creatorVerified: true, - creatorAddress: entityCreatorKey(DAO_KEY)[0].toBase58(), - }, - accountBlacklist, - }) - } - }, [ - walletSignOpts.serializedTxs, - cluster, - connection, - wallet, - accountBlacklist, - ]) - const [currentTxs, hasMore] = useMemo(() => { - if (simulationResults) { - const totalPages = Math.ceil( - (simulationResults.length || 0) / itemsPerPage, - ) - const more = currentPage < totalPages - let scopedTxs - - if (simulationResults) { - const endIndex = currentPage * itemsPerPage - scopedTxs = simulationResults.slice(0, endIndex) - } - - return [scopedTxs, more] - } - return [[], false] - }, [simulationResults, currentPage]) - - const handleLoadMore = useCallback(() => { - setCurrentPage((page) => page + 1) - }, [setCurrentPage]) - - const estimatedTotalLamports = useMemo( - () => - simulationResults?.reduce( - (a, b) => a + b.solFee + (b.priorityFee || 0), - 0, - ) || 0, - [simulationResults], - ) - const estimatedTotalSol = useMemo( - () => - loading ? '...' : humanReadable(new BN(estimatedTotalLamports), 9), - [estimatedTotalLamports, loading], - ) - const estimatedTotalBaseFee = useMemo( - () => - humanReadable( - new BN(simulationResults?.reduce((a, b) => a + b.solFee, 0) || 0), - 9, - ), - [simulationResults], - ) - const estimatedTotalPriorityFee = useMemo( - () => - humanReadable( - new BN( - simulationResults?.reduce((a, b) => a + (b.priorityFee || 0), 0) || - 0, - ), - 9, - ), - [simulationResults], - ) - const totalWarnings = useMemo( - () => - simulationResults?.reduce( - (a, b) => - a + - (b.warnings.filter((w) => w.severity === 'critical').length > 0 - ? 1 - : 0), - 0, - ), - [simulationResults], - ) - const worstSeverity = useMemo(() => { - if (simulationResults) { - return simulationResults?.reduce((a, b) => { - if (a === 'critical') { - return 'critical' - } - if (b.warnings.some((w) => w.severity === 'critical')) { - return 'critical' - } - - return a - }, 'warning') - } - return 'critical' - }, [simulationResults]) - - const insufficientRentExempt = useMemo(() => { - if (solBalance) { - return new BN(solBalance.toString()) - .sub(new BN(estimatedTotalLamports)) - .lt(new BN(rentExempt || 0)) - } - }, [solBalance, estimatedTotalLamports, rentExempt]) - const insufficientFunds = useMemo( - () => - new BN(estimatedTotalLamports).gt( - new BN(solBalance?.toString() || '0'), - ) || simulationResults?.some((r) => r.insufficientFunds), - [solBalance, estimatedTotalLamports, simulationResults], + const hasRenderer = useMemo( + () => walletSignOpts.renderer !== undefined, + [walletSignOpts], ) - const animatedContentHeight = useSharedValue(0) + useEffect(() => { + bottomSheetModalRef.current?.present() + }, [bottomSheetModalRef]) const hide = useCallback(() => { - setIsVisible(false) bottomSheetModalRef.current?.close() + setSimulated(false) }, []) - const show = useCallback( - ({ - type, - url, - warning, - additionalMessage, - serializedTxs, - header, - suppressWarnings, - }: WalletSignOpts) => { - bottomSheetModalRef.current?.expand() - setIsVisible(true) - setWalletSignOpts({ - type, - url, - warning, - additionalMessage, - serializedTxs, - header, - suppressWarnings, - }) - const p = new Promise((resolve) => { - promiseResolve = resolve - }) - return p - }, - [], - ) + const show = useCallback((opts: WalletSignOpts) => { + bottomSheetModalRef.current?.expand() + setWalletSignOpts(opts) + + return new Promise((resolve) => { + promiseResolve = resolve + }) + }, []) const renderBackdrop = useCallback( (props) => ( @@ -284,18 +90,11 @@ const WalletSignBottomSheet = forwardRef( } // We need to re present the bottom sheet after it is dismissed so that it can be expanded again bottomSheetModalRef.current?.present() - setIsVisible(false) if (onClose) { onClose() } }, [onClose]) - const handleIndicatorStyle = useMemo(() => { - return { - backgroundColor: secondaryText, - } - }, [secondaryText]) - const onAcceptHandler = useCallback(() => { if (promiseResolve) { hide() @@ -310,16 +109,6 @@ const WalletSignBottomSheet = forwardRef( } }, [hide]) - useEffect(() => { - bottomSheetModalRef.current?.present() - }, [bottomSheetModalRef]) - - const showWarnings = - totalWarnings && - !walletSignOpts.suppressWarnings && - worstSeverity === 'critical' - - const { type, warning, additionalMessage } = walletSignOpts return ( @@ -330,7 +119,9 @@ const WalletSignBottomSheet = forwardRef( backdropComponent={renderBackdrop} onDismiss={handleModalDismiss} enableDismissOnClose - handleIndicatorStyle={handleIndicatorStyle} + handleIndicatorStyle={{ + backgroundColor: secondaryText, + }} // https://ethercreative.github.io/react-native-shadow-generator/ style={{ shadowColor: '#000', @@ -346,354 +137,20 @@ const WalletSignBottomSheet = forwardRef( contentHeight={animatedContentHeight} > - - {walletSignOpts.header || walletSignOpts.url ? ( - - {walletSignOpts.header ? ( - - {walletSignOpts.header} - - ) : null} - {walletSignOpts.url ? ( - - {walletSignOpts.url || ''} - - ) : null} - - ) : null} - {type === WalletStandardMessageTypes.connect && ( - - - - - - {t('browserScreen.connectBullet1')} - - - - - - {t('browserScreen.connectBullet2')} - - - - - - {t('browserScreen.connectToWebsitesYouTrust')} - - - - )} - {(type === WalletStandardMessageTypes.signMessage || - type === WalletStandardMessageTypes.signAndSendTransaction || - type === WalletStandardMessageTypes.signTransaction) && ( - - {warning && ( - - - {warning} - - - )} - - - - - - {t('browserScreen.estimatedChanges')} - - setInfoVisible((prev) => !prev)} - > - - - - {infoVisible && ( - - {t('browserScreen.estimatedChangesDescription')} - - )} - - {!(insufficientFunds || insufficientRentExempt) && - additionalMessage && ( - - {additionalMessage} - - )} - - {showWarnings ? ( - - - - ) : null} - - {(insufficientFunds || insufficientRentExempt) && ( - - - - )} - - - - - {t('browserScreen.writableAccounts')} - - - setWritableInfoVisible((prev) => !prev) - } - > - - - - - {t('browserScreen.transactions', { - num: simulationResults?.length || 1, - })} - - - {writableInfoVisible && ( - - {t('browserScreen.writableAccountsDescription')} - - )} - - - {loading && } - {error ? ( - - - - - {error.message || error.toString()} - - - - - ) : null} - {isVisible && currentTxs && ( - <> - {currentTxs.map((tx, idx) => ( - - ))} - {hasMore && ( - - )} - - )} - - - {(type === - WalletStandardMessageTypes.signAndSendTransaction || - type === - WalletStandardMessageTypes.signTransaction) && ( - - setFeesExpanded(!feesExpanded)} - marginTop="s" - flexDirection="row" - justifyContent="space-between" - > - - {t('browserScreen.totalNetworkFee')} - - - - {`~${estimatedTotalSol} SOL`} - - - - - {feesExpanded ? ( - - - - - - {t('browserScreen.totalBaseFee')} - - - - - {`~${estimatedTotalBaseFee} SOL`} - - - - - - - {t('browserScreen.totalPriorityFee')} - - - - - {`~${estimatedTotalPriorityFee} SOL`} - - - - ) : null} - - )} - - - )} - {showWarnings ? ( - - - - - - - ) : ( - - - - - - )} - + {hasRenderer && !simulated ? ( + setSimulated(true)} + onAccept={onAcceptHandler} + onCancel={onCancelHandler} + /> + ) : ( + + )} {children} diff --git a/src/solana/WalletSignBottomSheetCompact.tsx b/src/solana/WalletSignBottomSheetCompact.tsx new file mode 100644 index 000000000..7e775c363 --- /dev/null +++ b/src/solana/WalletSignBottomSheetCompact.tsx @@ -0,0 +1,151 @@ +import Box from '@components/Box' +import ButtonPressable from '@components/ButtonPressable' +import Text from '@components/Text' +import React, { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { TouchableOpacity } from 'react-native-gesture-handler' +import { useCurrentWallet } from '@hooks/useCurrentWallet' +import { useBN } from '@hooks/useBN' +import { useSolOwnedAmount } from '@helium/helium-react-hooks' +import { useRentExempt } from '@hooks/useRentExempt' +import { LAMPORTS_PER_SOL } from '@solana/web3.js' +import BN from 'bn.js' +import { getBasePriorityFee } from '@utils/walletApiV2' +import { useAsync } from 'react-async-hook' +import { WalletSignOpts } from './walletSignBottomSheetTypes' + +type IWalletSignBottomSheetCompactProps = WalletSignOpts & { + onSimulate: () => void + onCancel: () => void + onAccept: () => void +} + +export const WalletSignBottomSheetCompact = ({ + header, + message, + warning, + serializedTxs, + renderer, + onSimulate, + onCancel, + onAccept, +}: IWalletSignBottomSheetCompactProps) => { + const { t } = useTranslation() + const wallet = useCurrentWallet() + const solBalance = useBN(useSolOwnedAmount(wallet).amount) + const { rentExempt } = useRentExempt() + const [estimatedTotalSolByLamports, setEstimatedTotalSolByLamports] = + useState(0) + + useAsync(async () => { + let fees = 5000 / LAMPORTS_PER_SOL + if (serializedTxs) { + const basePriorityFee = await getBasePriorityFee() + const priorityFees = serializedTxs.length * basePriorityFee + fees = (serializedTxs.length * 5000 + priorityFees) / LAMPORTS_PER_SOL + } + + setEstimatedTotalSolByLamports(fees) + }, [serializedTxs, setEstimatedTotalSolByLamports]) + + const insufficientRentExempt = useMemo(() => { + if (solBalance) { + return new BN(solBalance.toString()) + .sub(new BN(estimatedTotalSolByLamports)) + .lt(new BN(rentExempt || 0)) + } + }, [solBalance, estimatedTotalSolByLamports, rentExempt]) + + const insufficientFunds = useMemo( + () => + new BN(estimatedTotalSolByLamports).gt( + new BN(solBalance?.toString() || '0'), + ), + [solBalance, estimatedTotalSolByLamports], + ) + + return ( + + {warning && ( + + + {warning} + + + )} + + {!(insufficientFunds || insufficientRentExempt) && ( + {header || t('transactions.signTxn')} + )} + + {!(insufficientFunds || insufficientRentExempt) && message && ( + + {message} + + )} + + {(insufficientFunds || insufficientRentExempt) && ( + + + {insufficientFunds + ? t('browserScreen.insufficientFunds') + : t('browserScreen.insufficientRentExempt', { + amount: rentExempt, + })} + + + )} + {renderer && renderer()} + + + {t('browserScreen.totalNetworkFee')} + + + {`~${estimatedTotalSolByLamports} SOL`} + + + + + + {t('transactions.simulateTxn')} + + + + + + + + + ) +} diff --git a/src/solana/walletSignBottomSheetTypes.tsx b/src/solana/walletSignBottomSheetTypes.tsx index ad04b0578..3ed6593bc 100644 --- a/src/solana/walletSignBottomSheetTypes.tsx +++ b/src/solana/walletSignBottomSheetTypes.tsx @@ -7,32 +7,19 @@ export enum WalletStandardMessageTypes { signMessage = 'signMessage', } -export type BalanceChange = { - ticker: string - amount: number - type: 'send' | 'receive' -} - export type WalletSignOpts = { + url?: string type: WalletStandardMessageTypes - url: string - serializedTxs: Buffer[] | undefined - warning?: string - additionalMessage?: string - header?: string - // Allow supressing warnings for our own txs suppressWarnings?: boolean + header?: string + message?: string + warning?: string + serializedTxs?: Buffer[] + renderer?: () => ReactNode } export type WalletSignBottomSheetRef = { - show: ({ - type, - url, - additionalMessage, - serializedTxs, - header, - suppressWarnings, - }: WalletSignOpts) => Promise + show: (opts: WalletSignOpts) => Promise hide: () => void } diff --git a/src/types/solana.ts b/src/types/solana.ts index c58f45ffe..9c5037bc8 100644 --- a/src/types/solana.ts +++ b/src/types/solana.ts @@ -87,6 +87,12 @@ export type CompressedNFT = { export type Collectable = any +export const isCompressedNFT = ( + collectable: CompressedNFT | Collectable, +): collectable is CompressedNFT => { + return (collectable as CompressedNFT).compression?.compressed !== undefined +} + type NativeTransfer = { fromUserAccount: string toUserAccount: string