diff --git a/src/components/icons/Checkmark.tsx b/src/components/icons/Checkmark.tsx index fe52a64..1fa6fab 100644 --- a/src/components/icons/Checkmark.tsx +++ b/src/components/icons/Checkmark.tsx @@ -17,11 +17,11 @@ function _Checkmark({ width={width} height={height} className={className} - viewBox="0 0 16 16" + viewBox="0 0 36 27" > ); diff --git a/src/components/nav/NavBar.tsx b/src/components/nav/NavBar.tsx index f0740fd..6cd277b 100644 --- a/src/components/nav/NavBar.tsx +++ b/src/components/nav/NavBar.tsx @@ -36,7 +36,7 @@ export function NavBar({ collapsed }: { collapsed?: boolean }) { {l.to === pathname && (
)} diff --git a/src/components/notifications/TxSuccessToast.tsx b/src/components/notifications/TxSuccessToast.tsx index 8050792..e4a142f 100644 --- a/src/components/notifications/TxSuccessToast.tsx +++ b/src/components/notifications/TxSuccessToast.tsx @@ -1,47 +1,38 @@ import { useEffect } from 'react'; import { toast } from 'react-toastify'; import { ExternalLink } from 'src/components/buttons/ExternalLink'; -import { ChainId, chainIdToChain } from 'src/config/chains'; +import { ChainId } from 'src/config/chains'; +import { getTxExplorerUrl } from 'src/features/transactions/utils'; import { logger } from 'src/utils/logger'; -export function useToastTxSuccess( - isConfirmed?: boolean, - txHash?: string, - msg?: string, - chainId: ChainId = ChainId.Celo, -) { - useEffect(() => { - if (!isConfirmed || !txHash) return; - logger.debug(msg); - toastTxSuccess(txHash, msg, chainId); - }, [isConfirmed, txHash, msg, chainId]); -} - -export function toastTxSuccess( - txHash?: string, - msg = 'Transaction confirmed!', - chainId: ChainId = ChainId.Celo, -) { - if (!txHash) return; - const explorerUrl = chainIdToChain[chainId].explorerUrl; - toast.success(, { - autoClose: 15000, - }); -} - -export function TxSuccessToast({ - msg, +export function useToastTxSuccess({ + isConfirmed, txHash, - explorerUrl, + message = 'Transaction confirmed!', + chainId = ChainId.Celo, + enabled = true, }: { - msg: string; - txHash: string; - explorerUrl: string; + isConfirmed?: boolean; + txHash?: string; + message?: string; + chainId?: ChainId; + enabled?: boolean; }) { + useEffect(() => { + if (!isConfirmed || !txHash || !enabled) return; + logger.debug(message); + const explorerUrl = getTxExplorerUrl(txHash, chainId); + toast.success(, { + autoClose: 15000, + }); + }, [isConfirmed, txHash, message, chainId, enabled]); +} + +function TxSuccessToast({ message, explorerUrl }: { message: string; explorerUrl: string }) { return (
- {msg + ' '} - + {message + ' '} + See Details
diff --git a/src/features/account/AccountRegisterForm.tsx b/src/features/account/AccountRegisterForm.tsx index 9d2c0dc..d665613 100644 --- a/src/features/account/AccountRegisterForm.tsx +++ b/src/features/account/AccountRegisterForm.tsx @@ -12,7 +12,7 @@ export function AccountRegisterForm({ }) { const { writeContract, isLoading } = useWriteContractWithReceipt( 'account registration', - refetchAccountDetails, + () => refetchAccountDetails, ); const onClickCreate = () => { diff --git a/src/features/locking/LockFlow.tsx b/src/features/locking/LockFlow.tsx index 31510bf..7a65957 100644 --- a/src/features/locking/LockFlow.tsx +++ b/src/features/locking/LockFlow.tsx @@ -3,26 +3,35 @@ import { AccountRegisterForm } from 'src/features/account/AccountRegisterForm'; import { useAccountDetails } from 'src/features/account/hooks'; import { LockForm } from 'src/features/locking/LockForm'; import { LockActionType } from 'src/features/locking/types'; +import { TransactionConfirmation } from 'src/features/transactions/TransactionConfirmation'; +import { useTransactionFlowConfirmation } from 'src/features/transactions/hooks'; import { isNullish } from 'src/utils/typeof'; import { useAccount } from 'wagmi'; -export function LockFlow({ defaultAction }: { defaultAction?: LockActionType }) { +export function LockFlow({ + defaultAction, + closeModal, +}: { + defaultAction?: LockActionType; + closeModal: () => void; +}) { const { address } = useAccount(); - const { - isRegistered, - isLoading: isLoadingRegistration, - refetch: refetchAccountDetails, - } = useAccountDetails(address); + const { isRegistered, refetch: refetchAccountDetails } = useAccountDetails(address); + + const { confirmationDetails, onConfirmed } = useTransactionFlowConfirmation(); let Component; - if (!address || isLoadingRegistration || isNullish(isRegistered)) { + if (!address || isNullish(isRegistered)) { Component = Loading account data...; } else if (!isRegistered) { Component = ; - // TODO lock complete screen here + } else if (!confirmationDetails) { + Component = ; } else { - Component = ; + Component = ( + + ); } return ( diff --git a/src/features/locking/LockForm.tsx b/src/features/locking/LockForm.tsx index c801c4d..aca708a 100644 --- a/src/features/locking/LockForm.tsx +++ b/src/features/locking/LockForm.tsx @@ -1,4 +1,3 @@ -import { lockedGoldABI } from '@celo/abis'; import { Form, Formik, FormikErrors, useFormikContext } from 'formik'; import { useEffect, useMemo } from 'react'; import { toast } from 'react-toastify'; @@ -7,7 +6,6 @@ import { AmountField } from 'src/components/input/AmountField'; import { RadioField } from 'src/components/input/RadioField'; import { TipBox } from 'src/components/layout/TipBox'; import { MIN_REMAINING_BALANCE } from 'src/config/consts'; -import { Addresses } from 'src/config/contracts'; import { useBalance } from 'src/features/account/hooks'; import { useIsGovernanceVoting } from 'src/features/governance/useVotingStatus'; import { getLockTxPlan } from 'src/features/locking/lockPlan'; @@ -26,6 +24,7 @@ import { import { StakingBalances } from 'src/features/staking/types'; import { emptyStakeBalances, useStakingBalances } from 'src/features/staking/useStakingBalances'; import { useTransactionPlan, useWriteContractWithReceipt } from 'src/features/transactions/hooks'; +import { ConfirmationDetails } from 'src/features/transactions/types'; import { fromWeiRounded, toWei } from 'src/utils/amount'; import { logger } from 'src/utils/logger'; import { toTitleCase } from 'src/utils/strings'; @@ -40,9 +39,11 @@ const initialValues: LockFormValues = { export function LockForm({ defaultAction, showTip, + onConfirmed, }: { defaultAction?: LockActionType; showTip?: boolean; + onConfirmed?: (details: ConfirmationDetails) => void; }) { const { address } = useAccount(); const { balance: walletBalance } = useBalance(address); @@ -54,18 +55,24 @@ export function LockForm({ useTransactionPlan({ createTxPlan: (v) => getLockTxPlan(v, pendingWithdrawals || [], stakeBalances || emptyStakeBalances), - onStepSuccess: refetch, + onStepSuccess: () => refetch, + onPlanSuccess: onConfirmed + ? (v, r) => + onConfirmed({ + message: `${v.action} successful`, + amount: v.amount, + receipt: r, + properties: [ + { label: 'Action', value: toTitleCase(v.action) }, + { label: 'Amount', value: `${v.amount} CELO` }, + ], + }) + : undefined, }); const { writeContract, isLoading } = useWriteContractWithReceipt('lock/unlock', onTxSuccess); const isInputDisabled = isLoading || isPlanStarted; - const onSubmit = (values: LockFormValues) => { - writeContract({ - address: Addresses.LockedGold, - abi: lockedGoldABI, - ...getNextTx(values), - }); - }; + const onSubmit = (values: LockFormValues) => writeContract(getNextTx(values)); const validate = (values: LockFormValues) => { if (isNullish(walletBalance) || !lockedBalances || !stakeBalances || isNullish(isVoting)) { diff --git a/src/features/locking/lockPlan.ts b/src/features/locking/lockPlan.ts index 070acdf..a28b4e5 100644 --- a/src/features/locking/lockPlan.ts +++ b/src/features/locking/lockPlan.ts @@ -1,3 +1,5 @@ +import { lockedGoldABI } from '@celo/abis'; +import { Addresses } from 'src/config/contracts'; import { LockActionType, LockFormValues, PendingWithdrawal } from 'src/features/locking/types'; import { StakingBalances } from 'src/features/staking/types'; import { TxPlan } from 'src/features/transactions/types'; @@ -19,7 +21,15 @@ export function getLockTxPlan( // TODO update this to account for staking, governance, and delegation revocations first if (action === LockActionType.Unlock) { - return [{ action, functionName: 'unlock', args: [amountWei] }]; + return [ + { + action, + address: Addresses.LockedGold, + abi: lockedGoldABI, + functionName: 'unlock', + args: [amountWei], + }, + ]; } else if (action === LockActionType.Lock) { const txs: TxPlan = []; // Need relock from the pendings in reverse order @@ -31,6 +41,8 @@ export function getLockTxPlan( const txAmount = bigIntMin(amountRemaining, p.value); txs.push({ action, + address: Addresses.LockedGold, + abi: lockedGoldABI, functionName: 'relock', args: [p.index, txAmount], }); @@ -38,7 +50,13 @@ export function getLockTxPlan( } // If pending relocks didn't cover it if (amountRemaining > 0) { - txs.push({ action, functionName: 'lock', value: amountRemaining }); + txs.push({ + action, + address: Addresses.LockedGold, + abi: lockedGoldABI, + functionName: 'lock', + value: amountRemaining, + }); } return txs; } else if (action === LockActionType.Withdraw) { @@ -49,6 +67,8 @@ export function getLockTxPlan( if (p.timestamp <= now) txs.push({ action, + address: Addresses.LockedGold, + abi: lockedGoldABI, functionName: 'withdraw', args: [p.index], }); diff --git a/src/features/staking/StakeFlow.tsx b/src/features/staking/StakeFlow.tsx index 6b75290..8afba4e 100644 --- a/src/features/staking/StakeFlow.tsx +++ b/src/features/staking/StakeFlow.tsx @@ -5,6 +5,8 @@ import { LockForm } from 'src/features/locking/LockForm'; import { StakeForm } from 'src/features/staking/StakeForm'; import { StakeActionType } from 'src/features/staking/types'; import { useStakingBalances } from 'src/features/staking/useStakingBalances'; +import { TransactionConfirmation } from 'src/features/transactions/TransactionConfirmation'; +import { useTransactionFlowConfirmation } from 'src/features/transactions/hooks'; import { isNullish } from 'src/utils/typeof'; import { useAccount } from 'wagmi'; @@ -12,38 +14,39 @@ import { useAccount } from 'wagmi'; export function StakeFlow({ defaultGroup, defaultAction, + closeModal, }: { defaultGroup?: Address; defaultAction?: StakeActionType; + closeModal: () => void; }) { const { address } = useAccount(); - const { lockedBalance, isLoading: isLoadingLocked } = useLockedBalance(address); - const { stakeBalances, isLoading: isLoadingStaked } = useStakingBalances(address); - const { - isRegistered, - isLoading: isLoadingRegistration, - refetch: refetchAccountDetails, - } = useAccountDetails(address); + const { lockedBalance } = useLockedBalance(address); + const { stakeBalances } = useStakingBalances(address); + const { isRegistered, refetch: refetchAccountDetails } = useAccountDetails(address); + + const { confirmationDetails, onConfirmed } = useTransactionFlowConfirmation(); let Component; - if ( - !address || - isLoadingLocked || - isLoadingStaked || - isLoadingRegistration || - isNullish(lockedBalance) || - isNullish(stakeBalances) || - isNullish(isRegistered) - ) { + if (!address || isNullish(lockedBalance) || isNullish(stakeBalances) || isNullish(isRegistered)) { Component = Loading staking data...; } else if (!isRegistered) { Component = ; } else if (lockedBalance <= 0n && stakeBalances.total <= 0n) { Component = ; + } else if (!confirmationDetails) { + Component = ( + + ); } else { - Component = ; + Component = ( + + ); } - // TODO stake complete screen here return ( <> diff --git a/src/features/staking/StakeForm.tsx b/src/features/staking/StakeForm.tsx index 332a680..bce9f73 100644 --- a/src/features/staking/StakeForm.tsx +++ b/src/features/staking/StakeForm.tsx @@ -1,4 +1,3 @@ -import { electionABI } from '@celo/abis'; import { Form, Formik, FormikErrors, useField, useFormikContext } from 'formik'; import { SyntheticEvent, useCallback, useEffect, useMemo } from 'react'; import { IconButton } from 'src/components/buttons/IconButton'; @@ -8,7 +7,6 @@ import { AmountField } from 'src/components/input/AmountField'; import { RadioField } from 'src/components/input/RadioField'; import { DropdownMenu } from 'src/components/menus/Dropdown'; import { MIN_GROUP_SCORE_FOR_RANDOM, ZERO_ADDRESS } from 'src/config/consts'; -import { Addresses } from 'src/config/contracts'; import { LockedBalances } from 'src/features/locking/types'; import { useLockedStatus } from 'src/features/locking/useLockedStatus'; import { getStakeTxPlan } from 'src/features/staking/stakePlan'; @@ -21,6 +19,7 @@ import { } from 'src/features/staking/types'; import { useStakingBalances } from 'src/features/staking/useStakingBalances'; import { useTransactionPlan, useWriteContractWithReceipt } from 'src/features/transactions/hooks'; +import { ConfirmationDetails } from 'src/features/transactions/types'; import { ValidatorGroupLogo } from 'src/features/validators/ValidatorGroupLogo'; import { ValidatorGroup } from 'src/features/validators/types'; import { useValidatorGroups } from 'src/features/validators/useValidatorGroups'; @@ -42,9 +41,11 @@ const initialValues: StakeFormValues = { export function StakeForm({ defaultGroup, defaultAction, + onConfirmed, }: { defaultGroup?: Address; defaultAction?: StakeActionType; + onConfirmed: (details: ConfirmationDetails) => void; }) { const { address } = useAccount(); const { groups } = useValidatorGroups(); @@ -54,18 +55,24 @@ export function StakeForm({ const { getNextTx, txPlanIndex, numTxs, isPlanStarted, onTxSuccess } = useTransactionPlan({ createTxPlan: (v) => getStakeTxPlan(v, groups || [], groupToStake || {}), - onStepSuccess: refetch, + onStepSuccess: () => refetch, + onPlanSuccess: (v, r) => + onConfirmed({ + message: `${v.action} successful`, + amount: v.amount, + receipt: r, + properties: [ + { label: 'Action', value: toTitleCase(v.action) }, + { label: 'Group', value: findGroup(groups, v.group)?.name || 'Unknown' }, + { label: 'Amount', value: `${v.amount} CELO` }, + ], + }), }); + const { writeContract, isLoading } = useWriteContractWithReceipt('staking', onTxSuccess); const isInputDisabled = isLoading || isPlanStarted; - const onSubmit = (values: StakeFormValues) => { - writeContract({ - address: Addresses.Election, - abi: electionABI, - ...getNextTx(values), - }); - }; + const onSubmit = (values: StakeFormValues) => writeContract(getNextTx(values)); const validate = (values: StakeFormValues) => { if (!lockedBalances || !stakeBalances || !groupToStake || !groups) { diff --git a/src/features/staking/stakePlan.ts b/src/features/staking/stakePlan.ts index a82e84a..1bf3891 100644 --- a/src/features/staking/stakePlan.ts +++ b/src/features/staking/stakePlan.ts @@ -1,4 +1,6 @@ +import { electionABI } from '@celo/abis'; import { MIN_INCREMENTAL_VOTE_AMOUNT, ZERO_ADDRESS } from 'src/config/consts'; +import { Addresses } from 'src/config/contracts'; import { GroupToStake, StakeActionType, StakeFormValues } from 'src/features/staking/types'; import { TxPlan } from 'src/features/transactions/types'; import { ValidatorGroup } from 'src/features/validators/types'; @@ -36,6 +38,8 @@ function getStakeActionPlan(amountWei: bigint, group: Address, groups: Validator return [ { action: StakeActionType.Stake, + address: Addresses.Election, + abi: electionABI, functionName: 'vote', args: [group, amountWei, lesser, greater], }, @@ -60,6 +64,8 @@ function getUnstakeActionPlan( const { lesser, greater } = findLesserAndGreaterAfterVote(groups, group, pendingToRevoke * -1n); txs.push({ action: StakeActionType.Unstake, + address: Addresses.Election, + abi: electionABI, functionName: 'revokePending', args: [group, pendingToRevoke, lesser, greater, groupIndex], }); @@ -73,6 +79,8 @@ function getUnstakeActionPlan( const { lesser, greater } = findLesserAndGreaterAfterVote(groups, group, amountRemaining * -1n); txs.push({ action: StakeActionType.Unstake, + address: Addresses.Election, + abi: electionABI, functionName: 'revokeActive', args: [group, amountRemaining, lesser, greater, groupIndex], }); diff --git a/src/features/transactions/TransactionConfirmation.tsx b/src/features/transactions/TransactionConfirmation.tsx new file mode 100644 index 0000000..933dc9e --- /dev/null +++ b/src/features/transactions/TransactionConfirmation.tsx @@ -0,0 +1,47 @@ +import { ExternalLink } from 'src/components/buttons/ExternalLink'; +import { SolidButton } from 'src/components/buttons/SolidButton'; +import { Checkmark } from 'src/components/icons/Checkmark'; +import { Amount } from 'src/components/numbers/Amount'; +import { ConfirmationDetails } from 'src/features/transactions/types'; +import { getTxExplorerUrl } from 'src/features/transactions/utils'; +import { toTitleCase } from 'src/utils/strings'; + +export function TransactionConfirmation({ + confirmation, + closeModal, +}: { + confirmation: ConfirmationDetails; + closeModal: () => void; +}) { + return ( +
+
+
+
+ +
+

{toTitleCase(confirmation.message)}

+ +
+
+ {confirmation.properties.map(({ label, value }) => ( +
+
{label}
+
{value}
+
+ ))} +
+
Transaction
+ + View in explorer + +
+
+
+ Close +
+ ); +} diff --git a/src/features/transactions/TransactionModal.tsx b/src/features/transactions/TransactionModal.tsx index 60aa4fb..fb25d9d 100644 --- a/src/features/transactions/TransactionModal.tsx +++ b/src/features/transactions/TransactionModal.tsx @@ -50,7 +50,7 @@ export function TransactionModal() { return (
- +
); diff --git a/src/features/transactions/hooks.ts b/src/features/transactions/hooks.ts index 01cda13..9fccda1 100644 --- a/src/features/transactions/hooks.ts +++ b/src/features/transactions/hooks.ts @@ -1,12 +1,17 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useToastTxSuccess } from 'src/components/notifications/TxSuccessToast'; import { useToastError } from 'src/components/notifications/useToastError'; -import { TxPlan } from 'src/features/transactions/types'; +import { ConfirmationDetails, TxPlan } from 'src/features/transactions/types'; import { logger } from 'src/utils/logger'; import { toTitleCase } from 'src/utils/strings'; +import { TransactionReceipt } from 'viem'; import { useWaitForTransactionReceipt, useWriteContract } from 'wagmi'; -export function useWriteContractWithReceipt(description: string, onSuccess?: () => any) { +export function useWriteContractWithReceipt( + description: string, + onSuccess?: (receipt: TransactionReceipt) => any, + showTxSuccessToast = false, +) { const { data: hash, error: writeError, @@ -20,6 +25,7 @@ export function useWriteContractWithReceipt(description: string, onSuccess?: () isSuccess: isConfirmed, error: waitError, isError: isWaitError, + data: receipt, } = useWaitForTransactionReceipt({ hash, confirmations: 1, @@ -34,20 +40,26 @@ export function useWriteContractWithReceipt(description: string, onSuccess?: () waitError, `Error confirming ${description} transaction, please ensure the transaction is valid.`, ); - useToastTxSuccess(isConfirmed, hash, `${toTitleCase(description)} transaction is confirmed!`); + // TODO remove? + useToastTxSuccess({ + isConfirmed, + txHash: hash, + message: `${toTitleCase(description)} transaction is confirmed!`, + enabled: showTxSuccessToast, + }); // Run onSuccess when tx is confirmed // Some extra state is needed to ensure this only runs once per tx const [hasRunOnSuccess, setHasRunOnSuccess] = useState(false); useEffect(() => { - if (hash && isConfirmed && !hasRunOnSuccess && onSuccess) { + if (hash && receipt && isConfirmed && !hasRunOnSuccess && onSuccess) { setHasRunOnSuccess(true); - onSuccess(); + onSuccess(receipt); } - }, [hash, isConfirmed, hasRunOnSuccess, onSuccess]); + }, [hash, receipt, isConfirmed, hasRunOnSuccess, onSuccess]); useEffect(() => { - if (!hash || !isConfirmed) setHasRunOnSuccess(false); - }, [hash, isConfirmed]); + if (!hash || !receipt || !isConfirmed) setHasRunOnSuccess(false); + }, [hash, receipt, isConfirmed]); return { hash, @@ -66,10 +78,11 @@ export function useTransactionPlan({ onPlanSuccess, }: { createTxPlan: (v: FormValues) => TxPlan; - onStepSuccess?: () => any; - onPlanSuccess?: () => any; + onStepSuccess?: (receipt: TransactionReceipt) => any; + onPlanSuccess?: (v: FormValues, receipt: TransactionReceipt) => any; }) { const [txPlan, setTxPlan] = useState(undefined); + const [formValues, setFormValues] = useState(undefined); const [txPlanIndex, setTxPlanIndex] = useState(0); const isPlanStarted = txPlanIndex > 0; @@ -78,6 +91,7 @@ export function useTransactionPlan({ if (txPlan) return txPlan; const plan = createTxPlan(v); setTxPlan(plan); + setFormValues(v); return plan; }, [txPlan, createTxPlan], @@ -92,17 +106,29 @@ export function useTransactionPlan({ const numTxs = useMemo(() => (txPlan ? txPlan.length : 0), [txPlan]); - const onTxSuccess = useCallback(() => { - logger.debug(`Executing onSuccess for tx ${txPlanIndex + 1} of ${numTxs}`); - if (onStepSuccess) onStepSuccess(); - if (txPlanIndex >= numTxs - 1) { - setTxPlan(undefined); - setTxPlanIndex(0); - if (onPlanSuccess) onPlanSuccess(); - } else { - setTxPlanIndex(txPlanIndex + 1); - } - }, [numTxs, txPlanIndex, onStepSuccess, onPlanSuccess]); + const onTxSuccess = useCallback( + (receipt: TransactionReceipt) => { + if (!formValues) throw new Error('onTxSuccess:formValues is undefined'); + logger.debug(`Executing onSuccess for tx ${txPlanIndex + 1} of ${numTxs}`); + if (onStepSuccess) onStepSuccess(receipt); + if (txPlanIndex >= numTxs - 1) { + setTxPlan(undefined); + setTxPlanIndex(0); + if (onPlanSuccess) onPlanSuccess(formValues, receipt); + } else { + setTxPlanIndex(txPlanIndex + 1); + } + }, + [numTxs, txPlanIndex, formValues, onStepSuccess, onPlanSuccess], + ); return { getTxPlan, getNextTx, txPlanIndex, numTxs, isPlanStarted, onTxSuccess }; } + +export function useTransactionFlowConfirmation() { + const [confirmationDetails, setConfirmationDetails] = useState( + undefined, + ); + const onConfirmed = useCallback((d: ConfirmationDetails) => setConfirmationDetails(d), []); + return { confirmationDetails, onConfirmed }; +} diff --git a/src/features/transactions/types.ts b/src/features/transactions/types.ts index 2a04037..9b9523a 100644 --- a/src/features/transactions/types.ts +++ b/src/features/transactions/types.ts @@ -1,3 +1,5 @@ +import type { TransactionReceipt } from 'viem'; + export enum TxModalType { Lock = 'lock', Stake = 'stake', @@ -7,7 +9,16 @@ export enum TxModalType { export type TxPlan = Array<{ action: A; + address: Address; + abi: any; functionName: F; args?: Array; value?: bigint; }>; + +export interface ConfirmationDetails { + message: string; + amount: bigint | number; + receipt: TransactionReceipt; + properties: Array<{ label: string; value: string }>; +} diff --git a/src/features/transactions/utils.ts b/src/features/transactions/utils.ts new file mode 100644 index 0000000..625f2f4 --- /dev/null +++ b/src/features/transactions/utils.ts @@ -0,0 +1,6 @@ +import { ChainId, chainIdToChain } from 'src/config/chains'; + +export function getTxExplorerUrl(hash: string, chainId: ChainId = ChainId.Celo) { + const chain = chainIdToChain[chainId]; + return `${chain.explorerUrl}/tx/${hash}`; +}