Source: chainweb.js

/**
 * Functions for querying the Kadena Chainweb blockchain API
 *
 * @author Lars 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));

class ResponseError extends Error {
    constructor(response) {
        const msg = `Request ${response.url} failed with ${response.status}, ${response.statusText}`;
        super(msg);
        this.response = response;
    }
}

/**
 * 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) => {

    retryOptions = {
        onFailedAttempt: retryOptions?.onFailedAttempt ?? (x => console.log("failed fetch attempt:", x.message)),
        retries: retryOptions?.retries ?? 2,
        minTimeout: retryOptions?.minTimeout ?? 500,
        randomize: retryOptions?.randomize ?? true,
        retry404: retryOptions?.retry404 ?? false,
    };

    const retry404 = retryOptions.retry404;

    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 new ResponseError(response);

        // retry potentially ephemeral failure conditions
        } else if (response.status == 408) { // response timeout
            throw new ResponseError(response);
        } else if (response.status == 423) { // locked
            throw new ResponseError(response);
        } else if (response.status == 425) { // too early
            throw new ResponseError(response);
        } else if (response.status == 429) { // too many requests
            throw new ResponseError(response);
        } else if (response.status == 500) { // internal server error
            throw new ResponseError(response);
        } else if (response.status == 502) { // bad gateway
            throw new ResponseError(response);
        } else if (response.status == 503) { // service unavailable
            throw new ResponseError(response);
        } else if (response.status == 504) { // gateway timeout
            throw new ResponseError(response);

        // don't retry on anything else
        } else if (response.status == 204) { // no content
            throw new pRetry.AbortError(new ResponseError(response));
        } else {
            throw new pRetry.AbortError(new ResponseError(response));
        }
    }

    return await pRetry(run, retryOptions);
}

/**
 * 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 new Error("missing chainId parameter");
    }
    return baseUrl(network, host, `chain/${chainId}/${pathSuffix}`);
}

/* ************************************************************************** */
/* Pageing Tools */

/** Yields full pages, i.e. arrarys of page items.
 *
 * @param {callback} query - A query callback that takes a `next` and an optional `limit` parameter.
 * @param {number} [n] - Optional upper limit and the number of returned items.
 */
const pageIterator = async function * (query, n) {
    let next = null;
    let c = 0;
    do {
        const limit = n ? n - c : null;
        const page = await query(next, limit);
        next = page.next;
        c += page.limit;
        yield page.items;
    } while (next && (n ? c < n : true));
}

/** Yields flattened pages, i.e. individual page items are yielded.
 */
const pageItemIterator = async function * (query, n) {
    const iter = pageIterator(query, n);
    for await (p of iter) {
        for await (i of p) {
            yield p;
        }
    }
}

/* Yields items from pages in reverse order.
 *
 * WARNING: This awaits and buffers all pages before returning.
 */
const reversePages = async (query, n) => {
    const iter = pageIterator(query, n);
    let ps = [];
    for await (p of iter) {
        ps.unshift(p.reverse());
    }
    return ps.flat();
}

/* ************************************************************************** */
/* 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();
}

/**
 * Page of 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[ Page of peer objects. The size of a page is determined by the server.
 *
 * @alias module:chainweb.cut.peers
 */
