edge-core-js
Version:
Edge account & wallet management library
839 lines (685 loc) • 25.3 kB
JavaScript
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 }
}
}