Skip to content

Commit 9f52957

Browse files
committed
CR: yorke
1 parent b424794 commit 9f52957

11 files changed

Lines changed: 345 additions & 250 deletions

File tree

typescript/relayer/README.md

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { HyperlaneRelayer } from '@hyperlane-xyz/relayer';
2323
import { HyperlaneCore } from '@hyperlane-xyz/sdk';
2424

2525
// Direct usage (browser-safe)
26+
const addresses = /* chain -> contract addresses map */;
2627
const core = HyperlaneCore.fromAddressesMap(addresses, multiProvider);
2728
const relayer = new HyperlaneRelayer({ core });
2829

@@ -38,18 +39,16 @@ relayer.start();
3839
For Node.js environments with filesystem access, use the `/fs` export:
3940

4041
```typescript
41-
import { RelayerConfig, RelayerService } from '@hyperlane-xyz/relayer/fs';
42+
import { RelayerService, loadConfig } from '@hyperlane-xyz/relayer/fs';
4243

4344
// Load config from file
44-
const config = RelayerConfig.load('./config.yaml');
45+
const relayerConfig = loadConfig('./config.yaml');
4546

4647
// Start the service
47-
const service = new RelayerService(
48-
multiProvider,
49-
registry,
50-
{ mode: 'daemon', enableMetrics: true },
51-
config,
52-
);
48+
const service = await RelayerService.create(multiProvider, registry, {
49+
enableMetrics: true,
50+
relayerConfig,
51+
});
5352
await service.start();
5453
```
5554

@@ -105,10 +104,10 @@ cacheFile: ./relayer-cache.json
105104
106105
## Package Exports
107106
108-
| Export | Description | Browser-safe |
109-
| --------------------------- | -------------------------------------------- | ------------ |
110-
| `@hyperlane-xyz/relayer` | Core relayer, metadata builders, schemas | Yes |
111-
| `@hyperlane-xyz/relayer/fs` | RelayerService, RelayerConfig (file loading) | No (Node.js) |
107+
| Export | Description | Browser-safe |
108+
| --------------------------- | ----------------------------------------- | ------------ |
109+
| `@hyperlane-xyz/relayer` | Core relayer, metadata builders, schemas | Yes |
110+
| `@hyperlane-xyz/relayer/fs` | RelayerService, loadConfig (file loading) | No (Node.js) |
112111

113112
## Prometheus Metrics
114113

@@ -130,14 +129,17 @@ typescript/relayer/
130129
├── src/
131130
│ ├── index.ts # Browser-safe exports
132131
│ ├── core/
133-
│ │ └── HyperlaneRelayer.ts # Core relaying logic (browser-safe)
132+
│ │ ├── HyperlaneRelayer.ts # Core relaying logic (browser-safe)
133+
│ │ ├── cache.ts # Cache schema + types
134+
│ │ ├── events.ts # Relayer event types
135+
│ │ └── whitelist.ts # Whitelist helper
134136
│ ├── metadata/ # ISM metadata builders (browser-safe)
135137
│ ├── config/
136138
│ │ └── schema.ts # Config schema (browser-safe)
137139
│ └── fs/ # Node.js specific
138140
│ ├── index.ts # Node.js exports
139141
│ ├── RelayerService.ts # Service with file cache + signals
140-
│ ├── RelayerConfig.ts # Config file loading
142+
│ ├── RelayerConfig.ts # Config file loading helper
141143
│ ├── service.ts # Daemon entry point
142144
│ ├── relayerMetrics.ts # Prometheus metric definitions
143145
│ └── metricsServer.ts # HTTP server for /metrics

typescript/relayer/src/core/HyperlaneRelayer.ts

Lines changed: 52 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { ethers, providers } from 'ethers';
22
import { Logger } from 'pino';
3-
import { z } from 'zod';
43

