diff --git a/config/default.js b/config/default.js index db2999dec..980939bdb 100644 --- a/config/default.js +++ b/config/default.js @@ -737,4 +737,11 @@ module.exports = { }, TIMER_CHANGE_PROPOSER_SECOND: Number(process.env.TIMER_CHANGE_PROPOSER_SECOND) || 30, MAX_ROTATE_TIMES: Number(process.env.MAX_ROTATE_TIMES) || 2, + WEBHOOK: { + CALL_WEBHOOK_ON_CONFIRMATION: process.env.CALL_WEBHOOK_ON_CONFIRMATION, + CALL_WEBHOOK_MAX_RETRIES: process.env.CALL_WEBHOOK_MAX_RETRIES || 3, + CALL_WEBHOOK_INITIAL_BACKOFF: process.env.CALL_WEBHOOK_INITIAL_BACKOFF || 15000, + WEBHOOK_PATH: process.env.WEBHOOK_PATH, // For posting optional layer 2 transaction finalization details + WEBHOOK_SIGNING_KEY: process.env.WEBHOOK_SIGNING_KEY, + }, }; diff --git a/nightfall-client/src/event-handlers/block-proposed.mjs b/nightfall-client/src/event-handlers/block-proposed.mjs index 60a662184..c88d60d53 100644 --- a/nightfall-client/src/event-handlers/block-proposed.mjs +++ b/nightfall-client/src/event-handlers/block-proposed.mjs @@ -29,8 +29,14 @@ import { } from '../services/database.mjs'; import { decryptCommitment } from '../services/commitment-sync.mjs'; import { syncState } from '../services/state-sync.mjs'; +import DataPublisher from '../utils/dataPublisher.mjs'; -const { TIMBER_HEIGHT, HASH_TYPE, TXHASH_TREE_HASH_TYPE } = config; +const { + TIMBER_HEIGHT, + HASH_TYPE, + TXHASH_TREE_HASH_TYPE, + WEBHOOK: { CALL_WEBHOOK_ON_CONFIRMATION, WEBHOOK_PATH, WEBHOOK_SIGNING_KEY }, +} = config; const { ZERO, WITHDRAW } = constants; const { generalise } = gen; @@ -242,6 +248,31 @@ async function blockProposedEventHandler(data, syncing) { }), ); } + + // Send finalization details via webhook optionally, if CALL_WEBHOOK_ON_CONFIRMATION + // is enabled + if (CALL_WEBHOOK_ON_CONFIRMATION) { + if (!WEBHOOK_PATH) { + throw new Error('WEBHOOK_PATH is not set'); + } + const dataToPublish = { + proposer: block.proposer, + blockNumberL2: block.blockNumberL2, + transactionHashes: block.transactionHashes, + }; + logger.info({ msg: 'Calling webhook', url: WEBHOOK_PATH, data: dataToPublish }); + try { + await new DataPublisher([ + { + type: 'webhook', + url: `${WEBHOOK_PATH}`, + signingKey: WEBHOOK_SIGNING_KEY, + }, + ]).publish(dataToPublish); + } catch (err) { + logger.error(`ERROR: Calling webhook ${JSON.stringify(err)}`); + } + } } export default blockProposedEventHandler; diff --git a/nightfall-client/src/utils/dataPublisher.mjs b/nightfall-client/src/utils/dataPublisher.mjs new file mode 100644 index 000000000..ada6033ae --- /dev/null +++ b/nightfall-client/src/utils/dataPublisher.mjs @@ -0,0 +1,170 @@ +/* eslint-disable max-classes-per-file */ +import axios from 'axios'; +import crypto from 'crypto'; +import config from 'config'; + +const { + WEBHOOK: { CALL_WEBHOOK_MAX_RETRIES, CALL_WEBHOOK_INITIAL_BACKOFF }, +} = config; + +/** + * Class for signing request payloads. + */ +class PayloadSigner { + /** + * @param {string} signingKey - The signing key. + */ + constructor(signingkey) { + this.signingKey = signingkey; + } + + /** + * Signs a payload. + * @param {*} data - The data to be signed. + * @returns {string} The signature. + * @throws {Error} If the data is not an object or is null. + */ + sign(data) { + if (typeof data !== 'object' || data === null) { + throw new Error('Data must be an object'); + } + + const hmac = crypto.createHmac('sha256', this.signingKey); + hmac.update(JSON.stringify(data)); + return hmac.digest('hex'); + } +} + +/** + * Class for handling retries with exponential backoff and jitter. + */ +class RetryHandler { + /** + * @param {number} maxRetries - The maximum number of retries. + * @param {number} initialBackoff - The initial backoff time in milliseconds. + */ + constructor(maxRetries, initialBackoff) { + this.maxRetries = maxRetries; + this.initialBackoff = initialBackoff; + } + + /** + * Executes a request function with retries. + * @param {Function} requestFunction - The function to execute with retries. + * @returns {*} The result of the request function. + * @throws {Error} If the request function fails after all retries. + */ + async executeWithRetry(requestFunction, retries = 0) { + let timeIdToClear; + if (retries >= this.maxRetries) { + throw new Error('Failed to execute request after retries'); + } + + try { + return await requestFunction(); + } catch (error) { + // Passing an invalid ID to clearTimeout() silently does nothing; no exception is thrown. + clearTimeout(timeIdToClear); + // Have an exponential back-off strategy with jitter + const newBackoff = this.initialBackoff * 2 ** retries + Math.random() * this.initialBackoff; + timeIdToClear = await new Promise(resolve => setTimeout(resolve, newBackoff)); + return this.executeWithRetry(requestFunction, retries + 1); + } + } +} + +/** + * Class for publishing data to webhook destinations. + */ +class WebhookPublisher { + /** + * @param {PayloadSigner} signer - The payload signer instance. + * @param {RetryHandler} retryHandler - The retry handler instance. + */ + constructor(signer, retryHandler) { + this.signer = signer; + this.retryHandler = retryHandler; + } + + /** + * Validates the destination configuration + * @param {Object} destination - the webhook destination configuration + * @throws {Error} If the destination configuration is invalid + */ + static validateDestination(destination) { + if (!destination || typeof destination !== 'object') { + throw new Error('Invalid destination configuration: must be an object'); + } + + if (!destination.url || typeof destination.url !== 'string') { + throw new Error('Invalid destination configuration: url must be a string'); + } + } + + /** + * Publishes data to a webhook destination. + * @param {Object} destination - The webhook destination configuration. + * @param {*} data - The data to be published. + */ + async publish(destination, data) { + WebhookPublisher.validateDestination(destination); + const headers = {}; + + if (destination.signingKey) { + const signature = this.signer.sign(data); + headers['X-Signature-SHA256'] = signature; + } + + const requestFunction = async () => { + const response = await axios.post(destination.url, data, { headers }); + if (response.status < 200 || response.status >= 300) { + throw new Error(`Webhook failed with status: ${response.status}`); + } + }; + + await this.retryHandler.executeWithRetry(requestFunction); + } +} + +/** + * Class for publishing data to various destinations. + */ +export default class DataPublisher { + /** + * @param {Array} options - An array of destination configurations. + */ + constructor(options) { + this.options = options || []; + } + + /** + * Publishes data to all configured destinations/transports. + * @param {*} data - The data to be published. + */ + async publish(data) { + const promises = this.options.map(async destination => { + try { + const signer = new PayloadSigner(destination.signingKey); + const retryHandler = new RetryHandler( + destination.maxRetries || CALL_WEBHOOK_MAX_RETRIES, + destination.initialBackoff || CALL_WEBHOOK_INITIAL_BACKOFF, + ); + + switch (destination.type) { + case 'webhook': { + const publisher = new WebhookPublisher(signer, retryHandler); + await publisher.publish(destination, data); + break; + } + // Other destination types can be added here as needed + default: + console.error('Unknown destination type:', destination.type); + } + } catch (err) { + console.error('Unable to publish to destination: ', err); + } + }); + + return Promise.all(promises); + } +}