@waku/sds 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/sds.ts ADDED
@@ -0,0 +1,315 @@
1
+ import { sha256 } from "@noble/hashes/sha256";
2
+ import { bytesToHex } from "@noble/hashes/utils";
3
+ import { proto_sds_message } from "@waku/proto";
4
+
5
+ import { DefaultBloomFilter } from "./bloom.js";
6
+
7
+ export type Message = proto_sds_message.SdsMessage;
8
+ export type ChannelId = string;
9
+
10
+ export const DEFAULT_BLOOM_FILTER_OPTIONS = {
11
+ capacity: 10000,
12
+ errorRate: 0.001
13
+ };
14
+
15
+ const DEFAULT_CAUSAL_HISTORY_SIZE = 2;
16
+ const DEFAULT_RECEIVED_MESSAGE_TIMEOUT = 1000 * 60 * 5; // 5 minutes
17
+
18
+ export class MessageChannel {
19
+ private lamportTimestamp: number;
20
+ private filter: DefaultBloomFilter;
21
+ private outgoingBuffer: Message[];
22
+ private acknowledgements: Map<string, number>;
23
+ private incomingBuffer: Message[];
24
+ private messageIdLog: { timestamp: number; messageId: string }[];
25
+ private channelId: ChannelId;
26
+ private causalHistorySize: number;
27
+ private acknowledgementCount: number;
28
+ private timeReceived: Map<string, number>;
29
+
30
+ public constructor(
31
+ channelId: ChannelId,
32
+ causalHistorySize: number = DEFAULT_CAUSAL_HISTORY_SIZE,
33
+ private receivedMessageTimeoutEnabled: boolean = false,
34
+ private receivedMessageTimeout: number = DEFAULT_RECEIVED_MESSAGE_TIMEOUT
35
+ ) {
36
+ this.channelId = channelId;
37
+ this.lamportTimestamp = 0;
38
+ this.filter = new DefaultBloomFilter(DEFAULT_BLOOM_FILTER_OPTIONS);
39
+ this.outgoingBuffer = [];
40
+ this.acknowledgements = new Map();
41
+ this.incomingBuffer = [];
42
+ this.messageIdLog = [];
43
+ this.causalHistorySize = causalHistorySize;
44
+ this.acknowledgementCount = this.getAcknowledgementCount();
45
+ this.timeReceived = new Map();
46
+ }
47
+
48
+ public static getMessageId(payload: Uint8Array): string {
49
+ return bytesToHex(sha256(payload));
50
+ }
51
+
52
+ /**
53
+ * Send a message to the SDS channel.
54
+ *
55
+ * Increments the lamport timestamp, constructs a `Message` object
56
+ * with the given payload, and adds it to the outgoing buffer.
57
+ *
58
+ * If the callback is successful, the message is also added to
59
+ * the bloom filter and message history. In the context of
60
+ * Waku, this likely means the message was published via
61
+ * light push or relay.
62
+ *
63
+ * See https://rfc.vac.dev/vac/raw/sds/#send-message
64
+ *
65
+ * @param payload - The payload to send.
66
+ * @param callback - A callback function that returns a boolean indicating whether the message was sent successfully.
67
+ */
68
+ public async sendMessage(
69
+ payload: Uint8Array,
70
+ callback?: (message: Message) => Promise<boolean>
71
+ ): Promise<void> {
72
+ this.lamportTimestamp++;
73
+
74
+ const messageId = MessageChannel.getMessageId(payload);
75
+
76
+ const message: Message = {
77
+ messageId,
78
+ channelId: this.channelId,
79
+ lamportTimestamp: this.lamportTimestamp,
80
+ causalHistory: this.messageIdLog
81
+ .slice(-this.causalHistorySize)
82
+ .map(({ messageId }) => messageId),
83
+ bloomFilter: this.filter.toBytes(),
84
+ content: payload
85
+ };
86
+
87
+ this.outgoingBuffer.push(message);
88
+
89
+ if (callback) {
90
+ const success = await callback(message);
91
+ if (success) {
92
+ this.filter.insert(messageId);
93
+ this.messageIdLog.push({ timestamp: this.lamportTimestamp, messageId });
94
+ }
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Process a received SDS message for this channel.
100
+ *
101
+ * Review the acknowledgement status of messages in the outgoing buffer
102
+ * by inspecting the received message's bloom filter and causal history.
103
+ * Add the received message to the bloom filter.
104
+ * If the local history contains every message in the received message's
105
+ * causal history, deliver the message. Otherwise, add the message to the
106
+ * incoming buffer.
107
+ *
108
+ * See https://rfc.vac.dev/vac/raw/sds/#receive-message
109
+ *
110
+ * @param message - The received SDS message.
111
+ */
112
+ public receiveMessage(message: Message): void {
113
+ // review ack status
114
+ this.reviewAckStatus(message);
115
+ // add to bloom filter (skip for messages with empty content)
116
+ if (message.content?.length && message.content.length > 0) {
117
+ this.filter.insert(message.messageId);
118
+ }
119
+ // verify causal history
120
+ const dependenciesMet = message.causalHistory.every((messageId) =>
121
+ this.messageIdLog.some(
122
+ ({ messageId: logMessageId }) => logMessageId === messageId
123
+ )
124
+ );
125
+ if (!dependenciesMet) {
126
+ this.incomingBuffer.push(message);
127
+ this.timeReceived.set(message.messageId, Date.now());
128
+ } else {
129
+ this.deliverMessage(message);
130
+ }
131
+ }
132
+
133
+ // https://rfc.vac.dev/vac/raw/sds/#periodic-incoming-buffer-sweep
134
+ public sweepIncomingBuffer(): string[] {
135
+ const { buffer, missing } = this.incomingBuffer.reduce<{
136
+ buffer: Message[];
137
+ missing: string[];
138
+ }>(
139
+ ({ buffer, missing }, message) => {
140
+ // Check each message for missing dependencies
141
+ const missingDependencies = message.causalHistory.filter(
142
+ (messageId) =>
143
+ !this.messageIdLog.some(
144
+ ({ messageId: logMessageId }) => logMessageId === messageId
145
+ )
146
+ );
147
+ if (missingDependencies.length === 0) {
148
+ // Any message with no missing dependencies is delivered
149
+ // and removed from the buffer (implicitly by not adding it to the new incoming buffer)
150
+ this.deliverMessage(message);
151
+ return { buffer, missing };
152
+ }
153
+
154
+ // Optionally, if a message has not been received after a predetermined amount of time,
155
+ // it is marked as irretrievably lost (implicitly by removing it from the buffer without delivery)
156
+ if (this.receivedMessageTimeoutEnabled) {
157
+ const timeReceived = this.timeReceived.get(message.messageId);
158
+ if (
159
+ timeReceived &&
160
+ Date.now() - timeReceived > this.receivedMessageTimeout
161
+ ) {
162
+ return { buffer, missing };
163
+ }
164
+ }
165
+ // Any message with missing dependencies stays in the buffer
166
+ // and the missing message IDs are returned for processing.
167
+ return {
168
+ buffer: buffer.concat(message),
169
+ missing: missing.concat(missingDependencies)
170
+ };
171
+ },
172
+ { buffer: new Array<Message>(), missing: new Array<string>() }
173
+ );
174
+ // Update the incoming buffer to only include messages with no missing dependencies
175
+ this.incomingBuffer = buffer;
176
+ return missing;
177
+ }
178
+
179
+ // https://rfc.vac.dev/vac/raw/sds/#periodic-outgoing-buffer-sweep
180
+ public sweepOutgoingBuffer(): {
181
+ unacknowledged: Message[];
182
+ possiblyAcknowledged: Message[];
183
+ } {
184
+ // Partition all messages in the outgoing buffer into unacknowledged and possibly acknowledged messages
185
+ return this.outgoingBuffer.reduce<{
186
+ unacknowledged: Message[];
187
+ possiblyAcknowledged: Message[];
188
+ }>(
189
+ ({ unacknowledged, possiblyAcknowledged }, message) => {
190
+ if (this.acknowledgements.has(message.messageId)) {
191
+ return {
192
+ unacknowledged,
193
+ possiblyAcknowledged: possiblyAcknowledged.concat(message)
194
+ };
195
+ }
196
+ return {
197
+ unacknowledged: unacknowledged.concat(message),
198
+ possiblyAcknowledged
199
+ };
200
+ },
201
+ {
202
+ unacknowledged: new Array<Message>(),
203
+ possiblyAcknowledged: new Array<Message>()
204
+ }
205
+ );
206
+ }
207
+
208
+ /**
209
+ * Send a sync message to the SDS channel.
210
+ *
211
+ * Increments the lamport timestamp, constructs a `Message` object
212
+ * with an empty load. Skips outgoing buffer, filter, and local log.
213
+ *
214
+ * See https://rfc.vac.dev/vac/raw/sds/#send-sync-message
215
+ *
216
+ * @param callback - A callback function that returns a boolean indicating whether the message was sent successfully.
217
+ */
218
+ public sendSyncMessage(
219
+ callback?: (message: Message) => Promise<boolean>
220
+ ): Promise<boolean> {
221
+ this.lamportTimestamp++;
222
+
223
+ const emptyMessage = new Uint8Array();
224
+
225
+ const message: Message = {
226
+ messageId: MessageChannel.getMessageId(emptyMessage),
227
+ channelId: this.channelId,
228
+ lamportTimestamp: this.lamportTimestamp,
229
+ causalHistory: this.messageIdLog
230
+ .slice(-this.causalHistorySize)
231
+ .map(({ messageId }) => messageId),
232
+ bloomFilter: this.filter.toBytes(),
233
+ content: emptyMessage
234
+ };
235
+
236
+ if (callback) {
237
+ return callback(message);
238
+ }
239
+ return Promise.resolve(false);
240
+ }
241
+
242
+ // See https://rfc.vac.dev/vac/raw/sds/#deliver-message
243
+ private deliverMessage(message: Message): void {
244
+ const messageLamportTimestamp = message.lamportTimestamp ?? 0;
245
+ if (messageLamportTimestamp > this.lamportTimestamp) {
246
+ this.lamportTimestamp = messageLamportTimestamp;
247
+ }
248
+
249
+ if (message.content?.length === 0) {
250
+ // Messages with empty content are sync messages.
251
+ // They are not added to the local log or bloom filter.
252
+ return;
253
+ }
254
+
255
+ // The participant MUST insert the message ID into its local log,
256
+ // based on Lamport timestamp.
257
+ // If one or more message IDs with the same Lamport timestamp already exists,
258
+ // the participant MUST follow the Resolve Conflicts procedure.
259
+ // https://rfc.vac.dev/vac/raw/sds/#resolve-conflicts
260
+ this.messageIdLog.push({
261
+ timestamp: messageLamportTimestamp,
262
+ messageId: message.messageId
263
+ });
264
+ this.messageIdLog.sort((a, b) => {
265
+ if (a.timestamp !== b.timestamp) {
266
+ return a.timestamp - b.timestamp;
267
+ }
268
+ return a.messageId.localeCompare(b.messageId);
269
+ });
270
+ }
271
+
272
+ // For each received message (including sync messages), inspect the causal history and bloom filter
273
+ // to determine the acknowledgement status of messages in the outgoing buffer.
274
+ // See https://rfc.vac.dev/vac/raw/sds/#review-ack-status
275
+ private reviewAckStatus(receivedMessage: Message): void {
276
+ // the participant MUST mark all messages in the received causal_history as acknowledged.
277
+ receivedMessage.causalHistory.forEach((messageId) => {
278
+ this.outgoingBuffer = this.outgoingBuffer.filter(
279
+ (msg) => msg.messageId !== messageId
280
+ );
281
+ this.acknowledgements.delete(messageId);
282
+ if (!this.filter.lookup(messageId)) {
283
+ this.filter.insert(messageId);
284
+ }
285
+ });
286
+ // the participant MUST mark all messages included in the bloom_filter as possibly acknowledged
287
+ if (!receivedMessage.bloomFilter) {
288
+ return;
289
+ }
290
+ const messageBloomFilter = DefaultBloomFilter.fromBytes(
291
+ receivedMessage.bloomFilter,
292
+ this.filter.options
293
+ );
294
+ this.outgoingBuffer = this.outgoingBuffer.filter((message) => {
295
+ if (!messageBloomFilter.lookup(message.messageId)) {
296
+ return true;
297
+ }
298
+ // If a message appears as possibly acknowledged in multiple received bloom filters,
299
+ // the participant MAY mark it as acknowledged based on probabilistic grounds,
300
+ // taking into account the bloom filter size and hash number.
301
+ const count = (this.acknowledgements.get(message.messageId) ?? 0) + 1;
302
+ if (count < this.acknowledgementCount) {
303
+ this.acknowledgements.set(message.messageId, count);
304
+ return true;
305
+ }
306
+ this.acknowledgements.delete(message.messageId);
307
+ return false;
308
+ });
309
+ }
310
+
311
+ // TODO: this should be determined based on the bloom filter parameters and number of hashes
312
+ private getAcknowledgementCount(): number {
313
+ return 2;
314
+ }
315
+ }