Skip to content

Commit

Permalink
Create DeployerRecoveryModal
Browse files Browse the repository at this point in the history
Rename TempDeployer* to Deployer*
  • Loading branch information
jmrossy committed Dec 29, 2024
1 parent b484833 commit e51c76d
Show file tree
Hide file tree
Showing 14 changed files with 391 additions and 182 deletions.
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
NEXT_PUBLIC_WALLET_CONNECT_ID=12345678901234567890123456789012
NEXT_PUBLIC_CHAIN_WALLET_WHITELISTS='{"eclipsemainnet":["Salmon", "Backpack", "Connect by Drift"]}'
NEXT_PUBLIC_TEMP_WALLET_ENCRYPTION_KEY=todoReplaceMeWithAnyStrongPassword! # Any strong password will work
NEXT_PUBLIC_TEMP_WALLET_ENCRYPTION_SALT=3424521f5cc25e6d05bd062eedc3c33d9201118dbb4baa3f7656c8dd34a95e28 # E.g. Sha256 of 'hyperlane-deploy-app'
NEXT_PUBLIC_DEPLOYER_WALLET_ENCRYPTION_KEY=todoReplaceMeWithAnyStrongPassword! # Any strong password will work
NEXT_PUBLIC_DEPLOYER_WALLET_ENCRYPTION_SALT=3424521f5cc25e6d05bd062eedc3c33d9201118dbb4baa3f7656c8dd34a95e28 # E.g. Sha256 of 'hyperlane-deploy-app'
56 changes: 56 additions & 0 deletions src/components/nav/FloatingButtonRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import Link from 'next/link';

import { objLength } from '@hyperlane-xyz/utils';
import { DocsIcon, HistoryIcon, IconButton, useModal } from '@hyperlane-xyz/widgets';
import { links } from '../../consts/links';
import { DeployerRecoveryModal } from '../../features/deployerWallet/DeployerRecoveryModal';
import { useStore } from '../../features/store';
import { Color } from '../../styles/Color';
import { GasIcon } from '../icons/GasIcon';

export function FloatingButtonRow() {
const { setIsSideBarOpen, isSideBarOpen, deployerKeys } = useStore((s) => ({
setIsSideBarOpen: s.setIsSideBarOpen,
isSideBarOpen: s.isSideBarOpen,
deployerKeys: s.deployerKeys,
}));

const { isOpen, open, close } = useModal();

const hasTempKeys = objLength(deployerKeys) > 0;

return (
<div className="absolute -top-8 right-2 flex items-center gap-3">
{hasTempKeys && (
<IconButton
className={`p-0.5 ${styles.roundedCircle} `}
title="Deployer Accounts"
onClick={open}
>
<GasIcon color={Color.primary['500']} height={20} width={20} className="p-0.5" />
</IconButton>
)}
<IconButton
className={`p-0.5 ${styles.roundedCircle} `}
title="History"
onClick={() => setIsSideBarOpen(!isSideBarOpen)}
>
<HistoryIcon color={Color.primary['500']} height={22} width={22} />
</IconButton>
<Link
href={links.warpDocs}
target="_blank"
className={`p-0.5 ${styles.roundedCircle} ${styles.link}`}
title="Documentation"
>
<DocsIcon color={Color.primary['500']} height={21} width={21} className="p-px" />
</Link>
<DeployerRecoveryModal isOpen={isOpen} close={close} />
</div>
);
}

const styles = {
link: 'hover:opacity-70 active:opacity-60',
roundedCircle: 'rounded-full bg-white',
};
13 changes: 7 additions & 6 deletions src/consts/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ const isDevMode = process?.env?.NODE_ENV === 'development';
const registryUrl = process?.env?.NEXT_PUBLIC_REGISTRY_URL || undefined;
const registryBranch = process?.env?.NEXT_PUBLIC_REGISTRY_BRANCH || undefined;
const registryProxyUrl = process?.env?.NEXT_PUBLIC_GITHUB_PROXY || 'https://proxy.hyperlane.xyz';
const tempWalletEncryptionKey = process?.env?.NEXT_PUBLIC_TEMP_WALLET_ENCRYPTION_KEY || '';
const tempWalletEncryptionSalt = process?.env?.NEXT_PUBLIC_TEMP_WALLET_ENCRYPTION_SALT || '';
const deployerWalletEncryptionKey = process?.env?.NEXT_PUBLIC_DEPLOYER_WALLET_ENCRYPTION_KEY || '';
const deployerWalletEncryptionSalt =
process?.env?.NEXT_PUBLIC_DEPLOYER_WALLET_ENCRYPTION_SALT || '';
const version = process?.env?.NEXT_PUBLIC_VERSION || '0.0.0';
const walletConnectProjectId = process?.env?.NEXT_PUBLIC_WALLET_CONNECT_ID || '';

