Skip to content

Commit

Permalink
feat: sync watched-only accounts between extension and portal (#725)
Browse files Browse the repository at this point in the history
* feat: sync watched-only accounts between extension and portal

* fix: possible duplicated accounts with injected readonly accounts

* feat: only allow deleting of locally created readonly account
  • Loading branch information
tien authored Aug 3, 2023
1 parent a9d61c5 commit 1b528e6
Show file tree
Hide file tree
Showing 10 changed files with 115 additions and 66 deletions.
6 changes: 3 additions & 3 deletions apps/web/src/archetypes/Crowdloan/Contribute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Button, DesktopRequired, Field, MaterialLoader, useModal } from '@compo
import { TalismanHandLike } from '@components/TalismanHandLike'
import { TalismanHandLoader } from '@components/TalismanHandLoader'
import { useAccountSelector } from '@components/widgets/AccountSelector'
import { injectedSubstrateAccountsState } from '@domains/accounts'
import { writeableSubstrateAccountsState } from '@domains/accounts'
import { useTheme } from '@emotion/react'
import styled from '@emotion/styled'
import { ContributeEvent, useCrowdloanContribute } from '@libs/crowdloans'
Expand All @@ -13,7 +13,7 @@ import { useCrowdloanById } from '@libs/talisman'
import { CircularProgressIndicator, Text } from '@talismn/ui'
import { isMobileBrowser } from '@util/helpers'
import { Maybe } from '@util/monads'
import { type MouseEventHandler, useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState, type MouseEventHandler } from 'react'
import { useTranslation } from 'react-i18next'
import { useRecoilValue } from 'recoil'

