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

[SW-1244] Move accounts vuex state to composable #2334

Merged
merged 4 commits into from
Oct 4, 2023
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
3 changes: 1 addition & 2 deletions src/background/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { UNFINISHED_FEATURES } from '@/constants';
import initDeeplinkHandler from './deeplinkHandler';
import * as wallet from './wallet';
import Logger from '../lib/logger';
import store from './store';
import { useAccounts } from '../composables';

Logger.init({ background: true });
Expand All @@ -20,7 +19,7 @@ browser.runtime.onMessage.addListener(async (msg) => {
}

if (method === 'checkHasAccount') {
const { isLoggedIn } = useAccounts({ store });
const { isLoggedIn } = useAccounts();
return isLoggedIn.value;
}

Expand Down
4 changes: 0 additions & 4 deletions src/background/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,12 @@ import Vuex from 'vuex';
import persistState from '../store/plugins/persistState';
import permissions from '../store/modules/permissions';
import accounts from '../store/modules/accounts';
import getters from '../store/getters';

const store = new Vuex.Store({
plugins: [persistState()],
modules: { permissions, accounts },
getters: {
'names/getDefault': () => (address) => `placeholder name for ${address}`,
wallet: getters.wallet,
accounts: getters.accounts,
account: getters.account,
},
});

Expand Down
276 changes: 178 additions & 98 deletions src/composables/accounts.ts
Original file line number Diff line number Diff line change
@@ -1,95 +1,131 @@
import {
computed,
ref,
} from 'vue';
import { computed } from 'vue';
import { uniq } from 'lodash-es';
import { Encoded } from '@aeternity/aepp-sdk';
import { generateMnemonic, mnemonicToSeed } from '@aeternity/bip39';
import type {
IAccount,
IAccountRaw,
IDefaultComposableOptions,
IFormSelectOption,
Protocol,
ProtocolRecord,
} from '@/types';
import {
ACCOUNT_HD_WALLET,
PROTOCOL_AETERNITY,
PROTOCOLS,
STORAGE_KEYS,
} from '@/constants';
import {
getAccountNameToDisplay,
prepareAccountSelectOptions,
watchUntilTruthy,
} from '@/utils';
import { AE_FAUCET_URL } from '@/protocols/aeternity/config';
import { buildSimplexLink } from '@/protocols/aeternity/helpers';
import { ProtocolAdapterFactory } from '@/lib/ProtocolAdapterFactory';
import { useStorageRef } from './composablesHelpers';

let isIdxInitialized = false;
const protocolNextAccountIdx = ref<ProtocolRecord<number>>(
// Aeternity starts from 1 as we have 1 account as default
PROTOCOLS.reduce((acc, protocol) => ({ ...acc, [protocol]: 0, [PROTOCOL_AETERNITY]: 1 }), {}),
);
const protocolLastActiveGlobalIdx = useStorageRef<ProtocolRecord<number>>({}, 'protocol-last-active-account-idx');
let isInitialized = false;

/**
* TODO in the future the state of the accounts should be stored in this composable
* TODO Implement more safe way of storing mnemonic
* For example by encrypting it with password or pin code.
*/
export function useAccounts({ store }: IDefaultComposableOptions) {
const activeIdx = computed((): number => store.state.accounts?.activeIdx || 0);
const accountsRaw = computed((): IAccountRaw[] => store.state.accounts?.list || []);
const accounts = computed((): IAccount[] => store.getters.accounts || []);
const accountsGroupedByProtocol = computed(
() => accounts.value.reduce(
(acc, account) => ({
...acc,
[account.protocol]: [...(acc?.[account.protocol] || []), account],
}),
{} as ProtocolRecord<IAccount[]>,
),
);
const aeAccounts = computed(
(): IAccount[] => accountsGroupedByProtocol.value[PROTOCOL_AETERNITY] || [],
);
const mnemonic = useStorageRef<string>(
'',
STORAGE_KEYS.mnemonic,
{ backgroundSync: true },
);

const accountsAddressList = computed(() => accounts.value.map((acc) => acc.address));
const accountsRaw = useStorageRef<IAccountRaw[]>(
[],
STORAGE_KEYS.accountsRaw,
{ backgroundSync: true },
);

const activeAccount = computed((): IAccount => accounts.value[activeIdx.value] || {});
const isActiveAccountAe = computed(() => activeAccount.value.protocol === PROTOCOL_AETERNITY);
const activeAccountGlobalIdx = useStorageRef<number>(
0,
STORAGE_KEYS.activeAccountGlobalIdx,
{ backgroundSync: true },
);

const isLoggedIn = computed(
(): boolean => activeAccount.value && Object.keys(activeAccount.value).length > 0,
);
const aeNextAccountIdx = computed(
(): number => protocolNextAccountIdx.value[PROTOCOL_AETERNITY] || 0,
);
const protocolLastActiveGlobalIdx = useStorageRef<ProtocolRecord<number>>(
{},
STORAGE_KEYS.protocolLastActiveAccountIdx,
{ backgroundSync: true },
);

/**
* Accounts data formatted as the form select options
*/
function prepareAccountSelectOptions(accountList: IAccount[]): IFormSelectOption[] {
return accountList.map((acc) => ({
text: getAccountNameToDisplay(acc),
value: acc.address,
address: acc.address,
name: acc.name,
idx: acc.idx,
protocol: acc.protocol || PROTOCOL_AETERNITY,
globalIdx: acc.globalIdx,
}));
const mnemonicSeed = computed(() => mnemonic.value ? mnemonicToSeed(mnemonic.value) : null);

const accounts = computed((): IAccount[] => {
if (!mnemonic.value || !accountsRaw.value?.length) {
return [];
}

const accountsSelectOptions = computed(() => prepareAccountSelectOptions(accounts.value));
const idxList = PROTOCOLS.reduce(
(list, protocol) => ({ ...list, [protocol]: 0 }),
{} as Record<Protocol, number>,
);

const aeAccountsSelectOptions = computed(() => prepareAccountSelectOptions(aeAccounts.value));
return accountsRaw.value
.map((account, globalIdx) => {
const idx = idxList[account.protocol];
const hdWallet = ProtocolAdapterFactory
.getAdapter(account.protocol)
// Type `any` here is used only to satisfy the account address type differences
// TODO remove `any` when IAccount.address will be set to `string`.
.getHdWalletAccountFromMnemonicSeed(mnemonicSeed.value, idx) as any;

const activeAccountSimplexLink = computed(() => buildSimplexLink(activeAccount.value.address));
idxList[account.protocol] += 1;

const activeAccountFaucetUrl = computed(() => `${AE_FAUCET_URL}?address=${activeAccount.value.address}`);
return {
globalIdx,
idx,
...account,
...hdWallet,
};
});
});

const protocolsInUse = computed(
(): Protocol[] => uniq(accounts.value.map((account) => account.protocol)),
);
const activeAccount = computed((): IAccount => accounts.value[activeAccountGlobalIdx.value] || {});

const accountsGroupedByProtocol = computed(
() => accounts.value.reduce(
(acc, account) => ({
...acc,
[account.protocol]: [...(acc?.[account.protocol] || []), account],
}),
{} as ProtocolRecord<IAccount[]>,
),
);

const aeAccounts = computed(
(): IAccount[] => accountsGroupedByProtocol.value[PROTOCOL_AETERNITY] || [],
);

const accountsAddressList = computed(
(): string[] => accounts.value.map(({ address }) => address),
);

const accountsSelectOptions = computed(
(): IFormSelectOption[] => prepareAccountSelectOptions(accounts.value),
);

const aeAccountsSelectOptions = computed(
(): IFormSelectOption[] => prepareAccountSelectOptions(aeAccounts.value),
);

const isLoggedIn = computed(
(): boolean => activeAccount.value && Object.keys(activeAccount.value).length > 0,
);

const protocolsInUse = computed(
(): Protocol[] => uniq(accounts.value.map(({ protocol }) => protocol)),
);

/**
* Composable that handles all operations related to the accounts.
* The app is storing only basic account data in the browser storage.
* The wallets's data is created in fly with the use of computed properties.
*/
export function useAccounts() {
function getAccountByAddress(address: Encoded.AccountAddress): IAccount | undefined {
return accounts.value.find((acc) => acc.address === address);
}
Expand All @@ -98,12 +134,30 @@ export function useAccounts({ store }: IDefaultComposableOptions) {
return accounts.value.find((acc) => acc.globalIdx === globalIdx);
}

function setActiveAccountByGlobalIdx(globalIdx: number = 0) {
const account = getAccountByGlobalIdx(globalIdx);
/**
* Access last used (or current) account of the protocol when accessing features
* related to protocol different than the current account is using.
*/
function getLastActiveProtocolAccount(protocol: Protocol): IAccount | undefined {
if (activeAccount.value.protocol === protocol) {
return activeAccount.value;
}
const lastUsedGlobalIdx = protocolLastActiveGlobalIdx.value[protocol];
return (lastUsedGlobalIdx)
? getAccountByGlobalIdx(lastUsedGlobalIdx)
: accounts.value.find((account) => account.protocol === protocol);
}

// TODO replace with updating local state after removing the Vuex
store.commit('accounts/setActiveIdx', account?.globalIdx || 0);
function getLastProtocolAccount(protocol: Protocol): IAccount | undefined {
const protocolAccounts = accountsGroupedByProtocol.value[protocol];
return (protocolAccounts)
? protocolAccounts[protocolAccounts.length - 1]
: undefined;
}

function setActiveAccountByGlobalIdx(globalIdx: number = 0) {
const account = getAccountByGlobalIdx(globalIdx);
activeAccountGlobalIdx.value = account?.globalIdx || 0;
if (account) {
protocolLastActiveGlobalIdx.value[account.protocol] = account.globalIdx;
}
Expand All @@ -115,47 +169,72 @@ export function useAccounts({ store }: IDefaultComposableOptions) {
}
}

function setActiveAccountByProtocolAndIdx(protocol: Protocol, idx: number) {
const accountFound = accountsGroupedByProtocol.value[protocol]
?.find((account) => account.idx === idx);
if (accountFound) {
setActiveAccountByGlobalIdx(accountFound.globalIdx);
}
}

function setMnemonic(newMnemonic: string) {
mnemonic.value = newMnemonic;
}

function setGeneratedMnemonic() {
setMnemonic(generateMnemonic());
}

/**
* Determine if provided address belongs to any of the current user's accounts.
*/
function isLocalAccountAddress(address: Encoded.AccountAddress): boolean {
return accountsAddressList.value.includes(address);
}

function incrementProtocolNextAccountIdx(protocol: Protocol) {
if (protocolNextAccountIdx.value[protocol] === undefined) {
protocolNextAccountIdx.value[protocol] = 0;
} else {
protocolNextAccountIdx.value[protocol]! += 1;
}
}

function setProtocolNextAccountIdx(value: number, protocol: Protocol) {
protocolNextAccountIdx.value[protocol] = value;
function addRawAccount({
isRestored,
protocol,
}: Omit<IAccountRaw, 'type'>): number {
accountsRaw.value.push({
protocol,
isRestored,
type: ACCOUNT_HD_WALLET,
});
return getLastProtocolAccount(protocol)?.idx || 0;
}

/**
* Access last used (or current) account of the protocol when accessing features
* related to protocol different than the current account is using.
* Establish the last used account index under the actual seed phrase for each of the protocols
* and collect the raw accounts so they can be stored in the browser storage.
*/
function getLastActiveProtocolAccount(protocol: Protocol): IAccount | undefined {
if (activeAccount.value.protocol === protocol) {
return activeAccount.value;
}
const lastUsedGlobalIdx = protocolLastActiveGlobalIdx.value[protocol];
return (lastUsedGlobalIdx)
? getAccountByGlobalIdx(lastUsedGlobalIdx)
: accounts.value.find((account) => account.protocol === protocol);
async function discoverAccounts() {
const lastUsedAccountIndexRegistry: number[] = await Promise.all(
PROTOCOLS.map(
(protocol) => ProtocolAdapterFactory
.getAdapter(protocol)
.discoverLastUsedAccountIndex(mnemonicSeed.value),
),
);

PROTOCOLS.forEach((protocol, index) => {
for (let i = 0; i <= lastUsedAccountIndexRegistry[index]; i += 1) {
addRawAccount({ isRestored: true, protocol });
}
});
}

function resetAccounts() {
mnemonic.value = '';
accountsRaw.value = [];
activeAccountGlobalIdx.value = 0;
}

(async () => {
if (!isIdxInitialized) {
await watchUntilTruthy(() => store.state.isRestored);
Object.entries(accountsGroupedByProtocol.value).forEach(([protocol, protocolAccounts]) => {
setProtocolNextAccountIdx(protocolAccounts?.length || 0, protocol as Protocol);
});
if (!isInitialized) {
await watchUntilTruthy(isLoggedIn);

isIdxInitialized = true;
isInitialized = true;

protocolLastActiveGlobalIdx
.value[activeAccount.value.protocol] = activeAccount.value.globalIdx;
Expand All @@ -169,22 +248,23 @@ export function useAccounts({ store }: IDefaultComposableOptions) {
accountsSelectOptions,
aeAccountsSelectOptions,
accountsRaw,
aeNextAccountIdx,
activeAccount,
activeAccountSimplexLink,
activeAccountFaucetUrl,
activeIdx,
activeAccountGlobalIdx,
isLoggedIn,
mnemonic,
mnemonicSeed,
protocolsInUse,
protocolNextAccountIdx,
isActiveAccountAe,
incrementProtocolNextAccountIdx,
prepareAccountSelectOptions,
discoverAccounts,
isLocalAccountAddress,
addRawAccount,
getAccountByAddress,
getAccountByGlobalIdx,
getLastActiveProtocolAccount,
setActiveAccountByAddress,
setActiveAccountByGlobalIdx,
setActiveAccountByProtocolAndIdx,
setMnemonic,
setGeneratedMnemonic,
resetAccounts,
};
}
Loading