Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: [lw-11876]: upgrade to lace in send flow if locked rewards available #1570

Merged
merged 5 commits into from
Dec 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/browser-extension-wallet/src/popup.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import * as ReactDOM from 'react-dom';
import { HashRouter } from 'react-router-dom';
import { PopupView } from '@routes';
import { PopupView, walletRoutePaths } from '@routes';
import { StoreProvider } from '@stores';
import { CurrencyStoreProvider } from '@providers/currency';
import { AppSettingsProvider, DatabaseProvider, ThemeProvider, AnalyticsProvider } from '@providers';
Expand Down Expand Up @@ -34,8 +34,8 @@ const App = (): React.ReactElement => {
const newModeValue = changes.BACKGROUND_STORAGE?.newValue?.namiMigration;
if (oldModeValue?.mode !== newModeValue?.mode) {
setMode(newModeValue);
// Force back to original routing
window.location.hash = '#';
// Force back to original routing unless it is staking route (see LW-11876)
if (window.location.hash.split('#')[1] !== walletRoutePaths.earn) window.location.hash = '#';
}
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/* eslint-disable no-magic-numbers */
/* eslint-disable import/imports-first */
const mockUseWalletStore = jest.fn();
import { renderHook } from '@testing-library/react-hooks';
import { useRewardAccountsData } from '../hooks';
import { act } from 'react-dom/test-utils';
import { BehaviorSubject } from 'rxjs';
import * as Stores from '@src/stores';
import { Wallet } from '@lace/cardano';

const rewardAccounts$ = new BehaviorSubject([]);

const inMemoryWallet = {
delegation: {
rewardAccounts$
}
};

jest.mock('@src/stores', (): typeof Stores => ({
...jest.requireActual<typeof Stores>('@src/stores'),
useWalletStore: mockUseWalletStore
}));

describe('Testing useRewardAccountsData hook', () => {
test('should return proper rewards accounts hook state', async () => {
mockUseWalletStore.mockReset();
mockUseWalletStore.mockImplementation(() => ({
inMemoryWallet
}));

const hook = renderHook(() => useRewardAccountsData());
expect(hook.result.current.areAllRegisteredStakeKeysWithoutVotingDelegation).toEqual(false);
expect(hook.result.current.poolIdToRewardAccountsMap).toEqual(new Map());
expect(hook.result.current.lockedStakeRewards).toEqual(BigInt(0));

act(() => {
rewardAccounts$.next([{ credentialStatus: Wallet.Cardano.StakeCredentialStatus.Unregistered }]);
});
expect(hook.result.current.areAllRegisteredStakeKeysWithoutVotingDelegation).toEqual(false);
expect(hook.result.current.poolIdToRewardAccountsMap).toEqual(new Map());
expect(hook.result.current.lockedStakeRewards).toEqual(BigInt(0));

act(() => {
rewardAccounts$.next([
{ credentialStatus: Wallet.Cardano.StakeCredentialStatus.Registered, rewardBalance: BigInt(0) }
]);
});
expect(hook.result.current.areAllRegisteredStakeKeysWithoutVotingDelegation).toEqual(true);
expect(hook.result.current.poolIdToRewardAccountsMap).toEqual(new Map());
expect(hook.result.current.lockedStakeRewards).toEqual(BigInt(0));

act(() => {
rewardAccounts$.next([
{
credentialStatus: Wallet.Cardano.StakeCredentialStatus.Registered,
dRepDelegatee: { delegateRepresentative: { active: true } },
rewardBalance: BigInt(1_000_000)
}
]);
});
expect(hook.result.current.areAllRegisteredStakeKeysWithoutVotingDelegation).toEqual(false);
expect(hook.result.current.poolIdToRewardAccountsMap).toEqual(new Map());
expect(hook.result.current.lockedStakeRewards).toEqual(BigInt(0));

act(() => {
rewardAccounts$.next([
{
credentialStatus: Wallet.Cardano.StakeCredentialStatus.Unregistered,
rewardBalance: BigInt(1_000_000)
},
{
credentialStatus: Wallet.Cardano.StakeCredentialStatus.Unregistered,
dRepDelegatee: { delegateRepresentative: { active: false } },
rewardBalance: BigInt(1_000_000)
},
{
credentialStatus: Wallet.Cardano.StakeCredentialStatus.Unregistered,
dRepDelegatee: { delegateRepresentative: { active: true } },
rewardBalance: BigInt(1_000_000)
},
{
credentialStatus: Wallet.Cardano.StakeCredentialStatus.Registered,
rewardBalance: BigInt(1_000_000)
},
{
credentialStatus: Wallet.Cardano.StakeCredentialStatus.Registered,
dRepDelegatee: { delegateRepresentative: { active: true } },
rewardBalance: BigInt(1_000_000)
},
{
credentialStatus: Wallet.Cardano.StakeCredentialStatus.Registered,
dRepDelegatee: { delegateRepresentative: { active: false } },
rewardBalance: BigInt(1_000_000)
}
]);
});
expect(hook.result.current.areAllRegisteredStakeKeysWithoutVotingDelegation).toEqual(false);
expect(hook.result.current.poolIdToRewardAccountsMap).toEqual(new Map());
expect(hook.result.current.lockedStakeRewards).toEqual(BigInt(2_000_000));

const poolId = 'poolId';
const rewardAccount = {
credentialStatus: Wallet.Cardano.StakeCredentialStatus.Registered,
dRepDelegatee: { delegateRepresentative: {} },
delegatee: { nextNextEpoch: { id: poolId } },
rewardBalance: BigInt(0)
};
act(() => {
rewardAccounts$.next([rewardAccount]);
});
expect(hook.result.current.areAllRegisteredStakeKeysWithoutVotingDelegation).toEqual(false);
expect(hook.result.current.poolIdToRewardAccountsMap.size).toEqual(1);
expect(hook.result.current.poolIdToRewardAccountsMap.get(poolId)).toEqual([rewardAccount]);
});
});
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import { useDelegationStore } from '@src/features/delegation/stores';
import { useWalletStore } from '@stores';
import { withSignTxConfirmation } from '@lib/wallet-api-ui';
import { useSecrets } from '@lace/core';
import { useObservable } from '@lace/common';
import { Wallet } from '@lace/cardano';
import groupBy from 'lodash/groupBy';

interface UseRewardAccountsDataType {
areAllRegisteredStakeKeysWithoutVotingDelegation: boolean;
poolIdToRewardAccountsMap: Map<string, Wallet.Cardano.RewardAccountInfo[]>;
lockedStakeRewards: bigint;
}

export const useDelegationTransaction = (): { signAndSubmitTransaction: () => Promise<void> } => {
const { password, clearSecrets } = useSecrets();
Expand All @@ -17,3 +26,69 @@ export const useDelegationTransaction = (): { signAndSubmitTransaction: () => Pr

return { signAndSubmitTransaction };
};

export const getPoolIdToRewardAccountsMap = (
rewardAccounts: Wallet.Cardano.RewardAccountInfo[]
): UseRewardAccountsDataType['poolIdToRewardAccountsMap'] =>
new Map(
Object.entries(
groupBy(rewardAccounts, ({ delegatee }) => {
const delagationInfo = delegatee?.nextNextEpoch || delegatee?.nextEpoch || delegatee?.currentEpoch;
return delagationInfo?.id.toString() ?? '';
})
).filter(([poolId]) => !!poolId)
);

export const useRewardAccountsData = (): UseRewardAccountsDataType => {
const { inMemoryWallet } = useWalletStore();
const rewardAccounts = useObservable(inMemoryWallet.delegation.rewardAccounts$);
const accountsWithRegisteredStakeCreds = useMemo(
() =>
rewardAccounts?.filter(
({ credentialStatus }) => Wallet.Cardano.StakeCredentialStatus.Registered === credentialStatus
) ?? [],
[rewardAccounts]
);

const areAllRegisteredStakeKeysWithoutVotingDelegation = useMemo(
() =>
accountsWithRegisteredStakeCreds.length > 0 &&
!accountsWithRegisteredStakeCreds.some(({ dRepDelegatee }) => dRepDelegatee),
[accountsWithRegisteredStakeCreds]
);

const accountsWithRegisteredStakeCredsWithoutVotingDelegation = useMemo(
() =>
accountsWithRegisteredStakeCreds.filter(
({ dRepDelegatee }) =>
!dRepDelegatee ||
(dRepDelegatee &&
'active' in dRepDelegatee.delegateRepresentative &&
!dRepDelegatee.delegateRepresentative.active)
),
[accountsWithRegisteredStakeCreds]
);

const lockedStakeRewards = useMemo(
() =>
BigInt(
accountsWithRegisteredStakeCredsWithoutVotingDelegation
? Wallet.BigIntMath.sum(
accountsWithRegisteredStakeCredsWithoutVotingDelegation.map(({ rewardBalance }) => rewardBalance)
)
: 0
),
[accountsWithRegisteredStakeCredsWithoutVotingDelegation]
);

const poolIdToRewardAccountsMap = useMemo(
() => getPoolIdToRewardAccountsMap(accountsWithRegisteredStakeCreds),
[accountsWithRegisteredStakeCreds]
);

return {
areAllRegisteredStakeKeysWithoutVotingDelegation,
lockedStakeRewards,
poolIdToRewardAccountsMap
};
};
14 changes: 11 additions & 3 deletions apps/browser-extension-wallet/src/views/nami-mode/NamiView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,16 @@ import {
useWalletManager,
useBuildDelegation,
useBalances,
useHandleResolver
useHandleResolver,
useRedirection
} from '@hooks';
import { walletManager, withSignTxConfirmation } from '@lib/wallet-api-ui';
import { useAnalytics } from './hooks';
import { useDappContext, withDappContext } from '@src/features/dapp/context';
import { localDappService } from '../browser-view/features/dapp/components/DappList/localDappService';
import { isValidURL } from '@src/utils/is-valid-url';
import { CARDANO_COIN_SYMBOL } from './constants';
import { useDelegationTransaction } from '../browser-view/features/staking/hooks';
import { useDelegationTransaction, useRewardAccountsData } from '../browser-view/features/staking/hooks';
import { useSecrets } from '@lace/core';
import { useDelegationStore } from '@src/features/delegation/stores';
import { useStakePoolDetails } from '@src/features/stake-pool-details/store';
Expand All @@ -43,6 +44,7 @@ import { BackgroundStorage } from '@lib/scripts/types';
import { getWalletAccountsQtyString } from '@src/utils/get-wallet-count-string';
import { useNetworkError } from '@hooks/useNetworkError';
import { createHistoricalOwnInputResolver } from '@src/utils/own-input-resolver';
import { walletRoutePaths } from '@routes';

const { AVAILABLE_CHAINS, DEFAULT_SUBMIT_API } = config();

Expand Down Expand Up @@ -179,6 +181,10 @@ export const NamiView = withDappContext((): React.ReactElement => {
setDeletingWallet(false);
}, [analytics, deleteWallet, setDeletingWallet, walletRepository]);

const { lockedStakeRewards } = useRewardAccountsData();

const redirectToStaking = useRedirection(walletRoutePaths.earn);

return (
<OutsideHandlesProvider
{...{
Expand Down Expand Up @@ -239,7 +245,9 @@ export const NamiView = withDappContext((): React.ReactElement => {
chainHistoryProvider,
protocolParameters: walletState?.protocolParameters,
assetInfo: walletState?.assetInfo,
createHistoricalOwnInputResolver
createHistoricalOwnInputResolver,
lockedStakeRewards,
redirectToStaking
}}
>
<CommonOutsideHandlesProvider
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,4 +131,6 @@ export interface OutsideHandlesContextValue {
addresses: Wallet.WalletAddress[];
}>,
) => Wallet.Cardano.InputResolver;
lockedStakeRewards: bigint;
redirectToStaking: () => void;
}
86 changes: 86 additions & 0 deletions packages/nami/src/ui/app/components/upgradeToLaceBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import React from 'react';

import { Box, Button, Text, Link, useColorModeValue } from '@chakra-ui/react';
import { AnimatePresence, motion } from 'framer-motion';

import { useOutsideHandles } from '../../../features/outside-handles-provider';

export const UpgradeToLaceBanner = ({
showSwitchToLaceBanner,
}: Readonly<{ showSwitchToLaceBanner: boolean }>) => {
const warningBackground = useColorModeValue('#fcf5e3', '#fcf5e3');
const { openExternalLink, switchWalletMode, redirectToStaking } =
useOutsideHandles();

return (
<AnimatePresence>
{showSwitchToLaceBanner && (
<motion.div
key="splashScreen"
initial={{
y: '-224px',
height: '0px',
marginBottom: 0,
}}
animate={{
y: '0px',
height: '224px',
marginBottom: '1.25rem',
}}
transition={{
all: { duration: 5, ease: 'easeInOut' },
}}
exit={{
y: '-224px',
height: '0px',
marginBottom: 0,
}}
>
<Box
display="flex"
alignItems="center"
justifyContent="flex-end"
flexDirection="column"
background={warningBackground}
rounded="xl"
padding="18"
gridGap="8px"
mb="4"
overflow="hidden"
>
<Text
color="gray.800"
fontSize="14"
fontWeight="500"
lineHeight="24px"
>
Your ADA balance includes Locked Stake Rewards that can only be
withdrawn or transacted after registering your voting power.
Upgrade to Lace to continue. For more information, visit our{' '}
<Link
isExternal
textDecoration="underline"
onClick={() => {
openExternalLink('https://www.lace.io/faq');
}}
>
FAQs.
</Link>
</Text>
<Button
height="36px"
width="100%"
colorScheme="teal"
onClick={async () => {
redirectToStaking();
await switchWalletMode();
}}
>
Upgrade to Lace
</Button>
</Box>
</motion.div>
)}
</AnimatePresence>
);
};
Loading
Loading