diff --git a/packages/core-mobile/app/hooks/earn/useClaimFees.ts b/packages/core-mobile/app/hooks/earn/useClaimFees.ts index cbe56f433e..a5541cf211 100644 --- a/packages/core-mobile/app/hooks/earn/useClaimFees.ts +++ b/packages/core-mobile/app/hooks/earn/useClaimFees.ts @@ -4,14 +4,15 @@ import { calculatePChainFee } from 'services/earn/calculateCrossChainFees' import { useSelector } from 'react-redux' -import { selectIsDeveloperMode } from 'store/settings/advanced' +import { selectIsDeveloperMode } from 'store/settings/advanced/slice' +import { selectPFeeAdjustmentThreshold } from 'store/posthog/slice' import NetworkService from 'services/network/NetworkService' -import { selectActiveAccount } from 'store/account' +import { selectActiveAccount } from 'store/account/slice' import WalletService from 'services/wallet/WalletService' import Logger from 'utils/Logger' import { useCChainBaseFee } from 'hooks/useCChainBaseFee' import { TokenUnit } from '@avalabs/core-utils-sdk' -import { selectActiveNetwork } from 'store/network' +import { selectActiveNetwork } from 'store/network/slice' import { isDevnet } from 'utils/isDevnet' import { weiToNano } from 'utils/units/converter' import { CorePrimaryAccount } from '@avalabs/types' @@ -46,6 +47,7 @@ export const useClaimFees = ( const isDevMode = useSelector(selectIsDeveloperMode) const activeAccount = useSelector(selectActiveAccount) const activeNetwork = useSelector(selectActiveNetwork) + const pFeeAdjustmentThreshold = useSelector(selectPFeeAdjustmentThreshold) const [totalFees, setTotalFees] = useState() const [exportFee, setExportFee] = useState() const [defaultTxFee, setDefaultTxFee] = useState() @@ -92,7 +94,8 @@ export const useClaimFees = ( activeAccount, avaxXPNetwork, provider: xpProvider, - feeState: defaultFeeState + feeState: defaultFeeState, + pFeeAdjustmentThreshold }) setDefaultTxFee(txFee) } @@ -102,7 +105,8 @@ export const useClaimFees = ( avaxXPNetwork, xpProvider, totalClaimable, - defaultFeeState + defaultFeeState, + pFeeAdjustmentThreshold ]) useEffect(() => { @@ -136,7 +140,8 @@ export const useClaimFees = ( activeAccount, avaxXPNetwork, provider: xpProvider, - feeState + feeState, + pFeeAdjustmentThreshold }) Logger.info('importCFee', importCFee.toDisplay()) @@ -175,7 +180,8 @@ export const useClaimFees = ( avaxXPNetwork, totalClaimable, xpProvider, - feeState + feeState, + pFeeAdjustmentThreshold ]) return { @@ -192,7 +198,8 @@ const getExportPFee = async ({ activeAccount, avaxXPNetwork, provider, - feeState + feeState, + pFeeAdjustmentThreshold }: { amountInNAvax: TokenUnit activeAccount: CorePrimaryAccount @@ -200,6 +207,7 @@ const getExportPFee = async ({ provider: Avalanche.JsonRpcProvider feeState?: pvm.FeeState missingAvax?: bigint + pFeeAdjustmentThreshold: number }): Promise => { if (provider.isEtnaEnabled()) { let unsignedTxP @@ -220,26 +228,41 @@ const getExportPFee = async ({ getAssetId(avaxXPNetwork) ) - if (missingAmount) { - const amountAvailableToClaim = amountInNAvax.toSubUnit() - missingAmount + if (!missingAmount) { + // rethrow error if it's not an insufficient funds error + throw error + } - if (amountAvailableToClaim <= 0) { - // rethrow insufficient funds error when balance is not enough to cover fee - throw error - } + const amountAvailable = amountInNAvax.toSubUnit() + const ratio = Number(missingAmount) / Number(amountAvailable) - unsignedTxP = await WalletService.createExportPTx({ - amountInNAvax: amountAvailableToClaim, - accountIndex: activeAccount.index, - avaxXPNetwork, - destinationAddress: activeAccount.addressPVM, - destinationChain: 'C', - feeState + if (ratio > pFeeAdjustmentThreshold) { + // rethrow insufficient funds error when missing fee is too much compared to total token amount + Logger.error('Failed to simulate export p due to excessive fees', { + missingAmount, + ratio }) - } else { - // rethrow error if it's not an insufficient funds error throw error } + + const amountAvailableToClaim = amountAvailable - missingAmount + + if (amountAvailableToClaim <= 0) { + Logger.error('Failed to simulate export p due to excessive fees', { + missingAmount + }) + // rethrow insufficient funds error when balance is not enough to cover fee + throw error + } + + unsignedTxP = await WalletService.createExportPTx({ + amountInNAvax: amountAvailableToClaim, + accountIndex: activeAccount.index, + avaxXPNetwork, + destinationAddress: activeAccount.addressPVM, + destinationChain: 'C', + feeState + }) } const tx = await Avalanche.parseAvalancheTx( diff --git a/packages/core-mobile/app/hooks/earn/useEstimateStakingFees.ts b/packages/core-mobile/app/hooks/earn/useEstimateStakingFees.ts index dc97a3372c..9283f5421d 100644 --- a/packages/core-mobile/app/hooks/earn/useEstimateStakingFees.ts +++ b/packages/core-mobile/app/hooks/earn/useEstimateStakingFees.ts @@ -173,7 +173,7 @@ const getStakingFeeFromDummyTx = async ({ feeState?: pvm.FeeState }): Promise => { if (provider.isEtnaEnabled() && feeState) { - const unsignedImportTx = await WalletService.createDummyImportPTx({ + const unsignedImportTx = await WalletService.simulateImportPTx({ stakingAmount: stakingAmount.toSubUnit(), accountIndex: activeAccount.index, sourceChain: 'C', @@ -188,7 +188,7 @@ const getStakingFeeFromDummyTx = async ({ activeAccount.addressPVM ) const unsignedAddPermissionlessDelegatorTx = - await WalletService.createDummyAddPermissionlessDelegatorTx({ + await WalletService.simulateAddPermissionlessDelegatorTx({ amountInNAvax: stakingAmount.toSubUnit(), accountIndex: activeAccount.index, destinationChain: 'C', diff --git a/packages/core-mobile/app/hooks/earn/useIssueDelegation.ts b/packages/core-mobile/app/hooks/earn/useIssueDelegation.ts index 7f09429d0a..06d6028abb 100644 --- a/packages/core-mobile/app/hooks/earn/useIssueDelegation.ts +++ b/packages/core-mobile/app/hooks/earn/useIssueDelegation.ts @@ -6,9 +6,10 @@ import { } from '@tanstack/react-query' import { useSelector } from 'react-redux' import EarnService from 'services/earn/EarnService' -import { selectIsDeveloperMode } from 'store/settings/advanced' -import { selectActiveAccount } from 'store/account' -import { selectSelectedCurrency } from 'store/settings/currency' +import { selectIsDeveloperMode } from 'store/settings/advanced/slice' +import { selectActiveAccount } from 'store/account/slice' +import { selectSelectedCurrency } from 'store/settings/currency/slice' +import { selectPFeeAdjustmentThreshold } from 'store/posthog/slice' import { calculateAmountForCrossChainTransferBigint } from 'hooks/earn/useGetAmountForCrossChainTransfer' import Logger from 'utils/Logger' import { FundsStuckError } from 'hooks/earn/errors' @@ -19,7 +20,7 @@ import { coingeckoInMemoryCache } from 'utils/coingeckoInMemoryCache' import { isTokenWithBalancePVM } from '@avalabs/avalanche-module' import { TokenUnit } from '@avalabs/core-utils-sdk' import { isDevnet } from 'utils/isDevnet' -import { selectActiveNetwork } from 'store/network' +import { selectActiveNetwork } from 'store/network/slice' import { nanoToWei } from 'utils/units/converter' import { useCChainBalance } from './useCChainBalance' import { useGetFeeState } from './useGetFeeState' @@ -56,6 +57,7 @@ export const useIssueDelegation = ( isDeveloperMode, isDevnet(activeNetwork) ) + const pFeeAdjustmentThreshold = useSelector(selectPFeeAdjustmentThreshold) const pAddress = activeAccount?.addressPVM ?? '' const cAddress = activeAccount?.addressC ?? '' @@ -147,7 +149,8 @@ export const useIssueDelegation = ( stakeAmountNanoAvax: data.stakingAmountNanoAvax, startDate: data.startDate, isDevnet: isDevnet(activeNetwork), - feeState: getFeeState(data.gasPrice) + feeState: getFeeState(data.gasPrice), + pFeeAdjustmentThreshold }) }, onSuccess: txId => { diff --git a/packages/core-mobile/app/screens/earn/components/Balance.tsx b/packages/core-mobile/app/screens/earn/components/Balance.tsx index ac0197a136..8a94563b0a 100644 --- a/packages/core-mobile/app/screens/earn/components/Balance.tsx +++ b/packages/core-mobile/app/screens/earn/components/Balance.tsx @@ -220,7 +220,9 @@ export const Balance = (): JSX.Element | null => { { - {claimableInAvax?.gt(0) + {claimableInAvax?.gt(0.05) ? renderStakeAndClaimButton() : renderStakeButton()} diff --git a/packages/core-mobile/app/services/earn/EarnService.ts b/packages/core-mobile/app/services/earn/EarnService.ts index f4e6137c41..5fda74f38b 100644 --- a/packages/core-mobile/app/services/earn/EarnService.ts +++ b/packages/core-mobile/app/services/earn/EarnService.ts @@ -258,7 +258,8 @@ class EarnService { endDate, isDevMode, isDevnet, - feeState + feeState, + pFeeAdjustmentThreshold }: AddDelegatorTransactionProps): Promise { const startDateUnix = getUnixTime(startDate) const endDateUnix = getUnixTime(endDate) @@ -277,7 +278,8 @@ class EarnService { endDate: endDateUnix, stakeAmountInNAvax: stakeAmountNanoAvax, isDevMode, - feeState + feeState, + pFeeAdjustmentThreshold } as AddDelegatorProps) const signedTxJson = await WalletService.sign({ diff --git a/packages/core-mobile/app/services/earn/exportC.test.ts b/packages/core-mobile/app/services/earn/exportC.test.ts index 0cb4d4f806..103a2f5a56 100644 --- a/packages/core-mobile/app/services/earn/exportC.test.ts +++ b/packages/core-mobile/app/services/earn/exportC.test.ts @@ -90,7 +90,7 @@ describe('earn/exportC', () => { isDevnet: false }) expect(WalletService.createExportCTx).toHaveBeenCalledWith({ - amountInNAvax: 1001000000n, + amountInNAvax: 1000000000n, baseFeeInNAvax: 0n, accountIndex: undefined, avaxXPNetwork: NetworkService.getAvalancheNetworkP(false, false), diff --git a/packages/core-mobile/app/services/earn/exportC.ts b/packages/core-mobile/app/services/earn/exportC.ts index 06060e2d4c..adb63db040 100644 --- a/packages/core-mobile/app/services/earn/exportC.ts +++ b/packages/core-mobile/app/services/earn/exportC.ts @@ -2,7 +2,6 @@ import { ChainId } from '@avalabs/core-chains-sdk' import { assertNotUndefined } from 'utils/assertions' import { retry } from 'utils/js/retry' import Logger from 'utils/Logger' -import { calculatePChainFee } from 'services/earn/calculateCrossChainFees' import WalletService from 'services/wallet/WalletService' import { Account } from 'store/account/types' import { AvalancheTransactionRequest } from 'services/wallet/types' @@ -15,7 +14,7 @@ import { maxTransactionStatusCheckRetries } from './utils' export type ExportCParams = { cChainBalanceWei: bigint - requiredAmountWei: bigint + requiredAmountWei: bigint // this amount should already include the fee to export activeAccount: Account isDevMode: boolean isDevnet: boolean @@ -53,15 +52,13 @@ export async function exportC({ const cChainBalanceAvax = AvaxC.fromWei(cChainBalanceWei) const requiredAmountAvax = AvaxC.fromWei(requiredAmountWei) - const pChainFeeAvax = await calculatePChainFee(avaxXPNetwork) - const amountAvax = requiredAmountAvax.add(pChainFeeAvax) - if (cChainBalanceAvax.lt(amountAvax)) { + if (cChainBalanceAvax.lt(requiredAmountAvax)) { throw Error('Not enough balance on C chain') } const unsignedTxWithFee = await WalletService.createExportCTx({ - amountInNAvax: weiToNano(amountAvax.toSubUnit()), + amountInNAvax: weiToNano(requiredAmountAvax.toSubUnit()), baseFeeInNAvax: weiToNano(instantBaseFeeAvax.toSubUnit()), accountIndex: activeAccount.index, avaxXPNetwork, diff --git a/packages/core-mobile/app/services/earn/types.ts b/packages/core-mobile/app/services/earn/types.ts index 61dcae1b07..359e0aff51 100644 --- a/packages/core-mobile/app/services/earn/types.ts +++ b/packages/core-mobile/app/services/earn/types.ts @@ -14,6 +14,7 @@ export type AddDelegatorTransactionProps = { isDevMode: boolean isDevnet: boolean feeState?: pvm.FeeState + pFeeAdjustmentThreshold: number } export type UnixTimeMs = number diff --git a/packages/core-mobile/app/services/posthog/types.ts b/packages/core-mobile/app/services/posthog/types.ts index fccf76e875..6fd6847d6e 100644 --- a/packages/core-mobile/app/services/posthog/types.ts +++ b/packages/core-mobile/app/services/posthog/types.ts @@ -35,7 +35,8 @@ export enum FeatureGates { } export enum FeatureVars { - SENTRY_SAMPLE_RATE = 'sentry-sample-rate' + SENTRY_SAMPLE_RATE = 'sentry-sample-rate', + P_FEE_ADJUSTMENT_THRESHOLD = 'p-fee-adjustment-threshold' } // posthog response can be an empty object when all features are disabled diff --git a/packages/core-mobile/app/services/wallet/WalletService.tsx b/packages/core-mobile/app/services/wallet/WalletService.tsx index 90f559dab8..699b6487c6 100644 --- a/packages/core-mobile/app/services/wallet/WalletService.tsx +++ b/packages/core-mobile/app/services/wallet/WalletService.tsx @@ -32,6 +32,7 @@ import { TokenUnit } from '@avalabs/core-utils-sdk' import { UTCDate } from '@date-fns/utc' import { nanoToWei } from 'utils/units/converter' import { isDevnet } from 'utils/isDevnet' +import { extractNeededAmount } from 'hooks/earn/utils/extractNeededAmount' import { getStakeableOutUtxos, getTransferOutputUtxos, @@ -536,6 +537,7 @@ class WalletService { return unsignedTx } + // eslint-disable-next-line sonarjs/cognitive-complexity public async createAddDelegatorTx({ accountIndex, avaxXPNetwork, @@ -546,7 +548,8 @@ class WalletService { rewardAddress, isDevMode, shouldValidateBurnedAmount = true, - feeState + feeState, + pFeeAdjustmentThreshold }: AddDelegatorProps): Promise { if (!nodeId.startsWith('NodeID-')) { throw Error('Invalid node id: ' + nodeId) @@ -585,16 +588,82 @@ class WalletService { ) const utxoSet = await readOnlySigner.getUTXOs('P') - const unsignedTx = readOnlySigner.addPermissionlessDelegator({ - utxoSet, - nodeId, - start: BigInt(startDate), - end: BigInt(endDate), - weight: stakeAmountInNAvax, - subnetId: PChainId._11111111111111111111111111111111LPO_YY, - rewardAddresses: [rewardAddress], - feeState - }) + + let unsignedTx + + try { + unsignedTx = readOnlySigner.addPermissionlessDelegator({ + utxoSet, + nodeId, + start: BigInt(startDate), + end: BigInt(endDate), + weight: stakeAmountInNAvax, + subnetId: PChainId._11111111111111111111111111111111LPO_YY, + rewardAddresses: [rewardAddress], + feeState + }) + } catch (error) { + Logger.warn('unable to create add delegator tx', error) + + const provider = await NetworkService.getAvalancheProviderXP( + Boolean(avaxXPNetwork.isTestnet), + isDevnet(avaxXPNetwork) + ) + + if (!provider.isEtnaEnabled()) { + // rethrow error if the network is not Etna enabled + throw error + } + + const missingAmount = extractNeededAmount( + (error as Error).message, + getAssetId(avaxXPNetwork) + ) + + if (!missingAmount) { + // rethrow error if it's not an insufficient funds error + throw error + } + + const amountToStake = stakeAmountInNAvax + const ratio = Number(missingAmount) / Number(amountToStake) + + if (ratio > pFeeAdjustmentThreshold) { + // rethrow insufficient funds error when missing fee is too much compared to total token amount + Logger.error( + 'Failed to create add delegator transaction due to excessive fees', + { + missingAmount, + ratio + } + ) + throw error + } + + const amountAvailableToStake = amountToStake - missingAmount + + if (amountAvailableToStake <= 0) { + Logger.error( + 'Failed to create add delegator transaction due to excessive fees', + { + missingAmount + } + ) + // rethrow insufficient funds error when balance is not enough to cover fee + throw error + } + + unsignedTx = readOnlySigner.addPermissionlessDelegator({ + utxoSet, + nodeId, + start: BigInt(startDate), + end: BigInt(endDate), + weight: amountAvailableToStake, + subnetId: PChainId._11111111111111111111111111111111LPO_YY, + rewardAddresses: [rewardAddress], + feeState + }) + } shouldValidateBurnedAmount && this.validateAvalancheFee({ @@ -678,7 +747,7 @@ class WalletService { return wallet.getReadOnlyAvaSigner({ accountIndex, provXP }) } - public async createDummyImportPTx({ + public async simulateImportPTx({ stakingAmount, accountIndex, avaxXPNetwork, @@ -707,7 +776,7 @@ class WalletService { }) } - public async createDummyAddPermissionlessDelegatorTx({ + public async simulateAddPermissionlessDelegatorTx({ amountInNAvax, accountIndex, avaxXPNetwork, diff --git a/packages/core-mobile/app/services/wallet/types.ts b/packages/core-mobile/app/services/wallet/types.ts index 1ae33a278c..9802da3432 100644 --- a/packages/core-mobile/app/services/wallet/types.ts +++ b/packages/core-mobile/app/services/wallet/types.ts @@ -56,6 +56,7 @@ export type AddDelegatorProps = { isDevMode: boolean shouldValidateBurnedAmount?: boolean feeState?: pvm.FeeState + pFeeAdjustmentThreshold: number } export interface CommonAvalancheTxParamsBase { diff --git a/packages/core-mobile/app/store/posthog/slice.ts b/packages/core-mobile/app/store/posthog/slice.ts index 98b946757f..a00c885475 100644 --- a/packages/core-mobile/app/store/posthog/slice.ts +++ b/packages/core-mobile/app/store/posthog/slice.ts @@ -172,6 +172,13 @@ export const selectSentrySampleRate = (state: RootState): number => { ) } +export const selectPFeeAdjustmentThreshold = (state: RootState): number => { + const { featureFlags } = state.posthog + return parseFloat( + featureFlags[FeatureVars.P_FEE_ADJUSTMENT_THRESHOLD] as string + ) +} + export const selectUseLeftFab = (state: RootState): boolean => { const { featureFlags } = state.posthog return ( diff --git a/packages/core-mobile/app/store/posthog/types.ts b/packages/core-mobile/app/store/posthog/types.ts index 263401d3d8..cc7868b755 100644 --- a/packages/core-mobile/app/store/posthog/types.ts +++ b/packages/core-mobile/app/store/posthog/types.ts @@ -13,6 +13,7 @@ export const DefaultFeatureFlagConfig = { [FeatureGates.SEND_NFT_IOS]: true, [FeatureGates.SEND_NFT_ANDROID]: true, [FeatureVars.SENTRY_SAMPLE_RATE]: '10', // 10% of events/errors + [FeatureVars.P_FEE_ADJUSTMENT_THRESHOLD]: '1e-3', // 0.1% [FeatureGates.BUY_COINBASE_PAY]: true, [FeatureGates.DEFI]: true, [FeatureGates.BROWSER]: true,