diff --git a/.github/workflows/bundle-desktop.yml b/.github/workflows/bundle-desktop.yml index 222c8549..6a9a0c64 100644 --- a/.github/workflows/bundle-desktop.yml +++ b/.github/workflows/bundle-desktop.yml @@ -60,6 +60,5 @@ jobs: name: celowallet-artifacts path: | dist-electron/*-mac*.dmg - dist-electron/*-mac*.zip dist-electron/*-linux*.AppImage dist-electron/*-win*.exe diff --git a/electron-builder.yml b/electron-builder.yml index 65e2225e..429b99db 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -21,7 +21,6 @@ mac: entitlementsInherit: electron/build/mac/entitlements.plist target: - dmg - - zip linux: artifactName: ${productName}-${version}-${os}-${arch}.${ext} diff --git a/package-electron.json b/package-electron.json index 0f441edc..a959f2b2 100644 --- a/package-electron.json +++ b/package-electron.json @@ -1,6 +1,6 @@ { "name": "celo-web-wallet", - "version": "1.0.0", + "version": "1.0.1", "description": "A lightweight web and desktop wallet for the Celo network", "main": "main.js", "keywords": [ diff --git a/package.json b/package.json index ccf3cdc7..c5bfb41b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "celo-web-wallet", - "version": "1.0.0", + "version": "1.0.1", "description": "A lightweight web and desktop wallet for the Celo network", "keywords": [ "Celo", diff --git a/src/components/modal/useNavHintModal.tsx b/src/components/modal/useNavHintModal.tsx index 8c7d1957..7f3ed810 100644 --- a/src/components/modal/useNavHintModal.tsx +++ b/src/components/modal/useNavHintModal.tsx @@ -12,6 +12,7 @@ export function useNavHintModal( body: string, navLabel: string, navTarget: string, + navState?: Record, onClose?: () => void ) { const navigate = useNavigate() @@ -29,7 +30,7 @@ export function useNavHintModal( color: Color.altGrey, } const onActionClick = (action: ModalAction) => { - if (action.key === 'nav') navigate(navTarget) + if (action.key === 'nav') navigate(navTarget, navState ? { state: navState } : undefined) if (onClose) onClose() closeModal() } diff --git a/src/consts.ts b/src/consts.ts index cc2118b0..2cfb3fda 100644 --- a/src/consts.ts +++ b/src/consts.ts @@ -30,7 +30,7 @@ export const ACCOUNT_UNLOCK_TIMEOUT = 600000 // 10 minutes export const BALANCE_STALE_TIME = 15000 // 15 seconds export const GAS_PRICE_STALE_TIME = 10000 // 10 seconds export const EXCHANGE_RATE_STALE_TIME = 15000 // 15 seconds -export const ACCOUNT_STATUS_STALE_TIME = 86400000 // 1 day +export const ACCOUNT_STATUS_STALE_TIME = 43200000 // 12 hours export const VALIDATOR_LIST_STALE_TIME = 86400000 // 1 day export const VALIDATOR_VOTES_STALE_TIME = 300000 // 5 minutes export const VALIDATOR_ACTIVATABLE_STALE_TIME = 43200000 // 12 hours diff --git a/src/features/home/HomeScreen.tsx b/src/features/home/HomeScreen.tsx index 7a98407c..3d03c47e 100644 --- a/src/features/home/HomeScreen.tsx +++ b/src/features/home/HomeScreen.tsx @@ -13,6 +13,7 @@ import { HeaderSection } from 'src/features/home/HeaderSection' import { HeaderSectionEmpty } from 'src/features/home/HeaderSectionEmpty' import { toggleHomeHeaderDismissed } from 'src/features/settings/settingsSlice' import { PriceChartCelo } from 'src/features/tokenPrice/PriceChartCelo' +import { StakeActionType } from 'src/features/validators/types' import { dismissActivatableReminder } from 'src/features/validators/validatorsSlice' import { useAreBalancesEmpty } from 'src/features/wallet/utils' import { Color } from 'src/styles/Color' @@ -61,16 +62,18 @@ export function HomeScreen() { // END PIN MIGRATION // Detect if user has unactivated staking votes - const { status: hasActivatableVotes, reminderDismissed: votesReminderDismissed } = useSelector( - (state: RootState) => state.validators.hasActivatable - ) - const showActivateModal = hasActivatableVotes && !votesReminderDismissed + const hasActivatable = useSelector((state: RootState) => state.validators.hasActivatable) + const showActivateModal = + hasActivatable.status && + hasActivatable.groupAddresses.length && + !hasActivatable.reminderDismissed useNavHintModal( showActivateModal, 'Activate Your Votes!', 'You have pending validator votes that are ready to be activated. They must be activated to start earning staking rewards.', 'Activate', '/stake', + { groupAddress: hasActivatable.groupAddresses[0], action: StakeActionType.Activate }, () => { dispatch(dismissActivatableReminder()) } diff --git a/src/features/home/Tips.ts b/src/features/home/Tips.ts index 30ca4047..0ece486e 100644 --- a/src/features/home/Tips.ts +++ b/src/features/home/Tips.ts @@ -1,4 +1,6 @@ -export const Tips = [ +import { config } from 'src/config' + +const Tips = [ [ 'Transaction fees can be paid in any currency but they are smaller when paid with CELO.', 'Consider keeping some CELO in your account to pay for fees. It will be used by default.', @@ -17,11 +19,7 @@ export const Tips = [ ], [ 'Your wallet can be imported in many places at once.', - 'For example, use your Account Key to load it into the Valora mobile app.', - ], - [ - 'Using this wallet in a browser is only safe for small accounts or Ledger users.', - 'For large accounts, downloading the Desktop App is strongly recommended.', + 'For example, you can import your Account Key into the Valora mobile app.', ], [ 'You can lock CELO to participate in Celo network elections and governance.', @@ -29,8 +27,16 @@ export const Tips = [ ], ] +const WebTips = [ + ...Tips, + [ + 'Using this wallet in a browser is only safe for small accounts or Ledger users.', + 'For large accounts, downloading the Desktop App is strongly recommended.', + ], +] + export function useDailyTip() { - // TODO save the starting date in storage so tips can be cycled through in order from the first + const tips = config.isElectron ? Tips : WebTips const date = new Date().getDate() - return Tips[date % Tips.length] + return tips[date % tips.length] } diff --git a/src/features/ledger/LedgerSigner.ts b/src/features/ledger/LedgerSigner.ts index 9ef9d8ec..65209e4a 100644 --- a/src/features/ledger/LedgerSigner.ts +++ b/src/features/ledger/LedgerSigner.ts @@ -1,9 +1,9 @@ +import 'src/features/ledger/buffer' // Must be the first import import { CeloTransactionRequest, serializeCeloTransaction } from '@celo-tools/celo-ethers-wrapper' import { TransportError, TransportStatusError } from '@ledgerhq/errors' import { BigNumber, providers, Signer, utils } from 'ethers' import { config } from 'src/config' import { CELO_LEDGER_APP_MIN_VERSION } from 'src/consts' -import 'src/features/ledger/buffer' // Must be the first import import { CeloLedgerApp } from 'src/features/ledger/CeloLedgerApp' import { getLedgerTransport } from 'src/features/ledger/ledgerTransport' import { getTokenData } from 'src/features/ledger/tokenData' diff --git a/src/features/lock/LockFormScreen.tsx b/src/features/lock/LockFormScreen.tsx index 38108581..d02188b5 100644 --- a/src/features/lock/LockFormScreen.tsx +++ b/src/features/lock/LockFormScreen.tsx @@ -63,6 +63,7 @@ export function LockFormScreen() { handleSubmit, setValues, resetValues, + resetErrors, } = useCustomForm(getInitialValues(tx), onSubmit, validateForm) // Keep form in sync with tx state @@ -83,6 +84,7 @@ export function LockFormScreen() { autoSetAmount = '0' } setValues({ ...values, [name]: value, amount: autoSetAmount }) + resetErrors() } const onUseMax = () => { @@ -96,6 +98,7 @@ export function LockFormScreen() { maxAmount = fromWeiRounded(pendingFree, Currency.CELO, true) } setValues({ ...values, amount: maxAmount }) + resetErrors() } const summaryData = useMemo(() => getSummaryChartData(balances), [balances]) diff --git a/src/features/lock/lockToken.ts b/src/features/lock/lockToken.ts index 29e696d5..a079e8b3 100644 --- a/src/features/lock/lockToken.ts +++ b/src/features/lock/lockToken.ts @@ -15,11 +15,12 @@ import { getTotalUnlockedCelo } from 'src/features/lock/utils' import { LockTokenTx, LockTokenType, TransactionType } from 'src/features/types' import { GroupVotes } from 'src/features/validators/types' import { getTotalNonvotingLocked } from 'src/features/validators/utils' -import { createAccountRegisterTx } from 'src/features/wallet/accountsContract' +import { createAccountRegisterTx, fetchAccountStatus } from 'src/features/wallet/accountsContract' import { fetchBalancesActions, fetchBalancesIfStale } from 'src/features/wallet/fetchBalances' import { Balances } from 'src/features/wallet/types' import { setAccountIsRegistered } from 'src/features/wallet/walletSlice' import { + areAmountsNearlyEqual, BigNumberMin, getAdjustedAmount, validateAmount, @@ -46,13 +47,14 @@ export function validate( if (!amountInWei) { errors = { ...errors, ...invalidInput('amount', 'Amount Missing') } } else { + const { locked, pendingFree, pendingBlocked } = balances.lockedCelo const adjustedBalances = { ...balances } if (action === LockActionType.Lock) { adjustedBalances.celo = getTotalUnlockedCelo(balances).toString() } else if (action === LockActionType.Unlock) { - adjustedBalances.celo = balances.lockedCelo.locked + adjustedBalances.celo = locked } else if (action === LockActionType.Withdraw) { - adjustedBalances.celo = balances.lockedCelo.pendingFree + adjustedBalances.celo = pendingFree } errors = { ...errors, @@ -60,16 +62,28 @@ export function validate( } // Special case handling for withdraw which is confusing - if ( - action === LockActionType.Withdraw && - BigNumber.from(balances.lockedCelo.pendingFree).lte(0) - ) { + if (action === LockActionType.Withdraw && BigNumber.from(pendingFree).lte(0)) { errors = { ...errors, ...invalidInput('amount', 'No pending available to withdraw'), } } + // Special case handling for locking whole balance + if (action === LockActionType.Lock && !errors.amount) { + const remainingAfterPending = BigNumber.from(amountInWei).sub(pendingFree).sub(pendingBlocked) + if ( + remainingAfterPending.gt(0) && + (remainingAfterPending.gte(balances.celo) || + areAmountsNearlyEqual(remainingAfterPending, balances.celo, Currency.CELO)) + ) { + errors = { + ...errors, + ...invalidInput('amount', 'Locking whole balance is not allowed'), + } + } + } + if (validateFee) { errors = { ...errors, @@ -112,11 +126,17 @@ function* lockToken(params: LockTokenParams) { throw new Error('Fee estimates missing or do not match txPlan') } + const { txPlan: txPlanAdjusted, feeEstimates: feeEstimatesAdjusted } = yield* call( + ensureAccountNotAlreadyRegistered, + txPlan, + feeEstimates + ) + logger.info(`Executing ${action} for ${amountInWei} CELO`) yield* call>( executeTxPlan, - txPlan, - feeEstimates, + txPlanAdjusted, + feeEstimatesAdjusted, createActionTx, createPlaceholderTx, 'lockToken' @@ -190,7 +210,7 @@ export function getLockActionTxPlan( let amountRemaining = BigNumber.from(amountInWei) const pwSorted = pendingWithdrawals.sort((a, b) => b.index - a.index) for (const p of pwSorted) { - if (amountRemaining.lte(MIN_LOCK_AMOUNT)) break + if (amountRemaining.lt(MIN_LOCK_AMOUNT)) break const txAmount = BigNumberMin(amountRemaining, BigNumber.from(p.value)) const adjustedAmount = getAdjustedAmount(txAmount, p.value, Currency.CELO) txs.push({ @@ -278,8 +298,8 @@ async function createWithdrawCeloTx( } async function ensureAccountNotGovernanceVoting() { - const governance = getContract(CeloContract.Governance) const address = getSigner().signer.address + const governance = getContract(CeloContract.Governance) const isVoting: boolean = await governance.isVoting(address) if (isVoting) throw new Error( @@ -287,6 +307,24 @@ async function ensureAccountNotGovernanceVoting() { ) } +// This is necessary in case the isAccountRegistered in state is +// stale or incorrect, which is rare but does happen +function* ensureAccountNotAlreadyRegistered(txPlan: LockTokenTxPlan, feeEstimates: FeeEstimate[]) { + if (txPlan[0].type !== TransactionType.AccountRegistration) { + // Not trying to register, no adjustments needed + return { txPlan, feeEstimates } + } + + // Force fetch latest account status + const { isRegistered } = yield* call(fetchAccountStatus, true) + if (isRegistered) { + // Remove the account registration tx from plan and estimates + return { txPlan: txPlan.slice(1), feeEstimates: feeEstimates.slice(1) } + } else { + return { txPlan, feeEstimates } + } +} + export const { name: lockTokenSagaName, wrappedSaga: lockTokenSaga, diff --git a/src/features/validators/StakeFormScreen.tsx b/src/features/validators/StakeFormScreen.tsx index 665ca2b3..ab53ffce 100644 --- a/src/features/validators/StakeFormScreen.tsx +++ b/src/features/validators/StakeFormScreen.tsx @@ -20,6 +20,7 @@ import { TxFlowTransaction, TxFlowType } from 'src/features/txFlow/types' import { getResultChartData, getSummaryChartData } from 'src/features/validators/barCharts' import { validate } from 'src/features/validators/stakeToken' import { + GroupVotes, stakeActionLabel, StakeActionType, StakeTokenParams, @@ -78,11 +79,16 @@ export function StakeFormScreen() { handleSubmit, setValues, resetValues, - } = useCustomForm(getInitialValues(location, tx), onSubmit, validateForm) + resetErrors, + } = useCustomForm( + getInitialValues(location, tx, groupVotes), + onSubmit, + validateForm + ) // Keep form in sync with tx state useEffect(() => { - const initialValues = getInitialValues(location, tx) + const initialValues = getInitialValues(location, tx, groupVotes) resetValues(initialValues) // Ensure we have the info needed otherwise send user back if (!groups || !groups.length) { @@ -114,6 +120,7 @@ export function StakeFormScreen() { autoSetAmount = '0' } setValues({ ...values, [name]: value, amount: autoSetAmount }) + resetErrors() } const onUseMax = () => { @@ -124,6 +131,7 @@ export function StakeFormScreen() { values.groupAddress ) setValues({ ...values, amount: fromWeiRounded(maxAmount, Currency.CELO, true) }) + resetErrors() } const onGoBack = () => { @@ -270,17 +278,31 @@ function HelpModal() { ) } -function getInitialValues(location: Location, tx: TxFlowTransaction | null): StakeTokenForm { - const groupAddress = location?.state?.groupAddress - const initialGroup = groupAddress && utils.isAddress(groupAddress) ? groupAddress : '' - if (!tx || !tx.params || tx.type !== TxFlowType.Stake) { - return { - ...initialValues, - groupAddress: initialGroup, - } - } else { +function getInitialValues( + location: Location, + tx: TxFlowTransaction | null, + groupVotes: GroupVotes +): StakeTokenForm { + if (tx && tx.params && tx.type === TxFlowType.Stake) { return amountFieldFromWei(tx.params) } + + const initialAction = location?.state?.action ?? initialValues.action + const groupAddress = location?.state?.groupAddress + const initialGroup = + groupAddress && utils.isAddress(groupAddress) ? groupAddress : initialValues.groupAddress + + // Auto use pending when defaulting to activate + const initialAmount = + groupAddress && groupVotes[groupAddress] && initialAction === StakeActionType.Activate + ? fromWeiRounded(groupVotes[groupAddress].pending, Currency.CELO, true) + : initialValues.amount + + return { + groupAddress: initialGroup, + action: initialAction, + amount: initialAmount, + } } function getSelectOptions(groups: ValidatorGroup[]) { diff --git a/src/features/validators/fetchGroupVotes.ts b/src/features/validators/fetchGroupVotes.ts index 0b8e5a28..f0d02c20 100644 --- a/src/features/validators/fetchGroupVotes.ts +++ b/src/features/validators/fetchGroupVotes.ts @@ -74,6 +74,7 @@ async function checkHasActivatable(groupVotes: GroupVotes, accountAddress: strin status: false, lastUpdated: Date.now(), reminderDismissed: false, + groupAddresses: [], } } @@ -84,11 +85,15 @@ async function checkHasActivatable(groupVotes: GroupVotes, accountAddress: strin 'hasActivatablePendingVotes', groupAddrsAndAccount ) - const status = hasActivatable.some((v) => !!v) + if (groupsWithPending.length !== hasActivatable.length) + throw new Error('Groups, activatable lists size mismatch') + const groupToActivate = groupsWithPending.filter((v, i) => !!hasActivatable[i]) + const status = groupToActivate.length > 0 return { status, lastUpdated: Date.now(), reminderDismissed: false, + groupAddresses: groupToActivate, } } diff --git a/src/features/validators/validatorsSlice.ts b/src/features/validators/validatorsSlice.ts index acf56158..58608d66 100644 --- a/src/features/validators/validatorsSlice.ts +++ b/src/features/validators/validatorsSlice.ts @@ -8,6 +8,7 @@ interface ActivatableStatus { status: boolean lastUpdated: number | null reminderDismissed: boolean + groupAddresses: string[] // the groups whose votes can be activated } interface ValidatorsState { @@ -29,6 +30,7 @@ export const validatorsInitialState: ValidatorsState = { status: false, lastUpdated: null, reminderDismissed: false, + groupAddresses: [], }, } diff --git a/src/features/wallet/accountsContract.ts b/src/features/wallet/accountsContract.ts index bb680c80..feadd8e4 100644 --- a/src/features/wallet/accountsContract.ts +++ b/src/features/wallet/accountsContract.ts @@ -1,7 +1,6 @@ import { logger, utils } from 'ethers' import { RootState } from 'src/app/rootReducer' import { getContract } from 'src/blockchain/contracts' -import { getSigner } from 'src/blockchain/signer' import { signTransaction } from 'src/blockchain/transaction' import { CeloContract } from 'src/config' import { ACCOUNT_STATUS_STALE_TIME } from 'src/consts' @@ -11,11 +10,11 @@ import { areAddressesEqual } from 'src/utils/addresses' import { isStale } from 'src/utils/time' import { call, put, select } from 'typed-redux-saga' -export function* fetchAccountStatus() { +export function* fetchAccountStatus(force = false) { const { address, account } = yield* select((state: RootState) => state.wallet) if (!address) throw new Error('Cannot fetch account status before address is set') - if (isStale(account.lastUpdated, ACCOUNT_STATUS_STALE_TIME)) { + if (isStale(account.lastUpdated, ACCOUNT_STATUS_STALE_TIME) || force) { const accountUpdated = yield* call(fetchAccountRegistrationStatus, address) yield* put(setAccountStatus(accountUpdated)) return accountUpdated @@ -52,13 +51,7 @@ async function fetchVoteSignerAccount(address: string) { } export async function createAccountRegisterTx(feeEstimate: FeeEstimate, nonce: number) { - const address = getSigner().signer.address const accounts = getContract(CeloContract.Accounts) - const isRegisteredAccount = await accounts.isAccount(address) - if (isRegisteredAccount) { - throw new Error('Attempting to register account that already exists') - } - /** * Just using createAccount for now but if/when DEKs are * supported than using setAccount here would make sense. diff --git a/src/utils/useCustomForm.ts b/src/utils/useCustomForm.ts index ccd653ce..e905bcc6 100644 --- a/src/utils/useCustomForm.ts +++ b/src/utils/useCustomForm.ts @@ -16,6 +16,10 @@ export function useCustomForm( setTouched({}) } + const resetErrors = () => { + setErrors({ isValid: true }) + } + useEffect(() => { resetValues(initialValues) }, []) @@ -52,5 +56,6 @@ export function useCustomForm( handleSubmit, setValues, resetValues, + resetErrors, } }