const cutPeerPage = async (network, host, retryOptions) => {
    const response = await retryFetch(
        retryOptions,
        () => fetch(baseUrl(network, host, "cut/peer"))
    );
    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
 *
 * @alias module:chainweb.cut.peers
 */
const cutPeers = async (network, host, retryOptions) => {
    const page = await cutPeerPage(network, host, retryOptions);
    return page.items.reverse();
}

/**
 * A signle block header page 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 {number} [next] - if given, provides a cursor that points to the next page of the result. The cursor is the `next` property of the previous page.
 * @param {string} [format='json'] - encoding of result headers. Possible values are 'json' (default) and 'binary'.
 * @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} Page of block headers in requested format. Headers are listed in decending order by height. The page size of a page is determined by the server.
 *
 * @alias module:chainweb.internal.branch
 */
const branchPage = async (chainId, upper, lower, minHeight, maxHeight, n, next, format, network, host, retryOptions) => {

    /* Format and Accept header value */
    format = format ?? 'json';
    var accept = "";
    switch (format) {
        case 'json': accept = 'application/json;blockheader-encoding=object'; break;
        case 'binary': accept = 'application/json'; break;
        default: throw new Error(`Unsupported header format ${format}. Supported values are 'json' and 'binary'.`)
    }

    /* 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);
    }
    if (next != null) {
        url.searchParams.append("next", next);
    }

    /* 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': accept,
            }
        })
    );
    return response.json();
}

/**
 * Return block headers from chain
 *
 * @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} [format='json'] - encoding of result headers. Possible values are 'json' (default) and 'binary'.
 * @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 headers in the requested format.
 *
 * @alias module:chainweb.internal.branch
 */
const branch = async (chainId, upper, lower, minHeight, maxHeight, n, format, network, host, retryOptions) => {
    return await reversePages((next, limit) => {
        return branchPage (chainId, upper, lower, minHeight, maxHeight, limit, next, format, network, host, retryOptions)
    }, n);
}

/**
 * Headers from the current branch of the chain
 *
 * @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 {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} [format='json'] - encoding of result headers. Possible values are 'json' (default) and 'binary'.
 * @param {string} [network="mainnet01"] - chainweb network
 * @param {string} [host="https://api.chainweb.com"] - chainweb api host
 * @return [Object] Array of block headers in the requested format.
 *
 * @alias module:chainweb.internal.currentBranch
 */
const currentBranch = async (chainId, start, end, n, format, network, host) => {
    const cut = await currentCut(network, host);
    return await branch(
            chainId,
            [cut.hashes[`${chainId}`].hash],
            [],
            start,
            end,
            n,
            format,
            network,
            host
        );
}

/**
 * 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} [format='json'] - encoding of payload properties. Possible values are 'json' (default) and 'base64'.
 * @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. There is no guarantee about how many paylaods are returned and what payloads aer included in the result.
 *
 * @alias module:chainweb.internal.payloads
 */
const payloads = async (chainId, hashes, format, network, host, retryOptions) => {

    format = format ?? 'json';

    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();

    if (format == '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;
        });
    } else if (format == 'base64') {
        return x;
    } else {
        throw new Error(`Unsupported format '${format}'. Supported formats are 'json' (default) and 'base64'`);
    }
}

/**
 * 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 in ascending order.
 *
 * @alias module:chainweb.header.range
 */
const headers = async (chainId, start, end, network, host) => {
    return await currentBranch(chainId, start, end, null, 'json', network, host);
}

/**
 * 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 in ascending order.
 *
 * @alias module:chainweb.header.recent
 */
const recentHeaders = async (chainId, depth = 0, n = 1, network, host) => {
    const cut = await currentCut(network, host);
    const start = cut.hashes['0'].height - depth - n + 1;
    const end = cut.hashes['0'].height - depth;
    const upper = cut.hashes[`${chainId}`].hash;
    return await branch(chainId, [upper], [], start, end, n, 'json', network, host);
}

/**
 * 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} Block header with the requested hash
 *
 * @alias module:chainweb.header.hash
 */
const headerByBlockHash = async (chainId, hash, network, host) => {
    const x = await branch(chainId, [hash], [], null, null, 1);
    return x[0];
}

/**
 * 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} Block header at the requested height
 *
 * @alias module:chainweb.header.height
 */
