Node.js Cross-Chain Bridge Relayers
Connecting blockchains using event emitters in Node.js
What Are Relayers?
Simply put, relayers are off-chain solutions that help transmit data from one blockchain network to another—enabling cross-chain transactions. Relayers are essential to enabling interoperability and bridging protocols in blockchain systems. They do this by listening for events on one blockchain and transmitting data from that event to another blockchain. This allows the receiving blockchain to further process the event to complete the cross-chain transaction (e.g., exchanging an ERC-20 token for another token on another blockchain).
Building Astro Relayers
At Astro, we chose Node.js for our relayers because JavaScript was the team's shared expertise. Given our time constraints, we agreed to a solution that was quick to build, repeatable, and easy to operate. To me, this meant:
- Containerization
- Quick deployments
- Dark releases
- Feature flags
- Componentized architecture
The first four requirements were straightforward. The real challenge was designing the componentized architecture. If you're not familiar with componentized architecture, it heavily relies on interfaces; and interfaces can be a perishable skill if you don't use them all the time.
Our core interface setup was as follows:
IPooledEventEmitter
All event emitters followed this interface.
export interface IPooledEventEmitter extends EventEmitter {
/**
* A unique name to identify it in observability operations.
*/
name: string;
/**
* Broadcast an event to all event emitters in the pool.
* @param eventName The name of the event for event emitters to handle or
* ignore based on the listeners they have implemented.
* @param args The arguments associated with the event to pass to the event
* emitters.
* @returns An array of event emitter results containing the name of the event
* emitter and the result it returned. The event name is also returned in the
* results.
*/
broadcast(
eventName: string,
...args: unknown[]
): BroadcastEventResult;
/**
* Start all listeners for this event emitter.
* @returns `true` if listeners started, `false`, if not.
*/
listen(): boolean;
/**
* This event emitter can belong to a pool of event emitters. If added to a
* pool, it can call an event emitter in the pool for further event handling.
* @param pool The pool instance.
*/
setPool(pool: IEventEmitterPool): this;
/**
* Alias for `this.on()`, but checks if the listener exists first before
* adding it. If a listener already exists for the event, it won't add another one.
* @param eventName The name of the event to listen for.
* @param listener The function to call when the event is emitted.
* @returns `this` instance for further method chaining.
*/
setOnce(
eventName: string | symbol,
listener: (...args: any[]) => any,
): this;
}BasePooledEventEmitter
To ensure all event emitters followed the IPooledEventEmitter interface, I introduced a base class event emitter that all event emitters extended.
export class BasePooledEventEmitter<
Events extends BaseEventMap = BaseEventMap,
> extends EventEmitter<EventMap<Events>> implements IPooledEventEmitter {
/**
* This emitter's name for use in key-value mappings in a network.
*/
readonly name: string;
/**
* If a caller calls the pool and an error is thrown, then the implementation
* needs to be fixed so the pool exists.
*/
protected pool?: IEventEmitterPool;
constructor(
name: string,
options?: ConstructorParameters<typeof EventEmitter>[0],
) {
super(
options ||
{
captureRejections:
process.env.CAPTURE_EVENT_EMITTER_REJECTIONS === "true",
},
);
this.name = name;
}
broadcast(eventName: string, ...args: unknown[]) {
if (!this.pool) {
logger.error(
"This event emitter is not connected to a pool. Cannot send broadcast message.",
);
return {
event_name: eventName,
emitter_results: [],
};
}
return this.pool.broadcast(eventName, ...args);
}
setPool(pool: IEventEmitterPool) {
logger.debug(`Setting pool: ${pool?.constructor?.name}`);
this.pool = pool;
return this;
}
listen() {
logger.debug("This event emitter does not implement `this.listen()`");
return false;
}
/**
* @alias for `this.on()`, but checks if the listener exists first before
* adding it.
*/
setOnce<K>(
eventName: EventMap<Events> extends [never] ? string | symbol
: keyof Events | K | "error",
listener: EventMap<Events> extends [never] ? (...args: any[]) => any
: K extends keyof Events
? EventMap<Events>[K] extends unknown[]
? (...args: EventMap<Events>[K]) => any
: never
: K extends "error" ? (args: { event: K; error: Error }) => any
: never,
): this {
// If a listener exists for this event, then skip adding it
const listeners = this.listeners(eventName);
if (listeners?.length > 0) {
logger.debug(`Listener for event '${eventName}' already exists`);
return this;
}
logger.debug(`Adding listener for event '${eventName}'`);
// When passing the listener to `this.on()`, its context of `this` gets
// removed. This causes it to throw errors when it calls `this` in itself.
// To prevent this, we ensure the listener is bound to `this` so it can call
// `this` without erroring out.
const boundListener = listener.bind(this);
// @ts-ignore ...args is untyped right now
this.on(eventName, async (...args) => {
try {
// @ts-ignore ...args is untyped right now
return await boundListener(...args);
} catch (e) {
// @ts-ignore `error` is not added to the event map right now
this.emit("error", {
event: eventName,
error: e,
});
}
});
return this;
}
override on<K>(
eventName: EventMap<Events> extends [never] ? string | symbol
: keyof Events | K,
listener: EventMap<Events> extends [never] ? (...args: any[]) => void
: K extends keyof Events ? (...args: EventMap<Events>[K]) => void
: (...args: any[]) => void,
): this {
logger.debug(`Setting event.on("${eventName}", ...)`);
// @ts-ignore We only care about getting the typing correct for an event
// name that is created on the fly. Event names created on the fly do not
// exist in the initial `EventMap` typing for the instance of this class.
// To get around this type check, we ignore `listener` below since no arg
// mapping exists for it.
return super.on(eventName, listener);
}
override removeAllListeners(
eventName?:
| (EventMap<Events> extends [never] ? string | symbol
: unknown)
| undefined,
): this {
if (!eventName) {
return this;
}
logger.debug(`Removing all listeners for event: ${eventName}`);
return super.removeAllListeners(eventName);
}
/**
* Helper method that wraps the starting of all listeners and returns the
* results of trying to start the listeners.
* @param cb The callback that should contain all `this.on()` calls.
* @returns `true` if no errors occurred while starting all the listeners.
* `false` if an error occurred.
*/
protected startListeners(cb: () => Promise<void> | void) {
try {
cb();
} catch (error) {
logger.error(`Failed to start listeners:`, error);
return false;
}
return true;
}
}This setup enabled us to add chain-specific event emitters independently. For example, we could add a Solana event emitter without modifying our Ethereum event emitter. This kept the system decoupled, clean, and scalable.
A little more about how our pooled event emitters worked is explained below.
How It Works
Every actor (black boxes) in the relayer is an event emitter. They have two jobs:
- broadcast events; and
- handle events they are listening for.
All actors are stored in a pool (white box) which is similar to the BroadcastChannel API. This pool has two jobs:
- receive broadcasted events from actors (blue lines); and
- send (aka emit) events from those actors to other actors in the pool (orange lines).
When an actor broadcasts an event, the event is sent to the pool's broadcast handler where it is sent to every actor in the pool. If an actor is listening for the event, it handles it accordingly (yellow boxes). Otherwise, it ignores the event. The pool allows each actor to handle events specific to them and ignore events they do not care about.
If there are new events that need to be handled, a new actor can be introduced to the pool (green box) with its own set of event handlers. It would have the same two jobs as other actors:
- broadcast events; and
- handle events they are listening for.
Example Scenario
- Actor
EthBridgeLockEventEmitter(left black box) broadcasts aNewEthTransactionevent (blue line) to the pool. - The event is sent to the pool's broadcast handler.
- The pool's broadcast handler sends the event to every actor in the pool (except to the actor that sent it) and only the
AoUsdaEventEmitteractor is listening for it (orange line). - The
AoUsdaEventEmitteractor handles the event accordingly (yellow box).