Expand Down Expand Up @@ -127,7 +127,7 @@ const ContributeTo = styled(

const [chainHasTerms, termsAgreed, onTermsCheckboxClick] = useTerms(relayChainId, parachainId)

const [account, accountSelector] = useAccountSelector(useRecoilValue(injectedSubstrateAccountsState), 0)
const [account, accountSelector] = useAccountSelector(useRecoilValue(writeableSubstrateAccountsState), 0)

useEffect(() => {
dispatch(ContributeEvent.setAccount(account?.address))
Expand Down
52 changes: 31 additions & 21 deletions apps/web/src/components/widgets/AccountsManagementMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import {
injectedAccountsState,
portfolioAccountsState,
readOnlyAccountsState,
selectedAccountAddressesState,
selectedAccountsState,
} from '@domains/accounts/recoils'
import { fiatBalancesState, totalInjectedAccountsFiatBalance } from '@domains/balances/recoils'
import { fiatBalancesState, totalPortfolioFiatBalance } from '@domains/balances/recoils'
import { copyAddressToClipboard } from '@domains/common/utils'
import { useIsWeb3Injected } from '@domains/extension/hooks'
import { allowExtensionConnectionState } from '@domains/extension/recoils'
import { useTheme } from '@emotion/react'
import { Copy, Download, Eye, EyePlus, Link, PlusCircle, Power, TalismanHand, Trash2, Users } from '@talismn/icons'
import { CircularProgressIndicator, IconButton, Identicon, ListItem, Menu, Text } from '@talismn/ui'
import { CircularProgressIndicator, IconButton, Identicon, ListItem, Menu, Text, Tooltip } from '@talismn/ui'
import { shortenAddress } from '@util/format'
import { Maybe } from '@util/monads'
import { useMemo, type ReactNode } from 'react'
Expand Down Expand Up @@ -62,10 +62,10 @@ const AccountsManagementIconButton = (props: { size?: number | string }) => {
const AccountsManagementMenu = (props: { button: ReactNode }) => {
const theme = useTheme()

const totalBalance = useRecoilValueLoadable(totalInjectedAccountsFiatBalance)
const totalBalance = useRecoilValueLoadable(totalPortfolioFiatBalance)

const setSelectedAccountAddresses = useSetRecoilState(selectedAccountAddressesState)
const injectedAccounts = useRecoilValue(injectedAccountsState)
const portfolioAccounts = useRecoilValue(portfolioAccountsState)
const readonlyAccounts = useRecoilValue(readOnlyAccountsState)

const fiatBalances = useRecoilValueLoadable(fiatBalancesState)
Expand Down Expand Up @@ -170,7 +170,7 @@ const AccountsManagementMenu = (props: { button: ReactNode }) => {
<TalismanHand size="1em" /> My accounts
</Text.Body>
{leadingMenuItem}
{injectedAccounts.map((x, index) => (
{portfolioAccounts.map((x, index) => (
<Menu.Item key={index} onClick={() => setSelectedAccountAddresses(() => [x.address])}>
<ListItem
headlineText={x.name ?? shortenAddress(x.address)}
Expand Down Expand Up @@ -206,41 +206,51 @@ const AccountsManagementMenu = (props: { button: ReactNode }) => {
>
<Eye size="1em" /> Watched accounts
</Text.Body>
{readonlyAccounts.map((x, index) => (
<RemoveWatchedAccountConfirmationDialog key={index} account={x}>
{readonlyAccounts.map((account, index) => (
<RemoveWatchedAccountConfirmationDialog key={index} account={account}>
{({ onToggleOpen: toggleRemoveDialog }) => (
<Menu.Item onClick={() => setSelectedAccountAddresses(() => [x.address])}>
<Menu.Item onClick={() => setSelectedAccountAddresses(() => [account.address])}>
<ListItem
headlineText={x.name ?? shortenAddress(x.address)}
headlineText={account.name ?? shortenAddress(account.address)}
overlineText={Maybe.of(fiatBalances.valueMaybe()).mapOr(
<CircularProgressIndicator size="1em" />,
balances => (
<AnimatedFiatNumber end={balances[x.address] ?? 0} />
<AnimatedFiatNumber end={balances[account.address] ?? 0} />
)
)}
leadingContent={<Identicon value={x.address} size="4rem" />}
leadingContent={<Identicon value={account.address} size="4rem" />}
revealTrailingContentOnHover
trailingContent={
<div css={{ display: 'flex' }}>
<IconButton
containerColor={theme.color.foreground}
onClick={(event: any) => {
event.stopPropagation()
void copyAddressToClipboard(x.address)
void copyAddressToClipboard(account.address)
}}
css={{ cursor: 'copy' }}
>
<Copy />
</IconButton>
<IconButton
containerColor={theme.color.foreground}
onClick={(event: any) => {
event.stopPropagation()
toggleRemoveDialog()
}}
<Tooltip
content="This account can be managed via the extension"
disabled={account.origin === 'local'}
>
<Trash2 />
</IconButton>
{tooltipProps => (
<div {...tooltipProps}>
<IconButton
containerColor={theme.color.foreground}
onClick={(event: any) => {
event.stopPropagation()
toggleRemoveDialog()
}}
disabled={account.origin !== 'local'}
>
<Trash2 />
</IconButton>
</div>
)}
</Tooltip>
</div>
}
/>
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/components/widgets/dex/SwapForm.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import DexForm from '@components/recipes/DexForm/DexForm'
import { injectedAccountsState } from '@domains/accounts'
import { writeableAccountsState } from '@domains/accounts'
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useRecoilValue } from 'recoil'
import AccountSelector from '../AccountSelector'
import TokenSelectorButton from '../TokenSelectorButton'

const SwapForm = () => {
const [account, setAccount] = useState(useRecoilValue(injectedAccountsState).at(0))
const [account, setAccount] = useState(useRecoilValue(writeableAccountsState).at(0))

return (
<DexForm
Expand All @@ -18,7 +18,7 @@ const SwapForm = () => {
<DexForm.Swap
accountSelector={
<AccountSelector
accounts={useRecoilValue(injectedAccountsState)}
accounts={useRecoilValue(writeableAccountsState)}
selectedAccount={account}
onChangeSelectedAccount={setAccount}
/>
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/widgets/dex/TransportForm.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { FixedPointNumber } from '@acala-network/sdk-core'
import DexForm from '@components/recipes/DexForm/DexForm'
import { injectedSubstrateAccountsState } from '@domains/accounts'
import { writeableSubstrateAccountsState } from '@domains/accounts'
import { bridgeAdapterState, bridgeState } from '@domains/bridge'
import { useExtrinsic } from '@domains/common'
import { type SubmittableExtrinsic } from '@polkadot/api/types'
Expand All @@ -21,7 +21,7 @@ const TransportForm = () => {
const bridge = useRecoilValue(bridgeState)

const [amount, setAmount] = useState('')
const [sender, senderSelector] = useAccountSelector(useRecoilValue(injectedSubstrateAccountsState), 0)
const [sender, senderSelector] = useAccountSelector(useRecoilValue(writeableSubstrateAccountsState), 0)

const [fromChain, setFromChain] = useState<Chain>()
const [toChain, setToChain] = useState<Chain>()
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/widgets/staking/StakeForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import ClaimStakeDialog from '@components/recipes/ClaimStakeDialog'
import PoolSelectorDialog from '@components/recipes/PoolSelectorDialog'
import StakeFormComponent from '@components/recipes/StakeForm'
import { type StakeStatus } from '@components/recipes/StakeStatusIndicator'
import { injectedSubstrateAccountsState, type Account } from '@domains/accounts/recoils'
import { writeableSubstrateAccountsState, type Account } from '@domains/accounts/recoils'
import { ChainContext, ChainProvider, chainsState, useNativeTokenDecimalState, type Chain } from '@domains/chains'
import {
useChainState,
Expand Down Expand Up @@ -265,7 +265,7 @@ export const ControlledStakeForm = (props: { assetSelector: ReactNode }) => {
const [showPoolSelector, setShowPoolSelector] = useState(false)

const [selectedAccount, accountSelector] = useAccountSelector(
useRecoilValue(injectedSubstrateAccountsState),
useRecoilValue(writeableSubstrateAccountsState),
// We don't want to select the first account when poolId is present in the URL
// because we want to showcase that pool & the first account might have already joined one
poolIdFromSearch === undefined ? 0 : undefined
Expand Down
68 changes: 46 additions & 22 deletions apps/web/src/domains/accounts/recoils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,30 @@ import { storageEffect } from '@domains/common/effects'
import type { InjectedAccount } from '@polkadot/extension-inject/types'
import { array, jsonParser, object, optional, string } from '@recoiljs/refine'
import { Maybe } from '@util/monads'
import { DefaultValue, atom, selector, waitForAll } from 'recoil'
import { uniqBy } from 'lodash'
import { atom, selector, waitForAll } from 'recoil'
import { isAddress as isEvmAddress } from 'viem'

export type Account = InjectedAccount & {
readonly?: boolean
}
type AccountWithOrigin = InjectedAccount & { origin?: 'injected' | 'local' }

type AccountWithReadonlyInfo = InjectedAccount & ({ readonly?: false } | { readonly: true; partOfPortfolio: boolean })

export type Account = AccountWithOrigin & AccountWithReadonlyInfo

export type ReadonlyAccount = Pick<Account, 'address' | 'name'>

export const injectedAccountsState = atom<Account[]>({
key: 'InjectedAccounts',
const _injectedAccountsState = atom<AccountWithReadonlyInfo[]>({
key: '_InjectedAccounts',
default: [],
})

export const injectedSubstrateAccountsState = selector({
key: 'InjectedSubstrateAccountsState',
get: ({ get }) => get(injectedAccountsState).filter(x => x.type !== 'ethereum'),
export const injectedAccountsState = selector<Account[]>({
key: 'InjectedAccounts',
get: ({ get }) => get(_injectedAccountsState).map(x => ({ ...x, origin: 'injected' })),
set: ({ set }, newValue) => set(_injectedAccountsState, newValue),
})

const _readOnlyAccountsState = atom<ReadonlyAccount[]>({
const _readonlyAccountsState = atom<ReadonlyAccount[]>({
key: 'readonly_accounts',
default: [],
effects: [
Expand All @@ -41,23 +45,42 @@ const _readOnlyAccountsState = atom<ReadonlyAccount[]>({
export const readOnlyAccountsState = selector<Account[]>({
key: 'ReadonlyAccounts',
get: ({ get }) => {
const injectedAddresses = get(injectedAccountsState).map(x => x.address)
return get(_readOnlyAccountsState)
.filter(x => !injectedAddresses.includes(x.address))
.map(x => ({ ...x, readonly: true, type: isEvmAddress(x.address) ? 'ethereum' : undefined }))
},
set: ({ set, reset }, newValue) => {
if (newValue instanceof DefaultValue) {
reset(_readOnlyAccountsState)
} else {
set(_readOnlyAccountsState, newValue)
}
const injectedAccounts = get(injectedAccountsState)
const injectedAddresses = injectedAccounts.map(x => x.address)
return [
...injectedAccounts.filter(x => x.readonly && !x.partOfPortfolio),
...get(_readonlyAccountsState)
.filter(x => !injectedAddresses.includes(x.address))
.map(x => ({
...x,
origin: 'local' as const,
readonly: true,
partOfPortfolio: false,
type: isEvmAddress(x.address) ? ('ethereum' as const) : undefined,
})),
]
},
set: ({ set }, newValue) => set(_readonlyAccountsState, newValue),
})

export const accountsState = selector({
key: 'Accounts',
get: ({ get }) => [...get(injectedAccountsState), ...get(readOnlyAccountsState)],
get: ({ get }) => uniqBy([...get(injectedAccountsState), ...get(readOnlyAccountsState)], x => x.address),
})

export const portfolioAccountsState = selector({
key: 'PortfolioAccounts',
get: ({ get }) => get(accountsState).filter(x => !x.readonly || x.partOfPortfolio),
})

export const writeableAccountsState = selector({
key: 'WriteableAccounts',
get: ({ get }) => get(accountsState).filter(x => !x.readonly),
})

export const writeableSubstrateAccountsState = selector({
key: 'WriteableSubstrateAccounts',
get: ({ get }) => get(writeableAccountsState).filter(x => x.type !== 'ethereum'),
})

export const substrateAccountsState = selector({
Expand All @@ -73,6 +96,7 @@ export const selectedAccountAddressesState = atom<string[] | undefined>({
default: undefined,
})

// TODO: either clean this up or add some tests
export const selectedAccountsState = selector({
key: 'SelectedAccounts',
get: ({ get }) => {
Expand Down
10 changes: 5 additions & 5 deletions apps/web/src/domains/balances/recoils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// TODO: nuke everything and re-write balances lib integration

import { accountsState, injectedAccountsState, selectedAccountsState } from '@domains/accounts/recoils'
import { accountsState, portfolioAccountsState, selectedAccountsState } from '@domains/accounts/recoils'
import { Balances } from '@talismn/balances'
import { useBalances as _useBalances, useAllAddresses, useChaindata, useTokens } from '@talismn/balances-react'
import { type ChaindataProvider, type TokenList } from '@talismn/chaindata-provider'
Expand Down Expand Up @@ -49,14 +49,14 @@ export const fiatBalancesState = atom<Record<string, number>>({
key: 'FiatBalances',
})

export const totalInjectedAccountsFiatBalance = selector({
key: 'TotalInjectedAccountsFiatBalance',
export const totalPortfolioFiatBalance = selector({
key: 'TotalPortfolioFiatBalance',
get: ({ get }) => {
const injecteds = get(injectedAccountsState).map(x => x.address)
const accounts = get(portfolioAccountsState).map(x => x.address)
const fiatBalances = get(fiatBalancesState)

return Object.entries(fiatBalances)
.filter(([key]) => injecteds.includes(key))
.filter(([key]) => accounts.includes(key))
.reduce((previous, current) => previous + current[1], 0)
},
})
Expand Down
21 changes: 15 additions & 6 deletions apps/web/src/domains/extension/recoils.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { injectedAccountsState } from '@domains/accounts/recoils'
import { storageEffect } from '@domains/common/effects'
import { web3AccountsSubscribe, web3Enable } from '@polkadot/extension-dapp'
import { web3Enable } from '@polkadot/extension-dapp'
import type { InjectedWindow } from '@polkadot/extension-inject/types'
import { uniqBy } from 'lodash'
import { usePostHog } from 'posthog-js/react'
import { useEffect } from 'react'
import { atom, useRecoilState, useSetRecoilState } from 'recoil'
Expand All @@ -23,20 +22,30 @@ export const ExtensionWatcher = () => {
return setAccounts([])
}

const unsubscribePromise = web3Enable(import.meta.env.REACT_APP_APPLICATION_NAME ?? 'Talisman').then(
const unsubscribesPromise = web3Enable(import.meta.env.REACT_APP_APPLICATION_NAME ?? 'Talisman').then(
async extensions => {
posthog?.capture('Substrate extensions connected', {
$set: { substrateExtensions: extensions.map(x => x.name) },
})

return await web3AccountsSubscribe(accounts =>
setAccounts(uniqBy(accounts, account => account.address).map(account => ({ ...account, ...account.meta })))
return extensions.map(extension =>
extension.accounts.subscribe(accounts =>
setAccounts(
accounts.map(account => ({
...account,
// @ts-expect-error
readonly: Boolean(account.readonly),
// @ts-expect-error
partOfPortfolio: Boolean(account.partOfPortfolio),
}))
)
)
)
}
)

return () => {
void unsubscribePromise.then(unsubscribe => unsubscribe())
void unsubscribesPromise.then(unsubscribes => unsubscribes.map(unsubscribe => unsubscribe()))
}
}, [allowExtensionConnection, posthog, setAccounts])

Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/domains/fastUnstake/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { injectedSubstrateAccountsState } from '@domains/accounts/recoils'
import { writeableSubstrateAccountsState } from '@domains/accounts/recoils'
import { useSubstrateApiEndpoint, useSubstrateApiState } from '@domains/common'
import { encodeAddress } from '@polkadot/util-crypto'
import { bool, coercion, jsonParser, writableDict } from '@recoiljs/refine'
Expand Down Expand Up @@ -93,7 +93,8 @@ const unexposedAddressesState = atomFamily<
export const useInjectedAccountFastUnstakeEligibility = () => {
const api = useRecoilValue(useSubstrateApiState())

const addresses = useRecoilValue(injectedSubstrateAccountsState).map(x => x.address)
const accounts = useRecoilValue(writeableSubstrateAccountsState)
const addresses = useMemo(() => accounts.map(x => x.address), [accounts])
const bondedAccounts = useRecoilValue(useQueryState('staking', 'bonded.multi', addresses))

const addressesToRunExposureCheck = useMemo(
Expand Down
Loading

0 comments on commit 1b528e6

Please sign in to comment.