UNPKG

edge-core-js

Version:

Edge account & wallet management library

839 lines (685 loc) 25.3 kB
function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; } import { base32 } from 'rfc4648' import { bridgifyObject, onMethod, watchMethod } from 'yaob' import { AccountSync, fixUsername } from '../../client-side' import { base58 } from '../../util/encoding' import { getPublicWalletInfo } from '../currency/wallet/currency-wallet-pixie' import { finishWalletCreation, makeCurrencyWalletKeys, makeKeysKit } from '../login/keys' import { applyKit, decryptChildKey, searchTree } from '../login/login' import { deleteLogin } from '../login/login-delete' import { changeUsername } from '../login/login-username' import { cancelOtpReset, disableOtp, disableTempOtp, enableOtp, enableTempOtp, repairOtp } from '../login/otp' import { changePassword, checkPassword, deletePassword } from '../login/password' import { changePin, checkPin2, deletePin } from '../login/pin2' import { changeRecovery, deleteRecovery } from '../login/recovery2' import { listSplittableWalletTypes, splitWalletInfo } from '../login/splitting' import { changeVoucherStatus } from '../login/vouchers' import { findCurrencyPluginId, getCurrencyTools } from '../plugins/plugins-selectors' import { makeLocalDisklet } from '../storage/repo' import { makeStorageWalletApi } from '../storage/storage-api' import { fetchSwapQuotes } from '../swap/swap-api' import { changeWalletStates } from './account-files' import { ensureAccountExists } from './account-init' import { makeDataStoreApi } from './data-store-api' import { makeLobbyApi } from './lobby-api' import { makeMemoryWalletInner } from './memory-wallet' import { CurrencyConfig, SwapConfig } from './plugin-api' /** * Creates an unwrapped account API object around an account state object. */ export function makeAccountApi(ai, accountId) { // We don't want accountState to be undefined when we log out, // so preserve a snapshot of our last state: let lastState = ai.props.state.accounts[accountId] const accountState = () => { const nextState = ai.props.state.accounts[accountId] if (nextState != null) lastState = nextState return lastState } const { accountWalletInfo, loginType } = accountState() // Plugin config API's: const currencyConfigs = {} for (const pluginId of Object.keys(ai.props.state.plugins.currency)) { const api = new CurrencyConfig(ai, accountId, pluginId) currencyConfigs[pluginId] = api } const swapConfigs = {} for (const pluginId of Object.keys(ai.props.state.plugins.swap)) { const api = new SwapConfig(ai, accountId, pluginId) swapConfigs[pluginId] = api } // Specialty API's: const dataStore = makeDataStoreApi(ai, accountId) const storageWalletApi = makeStorageWalletApi(ai, accountWalletInfo) function lockdown() { if (ai.props.state.hideKeys) { throw new Error('Not available when `hideKeys` is enabled') } } // This is used to fake duress mode settings while in duress mode: let fakeDuressModeSetup = false const out = { on: onMethod, watch: watchMethod, // ---------------------------------------------------------------- // Data store: // ---------------------------------------------------------------- get id() { return storageWalletApi.id }, get type() { return storageWalletApi.type }, get disklet() { lockdown() return storageWalletApi.disklet }, get localDisklet() { lockdown() return storageWalletApi.localDisklet }, async sync() { await storageWalletApi.sync() }, // ---------------------------------------------------------------- // Basic login information: // ---------------------------------------------------------------- get appId() { return accountState().login.appId }, get created() { return accountState().login.created }, async getLoginKey() { lockdown() return base58.stringify(accountState().login.loginKey) }, get lastLogin() { return accountState().login.lastLogin }, get loggedIn() { return ai.props.state.accounts[accountId] != null }, get recoveryKey() { lockdown() const { login } = accountState() return login.recovery2Key != null ? base58.stringify(login.recovery2Key) : undefined }, get rootLoginId() { lockdown() const { loginTree } = accountState() return base58.stringify(loginTree.loginId) }, get username() { const { loginTree } = accountState() return loginTree.username }, // ---------------------------------------------------------------- // Duress mode: // ---------------------------------------------------------------- get canDuressLogin() { const { activeAppId } = accountState() if (ai.props.state.clientInfo.duressEnabled) { return fakeDuressModeSetup } const duressAppId = activeAppId.endsWith('.duress') ? activeAppId : activeAppId + '.duress' const duressStash = searchTree( accountState().loginTree, stash => stash.appId === duressAppId ) return _optionalChain([duressStash, 'optionalAccess', _ => _.pin2Key]) != null }, get isDuressAccount() { const { activeAppId } = accountState() return activeAppId.endsWith('.duress') }, // ---------------------------------------------------------------- // Specialty API's: // ---------------------------------------------------------------- get currencyConfig() { return currencyConfigs }, get swapConfig() { return swapConfigs }, get dataStore() { return dataStore }, // ---------------------------------------------------------------- // What login method was used? // ---------------------------------------------------------------- get edgeLogin() { const { loginTree } = accountState() return loginTree.loginKey == null }, keyLogin: loginType === 'keyLogin', newAccount: loginType === 'newAccount', passwordLogin: loginType === 'passwordLogin', pinLogin: loginType === 'pinLogin', recoveryLogin: loginType === 'recoveryLogin', // ---------------------------------------------------------------- // Change or create credentials: // ---------------------------------------------------------------- async changePassword(password) { lockdown() // Noop for duress accounts: if (this.isDuressAccount) return await changePassword(ai, accountId, password) }, async changePin(opts) { lockdown() // For crash errors: ai.props.log.breadcrumb('EdgeAccount.changePin', {}) // Check if we are in duress mode: const { forDuressAccount = false } = opts const { activeAppId } = accountState() const duressAppId = activeAppId.endsWith('.duress') ? activeAppId : activeAppId + '.duress' // Fakes for duress mode: if (this.isDuressAccount) { // Fake duress mode setup if this is a duress account: if (forDuressAccount) { fakeDuressModeSetup = _nullishCoalesce(opts.enableLogin, () => ( opts.pin != null)) ai.props.dispatch({ type: 'UPDATE_NEXT' }) return '' } } // Ensure the duress account exists: if (forDuressAccount) { await ensureAccountExists( ai, accountState().stashTree, accountState().sessionKey, duressAppId ) } await changePin(ai, accountId, opts) const login = forDuressAccount ? searchTree(accountState().login, stash => stash.appId === duressAppId) : accountState().login if (login == null) { // This shouldn't ever happen because not finding the duress account // when we have called `ensureAccountExists` is a bug in // `ensureAccountExists`. throw new Error('Failed to find account.') } return login.pin2Key != null ? base58.stringify(login.pin2Key) : '' }, async changeRecovery( questions, answers ) { lockdown() if (this.isDuressAccount) { // Use something that looks like a valid recovery key, // but is not the real one. So that way if support ever encounters it, // they know the person had attempted to get access to an account that // was in duress mode, or a user accidentally was in duress mode when // setting up password recovery (unlikely, but possible). // This is one of satoshi's non-spendable addresses on-chain: // https://www.blockchain.com/explorer/addresses/btc/1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa return '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa' } await changeRecovery(ai, accountId, questions, answers) const { loginTree } = accountState() if (loginTree.recovery2Key == null) { throw new Error('Missing recoveryKey') } return base58.stringify(loginTree.recovery2Key) }, async changeUsername(change) { lockdown() change.username = fixUsername(change.username) await changeUsername(ai, accountId, change) }, // ---------------------------------------------------------------- // Verify existing credentials: // ---------------------------------------------------------------- async checkPassword(password) { lockdown() const { loginTree, stashTree } = accountState() // The loginKey is a deprecated optimization because LoginTree is // deprecated: const { loginKey } = loginTree return await checkPassword(ai, stashTree, password, loginKey) }, async checkPin( pin, opts = {} ) { lockdown() const { login, loginTree } = accountState() // Try to check the PIN locally, then fall back on the server: if (login.pin != null && opts.forDuressAccount == null) { return pin === login.pin } else { return await checkPin2(ai, loginTree, pin, opts.forDuressAccount) } }, async getPin() { const { login, loginTree } = accountState() return _nullishCoalesce(login.pin, () => ( loginTree.pin)) }, // ---------------------------------------------------------------- // Remove credentials: // ---------------------------------------------------------------- async deletePassword() { lockdown() await deletePassword(ai, accountId) }, async deletePin() { lockdown() // Check if we are in duress mode: const inDuressMode = ai.props.state.clientInfo.duressEnabled await deletePin(ai, accountId, inDuressMode) }, async deleteRecovery() { lockdown() await deleteRecovery(ai, accountId) }, // ---------------------------------------------------------------- // OTP: // ---------------------------------------------------------------- get otpKey() { lockdown() const { loginTree } = accountState() return loginTree.otpKey != null ? base32.stringify(loginTree.otpKey, { pad: false }) : undefined }, get otpResetDate() { lockdown() const { loginTree } = accountState() return loginTree.otpResetDate }, async cancelOtpReset() { lockdown() await cancelOtpReset(ai, accountId) }, async enableOtp(timeout = 7 * 24 * 60 * 60) { lockdown() if (this.isDuressAccount) { return await enableTempOtp(ai, accountId) } await enableOtp(ai, accountId, timeout) }, async disableOtp() { lockdown() if (this.isDuressAccount) { return await disableTempOtp(ai, accountId) } await disableOtp(ai, accountId) }, async repairOtp(otpKey) { lockdown() await repairOtp(ai, accountId, base32.parse(otpKey, { loose: true })) }, // ---------------------------------------------------------------- // 2fa bypass voucher approval / rejection: // ---------------------------------------------------------------- get pendingVouchers() { const { login } = accountState() return login.pendingVouchers }, async approveVoucher(voucherId) { const { loginTree } = accountState() return await changeVoucherStatus(ai, loginTree, { approvedVouchers: [voucherId] }) }, async rejectVoucher(voucherId) { const { loginTree } = accountState() return await changeVoucherStatus(ai, loginTree, { rejectedVouchers: [voucherId] }) }, // ---------------------------------------------------------------- // Edge login approval: // ---------------------------------------------------------------- async fetchLobby(lobbyId) { // For crash errors: ai.props.log.breadcrumb('EdgeAccount.fetchLobby', {}) lockdown() return await makeLobbyApi(ai, accountId, lobbyId) }, // ---------------------------------------------------------------- // Login management: // ---------------------------------------------------------------- async deleteRemoteAccount() { const { loginTree } = accountState() if (this.isDuressAccount) { return } await deleteLogin(ai, loginTree) }, async logout() { ai.props.dispatch({ type: 'LOGOUT', payload: { accountId } }) }, // ---------------------------------------------------------------- // Master wallet list: // ---------------------------------------------------------------- get allKeys() { return ai.props.state.accounts[accountId].allWalletInfosClean }, async changeWalletStates(walletStates) { await changeWalletStates(ai, accountId, walletStates) }, async createWallet(walletType, keys) { const { login, sessionKey, stashTree } = accountState() // For crash errors: ai.props.log.breadcrumb('EdgeAccount.createWallet', {}) const walletInfo = await makeCurrencyWalletKeys(ai, walletType, { keys }) const childKey = decryptChildKey(stashTree, sessionKey, login.loginId) await applyKit(ai, sessionKey, makeKeysKit(ai, childKey, [walletInfo])) return walletInfo.id }, getFirstWalletInfo: AccountSync.prototype.getFirstWalletInfo, getWalletInfo: AccountSync.prototype.getWalletInfo, listWalletIds: AccountSync.prototype.listWalletIds, async splitWalletInfo( walletId, newWalletType ) { return await splitWalletInfo(ai, accountId, walletId, newWalletType) }, async listSplittableWalletTypes(walletId) { return await listSplittableWalletTypes(ai, accountId, walletId) }, // ---------------------------------------------------------------- // Key access: // ---------------------------------------------------------------- async getDisplayPrivateKey(walletId) { const info = getRawPrivateKey(ai, accountId, walletId) const pluginId = findCurrencyPluginId( ai.props.state.plugins.currency, info.type ) const tools = await getCurrencyTools(ai, pluginId) if (tools.getDisplayPrivateKey != null) { return await tools.getDisplayPrivateKey(info) } const { engine } = ai.props.output.currency.wallets[walletId] if (engine == null || engine.getDisplayPrivateSeed == null) { throw new Error('Wallet has not yet loaded') } const out = await engine.getDisplayPrivateSeed(info.keys) if (out == null) throw new Error('The engine failed to return a key') return out }, async getDisplayPublicKey(walletId) { const info = getRawPrivateKey(ai, accountId, walletId) const pluginId = findCurrencyPluginId( ai.props.state.plugins.currency, info.type ) const tools = await getCurrencyTools(ai, pluginId) if (tools.getDisplayPublicKey != null) { const disklet = makeLocalDisklet(ai.props.io, walletId) const publicInfo = await getPublicWalletInfo(info, disklet, tools) return await tools.getDisplayPublicKey(publicInfo) } const { engine } = ai.props.output.currency.wallets[walletId] if (engine == null || engine.getDisplayPublicSeed == null) { throw new Error('Wallet has not yet loaded') } const out = await engine.getDisplayPublicSeed() if (out == null) throw new Error('The engine failed to return a key') return out }, async getRawPrivateKey(walletId) { return getRawPrivateKey(ai, accountId, walletId).keys }, async getRawPublicKey(walletId) { const info = getRawPrivateKey(ai, accountId, walletId) const pluginId = findCurrencyPluginId( ai.props.state.plugins.currency, info.type ) const tools = await getCurrencyTools(ai, pluginId) const disklet = makeLocalDisklet(ai.props.io, walletId) const publicInfo = await getPublicWalletInfo(info, disklet, tools) return publicInfo.keys }, // ---------------------------------------------------------------- // Currency wallets: // ---------------------------------------------------------------- get activeWalletIds() { return ai.props.state.accounts[accountId].activeWalletIds }, get archivedWalletIds() { return ai.props.state.accounts[accountId].archivedWalletIds }, get hiddenWalletIds() { return ai.props.state.accounts[accountId].hiddenWalletIds }, get currencyWallets() { return ai.props.output.accounts[accountId].currencyWallets }, get currencyWalletErrors() { return ai.props.state.accounts[accountId].currencyWalletErrors }, async createCurrencyWallet( walletType, opts = {} ) { const { login, sessionKey, stashTree } = accountState() // For crash errors: ai.props.log.breadcrumb('EdgeAccount.createCurrencyWallet', {}) const walletInfo = await makeCurrencyWalletKeys(ai, walletType, opts) const childKey = decryptChildKey(stashTree, sessionKey, login.loginId) await applyKit(ai, sessionKey, makeKeysKit(ai, childKey, [walletInfo])) return await finishWalletCreation(ai, accountId, walletInfo.id, opts) }, async makeMemoryWallet( walletType, opts = {} ) { const config = Object.values(currencyConfigs).find( plugin => plugin.currencyInfo.walletType === walletType ) if (config == null) throw new Error('Invalid walletType') return await makeMemoryWalletInner(ai, config, walletType, opts) }, async createCurrencyWallets( createWallets ) { const { login, sessionKey, stashTree } = accountState() // For crash errors: ai.props.log.breadcrumb('EdgeAccount.makeMemoryWallet', {}) // Create the keys: const walletInfos = await Promise.all( createWallets.map( async opts => await makeCurrencyWalletKeys(ai, opts.walletType, opts) ) ) // Store the keys on the server: const childKey = decryptChildKey(stashTree, sessionKey, login.loginId) await applyKit(ai, sessionKey, makeKeysKit(ai, childKey, walletInfos)) // Set up options: return await Promise.all( walletInfos.map( async (info, i) => await makeEdgeResult( finishWalletCreation(ai, accountId, info.id, createWallets[i]) ) ) ) }, async waitForCurrencyWallet(walletId) { return await new Promise((resolve, reject) => { const check = () => { const wallet = this.currencyWallets[walletId] if (wallet != null) { resolve(wallet) cleanup() } const error = this.currencyWalletErrors[walletId] if (error != null) { reject(error) cleanup() } } const cleanup = () => { for (const cleanup of cleanups) cleanup() } const cleanups = [ this.watch('currencyWallets', check), this.watch('currencyWalletErrors', check) ] check() }) }, async waitForAllWallets() { return await new Promise((resolve, reject) => { const check = () => { const busyWallet = this.activeWalletIds.find( id => this.currencyWallets[id] == null && this.currencyWalletErrors[id] == null ) if (busyWallet == null) { for (const cleanup of cleanups) cleanup() resolve() } } const cleanups = [ this.watch('activeWalletIds', check), this.watch('currencyWallets', check), this.watch('currencyWalletErrors', check) ] check() }) }, // ---------------------------------------------------------------- // Token & wallet activation: // ---------------------------------------------------------------- async getActivationAssets({ activateWalletId, activateTokenIds }) { const { currencyWallets } = ai.props.output.accounts[accountId] const walletOutput = ai.props.output.currency.wallets[activateWalletId] const { engine } = walletOutput if (engine == null) throw new Error(`Invalid wallet: ${activateWalletId} not found`) if (engine.engineGetActivationAssets == null) throw new Error( `getActivationAssets unsupported by walletId ${activateWalletId}` ) const out = await engine.engineGetActivationAssets({ currencyWallets, activateTokenIds }) // Added for backward compatibility for plugins using core 1.x out.assetOptions.forEach(asset => (asset.tokenId = _nullishCoalesce(asset.tokenId, () => ( null)))) return out }, async activateWallet( opts ) { const { activateWalletId, activateTokenIds, paymentInfo } = opts const { currencyWallets } = ai.props.output.accounts[accountId] const walletOutput = ai.props.output.currency.wallets[activateWalletId] const { engine } = walletOutput if (engine == null) throw new Error(`Invalid wallet: ${activateWalletId} not found`) if (engine.engineActivateWallet == null) throw new Error( `activateWallet unsupported by walletId ${activateWalletId}` ) const walletId = _nullishCoalesce(_optionalChain([paymentInfo, 'optionalAccess', _2 => _2.walletId]), () => ( '')) const wallet = currencyWallets[walletId] if (wallet == null) { throw new Error(`No wallet for walletId ${walletId}`) } const out = await engine.engineActivateWallet({ activateTokenIds, paymentInfo: paymentInfo != null ? { wallet, tokenId: paymentInfo.tokenId } : undefined, // Added for backward compatibility for plugins using core 1.x // @ts-expect-error paymentTokenId: _optionalChain([paymentInfo, 'optionalAccess', _3 => _3.tokenId]), paymentWallet: wallet }) // Added for backward compatibility for plugins using core 1.x if (out.networkFee.tokenId === undefined) out.networkFee.tokenId = null return bridgifyObject(out) }, // ---------------------------------------------------------------- // Swapping: // ---------------------------------------------------------------- async fetchSwapQuote( request, opts ) { const [bestQuote, ...otherQuotes] = await fetchSwapQuotes( ai, accountId, request, opts ) // Close unused quotes: for (const otherQuote of otherQuotes) { otherQuote.close().catch(() => undefined) } // Return the front quote: if (bestQuote == null) throw new Error('No swap providers enabled') return bestQuote }, async fetchSwapQuotes( request, opts ) { return await fetchSwapQuotes(ai, accountId, request, opts) } } bridgifyObject(out) return out } function getRawPrivateKey( ai, accountId, walletId ) { const infos = ai.props.state.accounts[accountId].allWalletInfosFull const info = infos.find(key => key.id === walletId) if (info == null) { throw new Error(`Invalid wallet: ${walletId} not found`) } return info } async function makeEdgeResult(promise) { try { return { ok: true, result: await promise } } catch (error) { return { ok: false, error } } }