edge-core-js
Version:
Edge account & wallet management library
663 lines (582 loc) • 18.9 kB
JavaScript
function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } async function _asyncNullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return await 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 { number as currencyFromNumber } from 'currency-codes'
import { justFiles, navigateDisklet } from 'disklet'
import { makeJsonFile } from '../../../util/file-helpers'
import { fetchAppIdInfo } from '../../account/lobby-api'
import { toApiInput } from '../../root-pixie'
import {
getStorageWalletDisklet,
getStorageWalletLocalDisklet,
hashStorageWalletFilename
} from '../../storage/storage-selectors'
import { combineTxWithFile } from './currency-wallet-api'
import {
asLegacyAddressFile,
asLegacyMapFile,
asLegacyTokensFile,
asLegacyTransactionFile,
asSeenCheckpointFile,
asTokensFile,
asTransactionFile,
asWalletFiatFile,
asWalletNameFile,
} from './currency-wallet-cleaners'
import { currencyCodesToTokenIds } from './enabled-tokens'
import { mergeMetadata } from './metadata'
const CURRENCY_FILE = 'Currency.json'
const LEGACY_MAP_FILE = 'fixedLegacyFileNames.json'
const LEGACY_TOKENS_FILE = 'EnabledTokens.json'
const SEEN_TX_CHECKPOINT_FILE = 'seenTxCheckpoint.json'
const TOKENS_FILE = 'Tokens.json'
const WALLET_NAME_FILE = 'WalletName.json'
const legacyAddressFile = makeJsonFile(asLegacyAddressFile)
const legacyMapFile = makeJsonFile(asLegacyMapFile)
const legacyTokensFile = makeJsonFile(asLegacyTokensFile)
const legacyTransactionFile = makeJsonFile(asLegacyTransactionFile)
const seenCheckpointFile = makeJsonFile(asSeenCheckpointFile)
const tokensFile = makeJsonFile(asTokensFile)
const transactionFile = makeJsonFile(asTransactionFile)
const walletFiatFile = makeJsonFile(asWalletFiatFile)
const walletNameFile = makeJsonFile(asWalletNameFile)
/**
* Updates the enabled tokens on a wallet.
*/
export async function writeTokensFile(
input,
detectedTokenIds,
enabledTokenIds
) {
const { state, walletId } = input.props
const disklet = getStorageWalletDisklet(state, walletId)
await tokensFile.save(disklet, TOKENS_FILE, {
detectedTokenIds,
enabledTokenIds
})
}
/**
* Converts a LegacyTransactionFile to a TransactionFile.
*/
function fixLegacyFile(
file,
walletCurrency,
walletFiat
) {
const out = {
creationDate: file.state.creationDate,
currencies: new Map(),
tokens: new Map(),
internal: file.state.internal,
txid: file.state.malleableTxId
}
const exchangeAmount = {}
exchangeAmount[walletFiat] = file.meta.amountCurrency
out.currencies.set(walletCurrency, {
metadata: {
bizId: file.meta.bizId,
category: file.meta.category,
exchangeAmount,
name: file.meta.name,
notes: file.meta.notes
},
providerFeeSent: file.meta.amountFeeAirBitzSatoshi.toFixed()
})
return out
}
function deriveFileNameFields(
state,
walletId,
txid,
txDate
) {
const fileNames = state.currency.wallets[walletId].fileNames
const txidHash = hashStorageWalletFilename(state, walletId, txid)
// Set up the new file:
const { creationDate = Math.min(txDate, Date.now() / 1000) } =
_nullishCoalesce(fileNames[txidHash], () => ( {}))
// Should match `fileNames[txidHash].fileName` if `fileNames[txidHash] != null`:
const fileName = `${creationDate.toFixed(0)}-${txidHash}.json`
return {
creationDate,
fileName,
txidHash
}
}
/**
* Changes a wallet's name.
*/
export async function renameCurrencyWallet(
input,
name
) {
const { dispatch, state, walletId } = input.props
const disklet = getStorageWalletDisklet(state, walletId)
await walletNameFile.save(disklet, WALLET_NAME_FILE, {
walletName: name
})
dispatch({
type: 'CURRENCY_WALLET_NAME_CHANGED',
payload: { name, walletId }
})
}
/**
* Changes a wallet's fiat currency code.
*/
export async function setCurrencyWalletFiat(
input,
fiatCurrencyCode
) {
const { dispatch, state, walletId } = input.props
const disklet = getStorageWalletDisklet(state, walletId)
if (!/^iso:/.test(fiatCurrencyCode)) {
throw new TypeError('Fiat currency codes must start with `iso:`')
}
await walletFiatFile.save(disklet, CURRENCY_FILE, {
fiat: fiatCurrencyCode,
num: undefined
})
dispatch({
type: 'CURRENCY_WALLET_FIAT_CHANGED',
payload: { fiatCurrencyCode, walletId }
})
}
/**
* Loads the wallet fiat currency file.
*/
export async function loadFiatFile(input) {
const { dispatch, state, walletId } = input.props
const disklet = getStorageWalletDisklet(state, walletId)
const clean = await walletFiatFile.load(disklet, CURRENCY_FILE)
let fiatCurrencyCode = 'iso:USD'
if (clean != null) {
if (clean.fiat != null) {
fiatCurrencyCode = clean.fiat
} else if (clean.num != null) {
const code = currencyFromNumber(`000${clean.num}`.slice(-3))
if (code != null) fiatCurrencyCode = `iso:${code.code}`
}
}
dispatch({
type: 'CURRENCY_WALLET_FIAT_CHANGED',
payload: { fiatCurrencyCode, walletId }
})
}
/**
* Loads the wallet name file.
*/
export async function loadNameFile(input) {
const { dispatch, state, walletId } = input.props
const disklet = getStorageWalletDisklet(state, walletId)
const clean = await walletNameFile.load(disklet, WALLET_NAME_FILE)
let name = null
if (clean == null || clean.walletName == null) {
// If a wallet has no name file, try to pick a name based on the appId:
const { appIds = [] } = input.props.walletState.walletInfo
const appId = appIds.find(appId => appId !== '')
if (appId != null) {
const { appName } = await fetchAppIdInfo(toApiInput(input), appId)
name = appName
}
} else {
name = clean.walletName
}
dispatch({
type: 'CURRENCY_WALLET_NAME_CHANGED',
payload: {
name: typeof name === 'string' ? name : null,
walletId
}
})
}
/**
* Load the enabled tokens file, with fallback to the legacy file.
*/
export async function loadTokensFile(
input
) {
const { dispatch, state, walletId } = input.props
const disklet = getStorageWalletDisklet(state, walletId)
const clean = await tokensFile.load(disklet, TOKENS_FILE)
if (clean != null) {
const shortId = walletId.slice(0, 2)
input.props.log.warn(`enabledTokenIds: ${shortId} loaded modern file`)
dispatch({
type: 'CURRENCY_WALLET_LOADED_TOKEN_FILE',
payload: { walletId: input.props.walletId, ...clean }
})
return
}
const legacyCurrencyCodes = await legacyTokensFile.load(
disklet,
LEGACY_TOKENS_FILE
)
if (legacyCurrencyCodes != null) {
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
)
const shortId = walletId.slice(0, 2)
input.props.log.warn(`enabledTokenIds: ${shortId} loaded legacy file`)
dispatch({
type: 'CURRENCY_WALLET_LOADED_TOKEN_FILE',
payload: {
walletId: input.props.walletId,
detectedTokenIds: [],
enabledTokenIds: tokenIds
}
})
return
}
// Both the new and old files are missing:
const shortId = walletId.slice(0, 2)
input.props.log.warn(`enabledTokenIds: ${shortId} loaded neither file`)
dispatch({
type: 'CURRENCY_WALLET_LOADED_TOKEN_FILE',
payload: {
walletId: input.props.walletId,
detectedTokenIds: [],
enabledTokenIds: []
}
})
}
/**
* Loads transaction metadata files.
*/
export async function loadTxFiles(
input,
txIdHashes
) {
const { dispatch, walletId } = input.props
const disklet = getStorageWalletDisklet(input.props.state, walletId)
const walletCurrency = input.props.walletState.currencyInfo.currencyCode
const fileNames = input.props.walletState.fileNames
const walletFiat = input.props.walletState.fiat
const out = {}
await Promise.all(
txIdHashes.map(async txidHash => {
if (fileNames[txidHash] == null) return
const path = `Transactions/${fileNames[txidHash].fileName}`
const clean = await legacyTransactionFile.load(disklet, path)
if (clean == null) return
out[txidHash] = fixLegacyFile(clean, walletCurrency, walletFiat)
})
)
await Promise.all(
txIdHashes.map(async txidHash => {
if (fileNames[txidHash] == null) return
const path = `transaction/${fileNames[txidHash].fileName}`
const clean = await transactionFile.load(disklet, path)
if (clean == null) return
out[txidHash] = clean
})
)
dispatch({
type: 'CURRENCY_WALLET_FILES_LOADED',
payload: { files: out, walletId }
})
return out
}
/**
* Return the legacy file names in the new format.
* If they in the legacy format, convert them to the new format
* and cache them on disk
*/
async function getLegacyFileNames(
state,
walletId,
disklet
) {
// Load the cache, if it exists:
const localDisklet = getStorageWalletLocalDisklet(state, walletId)
const legacyMap =
await _asyncNullishCoalesce((await legacyMapFile.load(localDisklet, LEGACY_MAP_FILE)), async () => ( {}))
// Get the real legacy file names:
const legacyFileNames = justFiles(await disklet.list())
const newFormatFileNames = {}
const missingLegacyFiles = []
for (let i = 0; i < legacyFileNames.length; i++) {
const fileName = legacyFileNames[i]
const fileNameMap = legacyMap[fileName]
// If we haven't converted it, then open the legacy file and convert it to the new format
if (fileNameMap != null) {
const { timestamp, txidHash } = fileNameMap
newFormatFileNames[txidHash] = { creationDate: timestamp, fileName }
} else {
missingLegacyFiles.push(fileName)
}
}
const convertFileNames = missingLegacyFiles.map(async legacyFileName => {
const clean = await legacyTransactionFile.load(disklet, legacyFileName)
if (clean == null) return
const { creationDate, malleableTxId } = clean.state
const fileName = legacyFileName
const txidHash = hashStorageWalletFilename(state, walletId, malleableTxId)
newFormatFileNames[txidHash] = { creationDate, fileName }
legacyMap[fileName] = { timestamp: creationDate, txidHash }
})
if (convertFileNames.length > 0) {
await Promise.all(convertFileNames)
// Cache the new results
await legacyMapFile
.save(localDisklet, LEGACY_MAP_FILE, legacyMap)
.catch(() => {})
}
return newFormatFileNames
}
/**
* Loads transaction metadata file names.
*/
export async function loadTxFileNames(
input
) {
const { dispatch, state, walletId } = input.props
const disklet = getStorageWalletDisklet(state, walletId)
// Legacy transactions files:
const txFileNames = await getLegacyFileNames(
state,
walletId,
navigateDisklet(disklet, 'Transactions')
)
// New transactions files:
const listing = await navigateDisklet(disklet, 'transaction').list()
for (const fileName of justFiles(listing)) {
const prefix = fileName.split('.json')[0]
const split = prefix.split('-')
const [creationDatePart, txidHash] = split
const creationDate = parseInt(creationDatePart)
// Create entry in the txFileNames for the txidHash if it doesn't exist
// or the creation date is older than the existing one
if (
txFileNames[txidHash] == null ||
creationDate < txFileNames[txidHash].creationDate
) {
txFileNames[txidHash] = { creationDate, fileName }
}
}
dispatch({
type: 'CURRENCY_WALLET_FILE_NAMES_LOADED',
payload: { txFileNames, walletId }
})
}
/**
* Loads address metadata files.
*/
export async function loadAddressFiles(
input
) {
const { state, walletId } = input.props
const disklet = getStorageWalletDisklet(state, walletId)
// Save the results to our state:
const out = []
const paths = justFiles(await disklet.list('Addresses'))
await Promise.all(
paths.map(async path => {
const clean = await legacyAddressFile.load(disklet, path)
if (clean == null) return
if (clean.address === '' || clean.state.recycleable) return
out.push(clean.address)
})
)
// Load these addresses into the engine:
const engine = _optionalChain([input, 'access', _ => _.props, 'access', _2 => _2.walletOutput, 'optionalAccess', _3 => _3.engine])
if (engine != null) await engine.addGapLimitAddresses(out)
}
/**
* Changes a wallet's metadata.
*/
export async function updateCurrencyWalletTxMetadata(
input,
txid,
tokenId,
fakeCallbacks,
metadataChange,
assetAction,
savedAction
) {
const { dispatch, state, walletId } = input.props
const disklet = getStorageWalletDisklet(state, walletId)
// Find the currency code:
const { accountId, currencyInfo, pluginId } = input.props.walletState
const allTokens = input.props.state.accounts[accountId].allTokens[pluginId]
const { currencyCode } = tokenId == null ? currencyInfo : allTokens[tokenId]
// Find the tx:
const tx = input.props.walletState.txs[txid]
if (tx == null) {
throw new Error(`Setting metatdata for missing tx ${txid}`)
}
// Derive the file name:
const { creationDate, fileName, txidHash } = deriveFileNameFields(
state,
walletId,
tx.txid,
tx.date
)
// Get the old file data if it exists:
const oldFile = await transactionFile.load(disklet, `transaction/${fileName}`)
// Merge with old file
const newFile = {
...oldFile,
creationDate,
currencies: new Map(_nullishCoalesce(_optionalChain([oldFile, 'optionalAccess', _4 => _4.currencies]), () => ( []))),
internal: true,
tokens: new Map(_nullishCoalesce(_optionalChain([oldFile, 'optionalAccess', _5 => _5.tokens]), () => ( []))),
txid
}
// Migrate the asset data from currencyCode to tokenId:
const assetData = {
metadata: {},
...newFile.currencies.get(currencyCode),
...newFile.tokens.get(tokenId)
}
newFile.tokens.set(tokenId, assetData)
newFile.currencies.delete(currencyCode)
// Make the change:
if (metadataChange != null) {
assetData.metadata = mergeMetadata(_nullishCoalesce(assetData.metadata, () => ( {})), metadataChange)
}
if (assetAction != null) assetData.assetAction = assetAction
if (savedAction != null) newFile.savedAction = savedAction
// Save the new file:
dispatch({
type: 'CURRENCY_WALLET_FILE_CHANGED',
payload: { creationDate, fileName, json: newFile, txid, txidHash, walletId }
})
await transactionFile.save(disklet, 'transaction/' + fileName, newFile)
const callbackTx = combineTxWithFile(input, tx, newFile, tokenId)
fakeCallbacks.onTransactions([
// This method is used to update metadata for existing/seen transactions,
// so we should always mark the transaction as not new.
{ isNew: false, transaction: callbackTx }
])
}
/**
* Sets up metadata for a transaction in-memory without persisting the
* metadata to disk.
*/
export async function setupNewTxMetadata(
input,
tx
) {
const { dispatch, state, walletId } = input.props
const { creationDate, fileName, txidHash } = deriveFileNameFields(
state,
walletId,
tx.txid,
tx.date
)
// Basic file template:
const txFile = {
txid: tx.txid,
internal: true,
creationDate,
currencies: new Map(),
feeRateUsed: tx.feeRateUsed,
tokens: new Map(),
savedAction: tx.savedAction,
swap: tx.swapData
}
txFile.tokens.set(tx.tokenId, {
assetAction: tx.assetAction,
metadata: _nullishCoalesce(tx.metadata, () => ( {})),
nativeAmount: tx.nativeAmount
})
// Set up the fee metadata:
if (tx.networkFeeOption != null) {
txFile.feeRateRequested =
tx.networkFeeOption === 'custom'
? tx.requestedCustomFee
: tx.networkFeeOption
}
// Set up payees:
if (tx.spendTargets != null) {
txFile.payees = tx.spendTargets.map(target => ({
currency: target.currencyCode,
address: target.publicAddress,
amount: target.nativeAmount,
tag: target.memo
}))
// Only write device description if it's a spend
if (tx.deviceDescription != null)
txFile.deviceDescription = tx.deviceDescription
}
if (typeof tx.txSecret === 'string') txFile.secret = tx.txSecret
// Save the new file:
dispatch({
type: 'CURRENCY_WALLET_FILE_CHANGED',
payload: {
creationDate,
fileName,
json: txFile,
txid: tx.txid,
txidHash,
walletId
}
})
return { fileName, txFile }
}
/**
* Persists metadata for a transaction to disk.
*/
export async function saveTxMetadataFile(
input,
fileName,
txFile
) {
const { state, walletId } = input.props
const disklet = getStorageWalletDisklet(state, walletId)
await transactionFile.save(disklet, 'transaction/' + fileName, txFile)
}
/**
* Loads the seen transaction checkpoint file for a specific wallet.
*/
export async function loadSeenTxCheckpointFile(
input
) {
const { dispatch, state, walletId } = input.props
const disklet = getStorageWalletDisklet(state, walletId)
const { checkpoint } =
await _asyncNullishCoalesce((await seenCheckpointFile.load(disklet, SEEN_TX_CHECKPOINT_FILE)), async () => ( {}))
if (checkpoint != null) {
dispatch({
type: 'CURRENCY_ENGINE_SEEN_TX_CHECKPOINT_CHANGED',
payload: { checkpoint, walletId }
})
}
return checkpoint
}
/**
* Save's the seen transaction checkpoint file for a specific wallet.
*/
export async function saveSeenTxCheckpointFile(
input,
checkpoint
) {
const { dispatch, state, walletId } = input.props
const disklet = getStorageWalletDisklet(state, walletId)
const fileData = {
checkpoint
}
dispatch({
type: 'CURRENCY_ENGINE_SEEN_TX_CHECKPOINT_CHANGED',
payload: { checkpoint, walletId }
})
await seenCheckpointFile.save(disklet, SEEN_TX_CHECKPOINT_FILE, fileData)
}
export async function reloadWalletFiles(
input,
changes
) {
if (changes.includes(TOKENS_FILE) || changes.includes(LEGACY_TOKENS_FILE)) {
await loadTokensFile(input)
}
if (changes.includes(CURRENCY_FILE)) {
await loadFiatFile(input)
}
if (changes.includes(WALLET_NAME_FILE)) {
await loadNameFile(input)
}
await loadTxFileNames(input)
await loadAddressFiles(input)
}