diff --git a/modules/chronik-client/README.md b/modules/chronik-client/README.md --- a/modules/chronik-client/README.md +++ b/modules/chronik-client/README.md @@ -99,3 +99,4 @@ 0.21.0 - Skipped as accidentally published 0.22.0 before diff approval at 0.21.1-rc.1 0.22.0 - Add support for `tokenId` endpoints and token data in utxos to `ChronikClientNode` 0.22.1 - Return `script` key for utxos fetched from `tokenId` endpoint +0.23.0 - Add support for returning `TokenInfo` from `chronik.token(tokenId)` calls to `ChronikClientNode` diff --git a/modules/chronik-client/package-lock.json b/modules/chronik-client/package-lock.json --- a/modules/chronik-client/package-lock.json +++ b/modules/chronik-client/package-lock.json @@ -1,12 +1,12 @@ { "name": "chronik-client", - "version": "0.22.1", + "version": "0.23.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "chronik-client", - "version": "0.22.1", + "version": "0.23.0", "license": "MIT", "dependencies": { "@types/ws": "^8.2.1", diff --git a/modules/chronik-client/package.json b/modules/chronik-client/package.json --- a/modules/chronik-client/package.json +++ b/modules/chronik-client/package.json @@ -1,6 +1,6 @@ { "name": "chronik-client", - "version": "0.22.1", + "version": "0.23.0", "description": "A client for accessing the Chronik Indexer API", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/modules/chronik-client/src/ChronikClientNode.ts b/modules/chronik-client/src/ChronikClientNode.ts --- a/modules/chronik-client/src/ChronikClientNode.ts +++ b/modules/chronik-client/src/ChronikClientNode.ts @@ -129,6 +129,13 @@ return blocks.blocks.map(convertToBlockInfo); } + /** Fetch token info and stats given the tokenId. */ + public async token(tokenId: string): Promise { + const data = await this._proxyInterface.get(`/token/${tokenId}`); + const tokenInfo = proto.TokenInfo.decode(data); + return convertToTokenInfo(tokenInfo); + } + /** Fetch tx details given the txid. */ public async tx(txid: string): Promise { const data = await this._proxyInterface.get(`/tx/${txid}`); @@ -863,6 +870,61 @@ return 'UNRECOGNIZED'; } +function convertToTokenInfo(tokenInfo: proto.TokenInfo): TokenInfo { + if (typeof tokenInfo.tokenType === 'undefined') { + // Not expected to ever happen + throw new Error( + `chronik returned undefined tokenInfo.tokenType for tokenId "${tokenInfo.tokenId}"`, + ); + } + const tokenType = convertToTokenType(tokenInfo.tokenType); + const returnedTokenInfo: TokenInfo = { + tokenId: tokenInfo.tokenId, + tokenType, + timeFirstSeen: tokenInfo.timeFirstSeen, + }; + // Use tokenType.type to get correct shape of GenesisInfo + if (typeof tokenInfo.genesisInfo !== 'undefined') { + returnedTokenInfo.genesisInfo = convertToGenesisInfo( + tokenInfo.genesisInfo, + tokenType, + ); + } + if (typeof tokenInfo.block !== 'undefined') { + returnedTokenInfo.block = convertToBlockMeta(tokenInfo.block); + } + return returnedTokenInfo; +} + +function convertToGenesisInfo( + genesisInfo: proto.GenesisInfo, + tokenType: TokenType, +): GenesisInfo { + const decoder = new TextDecoder(); + const returnedGenesisInfo: GenesisInfo = { + tokenTicker: decoder.decode(genesisInfo.tokenTicker), + tokenName: decoder.decode(genesisInfo.tokenName), + url: decoder.decode(genesisInfo.url), + decimals: genesisInfo.decimals, + }; + // Add ALP fields for ALP types only + if (tokenType.protocol === 'ALP') { + returnedGenesisInfo.data = toHex(genesisInfo.data); + returnedGenesisInfo.authPubkey = toHex(genesisInfo.authPubkey); + } + // Add mintVaultHash for SLP Mint Vault only + if (tokenType.type === 'SLP_TOKEN_TYPE_MINT_VAULT') { + returnedGenesisInfo.mintVaultScripthash = toHex( + genesisInfo.mintVaultScripthash, + ); + } + // Add url for SLP only + if (tokenType.protocol === 'SLP') { + returnedGenesisInfo.hash = toHex(genesisInfo.hash); + } + return returnedGenesisInfo; +} + function isTxMsgType(msgType: any): msgType is TxMsgType { return TX_MSG_TYPES.includes(msgType); } @@ -1322,3 +1384,40 @@ /** UTXOs */ utxos: Utxo_InNode[]; } + +/** Info about a token */ +export interface TokenInfo { + /** + * Hex token_id (in big-endian, like usually displayed to users) of the token. + * This is not `bytes` because SLP and ALP use different endiannnes, so to avoid this we use hex, which conventionally implies big-endian in a bitcoin context. + */ + tokenId: string; + /** Token type of the token */ + tokenType?: TokenType; + /** Info found in the token's GENESIS tx */ + genesisInfo?: GenesisInfo; + /** Block of the GENESIS tx, if it's mined already */ + block?: BlockMetadata_InNode; + /** Time the GENESIS tx has first been seen by the indexer */ + timeFirstSeen: string; +} + +/** Genesis info found in GENESIS txs of tokens */ +export interface GenesisInfo { + /** token_ticker of the token */ + tokenTicker: string; + /** token_name of the token */ + tokenName: string; + /** URL of the token */ + url: string; + /** token_document_hash of the token (only on SLP) */ + hash?: string; + /** mint_vault_scripthash (only on SLP V2 Mint Vault) */ + mintVaultScripthash?: string; + /** Arbitray payload data of the token (only on ALP) */ + data?: string; + /** auth_pubkey of the token (only on ALP) */ + authPubkey?: string; + /** decimals of the token, i.e. how many decimal places the token should be displayed with. */ + decimals: number; +} diff --git a/modules/chronik-client/test/integration/token_alp.ts b/modules/chronik-client/test/integration/token_alp.ts --- a/modules/chronik-client/test/integration/token_alp.ts +++ b/modules/chronik-client/test/integration/token_alp.ts @@ -8,6 +8,7 @@ import { EventEmitter, once } from 'node:events'; import { ChronikClientNode, + TokenInfo, Token_InNode, TxHistoryPage_InNode, Tx_InNode, @@ -157,6 +158,28 @@ let alpMintTwo: Tx_InNode; let alpSendTwo: Tx_InNode; + const alpTokenInfo: TokenInfo = { + tokenId: + '1111111111111111111111111111111111111111111111111111111111111111', + timeFirstSeen: '1300000000', + tokenType: { + protocol: 'ALP', + type: 'ALP_TOKEN_TYPE_STANDARD', + number: 0, + }, + // We do not get hash in GenesisInfo for ALP + // We get data and authPubkey keys in GenesisInfo for ALP + // We do not get mintVaultScripthash for non-SLP_MINT_VAULT + genesisInfo: { + tokenTicker: 'TEST', + tokenName: 'Test Token', + url: 'http://example.com', + data: '546f6b656e2044617461', + authPubkey: '546f6b656e205075626b6579', + decimals: 4, + }, + }; + let confirmedTxsForAlpGenesisTxid: TxHistoryPage_InNode; it('Gets an ALP genesis tx from the mempool', async () => { @@ -317,6 +340,27 @@ .history(); expect(historyForThisTokenId.txs.length).to.eql(1); expect(historyForThisTokenId.txs[0]).to.deep.equal(alpGenesis); + + // We can get token info of an alp token from the mempool + const alpGenesisMempoolInfo = await chronik.token(alpGenesisTxid); + expect(alpGenesisMempoolInfo).to.deep.equal({ + ...alpTokenInfo, + tokenId: alpGenesisTxid, + }); + // Invalid tokenId is rejected + await expect(chronik.token('somestring')).to.be.rejectedWith( + Error, + `Failed getting /token/somestring (): 400: Not a txid: somestring`, + ); + // We get expected error for a txid that is not in the mempool + await expect( + chronik.token( + '0dab1008db30343a4f771983e9fd96cbc15f0c6efc73f5249c9bae311ef1e92f', + ), + ).to.be.rejectedWith( + Error, + `Failed getting /token/0dab1008db30343a4f771983e9fd96cbc15f0c6efc73f5249c9bae311ef1e92f (): 404: Token 0dab1008db30343a4f771983e9fd96cbc15f0c6efc73f5249c9bae311ef1e92f not found in the index`, + ); }); it('Gets an ALP mint tx from the mempool', async () => { const chronikUrl = await chronik_url; @@ -393,6 +437,12 @@ // Normal status expect(alpMint.tokenStatus).to.eql('TOKEN_STATUS_NORMAL'); + + // Error is thrown for a txid that is in the mempool but is not a tokenId + await expect(chronik.token(alpMintTxid)).to.be.rejectedWith( + Error, + `Failed getting /token/0dab1008db30343a4f771983e9fd96cbc15f0c6efc73f5249c9bae311ef1e92f (): 404: Token 0dab1008db30343a4f771983e9fd96cbc15f0c6efc73f5249c9bae311ef1e92f not found in the index`, + ); }); it('Gets an ALP send tx from the mempool', async () => { const chronikUrl = await chronik_url; @@ -737,6 +787,18 @@ const chronikUrl = await chronik_url; const chronik = new ChronikClientNode(chronikUrl); + // Now that we have a block, we get a block key from token info + const alpGenesisConfirmedInfo = await chronik.token(alpGenesisTxid); + expect(alpGenesisConfirmedInfo).to.deep.equal({ + ...alpTokenInfo, + tokenId: alpGenesisTxid, + block: { + hash: '5e75fc2b2b101c4cf8beec2a68303fcdc5e6d0e3684cc8fbe5ebea60d781b1bb', + height: 102, + timestamp: 1300000500, + }, + }); + alpMegaTxid = await get_alp_mega_txid; // Can this one from the tx endpoint diff --git a/modules/chronik-client/test/integration/token_slp_fungible.ts b/modules/chronik-client/test/integration/token_slp_fungible.ts --- a/modules/chronik-client/test/integration/token_slp_fungible.ts +++ b/modules/chronik-client/test/integration/token_slp_fungible.ts @@ -187,6 +187,28 @@ // Normal status expect(slpGenesis.tokenStatus).to.eql('TOKEN_STATUS_NORMAL'); + + // We can get token info of an slp token from the mempool + const slpGenesisMempoolInfo = await chronik.token(slpGenesisTxid); + expect(slpGenesisMempoolInfo).to.deep.equal({ + tokenId: slpGenesisTxid, + timeFirstSeen: '1300000000', + tokenType: { + protocol: 'SLP', + type: 'SLP_TOKEN_TYPE_FUNGIBLE', + number: 1, + }, + // We get hash in GenesisInfo for SLP + // We do not get mintVaultScripthash for non-SLP_MINT_VAULT + // We do not get data or authPubkey keys in GenesisInfo for non-ALP + genesisInfo: { + tokenTicker: 'SLPTEST', + tokenName: 'Test SLP Token 3', + url: 'http://example/slp', + hash: '7878787878787878787878787878787878787878787878787878787878787878', + decimals: 4, + }, + }); }); it('Gets an SLP fungible mint tx from the mempool', async () => { const chronikUrl = await chronik_url; @@ -395,6 +417,12 @@ const chronikUrl = await chronik_url; const chronik = new ChronikClientNode(chronikUrl); + // Now that we have a block, we get a block key from token info + const slpGenesisConfirmedInfo = await chronik.token(slpGenesisTxid); + expect(typeof slpGenesisConfirmedInfo.block !== 'undefined').to.eql( + true, + ); + const blockTxs = await chronik.blockTxs(CHAIN_INIT_HEIGHT + 2); // Clone as we will use blockTxs.txs later diff --git a/modules/chronik-client/test/integration/token_slp_mint_vault.ts b/modules/chronik-client/test/integration/token_slp_mint_vault.ts --- a/modules/chronik-client/test/integration/token_slp_mint_vault.ts +++ b/modules/chronik-client/test/integration/token_slp_mint_vault.ts @@ -240,6 +240,29 @@ // Normal status expect(slpVaultGenesis.tokenStatus).to.eql('TOKEN_STATUS_NORMAL'); + + // We can get token info of an slp vault token from the mempool + const slpGenesisMempoolInfo = await chronik.token(slpVaultGenesisTxid); + expect(slpGenesisMempoolInfo).to.deep.equal({ + tokenId: slpVaultGenesisTxid, + timeFirstSeen: '1300000000', + tokenType: { + protocol: 'SLP', + type: 'SLP_TOKEN_TYPE_MINT_VAULT', + number: 2, + }, + // We get mintVaultScripthash in GenesisInfo for SLP MINT VAULT + // We get hash in GenesisInfo for SLP + // We do not get data or authPubkey keys in GenesisInfo for non-ALP + genesisInfo: { + tokenTicker: 'SLPVAULT', + tokenName: '0', + url: '0', + hash: '7878787878787878787878787878787878787878787878787878787878787878', + mintVaultScripthash: '28e2146de5a061bf57845a04968d89cbdab733e3', + decimals: 0, + }, + }); }); it('Gets a badly constructed SLP v2 Vault Mint tx from the mempool', async () => { const chronikUrl = await chronik_url; diff --git a/modules/chronik-client/test/integration/token_slp_nft1.ts b/modules/chronik-client/test/integration/token_slp_nft1.ts --- a/modules/chronik-client/test/integration/token_slp_nft1.ts +++ b/modules/chronik-client/test/integration/token_slp_nft1.ts @@ -187,6 +187,28 @@ // Normal status expect(slpGenesis.tokenStatus).to.eql('TOKEN_STATUS_NORMAL'); + + // We can get token info of an slp nft1 from the mempool + const slpGenesisMempoolInfo = await chronik.token(slpGenesisTxid); + + // We do not get mintVaultScripthash for non-SLP_MINT_VAULT + // We do not get data or authPubkey keys in GenesisInfo for non-ALP + expect(slpGenesisMempoolInfo).to.deep.equal({ + tokenId: slpGenesisTxid, + timeFirstSeen: '1300000000', + tokenType: { + protocol: 'SLP', + type: 'SLP_TOKEN_TYPE_NFT1_GROUP', + number: 129, + }, + genesisInfo: { + tokenTicker: 'SLP NFT GROUP', + tokenName: 'Slp NFT GROUP token', + url: 'http://slp.nft', + hash: '7878787878787878787878787878787878787878787878787878787878787878', + decimals: 4, + }, + }); }); it('Gets an SLP NFT1 mint tx from the mempool', async () => { const chronikUrl = await chronik_url; @@ -359,6 +381,29 @@ slpChildGenesisTxid = await get_slp_nft1_child_genesis1_txid; + // We can get token info of an slp nft1 child genesis + const slpChildGenesisMempoolInfo = await chronik.token( + slpChildGenesisTxid, + ); + // We do not get mintVaultScripthash, data, or authPubkey keys in GenesisInfo for SLP NFT1 + expect(slpChildGenesisMempoolInfo).to.deep.equal({ + tokenId: slpChildGenesisTxid, + timeFirstSeen: '1300000000', + tokenType: { + protocol: 'SLP', + type: 'SLP_TOKEN_TYPE_NFT1_CHILD', + number: 65, + }, + genesisInfo: { + tokenTicker: 'SLP NFT CHILD', + tokenName: 'Slp NFT CHILD token', + url: '', + // We get hash even if blank because SLP tokens can have this field + hash: '', + decimals: 0, + }, + }); + slpChildGenesis = await chronik.tx(slpChildGenesisTxid); // We get expected inputs including expected Token data @@ -443,6 +488,18 @@ const blockTxs = await chronik.blockTxs(CHAIN_INIT_HEIGHT + 2); + // Now that we have a block, we get a block key from token info + const slpGenesisConfirmedInfo = await chronik.token(slpGenesisTxid); + expect(typeof slpGenesisConfirmedInfo.block !== 'undefined').to.eql( + true, + ); + const slpChildGenesisConfirmedInfo = await chronik.token( + slpChildGenesisTxid, + ); + expect( + typeof slpChildGenesisConfirmedInfo.block !== 'undefined', + ).to.eql(true); + // Clone as we will use blockTxs.txs later const txsFromBlock = JSON.parse(JSON.stringify(blockTxs.txs));