Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Clean up indexer scaffolding logic #8

Merged
merged 2 commits into from
Mar 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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