@marinade.finance/ts-subscription-client 1.0.0-beta.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/README.md ADDED
@@ -0,0 +1,107 @@
1
+ # ts-subscription-client
2
+
3
+ TypeScript client for the Marinade notification subscription API.
4
+ Allows users to subscribe/unsubscribe to notifications and list
5
+ active subscriptions.
6
+
7
+ ## Usage
8
+
9
+ ```typescript
10
+ import {
11
+ createSubscriptionClient,
12
+ subscribeMessage,
13
+ unsubscribeMessage,
14
+ listSubscriptionsMessage,
15
+ } from '@marinade.finance/ts-subscription-client'
16
+
17
+ const client = createSubscriptionClient({
18
+ base_url: 'https://notifications.marinade.finance',
19
+ })
20
+ ```
21
+
22
+ The message format is built by helper functions (`subscribeMessage`,
23
+ `unsubscribeMessage`, `listSubscriptionsMessage`). Each request
24
+ includes a signature and message for server-side authentication.
25
+
26
+ For local development and testing subscriptions end-to-end, see
27
+ [notification-service/DEV_GUIDE.md](../notification-service/DEV_GUIDE.md).
28
+
29
+ ## Channels
30
+
31
+ A "channel" is a delivery medium for notifications. Each subscription
32
+ binds a user to a channel + address pair.
33
+
34
+ ### telegram
35
+
36
+ Telegram subscriptions use a deep-link activation flow:
37
+
38
+ 1. **Subscribe** — the client sends a `POST /subscriptions` request
39
+ with `channel=telegram`. The server creates a subscription with
40
+ `telegram_status='pending'` and returns a `deep_link` in the
41
+ response (e.g., `https://t.me/MarinadeBot?start=feature_sam_auction-<uuid>`).
42
+
43
+ 2. **Activate** — the user opens the deep link in Telegram and
44
+ presses "Start". The telegram-bot processes the link and maps
45
+ the user's chat to the subscription. From this point on,
46
+ notifications are delivered as Telegram messages.
47
+
48
+ The `telegram_status` field tracks delivery state:
49
+ `pending` → `active` → `inactive` / `unsubscribed` / `blocked`
50
+
51
+ ### api
52
+
53
+ The API channel stores notifications in a server-side outbox.
54
+ Clients pull them via the read API (notifications endpoint).
55
+ No activation step is needed — subscribing is sufficient.
56
+
57
+ ## Notification types
58
+
59
+ ### sam_auction
60
+
61
+ SAM auction notifications for validators. Subscribing requires
62
+ proof of authority over a bond — the signer must be the bond
63
+ authority, validator identity, or vote account owner. The server
64
+ verifies this against on-chain bond accounts.
65
+
66
+ The `additional_data` field for subscribe/unsubscribe should include:
67
+
68
+ ```typescript
69
+ {
70
+ config_address: string // bonds program config
71
+ vote_account: string // validator vote account
72
+ bond_pubkey: string // derived bond PDA
73
+ }
74
+ ```
75
+
76
+ For listing subscriptions (`GET /subscriptions`), no `additional_data`
77
+ is needed — the server performs a reverse lookup from the pubkey.
78
+
79
+ ## Known limitations
80
+
81
+ ### Telegram subscription lifecycle
82
+
83
+ The notification service tracks `telegram_status` for each telegram subscription:
84
+ `pending` → `active` → `inactive` / `unsubscribed` / `blocked`.
85
+
86
+ The consumer filters by `telegram_status` before attempting delivery.
87
+ It does not attempt to send notifications to subscriptions marked as `inactive`,
88
+ `unsubscribed`, or `blocked`. The status is updated based on the telegram-bot's
89
+ `/send` response:
90
+
91
+ - **200** → `active` (message delivered)
92
+ - **404** → `inactive` (subscription never existed in telegram-bot — bad external_id)
93
+ - **410 "Subscription inactive"** → `unsubscribed` (user unsubscribed via bot UI)
94
+ - **410 "User blocked bot"** → `blocked` (user blocked the bot in Telegram)
95
+ - **429 / 5xx** → retry with backoff, status unchanged
96
+
97
+ #### Re-subscribe after unsubscribe or block
98
+
99
+ When a user unsubscribes via the Telegram bot UI or blocks the bot, the
100
+ notification service marks the subscription as `unsubscribed` or `blocked` and
101
+ stops delivery. Clicking the original Telegram deep link again reactivates the
102
+ subscription on the telegram-bot side, but the notification service is not
103
+ notified — it still sees the old status and skips delivery.
104
+
105
+ To re-subscribe, the user must use the **CLI subscribe command**. This calls
106
+ `POST /subscriptions` which resets `telegram_status` to `pending`, allowing
107
+ delivery to resume on the next event.
@@ -0,0 +1,245 @@
1
+ import http from 'http'
2
+
3
+ import { createSubscriptionClient, NetworkError } from '../index'
4
+
5
+ import type { SubscriptionClient } from '../index'
6
+ import type { IncomingMessage, ServerResponse } from 'http'
7
+
8
+ let server: http.Server
9
+ let port: number
10
+ let client: SubscriptionClient
11
+
12
+ function startServer(
13
+ handler: (req: IncomingMessage, res: ServerResponse) => void,
14
+ ): Promise<void> {
15
+ return new Promise(resolve => {
16
+ server = http.createServer(handler)
17
+ server.listen(0, () => {
18
+ const addr = server.address()
19
+ if (addr && typeof addr !== 'string') {
20
+ port = addr.port
21
+ }
22
+ client = createSubscriptionClient({
23
+ base_url: `http://localhost:${port}`,
24
+ })
25
+ resolve()
26
+ })
27
+ })
28
+ }
29
+
30
+ function stopServer(): Promise<void> {
31
+ return new Promise(resolve => {
32
+ server.close(() => resolve())
33
+ })
34
+ }
35
+
36
+ function readBody(req: IncomingMessage): Promise<string> {
37
+ return new Promise(resolve => {
38
+ let data = ''
39
+ req.on('data', (chunk: Buffer) => {
40
+ data += chunk.toString()
41
+ })
42
+ req.on('end', () => resolve(data))
43
+ })
44
+ }
45
+
46
+ describe('SubscriptionClient E2E', () => {
47
+ afterEach(async () => {
48
+ if (server) {
49
+ await stopServer()
50
+ }
51
+ })
52
+
53
+ describe('subscribe', () => {
54
+ it('sends POST with correct body and returns typed response', async () => {
55
+ let receivedBody: Record<string, unknown> | undefined
56
+ let receivedMethod: string | undefined
57
+
58
+ await startServer((req, res) => {
59
+ receivedMethod = req.method
60
+ void readBody(req).then(body => {
61
+ receivedBody = JSON.parse(body) as Record<string, unknown>
62
+ res.writeHead(200, { 'Content-Type': 'application/json' })
63
+ res.end(
64
+ JSON.stringify({
65
+ user_id: 'u1',
66
+ notification_type: 'sam_auction',
67
+ channel: 'telegram',
68
+ channel_address: '@test',
69
+ created_at: '2024-01-01',
70
+ deep_link: 'https://t.me/bot?start=abc',
71
+ }),
72
+ )
73
+ })
74
+ })
75
+
76
+ const result = await client.subscribe({
77
+ pubkey: 'pk1',
78
+ notification_type: 'sam_auction',
79
+ channel: 'telegram',
80
+ channel_address: '@test',
81
+ signature: 'sig1',
82
+ message: 'Subscribe sam_auction telegram 1710000000',
83
+ additional_data: { vote_account: 'va1' },
84
+ })
85
+
86
+ expect(receivedMethod).toBe('POST')
87
+ expect(receivedBody).toEqual({
88
+ pubkey: 'pk1',
89
+ notification_type: 'sam_auction',
90
+ channel: 'telegram',
91
+ channel_address: '@test',
92
+ signature: 'sig1',
93
+ message: 'Subscribe sam_auction telegram 1710000000',
94
+ additional_data: { vote_account: 'va1' },
95
+ })
96
+ expect(result.deep_link).toBe('https://t.me/bot?start=abc')
97
+ expect(result.user_id).toBe('u1')
98
+ })
99
+ })
100
+
101
+ describe('unsubscribe', () => {
102
+ it('sends DELETE with correct body', async () => {
103
+ let receivedMethod: string | undefined
104
+ let receivedBody: Record<string, unknown> | undefined
105
+
106
+ await startServer((req, res) => {
107
+ receivedMethod = req.method
108
+ void readBody(req).then(body => {
109
+ receivedBody = JSON.parse(body) as Record<string, unknown>
110
+ res.writeHead(200, { 'Content-Type': 'application/json' })
111
+ res.end(JSON.stringify({ deleted: true }))
112
+ })
113
+ })
114
+
115
+ const result = await client.unsubscribe({
116
+ pubkey: 'pk1',
117
+ notification_type: 'sam_auction',
118
+ channel: 'telegram',
119
+ signature: 'sig1',
120
+ message: 'Unsubscribe sam_auction telegram 1710000000',
121
+ })
122
+
123
+ expect(receivedMethod).toBe('DELETE')
124
+ expect(receivedBody?.channel_address).toBeUndefined()
125
+ expect(result.deleted).toBe(true)
126
+ })
127
+
128
+ it('sends DELETE with channel_address when provided', async () => {
129
+ let receivedBody: Record<string, unknown> | undefined
130
+
131
+ await startServer((req, res) => {
132
+ void readBody(req).then(body => {
133
+ receivedBody = JSON.parse(body) as Record<string, unknown>
134
+ res.writeHead(200, { 'Content-Type': 'application/json' })
135
+ res.end(JSON.stringify({ deleted: true }))
136
+ })
137
+ })
138
+
139
+ await client.unsubscribe({
140
+ pubkey: 'pk1',
141
+ notification_type: 'sam_auction',
142
+ channel: 'telegram',
143
+ channel_address: '@testuser',
144
+ signature: 'sig1',
145
+ message: 'Unsubscribe sam_auction telegram 1710000000',
146
+ })
147
+
148
+ expect(receivedBody?.channel_address).toBe('@testuser')
149
+ })
150
+ })
151
+
152
+ describe('listSubscriptions', () => {
153
+ it('sends GET with correct query params and auth headers', async () => {
154
+ let receivedUrl: string | undefined
155
+ let receivedHeaders: Record<string, string | undefined> | undefined
156
+
157
+ await startServer((req, res) => {
158
+ receivedUrl = req.url
159
+ receivedHeaders = {
160
+ 'x-solana-signature': req.headers['x-solana-signature'] as string,
161
+ 'x-solana-message': req.headers['x-solana-message'] as string,
162
+ }
163
+ res.writeHead(200, { 'Content-Type': 'application/json' })
164
+ res.end(
165
+ JSON.stringify([
166
+ {
167
+ user_id: 'u1',
168
+ notification_type: 'sam_auction',
169
+ channel: 'telegram',
170
+ channel_address: '@test',
171
+ created_at: '2024-01-01',
172
+ },
173
+ ]),
174
+ )
175
+ })
176
+
177
+ const result = await client.listSubscriptions(
178
+ {
179
+ pubkey: 'pk1',
180
+ notification_type: 'sam_auction',
181
+ },
182
+ {
183
+ signature: 'sig1',
184
+ message: 'ListSubscriptions pk1 1710000000',
185
+ },
186
+ )
187
+
188
+ expect(receivedUrl).toBe(
189
+ '/subscriptions?pubkey=pk1&notification_type=sam_auction',
190
+ )
191
+ expect(receivedHeaders).toEqual({
192
+ 'x-solana-signature': 'sig1',
193
+ 'x-solana-message': 'ListSubscriptions pk1 1710000000',
194
+ })
195
+ expect(result).toHaveLength(1)
196
+ expect(result[0].channel).toBe('telegram')
197
+ })
198
+ })
199
+
200
+ describe('error propagation', () => {
201
+ it.each([400, 401, 403, 404])(
202
+ 'throws NetworkError with status %d',
203
+ async statusCode => {
204
+ await startServer((_req, res) => {
205
+ res.writeHead(statusCode, { 'Content-Type': 'text/plain' })
206
+ res.end(`error ${statusCode}`)
207
+ })
208
+
209
+ const error = await client
210
+ .subscribe({
211
+ pubkey: 'p',
212
+ notification_type: 'sam_auction',
213
+ channel: 'telegram',
214
+ channel_address: '@t',
215
+ signature: 's',
216
+ message: 'm',
217
+ })
218
+ .catch((e: unknown) => e)
219
+
220
+ expect(error).toBeInstanceOf(NetworkError)
221
+ expect((error as NetworkError).status).toBe(statusCode)
222
+ expect((error as NetworkError).response).toBe(`error ${statusCode}`)
223
+ },
224
+ )
225
+ })
226
+
227
+ describe('connection refused', () => {
228
+ it('throws NetworkError when server is unreachable', async () => {
229
+ const unreachableClient = createSubscriptionClient({
230
+ base_url: 'http://localhost:19999',
231
+ })
232
+
233
+ await expect(
234
+ unreachableClient.subscribe({
235
+ pubkey: 'p',
236
+ notification_type: 'sam_auction',
237
+ channel: 'telegram',
238
+ channel_address: '@t',
239
+ signature: 's',
240
+ message: 'm',
241
+ }),
242
+ ).rejects.toThrow(NetworkError)
243
+ })
244
+ })
245
+ })
@@ -0,0 +1,348 @@
1
+ import { createSubscriptionClient, NetworkError } from '../index'
2
+
3
+ global.fetch = jest.fn()
4
+
5
+ describe('SubscriptionClient', () => {
6
+ beforeEach(() => {
7
+ jest.clearAllMocks()
8
+ })
9
+
10
+ afterEach(() => {
11
+ jest.restoreAllMocks()
12
+ })
13
+
14
+ describe('subscribe', () => {
15
+ it('should send POST request with correct body', async () => {
16
+ const mockFetch = global.fetch as jest.Mock
17
+ const responseBody = {
18
+ user_id: 'user-1',
19
+ notification_type: 'sam_auction',
20
+ channel: 'telegram',
21
+ channel_address: '@testuser',
22
+ created_at: '2024-01-01T00:00:00Z',
23
+ deep_link: 'https://t.me/test',
24
+ }
25
+ mockFetch.mockResolvedValue({
26
+ ok: true,
27
+ json: () => Promise.resolve(responseBody),
28
+ } as Response)
29
+
30
+ const client = createSubscriptionClient({
31
+ base_url: 'http://localhost:3000',
32
+ })
33
+
34
+ const request = {
35
+ pubkey: 'pubkey123',
36
+ notification_type: 'sam_auction',
37
+ channel: 'telegram',
38
+ channel_address: '@testuser',
39
+ signature: 'sig123',
40
+ message: 'Subscribe sam_auction telegram 1710000000',
41
+ additional_data: { vote_account: 'vote123' },
42
+ }
43
+
44
+ const result = await client.subscribe(request)
45
+
46
+ expect(result).toEqual(responseBody)
47
+ expect(mockFetch).toHaveBeenCalledTimes(1)
48
+ const [url, options] = mockFetch.mock.calls[0]
49
+ expect(url).toBe('http://localhost:3000/subscriptions')
50
+ expect(options?.method).toBe('POST')
51
+ expect(options?.headers).toEqual({ 'Content-Type': 'application/json' })
52
+ expect(JSON.parse(options?.body as string)).toEqual(request)
53
+ })
54
+
55
+ it('should throw NetworkError on HTTP error', async () => {
56
+ const mockFetch = global.fetch as jest.Mock
57
+ mockFetch.mockResolvedValue({
58
+ ok: false,
59
+ status: 400,
60
+ statusText: 'Bad Request',
61
+ text: () => Promise.resolve('validation failed'),
62
+ } as Response)
63
+
64
+ const client = createSubscriptionClient({
65
+ base_url: 'http://localhost:3000',
66
+ })
67
+
68
+ await expect(
69
+ client.subscribe({
70
+ pubkey: 'p',
71
+ notification_type: 'sam_auction',
72
+ channel: 'telegram',
73
+ channel_address: '@test',
74
+ signature: 's',
75
+ message: 'm',
76
+ }),
77
+ ).rejects.toThrow(NetworkError)
78
+ })
79
+
80
+ it('should throw NetworkError on network failure', async () => {
81
+ const mockFetch = global.fetch as jest.Mock
82
+ mockFetch.mockRejectedValue(new Error('ECONNREFUSED'))
83
+
84
+ const client = createSubscriptionClient({
85
+ base_url: 'http://localhost:3000',
86
+ })
87
+
88
+ await expect(
89
+ client.subscribe({
90
+ pubkey: 'p',
91
+ notification_type: 'sam_auction',
92
+ channel: 'telegram',
93
+ channel_address: '@test',
94
+ signature: 's',
95
+ message: 'm',
96
+ }),
97
+ ).rejects.toThrow('ECONNREFUSED')
98
+ })
99
+ })
100
+
101
+ describe('unsubscribe', () => {
102
+ it('should send DELETE request with correct body', async () => {
103
+ const mockFetch = global.fetch as jest.Mock
104
+ mockFetch.mockResolvedValue({
105
+ ok: true,
106
+ json: () => Promise.resolve({ deleted: true }),
107
+ } as Response)
108
+
109
+ const client = createSubscriptionClient({
110
+ base_url: 'http://localhost:3000',
111
+ })
112
+
113
+ const request = {
114
+ pubkey: 'pubkey123',
115
+ notification_type: 'sam_auction',
116
+ channel: 'telegram',
117
+ signature: 'sig123',
118
+ message: 'Unsubscribe sam_auction telegram 1710000000',
119
+ }
120
+
121
+ const result = await client.unsubscribe(request)
122
+
123
+ expect(result).toEqual({ deleted: true })
124
+ const [url, options] = mockFetch.mock.calls[0]
125
+ expect(url).toBe('http://localhost:3000/subscriptions')
126
+ expect(options?.method).toBe('DELETE')
127
+ expect(JSON.parse(options?.body as string)).toEqual(request)
128
+ })
129
+
130
+ it('should include channel_address when provided', async () => {
131
+ const mockFetch = global.fetch as jest.Mock
132
+ mockFetch.mockResolvedValue({
133
+ ok: true,
134
+ json: () => Promise.resolve({ deleted: true }),
135
+ } as Response)
136
+
137
+ const client = createSubscriptionClient({
138
+ base_url: 'http://localhost:3000',
139
+ })
140
+
141
+ const request = {
142
+ pubkey: 'pubkey123',
143
+ notification_type: 'sam_auction',
144
+ channel: 'telegram',
145
+ channel_address: '@testuser',
146
+ signature: 'sig123',
147
+ message: 'Unsubscribe sam_auction telegram 1710000000',
148
+ }
149
+
150
+ await client.unsubscribe(request)
151
+
152
+ const [, options] = mockFetch.mock.calls[0]
153
+ const body = JSON.parse(options?.body as string)
154
+ expect(body.channel_address).toBe('@testuser')
155
+ })
156
+
157
+ it('should throw NetworkError on HTTP error', async () => {
158
+ const mockFetch = global.fetch as jest.Mock
159
+ mockFetch.mockResolvedValue({
160
+ ok: false,
161
+ status: 403,
162
+ statusText: 'Forbidden',
163
+ text: () => Promise.resolve('unauthorized'),
164
+ } as Response)
165
+
166
+ const client = createSubscriptionClient({
167
+ base_url: 'http://localhost:3000',
168
+ })
169
+
170
+ await expect(
171
+ client.unsubscribe({
172
+ pubkey: 'p',
173
+ notification_type: 'sam_auction',
174
+ channel: 'telegram',
175
+ signature: 's',
176
+ message: 'm',
177
+ }),
178
+ ).rejects.toThrow(NetworkError)
179
+ })
180
+ })
181
+
182
+ describe('listSubscriptions', () => {
183
+ it('should send GET request with correct query params and auth headers', async () => {
184
+ const mockFetch = global.fetch as jest.Mock
185
+ const responseBody = [
186
+ {
187
+ user_id: 'user-1',
188
+ notification_type: 'sam_auction',
189
+ channel: 'telegram',
190
+ channel_address: '@testuser',
191
+ created_at: '2024-01-01T00:00:00Z',
192
+ },
193
+ ]
194
+ mockFetch.mockResolvedValue({
195
+ ok: true,
196
+ json: () => Promise.resolve(responseBody),
197
+ } as Response)
198
+
199
+ const client = createSubscriptionClient({
200
+ base_url: 'http://localhost:3000',
201
+ })
202
+
203
+ const result = await client.listSubscriptions(
204
+ {
205
+ pubkey: 'pubkey123',
206
+ notification_type: 'sam_auction',
207
+ },
208
+ {
209
+ signature: 'sig123',
210
+ message: 'ListSubscriptions pubkey123 1710000000',
211
+ },
212
+ )
213
+
214
+ expect(result).toEqual(responseBody)
215
+ expect(mockFetch).toHaveBeenCalledTimes(1)
216
+ const [url, options] = mockFetch.mock.calls[0]
217
+ expect(url).toBe(
218
+ 'http://localhost:3000/subscriptions?pubkey=pubkey123&notification_type=sam_auction',
219
+ )
220
+ expect(options?.method).toBe('GET')
221
+ expect(options?.headers).toEqual({
222
+ 'x-solana-signature': 'sig123',
223
+ 'x-solana-message': 'ListSubscriptions pubkey123 1710000000',
224
+ })
225
+ })
226
+
227
+ it('should include additional_data as JSON query param', async () => {
228
+ const mockFetch = global.fetch as jest.Mock
229
+ mockFetch.mockResolvedValue({
230
+ ok: true,
231
+ json: () => Promise.resolve([]),
232
+ } as Response)
233
+
234
+ const client = createSubscriptionClient({
235
+ base_url: 'http://localhost:3000',
236
+ })
237
+
238
+ await client.listSubscriptions(
239
+ {
240
+ pubkey: 'pubkey123',
241
+ notification_type: 'sam_auction',
242
+ additional_data: { vote_account: 'vote123' },
243
+ },
244
+ { signature: 'sig', message: 'msg' },
245
+ )
246
+
247
+ const [url] = mockFetch.mock.calls[0]
248
+ const parsed = new URL(url)
249
+ expect(parsed.searchParams.get('additional_data')).toBe(
250
+ JSON.stringify({ vote_account: 'vote123' }),
251
+ )
252
+ })
253
+
254
+ it('should omit additional_data when empty object', async () => {
255
+ const mockFetch = global.fetch as jest.Mock
256
+ mockFetch.mockResolvedValue({
257
+ ok: true,
258
+ json: () => Promise.resolve([]),
259
+ } as Response)
260
+
261
+ const client = createSubscriptionClient({
262
+ base_url: 'http://localhost:3000',
263
+ })
264
+
265
+ await client.listSubscriptions(
266
+ { pubkey: 'pubkey123', additional_data: {} },
267
+ { signature: 'sig', message: 'msg' },
268
+ )
269
+
270
+ const [url] = mockFetch.mock.calls[0]
271
+ const parsed = new URL(url)
272
+ expect(parsed.searchParams.get('additional_data')).toBeNull()
273
+ })
274
+
275
+ it('should omit optional query params when not provided', async () => {
276
+ const mockFetch = global.fetch as jest.Mock
277
+ mockFetch.mockResolvedValue({
278
+ ok: true,
279
+ json: () => Promise.resolve([]),
280
+ } as Response)
281
+
282
+ const client = createSubscriptionClient({
283
+ base_url: 'http://localhost:3000',
284
+ })
285
+
286
+ await client.listSubscriptions(
287
+ { pubkey: 'pubkey123' },
288
+ { signature: 'sig', message: 'msg' },
289
+ )
290
+
291
+ const [url] = mockFetch.mock.calls[0]
292
+ expect(url).toBe('http://localhost:3000/subscriptions?pubkey=pubkey123')
293
+ })
294
+
295
+ it('should throw NetworkError on HTTP error', async () => {
296
+ const mockFetch = global.fetch as jest.Mock
297
+ mockFetch.mockResolvedValue({
298
+ ok: false,
299
+ status: 401,
300
+ statusText: 'Unauthorized',
301
+ text: () => Promise.resolve('invalid signature'),
302
+ } as Response)
303
+
304
+ const client = createSubscriptionClient({
305
+ base_url: 'http://localhost:3000',
306
+ })
307
+
308
+ await expect(
309
+ client.listSubscriptions(
310
+ { pubkey: 'p' },
311
+ { signature: 's', message: 'm' },
312
+ ),
313
+ ).rejects.toThrow(NetworkError)
314
+ })
315
+ })
316
+
317
+ describe('timeout', () => {
318
+ it('should throw NetworkError on timeout', async () => {
319
+ const mockFetch = global.fetch as jest.Mock
320
+ mockFetch.mockImplementation(
321
+ (_url: string, options: { signal: AbortSignal }) =>
322
+ new Promise((_resolve, reject) => {
323
+ options.signal.addEventListener('abort', () => {
324
+ const err = new Error('The operation was aborted')
325
+ err.name = 'AbortError'
326
+ reject(err)
327
+ })
328
+ }),
329
+ )
330
+
331
+ const client = createSubscriptionClient({
332
+ base_url: 'http://localhost:3000',
333
+ timeout_ms: 50,
334
+ })
335
+
336
+ await expect(
337
+ client.subscribe({
338
+ pubkey: 'p',
339
+ notification_type: 'sam_auction',
340
+ channel: 'telegram',
341
+ channel_address: '@test',
342
+ signature: 's',
343
+ message: 'm',
344
+ }),
345
+ ).rejects.toThrow(/timeout/)
346
+ })
347
+ })
348
+ })