Expand All @@ -20,8 +21,8 @@ interface Config {
registryUrl: string | undefined; // Optional URL to use a custom registry instead of the published canonical version
registryBranch?: string | undefined; // Optional customization of the registry branch instead of main
registryProxyUrl?: string; // Optional URL to use a custom proxy for the GithubRegistry
tempWalletEncryptionKey: string; // Encryption key for temporary deployer wallets
tempWalletEncryptionSalt: string; // Encryption salt for temporary deployer wallets
deployerWalletEncryptionKey: string; // Encryption key for temporary deployer wallets
deployerWalletEncryptionSalt: string; // Encryption salt for temporary deployer wallets
version: string; // Matches version number in package.json
walletConnectProjectId: string; // Project ID provided by walletconnect
}
Expand All @@ -34,8 +35,8 @@ export const config: Config = Object.freeze({
registryUrl,
registryBranch,
registryProxyUrl,
tempWalletEncryptionKey,
tempWalletEncryptionSalt,
deployerWalletEncryptionKey,
deployerWalletEncryptionSalt,
version,
walletConnectProjectId,
});
1 change: 1 addition & 0 deletions src/consts/consts.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export const MIN_CHAIN_BALANCE = 1; // 1 Wei
export const WARP_DEPLOY_GAS_UNITS = BigInt(1e7);
export const REFUND_FEE_PADDING_FACTOR = 1.1;
export const MIN_DEPLOYER_BALANCE_TO_SHOW = BigInt(1e15); // 0.001 ETH
117 changes: 117 additions & 0 deletions src/features/deployerWallet/DeployerRecoveryModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { Button, CopyButton, Modal, SpinnerIcon, tryClipboardSet } from '@hyperlane-xyz/widgets';
import { PropsWithChildren, useEffect, useMemo } from 'react';
import { toast } from 'react-toastify';
import { H2 } from '../../components/text/Headers';
import { MIN_DEPLOYER_BALANCE_TO_SHOW } from '../../consts/consts';
import { Color } from '../../styles/Color';
import { useMultiProvider } from '../chains/hooks';
import { getChainDisplayName } from '../chains/utils';
import { useDeployerBalances } from './balances';
import { useRefundDeployerAccounts } from './refund';
import { DeployerWallets, TypedWallet } from './types';
import { getDeployerWalletKey, useDeployerWallets, useRemoveDeployerWallet } from './wallets';

export function DeployerRecoveryModal({ isOpen, close }: { isOpen: boolean; close: () => void }) {
const { wallets } = useDeployerWallets();

// Close modal when no wallets are found
useEffect(() => {
if (isOpen && !Object.values(wallets).length) close();
}, [isOpen, wallets, close]);

return (
<Modal isOpen={isOpen} close={close} panelClassname="p-4 flex flex-col items-center gap-4">
<H2>Temporary Deployer Accounts</H2>
<p className="text-center text-sm text-gray-700">
Once the balances are successfully refunded, these temporary accounts can be safely deleted.
</p>
<AccountList wallets={wallets} />
<div className="py-2">
<Balances isOpen={isOpen} wallets={wallets} />
</div>
</Modal>
);
}

