diff --git a/cashtab/src/chronik/__tests__/index.test.js b/cashtab/src/chronik/__tests__/index.test.js --- a/cashtab/src/chronik/__tests__/index.test.js +++ b/cashtab/src/chronik/__tests__/index.test.js @@ -11,6 +11,7 @@ sortAndTrimChronikTxHistory, parseChronikTx, getMintAddress, + getTokenGenesisInfo, } from 'chronik'; import vectors from '../fixtures/vectors'; import { @@ -41,6 +42,7 @@ } from '../fixtures/chronikMintTxs'; import { ChronikClient } from 'chronik-client'; import { when } from 'jest-when'; +import { MockChronikClient } from '../../../../modules/mock-chronik-client'; describe('Cashtab chronik.js functions', () => { it(`getTokenStats successfully returns a token stats object`, async () => { @@ -451,4 +453,56 @@ }); }); }); + describe.only('We get info we want to cache about a token from its genesis tx and chronik token info endpoint', () => { + const { expectedReturns, expectedErrors } = vectors.getTokenGenesisInfo; + + // TODO populate mocks for expectedReturn vectors + expectedReturns.forEach(expectedReturn => { + const { description, tokenId, tokenInfo, genesisTx, returned } = + expectedReturn; + const mockedChronik = new MockChronikClient(); + + // Set mock for chronik.token(tokenId) + mockedChronik.setMock('token', { + input: tokenId, + output: tokenInfo, + }); + + // Set mock for chronik.tx(tokenId) + mockedChronik.setMock('tx', { + input: tokenId, + output: genesisTx, + }); + + it(`getTokenGenesisInfo: ${description}`, async () => { + expect( + await getTokenGenesisInfo(mockedChronik, tokenId), + ).toStrictEqual(returned); + }); + }); + + expectedErrors.forEach(expectedReturn => { + const { description, tokenId, tokenInfo, genesisTx, msg } = + expectedReturn; + const mockedChronik = new MockChronikClient(); + + // Set mock for chronik.token(tokenId) + mockedChronik.setMock('token', { + input: tokenId, + output: tokenInfo, + }); + + // Set mock for chronik.tx(tokenId) + mockedChronik.setMock('tx', { + input: tokenId, + output: genesisTx, + }); + + it(`getTokenGenesisInfo: ${description}`, async () => { + await expect( + getTokenGenesisInfo(mockedChronik, tokenId), + ).rejects.toEqual(msg); + }); + }); + }); }); diff --git a/cashtab/src/chronik/fixtures/vectors.js b/cashtab/src/chronik/fixtures/vectors.js --- a/cashtab/src/chronik/fixtures/vectors.js +++ b/cashtab/src/chronik/fixtures/vectors.js @@ -279,4 +279,594 @@ }, ], }, + getTokenGenesisInfo: { + expectedReturns: [ + { + description: 'slpv1 token with no minting batons', + tokenId: + 'b132878bfa81cf1b9e19192045ed4c797b10944cc17ae07da06aed3d7b566cb7', + tokenInfo: { + tokenId: + 'b132878bfa81cf1b9e19192045ed4c797b10944cc17ae07da06aed3d7b566cb7', + tokenType: { + protocol: 'SLP', + type: 'SLP_TOKEN_TYPE_FUNGIBLE', + number: 1, + }, + timeFirstSeen: '0', + genesisInfo: { + tokenTicker: 'ABC', + tokenName: 'ABC', + url: 'https://cashtab.com/', + decimals: 0, + hash: '', + }, + block: { + height: 832725, + hash: '000000000000000016d97961a24ac3460160bbc439810cd2af684264ae15083b', + timestamp: 1708607039, + }, + }, + genesisTx: { + txid: 'b132878bfa81cf1b9e19192045ed4c797b10944cc17ae07da06aed3d7b566cb7', + version: 2, + inputs: [ + { + prevOut: { + txid: '9866faa3294afc3f4dd5669c67ee4d0ded42db25d08728fe07166e9cda9ee8f9', + outIdx: 3, + }, + inputScript: + '483045022100fb14b5f82605972478186c91ff6fab2051b46abd2a8aa9774b3e9276715daf39022046a62933cc3acf59129fbf373ef05480342312bc33aaa8bf7fb5a0495b5dc80e412103771805b54969a9bea4e3eb14a82851c67592156ddb5e52d3d53677d14a40fba6', + value: 1617, + sequenceNo: 4294967295, + outputScript: + '76a91495e79f51d4260bc0dc3ba7fb77c7be92d0fbdd1d88ac', + }, + ], + outputs: [ + { + value: 0, + outputScript: + '6a04534c500001010747454e4553495303414243034142431468747470733a2f2f636173687461622e636f6d2f4c0001004c0008000000000000000c', + }, + { + value: 546, + outputScript: + '76a91495e79f51d4260bc0dc3ba7fb77c7be92d0fbdd1d88ac', + token: { + tokenId: + 'b132878bfa81cf1b9e19192045ed4c797b10944cc17ae07da06aed3d7b566cb7', + tokenType: { + protocol: 'SLP', + type: 'SLP_TOKEN_TYPE_FUNGIBLE', + number: 1, + }, + amount: '12', + isMintBaton: false, + entryIdx: 0, + }, + spentBy: { + txid: '41fd4cb3ce0162e44cfd5a446b389afa6b35461d466d55321be412a518c56d63', + outIdx: 0, + }, + }, + ], + lockTime: 0, + timeFirstSeen: 0, + size: 261, + isCoinbase: false, + tokenEntries: [ + { + tokenId: + 'b132878bfa81cf1b9e19192045ed4c797b10944cc17ae07da06aed3d7b566cb7', + tokenType: { + protocol: 'SLP', + type: 'SLP_TOKEN_TYPE_FUNGIBLE', + number: 1, + }, + txType: 'GENESIS', + isInvalid: false, + burnSummary: '', + failedColorings: [], + actualBurnAmount: '0', + intentionalBurn: '0', + burnsMintBatons: false, + }, + ], + tokenFailedParsings: [], + tokenStatus: 'TOKEN_STATUS_NORMAL', + block: { + height: 832725, + hash: '000000000000000016d97961a24ac3460160bbc439810cd2af684264ae15083b', + timestamp: 1708607039, + }, + }, + returned: { + tokenType: { + protocol: 'SLP', + type: 'SLP_TOKEN_TYPE_FUNGIBLE', + number: 1, + }, + timeFirstSeen: '0', + genesisInfo: { + tokenTicker: 'ABC', + tokenName: 'ABC', + url: 'https://cashtab.com/', + decimals: 0, + hash: '', + }, + block: { + height: 832725, + hash: '000000000000000016d97961a24ac3460160bbc439810cd2af684264ae15083b', + timestamp: 1708607039, + }, + genesisMintBatons: 0, + genesisOutputScripts: new Set([ + '76a91495e79f51d4260bc0dc3ba7fb77c7be92d0fbdd1d88ac', + ]), + genesisSupply: '12', + }, + }, + { + description: 'slpv1 token with minting baton', + tokenId: + '50d8292c6255cda7afc6c8566fed3cf42a2794e9619740fe8f4c95431271410e', + tokenInfo: { + tokenId: + '50d8292c6255cda7afc6c8566fed3cf42a2794e9619740fe8f4c95431271410e', + tokenType: { + protocol: 'SLP', + type: 'SLP_TOKEN_TYPE_FUNGIBLE', + number: 1, + }, + timeFirstSeen: '0', + genesisInfo: { + tokenTicker: 'TBC', + tokenName: 'tabcash', + url: 'https://cashtabapp.com/', + decimals: 0, + hash: '', + }, + block: { + height: 674143, + hash: '000000000000000034c77993a35c74fe2dddace27198681ca1e89e928d0c2fff', + timestamp: 1613859311, + }, + }, + genesisTx: { + txid: '50d8292c6255cda7afc6c8566fed3cf42a2794e9619740fe8f4c95431271410e', + version: 2, + inputs: [ + { + prevOut: { + txid: 'be38b0488679e25823b7a72b925ac695a7b486e7f78122994b913f3079b0b939', + outIdx: 2, + }, + inputScript: + '483045022100e28006843eb071ec6d8dd105284f2ca625a28f4dc85418910b59a5ab13fc6c2002205921fb12b541d1cd1a63e7e012aca5735df3398525f64bac04337d21029413614121034509251caa5f01e2787c436949eb94d71dcc451bcde5791ae5b7109255f5f0a3', + value: 91048, + sequenceNo: 4294967295, + outputScript: + '76a914b8d9512d2adf8b4e70c45c26b6b00d75c28eaa9688ac', + }, + ], + outputs: [ + { + value: 0, + outputScript: + '6a04534c500001010747454e455349530354424307746162636173681768747470733a2f2f636173687461626170702e636f6d2f4c0001000102080000000000000064', + }, + { + value: 546, + outputScript: + '76a914b8d9512d2adf8b4e70c45c26b6b00d75c28eaa9688ac', + token: { + tokenId: + '50d8292c6255cda7afc6c8566fed3cf42a2794e9619740fe8f4c95431271410e', + tokenType: { + protocol: 'SLP', + type: 'SLP_TOKEN_TYPE_FUNGIBLE', + number: 1, + }, + amount: '100', + isMintBaton: false, + entryIdx: 0, + }, + spentBy: { + txid: '618d0dd8c0c5fa5a34c6515c865dd72bb76f8311cd6ee9aef153bab20dabc0e6', + outIdx: 1, + }, + }, + { + value: 546, + outputScript: + '76a914b8d9512d2adf8b4e70c45c26b6b00d75c28eaa9688ac', + token: { + tokenId: + '50d8292c6255cda7afc6c8566fed3cf42a2794e9619740fe8f4c95431271410e', + tokenType: { + protocol: 'SLP', + type: 'SLP_TOKEN_TYPE_FUNGIBLE', + number: 1, + }, + amount: '0', + isMintBaton: true, + entryIdx: 0, + }, + }, + { + value: 89406, + outputScript: + '76a914b8d9512d2adf8b4e70c45c26b6b00d75c28eaa9688ac', + spentBy: { + txid: '618d0dd8c0c5fa5a34c6515c865dd72bb76f8311cd6ee9aef153bab20dabc0e6', + outIdx: 0, + }, + }, + ], + lockTime: 0, + timeFirstSeen: 0, + size: 336, + isCoinbase: false, + tokenEntries: [ + { + tokenId: + '50d8292c6255cda7afc6c8566fed3cf42a2794e9619740fe8f4c95431271410e', + tokenType: { + protocol: 'SLP', + type: 'SLP_TOKEN_TYPE_FUNGIBLE', + number: 1, + }, + txType: 'GENESIS', + isInvalid: false, + burnSummary: '', + failedColorings: [], + actualBurnAmount: '0', + intentionalBurn: '0', + burnsMintBatons: false, + }, + ], + tokenFailedParsings: [], + tokenStatus: 'TOKEN_STATUS_NORMAL', + block: { + height: 674143, + hash: '000000000000000034c77993a35c74fe2dddace27198681ca1e89e928d0c2fff', + timestamp: 1613859311, + }, + }, + returned: { + tokenType: { + protocol: 'SLP', + type: 'SLP_TOKEN_TYPE_FUNGIBLE', + number: 1, + }, + timeFirstSeen: '0', + genesisInfo: { + tokenTicker: 'TBC', + tokenName: 'tabcash', + url: 'https://cashtabapp.com/', + decimals: 0, + hash: '', + }, + block: { + height: 674143, + hash: '000000000000000034c77993a35c74fe2dddace27198681ca1e89e928d0c2fff', + timestamp: 1613859311, + }, + genesisMintBatons: 1, + genesisOutputScripts: new Set([ + '76a914b8d9512d2adf8b4e70c45c26b6b00d75c28eaa9688ac', + ]), + genesisSupply: '100', + }, + }, + { + description: 'ALP token with a minting baton', + tokenId: + 'cdcdcdcdcdc9dda4c92bb1145aa84945c024346ea66fd4b699e344e45df2e145', + tokenInfo: { + tokenId: + 'cdcdcdcdcdc9dda4c92bb1145aa84945c024346ea66fd4b699e344e45df2e145', + tokenType: { + protocol: 'ALP', + type: 'ALP_TOKEN_TYPE_STANDARD', + number: 0, + }, + timeFirstSeen: '0', + genesisInfo: { + tokenTicker: 'CRD', + tokenName: 'Credo In Unum Deo', + url: 'https://crd.network/token', + decimals: 4, + data: {}, + authPubkey: + '0334b744e6338ad438c92900c0ed1869c3fd2c0f35a4a9b97a88447b6e2b145f10', + }, + block: { + height: 795680, + hash: '00000000000000000b7e89959ee52ca1cd691e1fc3b4891c1888f84261c83e73', + timestamp: 1686305735, + }, + }, + genesisTx: { + txid: 'cdcdcdcdcdc9dda4c92bb1145aa84945c024346ea66fd4b699e344e45df2e145', + version: 1, + inputs: [ + { + prevOut: { + txid: 'dd2020be54ad3dccf98548512e6f735cac002434bbddb61f19cbe6f3f1de04da', + outIdx: 0, + }, + inputScript: + '4130ef71df9d2daacf48d05a0361e103e087b636f4d68af8decd769227caf198003991629bf7057fa1572fc0dd3581115a1b06b5c0eafc88555e58521956fe5cbc410768999600fc71a024752102d8cb55aaf01f84335130bf7b3751267e5cf3398a60e5162ff93ec8d77f14850fac', + value: 4000, + sequenceNo: 4294967295, + outputScript: + 'a91464275fca443d169d23d077c85ad1bb7a31b6e05987', + }, + ], + outputs: [ + { + value: 0, + outputScript: + '6a504c63534c5032000747454e455349530343524411437265646f20496e20556e756d2044656f1968747470733a2f2f6372642e6e6574776f726b2f746f6b656e00210334b744e6338ad438c92900c0ed1869c3fd2c0f35a4a9b97a88447b6e2b145f10040001', + }, + { + value: 546, + outputScript: + '76a914bbb6c4fecc56ecce35958f87c2367cd3f5e88c2788ac', + token: { + tokenId: + 'cdcdcdcdcdc9dda4c92bb1145aa84945c024346ea66fd4b699e344e45df2e145', + tokenType: { + protocol: 'ALP', + type: 'ALP_TOKEN_TYPE_STANDARD', + number: 0, + }, + amount: '0', + isMintBaton: true, + entryIdx: 0, + }, + spentBy: { + txid: 'ff06c312bef229f6f27989326d9be7e0e142aaa84538967b104b262af69f7f00', + outIdx: 0, + }, + }, + ], + lockTime: 777777, + timeFirstSeen: 0, + size: 308, + isCoinbase: false, + tokenEntries: [ + { + tokenId: + 'cdcdcdcdcdc9dda4c92bb1145aa84945c024346ea66fd4b699e344e45df2e145', + tokenType: { + protocol: 'ALP', + type: 'ALP_TOKEN_TYPE_STANDARD', + number: 0, + }, + txType: 'GENESIS', + isInvalid: false, + burnSummary: '', + failedColorings: [], + actualBurnAmount: '0', + intentionalBurn: '0', + burnsMintBatons: false, + }, + ], + tokenFailedParsings: [], + tokenStatus: 'TOKEN_STATUS_NORMAL', + block: { + height: 795680, + hash: '00000000000000000b7e89959ee52ca1cd691e1fc3b4891c1888f84261c83e73', + timestamp: 1686305735, + }, + }, + returned: { + tokenType: { + protocol: 'ALP', + type: 'ALP_TOKEN_TYPE_STANDARD', + number: 0, + }, + timeFirstSeen: '0', + genesisInfo: { + tokenTicker: 'CRD', + tokenName: 'Credo In Unum Deo', + url: 'https://crd.network/token', + decimals: 4, + data: {}, + authPubkey: + '0334b744e6338ad438c92900c0ed1869c3fd2c0f35a4a9b97a88447b6e2b145f10', + }, + block: { + height: 795680, + hash: '00000000000000000b7e89959ee52ca1cd691e1fc3b4891c1888f84261c83e73', + timestamp: 1686305735, + }, + genesisMintBatons: 1, + genesisOutputScripts: new Set([ + '76a914bbb6c4fecc56ecce35958f87c2367cd3f5e88c2788ac', + ]), + genesisSupply: '0', + }, + }, + { + description: 'slpv2 genesis tx', + tokenId: + '7e7dacd72dcdb14e00a03dd3aff47f019ed51a6f1f4e4f532ae50692f62bc4e5', // BUX + tokenInfo: { + tokenId: + '7e7dacd72dcdb14e00a03dd3aff47f019ed51a6f1f4e4f532ae50692f62bc4e5', + tokenType: { + protocol: 'SLP', + type: 'SLP_TOKEN_TYPE_FUNGIBLE', + number: 1, + }, + timeFirstSeen: '0', + genesisInfo: { + tokenTicker: 'BUX', + tokenName: 'Badger Universal Token', + url: 'https://bux.digital', + decimals: 4, + hash: '', + }, + block: { + height: 726564, + hash: '000000000000000010ea35897b2b7373261fdfbca3d02e4f9a6eeb79dc914315', + timestamp: 1644797123, + }, + }, + genesisTx: { + txid: '7e7dacd72dcdb14e00a03dd3aff47f019ed51a6f1f4e4f532ae50692f62bc4e5', + version: 1, + inputs: [ + { + prevOut: { + txid: 'b5605cdda8e5cc5f475f2473f34ad01b29fa0995bac5d37dcb54b858f76db61f', + outIdx: 0, + }, + inputScript: + '41614bc7f35d66b30c017e111c98ad22086730435bea6cf0ec54188ca425863f2a60ee808a11564258d0defc2bfa1505953e18a8108409fb048cfa39bdacc82fce4121027e6cf8229495afadcb5a7e40365bbc82afcf145eacca3193151e68a61fc81743', + value: 3200, + sequenceNo: 4294967295, + outputScript: + '76a914502ee2f475081f2031861f3a275c52722199280e88ac', + }, + ], + outputs: [ + { + value: 0, + outputScript: + '6a04534c500001010747454e45534953034255581642616467657220556e6976657273616c20546f6b656e1368747470733a2f2f6275782e6469676974616c4c0001040102080000000000000000', + }, + { + value: 2300, + outputScript: + 'a9144d80de3cda49fd1bd98eb535da0f2e4880935ea987', + spentBy: { + txid: '459a8dbf3b31750ddaaed4d2c6a12fb42ef1b83fc0f67175f43332962932aa7d', + outIdx: 0, + }, + }, + { + value: 546, + outputScript: + 'a91420d151c5ab4ca4154407626069eaafd8ce6306fc87', + token: { + tokenId: + '7e7dacd72dcdb14e00a03dd3aff47f019ed51a6f1f4e4f532ae50692f62bc4e5', + tokenType: { + protocol: 'SLP', + type: 'SLP_TOKEN_TYPE_FUNGIBLE', + number: 1, + }, + amount: '0', + isMintBaton: true, + entryIdx: 0, + }, + spentBy: { + txid: '459a8dbf3b31750ddaaed4d2c6a12fb42ef1b83fc0f67175f43332962932aa7d', + outIdx: 1, + }, + }, + ], + lockTime: 0, + timeFirstSeen: 0, + size: 302, + isCoinbase: false, + tokenEntries: [ + { + tokenId: + '7e7dacd72dcdb14e00a03dd3aff47f019ed51a6f1f4e4f532ae50692f62bc4e5', + tokenType: { + protocol: 'SLP', + type: 'SLP_TOKEN_TYPE_FUNGIBLE', + number: 1, + }, + txType: 'GENESIS', + isInvalid: false, + burnSummary: '', + failedColorings: [], + actualBurnAmount: '0', + intentionalBurn: '0', + burnsMintBatons: false, + }, + ], + tokenFailedParsings: [], + tokenStatus: 'TOKEN_STATUS_NORMAL', + block: { + height: 726564, + hash: '000000000000000010ea35897b2b7373261fdfbca3d02e4f9a6eeb79dc914315', + timestamp: 1644797123, + }, + }, + returned: { + tokenType: { + protocol: 'SLP', + type: 'SLP_TOKEN_TYPE_FUNGIBLE', + number: 1, + }, + timeFirstSeen: '0', + genesisInfo: { + tokenTicker: 'BUX', + tokenName: 'Badger Universal Token', + url: 'https://bux.digital', + decimals: 4, + hash: '', + }, + block: { + height: 726564, + hash: '000000000000000010ea35897b2b7373261fdfbca3d02e4f9a6eeb79dc914315', + timestamp: 1644797123, + }, + genesisMintBatons: 1, + genesisOutputScripts: new Set([ + 'a91420d151c5ab4ca4154407626069eaafd8ce6306fc87', + ]), + genesisSupply: '0', + }, + }, + /* + { + description: 'ALP token with no minting baton', + tokenId: '', + tokenInfo: {}, + genesisTx: {}, + returned: {}, + }, + { + description: + 'ALP token with minting batons and different mint quantities at different addresses', + tokenId: '', + tokenInfo: {}, + genesisTx: {}, + returned: {}, + }, + */ + ], + expectedErrors: [ + { + description: + 'Error is thrown if 1st chronik API call not completed successfully', + tokenId: + '1111111111111111111111111111111111111111111111111111111111111111', + tokenInfo: new Error( + 'Bad response from chronik.token(tokenId)', + ), + genesisTx: {}, // non-error response + msg: new Error('Bad response from chronik.token(tokenId)'), + }, + { + description: + 'Error is thrown if 2nd chronik API call not completed successfully', + tokenId: + '1111111111111111111111111111111111111111111111111111111111111111', + tokenInfo: {}, // non-error response + genesisTx: new Error('Bad response from chronik.tx(tokenId)'), + msg: new Error('Bad response from chronik.tx(tokenId)'), + }, + ], + }, }; diff --git a/cashtab/src/chronik/index.js b/cashtab/src/chronik/index.js --- a/cashtab/src/chronik/index.js +++ b/cashtab/src/chronik/index.js @@ -9,7 +9,7 @@ import { getStackArray } from 'ecash-script'; import cashaddr from 'ecashaddrjs'; import CashtabCache from 'config/CashtabCache'; -import { toXec } from 'wallet'; +import { toXec, decimalizeTokenAmount, undecimalizeTokenAmount } from 'wallet'; export const getTxHistoryPage = async (chronik, hash160, page = 0) => { let txHistoryPage; @@ -923,3 +923,120 @@ 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 + + const tokenInfo = await chronik.token(tokenId); + const genesisTxInfo = await chronik.tx(tokenId); + + const { block, 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 + */ + let genesisSupply = new BN(0); + + /** + * genesisMintBatons {number} + * Number of mint batons created in the genesis tx for this token + */ + let genesisMintBatons = 0; + + /** + * genesisOutputScripts {Set()} + * Address(es) where initial token supply was minted + */ + // TODO will need ne getter/setter methods to store a set in a map in cache + 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 + genesisSupply = genesisSupply.plus(new BN(amount)); + } + } + + genesisSupply = genesisSupply.shiftedBy(-1 * decimals).toString(); + + const tokenCache = { + tokenType, + genesisInfo, + timeFirstSeen, + block, + genesisSupply, + genesisOutputScripts, + genesisMintBatons, + }; + + 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)) + + BigInt(amount), + ), + ); + } + + return walletStateTokens; +}; diff --git a/cashtab/src/hooks/useWallet.js b/cashtab/src/hooks/useWallet.js --- a/cashtab/src/hooks/useWallet.js +++ b/cashtab/src/hooks/useWallet.js @@ -69,6 +69,12 @@ ); const { slpUtxos, nonSlpUtxos } = organizeUtxosByType(chronikUtxos); + // TODO build tokens. Should just be a map of tokenId to balance. + // TODO so do we even need this? we can just calc it on sendToken load, + // that's the only place it is used + // hm also the token list i guess + // Might as well calc it as you do need to check and see if you have tokens in cache + const preliminaryTokensArray = getPreliminaryTokensArray(slpUtxos); const { tokens, cachedTokens, newTokensToCache } = diff --git a/cashtab/src/wallet/__tests__/index.test.js b/cashtab/src/wallet/__tests__/index.test.js --- a/cashtab/src/wallet/__tests__/index.test.js +++ b/cashtab/src/wallet/__tests__/index.test.js @@ -11,6 +11,8 @@ fiatToSatoshis, getLegacyPaths, getWalletsForNewActiveWallet, + decimalizeTokenAmount, + undecimalizeTokenAmount, } from 'wallet'; import { isValidCashtabWallet } from 'validation'; import vectors from '../fixtures/vectors'; @@ -135,4 +137,51 @@ }); }); }); + describe.only('We can decimalize a token amount string and undecimalize it back', () => { + const { expectedReturns, expectedErrors } = + vectors.decimalizeTokenAmount; + expectedReturns.forEach(expectedReturn => { + const { description, amount, decimals, returned } = expectedReturn; + it(`decimalizeTokenAmount: ${description}`, () => { + expect(decimalizeTokenAmount(amount, decimals)).toBe(returned); + }); + it(`undecimalizeTokenAmount: ${description}`, () => { + expect(undecimalizeTokenAmount(returned, decimals)).toBe( + amount, + ); + }); + }); + expectedErrors.forEach(expectedError => { + const { description, amount, decimals, error } = expectedError; + it(`decimalizeTokenAmount throws error for: ${description}`, () => { + expect(() => decimalizeTokenAmount(amount, decimals)).toThrow( + error, + ); + }); + }); + }); + describe.only('We can undecimalize a decimalizedTokenAmount string, and we throw expected errors if undecimalizeTokenAmount is invalid', () => { + const { expectedReturns, expectedErrors } = + vectors.undecimalizeTokenAmount; + expectedReturns.forEach(expectedReturn => { + const { description, decimalizedAmount, decimals, returned } = + expectedReturn; + it(`undecimalizeTokenAmount: ${description}`, () => { + expect( + undecimalizeTokenAmount(decimalizedAmount, decimals), + ).toBe(returned); + }); + // Note that we cannot round trip these tests, as decimalizeTokenAmount will + // always return exact precision, while undecimalizeTokenAmount tolerates underprecision + }); + expectedErrors.forEach(expectedError => { + const { description, decimalizedAmount, decimals, error } = + expectedError; + it(`undecimalizeTokenAmount throws error for: ${description}`, () => { + expect(() => + undecimalizeTokenAmount(decimalizedAmount, decimals), + ).toThrow(error); + }); + }); + }); }); diff --git a/cashtab/src/wallet/fixtures/vectors.js b/cashtab/src/wallet/fixtures/vectors.js --- a/cashtab/src/wallet/fixtures/vectors.js +++ b/cashtab/src/wallet/fixtures/vectors.js @@ -2,6 +2,7 @@ // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. +const UNSAFE_INTEGER_STRING = '10000000000000000'; export default { getBalanceSatsVectors: { expectedReturns: [ @@ -293,4 +294,139 @@ }, ], }, + decimalizeTokenAmount: { + expectedReturns: [ + { + description: + 'Decimalizes amount for 0-decimal token amount larger than JS max safe integer', + amount: UNSAFE_INTEGER_STRING, + decimals: 0, + returned: UNSAFE_INTEGER_STRING, + }, + { + description: + 'Decimalizes amount for 9-decimal token amount larger than JS max safe integer', + amount: UNSAFE_INTEGER_STRING, + decimals: 9, + returned: '10000000.000000000', + }, + { + description: + 'Decimalizes amount for 9-decimal token amount larger than JS max safe integer with non-zero decimal places', + amount: '11111111123456789', + decimals: 9, + returned: '11111111.123456789', + }, + { + description: + 'Can decimalize for arbitrary decimals, as long as decimals is an integer', + amount: '11111111123456789123456789', + decimals: 18, + returned: '11111111.123456789123456789', + }, + ], + expectedErrors: [ + { + description: 'Throws error if input is not a string', + amount: 50, + decimals: 0, + error: 'amount must be a string', + }, + { + description: + 'Throws error if input is not a stringified integer', + amount: '123.45', + decimals: 0, + error: 'amount must be a stringified integer', + }, + { + description: 'Throws error if decimals is not an integer', + amount: '123', + decimals: 1.1234, + error: 'decimals must be an integer', + }, + ], + }, + undecimalizeTokenAmount: { + expectedReturns: [ + { + description: + 'Returns expected amount for a 0-decimal token that has a decimal point at the end', + decimalizedAmount: '100.', + decimals: 0, + returned: '100', + }, + { + description: + 'Handles a decimalized amount with no decimal place', + decimalizedAmount: '100', + decimals: 9, + returned: '100000000000', + }, + { + description: + 'Handles a decimalized amount with under-specified decimal places', + decimalizedAmount: '100.123', + decimals: 9, + returned: '100123000000', + }, + ], + expectedErrors: [ + { + description: + 'Throws error if decimalizedAmount is not a string', + decimalizedAmount: 100, + decimals: 1, + error: 'decimalizedAmount must be a string', + }, + { + description: + 'Throws error if decimalizedAmount is an empty string', + decimalizedAmount: '', + decimals: 1, + error: `decimalizedAmount must be a non-empty string containing only decimal numbers and optionally one decimal point "."`, + }, + { + description: + 'Throws error if decimalizedAmount includes more than one decimal', + decimalizedAmount: '100..2', + decimals: 1, + error: `decimalizedAmount must be a non-empty string containing only decimal numbers and optionally one decimal point "."`, + }, + { + description: + 'Throws error if decimalizedAmount includes a decimal point that is not a period', + decimalizedAmount: '100,25', + decimals: 1, + error: `decimalizedAmount must be a non-empty string containing only decimal numbers and optionally one decimal point "."`, + }, + { + description: + 'Throws error if decimalizedAmount includes alphabet characters', + decimalizedAmount: 'not a valid decimalizedAmount', + decimals: 1, + error: `decimalizedAmount must be a non-empty string containing only decimal numbers and optionally one decimal point "."`, + }, + { + description: 'Throws error if decimals is invalid', + decimalizedAmount: '100.123', + decimals: 1.23, + error: 'decimals must be an integer', + }, + { + description: + 'Throws precision error if decimals are over-specified for a 0-decimal token', + decimalizedAmount: '100.0', + decimals: 0, + error: 'decimalizedAmount specified at greater precision than supported token decimals', + }, + { + description: + 'Throws precision error if decimals are over-specified for a 9-decimal token', + decimalizedAmount: '100.1234567891', + decimals: 9, + error: 'decimalizedAmount specified at greater precision than supported token decimals', + }, + ], + }, }; diff --git a/cashtab/src/wallet/index.js b/cashtab/src/wallet/index.js --- a/cashtab/src/wallet/index.js +++ b/cashtab/src/wallet/index.js @@ -10,6 +10,8 @@ import appConfig from 'config/app'; const SATOSHIS_PER_XEC = 100; +const STRINGIFIED_INTEGER_REGEX = /^[0-9]+$/; +const STRINGIFIED_DECIMALIZED_REGEX = /^\d*\.?\d*$/; /** * Get total value of satoshis associated with an array of chronik utxos @@ -225,3 +227,104 @@ // Put walletToActivate at 0-index return [walletToActivate, ...currentWallets]; }; + +/** + * Convert a token amount like one from an in-node chronik utxo to a decimalized string + * @param {string} amount undecimalized token amount as a string, e.g. 10012345 at 5 decimals + * @param {Integer} decimals + * @returns {string} decimalized token amount as a string, e.g. 100.12345 + */ +export const decimalizeTokenAmount = (amount, decimals) => { + if (typeof amount !== 'string') { + throw new Error('amount must be a string'); + } + if (!STRINGIFIED_INTEGER_REGEX.test(amount)) { + throw new Error('amount must be a stringified integer'); + } + if (!Number.isInteger(decimals)) { + throw new Error('decimals must be an integer'); + } + if (decimals === 0) { + // If we have 0 decimal places, and amount is a stringified integer + // amount is already correct + return amount; + } + + // Insert decimal point in proper place + const stringAfterDecimalPoint = amount.slice(-1 * decimals); + const stringBeforeDecimalPoint = amount.slice( + 0, + amount.length - stringAfterDecimalPoint.length, + ); + return `${stringBeforeDecimalPoint}.${stringAfterDecimalPoint}`; +}; + +/** + * Convert a decimalized token amount to an undecimalized amount + * Useful to perform integer math as you can use BigInt for amounts greater than Number.MAX_SAFE_INTEGER in js + * @param {string} decimalizedAmount decimalized token amount as a string, e.g. 100.12345 for a 5-decimals token + * @param {Integer} decimals + * @returns {string} undecimalized token amount as a string, e.g. 10012345 for a 5-decimals token + */ +export const undecimalizeTokenAmount = (decimalizedAmount, decimals) => { + if (typeof decimalizedAmount !== 'string') { + throw new Error('decimalizedAmount must be a string'); + } + if ( + !STRINGIFIED_DECIMALIZED_REGEX.test(decimalizedAmount) || + decimalizedAmount.length === 0 + ) { + throw new Error( + `decimalizedAmount must be a non-empty string containing only decimal numbers and optionally one decimal point "."`, + ); + } + if (!Number.isInteger(decimals)) { + throw new Error('decimals must be an integer'); + } + + // If decimals is 0, we should not have a decimal point, or it should be at the very end + if (decimals === 0) { + if (!decimalizedAmount.includes('.')) { + // If 0 decimals and no '.' in decimalizedAmount, it's the same + return decimalizedAmount; + } + if (decimalizedAmount.slice(-1) !== '.') { + // If we have a decimal anywhere but at the very end, throw precision error + throw new Error( + 'decimalizedAmount specified at greater precision than supported token decimals', + ); + } + // Everything before the decimal point is what we want + return decimalizedAmount.split('.')[0]; + } + + // How many decimal places does decimalizedAmount account for + const accountedDecimals = decimalizedAmount.includes('.') + ? decimalizedAmount.split('.')[1].length + : 0; + + // Remove decimal point from the string + const undecimalizedAmountString = decimalizedAmount.split('.').join(''); + + if (accountedDecimals === decimals) { + // If decimalized amount is accounting for all decimals, we simply remove the decimal point + return undecimalizedAmountString; + } + + const unAccountedDecimals = decimals - accountedDecimals; + if (unAccountedDecimals > 0) { + // Handle too little precision + // say, a token amount for a 9-decimal token is only specified at 3 decimals + // e.g. 100.123 + const zerosToAdd = new Array(unAccountedDecimals).fill(0).join(''); + return `${undecimalizedAmountString}${zerosToAdd}`; + } + + // Do not accept too much precision + // say, a token amount for a 3-decimal token is specified at 5 decimals + // e.g. 100.12300 or 100.12345 + // Note if it is specied at 100.12345, we have an error, really too much precision + throw new Error( + 'decimalizedAmount specified at greater precision than supported token decimals', + ); +};