Changeset View
Changeset View
Standalone View
Standalone View
cashtab/src/chronik/index.js
// Copyright (c) 2024 The Bitcoin developers | // Copyright (c) 2024 The Bitcoin developers | ||||
// Distributed under the MIT software license, see the accompanying | // Distributed under the MIT software license, see the accompanying | ||||
// file COPYING or http://www.opensource.org/licenses/mit-license.php. | // file COPYING or http://www.opensource.org/licenses/mit-license.php. | ||||
import { BN } from 'slp-mdm'; | import { BN } from 'slp-mdm'; | ||||
import { getHashArrayFromWallet } from 'utils/cashMethods'; | import { getHashArrayFromWallet } from 'utils/cashMethods'; | ||||
import { opReturn as opreturnConfig } from 'config/opreturn'; | import { opReturn as opreturnConfig } from 'config/opreturn'; | ||||
import { chronik as chronikConfig } from 'config/chronik'; | import { chronik as chronikConfig } from 'config/chronik'; | ||||
import { getStackArray } from 'ecash-script'; | import { getStackArray } from 'ecash-script'; | ||||
import cashaddr from 'ecashaddrjs'; | import cashaddr from 'ecashaddrjs'; | ||||
import CashtabCache from 'config/CashtabCache'; | import { toXec, decimalizeTokenAmount, undecimalizeTokenAmount } from 'wallet'; | ||||
import { toXec } from 'wallet'; | |||||
export const getTxHistoryPage = async (chronik, hash160, page = 0) => { | export const getTxHistoryPage = async (chronik, hash160, page = 0) => { | ||||
let txHistoryPage; | let txHistoryPage; | ||||
try { | try { | ||||
txHistoryPage = await chronik | txHistoryPage = await chronik | ||||
.script('p2pkh', hash160) | .script('p2pkh', hash160) | ||||
// Get the 25 most recent transactions | // Get the 25 most recent transactions | ||||
.history(page, chronikConfig.txHistoryPageSize); | .history(page, chronikConfig.txHistoryPageSize); | ||||
Show All 35 Lines | for (let i = 0; i < registeredAliases.length; i++) { | ||||
) { | ) { | ||||
console.log(`Alias (${alias}) is registered`); | console.log(`Alias (${alias}) is registered`); | ||||
return true; | return true; | ||||
} | } | ||||
} | } | ||||
return false; | return false; | ||||
}; | }; | ||||
// Return false if do not get a valid response | |||||
// TODO deprecate, we should be getting this from cache | |||||
// This info will be cached on app startup | |||||
export const getTokenStats = async (chronik, tokenId) => { | |||||
try { | |||||
// token attributes available via chronik's token() method | |||||
let tokenResponseObj = await chronik.token(tokenId); | |||||
return tokenResponseObj; | |||||
} catch (err) { | |||||
console.log( | |||||
`Error fetching token stats for tokenId ${tokenId}: ` + err, | |||||
); | |||||
return false; | |||||
} | |||||
}; | |||||
/** | /** | ||||
* | * Return a promise to fetch all utxos at an address (and add a 'path' key to them) | ||||
* We need the path key so that we know which wif to sign this utxo with | |||||
* If we add HD wallet support, we will need to add an address key, and change the structure of wallet.paths | |||||
* @param {ChronikClientNode} chronik | * @param {ChronikClientNode} chronik | ||||
* @param {string} hash160 | * @param {string} address | ||||
* @returns chronik-client response object | * @param {number} path | ||||
*/ | * @returns {Promise} | ||||
export const getUtxosSingleHashChronik = async (chronik, hash160) => { | |||||
// Get utxos at a single address, which chronik takes in as a hash160 | |||||
const utxos = await chronik.script('p2pkh', hash160).utxos(); | |||||
return utxos.utxos; | |||||
}; | |||||
export const returnGetUtxosChronikPromise = (chronik, hash160AndAddressObj) => { | |||||
/* | |||||
Chronik thinks in hash160s, but people and wallets think in addresses | |||||
Add the address to each utxo | |||||
*/ | */ | ||||
export const returnGetPathedUtxosPromise = (chronik, address, path) => { | |||||
return new Promise((resolve, reject) => { | return new Promise((resolve, reject) => { | ||||
getUtxosSingleHashChronik(chronik, hash160AndAddressObj.hash).then( | chronik | ||||
.address(address) | |||||
.utxos() | |||||
.then( | |||||
result => { | result => { | ||||
for (let i = 0; i < result.length; i += 1) { | for (const utxo of result.utxos) { | ||||
const thisUtxo = result[i]; | utxo.path = path; | ||||
thisUtxo.address = hash160AndAddressObj.address; | |||||
} | } | ||||
resolve(result); | resolve(result.utxos); | ||||
}, | }, | ||||
err => { | err => { | ||||
reject(err); | reject(err); | ||||
}, | }, | ||||
); | ); | ||||
}); | }); | ||||
}; | }; | ||||
export const getUtxosChronik = async (chronik, hash160sMappedToAddresses) => { | /** | ||||
/* | * Get all utxos for a given wallet | ||||
Chronik only accepts utxo requests for one address at a time | * @param {ChronikClientNode} chronik | ||||
Construct an array of promises for each address | * @param {object} wallet a cashtab wallet | ||||
Note: Chronik requires the hash160 of an address for this request | * @returns | ||||
*/ | */ | ||||
export const getUtxos = async (chronik, wallet) => { | |||||
const chronikUtxoPromises = []; | const chronikUtxoPromises = []; | ||||
for (let i = 0; i < hash160sMappedToAddresses.length; i += 1) { | wallet.paths.forEach((pathInfo, path) => { | ||||
const thisPromise = returnGetUtxosChronikPromise( | const thisPromise = returnGetPathedUtxosPromise( | ||||
chronik, | chronik, | ||||
hash160sMappedToAddresses[i], | pathInfo.address, | ||||
path, | |||||
); | ); | ||||
chronikUtxoPromises.push(thisPromise); | chronikUtxoPromises.push(thisPromise); | ||||
} | }); | ||||
const allUtxos = await Promise.all(chronikUtxoPromises); | const utxoResponsesByPath = await Promise.all(chronikUtxoPromises); | ||||
// Since each individual utxo has address information, no need to keep them in distinct arrays | const flatUtxos = utxoResponsesByPath.flat(); | ||||
// Combine into one array of all utxos | |||||
const flatUtxos = allUtxos.flat(); | |||||
return flatUtxos; | return flatUtxos; | ||||
}; | }; | ||||
/** | /** | ||||
* Organize utxos by token and non-token | * Organize utxos by token and non-token | ||||
* TODO deprecate this and use better coinselect methods | * TODO deprecate this and use better coinselect methods | ||||
* @param {Tx_InNode[]} chronikUtxos | * @param {Tx_InNode[]} chronikUtxos | ||||
* @returns {object} {slpUtxos: [], nonSlpUtxos: []} | * @returns {object} {slpUtxos: [], nonSlpUtxos: []} | ||||
Show All 9 Lines | for (const utxo of chronikUtxos) { | ||||
nonSlpUtxos.push(utxo); | nonSlpUtxos.push(utxo); | ||||
} | } | ||||
} | } | ||||
return { slpUtxos, nonSlpUtxos }; | return { slpUtxos, nonSlpUtxos }; | ||||
}; | }; | ||||
/** | /** | ||||
* Build tokens array without accounting for token genesis info | * Get just the tx objects from chronik history() responses | ||||
* TODO this should be a map, not an array of objects. Handle after migration | * @param {TxHistoryPage_InNode[]} txHistoryOfAllAddresses | ||||
* @param {Tx_InNode[]} slpUtxos | * @returns {Tx_InNode[]} | ||||
* @returns | |||||
*/ | |||||
export const getPreliminaryTokensArray = slpUtxos => { | |||||
// Iterate over the slpUtxos to create the 'tokens' object | |||||
let tokensById = {}; | |||||
slpUtxos.forEach(slpUtxos => { | |||||
/* | |||||
Note that a wallet could have many eToken utxos all belonging to the same eToken | |||||
For example, a user could have 100 of a certain eToken, but this is composed of | |||||
four utxos, one for 17, one for 50, one for 30, one for 3 | |||||
*/ | |||||
// Start with the existing object for this particular token, if it exists | |||||
let token = tokensById[slpUtxos.token.tokenId]; | |||||
if (token) { | |||||
if (slpUtxos.token.amount) { | |||||
token.balance = token.balance.plus( | |||||
new BN(slpUtxos.token.amount), | |||||
); | |||||
} | |||||
} else { | |||||
// If it does not exist, create it | |||||
token = {}; | |||||
token.tokenId = slpUtxos.token.tokenId; | |||||
if (slpUtxos.token.amount) { | |||||
token.balance = new BN(slpUtxos.token.amount); | |||||
} else { | |||||
token.balance = new BN(0); | |||||
} | |||||
tokensById[slpUtxos.token.tokenId] = token; | |||||
} | |||||
}); | |||||
const preliminaryTokensArray = Object.values(tokensById); | |||||
return preliminaryTokensArray; | |||||
}; | |||||
/** | |||||
* Get and cache genesisInfo for a token | |||||
* @param {object} chronik chronik-client instance | |||||
* @param {string} tokenId tokenId you want genesisInfo for | |||||
* @param {Map} cachedTokens the map stored at cashtabCache.tokens | |||||
* @returns {promise} promise resolving to chronik's genesisInfo key from chronik.token(tokenId) | |||||
*/ | |||||
export const returnGetTokenInfoChronikPromise = ( | |||||
chronik, | |||||
tokenId, | |||||
cachedTokens, | |||||
) => { | |||||
/* | |||||
The chronik.tx(txid) API call returns extensive transaction information | |||||
For the purposes of finalizing token information, we only need the token metadata | |||||
This function returns a promise that extracts only this needed information from | |||||
the chronik.tx(txid) API call | |||||
In this way, calling Promise.all() on an array of tokenIds that lack metadata | |||||
will return an array with all required metadata | |||||
*/ | |||||
return new Promise((resolve, reject) => { | |||||
chronik.token(tokenId).then( | |||||
result => { | |||||
if (typeof result !== 'undefined') { | |||||
if ('slpTxData' in result) { | |||||
// NNG chronik | |||||
cachedTokens.set(tokenId, result.slpTxData.genesisInfo); | |||||
resolve(result.slpTxData.genesisInfo); | |||||
} | |||||
if ('genesisInfo' in result) { | |||||
// in-node chronik | |||||
cachedTokens.set(tokenId, result.genesisInfo); | |||||
resolve(result.genesisInfo); | |||||
} | |||||
} | |||||
reject( | |||||
new Error( | |||||
`Invalid token info format from chronik.token(${tokenId})`, | |||||
), | |||||
); | |||||
}, | |||||
err => { | |||||
reject(err); | |||||
}, | |||||
); | |||||
}); | |||||
}; | |||||
/** | |||||
* Add genesisInfo and calculate balance using now-known token decimals | |||||
* @param {array} preliminaryTokensArray array of token objects formatted to be read by Cashtab | |||||
* returned by getPreliminaryTokensArray | |||||
* @param {Map} cachedTokens the map stored at cashtabCache.tokens | |||||
* @returns {array} finalTokensArray = preliminaryTokensArray updated for decimals for | |||||
* tokens where we did not yet have this info from cache or chronik | |||||
*/ | |||||
export const processPreliminaryTokensArray = ( | |||||
preliminaryTokensArray, | |||||
cachedTokens, | |||||
) => { | |||||
/* Iterate over preliminaryTokensArray to | |||||
1 - Add slp metadata (token ticker, name, other metadata) | |||||
2 - Calculate the token balance. Token balance in | |||||
preliminaryTokensArray does not take into account the | |||||
decimal places of the token...so it is incorrect. | |||||
*/ | |||||
const finalTokenArray = []; | |||||
for (let i = 0; i < preliminaryTokensArray.length; i += 1) { | |||||
const thisToken = preliminaryTokensArray[i]; | |||||
const thisTokenId = thisToken.tokenId; | |||||
// Because tokenInfoByTokenId is indexed by tokenId, it's easy to reference | |||||
const thisTokenInfo = cachedTokens.get(thisTokenId); | |||||
// The decimals are specifically needed to calculate the correct balance | |||||
const thisTokenDecimals = thisTokenInfo.decimals; | |||||
// Add info object to token | |||||
thisToken.info = thisTokenInfo; | |||||
// Update balance according to decimals and store it as a string | |||||
thisToken.balance = thisToken.balance | |||||
.shiftedBy(-1 * thisTokenDecimals) | |||||
.toString(); | |||||
// Now that you have the metadata and the correct balance, | |||||
// preliminaryTokenInfo is finalTokenInfo | |||||
finalTokenArray.push(thisToken); | |||||
} | |||||
return finalTokenArray; | |||||
}; | |||||
/** | |||||
* Add tokenDecimals info to walletState.tokens and update cachedTokens if you have uncached tokens | |||||
* @param {object} chronik chronik-client instance | |||||
* @param {array} preliminaryTokensArray return value from getPreliminaryTokensArray | |||||
* @param {Map} cachedTokens the map stored at cashtabCache.tokens | |||||
* @returns {object} | |||||
* { | |||||
* tokens {array} output of processPreliminaryTokensArray | |||||
* cachedTokens {Map} the map stored at cashtabCache.tokens, either same as input or updated with new tokens | |||||
* newTokensToCache {boolean} true if we have added more tokens to cache | |||||
* } | |||||
*/ | */ | ||||
export const finalizeTokensArray = async ( | |||||
chronik, | |||||
preliminaryTokensArray, | |||||
cachedTokens = new CashtabCache().tokens, | |||||
) => { | |||||
// Iterate over preliminaryTokensArray to determine what tokens you need to make API calls for | |||||
// Create an array of promises | |||||
// Each promise is a chronik API call to obtain token metadata for this token ID | |||||
const getTokenInfoPromises = []; | |||||
const UNKNOWN_TOKEN_ID = | |||||
'0000000000000000000000000000000000000000000000000000000000000000'; | |||||
for (let i = 0; i < preliminaryTokensArray.length; i += 1) { | |||||
const thisTokenId = preliminaryTokensArray[i].tokenId; | |||||
// See if you already have this info in cachedTokenInfo | |||||
if (cachedTokens.has(thisTokenId)) { | |||||
// If you already have this info in cache, | |||||
// do not create an API request for it | |||||
continue; | |||||
} | |||||
if (thisTokenId === UNKNOWN_TOKEN_ID) { | |||||
// If we have unknown token utxos, hardcode cache info | |||||
// Calling chronik.token(UNKNOWN_TOKEN_ID) will always throw an error | |||||
cachedTokens.set(UNKNOWN_TOKEN_ID, { | |||||
decimals: 0, | |||||
tokenTicker: 'UNKNOWN', | |||||
tokenName: 'Unknown Token', | |||||
url: 'N/A', | |||||
}); | |||||
continue; | |||||
} | |||||
const thisTokenInfoPromise = returnGetTokenInfoChronikPromise( | |||||
chronik, | |||||
thisTokenId, | |||||
cachedTokens, | |||||
); | |||||
getTokenInfoPromises.push(thisTokenInfoPromise); | |||||
} | |||||
const newTokensToCache = getTokenInfoPromises.length > 0; | |||||
// Fetch uncached token genesisInfo and add to cache | |||||
try { | |||||
await Promise.all(getTokenInfoPromises); | |||||
} catch (err) { | |||||
console.log(`Error in Promise.all(getTokenInfoPromises)`, err); | |||||
} | |||||
// Now use cachedTokenInfoByTokenId object to finalize token info | |||||
// Split this out into a separate function so you can unit test | |||||
const finalTokenArray = processPreliminaryTokensArray( | |||||
preliminaryTokensArray, | |||||
cachedTokens, | |||||
); | |||||
// Sort tokens alphabetically by ticker | |||||
finalTokenArray.sort((a, b) => | |||||
a.info.tokenTicker.localeCompare(b.info.tokenTicker), | |||||
); | |||||
return { tokens: finalTokenArray, cachedTokens, newTokensToCache }; | |||||
}; | |||||
export const flattenChronikTxHistory = txHistoryOfAllAddresses => { | export const flattenChronikTxHistory = txHistoryOfAllAddresses => { | ||||
// Create an array of all txs | |||||
let flatTxHistoryArray = []; | let flatTxHistoryArray = []; | ||||
for (let i = 0; i < txHistoryOfAllAddresses.length; i += 1) { | for (const txHistoryThisAddress of txHistoryOfAllAddresses) { | ||||
const txHistoryResponseOfThisAddress = txHistoryOfAllAddresses[i]; | flatTxHistoryArray = flatTxHistoryArray.concat( | ||||
const txHistoryOfThisAddress = txHistoryResponseOfThisAddress.txs; | txHistoryThisAddress.txs, | ||||
flatTxHistoryArray = flatTxHistoryArray.concat(txHistoryOfThisAddress); | ); | ||||
} | } | ||||
return flatTxHistoryArray; | return flatTxHistoryArray; | ||||
}; | }; | ||||
export const sortAndTrimChronikTxHistory = ( | /** | ||||
flatTxHistoryArray, | * Sort an array of chronik txs chronologically and return the first renderedCount of them | ||||
txHistoryCount, | * @param {Tx_InNode[]} txs | ||||
) => { | * @param {number} renderedCount how many txs to return | ||||
// Isolate unconfirmed txs | * @returns | ||||
// In chronik, unconfirmed txs have an `undefined` block key | */ | ||||
export const sortAndTrimChronikTxHistory = (txs, renderedCount) => { | |||||
const unconfirmedTxs = []; | const unconfirmedTxs = []; | ||||
const confirmedTxs = []; | const confirmedTxs = []; | ||||
for (let i = 0; i < flatTxHistoryArray.length; i += 1) { | for (const tx of txs) { | ||||
const thisTx = flatTxHistoryArray[i]; | if (typeof tx.block === 'undefined') { | ||||
if (typeof thisTx.block === 'undefined') { | unconfirmedTxs.push(tx); | ||||
unconfirmedTxs.push(thisTx); | |||||
} else { | } else { | ||||
confirmedTxs.push(thisTx); | confirmedTxs.push(tx); | ||||
} | } | ||||
} | } | ||||
// Sort confirmed txs by blockheight, and then timeFirstSeen | // Sort confirmed txs by blockheight, and then timeFirstSeen | ||||
const sortedConfirmedTxHistoryArray = confirmedTxs.sort( | const sortedConfirmedTxHistoryArray = confirmedTxs.sort( | ||||
(a, b) => | (a, b) => | ||||
// We want more recent blocks i.e. higher blockheights to have earlier array indices | // We want more recent blocks i.e. higher blockheights to have earlier array indices | ||||
b.block.height - a.block.height || | b.block.height - a.block.height || | ||||
// For blocks with the same height, we want more recent timeFirstSeen i.e. higher timeFirstSeen to have earlier array indices | // For blocks with the same height, we want more recent timeFirstSeen i.e. higher timeFirstSeen to have earlier array indices | ||||
b.timeFirstSeen - a.timeFirstSeen, | b.timeFirstSeen - a.timeFirstSeen, | ||||
); | ); | ||||
// Sort unconfirmed txs by timeFirstSeen | // Sort unconfirmed txs by timeFirstSeen | ||||
const sortedUnconfirmedTxHistoryArray = unconfirmedTxs.sort( | const sortedUnconfirmedTxHistoryArray = unconfirmedTxs.sort( | ||||
(a, b) => b.timeFirstSeen - a.timeFirstSeen, | (a, b) => b.timeFirstSeen - a.timeFirstSeen, | ||||
); | ); | ||||
// The unconfirmed txs are more recent, so they should be inserted into an array before the confirmed txs | // The unconfirmed txs are more recent, so they should be inserted into an array before the confirmed txs | ||||
const sortedChronikTxHistoryArray = sortedUnconfirmedTxHistoryArray.concat( | const sortedChronikTxHistoryArray = sortedUnconfirmedTxHistoryArray.concat( | ||||
sortedConfirmedTxHistoryArray, | sortedConfirmedTxHistoryArray, | ||||
); | ); | ||||
const trimmedAndSortedChronikTxHistoryArray = | const trimmedAndSortedChronikTxHistoryArray = | ||||
sortedChronikTxHistoryArray.splice(0, txHistoryCount); | sortedChronikTxHistoryArray.splice(0, renderedCount); | ||||
return trimmedAndSortedChronikTxHistoryArray; | return trimmedAndSortedChronikTxHistoryArray; | ||||
}; | }; | ||||
export const returnGetTxHistoryChronikPromise = ( | |||||
chronik, | |||||
hash160AndAddressObj, | |||||
) => { | |||||
/* | |||||
Chronik thinks in hash160s, but people and wallets think in addresses | |||||
Add the address to each utxo | |||||
*/ | |||||
return new Promise((resolve, reject) => { | |||||
chronik | |||||
.script('p2pkh', hash160AndAddressObj.hash) | |||||
.history(/*page=*/ 0, /*page_size=*/ chronikConfig.txHistoryCount) | |||||
.then( | |||||
result => { | |||||
resolve(result); | |||||
}, | |||||
err => { | |||||
reject(err); | |||||
}, | |||||
); | |||||
}); | |||||
}; | |||||
/** | /** | ||||
* | * Parse a Tx_InNode object for rendering in Cashtab | ||||
* TODO Potentially more efficient to do this calculation in the Tx.js component | |||||
* @param {Tx_InNode} tx | * @param {Tx_InNode} tx | ||||
* @param {object} wallet cashtab wallet | * @param {object} wallet cashtab wallet | ||||
* @param {Map} cachedTokens | * @param {Map} cachedTokens | ||||
* @returns | * @returns | ||||
*/ | */ | ||||
export const parseChronikTx = (tx, wallet, cachedTokens) => { | export const parseTx = (tx, wallet, cachedTokens) => { | ||||
const walletHash160s = getHashArrayFromWallet(wallet); | const walletHash160s = getHashArrayFromWallet(wallet); | ||||
const { inputs, outputs, tokenEntries } = tx; | const { inputs, outputs, tokenEntries } = tx; | ||||
// Assign defaults | // Assign defaults | ||||
let incoming = true; | let incoming = true; | ||||
let satoshis = 0; | let satoshis = 0; | ||||
let etokenAmount = new BN(0); | let etokenAmount = new BN(0); | ||||
let isTokenBurn = false; | let isTokenBurn = false; | ||||
▲ Show 20 Lines • Show All 283 Lines • ▼ Show 20 Lines | export const parseTx = (tx, wallet, cachedTokens) => { | ||||
if (isEtokenTx && etokenAmount.isEqualTo(0)) { | if (isEtokenTx && etokenAmount.isEqualTo(0)) { | ||||
isEtokenTx = false; | isEtokenTx = false; | ||||
opReturnMessage = ''; | opReturnMessage = ''; | ||||
} | } | ||||
// Convert from sats to XEC | // Convert from sats to XEC | ||||
const xecAmount = toXec(satoshis); | const xecAmount = toXec(satoshis); | ||||
// Get decimal info for correct etokenAmount | // Get decimal info for correct etokenAmount | ||||
let genesisInfo = {}; | let assumedTokenDecimals = false; | ||||
if (isEtokenTx) { | if (isEtokenTx) { | ||||
// Get token genesis info from cache | // Parse with decimals = 0 if you do not have this token cached for some reason | ||||
// Acceptable error rendering in tx history | |||||
let decimals = 0; | let decimals = 0; | ||||
try { | const cachedTokenInfo = cachedTokens.get(tokenId); | ||||
genesisInfo = cachedTokens.get(tokenId); | if (typeof cachedTokenInfo !== 'undefined') { | ||||
if (typeof genesisInfo !== 'undefined') { | decimals = cachedTokenInfo.genesisInfo.decimals; | ||||
genesisInfo.success = true; | |||||
decimals = genesisInfo.decimals; | |||||
etokenAmount = etokenAmount.shiftedBy(-1 * decimals); | |||||
} else { | } else { | ||||
genesisInfo = { success: false }; | assumedTokenDecimals = true; | ||||
} | |||||
} catch (err) { | |||||
console.log( | |||||
`Error getting token info from cache in parseChronikTx for ${tx.txid}`, | |||||
err, | |||||
); | |||||
// To keep this function synchronous, do not get this info from the API if it is not in cache | |||||
// Instead, return a flag so that useWallet.js knows and can fetch this info + add it to cache | |||||
genesisInfo = { success: false }; | |||||
} | } | ||||
etokenAmount = etokenAmount.shiftedBy(-1 * decimals); | |||||
} | } | ||||
etokenAmount = etokenAmount.toString(); | etokenAmount = etokenAmount.toString(); | ||||
// Return eToken specific fields if eToken tx | // Return eToken specific fields if eToken tx | ||||
if (isEtokenTx) { | if (isEtokenTx) { | ||||
return { | return { | ||||
incoming, | incoming, | ||||
xecAmount, | xecAmount, | ||||
isEtokenTx, | isEtokenTx, | ||||
etokenAmount, | etokenAmount, | ||||
isTokenBurn, | isTokenBurn, | ||||
tokenEntries: tx.tokenEntries, | tokenEntries: tx.tokenEntries, | ||||
genesisInfo, | |||||
airdropFlag, | airdropFlag, | ||||
airdropTokenId, | airdropTokenId, | ||||
opReturnMessage: '', | opReturnMessage: '', | ||||
isCashtabMessage, | isCashtabMessage, | ||||
isEncryptedMessage, | isEncryptedMessage, | ||||
replyAddress, | replyAddress, | ||||
assumedTokenDecimals, | |||||
}; | }; | ||||
} | } | ||||
// Otherwise do not include these fields | // Otherwise do not include these fields | ||||
return { | return { | ||||
incoming, | incoming, | ||||
xecAmount, | xecAmount, | ||||
isEtokenTx, | isEtokenTx, | ||||
airdropFlag, | airdropFlag, | ||||
airdropTokenId, | airdropTokenId, | ||||
opReturnMessage, | opReturnMessage, | ||||
isCashtabMessage, | isCashtabMessage, | ||||
isEncryptedMessage, | isEncryptedMessage, | ||||
replyAddress, | replyAddress, | ||||
aliasFlag, | aliasFlag, | ||||
}; | }; | ||||
}; | }; | ||||
/** | /** | ||||
* Get tx history of cashtab wallet | * Get tx history of cashtab wallet | ||||
* @param {object} chronik chronik-client instance | * - Get tx history of each path in wallet | ||||
* - sort by timeFirstSeen + block | |||||
* - Trim to number of txs Cashtab renders | |||||
* - Parse txs for rendering in Cashtab | |||||
* - Update cachedTokens with any new tokenIds | |||||
* @param {ChronikClientNode} chronik chronik-client instance | |||||
* @param {object} wallet cashtab wallet | * @param {object} wallet cashtab wallet | ||||
* @param {Map} cachedTokens the map stored at cashtabCache.tokens | * @param {Map} cachedTokens the map stored at cashtabCache.tokens | ||||
* @returns {object} | * @returns {array} Tx_InNode[], each tx also has a 'parsed' key with other rendering info | ||||
* { | */ | ||||
* parsedTxHistory {array} tx history output parsed for rendering txs in Cashtab | export const getHistory = async (chronik, wallet, cachedTokens) => { | ||||
* cachedTokensAfterHistory {Map} cachedTokens the map stored at cashtabCache.tokens updated for any tokens found in tx history | const txHistoryPromises = []; | ||||
* txHistoryNewTokensToCache {boolean} true if we have added tokens | wallet.paths.forEach(pathInfo => { | ||||
* } | txHistoryPromises.push(chronik.address(pathInfo.address).history()); | ||||
*/ | }); | ||||
export const getTxHistoryChronik = async (chronik, wallet, cachedTokens) => { | |||||
// Create array of promises to get chronik history for each address | // Just throw an error if you get a chronik error | ||||
// Combine them all and sort by blockheight and firstSeen | // This will be handled in the update loop | ||||
// Add all the info cashtab needs to make them useful | const txHistoryOfAllAddresses = await Promise.all(txHistoryPromises); | ||||
let txHistoryPromises = []; | |||||
for (let i = 0; i < wallet.paths.length; i += 1) { | |||||
const txHistoryPromise = returnGetTxHistoryChronikPromise( | |||||
chronik, | |||||
wallet.paths[i], | |||||
); | |||||
txHistoryPromises.push(txHistoryPromise); | |||||
} | |||||
let txHistoryOfAllAddresses; | |||||
try { | |||||
txHistoryOfAllAddresses = await Promise.all(txHistoryPromises); | |||||
} catch (err) { | |||||
console.log(`Error in Promise.all(txHistoryPromises)`, err); | |||||
} | |||||
const flatTxHistoryArray = flattenChronikTxHistory(txHistoryOfAllAddresses); | const flatTxHistoryArray = flattenChronikTxHistory(txHistoryOfAllAddresses); | ||||
const sortedTxHistoryArray = sortAndTrimChronikTxHistory( | const renderedTxs = sortAndTrimChronikTxHistory( | ||||
flatTxHistoryArray, | flatTxHistoryArray, | ||||
chronikConfig.txHistoryCount, | chronikConfig.txHistoryCount, | ||||
); | ); | ||||
// Parse txs | // Parse txs | ||||
const chronikTxHistory = []; | const history = []; | ||||
const uncachedTokenIds = new Set(); | for (const tx of renderedTxs) { | ||||
for (let i = 0; i < sortedTxHistoryArray.length; i += 1) { | const { tokenEntries } = tx; | ||||
const sortedTx = sortedTxHistoryArray[i]; | |||||
// Add token genesis info so parsing function can calculate amount by decimals | // Get all tokenIds associated with this tx | ||||
sortedTx.parsed = parseChronikTx(sortedTx, wallet, cachedTokens); | const tokenIds = new Set(); | ||||
// Check to see if this tx was from a token without genesisInfo in cachedTokens | for (const tokenEntry of tokenEntries) { | ||||
if ( | tokenIds.add(tokenEntry.tokenId); | ||||
sortedTx.parsed.isEtokenTx && | |||||
sortedTx.parsed.genesisInfo && | |||||
!sortedTx.parsed.genesisInfo.success | |||||
) { | |||||
// Only add if the token id is not already in uncachedTokenIds | |||||
const uncachedTokenId = sortedTx.parsed.tokenEntries[0].tokenId; | |||||
uncachedTokenIds.add(uncachedTokenId); | |||||
} | |||||
chronikTxHistory.push(sortedTx); | |||||
} | } | ||||
const txHistoryNewTokensToCache = uncachedTokenIds.size > 0; | // Cache any tokenIds you do not have cached | ||||
for (const tokenId of [...tokenIds]) { | |||||
// Iterate over uncachedTokenIds to get genesis info and add to cache | if (typeof cachedTokens.get(tokenId) === 'undefined') { | ||||
const getTokenInfoPromises = []; | // Add it to cache right here | ||||
for (const uncachedTokenId of uncachedTokenIds) { | try { | ||||
const thisTokenInfoPromise = returnGetTokenInfoChronikPromise( | const newTokenCacheInfo = await getTokenGenesisInfo( | ||||
chronik, | chronik, | ||||
uncachedTokenId, | tokenId, | ||||
cachedTokens, | |||||
); | ); | ||||
getTokenInfoPromises.push(thisTokenInfoPromise); | cachedTokens.set(tokenId, newTokenCacheInfo); | ||||
} | |||||
// Get all the token info you need | |||||
try { | |||||
await Promise.all(getTokenInfoPromises); | |||||
} catch (err) { | } catch (err) { | ||||
console.log( | // If you have an error getting the calculated token cache info, do not throw | ||||
`Error in Promise.all(getTokenInfoPromises) in getTxHistoryChronik`, | // Could be some token out there that we do not parse properly with getTokenGenesisInfo | ||||
// Log it | |||||
// parseTx is tolerant to not having the info in cache | |||||
console.error( | |||||
`Error in getTokenGenesisInfo for tokenId ${tokenId}`, | |||||
err, | err, | ||||
); | ); | ||||
} | } | ||||
} | |||||
} | |||||
return { | tx.parsed = parseTx(tx, wallet, cachedTokens); | ||||
parsedTxHistory: chronikTxHistory, | |||||
cachedTokensAfterHistory: cachedTokens, | history.push(tx); | ||||
txHistoryNewTokensToCache, | } | ||||
}; | |||||
return history; | |||||
}; | }; | ||||
export const getMintAddress = async (chronik, tokenId) => { | export const getMintAddress = async (chronik, tokenId) => { | ||||
let genesisTx; | let genesisTx; | ||||
try { | try { | ||||
genesisTx = await chronik.tx(tokenId); | genesisTx = await chronik.tx(tokenId); | ||||
// get the minting address chronik | // get the minting address chronik | ||||
// iterate over the tx outputs | // iterate over the tx outputs | ||||
Show All 12 Lines | try { | ||||
return cashaddr.encodeOutputScript(thisOutput.outputScript); | return cashaddr.encodeOutputScript(thisOutput.outputScript); | ||||
} | } | ||||
} | } | ||||
} catch (err) { | } catch (err) { | ||||
console.log(`Error in getMintAddress`, err); | console.log(`Error in getMintAddress`, err); | ||||
return err; | return err; | ||||
} | } | ||||
}; | }; | ||||
/** | |||||
* Get all info about a token used in Cashtab's token cache | |||||
* @param {ChronikClientNode} chronik | |||||
* @param {string} tokenId | |||||
* @returns {object} | |||||
*/ | |||||
export const getTokenGenesisInfo = async (chronik, tokenId) => { | |||||
// We can get timeFirstSeen, block, tokenType, and genesisInfo from the token() endpoint | |||||
// If we call this endpoint before the genesis tx is confirmed, we will not get block | |||||
// So, block does not need to be included | |||||
const tokenInfo = await chronik.token(tokenId); | |||||
const genesisTxInfo = await chronik.tx(tokenId); | |||||
const { timeFirstSeen, genesisInfo, tokenType } = tokenInfo; | |||||
const decimals = genesisInfo.decimals; | |||||
// Initialize variables for determined quantities we want to cache | |||||
/** | |||||
* genesisSupply {string} | |||||
* Quantity of token created at mint | |||||
* Note: we may have genesisSupply at different genesisAddresses | |||||
* We do not track this information, only total genesisSupply | |||||
* Cached as a decimalized string, e.g. 0.000 if 0 with 3 decimal places | |||||
* 1000.000000000 if one thousand with 9 decimal places | |||||
*/ | |||||
let genesisSupply = decimalizeTokenAmount('0', decimals); | |||||
/** | |||||
* genesisMintBatons {number} | |||||
* Number of mint batons created in the genesis tx for this token | |||||
*/ | |||||
let genesisMintBatons = 0; | |||||
/** | |||||
* genesisOutputScripts {Set(<outputScript>)} | |||||
* Address(es) where initial token supply was minted | |||||
*/ | |||||
let genesisOutputScripts = new Set(); | |||||
// Iterate over outputs | |||||
for (const output of genesisTxInfo.outputs) { | |||||
if ('token' in output && output.token.tokenId === tokenId) { | |||||
// If this output of this genesis tx is associated with this tokenId | |||||
const { token, outputScript } = output; | |||||
// Add its outputScript to genesisOutputScripts | |||||
genesisOutputScripts.add(outputScript); | |||||
const { isMintBaton, amount } = token; | |||||
if (isMintBaton) { | |||||
// If it is a mintBaton, increment genesisMintBatons | |||||
genesisMintBatons += 1; | |||||
} | |||||
// Increment genesisSupply | |||||
// decimalizeTokenAmount, undecimalizeTokenAmount | |||||
//genesisSupply = genesisSupply.plus(new BN(amount)); | |||||
genesisSupply = decimalizeTokenAmount( | |||||
( | |||||
BigInt(undecimalizeTokenAmount(genesisSupply, decimals)) + | |||||
BigInt(amount) | |||||
).toString(), | |||||
decimals, | |||||
); | |||||
} | |||||
} | |||||
const tokenCache = { | |||||
tokenType, | |||||
genesisInfo, | |||||
timeFirstSeen, | |||||
genesisSupply, | |||||
// Return genesisOutputScripts as an array as we no longer require Set features | |||||
genesisOutputScripts: [...genesisOutputScripts], | |||||
genesisMintBatons, | |||||
}; | |||||
if ('block' in tokenInfo) { | |||||
// If the genesis tx is confirmed at the time we check | |||||
tokenCache.block = tokenInfo.block; | |||||
} | |||||
// Note: if it is not confirmed, we can update the cache later when we try to use this value | |||||
return tokenCache; | |||||
}; | |||||
/** | |||||
* Get decimalized balance of every token held by a wallet | |||||
* Update Cashtab's tokenCache if any tokens are uncached | |||||
* @param {ChronikClientNode} chronik | |||||
* @param {array} slpUtxos array of token utxos from chronik | |||||
* @param {Map} tokenCache Cashtab's token cache | |||||
* @returns {Map} Map of tokenId => token balance as decimalized string | |||||
* Also updates tokenCache | |||||
*/ | |||||
export const getTokenBalances = async (chronik, slpUtxos, tokenCache) => { | |||||
const walletStateTokens = new Map(); | |||||
for (const utxo of slpUtxos) { | |||||
// Every utxo in slpUtxos will have a tokenId | |||||
const { token } = utxo; | |||||
const { tokenId, amount } = token; | |||||
// Is this token cached? | |||||
let cachedTokenInfo = tokenCache.get(tokenId); | |||||
if (typeof cachedTokenInfo === 'undefined') { | |||||
// If we have not cached this token before, cache it | |||||
cachedTokenInfo = await getTokenGenesisInfo(chronik, tokenId); | |||||
tokenCache.set(tokenId, cachedTokenInfo); | |||||
} | |||||
// Now decimals is available | |||||
const decimals = cachedTokenInfo.genesisInfo.decimals; | |||||
const tokenBalanceInMap = walletStateTokens.get(tokenId); | |||||
// Update or initialize token balance as a decimalized string in walletStateTokens Map | |||||
walletStateTokens.set( | |||||
tokenId, | |||||
typeof tokenBalanceInMap === 'undefined' | |||||
? decimalizeTokenAmount(amount, decimals) | |||||
: decimalizeTokenAmount( | |||||
( | |||||
BigInt( | |||||
undecimalizeTokenAmount( | |||||
tokenBalanceInMap, | |||||
decimals, | |||||
), | |||||
) + BigInt(amount) | |||||
).toString(), | |||||
decimals, | |||||
), | |||||
); | |||||
} | |||||
return walletStateTokens; | |||||
}; |