function AccountList({ wallets }: { wallets: DeployerWallets }) {
const removeDeployerKey = useRemoveDeployerWallet();

const walletList = useMemo(() => Object.values(wallets), [wallets]);

const onClickCopyPrivateKey = (wallet: TypedWallet) => {
try {
const pk = getDeployerWalletKey(wallet);
tryClipboardSet(pk);
toast.success('Private key copied to clipboard');
} catch {
toast.error('Unable to retrieve private key');
}
};

const onClickDeleteAccount = (wallet: TypedWallet) => {
removeDeployerKey(wallet.protocol);
};

return (
<>
{walletList.map((w) => (
<div key={w.type} className="space-y-2 rounded-lg bg-primary-500/5 p-2">
<div className="flex items-center gap-2">
<span className="text-xs">{w.address}</span>
<CopyButton copyValue={w.address} width={12} height={12} className="opacity-50" />
</div>
<div className="flex items-center justify-center gap-10 text-sm text-accent-500">
<Button onClick={() => onClickCopyPrivateKey(w)}>Copy Private Key</Button>
<Button onClick={() => onClickDeleteAccount(w)}>Delete Account</Button>
</div>
</div>
))}
</>
);
}

function Balances({ isOpen, wallets }: { isOpen: boolean; wallets: DeployerWallets }) {
const multiProvider = useMultiProvider();
const { balances, isFetching, refetch } = useDeployerBalances(wallets);
const { refund, isPending } = useRefundDeployerAccounts(refetch);

// Refetch balances when modal is opened
useEffect(() => {
if (isOpen) refetch();
}, [isOpen, refetch]);

if (isFetching || !balances) {
return <BalanceSpinner>Searching for balances...</BalanceSpinner>;
}

if (isPending) {
return <BalanceSpinner>Refunding balances...</BalanceSpinner>;
}

const nonTrivialBalances = balances.filter((b) => b.amount >= MIN_DEPLOYER_BALANCE_TO_SHOW);
const balanceChains = nonTrivialBalances
.map((b) => getChainDisplayName(multiProvider, b.chainName))
.join(', ');

if (!nonTrivialBalances.length) {
return <div className="text-sm">No balances found on deployment chains</div>;
}

return (
<div className="flex flex-col items-center gap-2">
<span className="text-sm">{`Balances on found on chains: ${balanceChains}`}</span>
<Button onClick={() => refund()} className="text-sm text-accent-500">
Refund balances
</Button>
</div>
);
}

function BalanceSpinner({ children }: PropsWithChildren<unknown>) {
return (
<div className="flex flex-col items-center justify-center gap-2">
<SpinnerIcon width={30} height={30} color={Color.primary['500']} />
<span className="text-sm text-gray-700">{children}</span>
</div>
);
}
73 changes: 73 additions & 0 deletions src/features/deployerWallet/balances.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { MultiProtocolProvider, Token } from '@hyperlane-xyz/sdk';
import { ProtocolType } from '@hyperlane-xyz/utils';
import { useQuery } from '@tanstack/react-query';
import { logger } from '../../utils/logger';
import { useMultiProvider } from '../chains/hooks';
import { useDeploymentChains } from '../deployment/hooks';
import { DeployerWallets } from './types';

export interface Balance {
chainName: ChainName;
protocol: ProtocolType;
address: Address;
amount: bigint;
}

export function useDeployerBalances(wallets: DeployerWallets) {
const multiProvider = useMultiProvider();
const { chains } = useDeploymentChains();

const { data, isFetching, refetch } = useQuery({
// MultiProvider cannot be used here because it's not serializable
// eslint-disable-next-line @tanstack/query/exhaustive-deps
queryKey: ['getDeployerBalances', chains, wallets],
queryFn: () => getDeployerBalances(chains, wallets, multiProvider),
retry: 3,
staleTime: 10_000,
});

return {
isFetching,
balances: data,
refetch,
};
}

