@atomiqlabs/btc-mempool
Version:
Connector and synchronizer using mempool.space API for bitcoin
597 lines (550 loc) • 18 kB
text/typescript
import {Buffer} from "buffer";
import {MempoolApiError} from "../errors/MempoolApiError";
import {BitcoinNetwork, tryWithRetries} from "@atomiqlabs/base";
/**
* Bitcoin transaction confirmation status
*/
export type BitcoinTransactionStatus = {
confirmed: boolean,
block_height: number,
block_hash: string,
block_time: number
};
/**
* Bitcoin transaction output
*/
export type TxVout = {
scriptpubkey: string,
scriptpubkey_asm: string,
scriptpubkey_type: string,
scriptpubkey_address: string,
value: number
};
/**
* Bitcoin transaction input
*/
export type TxVin = {
txid: string,
vout: number,
prevout: TxVout,
scriptsig: string,
scriptsig_asm: string,
witness: string[],
is_coinbase: boolean,
sequence: number,
inner_witnessscript_asm: string
};
/**
* Full Bitcoin transaction data
*/
export type BitcoinTransaction = {
txid: string,
version: number,
locktime: number,
vin: TxVin[],
vout: TxVout[],
size: number,
weight: number,
fee: number,
status: BitcoinTransactionStatus
};
/**
* Bitcoin block data
*/
export type BlockData = {
bits: number,
difficulty: number,
extras: any,
height: number,
id: string,
mediantime: number,
merkle_root: string,
nonce: number,
previousblockhash: string,
size: number,
timestamp: number,
tx_count: number,
version: number,
weight: number
}
/**
* Bitcoin block header data
*/
export type BitcoinBlockHeader = {
id: string,
height: number,
version: number,
timestamp: number,
tx_count: number,
size: number,
weight: number,
merkle_root: string,
previousblockhash: string,
mediantime: number,
nonce: number,
bits: number,
difficulty: number
};
/**
* Lightning network node info
*/
export type LNNodeInfo = {
public_key: string,
alias: string,
first_seen: number,
updated_at: number,
color: string,
sockets: string,
as_number: number,
city_id: number,
country_id: number,
subdivision_id: number,
longtitude: number,
latitude: number,
iso_code: string,
as_organization: string,
city: {[lang: string]: string},
country: {[lang: string]: string},
subdivision: {[lang: string]: string},
active_channel_count: number,
capacity: string,
opened_channel_count: number,
closed_channel_count: number
};
/**
* Address information as returned from the mempool api
*/
export type AddressInfo = {
address: string;
chain_stats: {
funded_txo_count: number;
funded_txo_sum: number;
spent_txo_count: number;
spent_txo_sum: number;
tx_count: number;
};
mempool_stats: {
funded_txo_count: number;
funded_txo_sum: number;
spent_txo_count: number;
spent_txo_sum: number;
tx_count: number;
};
};
/**
* Transaction CPFP data response as returned from the mempool.space API
*/
export type TransactionCPFPData = {
ancestors: {
txid: string,
fee: number,
weight: number
}[],
descendants: {
txid: string,
fee: number,
weight: number
}[],
effectiveFeePerVsize: number,
sigops: number,
adjustedVsize: number
};
/**
* Bitcoin fees data response as returned from the mempool.space API
*/
export type BitcoinFees = {
fastestFee: number,
halfHourFee: number,
hourFee: number,
economyFee: number,
minimumFee: number
};
/**
* Pending (predicted next block based on the current mempool) block data response as returned from the mempool.space API
*/
export type BitcoinPendingBlock = {
blockSize: number,
blockVSize: number,
nTx: number,
totalFees: number,
medianFee: number,
feeRange: number[]
};
/**
* Block status as returned from the mempool.space API
*/
export type BlockStatus = {
in_best_chain: boolean,
height: number,
next_best: string
};
/**
* Transaction merkle proof as returned by the mempool.space API
*/
export type TransactionProof = {
block_height: number,
merkle: string[],
pos: number
};
/**
* Transaction output spend as returned by the mempool.space API
*/
export type TransactionOutspend = {
spent: boolean,
txid: string,
vin: number,
status: BitcoinTransactionStatus
};
const MempoolApiEndpoints: {[network in BitcoinNetwork]?: string[]} = {
[BitcoinNetwork.MAINNET]: [
"https://mempool.space/api/",
"https://mempool.fra.mempool.space/api/",
"https://mempool.va1.mempool.space/api/",
"https://mempool.tk7.mempool.space/api/"
],
[BitcoinNetwork.TESTNET]: [
"https://mempool.space/testnet/api/",
"https://mempool.fra.mempool.space/testnet/api/",
"https://mempool.va1.mempool.space/testnet/api/",
"https://mempool.tk7.mempool.space/testnet/api/"
],
[BitcoinNetwork.TESTNET4]: [
"https://mempool.space/testnet4/api/",
"https://mempool.fra.mempool.space/testnet4/api/",
"https://mempool.va1.mempool.space/testnet4/api/",
"https://mempool.tk7.mempool.space/testnet4/api/"
]
}
/**
* Mempool.space REST API client for Bitcoin blockchain data
*
* @category Bitcoin
*/
export class MempoolApi {
backends: {
url: string,
operational: boolean | null
}[];
timeout: number;
/**
* Returns api url that should be operational
*
* @private
*/
private getOperationalApi(): {url: string, operational: boolean | null} | undefined {
return this.backends.find(e => e.operational===true);
}
/**
* Returns api urls that are maybe operational, in case none is considered operational returns all of the price
* apis such that they can be tested again whether they are operational
*
* @private
*/
private getMaybeOperationalApis(): {url: string, operational: boolean | null}[] {
let operational = this.backends.filter(e => e.operational===true || e.operational===null);
if(operational.length===0) {
this.backends.forEach(e => e.operational=null);
operational = this.backends;
}
return operational;
}
/**
* Sends a GET or POST request to the mempool api, handling the non-200 responses as errors & throwing
*
* @param url
* @param path
* @param responseType
* @param type
* @param body
*/
private async _request<T>(
url: string,
path: string,
responseType: T extends string ? "str" : "obj",
type: "GET" | "POST" = "GET",
body?: string | any
) : Promise<T> {
const response: Response = await fetch(url+path, {
method: type,
signal: AbortSignal.timeout(this.timeout),
body: typeof(body)==="string" ? body : JSON.stringify(body)
});
if(response.status!==200) {
let resp: string;
try {
resp = await response.text();
} catch (e) {
throw new MempoolApiError(response.statusText, response.status);
}
throw new MempoolApiError(resp, response.status);
}
if(responseType==="str") return await response.text() as any;
return await response.json();
}
/**
* Sends request in parallel to multiple maybe operational api urls
*
* @param path
* @param responseType
* @param type
* @param body
* @private
*/
private async requestFromMaybeOperationalUrls<T>(
path: string,
responseType: T extends string ? "str" : "obj",
type: "GET" | "POST" = "GET",
body?: string | any
) : Promise<T> {
try {
return await Promise.any<T>(this.getMaybeOperationalApis().map(
obj => (async () => {
try {
const result = await this._request<T>(obj.url, path, responseType, type, body);
obj.operational = true;
return result;
} catch (e) {
//Only mark as non operational on 5xx server errors!
if(e instanceof MempoolApiError && Math.floor(e.httpCode/100)!==5) {
obj.operational = true;
throw e;
} else {
obj.operational = false;
throw e;
}
}
})()
))
} catch (_e: any) {
const e = _e as AggregateError;
throw e.errors.find(err => err instanceof MempoolApiError && Math.floor(err.httpCode/100)!==5) || e.errors[0];
}
}
/**
* Sends a request to mempool API, first tries to use the operational API (if any) and if that fails it falls back
* to using maybe operational price APIs
*
* @param path
* @param responseType
* @param type
* @param body
* @private
*/
private async request<T>(
path: string,
responseType: T extends string ? "str" : "obj",
type: "GET" | "POST" = "GET",
body?: string | any
) : Promise<T> {
return tryWithRetries<T>(() => {
const operationalPriceApi = this.getOperationalApi();
if(operationalPriceApi!=null) {
return this._request(operationalPriceApi.url, path, responseType, type, body).catch(err => {
//Only retry on 5xx server errors!
if(err instanceof MempoolApiError && Math.floor(err.httpCode/100)!==5) throw err;
operationalPriceApi.operational = false;
return this.requestFromMaybeOperationalUrls(path, responseType, type, body);
});
}
return this.requestFromMaybeOperationalUrls(path, responseType, type, body);
}, undefined, (err: any) => err instanceof MempoolApiError && Math.floor(err.httpCode/100)!==5);
}
constructor(network: BitcoinNetwork, timeout?: number);
constructor(url: string | string[], timeout?: number);
constructor(urlOrNetwork: BitcoinNetwork | string | string[], timeout?: number) {
if(typeof(urlOrNetwork)==="number") {
const endpoints = MempoolApiEndpoints[urlOrNetwork];
if(endpoints==null)
throw new Error(`No default endpoints found for ${BitcoinNetwork[urlOrNetwork]} network, please pass the manually as string or string[]`);
this.backends = endpoints.map(val => ({url: val, operational: null}))
} else {
if(Array.isArray(urlOrNetwork)) {
this.backends = urlOrNetwork.map(val => ({url: val, operational: null}));
} else {
this.backends = [{url: urlOrNetwork, operational: null}];
}
}
this.timeout = timeout ?? 15*1000;
}
/**
* Returns information about a specific lightning network node as identified by the public key (in hex encoding)
*
* @param pubkey
*/
getLNNodeInfo(pubkey: string): Promise<LNNodeInfo | null> {
//500, 200
return this.request<LNNodeInfo>("v1/lightning/nodes/"+pubkey, "obj").catch((e: MempoolApiError) => {
if(e.responseMessage==="This node does not exist, or our node is not seeing it yet") return null;
throw e;
});
}
/**
* Returns on-chain transaction as identified by its txId
*
* @param txId
*/
getTransaction(txId: string): Promise<BitcoinTransaction | null> {
//404 ("Transaction not found"), 200
return this.request<BitcoinTransaction>("tx/"+txId, "obj").catch((e: MempoolApiError) => {
if(e.responseMessage==="Transaction not found") return null;
throw e;
});
}
/**
* Returns raw binary encoded bitcoin transaction, also strips the witness data from the transaction
*
* @param txId
*/
async getRawTransaction(txId: string): Promise<Buffer | null> {
//404 ("Transaction not found"), 200
const rawTransaction: string | null = await this.request<string>("tx/"+txId+"/hex", "str").catch((e: MempoolApiError) => {
if(e.responseMessage==="Transaction not found") return null;
throw e;
});
return rawTransaction==null ? null : Buffer.from(rawTransaction, "hex")
}
/**
* Returns confirmed & unconfirmed balance of the specific bitcoin address
*
* @param address
*/
async getAddressBalances(address: string): Promise<{
confirmedBalance: bigint,
unconfirmedBalance: bigint
}> {
//400 ("Invalid Bitcoin address"), 200
const jsonBody = await this.request<AddressInfo>("address/"+address, "obj");
const confirmedInput = BigInt(jsonBody.chain_stats.funded_txo_sum);
const confirmedOutput = BigInt(jsonBody.chain_stats.spent_txo_sum);
const unconfirmedInput = BigInt(jsonBody.mempool_stats.funded_txo_sum);
const unconfirmedOutput = BigInt(jsonBody.mempool_stats.spent_txo_sum);
return {
confirmedBalance: confirmedInput - confirmedOutput,
unconfirmedBalance: unconfirmedInput - unconfirmedOutput
}
}
/**
* Returns CPFP (children pays for parent) data for a given transaction
*
* @param txId
*/
getCPFPData(txId: string): Promise<TransactionCPFPData> {
//200
return this.request<TransactionCPFPData>("v1/cpfp/"+txId, "obj");
}
/**
* Returns UTXOs (unspent transaction outputs) for a given address
*
* @param address
*/
async getAddressUTXOs(address: string): Promise<{
txid: string,
vout: number,
status: {
confirmed: boolean,
block_height: number,
block_hash: string,
block_time: number
},
value: bigint
}[]> {
//400 ("Invalid Bitcoin address"), 200
let jsonBody = await this.request<any[]>("address/"+address+"/utxo", "obj");
jsonBody.forEach(e => e.value = BigInt(e.value));
return jsonBody;
}
/**
* Returns current on-chain bitcoin fees
*/
getFees(): Promise<BitcoinFees> {
//200
return this.request<BitcoinFees>("v1/fees/recommended", "obj");
}
/**
* Returns all transactions for a given address
*
* @param address
*/
getAddressTransactions(address: string): Promise<BitcoinTransaction[]> {
//400 ("Invalid Bitcoin address"), 200
return this.request<BitcoinTransaction[]>("address/"+address+"/txs", "obj");
}
/**
* Returns expected pending (mempool) blocks
*/
getPendingBlocks(): Promise<BitcoinPendingBlock[]> {
//200
return this.request<BitcoinPendingBlock[]>("v1/fees/mempool-blocks", "obj");
}
/**
* Returns the blockheight of the current bitcoin blockchain's tip
*/
async getTipBlockHeight() : Promise<number> {
//200
const response: string = await this.request<string>("blocks/tip/height", "str");
return parseInt(response);
}
/**
* Returns the bitcoin blockheader as identified by its blockhash
*
* @param blockhash
*/
getBlockHeader(blockhash: string): Promise<BitcoinBlockHeader> {
//404 ("Block not found"), 200
return this.request<BitcoinBlockHeader>("block/"+blockhash, "obj");
}
/**
* Returns the block status
*
* @param blockhash
*/
getBlockStatus(blockhash: string): Promise<BlockStatus> {
//200
return this.request<BlockStatus>("block/"+blockhash+"/status", "obj");
}
/**
* Returns the transaction's proof (merkle proof)
*
* @param txId
*/
getTransactionProof(txId: string) : Promise<TransactionProof> {
//404 ("Transaction not found or is unconfirmed"), 200
return this.request<TransactionProof>("tx/"+txId+"/merkle-proof", "obj");
}
/**
* Returns the transaction's proof (merkle proof)
*
* @param txId
*/
getOutspends(txId: string) : Promise<TransactionOutspend[]> {
//404 ("Transaction not found"), 200
return this.request<TransactionOutspend[]>("tx/"+txId+"/outspends", "obj");
}
/**
* Returns blockhash of a block at a specific blockheight
*
* @param height
*/
getBlockHash(height: number): Promise<string> {
//404 ("Block not found"), 200
return this.request<string>("block-height/"+height, "str");
}
/**
* Returns past 15 blockheaders before (and including) the specified height
*
* @param endHeight
*/
getPast15BlockHeaders(endHeight: number) : Promise<BlockData[]> {
//200
return this.request<BlockData[]>("v1/blocks/"+endHeight, "obj");
}
/**
* Sends raw hex encoded bitcoin transaction
*
* @param transactionHex
*/
sendTransaction(transactionHex: string): Promise<string> {
//400??, 200
return this.request<string>("tx", "str", "POST", transactionHex);
}
}