diff --git a/package.json b/package.json index d253bf0..73f2784 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,8 @@ "name": "runestone-lib", "version": "1.0.0", "description": "", - "main": "index.js", + "main": "./dist/index.js", + "types": "./src/index.d.ts", "type": "module", "scripts": { "test": "jest", diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..59332da --- /dev/null +++ b/src/index.ts @@ -0,0 +1 @@ +export * from './indexer'; diff --git a/src/indexer/events.ts b/src/indexer/events.ts deleted file mode 100644 index aefa574..0000000 --- a/src/indexer/events.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Edict } from '../edict'; -import { Etching } from '../etching'; - -export type Runestone = { - claim: boolean; - burn: boolean; - edicts: Edict[]; - etching?: Etching; -}; - -export type RunestoneEvent = { - txid: string; - blockhash: string; - blockheight: number; - blockTxIndex: number; - encodedBytes: Buffer; - - runestone: Runestone; -}; diff --git a/src/indexer/index.ts b/src/indexer/index.ts index 0866ab2..43df07c 100644 --- a/src/indexer/index.ts +++ b/src/indexer/index.ts @@ -1,84 +1,119 @@ -import { RPCClient } from 'rpc-bitcoin'; -import { RunestoneEvent } from './events'; - -export interface IRunestoneIndexer { - /** - * Begin indexing the blockchain from the last checkpoint. - */ - start(): Promise; - - /** - * Stop the indexer, waiting for any in-progress operations to complete before returning. - */ - stop(): Promise; -} +import { GetBlockParams, RPCClient, Verbosity } from 'rpc-bitcoin'; +import { + RunestoneStorage, + RuneBlockIndex, + RunestoneIndexerOptions, +} from './types'; +import { Chain } from '../chain'; + +type Vin = { + txid: string; + vout: number; +}; -export interface IRunestoneStorage { - /** - * Connect to the storage backend, called at indexer startup. - */ - connect(): Promise; - - /** - * Disconnect from the storage backend, called at indexer shutdown. - */ - disconnect(): Promise; - - /** - * Handle a Runestone event. - * @param event The {@link RunestoneEvent} to handle. - * @returns A promise that resolves to true if the event was handled without error, false otherwise. - */ - handleEvent(event: RunestoneEvent): Promise; - - /** - * Called after each block is processed to save progress. - * @param blockhash The hash of the block that was processed. - * @param blockheight The height of the block that was processed. - * @returns A promise that resolves to true if the checkpoint was saved without error, false otherwise. - */ - saveCheckpoint(blockhash: string, blockheight: number): Promise; - - /** - * Called at startup to load the last saved checkpoint. - * @returns A promise that resolves to an object containing the blockhash and blockheight of the last saved checkpoint. - * If no checkpoint is found, returns null. - */ - loadCheckpoint(): Promise<{ blockhash: string; blockheight: number } | null>; -} +type VinCoinbase = { + coinbase: string; +}; -export type RunestoneIndexerOptions = { - bitcoinRpc: { - url: string; - user: string; - pass: string; - port: number; +type Vout = { + value: number; + n: number; + scriptPubKey: { + asm: string; + desc: string; + hex: string; + type: string; + address?: string; }; - storage: IRunestoneStorage; - /** - * The interval at which to poll the RPC for new blocks, in milliseconds. - * Defaults to `10000` (10 seconds), and must be positive. - */ - pollIntervalMs?: number; }; -export class RunestoneIndexer implements IRunestoneIndexer { - private readonly _storage: IRunestoneStorage; - private readonly _rpc: RPCClient; +type Tx = { + txid: string; + hash: string; + version: number; + size: number; + vsize: number; + weight: number; + locktime: number; + vin: (Vin | VinCoinbase)[]; + vout: Vout[]; +}; + +type BitcoinBlock = { + hash: string; + confirmations: number; + size: number; + strippedsize: number; + weight: number; + height: number; + version: number; + versionHex: string; + merkleroot: string; + time: number; + mediantime: number; + nonce: number; + bits: string; + difficulty: number; + chainwork: string; + nTx: number; + previousblockhash: string; +}; + +type GetBlockReturn = T extends { verbosity: 0 } + ? string + : T extends { verbosity: 1 } + ? { tx: string[] } & BitcoinBlock + : T extends { verbosity: 2 } + ? { tx: Tx[] } & BitcoinBlock + : { tx: string[] } & BitcoinBlock; + +class BitcoinRpcClient { + constructor(private readonly _rpc: RPCClient) {} + + getbestblockhash(): Promise { + return this._rpc.getbestblockhash(); + } + + async getblockchaintype(): Promise { + const { chain } = await this._rpc.getblockchaininfo(); + switch (chain) { + case 'main': + return Chain.MAINNET; + case 'test': + return Chain.TESTNET; + case 'signet': + return Chain.SIGNET; + case 'regtest': + return Chain.REGTEST; + default: + return Chain.MAINNET; + } + } + + getblock({ + verbosity, + blockhash, + }: T): Promise> { + return this._rpc.getblock({ verbosity, blockhash }); + } +} + +export * from './types'; + +export class RunestoneIndexer { + private readonly _storage: RunestoneStorage; + private readonly _rpc: BitcoinRpcClient; private readonly _pollIntervalMs: number; private _started: boolean; + private _chain: Chain; private _intervalId: NodeJS.Timeout | null = null; constructor(options: RunestoneIndexerOptions) { - this._rpc = new RPCClient({ - url: options.bitcoinRpc.url, - user: options.bitcoinRpc.user, - pass: options.bitcoinRpc.pass, - port: options.bitcoinRpc.port, - }); + this._rpc = new BitcoinRpcClient(new RPCClient(options.bitcoinRpc)); this._storage = options.storage; this._started = false; + this._chain = Chain.MAINNET; this._pollIntervalMs = Math.max(options.pollIntervalMs ?? 10000, 1); } @@ -88,9 +123,15 @@ export class RunestoneIndexer implements IRunestoneIndexer { } await this._storage.connect(); - const checkpoint = await this._storage.loadCheckpoint(); this._started = true; + + this._chain = await this._rpc.getblockchaintype(); + + this._intervalId = setInterval( + () => this.updateRuneUtxoBalances(), + this._pollIntervalMs + ); } async stop(): Promise { @@ -106,4 +147,89 @@ export class RunestoneIndexer implements IRunestoneIndexer { await this._storage.disconnect(); this._started = false; } + + private async updateRuneUtxoBalances() { + const newBlockhashesToIndex: string[] = []; + + const currentStorageBlock = await this._storage.getCurrentBlock(); + if (currentStorageBlock != null) { + // If rpc block indexing is ahead of our storage, let's save up all block hashes + // until we arrive back to the current storage's block tip. + const bestblockhash: string = await this._rpc.getbestblockhash(); + let rpcBlock = await this._rpc.getblock({ + blockhash: bestblockhash, + verbosity: 1, + }); + while (rpcBlock.height > currentStorageBlock.height) { + newBlockhashesToIndex.push(rpcBlock.hash); + + rpcBlock = await this._rpc.getblock({ + blockhash: rpcBlock.previousblockhash, + verbosity: 1, + }); + } + + // Handle edge case where storage block height is higher than rpc node block + // (such as pointing to a newly indexing rpc node) + let storageBlockhash = + currentStorageBlock && currentStorageBlock.height === rpcBlock.height + ? currentStorageBlock.hash + : await this._storage.getBlockhash(rpcBlock.height); + + // Now rpc and storage blocks are at the same height, + // iterate until they are also the same hash + while (rpcBlock.hash !== storageBlockhash) { + newBlockhashesToIndex.push(rpcBlock.hash); + + rpcBlock = await this._rpc.getblock({ + blockhash: rpcBlock.previousblockhash, + verbosity: 1, + }); + storageBlockhash = await this._storage.getBlockhash(rpcBlock.height); + } + + // We can reset our storage state to where rpc node and storage matches + if (currentStorageBlock && currentStorageBlock.hash !== rpcBlock.hash) { + await this._storage.resetCurrentBlock(rpcBlock); + } + } else { + const firstRuneHeight = Chain.getFirstRuneHeight(this._chain); + + // Iterate through the rpc blocks until we reach first rune height + const bestblockhash: string = await this._rpc.getbestblockhash(); + let rpcBlock = await this._rpc.getblock({ + blockhash: bestblockhash, + verbosity: 1, + }); + while (rpcBlock.height >= firstRuneHeight) { + newBlockhashesToIndex.push(rpcBlock.hash); + + rpcBlock = await this._rpc.getblock({ + blockhash: rpcBlock.previousblockhash, + verbosity: 1, + }); + } + } + + // Finally start processing balances using newBlockhashesToIndex + let blockhash = newBlockhashesToIndex.pop(); + while (blockhash !== undefined) { + const block = await this._rpc.getblock({ blockhash, verbosity: 2 }); + const runeBlockIndex: RuneBlockIndex = { + block, + etchings: [], + mints: [], + utxoBalances: [], + }; + + // TODO: implement retrieving etchings, mints, and utxo balances + // look through each transaction + // check if any runestones + // also check if any balances on inputs + // if balance with no runestone, done, transfer to first non op return output + + await this._storage.saveBlockIndex(runeBlockIndex); + blockhash = newBlockhashesToIndex.pop(); + } + } } diff --git a/src/indexer/types.ts b/src/indexer/types.ts new file mode 100644 index 0000000..d98b939 --- /dev/null +++ b/src/indexer/types.ts @@ -0,0 +1,113 @@ +export interface RunestoneStorage { + /** + * Connect to the storage backend, called at indexer startup. + */ + connect(): Promise; + + /** + * Disconnect from the storage backend, called at indexer shutdown. + */ + disconnect(): Promise; + + /** + * Get indexed block hash at specified block height + * @param blockHeight the block height + */ + getBlockhash(blockHeight: number): Promise; + + /** + * Get the most recently indexed block's index and hash stored in IRunestoneStorage. + */ + getCurrentBlock(): Promise; + + /** + * Reset the most recent index block to a previous block height/hash by unindexing all blocks + * following the specified block (this is used to handle reorgs). + * @param block the block height and hash to reset current block to + */ + resetCurrentBlock(block: BlockInfo): Promise; + + /** + * Save new utxo balances for the given block. + * @param balances the block with all the new utxo balances + */ + saveBlockIndex(balances: RuneBlockIndex): Promise; + + /** + * Get the etching that deployed the rune if it exists. + * @param rune rune string representation + */ + getEtching(rune: string): Promise; + + /** + * Get the total valid mint counts for rune. + * @param rune rune string representation + */ + getValidMintCount(rune: string): Promise; + + /** + * Get the rune balance for the given UTXO. + * @param rune rune string representation + * @param txid transaction id + * @param vout output index in transaction + */ + getUtxoBalance( + rune: string, + txid: string, + vout: number + ): Promise; +} + +export type RunestoneIndexerOptions = { + bitcoinRpc: { + url: string; + user: string; + pass: string; + port: number; + }; + storage: RunestoneStorage; + /** + * The interval at which to poll the RPC for new blocks, in milliseconds. + * Defaults to `10000` (10 seconds), and must be positive. + */ + pollIntervalMs?: number; +}; + +export type BlockInfo = { + height: number; + hash: string; +}; + +export type RuneUtxoBalance = { + txid: string; + vout: number; + address?: string; + scriptPubKey: Buffer; + rune: string; + amount: bigint; +}; + +export type RuneEtching = { + rune: string; + divisibility: number; + spacers: number[]; + symbol?: string; + mint?: { + deadline?: number; + limit?: bigint; + term?: number; + }; +}; + +export type RuneMint = { + rune: string; + txid: string; + valid: boolean; +}; + +export type RuneBlockIndex = { + block: BlockInfo; + etchings: RuneEtching[]; + mints: RuneMint[]; + utxoBalances: RuneUtxoBalance[]; +}; diff --git a/test/indexer/indexer.test.ts b/test/indexer/indexer.test.ts deleted file mode 100644 index 31487c0..0000000 --- a/test/indexer/indexer.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { mock } from 'jest-mock-extended'; -import { IRunestoneStorage, RunestoneIndexer } from '../../src/indexer'; - -describe('RunestoneIndexer', () => { - it('should start and stop', async () => { - const storage = mock(); - const indexer = new RunestoneIndexer({ - storage, - bitcoinRpc: { - url: 'http://localhost:8332', - user: 'user', - pass: 'pass', - port: 8332, - }, - }); - - await indexer.start(); - - expect(storage.connect).toHaveBeenCalledTimes(1); - expect(storage.loadCheckpoint).toHaveBeenCalledTimes(1); - - await indexer.stop(); - - expect(storage.disconnect).toHaveBeenCalledTimes(1); - }); -});