Skip to content

Commit

Permalink
Clean up indexer scaffolding logic (#8)
Browse files Browse the repository at this point in the history
  • Loading branch information
summraznboi authored Mar 22, 2024
1 parent 6c1f356 commit 3dad3ed
Show file tree
Hide file tree
Showing 6 changed files with 311 additions and 115 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './indexer';
19 changes: 0 additions & 19 deletions src/indexer/events.ts

This file was deleted.

264 changes: 195 additions & 69 deletions src/indexer/index.ts
Original file line number Diff line number Diff line change
@@ -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<void>;

/**
* Stop the indexer, waiting for any in-progress operations to complete before returning.
*/
stop(): Promise<void>;
}
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<void>;

/**
* Disconnect from the storage backend, called at indexer shutdown.
*/
disconnect(): Promise<void>;

/**
* 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<boolean>;

/**
* 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<boolean>;

/**
* 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> = 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<string> {
return this._rpc.getbestblockhash();
}

async getblockchaintype(): Promise<Chain> {
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<T extends GetBlockParams>({
verbosity,
blockhash,
}: T): Promise<GetBlockReturn<T>> {
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);
}

Expand All @@ -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<void> {
Expand All @@ -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();
}
}
}
Loading

0 comments on commit 3dad3ed

Please sign in to comment.