54
import {
65
ChainMap,
@@ -15,10 +14,8 @@ import {
1514
} from '@hyperlane-xyz/sdk';
1615
import {
1716
Address,
18-
ParsedMessage,
1917
WithAddress,
2018
assert,
21-
bytes32ToAddress,
2219
messageId,
2320
objMap,
2421
objMerge,
@@ -29,95 +26,21 @@ import {
2926

3027
import { BaseMetadataBuilder } from '../metadata/builder.js';
3128

29+
import { RelayerCache } from './cache.js';
30+
import { RelayerObserver } from './events.js';
31+
import { messageMatchesWhitelist } from './whitelist.js';
32+
3233
type DerivedHookConfig = WithAddress<Exclude<HookConfig, Address>>;
3334
type DerivedIsmConfig = WithAddress<Exclude<IsmConfig, Address>>;
3435

35-
/**
36-
* Callbacks for relayer events, useful for metrics and monitoring
37-
*/
38-
export interface RelayerEventCallbacks {
39-
onMessageRelayed?: (
40-
originChain: string,
41-
destinationChain: string,
42-
messageId: string,
43-
durationMs: number,
44-
) => void;
45-
onMessageFailed?: (
46-
originChain: string,
47-
destinationChain: string,
48-
messageId: string,
49-
error: Error,
50-
) => void;
51-
onMessageSkipped?: (
52-
originChain: string,
53-
destinationChain: string,
54-
messageId: string,
55-
reason: 'whitelist' | 'already_delivered',
56-
) => void;
57-
onRetry?: (
58-
originChain: string,
59-
destinationChain: string,
60-
messageId: string,
61-
attempt: number,
62-
) => void;
63-
onBacklogUpdate?: (size: number) => void;
64-
}
65-
66-
const BacklogMessageSchema = z.object({
67-
attempts: z.number(),
68-
lastAttempt: z.number(),
69-
message: z.string(),
70-
dispatchTx: z.string(),
71-
});
72-
73-
const MessageBacklogSchema = z.array(BacklogMessageSchema);
74-
75-
export const RelayerCacheSchema = z.object({
76-
hook: z.record(z.record(z.any())),
77-
ism: z.record(z.record(z.any())),
78-
backlog: MessageBacklogSchema,
79-
});
80-
81-
export type RelayerCache = z.infer<typeof RelayerCacheSchema>;
82-
83-
type MessageWhitelist = ChainMap<Set<Address>>;
84-
85-
export function messageMatchesWhitelist(
86-
whitelist: MessageWhitelist,
87-
message: ParsedMessage,
88-
): boolean {
89-
const originAddresses = whitelist[message.originChain ?? message.origin];
90-
if (!originAddresses) {
91-
return false;
92-
}
93-
94-
const sender = bytes32ToAddress(message.sender);
95-
if (originAddresses.size !== 0 && !originAddresses.has(sender)) {
96-
return false;
97-
}
98-
99-
const destinationAddresses =
100-
whitelist[message.destinationChain ?? message.destination];
101-
if (!destinationAddresses) {
102-
return false;
103-
}
104-
105-
const recipient = bytes32ToAddress(message.recipient);
106-
if (destinationAddresses.size !== 0 && !destinationAddresses.has(recipient)) {
107-
return false;
108-
}
109-
110-
return true;
111-
}
112-
11336
export class HyperlaneRelayer {
11437
protected multiProvider: MultiProvider;
11538
protected metadataBuilder: BaseMetadataBuilder;
11639
protected readonly core: HyperlaneCore;
11740
protected readonly retryTimeout: number;
11841

11942
protected readonly whitelist: ChainMap<Set<Address>> | undefined;
120-
protected readonly eventCallbacks: RelayerEventCallbacks;
43+
protected readonly observer: RelayerObserver;
12144

12245
public backlog: RelayerCache['backlog'];
12346
public cache: RelayerCache | undefined;
@@ -131,20 +54,20 @@ export class HyperlaneRelayer {
13154
caching = true,
13255
retryTimeout = 1000,
13356
whitelist = undefined,
134-
eventCallbacks = {},
57+
observer = {},
13558
}: {
13659
core: HyperlaneCore;
13760
caching?: boolean;
13861
retryTimeout?: number;
13962
whitelist?: ChainMap<Address[]>;
140-
eventCallbacks?: RelayerEventCallbacks;
63+
observer?: RelayerObserver;
14164
}) {
14265
this.core = core;
14366
this.retryTimeout = retryTimeout;
14467
this.logger = core.logger.child({ module: 'Relayer' });
14568
this.metadataBuilder = new BaseMetadataBuilder(core);
14669
this.multiProvider = core.multiProvider;
147-
this.eventCallbacks = eventCallbacks;
70+
this.observer = observer;
14871
if (whitelist) {
14972
this.whitelist = objMap(
15073
whitelist,
@@ -247,9 +170,11 @@ export class HyperlaneRelayer {
247170
destinationMap[message.parsed.destination].push(message);
248171
});
249172

173+
// parallelize relaying to different destinations
250174
return promiseObjAll(
251175
objMap(destinationMap, async (_destination, messages) => {
252176
const receipts: ethers.ContractReceipt[] = [];
177+
// serially relay messages to the same destination
253178
for (const message of messages) {
254179
try {
255180
const receipt = await this.relayMessage(
@@ -276,18 +201,22 @@ export class HyperlaneRelayer {
276201
const destinationChain = this.core.getDestination(message);
277202

278203
if (this.whitelist) {
204+
// add human readable names for use in whitelist checks
279205
message.parsed = {
280206
originChain,
281207
destinationChain,
282208
...message.parsed,
283209
};
284210
if (!messageMatchesWhitelist(this.whitelist, message.parsed)) {
285-
this.eventCallbacks.onMessageSkipped?.(
211+
this.observer.onEvent?.({
212+
type: 'messageSkipped',
213+
message,
286214
originChain,
287215
destinationChain,
288-
message.id,
289-
'whitelist',
290-
);
216+
messageId: message.id,
217+
reason: 'whitelist',
218+
dispatchTx,
219+
});
291220
throw new Error(`Message ${message.id} does not match whitelist`);
292221
}
293222
}
@@ -297,12 +226,15 @@ export class HyperlaneRelayer {
297226
const isDelivered = await this.core.isDelivered(message);
298227
if (isDelivered) {
299228
this.logger.info(`Message ${message.id} already delivered`);
300-
this.eventCallbacks.onMessageSkipped?.(
229+
this.observer.onEvent?.({
230+
type: 'messageSkipped',
231+
message,
301232
originChain,
302233
destinationChain,
303-
message.id,
304-
'already_delivered',
305-
);
234+
messageId: message.id,
235+
reason: 'already_delivered',
236+
dispatchTx,
237+
});
306238
return this.core.getProcessedReceipt(message);
307239
}
308240

@@ -311,6 +243,7 @@ export class HyperlaneRelayer {
311243
this.logger.debug({ message }, `Simulating recipient message handling`);
312244
await this.core.estimateHandle(message);
313245

246+
// parallelizable because configs are on different chains
314247
const [ism, hook] = await Promise.all([
315248
this.getRecipientIsmConfig(message),
316249
this.getSenderHookConfig(message),
@@ -328,20 +261,26 @@ export class HyperlaneRelayer {
328261

329262
const receipt = await this.core.deliver(message, metadata);
330263
const durationMs = Date.now() - startTime;
331-
this.eventCallbacks.onMessageRelayed?.(
264+
this.observer.onEvent?.({
265+
type: 'messageRelayed',
266+
message,
332267
originChain,
333268
destinationChain,
334-
message.id,
269+
messageId: message.id,
335270
durationMs,
336-
);
271+
dispatchTx,
272+
});
337273
return receipt;
338274
} catch (error) {
339-
this.eventCallbacks.onMessageFailed?.(
275+
this.observer.onEvent?.({
276+
type: 'messageFailed',
277+
message,
340278
originChain,
341279
destinationChain,
342-
message.id,
343-
error as Error,
344-
);
280+
messageId: message.id,
281+
error: error as Error,
282+
dispatchTx,
283+
});
345284
throw error;
346285
}
347286
}
@@ -351,6 +290,7 @@ export class HyperlaneRelayer {
351290
this.cache = objMerge(this.cache, cache);
352291
}
353292

293+
// fill cache with default ISM and hook configs for quicker relaying (optional)
354294
async hydrateDefaults(): Promise<void> {
355295
assert(this.cache, 'Caching not enabled');
356296

@@ -368,7 +308,10 @@ export class HyperlaneRelayer {
368308

369309
protected async flushBacklog(): Promise<void> {
370310
while (this.stopRelayingHandler) {
371-
this.eventCallbacks.onBacklogUpdate?.(this.backlog.length);
311+
this.observer.onEvent?.({
312+
type: 'backlog',
313+
size: this.backlog.length,
314+
});
372315

373316
const backlogMsg = this.backlog.shift();
374317

@@ -378,6 +321,7 @@ export class HyperlaneRelayer {
378321
continue;
379322
}
380323

324+
// linear backoff (attempts * retryTimeout)
381325
const backoffTime =
382326
backlogMsg.lastAttempt + backlogMsg.attempts * this.retryTimeout;
383327
if (Date.now() < backoffTime) {
@@ -397,6 +341,7 @@ export class HyperlaneRelayer {
397341
String(parsed.destination);
398342

399343
try {
344+
// TODO: handle batching
400345
const dispatchReceipt = await this.multiProvider
401346
.getProvider(parsed.origin)
402347
.getTransactionReceipt(dispatchTx);
@@ -407,12 +352,14 @@ export class HyperlaneRelayer {
407352
this.logger.error(
408353
`Failed to relay message ${id} (attempt #${newAttempts})`,
409354
);
410-
this.eventCallbacks.onRetry?.(
355+
this.observer.onEvent?.({
356+
type: 'retry',
357+
message: dispatchMsg,
411358
originChain,
412359
destinationChain,
413-
id,
414-
newAttempts,
415-
);
360+
messageId: id,
361+
attempt: newAttempts,
362+
});
416363
this.backlog.push({
417364
...backlogMsg,
418365
attempts: newAttempts,

0 commit comments

Comments
 (0)