diff --git a/src/account.js b/src/account.js deleted file mode 100644 index be371b72a5..0000000000 --- a/src/account.js +++ /dev/null @@ -1,693 +0,0 @@ -/** - * @flow - * @module account - */ - -// TODO split into folder & subfiles - -import invariant from "invariant"; -import { BigNumber } from "bignumber.js"; -import type { - Account, - AccountRaw, - TokenAccount, - TokenAccountRaw, - AccountIdParams, - Operation, - OperationRaw, - DailyOperations, - CryptoCurrency, - DerivationMode -} from "./types"; -import { asDerivationMode, getTagDerivationMode } from "./derivation"; -import { getCryptoCurrencyById, getTokenById } from "./currencies"; -import { getEnv } from "./env"; - -function startOfDay(t) { - return new Date(t.getFullYear(), t.getMonth(), t.getDate()); -} - -const emptyDailyOperations = { sections: [], completed: true }; - -type GroupOpsByDayOpts = { - count: number, - withTokenAccounts?: boolean -}; - -/** - * @memberof account - */ -export function groupAccountsOperationsByDay( - inputAccounts: Account[] | TokenAccount[] | (Account | TokenAccount)[], - { count, withTokenAccounts }: GroupOpsByDayOpts -): DailyOperations { - const accounts = withTokenAccounts - ? flattenAccounts(inputAccounts) - : inputAccounts; - // Track indexes of account.operations[] for each account - const indexes: number[] = Array(accounts.length).fill(0); - // Track indexes of account.pendingOperations[] for each account - const indexesPending: number[] = Array(accounts.length).fill(0); - // Returns the most recent operation from the account with current indexes - function getNextOperation(): ?Operation { - let bestOp: ?Operation; - let bestOpInfo = { accountI: 0, fromPending: false }; - for (let i = 0; i < accounts.length; i++) { - const account = accounts[i]; - // look in operations - const op = account.operations[indexes[i]]; - if (op && (!bestOp || op.date > bestOp.date)) { - bestOp = op; - bestOpInfo = { accountI: i, fromPending: false }; - } - // look in pending operations - if (account.type === "Account") { - const opP = account.pendingOperations[indexesPending[i]]; - if (opP && (!bestOp || opP.date > bestOp.date)) { - bestOp = opP; - bestOpInfo = { accountI: i, fromPending: true }; - } - } - } - if (bestOp) { - if (bestOpInfo.fromPending) { - indexesPending[bestOpInfo.accountI]++; - } else { - indexes[bestOpInfo.accountI]++; - } - } - return bestOp; - } - - let op = getNextOperation(); - if (!op) return emptyDailyOperations; - const sections = []; - let day = startOfDay(op.date); - let data = []; - for (let i = 0; i < count && op; i++) { - if (op.date < day) { - sections.push({ day, data }); - day = startOfDay(op.date); - data = [op]; - } else { - data.push(op); - } - op = getNextOperation(); - } - sections.push({ day, data }); - return { - sections, - completed: !op - }; -} - -/** - * Return a list of `{count}` operations grouped by day. - * @memberof account - */ -export function groupAccountOperationsByDay( - account: Account | TokenAccount, - arg: GroupOpsByDayOpts -): DailyOperations { - const accounts: (Account | TokenAccount)[] = [account]; - return groupAccountsOperationsByDay(accounts, arg); -} - -function ensureNoColon(value: string, ctx: string): string { - invariant( - !value.includes(":"), - "AccountId '%s' component must not use colon", - ctx - ); - return value; -} - -export function encodeAccountId({ - type, - version, - currencyId, - xpubOrAddress, - derivationMode -}: AccountIdParams) { - return `${ensureNoColon(type, "type")}:${ensureNoColon( - version, - "version" - )}:${ensureNoColon(currencyId, "currencyId")}:${ensureNoColon( - xpubOrAddress, - "xpubOrAddress" - )}:${ensureNoColon(derivationMode, "derivationMode")}`; -} - -export function decodeAccountId(accountId: string): AccountIdParams { - invariant(typeof accountId === "string", "accountId is not a string"); - const splitted = accountId.split(":"); - invariant(splitted.length === 5, "invalid size for accountId"); - const [type, version, currencyId, xpubOrAddress, derivationMode] = splitted; - return { - type, - version, - currencyId, - xpubOrAddress, - derivationMode: asDerivationMode(derivationMode) - }; -} - -// you can pass account because type is shape of Account -// wallet name is a lib-core concept that usually identify a pool of accounts with the same (seed, cointype, derivation scheme) config. -export function getWalletName({ - seedIdentifier, - derivationMode, - currency -}: { - seedIdentifier: string, - derivationMode: DerivationMode, - currency: CryptoCurrency -}) { - return `${seedIdentifier}_${currency.id}_${derivationMode}`; -} - -const MAX_ACCOUNT_NAME_SIZE = 50; - -export const getAccountPlaceholderName = ({ - currency, - index, - derivationMode -}: { - currency: CryptoCurrency, - index: number, - derivationMode: DerivationMode -}) => { - const tag = getTagDerivationMode(currency, derivationMode); - return `${currency.name} ${index + 1}${tag ? ` (${tag})` : ""}`; -}; - -// An account is empty if there is no operations AND balance is zero. -// balance can be non-zero in edgecases, for instance: -// - Ethereum contract only funds (api limitations) -// - Ripple node that don't show all ledgers and if you have very old txs - -export const isAccountEmpty = (a: Account | TokenAccount): boolean => - a.operations.length === 0 && a.balance.isZero(); - -export const getNewAccountPlaceholderName = getAccountPlaceholderName; // same naming - -export const validateNameEdition = (account: Account, name: ?string): string => - ( - (name || account.name || "").replace(/\s+/g, " ").trim() || - account.name || - getAccountPlaceholderName(account) - ).slice(0, MAX_ACCOUNT_NAME_SIZE); - -export type SortAccountsParam = { - accounts: Account[], - accountsBtcBalance: BigNumber[], - orderAccounts: string -}; - -type SortMethod = "name" | "balance"; - -const sortMethod: { [_: SortMethod]: (SortAccountsParam) => string[] } = { - balance: ({ accounts, accountsBtcBalance }) => - accounts - .map((a, i) => [a.id, accountsBtcBalance[i] || BigNumber(-1), a.name]) - .sort((a, b) => { - const numOrder = a[1].minus(b[1]).toNumber(); - if (numOrder === 0) { - return a[2].localeCompare(b[2]); - } - - return numOrder; - }) - .map(o => o[0]), - - name: ({ accounts }) => - accounts - .slice(0) - .sort((a, b) => a.name.localeCompare(b.name)) - .map(a => a.id) -}; - -export const reorderTokenAccountsByCountervalues = (rates: { - [ticker: string]: number // rates by ticker (on the same countervalue reference) -}) => (tokenAccounts: TokenAccount[]): TokenAccount[] => { - const meta = tokenAccounts - .map((ta, index) => ({ - price: ta.balance.times(rates[ta.token.ticker] || 0).toNumber(), - ticker: ta.token.ticker, - index - })) - .sort((a, b) => { - if (a.price === b.price) { - return a.ticker.localeCompare(b.ticker); - } - return b.price - a.price; - }); - if (meta.every((m, i) => m.index === i)) { - // account ordering is preserved, we keep the same array reference (this should happen most of the time) - return tokenAccounts; - } - // otherwise, need to reorder - return meta.map(m => tokenAccounts[m.index]); -}; - -// high level utility that uses reorderTokenAccountsByCountervalues and keep reference if unchanged -export const reorderAccountByCountervalues = (rates: { - [ticker: string]: number // rates by ticker (on the same countervalue reference) -}) => (account: Account): Account => { - if (!account.tokenAccounts) return account; - const tokenAccounts = reorderTokenAccountsByCountervalues(rates)( - account.tokenAccounts - ); - if (tokenAccounts === account.tokenAccounts) return account; - return { ...account, tokenAccounts }; -}; - -export function sortAccounts(param: SortAccountsParam) { - const [order, sort] = param.orderAccounts.split("|"); - if (order === "name" || order === "balance") { - const ids = sortMethod[order](param); - if (sort === "desc") { - ids.reverse(); - } - return ids; - } - return null; -} - -export const shouldShowNewAccount = ( - currency: CryptoCurrency, - derivationMode: DerivationMode -) => - derivationMode === "" - ? !!getEnv("SHOW_LEGACY_NEW_ACCOUNT") || !currency.supportsSegwit - : derivationMode === "segwit" || derivationMode === "native_segwit"; - -export const inferSubOperations = ( - txHash: string, - tokenAccounts: TokenAccount[] -): Operation[] => { - const all = []; - for (let i = 0; i < tokenAccounts.length; i++) { - const ta = tokenAccounts[i]; - for (let j = 0; j < ta.operations.length; j++) { - const op = ta.operations[j]; - if (op.hash === txHash) { - all.push(op); - } - } - } - return all; -}; - -export const toOperationRaw = ({ - date, - value, - fee, - subOperations, // eslint-disable-line - ...op -}: Operation): OperationRaw => ({ - ...op, - date: date.toISOString(), - value: value.toString(), - fee: fee.toString() -}); - -export const fromOperationRaw = ( - { date, value, fee, extra, ...op }: OperationRaw, - accountId: string, - tokenAccounts?: ?(TokenAccount[]) -): Operation => { - const res: $Exact = { - ...op, - accountId, - date: new Date(date), - value: BigNumber(value), - fee: BigNumber(fee), - extra: extra || {} - }; - - if (tokenAccounts) { - res.subOperations = inferSubOperations(op.hash, tokenAccounts); - } - - return res; -}; - -export function fromTokenAccountRaw(raw: TokenAccountRaw): TokenAccount { - const { id, parentId, tokenId, operations, balance } = raw; - const token = getTokenById(tokenId); - const convertOperation = op => fromOperationRaw(op, id); - return { - type: "TokenAccount", - id, - parentId, - token, - balance: BigNumber(balance), - operations: operations.map(convertOperation) - }; -} - -export function toTokenAccountRaw(raw: TokenAccount): TokenAccountRaw { - const { id, parentId, token, operations, balance } = raw; - return { - id, - parentId, - tokenId: token.id, - balance: balance.toString(), - operations: operations.map(toOperationRaw) - }; -} - -export function fromAccountRaw(rawAccount: AccountRaw): Account { - const { - id, - seedIdentifier, - derivationMode, - index, - xpub, - freshAddress, - freshAddressPath, - name, - blockHeight, - endpointConfig, - currencyId, - unitMagnitude, - operations, - pendingOperations, - lastSyncDate, - balance, - tokenAccounts: tokenAccountsRaw - } = rawAccount; - - const tokenAccounts = - tokenAccountsRaw && tokenAccountsRaw.map(fromTokenAccountRaw); - - const currency = getCryptoCurrencyById(currencyId); - - const unit = - currency.units.find(u => u.magnitude === unitMagnitude) || - currency.units[0]; - - const convertOperation = op => fromOperationRaw(op, id, tokenAccounts); - - const res: $Exact = { - type: "Account", - id, - seedIdentifier, - derivationMode, - index, - freshAddress, - freshAddressPath, - name, - blockHeight, - balance: BigNumber(balance), - operations: (operations || []).map(convertOperation), - pendingOperations: (pendingOperations || []).map(convertOperation), - unit, - currency, - lastSyncDate: new Date(lastSyncDate || 0) - }; - - if (xpub) { - res.xpub = xpub; - } - - if (endpointConfig) { - res.endpointConfig = endpointConfig; - } - - if (tokenAccounts) { - res.tokenAccounts = tokenAccounts; - } - - return res; -} - -export function toAccountRaw({ - id, - seedIdentifier, - xpub, - name, - derivationMode, - index, - freshAddress, - freshAddressPath, - blockHeight, - currency, - operations, - pendingOperations, - unit, - lastSyncDate, - balance, - tokenAccounts, - endpointConfig -}: Account): AccountRaw { - const res: $Exact = { - id, - seedIdentifier, - name, - derivationMode, - index, - freshAddress, - freshAddressPath, - blockHeight, - operations: operations.map(toOperationRaw), - pendingOperations: pendingOperations.map(toOperationRaw), - currencyId: currency.id, - unitMagnitude: unit.magnitude, - lastSyncDate: lastSyncDate.toISOString(), - balance: balance.toString() - }; - if (endpointConfig) { - res.endpointConfig = endpointConfig; - } - if (xpub) { - res.xpub = xpub; - } - if (tokenAccounts) { - res.tokenAccounts = tokenAccounts.map(toTokenAccountRaw); - } - return res; -} - -// clear account to a bare minimal version that can be restored via sync -// will preserve the balance to avoid user panic -export function clearAccount(account: T): T { - if (account.type === "TokenAccount") { - return { - ...account, - operations: [] - }; - } - - return { - ...account, - lastSyncDate: new Date(0), - operations: [], - pendingOperations: [], - tokenAccounts: - account.tokenAccounts && account.tokenAccounts.map(clearAccount) - }; -} - -export function flattenAccounts( - topAccounts: Account[] | TokenAccount[] | (Account | TokenAccount)[] -): (Account | TokenAccount)[] { - const accounts = []; - for (let i = 0; i < topAccounts.length; i++) { - const account = topAccounts[i]; - accounts.push(account); - if (account.type === "Account") { - const tokenAccounts = account.tokenAccounts || []; - for (let j = 0; j < tokenAccounts.length; j++) { - accounts.push(tokenAccounts[j]); - } - } - } - return accounts; -} - -export function canBeMigrated(account: Account) { - const { type, currencyId } = decodeAccountId(account.id); - // at the moment migrations requires experimental libcore - if (!getEnv("EXPERIMENTAL_LIBCORE")) return false; - return type === "ethereumjs" && currencyId === "ethereum"; // TODO remove currencyId match -} - -// attempt to find an account in scanned accounts that satisfy a migration -export function findAccountMigration( - account: Account, - scannedAccounts: Account[] -): ?Account { - if (!canBeMigrated(account)) return; - const { type } = decodeAccountId(account.id); - if (type === "ethereumjs") { - return scannedAccounts.find(a => a.freshAddress === account.freshAddress); - } -} - -export type AddAccountsSection = { - id: string, - selectable: boolean, - defaultSelected: boolean, - data: Account[] -}; - -export type AddAccountsSectionResult = { - sections: AddAccountsSection[], - alreadyEmptyAccount: ?Account -}; - -/** - * logic that for the Add Accounts sectioned list - */ -export function groupAddAccounts( - existingAccounts: Account[], - scannedAccounts: Account[], - context: { - scanning: boolean - } -): AddAccountsSectionResult { - const importedAccounts = []; - const importableAccounts = []; - const creatableAccounts = []; - const migrateAccounts = []; - let alreadyEmptyAccount; - - const scannedAccountsWithoutMigrate = [...scannedAccounts]; - existingAccounts.forEach(existingAccount => { - const migrate = findAccountMigration( - existingAccount, - scannedAccountsWithoutMigrate - ); - if (migrate) { - migrateAccounts.push({ - ...migrate, - name: existingAccount.name - }); - const index = scannedAccountsWithoutMigrate.indexOf(migrate); - if (index !== -1) { - scannedAccountsWithoutMigrate[index] = - scannedAccountsWithoutMigrate[ - scannedAccountsWithoutMigrate.length - 1 - ]; - scannedAccountsWithoutMigrate.pop(); - } - } - }); - - scannedAccountsWithoutMigrate.forEach(acc => { - const existingAccount = existingAccounts.find(a => a.id === acc.id); - const empty = isAccountEmpty(acc); - if (existingAccount) { - if (empty) { - alreadyEmptyAccount = existingAccount; - } - importedAccounts.push(existingAccount); - } else if (empty) { - creatableAccounts.push(acc); - } else { - importableAccounts.push(acc); - } - }); - - const sections = []; - - if (importableAccounts.length) { - sections.push({ - id: "importable", - selectable: true, - defaultSelected: true, - data: importableAccounts - }); - } - if (migrateAccounts.length) { - sections.push({ - id: "migrate", - selectable: true, - defaultSelected: true, - data: migrateAccounts - }); - } - if (!context.scanning || creatableAccounts.length) { - // NB if data is empty, need to do custom placeholder that depends on alreadyEmptyAccount - sections.push({ - id: "creatable", - selectable: true, - defaultSelected: false, - data: creatableAccounts - }); - } - - if (importedAccounts.length) { - sections.push({ - id: "imported", - selectable: false, - defaultSelected: false, - data: importedAccounts - }); - } - - return { - sections, - alreadyEmptyAccount - }; -} - -export type AddAccountsProps = { - existingAccounts: Account[], - scannedAccounts: Account[], - selectedIds: string[], - renamings: { [_: string]: string } -}; - -export function addAccounts({ - scannedAccounts, - existingAccounts, - selectedIds, - renamings -}: AddAccountsProps): Account[] { - const newAccounts = []; - - // scanned accounts that was selected - const selected = scannedAccounts.filter(a => selectedIds.includes(a.id)); - - // we'll search for potential migration and append to newAccounts - existingAccounts.forEach(existing => { - const migration = findAccountMigration(existing, selected); - if (migration) { - if (!newAccounts.includes(migration)) { - newAccounts.push(migration); - const index = selected.indexOf(migration); - if (index !== -1) { - selected[index] = selected[selected.length - 1]; - selected.pop(); - } - } - } else { - // we'll try to find an updated version of the existing account as opportunity to refresh the operations - const update = selected.find(a => a.id === existing.id); - if (update) { - // preserve existing name - newAccounts.push({ ...update, name: existing.name }); - } else { - newAccounts.push(existing); - } - } - }); - - // append the new accounts - selected.forEach(acc => { - const alreadyThere = newAccounts.find(a => a.id === acc.id); - if (!alreadyThere) { - newAccounts.push(acc); - } - }); - - // apply the renaming - return newAccounts.map(a => { - const name = validateNameEdition(a, renamings[a.id]); - if (name) return { ...a, name }; - return a; - }); -} diff --git a/src/account/accountId.js b/src/account/accountId.js new file mode 100644 index 0000000000..1d68001acc --- /dev/null +++ b/src/account/accountId.js @@ -0,0 +1,57 @@ +// @flow +import invariant from "invariant"; +import type { AccountIdParams, CryptoCurrency, DerivationMode } from "../types"; +import { asDerivationMode } from "../derivation"; + +function ensureNoColon(value: string, ctx: string): string { + invariant( + !value.includes(":"), + "AccountId '%s' component must not use colon", + ctx + ); + return value; +} + +export function encodeAccountId({ + type, + version, + currencyId, + xpubOrAddress, + derivationMode +}: AccountIdParams) { + return `${ensureNoColon(type, "type")}:${ensureNoColon( + version, + "version" + )}:${ensureNoColon(currencyId, "currencyId")}:${ensureNoColon( + xpubOrAddress, + "xpubOrAddress" + )}:${ensureNoColon(derivationMode, "derivationMode")}`; +} + +export function decodeAccountId(accountId: string): AccountIdParams { + invariant(typeof accountId === "string", "accountId is not a string"); + const splitted = accountId.split(":"); + invariant(splitted.length === 5, "invalid size for accountId"); + const [type, version, currencyId, xpubOrAddress, derivationMode] = splitted; + return { + type, + version, + currencyId, + xpubOrAddress, + derivationMode: asDerivationMode(derivationMode) + }; +} + +// you can pass account because type is shape of Account +// wallet name is a lib-core concept that usually identify a pool of accounts with the same (seed, cointype, derivation scheme) config. +export function getWalletName({ + seedIdentifier, + derivationMode, + currency +}: { + seedIdentifier: string, + derivationMode: DerivationMode, + currency: CryptoCurrency +}) { + return `${seedIdentifier}_${currency.id}_${derivationMode}`; +} diff --git a/src/account/accountName.js b/src/account/accountName.js new file mode 100644 index 0000000000..4cebd70239 --- /dev/null +++ b/src/account/accountName.js @@ -0,0 +1,27 @@ +// @flow +import type { Account, CryptoCurrency, DerivationMode } from "../types"; +import { getTagDerivationMode } from "../derivation"; + +const MAX_ACCOUNT_NAME_SIZE = 50; + +export const getAccountPlaceholderName = ({ + currency, + index, + derivationMode +}: { + currency: CryptoCurrency, + index: number, + derivationMode: DerivationMode +}) => { + const tag = getTagDerivationMode(currency, derivationMode); + return `${currency.name} ${index + 1}${tag ? ` (${tag})` : ""}`; +}; + +export const getNewAccountPlaceholderName = getAccountPlaceholderName; // same naming + +export const validateNameEdition = (account: Account, name: ?string): string => + ( + (name || account.name || "").replace(/\s+/g, " ").trim() || + account.name || + getAccountPlaceholderName(account) + ).slice(0, MAX_ACCOUNT_NAME_SIZE); diff --git a/src/account/addAccounts.js b/src/account/addAccounts.js new file mode 100644 index 0000000000..7971ae7f93 --- /dev/null +++ b/src/account/addAccounts.js @@ -0,0 +1,199 @@ +// @flow +import type { Account, CryptoCurrency, DerivationMode } from "../types"; +import { getEnv } from "../env"; +import { validateNameEdition } from "./accountName"; +import { isAccountEmpty } from "./helpers"; +import { decodeAccountId } from "./accountId"; + +export const shouldShowNewAccount = ( + currency: CryptoCurrency, + derivationMode: DerivationMode +) => + derivationMode === "" + ? !!getEnv("SHOW_LEGACY_NEW_ACCOUNT") || !currency.supportsSegwit + : derivationMode === "segwit" || derivationMode === "native_segwit"; + +export function canBeMigrated(account: Account) { + const { type, currencyId } = decodeAccountId(account.id); + // at the moment migrations requires experimental libcore + if (!getEnv("EXPERIMENTAL_LIBCORE")) return false; + return type === "ethereumjs" && currencyId === "ethereum"; // TODO remove currencyId match +} + +// attempt to find an account in scanned accounts that satisfy a migration +export function findAccountMigration( + account: Account, + scannedAccounts: Account[] +): ?Account { + if (!canBeMigrated(account)) return; + const { type } = decodeAccountId(account.id); + if (type === "ethereumjs") { + return scannedAccounts.find(a => a.freshAddress === account.freshAddress); + } +} + +export type AddAccountsSection = { + id: string, + selectable: boolean, + defaultSelected: boolean, + data: Account[] +}; + +export type AddAccountsSectionResult = { + sections: AddAccountsSection[], + alreadyEmptyAccount: ?Account +}; + +/** + * logic that for the Add Accounts sectioned list + */ +export function groupAddAccounts( + existingAccounts: Account[], + scannedAccounts: Account[], + context: { + scanning: boolean + } +): AddAccountsSectionResult { + const importedAccounts = []; + const importableAccounts = []; + const creatableAccounts = []; + const migrateAccounts = []; + let alreadyEmptyAccount; + + const scannedAccountsWithoutMigrate = [...scannedAccounts]; + existingAccounts.forEach(existingAccount => { + const migrate = findAccountMigration( + existingAccount, + scannedAccountsWithoutMigrate + ); + if (migrate) { + migrateAccounts.push({ + ...migrate, + name: existingAccount.name + }); + const index = scannedAccountsWithoutMigrate.indexOf(migrate); + if (index !== -1) { + scannedAccountsWithoutMigrate[index] = + scannedAccountsWithoutMigrate[ + scannedAccountsWithoutMigrate.length - 1 + ]; + scannedAccountsWithoutMigrate.pop(); + } + } + }); + + scannedAccountsWithoutMigrate.forEach(acc => { + const existingAccount = existingAccounts.find(a => a.id === acc.id); + const empty = isAccountEmpty(acc); + if (existingAccount) { + if (empty) { + alreadyEmptyAccount = existingAccount; + } + importedAccounts.push(existingAccount); + } else if (empty) { + creatableAccounts.push(acc); + } else { + importableAccounts.push(acc); + } + }); + + const sections = []; + + if (importableAccounts.length) { + sections.push({ + id: "importable", + selectable: true, + defaultSelected: true, + data: importableAccounts + }); + } + if (migrateAccounts.length) { + sections.push({ + id: "migrate", + selectable: true, + defaultSelected: true, + data: migrateAccounts + }); + } + if (!context.scanning || creatableAccounts.length) { + // NB if data is empty, need to do custom placeholder that depends on alreadyEmptyAccount + sections.push({ + id: "creatable", + selectable: true, + defaultSelected: false, + data: creatableAccounts + }); + } + + if (importedAccounts.length) { + sections.push({ + id: "imported", + selectable: false, + defaultSelected: false, + data: importedAccounts + }); + } + + return { + sections, + alreadyEmptyAccount + }; +} + +export type AddAccountsProps = { + existingAccounts: Account[], + scannedAccounts: Account[], + selectedIds: string[], + renamings: { [_: string]: string } +}; + +export function addAccounts({ + scannedAccounts, + existingAccounts, + selectedIds, + renamings +}: AddAccountsProps): Account[] { + const newAccounts = []; + + // scanned accounts that was selected + const selected = scannedAccounts.filter(a => selectedIds.includes(a.id)); + + // we'll search for potential migration and append to newAccounts + existingAccounts.forEach(existing => { + const migration = findAccountMigration(existing, selected); + if (migration) { + if (!newAccounts.includes(migration)) { + newAccounts.push(migration); + const index = selected.indexOf(migration); + if (index !== -1) { + selected[index] = selected[selected.length - 1]; + selected.pop(); + } + } + } else { + // we'll try to find an updated version of the existing account as opportunity to refresh the operations + const update = selected.find(a => a.id === existing.id); + if (update) { + // preserve existing name + newAccounts.push({ ...update, name: existing.name }); + } else { + newAccounts.push(existing); + } + } + }); + + // append the new accounts + selected.forEach(acc => { + const alreadyThere = newAccounts.find(a => a.id === acc.id); + if (!alreadyThere) { + newAccounts.push(acc); + } + }); + + // apply the renaming + return newAccounts.map(a => { + const name = validateNameEdition(a, renamings[a.id]); + if (name) return { ...a, name }; + return a; + }); +} diff --git a/src/account/groupOperations.js b/src/account/groupOperations.js new file mode 100644 index 0000000000..b107dde978 --- /dev/null +++ b/src/account/groupOperations.js @@ -0,0 +1,98 @@ +// @flow +import type { + Account, + TokenAccount, + Operation, + DailyOperations +} from "../types"; +import { flattenAccounts } from "./helpers"; + +function startOfDay(t) { + return new Date(t.getFullYear(), t.getMonth(), t.getDate()); +} + +const emptyDailyOperations = { sections: [], completed: true }; + +type GroupOpsByDayOpts = { + count: number, + withTokenAccounts?: boolean +}; + +/** + * @memberof account + */ +export function groupAccountsOperationsByDay( + inputAccounts: Account[] | TokenAccount[] | (Account | TokenAccount)[], + { count, withTokenAccounts }: GroupOpsByDayOpts +): DailyOperations { + const accounts = withTokenAccounts + ? flattenAccounts(inputAccounts) + : inputAccounts; + // Track indexes of account.operations[] for each account + const indexes: number[] = Array(accounts.length).fill(0); + // Track indexes of account.pendingOperations[] for each account + const indexesPending: number[] = Array(accounts.length).fill(0); + // Returns the most recent operation from the account with current indexes + function getNextOperation(): ?Operation { + let bestOp: ?Operation; + let bestOpInfo = { accountI: 0, fromPending: false }; + for (let i = 0; i < accounts.length; i++) { + const account = accounts[i]; + // look in operations + const op = account.operations[indexes[i]]; + if (op && (!bestOp || op.date > bestOp.date)) { + bestOp = op; + bestOpInfo = { accountI: i, fromPending: false }; + } + // look in pending operations + if (account.type === "Account") { + const opP = account.pendingOperations[indexesPending[i]]; + if (opP && (!bestOp || opP.date > bestOp.date)) { + bestOp = opP; + bestOpInfo = { accountI: i, fromPending: true }; + } + } + } + if (bestOp) { + if (bestOpInfo.fromPending) { + indexesPending[bestOpInfo.accountI]++; + } else { + indexes[bestOpInfo.accountI]++; + } + } + return bestOp; + } + + let op = getNextOperation(); + if (!op) return emptyDailyOperations; + const sections = []; + let day = startOfDay(op.date); + let data = []; + for (let i = 0; i < count && op; i++) { + if (op.date < day) { + sections.push({ day, data }); + day = startOfDay(op.date); + data = [op]; + } else { + data.push(op); + } + op = getNextOperation(); + } + sections.push({ day, data }); + return { + sections, + completed: !op + }; +} + +/** + * Return a list of `{count}` operations grouped by day. + * @memberof account + */ +export function groupAccountOperationsByDay( + account: Account | TokenAccount, + arg: GroupOpsByDayOpts +): DailyOperations { + const accounts: (Account | TokenAccount)[] = [account]; + return groupAccountsOperationsByDay(accounts, arg); +} diff --git a/src/account/helpers.js b/src/account/helpers.js new file mode 100644 index 0000000000..a8917c8916 --- /dev/null +++ b/src/account/helpers.js @@ -0,0 +1,42 @@ +// @flow +import type { Account, TokenAccount } from "../types"; + +export const isAccountEmpty = (a: Account | TokenAccount): boolean => + a.operations.length === 0 && a.balance.isZero(); + +// clear account to a bare minimal version that can be restored via sync +// will preserve the balance to avoid user panic +export function clearAccount(account: T): T { + if (account.type === "TokenAccount") { + return { + ...account, + operations: [] + }; + } + + return { + ...account, + lastSyncDate: new Date(0), + operations: [], + pendingOperations: [], + tokenAccounts: + account.tokenAccounts && account.tokenAccounts.map(clearAccount) + }; +} + +export function flattenAccounts( + topAccounts: Account[] | TokenAccount[] | (Account | TokenAccount)[] +): (Account | TokenAccount)[] { + const accounts = []; + for (let i = 0; i < topAccounts.length; i++) { + const account = topAccounts[i]; + accounts.push(account); + if (account.type === "Account") { + const tokenAccounts = account.tokenAccounts || []; + for (let j = 0; j < tokenAccounts.length; j++) { + accounts.push(tokenAccounts[j]); + } + } + } + return accounts; +} diff --git a/src/account/index.js b/src/account/index.js new file mode 100644 index 0000000000..e1e6bd6796 --- /dev/null +++ b/src/account/index.js @@ -0,0 +1,76 @@ +// @flow +import type { + AddAccountsSection, + AddAccountsSectionResult +} from "./addAccounts"; +import { isAccountEmpty, clearAccount, flattenAccounts } from "./helpers"; +import { + shouldShowNewAccount, + canBeMigrated, + findAccountMigration, + groupAddAccounts, + addAccounts +} from "./addAccounts"; +import { + inferSubOperations, + toOperationRaw, + fromOperationRaw, + toTokenAccountRaw, + fromTokenAccountRaw, + toAccountRaw, + fromAccountRaw +} from "./serialization"; +import { encodeAccountId, decodeAccountId, getWalletName } from "./accountId"; +import { + getAccountPlaceholderName, + getNewAccountPlaceholderName, + validateNameEdition +} from "./accountName"; +import { + sortAccounts, + sortAccountsComparatorFromOrder, + comparatorSortAccounts, + flattenSortAccounts, + nestedSortAccounts, + reorderAccountByCountervalues, + reorderTokenAccountsByCountervalues +} from "./ordering"; +import { + groupAccountsOperationsByDay, + groupAccountOperationsByDay +} from "./groupOperations"; + +export type { AddAccountsSection, AddAccountsSectionResult }; + +export { + isAccountEmpty, + clearAccount, + flattenAccounts, + shouldShowNewAccount, + canBeMigrated, + findAccountMigration, + groupAddAccounts, + addAccounts, + inferSubOperations, + toOperationRaw, + fromOperationRaw, + toTokenAccountRaw, + fromTokenAccountRaw, + toAccountRaw, + fromAccountRaw, + encodeAccountId, + decodeAccountId, + getWalletName, + getAccountPlaceholderName, + getNewAccountPlaceholderName, + validateNameEdition, + sortAccounts, + sortAccountsComparatorFromOrder, + comparatorSortAccounts, + flattenSortAccounts, + nestedSortAccounts, + reorderAccountByCountervalues, + reorderTokenAccountsByCountervalues, + groupAccountsOperationsByDay, + groupAccountOperationsByDay +}; diff --git a/src/account/ordering.js b/src/account/ordering.js new file mode 100644 index 0000000000..156034ff4f --- /dev/null +++ b/src/account/ordering.js @@ -0,0 +1,180 @@ +// @flow +import { BigNumber } from "bignumber.js"; +import type { + Account, + TokenAccount, + TokenCurrency, + CryptoCurrency +} from "../types"; +import { flattenAccounts } from "./helpers"; + +type AccountComparator = ( + a: Account | TokenAccount, + b: Account | TokenAccount +) => number; + +const sortNameLense = (a: Account | TokenAccount): string => + a.type === "Account" ? a.name : a.token.name; + +export const sortAccountsComparatorFromOrder = ( + orderAccounts: string, + calculateCountervalue: ( + currency: TokenCurrency | CryptoCurrency, + value: BigNumber + ) => ?BigNumber +): AccountComparator => { + const [order, sort] = orderAccounts.split("|"); + const ascValue = sort === "desc" ? -1 : 1; + if (order === "name") { + return (a, b) => + ascValue * sortNameLense(a).localeCompare(sortNameLense(b)); + } + const cvCaches = {}; + const lazyCalcCV = a => { + if (a.id in cvCaches) return cvCaches[a.id]; + const v = + calculateCountervalue( + a.type === "Account" ? a.currency : a.token, + a.balance + ) || BigNumber(-1); + cvCaches[a.id] = v; + return v; + }; + return (a, b) => { + const diff = + ascValue * + lazyCalcCV(a) + .minus(lazyCalcCV(b)) + .toNumber(); + if (diff === 0) return sortNameLense(a).localeCompare(sortNameLense(b)); + return diff; + }; +}; + +export const comparatorSortAccounts = ( + accounts: TA[], + comparator: AccountComparator +): TA[] => { + const meta = accounts + .map((ta, index) => ({ + account: ta, + index + })) + .sort((a, b) => comparator(a.account, b.account)); + if (meta.every((m, i) => m.index === i)) { + // account ordering is preserved, we keep the same array reference (this should happen most of the time) + return accounts; + } + // otherwise, need to reorder + return meta.map(m => accounts[m.index]); +}; + +// flatten accounts and sort between them (used for grid mode) +export const flattenSortAccounts = ( + accounts: Account[], + comparator: AccountComparator +): (Account | TokenAccount)[] => { + return comparatorSortAccounts(flattenAccounts(accounts), comparator); +}; + +// sort top level accounts and the inner token accounts if necessary (used for lists) +export const nestedSortAccounts = ( + topAccounts: Account[], + comparator: AccountComparator +): Account[] => { + let oneAccountHaveChanged = false; + // first of all we sort the inner token accounts + const accounts = topAccounts.map(a => { + if (!a.tokenAccounts) return a; + const tokenAccounts = comparatorSortAccounts(a.tokenAccounts, comparator); + if (tokenAccounts === a.tokenAccounts) return a; + oneAccountHaveChanged = true; + return { + ...a, + tokenAccounts + }; + }); + // then we sort again between them + return comparatorSortAccounts( + oneAccountHaveChanged ? accounts : topAccounts, + comparator + ); +}; + +// // // // BELOW IS LEGACY // // // // + +export type SortAccountsParam = { + accounts: Account[], + accountsBtcBalance: BigNumber[], + orderAccounts: string +}; + +type SortMethod = "name" | "balance"; + +const sortMethod: { [_: SortMethod]: (SortAccountsParam) => string[] } = { + balance: ({ accounts, accountsBtcBalance }) => + accounts + .map((a, i) => [a.id, accountsBtcBalance[i] || BigNumber(-1), a.name]) + .sort((a, b) => { + const numOrder = a[1].minus(b[1]).toNumber(); + if (numOrder === 0) { + return a[2].localeCompare(b[2]); + } + + return numOrder; + }) + .map(o => o[0]), + + name: ({ accounts }) => + accounts + .slice(0) + .sort((a, b) => a.name.localeCompare(b.name)) + .map(a => a.id) +}; + +export const reorderTokenAccountsByCountervalues = (rates: { + [ticker: string]: number // rates by ticker (on the same countervalue reference) +}) => (tokenAccounts: TokenAccount[]): TokenAccount[] => { + const meta = tokenAccounts + .map((ta, index) => ({ + price: ta.balance.times(rates[ta.token.ticker] || 0).toNumber(), + ticker: ta.token.ticker, + index + })) + .sort((a, b) => { + if (a.price === b.price) { + return a.ticker.localeCompare(b.ticker); + } + return b.price - a.price; + }); + if (meta.every((m, i) => m.index === i)) { + // account ordering is preserved, we keep the same array reference (this should happen most of the time) + return tokenAccounts; + } + // otherwise, need to reorder + return meta.map(m => tokenAccounts[m.index]); +}; + +// high level utility that uses reorderTokenAccountsByCountervalues and keep reference if unchanged +export const reorderAccountByCountervalues = (rates: { + [ticker: string]: number // rates by ticker (on the same countervalue reference) +}) => (account: Account): Account => { + if (!account.tokenAccounts) return account; + const tokenAccounts = reorderTokenAccountsByCountervalues(rates)( + account.tokenAccounts + ); + if (tokenAccounts === account.tokenAccounts) return account; + return { ...account, tokenAccounts }; +}; + +export function sortAccounts(param: SortAccountsParam) { + const [order, sort] = param.orderAccounts.split("|"); + if (order === "name" || order === "balance") { + const ids = sortMethod[order](param); + if (sort === "desc") { + ids.reverse(); + } + return ids; + } + return null; +} diff --git a/src/account/serialization.js b/src/account/serialization.js new file mode 100644 index 0000000000..57a2350658 --- /dev/null +++ b/src/account/serialization.js @@ -0,0 +1,199 @@ +// @flow +import { BigNumber } from "bignumber.js"; +import type { + Account, + AccountRaw, + TokenAccount, + TokenAccountRaw, + Operation, + OperationRaw +} from "../types"; +import { getCryptoCurrencyById, getTokenById } from "../currencies"; + +export const toOperationRaw = ({ + date, + value, + fee, + subOperations, // eslint-disable-line + ...op +}: Operation): OperationRaw => ({ + ...op, + date: date.toISOString(), + value: value.toString(), + fee: fee.toString() +}); + +export const inferSubOperations = ( + txHash: string, + tokenAccounts: TokenAccount[] +): Operation[] => { + const all = []; + for (let i = 0; i < tokenAccounts.length; i++) { + const ta = tokenAccounts[i]; + for (let j = 0; j < ta.operations.length; j++) { + const op = ta.operations[j]; + if (op.hash === txHash) { + all.push(op); + } + } + } + return all; +}; + +export const fromOperationRaw = ( + { date, value, fee, extra, ...op }: OperationRaw, + accountId: string, + tokenAccounts?: ?(TokenAccount[]) +): Operation => { + const res: $Exact = { + ...op, + accountId, + date: new Date(date), + value: BigNumber(value), + fee: BigNumber(fee), + extra: extra || {} + }; + + if (tokenAccounts) { + res.subOperations = inferSubOperations(op.hash, tokenAccounts); + } + + return res; +}; + +export function fromTokenAccountRaw(raw: TokenAccountRaw): TokenAccount { + const { id, parentId, tokenId, operations, balance } = raw; + const token = getTokenById(tokenId); + const convertOperation = op => fromOperationRaw(op, id); + return { + type: "TokenAccount", + id, + parentId, + token, + balance: BigNumber(balance), + operations: operations.map(convertOperation) + }; +} + +export function toTokenAccountRaw(raw: TokenAccount): TokenAccountRaw { + const { id, parentId, token, operations, balance } = raw; + return { + id, + parentId, + tokenId: token.id, + balance: balance.toString(), + operations: operations.map(toOperationRaw) + }; +} + +export function fromAccountRaw(rawAccount: AccountRaw): Account { + const { + id, + seedIdentifier, + derivationMode, + index, + xpub, + freshAddress, + freshAddressPath, + name, + blockHeight, + endpointConfig, + currencyId, + unitMagnitude, + operations, + pendingOperations, + lastSyncDate, + balance, + tokenAccounts: tokenAccountsRaw + } = rawAccount; + + const tokenAccounts = + tokenAccountsRaw && tokenAccountsRaw.map(fromTokenAccountRaw); + + const currency = getCryptoCurrencyById(currencyId); + + const unit = + currency.units.find(u => u.magnitude === unitMagnitude) || + currency.units[0]; + + const convertOperation = op => fromOperationRaw(op, id, tokenAccounts); + + const res: $Exact = { + type: "Account", + id, + seedIdentifier, + derivationMode, + index, + freshAddress, + freshAddressPath, + name, + blockHeight, + balance: BigNumber(balance), + operations: (operations || []).map(convertOperation), + pendingOperations: (pendingOperations || []).map(convertOperation), + unit, + currency, + lastSyncDate: new Date(lastSyncDate || 0) + }; + + if (xpub) { + res.xpub = xpub; + } + + if (endpointConfig) { + res.endpointConfig = endpointConfig; + } + + if (tokenAccounts) { + res.tokenAccounts = tokenAccounts; + } + + return res; +} + +export function toAccountRaw({ + id, + seedIdentifier, + xpub, + name, + derivationMode, + index, + freshAddress, + freshAddressPath, + blockHeight, + currency, + operations, + pendingOperations, + unit, + lastSyncDate, + balance, + tokenAccounts, + endpointConfig +}: Account): AccountRaw { + const res: $Exact = { + id, + seedIdentifier, + name, + derivationMode, + index, + freshAddress, + freshAddressPath, + blockHeight, + operations: operations.map(toOperationRaw), + pendingOperations: pendingOperations.map(toOperationRaw), + currencyId: currency.id, + unitMagnitude: unit.magnitude, + lastSyncDate: lastSyncDate.toISOString(), + balance: balance.toString() + }; + if (endpointConfig) { + res.endpointConfig = endpointConfig; + } + if (xpub) { + res.xpub = xpub; + } + if (tokenAccounts) { + res.tokenAccounts = tokenAccounts.map(toTokenAccountRaw); + } + return res; +} diff --git a/src/families/ethereum/libcore-buildTokenAccounts.js b/src/families/ethereum/libcore-buildTokenAccounts.js index 07cc1ac6f2..0686b7e323 100644 --- a/src/families/ethereum/libcore-buildTokenAccounts.js +++ b/src/families/ethereum/libcore-buildTokenAccounts.js @@ -31,7 +31,6 @@ async function buildERC20TokenAccount({ }) ); - // TODO keep reference if no operation have changed, nor id/token/balance const tokenAccount: $Exact = { type: "TokenAccount", id, diff --git a/src/reconciliation.js b/src/reconciliation.js index 8a95ef209a..46379c1450 100644 --- a/src/reconciliation.js +++ b/src/reconciliation.js @@ -1,5 +1,6 @@ // @flow // libcore reconciliation by the React definition. https://reactjs.org/docs/reconciliation.html +// TODO move to account/ import isEqual from "lodash/isEqual"; import { BigNumber } from "bignumber.js"; @@ -243,8 +244,13 @@ export function patchTokenAccount( updatedRaw: TokenAccountRaw ): TokenAccount { // id can change after a sync typically if changing the version or filling more info. in that case we consider all changes. - if (!account || account.id !== updatedRaw.id) + if ( + !account || + account.id !== updatedRaw.id || + account.parentId !== updatedRaw.parentId + ) { return fromTokenAccountRaw(updatedRaw); + } const operations = patchOperations( account.operations,