diff --git a/CHANGELOG.md b/CHANGELOG.md index 23167a308..c4fd4198a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- added: Accept an `onNewTokens` callback from `EdgeCurrencyEngine`. +- added: Emit an `enabledDetectedTokens` event when auto-enabling tokens. +- added: Expose auto-detected tokens as `EdgeCurrencyWallet.detectedTokenIds`. +- changed: Save enabled tokens by their tokenId, not by their currency code. - fixed: Add missing `export` to the `EdgeCorePluginFactory` type definition. ## 1.11.0 (2023-10-18) diff --git a/src/core/account/plugin-api.ts b/src/core/account/plugin-api.ts index 66a356db6..d7f02f5ef 100644 --- a/src/core/account/plugin-api.ts +++ b/src/core/account/plugin-api.ts @@ -93,15 +93,15 @@ export class CurrencyConfig payload: { accountId, pluginId, tokenId: newTokenId, token } }) - // Do we need to tweak enabled tokens? - if (oldToken.currencyCode !== token.currencyCode) { + if (newTokenId !== tokenId) { + // Enable the new token if the tokenId changed: const { wallets } = ai.props.state.currency for (const walletId of Object.keys(wallets)) { const walletState = wallets[walletId] if ( walletState.accountId !== accountId || walletState.pluginId !== pluginId || - !walletState.enabledTokens.includes(oldToken.currencyCode) + !walletState.enabledTokenIds.includes(tokenId) ) { continue } @@ -112,17 +112,15 @@ export class CurrencyConfig type: 'CURRENCY_WALLET_ENABLED_TOKENS_CHANGED', payload: { walletId, - currencyCodes: uniqueStrings( - [...walletState.enabledTokens, token.currencyCode], - [oldToken.currencyCode] + enabledTokenIds: uniqueStrings( + [...walletState.enabledTokenIds, newTokenId], + [tokenId] ) } }) } - } - // Remove the old token if the tokenId changed: - if (newTokenId !== tokenId) { + // Remove the old token if the tokenId changed: ai.props.dispatch({ type: 'ACCOUNT_CUSTOM_TOKEN_REMOVED', payload: { accountId, pluginId, tokenId } diff --git a/src/core/actions.ts b/src/core/actions.ts index 6a917c521..62787bebc 100644 --- a/src/core/actions.ts +++ b/src/core/actions.ts @@ -193,6 +193,23 @@ export type RootAction = txidHashes: TxidHashes } } + | { + // Called when a currency engine fires the onAddressChecked callback. + type: 'CURRENCY_ENGINE_CHANGED_UNACTIVATED_TOKEN_IDS' + payload: { + unactivatedTokenIds: string[] + walletId: string + } + } + | { + // Called when a currency engine fires the onNewTokens callback. + type: 'CURRENCY_ENGINE_DETECTED_TOKENS' + payload: { + detectedTokenIds: string[] + enablingTokenIds: string[] + walletId: string + } + } | { type: 'CURRENCY_ENGINE_GOT_TXS' payload: { @@ -247,7 +264,7 @@ export type RootAction = | { type: 'CURRENCY_WALLET_ENABLED_TOKENS_CHANGED' payload: { - currencyCodes: string[] + enabledTokenIds: string[] walletId: string } } @@ -287,6 +304,14 @@ export type RootAction = walletId: string } } + | { + type: 'CURRENCY_WALLET_LOADED_TOKEN_FILE' + payload: { + detectedTokenIds: string[] + enabledTokenIds: string[] + walletId: string + } + } | { // Called when a currency wallet receives a new name. type: 'CURRENCY_WALLET_NAME_CHANGED' @@ -303,14 +328,6 @@ export type RootAction = walletInfo: EdgeWalletInfo } } - | { - // Called when a currency engine fires the onAddressChecked callback. - type: 'CURRENCY_ENGINE_CHANGED_UNACTIVATED_TOKEN_IDS' - payload: { - unactivatedTokenIds: string[] - walletId: string - } - } | { // Fired when we fetch exchange pairs from some server. type: 'EXCHANGE_PAIRS_FETCHED' diff --git a/src/core/currency/wallet/currency-wallet-api.ts b/src/core/currency/wallet/currency-wallet-api.ts index 9fe764e8c..7f79f57e9 100644 --- a/src/core/currency/wallet/currency-wallet-api.ts +++ b/src/core/currency/wallet/currency-wallet-api.ts @@ -55,7 +55,6 @@ import { } from './currency-wallet-files' import { CurrencyWalletInput } from './currency-wallet-pixie' import { MergedTransaction } from './currency-wallet-reducer' -import { tokenIdsToCurrencyCodes, uniqueStrings } from './enabled-tokens' import { upgradeMemos } from './upgrade-memos' const fakeMetadata = { @@ -197,26 +196,25 @@ export function makeCurrencyWalletApi( // Tokens: async changeEnabledTokenIds(tokenIds: string[]): Promise { - const { dispatch, state, walletId, walletState } = input.props - const { builtinTokens, customTokens } = state.accounts[accountId] - const { currencyInfo } = walletState + const { dispatch, walletId, walletState } = input.props + const { accountId, pluginId } = walletState + const accountState = input.props.state.accounts[accountId] + const allTokens = accountState.allTokens[pluginId] ?? {} + + const enabledTokenIds = tokenIds.filter( + tokenId => allTokens[tokenId] != null + ) dispatch({ type: 'CURRENCY_WALLET_ENABLED_TOKENS_CHANGED', - payload: { - walletId, - currencyCodes: uniqueStrings( - tokenIdsToCurrencyCodes( - builtinTokens[pluginId], - customTokens[pluginId], - currencyInfo, - tokenIds - ) - ) - } + payload: { walletId, enabledTokenIds } }) }, + get detectedTokenIds(): string[] { + return input.props.walletState.detectedTokenIds + }, + get enabledTokenIds(): string[] { return input.props.walletState.enabledTokenIds }, diff --git a/src/core/currency/wallet/currency-wallet-callbacks.ts b/src/core/currency/wallet/currency-wallet-callbacks.ts index 838cf93d1..250bce0d4 100644 --- a/src/core/currency/wallet/currency-wallet-callbacks.ts +++ b/src/core/currency/wallet/currency-wallet-callbacks.ts @@ -19,9 +19,9 @@ import { combineTxWithFile } from './currency-wallet-api' import { asIntegerString } from './currency-wallet-cleaners' import { loadAddressFiles, - loadEnabledTokensFile, loadFiatFile, loadNameFile, + loadTokensFile, loadTxFileNames, setupNewTxMetadata } from './currency-wallet-files' @@ -34,6 +34,7 @@ import { mergeTx, TxidHashes } from './currency-wallet-reducer' +import { uniqueStrings } from './enabled-tokens' let throttleRateLimitMs = 5000 @@ -122,6 +123,39 @@ export function makeCurrencyWalletCallbacks( }) }, + onNewTokens(tokenIds: string[]) { + pushUpdate({ + id: walletId, + action: 'onNewTokens', + updateFunc: () => { + // Before we update redux, figure out what's truly new: + const { detectedTokenIds, enabledTokenIds } = input.props.walletState + const enablingTokenIds = uniqueStrings(tokenIds, [ + ...detectedTokenIds, + ...enabledTokenIds + ]) + + // Update redux: + input.props.dispatch({ + type: 'CURRENCY_ENGINE_DETECTED_TOKENS', + payload: { + detectedTokenIds: tokenIds, + enablingTokenIds, + walletId + } + }) + + // Fire an event to the GUI: + if (enablingTokenIds.length > 0) { + const walletApi = input.props?.walletOutput?.walletApi + if (walletApi != null) { + emit(walletApi, 'enabledDetectedTokens', enablingTokenIds) + } + } + } + }) + }, + onUnactivatedTokenIdsChanged(unactivatedTokenIds: string[]) { pushUpdate({ id: walletId, @@ -333,7 +367,7 @@ export function watchCurrencyWallet(input: CurrencyWalletInput): void { const changes = getStorageWalletLastChanges(props.state, walletId) if (changes !== lastChanges) { lastChanges = changes - await loadEnabledTokensFile(input) + await loadTokensFile(input) await loadFiatFile(input) await loadNameFile(input) await loadTxFileNames(input) diff --git a/src/core/currency/wallet/currency-wallet-cleaners.ts b/src/core/currency/wallet/currency-wallet-cleaners.ts index b7fb21670..35f9ace70 100644 --- a/src/core/currency/wallet/currency-wallet-cleaners.ts +++ b/src/core/currency/wallet/currency-wallet-cleaners.ts @@ -194,12 +194,22 @@ export function asIntegerString(raw: unknown): string { // --------------------------------------------------------------------- /** - * This uses currency codes, since we cannot break the data on disk. - * To fix this one day, we can either migrate to a new file name, - * or we can use `asEither` to switch between this format - * and some new format based on token ID's. + * Old core versions used currency codes instead of tokenId's. */ -export const asEnabledTokensFile = asArray(asString) +export const asLegacyTokensFile = asArray(asString) + +/** + * Stores enabled tokenId's on disk. + */ +export const asTokensFile = asObject({ + // All the tokens that the engine should check. + // This includes both manually-enabled tokens and auto-enabled tokens: + enabledTokenIds: asArray(asString), + + // These tokenId's have been detected on-chain at least once. + // The user can still remove them from the enabled tokens list. + detectedTokenIds: asArray(asString) +}) export const asTransactionFile = asObject({ txid: asString, diff --git a/src/core/currency/wallet/currency-wallet-files.ts b/src/core/currency/wallet/currency-wallet-files.ts index edb72490d..6af0dde89 100644 --- a/src/core/currency/wallet/currency-wallet-files.ts +++ b/src/core/currency/wallet/currency-wallet-files.ts @@ -17,10 +17,11 @@ import { } from '../../storage/storage-selectors' import { combineTxWithFile } from './currency-wallet-api' import { - asEnabledTokensFile, asLegacyAddressFile, asLegacyMapFile, + asLegacyTokensFile, asLegacyTransactionFile, + asTokensFile, asTransactionFile, asWalletFiatFile, asWalletNameFile, @@ -31,16 +32,19 @@ import { } from './currency-wallet-cleaners' import { CurrencyWalletInput } from './currency-wallet-pixie' import { TxFileNames } from './currency-wallet-reducer' +import { currencyCodesToTokenIds } from './enabled-tokens' const CURRENCY_FILE = 'Currency.json' -const ENABLED_TOKENS_FILE = 'EnabledTokens.json' const LEGACY_MAP_FILE = 'fixedLegacyFileNames.json' +const LEGACY_TOKENS_FILE = 'EnabledTokens.json' +const TOKENS_FILE = 'Tokens.json' const WALLET_NAME_FILE = 'WalletName.json' -const enabledTokensFile = makeJsonFile(asEnabledTokensFile) const legacyAddressFile = makeJsonFile(asLegacyAddressFile) const legacyMapFile = makeJsonFile(asLegacyMapFile) +const legacyTokensFile = makeJsonFile(asLegacyTokensFile) const legacyTransactionFile = makeJsonFile(asLegacyTransactionFile) +const tokensFile = makeJsonFile(asTokensFile) const transactionFile = makeJsonFile(asTransactionFile) const walletFiatFile = makeJsonFile(asWalletFiatFile) const walletNameFile = makeJsonFile(asWalletNameFile) @@ -48,14 +52,18 @@ const walletNameFile = makeJsonFile(asWalletNameFile) /** * Updates the enabled tokens on a wallet. */ -export async function changeEnabledTokens( +export async function writeTokensFile( input: CurrencyWalletInput, - currencyCodes: string[] + detectedTokenIds: string[], + enabledTokenIds: string[] ): Promise { const { state, walletId } = input.props const disklet = getStorageWalletDisklet(state, walletId) - await enabledTokensFile.save(disklet, ENABLED_TOKENS_FILE, currencyCodes) + await tokensFile.save(disklet, TOKENS_FILE, { + detectedTokenIds, + enabledTokenIds + }) } /** @@ -146,23 +154,6 @@ export async function setCurrencyWalletFiat( }) } -export async function loadEnabledTokensFile( - input: CurrencyWalletInput -): Promise { - const { dispatch, state, walletId } = input.props - const disklet = getStorageWalletDisklet(state, walletId) - - const clean = await enabledTokensFile.load(disklet, ENABLED_TOKENS_FILE) - if (clean == null) return - - // Future currencyCode to tokenId logic will live here. - - dispatch({ - type: 'CURRENCY_WALLET_ENABLED_TOKENS_CHANGED', - payload: { walletId: input.props.walletId, currencyCodes: clean } - }) -} - /** * Loads the wallet fiat currency file. */ @@ -219,6 +210,47 @@ export async function loadNameFile(input: CurrencyWalletInput): Promise { }) } +/** + * Load the enabled tokens file, with fallback to the legacy file. + */ +export async function loadTokensFile( + input: CurrencyWalletInput +): Promise { + const { dispatch, state, walletId } = input.props + const disklet = getStorageWalletDisklet(state, walletId) + + const clean = await tokensFile.load(disklet, TOKENS_FILE) + if (clean != null) { + dispatch({ + type: 'CURRENCY_WALLET_LOADED_TOKEN_FILE', + payload: { walletId: input.props.walletId, ...clean } + }) + return + } + + const legacyCurrencyCodes = await legacyTokensFile.load( + disklet, + LEGACY_TOKENS_FILE + ) + const { accountId, currencyInfo, pluginId } = input.props.walletState + const accountState = input.props.state.accounts[accountId] + const tokenIds = currencyCodesToTokenIds( + accountState.builtinTokens[pluginId], + accountState.customTokens[pluginId], + currencyInfo, + legacyCurrencyCodes ?? [] + ) + + dispatch({ + type: 'CURRENCY_WALLET_LOADED_TOKEN_FILE', + payload: { + walletId: input.props.walletId, + detectedTokenIds: [], + enabledTokenIds: tokenIds + } + }) +} + /** * Loads transaction metadata files. */ diff --git a/src/core/currency/wallet/currency-wallet-pixie.ts b/src/core/currency/wallet/currency-wallet-pixie.ts index bd72ca402..0f445955b 100644 --- a/src/core/currency/wallet/currency-wallet-pixie.ts +++ b/src/core/currency/wallet/currency-wallet-pixie.ts @@ -39,17 +39,14 @@ import { } from './currency-wallet-callbacks' import { asIntegerString, asPublicKeyFile } from './currency-wallet-cleaners' import { - changeEnabledTokens, loadAddressFiles, - loadEnabledTokensFile, loadFiatFile, loadNameFile, - loadTxFileNames + loadTokensFile, + loadTxFileNames, + writeTokensFile } from './currency-wallet-files' -import { - CurrencyWalletState, - initialEnabledTokens -} from './currency-wallet-reducer' +import { CurrencyWalletState, initialTokenIds } from './currency-wallet-reducer' import { tokenIdsToCurrencyCodes, uniqueStrings } from './enabled-tokens' export interface CurrencyWalletOutput { @@ -94,10 +91,6 @@ export const walletPixie: TamePixie = combinePixies({ input.props.io ) - // We need to know which tokens are enabled, - // so the engine can start in the right state: - await loadEnabledTokensFile(input) - // We need to know which transactions exist, // since new transactions may come in from the network: await loadTxFileNames(input) @@ -114,6 +107,10 @@ export const walletPixie: TamePixie = combinePixies({ payload: { walletInfo: publicWalletInfo, walletId } }) + // We need to know which tokens are enabled, + // so the engine can start in the right state: + await loadTokensFile(input) + // Start the engine: const accountState = state.accounts[accountId] const engine = await plugin.makeCurrencyEngine(publicWalletInfo, { @@ -331,17 +328,23 @@ export const walletPixie: TamePixie = combinePixies({ * we will consolidate those down to a single write to disk. */ tokenSaver(input: CurrencyWalletInput) { - let lastEnabledTokens: string[] = initialEnabledTokens + let lastDetectedTokenIds: string[] = initialTokenIds + let lastEnabledTokenIds: string[] = initialTokenIds return async function update() { - const { enabledTokens } = input.props.walletState - if (enabledTokens !== lastEnabledTokens && enabledTokens != null) { - await changeEnabledTokens(input, enabledTokens).catch(error => - input.props.onError(error) + const { detectedTokenIds, enabledTokenIds } = input.props.walletState + const isChanged = + detectedTokenIds !== lastDetectedTokenIds || + enabledTokenIds !== lastEnabledTokenIds + const isReady = detectedTokenIds != null && enabledTokenIds != null + if (isChanged && isReady) { + await writeTokensFile(input, detectedTokenIds, enabledTokenIds).catch( + error => input.props.onError(error) ) await snooze(100) // Rate limiting } - lastEnabledTokens = enabledTokens + lastDetectedTokenIds = detectedTokenIds + lastEnabledTokenIds = enabledTokenIds } }, @@ -349,7 +352,7 @@ export const walletPixie: TamePixie = combinePixies({ let lastState: CurrencyWalletState | undefined let lastSettings: JsonObject = {} let lastTokens: EdgeTokenMap = {} - let lastEnabledTokenIds: string[] = initialEnabledTokens + let lastEnabledTokenIds: string[] = initialTokenIds return async () => { const { state, walletState, walletOutput } = input.props diff --git a/src/core/currency/wallet/currency-wallet-reducer.ts b/src/core/currency/wallet/currency-wallet-reducer.ts index 7e5ec2853..4b9ae02be 100644 --- a/src/core/currency/wallet/currency-wallet-reducer.ts +++ b/src/core/currency/wallet/currency-wallet-reducer.ts @@ -17,7 +17,7 @@ import { RootAction } from '../../actions' import { findCurrencyPluginId } from '../../plugins/plugins-selectors' import { RootState } from '../../root-reducer' import { TransactionFile } from './currency-wallet-cleaners' -import { currencyCodesToTokenIds, uniqueStrings } from './enabled-tokens' +import { uniqueStrings } from './enabled-tokens' /** Maps from txid hash to file creation date & path. */ export interface TxFileNames { @@ -66,8 +66,8 @@ export interface CurrencyWalletState { readonly allEnabledTokenIds: string[] readonly balances: EdgeBalances readonly currencyInfo: EdgeCurrencyInfo + readonly detectedTokenIds: string[] readonly enabledTokenIds: string[] - readonly enabledTokens: string[] readonly engineFailure: Error | null readonly engineStarted: boolean readonly fiat: string @@ -94,7 +94,8 @@ export interface CurrencyWalletNext { readonly self: CurrencyWalletState } -export const initialEnabledTokens: string[] = [] +// Used for detectedTokenIds & enabledTokenIds: +export const initialTokenIds: string[] = [] const currencyWalletInner = buildReducer< CurrencyWalletState, @@ -144,25 +145,33 @@ const currencyWalletInner = buildReducer< return next.root.plugins.currency[pluginId].currencyInfo }, - enabledTokenIds: memoizeReducer( - next => - next.root.accounts[next.self.accountId].builtinTokens[next.self.pluginId], - next => - next.root.accounts[next.self.accountId].customTokens[next.self.pluginId], - next => next.self.currencyInfo, - next => next.self.enabledTokens, - currencyCodesToTokenIds + detectedTokenIds: sortStringsReducer( + (state = initialTokenIds, action): string[] => { + if (action.type === 'CURRENCY_WALLET_LOADED_TOKEN_FILE') { + return action.payload.detectedTokenIds + } else if (action.type === 'CURRENCY_ENGINE_DETECTED_TOKENS') { + const { detectedTokenIds } = action.payload + return uniqueStrings([...state, ...detectedTokenIds]) + } else if (action.type === 'CURRENCY_ENGINE_CLEARED') { + return [] + } + return state + } ), - enabledTokens(state = initialEnabledTokens, action): string[] { - if (action.type === 'CURRENCY_WALLET_ENABLED_TOKENS_CHANGED') { - const { currencyCodes } = action.payload - // Check for actual changes: - currencyCodes.sort((a, b) => (a === b ? 0 : a > b ? 1 : -1)) - if (!compare(currencyCodes, state)) return currencyCodes + enabledTokenIds: sortStringsReducer( + (state = initialTokenIds, action): string[] => { + if (action.type === 'CURRENCY_WALLET_LOADED_TOKEN_FILE') { + return action.payload.enabledTokenIds + } else if (action.type === 'CURRENCY_WALLET_ENABLED_TOKENS_CHANGED') { + return action.payload.enabledTokenIds + } else if (action.type === 'CURRENCY_ENGINE_DETECTED_TOKENS') { + const { enablingTokenIds } = action.payload + return uniqueStrings([...state, ...enablingTokenIds]) + } + return state } - return state - }, + ), engineFailure(state = null, action): Error | null { if (action.type === 'CURRENCY_ENGINE_FAILED') { @@ -451,3 +460,19 @@ export function mergeTx( return out } + +type StringsReducer = ( + state: string[] | undefined, + action: RootAction +) => string[] + +function sortStringsReducer(reducer: StringsReducer): StringsReducer { + return (state, action) => { + const out = reducer(state, action) + if (out === state) return state + + out.sort((a, b) => (a === b ? 0 : a > b ? 1 : -1)) + if (state == null || !compare(out, state)) return out + return state + } +} diff --git a/src/core/currency/wallet/enabled-tokens.ts b/src/core/currency/wallet/enabled-tokens.ts index 0acbe5236..9783beb19 100644 --- a/src/core/currency/wallet/enabled-tokens.ts +++ b/src/core/currency/wallet/enabled-tokens.ts @@ -47,13 +47,12 @@ export function tokenIdsToCurrencyCodes( * optionally removing the items in `omit`. */ export function uniqueStrings(array: string[], omit: string[] = []): string[] { - const table: { [key: string]: true } = {} - for (const item of omit) table[item] = true + const table = new Set(omit) const out: string[] = [] for (const item of array) { - if (table[item]) continue - table[item] = true + if (table.has(item)) continue + table.add(item) out.push(item) } return out diff --git a/src/types/types.ts b/src/types/types.ts index a3cf301b5..baef4ea1c 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -773,6 +773,7 @@ export interface EdgeCurrencyEngineCallbacks { currencyCode: string, nativeBalance: string ) => void + readonly onNewTokens: (tokenIds: string[]) => void readonly onStakingStatusChanged: (status: EdgeStakingStatus) => void readonly onTransactionsChanged: (transactions: EdgeTransaction[]) => void readonly onTxidsChanged: (txids: EdgeTxidMap) => void @@ -970,9 +971,10 @@ export type EdgeReceiveAddress = EdgeFreshAddress & { } export interface EdgeCurrencyWalletEvents { + addressChanged: void close: void + enabledDetectedTokens: string[] newTransactions: EdgeTransaction[] - addressChanged: void transactionsChanged: EdgeTransaction[] wcNewContractCall: JsonObject } @@ -1070,6 +1072,9 @@ export interface EdgeCurrencyWallet { readonly changeEnabledTokenIds: (tokenIds: string[]) => Promise readonly enabledTokenIds: string[] + /* Tokens detected on chain */ + readonly detectedTokenIds: string[] + // Transaction history: readonly getNumTransactions: ( opts?: EdgeCurrencyCodeOptions