Skip to content

Commit

Permalink
feat: added data publisher for posting block data to external webhooks
Browse files Browse the repository at this point in the history
  • Loading branch information
AdarshRon committed Oct 18, 2023
1 parent ed270cf commit 3641734
Show file tree
Hide file tree
Showing 3 changed files with 193 additions and 30 deletions.
7 changes: 7 additions & 0 deletions config/default.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};
33 changes: 32 additions & 1 deletion nightfall-client/src/event-handlers/block-proposed.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
183 changes: 154 additions & 29 deletions nightfall-client/src/utils/dataPublisher.mjs
Original file line number Diff line number Diff line change
@@ -1,45 +1,170 @@
/* eslint-disable max-classes-per-file */
import axios from 'axios';
import crypto from 'crypto';
import config from 'config';

export default class DataPublisher {
constructor(options) {
this.options = options || [];
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;
}

async publish(data) {
const promises = this.options.map(async destination => {
switch (destination.type) {
case 'webhook': {
await this.publishWebhook(destination, data);
break;
}
/**
* 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');
}

default:
throw new Error('Unknown destination type');
}
});
const hmac = crypto.createHmac('sha256', this.signingKey);
hmac.update(JSON.stringify(data));
return hmac.digest('hex');
}
}

await Promise.all(promises);
/**
* 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;
}

publishWebhook = async (destination, data) => {
const headers = {};
/**
* 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');
}

if (destination.signingkey) {
const signature = await this.signWebhookPayload(data, destination.signingkey);
headers['X-Signature'] = signature;
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);
}
}
}

const response = await axios.post(destination.url, data, { headers });
/**
* 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;
}

if (response.status !== 200) {
throw new Error('Webhook failed with status: ', response.status);
/**
* 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');
}
};

signWebhookPayload = (data, signingKey) => {
const hmac = crypto.createHmac('sha256', signingKey);
hmac.update(JSON.stringify(data));
return hmac.digest('hex');
};
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);
}
}

0 comments on commit 3641734

Please sign in to comment.