export async function getDeployerBalances(
chains: ChainName[],
wallets: DeployerWallets,
multiProvider: MultiProtocolProvider,
) {
const balances: Array<PromiseSettledResult<Balance | undefined>> = await Promise.allSettled(
chains.map(async (chainName) => {
try {
const chainMetadata = multiProvider.tryGetChainMetadata(chainName);
if (!chainMetadata) return undefined;
const address = wallets[chainMetadata.protocol]?.address;
if (!address) return undefined;
const token = Token.FromChainMetadataNativeToken(chainMetadata);
logger.debug('Checking balance', chainName, address);
const balance = await token.getBalance(multiProvider, address);
logger.debug('Balance retrieved', chainName, address, balance.amount);
return { chainName, protocol: chainMetadata.protocol, address, amount: balance.amount };
} catch (error: unknown) {
const msg = `Error getting balance for chain ${chainName}`;
logger.error(msg, error);
throw new Error(msg, { cause: error });
}
}),
);

const nonZeroBalances = balances
.filter((b) => b.status === 'fulfilled')
.map((b) => b.value)
.filter((b): b is Balance => !!b && b.amount > 0n);
if (nonZeroBalances.length) {
logger.debug(
'Non-zero balances found for chains:',
nonZeroBalances.map((b) => b.chainName).join(', '),
);
}

return nonZeroBalances;
}
61 changes: 9 additions & 52 deletions src/features/deployerWallet/refund.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,18 @@ import { logger } from '../../utils/logger';
import { useMultiProvider } from '../chains/hooks';
import { getChainDisplayName } from '../chains/utils';
import { useDeploymentChains } from '../deployment/hooks';
import { Balance, getDeployerBalances } from './balances';
import { getTransferTx, sendTxFromWallet } from './transactions';
import { TempDeployerWallets } from './types';
import { getDeployerAddressForProtocol, useTempDeployerWallets } from './wallets';
import { DeployerWallets } from './types';
import { useDeployerWallets } from './wallets';

export function useRefundDeployerAccounts(onSettled?: () => void) {
const multiProvider = useMultiProvider();
const { chains, protocols } = useDeploymentChains();
const { wallets } = useTempDeployerWallets(protocols);
const { chains } = useDeploymentChains();
const { wallets } = useDeployerWallets();
const { accounts } = useAccounts(multiProvider);

const { error, mutate, mutateAsync, submittedAt, isIdle } = useMutation({
const { error, mutate, mutateAsync, submittedAt, isIdle, isPending } = useMutation({
mutationKey: ['refundDeployerAccounts', chains, wallets, accounts],
mutationFn: () => refundDeployerAccounts(chains, wallets, multiProvider, accounts),
retry: false,
Expand All @@ -41,12 +42,13 @@ export function useRefundDeployerAccounts(onSettled?: () => void) {
refundAsync: mutateAsync,
isIdle,
hasRun: !!submittedAt,
isPending,
};
}

async function refundDeployerAccounts(
chains: ChainName[],
wallets: TempDeployerWallets,
wallets: DeployerWallets,
multiProvider: MultiProtocolProvider,
accounts: Record<ProtocolType, AccountInfo>,
) {
Expand All @@ -57,54 +59,9 @@ async function refundDeployerAccounts(
return true;
}

interface Balance {
chainName: ChainName;
protocol: ProtocolType;
address: Address;
amount: bigint;
}

async function getDeployerBalances(
chains: ChainName[],
wallets: TempDeployerWallets,
multiProvider: MultiProtocolProvider,
) {
const balances: Array<PromiseSettledResult<Balance | undefined>> = await Promise.allSettled(
chains.map(async (chainName) => {
try {
const chainMetadata = multiProvider.tryGetChainMetadata(chainName);
const address = getDeployerAddressForProtocol(wallets, chainMetadata?.protocol);
if (!chainMetadata || !address) return undefined;
const token = Token.FromChainMetadataNativeToken(chainMetadata);
logger.debug('Checking balance', chainName, address);
const balance = await token.getBalance(multiProvider, address);
logger.debug('Balance retrieved', chainName, address, balance.amount);
return { chainName, protocol: chainMetadata.protocol, address, amount: balance.amount };
} catch (error: unknown) {
const msg = `Error getting balance for chain ${chainName}`;
logger.error(msg, error);
throw new Error(msg, { cause: error });
}
}),
);

const nonZeroBalances = balances
.filter((b) => b.status === 'fulfilled')
.map((b) => b.value)
.filter((b): b is Balance => !!b && b.amount > 0n);
if (nonZeroBalances.length) {
logger.debug(
'Non-zero balances found for chains:',
nonZeroBalances.map((b) => b.chainName).join(', '),
);
}

return nonZeroBalances;
}

async function transferBalances(
balances: Balance[],
wallets: TempDeployerWallets,
wallets: DeployerWallets,
multiProvider: MultiProtocolProvider,
accounts: Record<ProtocolType, AccountInfo>,
) {
Expand Down
Loading

0 comments on commit e51c76d

Please sign in to comment.