/** chainweb.js
* Exports functions to support interacting with a chainweb block chain
* Author: Lar Kuhtz
* @module chainweb
*/
/* ************************************************************************** */
/* Dependencies */
/* External */
const base64url = require("base64-url");
const fetch = require("node-fetch");
const EventSource = require('eventsource')
const pRetry = require('p-retry');
/* Internal */
const HeaderBuffer = require('./HeaderBuffer');
/* ************************************************************************** */
/* Utils */
/**
* Decode base64url encoded JSON text
*
* @param {string} txt - base64url encoded json text
*/
const base64json = txt => JSON.parse(base64url.decode(txt));
/**
* Retry a fetch callback
*
* @param {Object} [retryOptions] - retry options object as accepted by the retry package
* @param {boolean} [retryOptions.retry404=false] - whether to retry on 404 results
* @return {Promise} Promise object that represents the response of the fetch action.
*/
const retryFetch = async (retryOptions, fetchAction) => {
let retry404 = false;
if (retryOptions) {
retry404 = retryOptions.retry404 ?? false;
} else {
retryOptions = {
onFailedAttempt: x => console.log(x),
retries: 2,
minTimeout: 500,
randomize: true
};
}
const run = async () => {
const response = await fetchAction();
if (response.status == 200) {
return response;
// retry 404 if requested
} else if (response.status == 404 && retry404) { // not found
throw response
// retry potentially ephemeral failure conditions
} else if (response.status == 408) { // response timeout
throw response
} else if (response.status == 423) { // locked
throw response
} else if (response.status == 425) { // too early
throw response
} else if (response.status == 429) { // too many requests
throw response
} else if (response.status == 500) { // internal server error
throw response
} else if (response.status == 502) { // bad gateway
throw response
} else if (response.status == 503) { // service unavailable
throw response
} else if (response.status == 504) { // gateway timeout
throw response
// don't retry on anything else
} else if (response.status == 204) { // no content
return pRetry.AbortError(response);
} else {
return pRetry.AbortError(response);
}
}
const res = await pRetry(run, retryOptions);
return res;
}
/**
* Create URL for the Chainweb API
*
* @param {string} [network="mainnet01"] - chainweb network
* @param {string} [host="https://api.chainweb.com"] - chainweb api host
* @param {string} pathSuffix - suffix of the path that is appended to the path of the base URL
* @return {Object} URL
*/
const baseUrl = (network = "mainnet01", host = "https://api.chainweb.com", pathSuffix) => {
return new URL(`${host}/chainweb/0.0/${network}/${pathSuffix}`);
}
/**
* Create URL for a chain endpoint of the Chainweb API
*
* @param {number|string} chainId - a chain id that is valid for the network
* @param {string} [network="mainnet01"] - chainweb network
* @param {string} [host="https://api.chainweb.com"] - chainweb api host
* @param {string} pathSuffix - suffix of the path that is appended to the path of the chain URL
* @return {Object} URL
*/
const chainUrl = (chainId, network, host, pathSuffix) => {
if (chainId == null) {
throw "missing chainId parameter";
}
return baseUrl(network, host, `chain/${chainId}/${pathSuffix}`);
}
/* ************************************************************************** */
/* Chainweb API Requests */
/**
* Cut the current cut from a chainweb node
*
* @param {string} [network="mainnet01"] - chainweb network
* @param {string} [host="https://api.chainweb.com"] - chainweb api host
* @param {Object} [retryOptions] - retry options object as accepted by the retry package
* @param {boolean} [retryOptions.retry404=false] - whether to retry on 404 results
* @return {Object} cut hashes object
*
* @alias module:chainweb.cut.current
*/
const currentCut = async (network, host, retryOptions) => {
const response = await retryFetch(
retryOptions,
() => fetch(baseUrl(network, host, "cut"))
);
return response.json();
}
/**
* P2P peers of the cut network
*
* @param {string} [network="mainnet01"] - chainweb network
* @param {string} [host="https://api.chainweb.com"] - chainweb api host
* @param {Object} [retryOptions] - retry options object as accepted by the retry package
* @param {boolean} [retryOptions.retry404=false] - whether to retry on 404 results
* @return {Object[]} Array of peer objects
*
* TODO: support paging
*
* @alias module:chainweb.cut.peers
*/
const cutPeers = async (network, host, retryOptions) => {
const response = await retryFetch(
retryOptions,
() => fetch(baseUrl(network, host, "cut/peer"))
);
return response.json();
}
/**
* Return block headers from chain in decending order
*
* @param {number|string} chainId - a chain id that is valid for the network
* @param {string[]} [upper]- only antecessors of these block hashes are returned. Note that if this is null, the result is empty.
* @param {string[]} [lower] - no antecessors of these block hashes are returned.
* @param {number} [minHeight] - if given, minimum height of returned headers
* @param {number} [maxHeight] - if given, maximum height of returned headers
* @param {number} [n] - if given, limits the number of results. This is an upper limit. The actual number of returned items can be lower.
* @param {string} [network="mainnet01"] - chainweb network
* @param {string} [host="https://api.chainweb.com"] - chainweb api host
* @param {Object} [retryOptions] - retry options object as accepted by the retry package
* @param {boolean} [retryOptions.retry404=false] - whether to retry on 404 results
* @return {Object[]} Array of block header objects
*
* TODO: support paging
*
* @alias module:chainweb.branch
*/
const branch = async (chainId, upper, lower, minHeight, maxHeight, n, network, host, retryOptions) => {
/* URL */
let url = chainUrl(chainId, network, host, "header/branch");
if (minHeight != null) {
url.searchParams.append("minheight", minHeight);
}
if (maxHeight != null) {
url.searchParams.append("maxheight", maxHeight);
}
if (n != null) {
url.searchParams.append("limit", n);
}
/* Body */
const body = {
upper: upper,
lower: lower
};
const response = await retryFetch(
retryOptions,
() => fetch(url, {
method: 'post',
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json;blockheader-encoding=object'
}
})
);
return response.json();
}
/**
* Payloads with outputs
*
* @param {number|string} chainId - a chain id that is valid for the network
* @param {string[]} hashes - array of block payload hashes
* @param {string} [network="mainnet01"] - chainweb network
* @param {string} [host="https://api.chainweb.com"] - chainweb api host
* @param {Object} [retryOptions] - retry options object as accepted by the retry package
* @param {boolean} [retryOptions.retry404=false] - whether to retry on 404 results
* @return {Object[]} Array of block header objects
*
* TODO: support paging
*
* @alias module:chainweb.payloads
*/
const payloads = async (chainId, hashes, network, host, retryOptions) => {
const url = chainUrl(chainId, network, host, `payload/outputs/batch`);
const response = await retryFetch(
retryOptions,
() => fetch(url, {
method: 'post',
body: JSON.stringify(hashes),
headers: {
'Content-Type': 'application/json'
}
})
);
let res = await response.json();
return res.map(x => {
const txs = x.transactions;
x.minerData = base64json(x.minerData);
x.coinbase = base64json(x.coinbase);
x.transactions = txs.map(y => {
const tx = base64json(y[0]);
const out = base64json(y[1]);
tx.cmd = JSON.parse(tx.cmd);
return {
transaction: tx,
output: out
};
});
return x;
});
}
/**
* Callback for processing individual items of an updates stream
*
* @callback updatesCallback
* @param {Object} update - update object
*/
/**
* @param {headerCallback} callback - function that is called for each update
* @param {string} [network="mainnet01"] - chainweb network
* @param {string} [host="https://api.chainweb.com"] - chainweb api host
*/
const headerUpdates = (callback, network, host) => {
const url = baseUrl(network, host, "header/updates");
const es = new EventSource(`${url}`);
es.onerror = (err) => { throw err; };
es.addEventListener('BlockHeader', m => callback(JSON.parse(m.data)));
return es;
}
/**
* Apply callback to new updates.
*
* Same as headerUpdates, but filters for chains and only processes header
* updates that have reached the given confirmation depth in the chain.
*
* @param {number} depth - confirmation depth at which blocks are yielded
* @param {number[]} chainIds - array of chainIds from which blocks are included
* @param {blockCallback} callback - function that is called for each update
* @param {string} [network="mainnet01"] - chainweb network
* @param {string} [host="https://api.chainweb.com"] - chainweb api host
* @returns the event source object the backs the stream
*/
const chainUpdates = (depth, chainIds, callback, network, host) => {
let bs = {};
chainIds.forEach(x => bs[x] = new HeaderBuffer(depth, callback));
return headerUpdates(
hdr => bs[hdr.header.chainId]?.add(hdr),
network,
host
);
}
/* ************************************************************************** */
/* Headers */
/**
* Headers from a range of block heights
*
* @param {number|string} chainId - a chain id that is valid for the network
* @param {number} start - start block height
* @param {number} end - end block height
* @param {string} [network="mainnet01"] - chainweb network
* @param {string} [host="https://api.chainweb.com"] - chainweb api host
* @return {Promise} Array of block headers
*
* TODO: support paging
*
* @alias module:chainweb.header.range
*/
const headers = async (chainId, start, end, network, host) => {
const cut = await currentCut(network, host);
return branch(
chainId,
[cut.hashes[`${chainId}`].hash],
[],
start,
end,
null,
network,
host
)
.then(x => x.items);
}
/**
* Recent Headers
*
* @param {number|string} chainId - a chain id that is valid for the network
* @param {number} depth - confirmation depth. Only headers at this depth are returned
* @param {number} n - maximual number of headers that are returned. The actual number of returned headers may be lower.
* @param {string} [network="mainnet01"] - chainweb network
* @param {string} [host="https://api.chainweb.com"] - chainweb api host
* @return {Promise} Array of headers
*
* TODO: support paging
*
* @alias module:chainweb.header.recent
*/
const recentHeaders = async (chainId, depth = 0, n = 1, network, host) => {
const cut = await currentCut(network, host);
return branch(
chainId,
[cut.hashes[`${chainId}`].hash],
[],
cut.hashes['0'].height - depth - n + 1,
cut.hashes['0'].height - depth,
n,
network,
host
)
.then(x => x.items);
}
/**
* Callback for processing individual items of a header stream
*
* @callback headerCallback
* @param {Object} header - header object
*/
/**
* Apply callback to new header.
*
* @param {number} depth - confirmation depth at which blocks are yielded
* @param {number[]} chainIds - array of chainIds from which blocks are included
* @param {blockCallback} callback - function that is called for each header
* @param {string} [network="mainnet01"] - chainweb network
* @param {string} [host="https://api.chainweb.com"] - chainweb api host
* @returns the event source object the backs the stream
*
* @alias module:chainweb.header.stream
*/
const headerStream = (depth, chainIds, callback, network, host) => {
return chainUpdates(depth, chainIds, u => callback(u.header), network, host);
}
/* No guarantee on order
*/
const headerStreamSince = (start, depth, chainId, callback, network, host) => {
// loop while (streamStart == null || a < streamStart)
const a = start - 1;
const streamStart = null;
// find recent upper bound (cur - depth)
// initialize header buffer (disabled check for continuous blocks)
// make sure to insert start as first item
// query entries for each gap that is smaller than streamStart
// start catching up to upper bound
// stream
const hdrs1p = headerStream(depth, [chainId], callback, network, host);
// catch up to the first block of the stream
while (streamStart == null || a < streamStart - 1) {
const hdrs0p = headers(chainId, start, null, network, host)
.then(x => { callback(x); a = Math.max(a, x.height); });
}
// release stream
return chainUpdates(depth, chainIds, u => callback(u.header), network, host);
}
/**
* Query block header by its block hash
*
* @param {number|string} chainId - a chain id that is valid for the network
* @param {string} hash - block hash
* @param {string} [network="mainnet01"] - chainweb network
* @param {string} [host="https://api.chainweb.com"] - chainweb api host
* @return {Promise}
*
* @alias module:chainweb.header.hash
*/
const headerByBlockHash = (chainId, hash, network, host) =>
branch(chainId, [hash], [], null, null, 1).then(x => x.items[0]);
/* ************************************************************************** */
/* Blocks */
/**
* Utility function for collecting the payloads with outputs for a set
* of headers from the same chain.
*
* TODO: Currently all blocks must be from the same chain. We should support
* blocks from different chains.
*/
const headers2blocks = async (hdrs, network, host, retryOptions) => {
if (hdrs.length === 0) {
return [];
}
const chainId = hdrs[0].chainId;
const pays = await payloads(
chainId,
hdrs.map(x => x.payloadHash),
network,
host,
retryOptions
);
if (hdrs.length !== pays.length) {
throw `failed to get payload for some blocks. Requested ${hdrs.length} payloads but got only ${pays.length}`
}
let result = [];
for (let i = 0; i < hdrs.length; ++i) {
const hdr = hdrs[i], pay = pays[i];
if (pays[i].payloadHash == hdrs[i].payloadHash) {
result.push({
header: hdr,
payload: pay
});
} else {
throw `failed to get payload for block hash ${hdr.hash} at height ${hdr.height}`
}
}
return result;
}
/**
* Blocks from a range of block heights
*
* @param {number|string} chainId - a chain id that is valid for the network
* @param {number} start - start block height
* @param {number} end - end block height
* @param {string} [network="mainnet01"] - chainweb network
* @param {string} [host="https://api.chainweb.com"] - chainweb api host
* @return {Promise} Array of blocks
*
* TODO: support paging
*
* @alias module:chainweb.block.range
*/
const blocks = async (chainId, start, end, network, host) => {
let hdrs = await headers(chainId, start, end, network, host);
return headers2blocks(hdrs, network, host);
}
/**
* Recent Blocks
*
* @param {number|string} chainId - a chain id that is valid for the network
* @param {number} depth - confirmation depth. Only blocks at this depth are returned
* @param {number} n - maximual number of blocks that are returned. The actual number of returned blocks may be lower.
* @param {string} [network="mainnet01"] - chainweb network
* @param {string} [host="https://api.chainweb.com"] - chainweb api host
* @return {Promise} Array of blocks
*
* TODO: support paging
*
* @alias module:chainweb.block.recent
*/
const recentBlocks = async (chainId, depth = 0, n = 1, network, host) => {
let hdrs = await recentHeaders(chainId, depth, n, network, host);
let ro = {}
if (depth <= 1) {
ro = { retry404: true, minTimeout: 1000 };
}
return headers2blocks(hdrs, network, host, ro);
}
/**
* Callback for processing individual items of a block stream
*
* @callback blockCallback
* @param {Object} block - block object
*/
/**
* Apply callback to new blocks.
*
* @param {number} depth - confirmation depth at which blocks are yielded
* @param {number[]} chainIds - array of chainIds from which blocks are included
* @param {blockCallback} callback - function that is called for each block
* @param {string} [network="mainnet01"] - chainweb network
* @param {string} [host="https://api.chainweb.com"] - chainweb api host
* @returns the event source object the backs the stream
*
* @alias module:chainweb.stream.stream
*/
const blockStream = (depth, chainIds, callback, network, host) => {
const ro = depth > 1 ? {} : { retry404: true, minTimeout: 1000 };
const cb = hdr => {
headers2blocks([hdr], network, host, ro)
.then(blocks => callback(blocks[0]))
.catch(err => console.log(err));
};
return headerStream(depth, chainIds, cb, network, host);
}
/**
* Query block header by its block hash
*
* @param {number|string} chainId - a chain id that is valid for the network
* @param {string} hash - block hash
* @param {string} [network="mainnet01"] - chainweb network
* @param {string} [host="https://api.chainweb.com"] - chainweb api host
* @return {Promise}
*
* @alias module:chainweb.block.hash
*/
const blockByBlockHash = async (chainId, hash, network, host) => {
const hdr = await headerByBlockHash(chainId, hash, network, host);
return headers2blocks([hdr], network, host).then(x => x[0]);
}
/* ************************************************************************** */
/* Transactions */
/**
* Utility function to filter the transactions from an array of blocks
*/
const filterTxs = (blocks) => {
return blocks
.filter(x => x.payload.transactions.length > 0)
.flatMap(x => {
let txs = x.payload.transactions;
txs.forEach(tx => tx.height = x.header.height);
return txs;
});
}
/**
* Transactions from a range of block heights
*
* @param {number|string} chainId - a chain id that is valid for the network
* @param {number} start - start block height
* @param {number} end - end block height
* @param {string} [network="mainnet01"] - chainweb network
* @param {string} [host="https://api.chainweb.com"] - chainweb api host
* @return {Promise} Array of transactions
*
* TODO: support paging
*
* @alias module:chainweb.transaction.range
*/
const txs = async (chainId, start, end, network, host) => {
return blocks(chainId, start, end, network, host).then(filterTxs);
}
/**
* Recent Transactions
*
* @param {number|string} chainId - a chain id that is valid for the network
* @param {number} depth - confirmation depth. Only transactions of blocks that this depth are returned
* @param {number} n - maximual number of blocks from which transactions are returned. The actual number of returned transactions may be lower
* @param {string} [network="mainnet01"] - chainweb network
* @param {string} [host="https://api.chainweb.com"] - chainweb api host
* @return {Promise} Array of transactions
*
* TODO: support paging
*
* @alias module:chainweb.transaction.recent
*/
const recentTxs = async (chainId, depth = 0, n = 1, network, host) => {
return recentBlocks(chainId, depth, n, network, host).then(filterTxs);
}
/**
* Callback for processing individual items of a transaction stream
*
* @callback transactionCallback
* @param {Object} transaction - transaction object
*/
/**
* Apply callback to new transactions.
*
* @param {number} depth - confirmation depth at which blocks are yielded
* @param {number[]} chainIds - array of chainIds from which blocks are included
* @param {transactionCallback} callback - function that is called for each transaction
* @param {string} [network="mainnet01"] - chainweb network
* @param {string} [host="https://api.chainweb.com"] - chainweb api host
* @returns the event source object the backs the stream
*
* @alias module:chainweb.transaction.stream
*/
const txStream = (depth, chainIds, callback, network, host) => {
const ro = depth > 1 ? {} : { retry404: true, minTimeout: 1000 };
const cb = u => {
if (u.txCount > 0) {
headers2blocks([u.header], network, host, ro)
.then(blocks => filterTxs(blocks).forEach(callback))
.catch(err => console.log(err));
}
};
return chainUpdates(depth, chainIds, cb, network, host);
}
/**
* Query transactions of a block by the block hash
*
* @param {number|string} chainId - a chain id that is valid for the network
* @param {string} hash - block hash
* @param {string} [network="mainnet01"] - chainweb network
* @param {string} [host="https://api.chainweb.com"] - chainweb api host
* @return {Promise}
*
* @alias module:chainweb.transaction.hash
*/
const txsByBlockHash = async (chainId, hash, network, host) => {
const block = await blockByBlockHash(chainId, hash, network, host)
return filterTxs([block]);
}
/* ************************************************************************** */
/* Events */
/**
* Utility function to filter the events from an array of blocks
*/
const filterEvents = (blocks) => {
return blocks
.filter(x => x.payload.transactions.length > 0)
.flatMap(x => x.payload.transactions.flatMap(y => {
let es = y.output.events ?? [];
es.forEach(e => e.height = x.header.height);
return es;
}));
}
/**
* Events from a range of block heights
*
* @param {number|string} chainId - a chain id that is valid for the network
* @param {number} start - start block height
* @param {number} end - end block height
* @param {string} [network="mainnet01"] - chainweb network
* @param {string} [host="https://api.chainweb.com"] - chainweb api host
* @return {Promise} Array of events
*
* TODO: support paging
*
* @alias module:chainweb.transaction.range
*/
const events = async (chainId, start, end, network, host) => {
return blocks(chainId, start, end, network, host).then(filterEvents);
}
/**
* Recent Events
*
* @param {number|string} chainId - a chain id that is valid for the network
* @param {number} depth - confirmation depth. Only events of blocks that this depth are returned
* @param {number} n - maximual number of blocks from which events are returned. The actual number of returned events may be lower.
* @param {string} [network="mainnet01"] - chainweb network
* @param {string} [host="https://api.chainweb.com"] - chainweb api host
* @return {Promise} Array of Pact events
*
* TODO: support paging
*
* @alias module:chainweb.event.recent
*/
const recentEvents = async (chainId, depth = 0, n = 1, network, host) => {
return recentBlocks(chainId, depth, n, network, host).then(filterEvents);
}
/**
* Callback for processing individual items of an event stream
*
* @callback eventCallback
* @param {Object} event - event object
*/
/**
* Apply callback to new events.
*
* @param {number} depth - confirmation depth at which blocks are yielded
* @param {number[]} chainIds - array of chainIds from which blocks are included
* @param {eventCallback} callback - function that is called for each event
* @param {string} [network="mainnet01"] - chainweb network
* @param {string} [host="https://api.chainweb.com"] - chainweb api host
* @returns the event source object the backs the stream
*
* @alias module:chainweb.event.stream
*/
const eventStream = (depth, chainIds, callback, network, host) => {
const ro = depth > 1 ? {} : { retry404: true, minTimeout: 1000 };
const cb = u => {
if (u.txCount > 0) {
headers2blocks([u.header], network, host, ro)
.then(blocks => filterEvents(blocks).forEach(callback))
.catch(err => console.log(err));
}
};
return chainUpdates(depth, chainIds, cb, network, host);
}
/**
* Query events of a block by the block hash
*
* @param {number|string} chainId - a chain id that is valid for the network
* @param {string} hash - block hash
* @param {string} [network="mainnet01"] - chainweb network
* @param {string} [host="https://api.chainweb.com"] - chainweb api host
* @return {Promise}
*
* @alias module:chainweb.event.hash
*/
const eventsByBlockHash = async (chainId, hash, network, host) => {
const block = await blockByBlockHash(chainId, hash, network, host)
return filterEvents([block]);
}
/* ************************************************************************** */
/* Module Exports */
module.exports = {
/**
* @namespace
*/
cut: {
current: currentCut,
peers: cutPeers
},
/**
* @namespace
*/
header: {
range: headers,
recent: recentHeaders,
stream: headerStream,
/**
* Query block header by its height
*
* @param {number|string} chainId - a chain id that is valid for the network
* @param {string} hash - block height
* @param {string} [network="mainnet01"] - chainweb network
* @param {string} [host="https://api.chainweb.com"] - chainweb api host
* @return {Promise}
*/
height: (chainId, height, network, host) => headers(chainId, height, height, network, host).then(x => x[0]),
blockHash: headerByBlockHash,
},
/**
* @namespace
*/
block: {
range: blocks,
recent: recentBlocks,
stream: blockStream,
/**
* Query block by its height
*
* @param {number|string} chainId - a chain id that is valid for the network
* @param {string} hash - block height
* @param {string} [network="mainnet01"] - chainweb network
* @param {string} [host="https://api.chainweb.com"] - chainweb api host
* @return {Promise}
*/
height: (chainId, height, network, host) => blocks(chainId, height, height, network, host).then(x => x[0]),
blockHash: blockByBlockHash,
},
/**
* @namespace
*/
transaction: {
range: txs,
recent: recentTxs,
stream: txStream,
/**
* Query transactions by height
*
* @param {number|string} chainId - a chain id that is valid for the network
* @param {string} hash - block height
* @param {string} [network="mainnet01"] - chainweb network
* @param {string} [host="https://api.chainweb.com"] - chainweb api host
* @return {Promise}
*/
height: (chainId, height, network, host) => txs(chainId, height, height, network, host),
blockHash: txsByBlockHash,
},
/**
* @namespace
*/
event: {
range: events,
recent: recentEvents,
stream: eventStream,
/**
* Query Events by height
*
* @param {number|string} chainId - a chain id that is valid for the network
* @param {string} hash - block height
* @param {string} [network="mainnet01"] - chainweb network
* @param {string} [host="https://api.chainweb.com"] - chainweb api host
* @return {Promise}
*/
height: (chainId, height, network, host) => events(chainId, height, height, network, host),
blockHash: eventsByBlockHash,
},
/* Low-level Utils */
branch: branch,
payloads: payloads,
};