const headerByHeight = async (chainId, height, network, host) => {
    const x = await headers(chainId, height, height, network, host);
    return x[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.
 *
 * TODO: The use of this function below is inefficient. It would be better
 * to start fetching payloads asynchronously while iterating through
 * block header pages.
 */
const headers2blocks = async (hdrs, network, host, retryOptions) => {
    let missing = hdrs;
    const result = [];

    while (missing.length > 0) {

        const chainId = hdrs[0].chainId;
        const pays = await payloads(
            chainId,
            hdrs.map(x => x.payloadHash),
            'json',
            network,
            host,
            retryOptions
        );

        // Note that in worst case a server may return only one payload at a time thus starving the
        // client. Well, chainweb nodes don't behave that way :-)
        if (pays.length === 0) {
            throw new Error (`failed to get payloads for some headers. Missing ${missing.map(h => ({ hash: h.hash, height: h.height}))}`);
        }

        // index payloads by payloadHash
        const paysMap = pays.reduce((m, c) => { m[c.payloadHash] = c; return m; }, {});

        // assign payload to headers
        missing = missing.filter((hdr, i) => {
            const pay = paysMap[hdr.payloadHash];
            if (pay) {
                result.push({
                    header: hdr,
                    payload: pay
                });
                return false;
            } else {
                return true;
            }
        });
    }
    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
 *
 * @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
 *
 * @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} Block with the requested hash
 *
 * @alias module:chainweb.block.hash
 */
const blockByBlockHash = async (chainId, hash, network, host) => {
    const hdr = await headerByBlockHash(chainId, hash, network, host);
    const bs = await headers2blocks([hdr], network, host);
    return bs[0];
}

/**
 * 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} Block at the requested height
 *
 * @alias module:chainweb.block.height
 */
const blockByHeight = async (chainId, height, network, host) => {
    const x = await blocks(chainId, height, height, network, host);
    return 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
 *
 * @alias module:chainweb.transaction.range
 */
const txs = async (chainId, start, end, network, host) => {
    const x = await blocks(chainId, start, end, network, host);
    return filterTxs(x);
}

/**
 * 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
 *
 * @alias module:chainweb.transaction.recent
 */
const recentTxs = async (chainId, depth = 0, n = 1, network, host) => {
    const x = await recentBlocks(chainId, depth, n, network, host);
    return filterTxs(x);
}

/**
 * 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} Transactions from the block with the requested hash
 *
 * @alias module:chainweb.transaction.hash
 */
const txsByBlockHash = async (chainId, hash, network, host) => {
    const block = await blockByBlockHash(chainId, hash, network, host)
    return filterTxs([block]);
}

/**
 * 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} Transactions from the block of the requested height
 *
 * @alias module:chainweb.transaction.height
 */
const txsByHeight = async (chainId, height, network, host) =>
    txs(chainId, height, height, network, host);

/* ************************************************************************** */
/* 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
 *
 * @alias module:chainweb.transaction.range
 */
const events = async (chainId, start, end, network, host) => {
    const x = await blocks(chainId, start, end, network, host);
    return filterEvents(x);
}

/**
 * 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
 *
 * @alias module:chainweb.event.recent
 */
const recentEvents = async (chainId, depth = 0, n = 1, network, host) => {
    const x = await recentBlocks(chainId, depth, n, network, host);
    return filterEvents(x);
}

/**
 * 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} Events from the block with the requested hash
 *
 * @alias module:chainweb.event.hash
 */
const eventsByBlockHash = async (chainId, hash, network, host) => {
    const block = await blockByBlockHash(chainId, hash, network, host)
    return filterEvents([block]);
}

/**
 * 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} Events from the block of the requested height
 *
 * @alias module:chainweb.event.height
 */
const eventsByHeight = async (chainId, height, network, host) =>
    events(chainId, height, height, network, host);

/* ************************************************************************** */
/* Module Exports */

module.exports = {
    ResponseError: ResponseError,

    /**
     * @namespace
     */
    cut: {
        current: currentCut,
        peers: cutPeers
    },
    /**
     * @namespace
     */
    header: {
        range: headers,
        recent: recentHeaders,
        stream: headerStream,
        height: headerByHeight,
        blockHash: headerByBlockHash,
    },
    /**
     * @namespace
     */
    block: {
        range: blocks,
        recent: recentBlocks,
        stream: blockStream,
        height: blockByHeight,
        blockHash: blockByBlockHash,
    },
    /**
     * @namespace
     */
    transaction: {
        range: txs,
        recent: recentTxs,
        stream: txStream,
        height: txsByHeight,
        blockHash: txsByBlockHash,
    },
    /**
     * @namespace
     */
    event: {
        range: events,
        recent: recentEvents,
        stream: eventStream,
        height: eventsByHeight,
        blockHash: eventsByBlockHash,
    },

    /**
     * Internal Utilities
     *
     * Anything that is exported within the `internal` namespace
     * is not covered by semantic versioning policy.
     *
     * @namespace
     */
    internal: {
        branchPage: branchPage,
        branch: branch,
        currentBranch: currentBranch,
        payloads: payloads,
        cutPeerPage: cutPeerPage,
    }
};