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 @@ -95,3 +95,4 @@ 0.17.0 - Add support for token proto to endpoints that return `Tx_InNode` to `ChronikClientNode` 0.18.0 - Add support for websocket connections to `ChronikClientNode` 0.19.0 - Add support for token data in tx inputs and outputs to `ChronikClientNode` +0.20.0 - Add support for `tokenId` endpoints 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.19.0", + "version": "0.20.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "chronik-client", - "version": "0.19.0", + "version": "0.20.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.19.0", + "version": "0.20.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 @@ -142,6 +142,11 @@ return convertToRawTx(rawTx); } + /** Create object that allows fetching info about a given token */ + public tokenId(tokenId: string): TokenIdEndpoint { + return new TokenIdEndpoint(this._proxyInterface, tokenId); + } + /** Create object that allows fetching script history or UTXOs. */ public script( scriptType: ScriptType_InNode, @@ -216,6 +221,91 @@ } } +/** Allows fetching script history and UTXOs. */ +export class TokenIdEndpoint { + private _proxyInterface: FailoverProxy; + private _tokenId: string; + + constructor(proxyInterface: FailoverProxy, tokenId: string) { + this._proxyInterface = proxyInterface; + this._tokenId = tokenId; + } + + /** + * Fetches the tx history of this tokenId, in anti-chronological order. + * @param page Page index of the tx history. + * @param pageSize Number of txs per page. + */ + public async history( + page = 0, // Get the first page if unspecified + pageSize = 25, // Must be less than 200, let server handle error as server setting could change + ): Promise { + const data = await this._proxyInterface.get( + `/token-id/${this._tokenId}/history?page=${page}&page_size=${pageSize}`, + ); + const historyPage = proto.TxHistoryPage.decode(data); + return { + txs: historyPage.txs.map(convertToTx), + numPages: historyPage.numPages, + numTxs: historyPage.numTxs, + }; + } + + /** + * Fetches the confirmed tx history of this tokenId, in anti-chronological order. + * @param page Page index of the tx history. + * @param pageSize Number of txs per page. + */ + public async confirmedTxs( + page = 0, // Get the first page if unspecified + pageSize = 25, // Must be less than 200, let server handle error as server setting could change + ): Promise { + const data = await this._proxyInterface.get( + `/token-id/${this._tokenId}/confirmed-txs?page=${page}&page_size=${pageSize}`, + ); + const historyPage = proto.TxHistoryPage.decode(data); + return { + txs: historyPage.txs.map(convertToTx), + numPages: historyPage.numPages, + numTxs: historyPage.numTxs, + }; + } + + /** + * Fetches the unconfirmed tx history of this tokenId, in anti-chronological order. + * @param page Page index of the tx history. + * @param pageSize Number of txs per page. + */ + public async unconfirmedTxs( + page = 0, // Get the first page if unspecified + pageSize = 25, // Must be less than 200, let server handle error as server setting could change + ): Promise { + const data = await this._proxyInterface.get( + `/token-id/${this._tokenId}/unconfirmed-txs?page=${page}&page_size=${pageSize}`, + ); + const historyPage = proto.TxHistoryPage.decode(data); + return { + txs: historyPage.txs.map(convertToTx), + numPages: historyPage.numPages, + numTxs: historyPage.numTxs, + }; + } + + /** + * Fetches the current UTXO set for this tokenId. + */ + public async utxos(): Promise { + const data = await this._proxyInterface.get( + `/token-id/${this._tokenId}/utxos`, + ); + const utxos = proto.Utxos.decode(data); + return { + tokenId: this._tokenId, + utxos: utxos.utxos.map(convertToUtxo), + }; + } +} + /** Config for a WebSocket connection to Chronik. */ export interface WsConfig_InNode { /** Fired when a message is sent from the WebSocket. */ @@ -575,7 +665,7 @@ if (utxo.outpoint === undefined) { throw new Error('UTXO outpoint is undefined'); } - return { + const utxoInNode: Utxo_InNode = { outpoint: { txid: toHexRev(utxo.outpoint.txid), outIdx: utxo.outpoint.outIdx, @@ -585,6 +675,11 @@ value: parseInt(utxo.value), isFinal: utxo.isFinal, }; + if (typeof utxo.token !== 'undefined') { + // We only return a token key if we have token data for this input + utxoInNode.token = convertToTokenInNode(utxo.token); + } + return utxoInNode; } function convertToTokenEntry(tokenEntry: proto.TokenEntry): TokenEntry { @@ -699,13 +794,19 @@ ); } - return { + const tokenInNode: Token_InNode = { tokenId: token.tokenId, tokenType: convertToTokenType(token.tokenType), - entryIdx: token.entryIdx, amount: token.amount, isMintBaton: token.isMintBaton, }; + + // We do not bother including entryIdx for utxos, where it is always -1 + if (token.entryIdx !== -1) { + tokenInNode.entryIdx = token.entryIdx; + } + + return tokenInNode; } function convertToBlockMsgType(msgType: proto.BlockMsgType): BlockMsgType { @@ -1051,6 +1152,8 @@ value: number; /** Is this utxo avalanche finalized */ isFinal: boolean; + /** Token value attached to this utxo */ + token?: Token_InNode; } /** Token coloring an input or output */ @@ -1060,7 +1163,7 @@ /** Token type of the token */ tokenType: TokenType; /** Index into `token_entries` for `Tx`. -1 for UTXOs */ - entryIdx: number; + entryIdx?: number; /** Base token amount of the input/output */ amount: string; /** Whether the token is a mint baton */ @@ -1151,3 +1254,11 @@ type: 'Error'; msg: string; } + +/** List of UTXOs */ +export interface TokenIdUtxos { + /** TokenId used to fetch these utxos */ + tokenId: string; + /** UTXOs */ + utxos: Utxo_InNode[]; +} 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 @@ -6,7 +6,12 @@ import chaiAsPromised from 'chai-as-promised'; import { ChildProcess } from 'node:child_process'; import { EventEmitter, once } from 'node:events'; -import { ChronikClientNode, Tx_InNode } from '../../index'; +import { + ChronikClientNode, + Token_InNode, + TxHistoryPage_InNode, + Tx_InNode, +} from '../../index'; import initializeTestRunner from '../setup/testRunner'; const expect = chai.expect; @@ -21,6 +26,7 @@ let get_alp_genesis2_txid: Promise; let get_alp_multi_txid: Promise; let get_alp_mega_txid: Promise; + let get_alp_mint_two_txid: Promise; const statusEvent = new EventEmitter(); before(async () => { @@ -69,6 +75,12 @@ }); } + if (message && message.alp_mint_two_txid) { + get_alp_mint_two_txid = new Promise(resolve => { + resolve(message.alp_mint_two_txid); + }); + } + if (message && message.status) { statusEvent.emit(message.status); } @@ -126,6 +138,7 @@ let alpNextGenesisTxid = ''; let alpMultiTxid = ''; let alpMegaTxid = ''; + let alpMintTwoTxid = ''; let alpGenesis: Tx_InNode; let alpMint: Tx_InNode; @@ -133,6 +146,9 @@ let alpNextGenesis: Tx_InNode; let alpMulti: Tx_InNode; let alpMega: Tx_InNode; + let alpMintTwo: Tx_InNode; + + let confirmedTxsForAlpGenesisTxid: TxHistoryPage_InNode; it('Gets an ALP genesis tx from the mempool', async () => { const chronikUrl = await chronik_url; @@ -157,7 +173,7 @@ ]); // We get expected outputs including expected Token data - expect(alpGenesis.outputs).to.deep.equal([ + const expectedOutputs = [ { ...BASE_TX_OUTPUT, value: 0, @@ -210,7 +226,8 @@ isMintBaton: true, }, }, - ]); + ]; + expect(alpGenesis.outputs).to.deep.equal(expectedOutputs); // We get a Entries of expected shape, with tokenId the txid for a genesis tx expect(alpGenesis.tokenEntries).to.deep.equal([ @@ -231,6 +248,55 @@ // Normal status expect(alpGenesis.tokenStatus).to.eql('TOKEN_STATUS_NORMAL'); + + // We can get the same token info from calling chronik.tokenId.utxos() on this genesis txid + const utxosByTokenId = await chronik.tokenId(alpGenesisTxid).utxos(); + + // We get the calling tokenId returned + expect(utxosByTokenId.tokenId).to.eql(alpGenesisTxid); + // Utxos returned by token id include Token object matching the outputs, except they have no entryIdx + const outputsWithTokenKey = expectedOutputs.filter( + output => 'token' in output, + ); + const utxoTokenKeysFromOutputs: Token_InNode[] = []; + for (const output of outputsWithTokenKey) { + if ('token' in output) { + const { token } = output; + delete (token as Token_InNode).entryIdx; + // We know there is a token key here, even if typescript does not + utxoTokenKeysFromOutputs.push(output.token as Token_InNode); + } + } + + // We have as many utxosByTokenId as we do outputs with token key + expect(utxosByTokenId.utxos.length).to.eql( + utxoTokenKeysFromOutputs.length, + ); + // They match and are in order + for (let i = 0; i < utxosByTokenId.utxos.length; i += 1) { + expect(utxosByTokenId.utxos[i].token).to.deep.equal( + utxoTokenKeysFromOutputs[i], + ); + } + + // We get the same info from calling chronik.tokenId().unconfirmedTxs() + const unconfirmedTxsForThisTokenId = await chronik + .tokenId(alpGenesisTxid) + .unconfirmedTxs(); + expect(unconfirmedTxsForThisTokenId.txs.length).to.eql(1); + expect(unconfirmedTxsForThisTokenId.txs[0]).to.deep.equal(alpGenesis); + + // We get nothing from confirmedTxs() as none are confirmed + const confirmedTxsForThisTokenId = await chronik + .tokenId(alpGenesisTxid) + .confirmedTxs(); + expect(confirmedTxsForThisTokenId.txs.length).to.eql(0); + // History returns the output of confirmed + unconfirmed (in this case, just unconfirmed) + const historyForThisTokenId = await chronik + .tokenId(alpGenesisTxid) + .history(); + expect(historyForThisTokenId.txs.length).to.eql(1); + expect(historyForThisTokenId.txs[0]).to.deep.equal(alpGenesis); }); it('Gets an ALP mint tx from the mempool', async () => { const chronikUrl = await chronik_url; @@ -904,7 +970,7 @@ alpMega, ].sort((a, b) => a.txid.localeCompare(b.txid)); - // The token fields of Tx_InNode(s) from blockTxs match the Tx_InNode(s) from tx] + // The token fields of Tx_InNode(s) from blockTxs match the Tx_InNode(s) from tx // Note the txs are not expected to fully match bc now we have block key and spentBy, // expected after confirmation // This type of functionality is tested in blocktxs_and_tx_and_rawtx.ts @@ -939,5 +1005,118 @@ // Same tx count as blockTxs expect(history.numTxs).to.eql(7); + + // Now we have no unconfirmed txs for the alpGenesisTxid + const unconfirmedTxsForThisTokenId = await chronik + .tokenId(alpGenesisTxid) + .unconfirmedTxs(); + expect(unconfirmedTxsForThisTokenId.txs.length).to.eql(0); + + // We can get all the confirmedTxs for alpGenesisTxid + const broadcastAlpTxsOfAlpGenesisTokenId = [ + alpGenesis, + alpMint, + alpSend, + alpMulti, + alpMega, + ].sort((a, b) => a.txid.localeCompare(b.txid)); + + confirmedTxsForAlpGenesisTxid = await chronik + .tokenId(alpGenesisTxid) + .confirmedTxs(); + + expect(confirmedTxsForAlpGenesisTxid.txs.length).to.eql( + broadcastAlpTxsOfAlpGenesisTokenId.length, + ); + for (let i = 0; i < confirmedTxsForAlpGenesisTxid.txs.length; i += 1) { + // in practice, everything matches except for the 'block' and 'output.spentBy' keys + // these are expected to have changed since we stored the txs when they were in the mempool + // now we are comparing result to confirmed txs + expect(confirmedTxsForAlpGenesisTxid.txs[i].txid).to.eql( + broadcastAlpTxsOfAlpGenesisTokenId[i].txid, + ); + expect(confirmedTxsForAlpGenesisTxid.txs[i].inputs).to.deep.equal( + broadcastAlpTxsOfAlpGenesisTokenId[i].inputs, + ); + expect( + confirmedTxsForAlpGenesisTxid.txs[i].tokenEntries, + ).to.deep.equal(broadcastAlpTxsOfAlpGenesisTokenId[i].tokenEntries); + } + }); + it('Can get confirmed and unconfirmed txs from tokenId.history()', async () => { + const chronikUrl = await chronik_url; + const chronik = new ChronikClientNode(chronikUrl); + + alpMintTwoTxid = await get_alp_mint_two_txid; + + // Can this one from the tx endpoint + alpMintTwo = await chronik.tx(alpMintTwoTxid); + + // Can get this from unconfirmed txs + const unconfirmedTxs = await chronik + .tokenId(alpGenesisTxid) + .unconfirmedTxs(); + expect(unconfirmedTxs.txs[0]).to.deep.equal(alpMintTwo); + + // It's the only unconfirmed tx + expect(unconfirmedTxs.txs.length).to.eql(1); + + // Calling chronik.tokenId.history() returns all confirmed and unconfirmed txs + + const allTokenTxsForAlpGenesisTxid = await chronik + .tokenId(alpGenesisTxid) + .history(); + + // We get all expected txs, confirmed and unconfirmed + expect(allTokenTxsForAlpGenesisTxid.txs.length).to.eql( + confirmedTxsForAlpGenesisTxid.txs.length + 1, + ); + + // The unconfirmed tx is most recent, and comes first + const unconfirmedTxFromTokenIdHistory = + allTokenTxsForAlpGenesisTxid.txs.shift(); + expect(unconfirmedTxFromTokenIdHistory).to.deep.equal(alpMintTwo); + + // We get the rest of the txs in indeterminate order + + // However, since one of the utxos from confirmedTxsForAlpGenesisTxid was spent to create + // the latest tx, this spentBy key has changed + + // We spent the mint baton + const newConfirmedMintTxIndex = + allTokenTxsForAlpGenesisTxid.txs.findIndex( + tx => tx.txid === alpMintTxid, + ); + + const newMintTx = allTokenTxsForAlpGenesisTxid.txs.splice( + newConfirmedMintTxIndex, + 1, + )[0]; + const confirmedMintTxIndex = + confirmedTxsForAlpGenesisTxid.txs.findIndex( + tx => tx.txid === alpMintTxid, + ); + const oldMintTx = confirmedTxsForAlpGenesisTxid.txs.splice( + confirmedMintTxIndex, + 1, + )[0]; + expect(allTokenTxsForAlpGenesisTxid.txs.length).to.eql( + confirmedTxsForAlpGenesisTxid.txs.length, + ); + + // They are the same except for outputs, as expected + expect(oldMintTx.inputs).to.deep.equal(newMintTx.inputs); + expect(oldMintTx.tokenEntries).to.deep.equal(newMintTx.tokenEntries); + + // The other txs are the same, though the order is indeterminate + expect( + allTokenTxsForAlpGenesisTxid.txs.sort((a, b) => + a.txid.localeCompare(b.txid), + ), + ).to.deep.equal( + confirmedTxsForAlpGenesisTxid.txs.sort((a, b) => + a.txid.localeCompare(b.txid), + ), + ); }); }); diff --git a/test/functional/setup_scripts/chronik-client_token_alp.py b/test/functional/setup_scripts/chronik-client_token_alp.py --- a/test/functional/setup_scripts/chronik-client_token_alp.py +++ b/test/functional/setup_scripts/chronik-client_token_alp.py @@ -309,9 +309,41 @@ yield True self.log.info("Step 7: Send wild oversized ALP tx") + # Another ALP Mint tx + alp_mint_two_tx = CTransaction() + alp_mint_two_tx.vin = [ + CTxIn( + COutPoint(int(another_alp_genesis_tx_txid, 16), 4), + SCRIPTSIG_OP_TRUE, + ), + CTxIn( + COutPoint(int(alp_mint_tx_txid, 16), 3), + SCRIPTSIG_OP_TRUE, + ), + ] + alp_mint_two_tx.vout = [ + alp_opreturn( + alp_mint( + token_id=alp_genesis_tx_txid, + mint_amounts=[5, 0], + num_batons=1, + ), + ), + CTxOut(546, P2SH_OP_TRUE), + CTxOut(546, P2SH_OP_TRUE), + CTxOut(546, P2SH_OP_TRUE), + CTxOut(coinvalue - 300000, P2SH_OP_TRUE), + ] + alp_mint_two_tx_txid = node.sendrawtransaction( + alp_mint_two_tx.serialize().hex() + ) + send_ipc_message({"alp_mint_two_txid": alp_mint_two_tx_txid}) assert_equal(node.getblockcount(), 102) yield True + self.log.info("Step 8: Send another ALP send tx in the mempool") + yield True + if __name__ == "__main__": ChronikClientTokenAlp().main()