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