Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Publish block data to external services via webhooks #1444

Merged
merged 2 commits into from
Oct 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
170 changes: 170 additions & 0 deletions nightfall-client/src/utils/dataPublisher.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
}