@marinade.finance/ts-subscription-client 1.0.0

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,82 @@
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 two-phase activation flow:
37
+
38
+ 1. **Subscribe** — the client sends a `POST /subscriptions` request
39
+ with `channel=telegram`. The server creates a pending activation
40
+ record and returns a `deep_link` in the response
41
+ (e.g., `https://t.me/MarinadeBot?start=<token>`).
42
+ At this point the subscription exists but cannot receive messages.
43
+
44
+ 2. **Activate** — the user opens the deep link in Telegram and
45
+ presses "Start". The bot receives the token via its webhook,
46
+ links the user's Telegram chat to the subscription, and confirms
47
+ activation. From this point on, notifications are delivered as
48
+ Telegram messages.
49
+
50
+ If the user has already activated, the response returns
51
+ `telegram_status: 'already_activated'` instead of a new deep link.
52
+
53
+ Blocking the bot automatically unsubscribes all Telegram
54
+ subscriptions for that chat.
55
+
56
+ ### api
57
+
58
+ The API channel stores notifications in a server-side outbox.
59
+ Clients pull them via the read API (notifications endpoint).
60
+ No activation step is needed — subscribing is sufficient.
61
+
62
+ ## Notification types
63
+
64
+ ### bonds
65
+
66
+ Bond risk notifications for validators in the SAM auction.
67
+ Subscribing requires proof of authority over a bond — the signer
68
+ must be the bond authority, validator identity, or vote account
69
+ owner. The server verifies this against on-chain bond accounts.
70
+
71
+ The `additional_data` field for subscribe/unsubscribe should include:
72
+
73
+ ```typescript
74
+ {
75
+ config_address: string // bonds program config
76
+ vote_account: string // validator vote account
77
+ bond_pubkey: string // derived bond PDA
78
+ }
79
+ ```
80
+
81
+ For listing subscriptions (`GET /subscriptions`), no `additional_data`
82
+ is needed — the server performs a reverse lookup from the pubkey.
@@ -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: 'bonds',
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: 'bonds',
79
+ channel: 'telegram',
80
+ channel_address: '@test',
81
+ signature: 'sig1',
82
+ message: 'Subscribe bonds 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: 'bonds',
90
+ channel: 'telegram',
91
+ channel_address: '@test',
92
+ signature: 'sig1',
93
+ message: 'Subscribe bonds 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: 'bonds',
118
+ channel: 'telegram',
119
+ signature: 'sig1',
120
+ message: 'Unsubscribe bonds 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: 'bonds',
142
+ channel: 'telegram',
143
+ channel_address: '@testuser',
144
+ signature: 'sig1',
145
+ message: 'Unsubscribe bonds 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: 'bonds',
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: 'bonds',
181
+ },
182
+ {
183
+ signature: 'sig1',
184
+ message: 'ListSubscriptions pk1 1710000000',
185
+ },
186
+ )
187
+
188
+ expect(receivedUrl).toBe(
189
+ '/subscriptions?pubkey=pk1&notification_type=bonds',
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: 'bonds',
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: 'bonds',
237
+ channel: 'telegram',
238
+ channel_address: '@t',
239
+ signature: 's',
240
+ message: 'm',
241
+ }),
242
+ ).rejects.toThrow(NetworkError)
243
+ })
244
+ })
245
+ })
@@ -0,0 +1,300 @@
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: 'bonds',
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: 'bonds',
37
+ channel: 'telegram',
38
+ channel_address: '@testuser',
39
+ signature: 'sig123',
40
+ message: 'Subscribe bonds 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: 'bonds',
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: 'bonds',
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: 'bonds',
116
+ channel: 'telegram',
117
+ signature: 'sig123',
118
+ message: 'Unsubscribe bonds 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: 'bonds',
144
+ channel: 'telegram',
145
+ channel_address: '@testuser',
146
+ signature: 'sig123',
147
+ message: 'Unsubscribe bonds 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: 'bonds',
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: 'bonds',
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: 'bonds',
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=bonds',
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 omit optional query params when not provided', 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
+ { pubkey: 'pubkey123' },
240
+ { signature: 'sig', message: 'msg' },
241
+ )
242
+
243
+ const [url] = mockFetch.mock.calls[0]
244
+ expect(url).toBe('http://localhost:3000/subscriptions?pubkey=pubkey123')
245
+ })
246
+
247
+ it('should throw NetworkError on HTTP error', async () => {
248
+ const mockFetch = global.fetch as jest.Mock
249
+ mockFetch.mockResolvedValue({
250
+ ok: false,
251
+ status: 401,
252
+ statusText: 'Unauthorized',
253
+ text: () => Promise.resolve('invalid signature'),
254
+ } as Response)
255
+
256
+ const client = createSubscriptionClient({
257
+ base_url: 'http://localhost:3000',
258
+ })
259
+
260
+ await expect(
261
+ client.listSubscriptions(
262
+ { pubkey: 'p' },
263
+ { signature: 's', message: 'm' },
264
+ ),
265
+ ).rejects.toThrow(NetworkError)
266
+ })
267
+ })
268
+
269
+ describe('timeout', () => {
270
+ it('should throw NetworkError on timeout', async () => {
271
+ const mockFetch = global.fetch as jest.Mock
272
+ mockFetch.mockImplementation(
273
+ (_url: string, options: { signal: AbortSignal }) =>
274
+ new Promise((_resolve, reject) => {
275
+ options.signal.addEventListener('abort', () => {
276
+ const err = new Error('The operation was aborted')
277
+ err.name = 'AbortError'
278
+ reject(err)
279
+ })
280
+ }),
281
+ )
282
+
283
+ const client = createSubscriptionClient({
284
+ base_url: 'http://localhost:3000',
285
+ timeout_ms: 50,
286
+ })
287
+
288
+ await expect(
289
+ client.subscribe({
290
+ pubkey: 'p',
291
+ notification_type: 'bonds',
292
+ channel: 'telegram',
293
+ channel_address: '@test',
294
+ signature: 's',
295
+ message: 'm',
296
+ }),
297
+ ).rejects.toThrow(/timeout/)
298
+ })
299
+ })
300
+ })
@@ -0,0 +1,30 @@
1
+ import {
2
+ subscribeMessage,
3
+ unsubscribeMessage,
4
+ listSubscriptionsMessage,
5
+ } from '../index'
6
+
7
+ describe('message helpers', () => {
8
+ it('subscribeMessage', () => {
9
+ expect(subscribeMessage('bonds', 'telegram', 1710000000)).toBe(
10
+ 'Subscribe bonds telegram 1710000000',
11
+ )
12
+ })
13
+
14
+ it('unsubscribeMessage', () => {
15
+ expect(unsubscribeMessage('bonds', 'email', 1710000000)).toBe(
16
+ 'Unsubscribe bonds email 1710000000',
17
+ )
18
+ })
19
+
20
+ it('listSubscriptionsMessage', () => {
21
+ expect(
22
+ listSubscriptionsMessage(
23
+ 'GrxB8UaaaaaaaaaaaaaaAAAAAAAAAAAAAAAAAAAAAAAA',
24
+ 1710000000,
25
+ ),
26
+ ).toBe(
27
+ 'ListSubscriptions GrxB8UaaaaaaaaaaaaaaAAAAAAAAAAAAAAAAAAAAAAAA 1710000000',
28
+ )
29
+ })
30
+ })
package/client.ts ADDED
@@ -0,0 +1,121 @@
1
+ import {
2
+ type SubscriptionClientConfig,
3
+ type Logger,
4
+ type SubscribeRequest,
5
+ type SubscribeResponse,
6
+ type UnsubscribeRequest,
7
+ type UnsubscribeResponse,
8
+ type ListSubscriptionsQuery,
9
+ type ListSubscriptionsAuth,
10
+ type Subscription,
11
+ NetworkError,
12
+ } from './types'
13
+
14
+ const PATH_SUBSCRIPTIONS = '/subscriptions'
15
+
16
+ export class SubscriptionClient {
17
+ private readonly base_url: string
18
+ private readonly timeout_ms: number
19
+ private readonly logger?: Logger
20
+
21
+ constructor(config: SubscriptionClientConfig) {
22
+ this.base_url = config.base_url
23
+ this.timeout_ms = config.timeout_ms ?? 10_000
24
+ this.logger = config.logger
25
+ }
26
+
27
+ async subscribe(request: SubscribeRequest): Promise<SubscribeResponse> {
28
+ this.logger?.debug(
29
+ `POST ${PATH_SUBSCRIPTIONS} body: ${JSON.stringify({
30
+ ...request,
31
+ signature: '[redacted]',
32
+ })}`,
33
+ )
34
+ return this.fetch<SubscribeResponse>(PATH_SUBSCRIPTIONS, {
35
+ method: 'POST',
36
+ headers: { 'Content-Type': 'application/json' },
37
+ body: JSON.stringify(request),
38
+ })
39
+ }
40
+
41
+ async unsubscribe(request: UnsubscribeRequest): Promise<UnsubscribeResponse> {
42
+ this.logger?.debug(
43
+ `DELETE ${PATH_SUBSCRIPTIONS} body: ${JSON.stringify({
44
+ ...request,
45
+ signature: '[redacted]',
46
+ })}`,
47
+ )
48
+ return this.fetch<UnsubscribeResponse>(PATH_SUBSCRIPTIONS, {
49
+ method: 'DELETE',
50
+ headers: { 'Content-Type': 'application/json' },
51
+ body: JSON.stringify(request),
52
+ })
53
+ }
54
+
55
+ async listSubscriptions(
56
+ query: ListSubscriptionsQuery,
57
+ auth: ListSubscriptionsAuth,
58
+ ): Promise<Subscription[]> {
59
+ const params = new URLSearchParams()
60
+ params.set('pubkey', query.pubkey)
61
+ if (query.notification_type) {
62
+ params.set('notification_type', query.notification_type)
63
+ }
64
+ const path = `${PATH_SUBSCRIPTIONS}?${params.toString()}`
65
+
66
+ this.logger?.debug(`GET ${path} signature: [redacted]`)
67
+ return this.fetch<Subscription[]>(path, {
68
+ method: 'GET',
69
+ headers: {
70
+ 'x-solana-signature': auth.signature,
71
+ 'x-solana-message': auth.message,
72
+ },
73
+ })
74
+ }
75
+
76
+ private async fetch<T>(path: string, init: RequestInit): Promise<T> {
77
+ const url = `${this.base_url}${path}`
78
+ const method = init.method ?? 'GET'
79
+
80
+ const controller = new AbortController()
81
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout_ms)
82
+
83
+ try {
84
+ const response = await globalThis.fetch(url, {
85
+ ...init,
86
+ signal: controller.signal,
87
+ })
88
+
89
+ clearTimeout(timeoutId)
90
+
91
+ if (!response.ok) {
92
+ const errorBody = await response.text().catch(() => 'unknown error')
93
+ throw new NetworkError(
94
+ `${method} ${path} failed: ` +
95
+ `${response.status} ${response.statusText}`,
96
+ response.status,
97
+ errorBody,
98
+ )
99
+ }
100
+
101
+ return (await response.json()) as T
102
+ } catch (error) {
103
+ clearTimeout(timeoutId)
104
+
105
+ if (error instanceof NetworkError) {
106
+ throw error
107
+ }
108
+
109
+ if (error instanceof Error) {
110
+ if (error.name === 'AbortError') {
111
+ throw new NetworkError(
112
+ `${method} ${path} timeout after ${this.timeout_ms}ms`,
113
+ )
114
+ }
115
+ throw new NetworkError(`${method} ${path} failed: ${error.message}`)
116
+ }
117
+
118
+ throw new NetworkError(`${method} ${path} failed: unknown error`)
119
+ }
120
+ }
121
+ }
@@ -0,0 +1,11 @@
1
+ import { type SubscriptionClientConfig, type SubscribeRequest, type SubscribeResponse, type UnsubscribeRequest, type UnsubscribeResponse, type ListSubscriptionsQuery, type ListSubscriptionsAuth, type Subscription } from './types';
2
+ export declare class SubscriptionClient {
3
+ private readonly base_url;
4
+ private readonly timeout_ms;
5
+ private readonly logger?;
6
+ constructor(config: SubscriptionClientConfig);
7
+ subscribe(request: SubscribeRequest): Promise<SubscribeResponse>;
8
+ unsubscribe(request: UnsubscribeRequest): Promise<UnsubscribeResponse>;
9
+ listSubscriptions(query: ListSubscriptionsQuery, auth: ListSubscriptionsAuth): Promise<Subscription[]>;
10
+ private fetch;
11
+ }
package/dist/client.js ADDED
@@ -0,0 +1,83 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SubscriptionClient = void 0;
4
+ const types_1 = require("./types");
5
+ const PATH_SUBSCRIPTIONS = '/subscriptions';
6
+ class SubscriptionClient {
7
+ constructor(config) {
8
+ this.base_url = config.base_url;
9
+ this.timeout_ms = config.timeout_ms ?? 10000;
10
+ this.logger = config.logger;
11
+ }
12
+ async subscribe(request) {
13
+ this.logger?.debug(`POST ${PATH_SUBSCRIPTIONS} body: ${JSON.stringify({
14
+ ...request,
15
+ signature: '[redacted]',
16
+ })}`);
17
+ return this.fetch(PATH_SUBSCRIPTIONS, {
18
+ method: 'POST',
19
+ headers: { 'Content-Type': 'application/json' },
20
+ body: JSON.stringify(request),
21
+ });
22
+ }
23
+ async unsubscribe(request) {
24
+ this.logger?.debug(`DELETE ${PATH_SUBSCRIPTIONS} body: ${JSON.stringify({
25
+ ...request,
26
+ signature: '[redacted]',
27
+ })}`);
28
+ return this.fetch(PATH_SUBSCRIPTIONS, {
29
+ method: 'DELETE',
30
+ headers: { 'Content-Type': 'application/json' },
31
+ body: JSON.stringify(request),
32
+ });
33
+ }
34
+ async listSubscriptions(query, auth) {
35
+ const params = new URLSearchParams();
36
+ params.set('pubkey', query.pubkey);
37
+ if (query.notification_type) {
38
+ params.set('notification_type', query.notification_type);
39
+ }
40
+ const path = `${PATH_SUBSCRIPTIONS}?${params.toString()}`;
41
+ this.logger?.debug(`GET ${path} signature: [redacted]`);
42
+ return this.fetch(path, {
43
+ method: 'GET',
44
+ headers: {
45
+ 'x-solana-signature': auth.signature,
46
+ 'x-solana-message': auth.message,
47
+ },
48
+ });
49
+ }
50
+ async fetch(path, init) {
51
+ const url = `${this.base_url}${path}`;
52
+ const method = init.method ?? 'GET';
53
+ const controller = new AbortController();
54
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout_ms);
55
+ try {
56
+ const response = await globalThis.fetch(url, {
57
+ ...init,
58
+ signal: controller.signal,
59
+ });
60
+ clearTimeout(timeoutId);
61
+ if (!response.ok) {
62
+ const errorBody = await response.text().catch(() => 'unknown error');
63
+ throw new types_1.NetworkError(`${method} ${path} failed: ` +
64
+ `${response.status} ${response.statusText}`, response.status, errorBody);
65
+ }
66
+ return (await response.json());
67
+ }
68
+ catch (error) {
69
+ clearTimeout(timeoutId);
70
+ if (error instanceof types_1.NetworkError) {
71
+ throw error;
72
+ }
73
+ if (error instanceof Error) {
74
+ if (error.name === 'AbortError') {
75
+ throw new types_1.NetworkError(`${method} ${path} timeout after ${this.timeout_ms}ms`);
76
+ }
77
+ throw new types_1.NetworkError(`${method} ${path} failed: ${error.message}`);
78
+ }
79
+ throw new types_1.NetworkError(`${method} ${path} failed: unknown error`);
80
+ }
81
+ }
82
+ }
83
+ exports.SubscriptionClient = SubscriptionClient;
@@ -0,0 +1,7 @@
1
+ import { SubscriptionClient } from './client';
2
+ import type { SubscriptionClientConfig } from './types';
3
+ export { SubscriptionClient };
4
+ export { subscribeMessage, unsubscribeMessage, listSubscriptionsMessage, } from './message';
5
+ export { NetworkError } from './types';
6
+ export type { Logger, SubscriptionClientConfig, SubscribeRequest, SubscribeResponse, UnsubscribeRequest, UnsubscribeResponse, ListSubscriptionsQuery, ListSubscriptionsAuth, Subscription, } from './types';
7
+ export declare function createSubscriptionClient(config: SubscriptionClientConfig): SubscriptionClient;
package/dist/index.js ADDED
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.NetworkError = exports.listSubscriptionsMessage = exports.unsubscribeMessage = exports.subscribeMessage = exports.SubscriptionClient = void 0;
4
+ exports.createSubscriptionClient = createSubscriptionClient;
5
+ const client_1 = require("./client");
6
+ Object.defineProperty(exports, "SubscriptionClient", { enumerable: true, get: function () { return client_1.SubscriptionClient; } });
7
+ var message_1 = require("./message");
8
+ Object.defineProperty(exports, "subscribeMessage", { enumerable: true, get: function () { return message_1.subscribeMessage; } });
9
+ Object.defineProperty(exports, "unsubscribeMessage", { enumerable: true, get: function () { return message_1.unsubscribeMessage; } });
10
+ Object.defineProperty(exports, "listSubscriptionsMessage", { enumerable: true, get: function () { return message_1.listSubscriptionsMessage; } });
11
+ var types_1 = require("./types");
12
+ Object.defineProperty(exports, "NetworkError", { enumerable: true, get: function () { return types_1.NetworkError; } });
13
+ function createSubscriptionClient(config) {
14
+ return new client_1.SubscriptionClient(config);
15
+ }
@@ -0,0 +1,6 @@
1
+ /** Builds the signed message string for subscribe operations */
2
+ export declare function subscribeMessage(notificationType: string, channel: string, timestampSeconds: number): string;
3
+ /** Builds the signed message string for unsubscribe operations */
4
+ export declare function unsubscribeMessage(notificationType: string, channel: string, timestampSeconds: number): string;
5
+ /** Builds the signed message string for listing subscriptions */
6
+ export declare function listSubscriptionsMessage(pubkey: string, timestampSeconds: number): string;
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.subscribeMessage = subscribeMessage;
4
+ exports.unsubscribeMessage = unsubscribeMessage;
5
+ exports.listSubscriptionsMessage = listSubscriptionsMessage;
6
+ /** Builds the signed message string for subscribe operations */
7
+ function subscribeMessage(notificationType, channel, timestampSeconds) {
8
+ return `Subscribe ${notificationType} ${channel} ${timestampSeconds}`;
9
+ }
10
+ /** Builds the signed message string for unsubscribe operations */
11
+ function unsubscribeMessage(notificationType, channel, timestampSeconds) {
12
+ return `Unsubscribe ${notificationType} ${channel} ${timestampSeconds}`;
13
+ }
14
+ /** Builds the signed message string for listing subscriptions */
15
+ function listSubscriptionsMessage(pubkey, timestampSeconds) {
16
+ return `ListSubscriptions ${pubkey} ${timestampSeconds}`;
17
+ }
@@ -0,0 +1 @@
1
+ {"root":["../client.ts","../index.ts","../message.ts","../types.ts"],"version":"5.8.3"}
@@ -0,0 +1,67 @@
1
+ /** Minimal logger interface — compatible with pino, console, etc. */
2
+ export interface Logger {
3
+ debug(msg: string): void;
4
+ }
5
+ /** Configuration for the subscription client */
6
+ export interface SubscriptionClientConfig {
7
+ base_url: string;
8
+ timeout_ms?: number;
9
+ logger?: Logger;
10
+ }
11
+ /** POST /subscriptions request */
12
+ export interface SubscribeRequest {
13
+ pubkey: string;
14
+ notification_type: string;
15
+ channel: string;
16
+ channel_address: string;
17
+ signature: string;
18
+ message: string;
19
+ additional_data?: Record<string, unknown>;
20
+ }
21
+ /** DELETE /subscriptions request */
22
+ export interface UnsubscribeRequest {
23
+ pubkey: string;
24
+ notification_type: string;
25
+ channel: string;
26
+ channel_address?: string;
27
+ signature: string;
28
+ message: string;
29
+ additional_data?: Record<string, unknown>;
30
+ }
31
+ /** GET /subscriptions auth */
32
+ export interface ListSubscriptionsAuth {
33
+ signature: string;
34
+ message: string;
35
+ }
36
+ /** GET /subscriptions query */
37
+ export interface ListSubscriptionsQuery {
38
+ pubkey: string;
39
+ notification_type?: string;
40
+ }
41
+ /** POST /subscriptions response */
42
+ export interface SubscribeResponse {
43
+ user_id: string;
44
+ notification_type: string;
45
+ channel: string;
46
+ channel_address: string;
47
+ created_at: string;
48
+ deep_link?: string;
49
+ telegram_status?: 'already_activated' | 'bot_not_configured';
50
+ }
51
+ /** DELETE /subscriptions response */
52
+ export interface UnsubscribeResponse {
53
+ deleted: boolean;
54
+ }
55
+ /** GET /subscriptions response item */
56
+ export interface Subscription {
57
+ user_id: string;
58
+ notification_type: string;
59
+ channel: string;
60
+ channel_address: string;
61
+ created_at: string;
62
+ }
63
+ export declare class NetworkError extends Error {
64
+ readonly status?: number | undefined;
65
+ readonly response?: unknown | undefined;
66
+ constructor(message: string, status?: number | undefined, response?: unknown | undefined);
67
+ }
package/dist/types.js ADDED
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.NetworkError = void 0;
4
+ class NetworkError extends Error {
5
+ constructor(message, status, response) {
6
+ super(message);
7
+ this.status = status;
8
+ this.response = response;
9
+ this.name = 'NetworkError';
10
+ }
11
+ }
12
+ exports.NetworkError = NetworkError;
package/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ import { SubscriptionClient } from './client'
2
+
3
+ import type { SubscriptionClientConfig } from './types'
4
+
5
+ export { SubscriptionClient }
6
+ export {
7
+ subscribeMessage,
8
+ unsubscribeMessage,
9
+ listSubscriptionsMessage,
10
+ } from './message'
11
+ export { NetworkError } from './types'
12
+ export type {
13
+ Logger,
14
+ SubscriptionClientConfig,
15
+ SubscribeRequest,
16
+ SubscribeResponse,
17
+ UnsubscribeRequest,
18
+ UnsubscribeResponse,
19
+ ListSubscriptionsQuery,
20
+ ListSubscriptionsAuth,
21
+ Subscription,
22
+ } from './types'
23
+
24
+ export function createSubscriptionClient(
25
+ config: SubscriptionClientConfig,
26
+ ): SubscriptionClient {
27
+ return new SubscriptionClient(config)
28
+ }
package/jest.config.js ADDED
@@ -0,0 +1,10 @@
1
+ /** @type {import('ts-jest').JestConfigWithTsJest} */
2
+
3
+ module.exports = {
4
+ preset: 'ts-jest',
5
+ rootDir: '.',
6
+ testRegex: '.*\\.test\\.ts$',
7
+ testEnvironment: 'node',
8
+ modulePathIgnorePatterns: ['dist/'],
9
+ testTimeout: 10000,
10
+ }
package/message.ts ADDED
@@ -0,0 +1,25 @@
1
+ /** Builds the signed message string for subscribe operations */
2
+ export function subscribeMessage(
3
+ notificationType: string,
4
+ channel: string,
5
+ timestampSeconds: number,
6
+ ): string {
7
+ return `Subscribe ${notificationType} ${channel} ${timestampSeconds}`
8
+ }
9
+
10
+ /** Builds the signed message string for unsubscribe operations */
11
+ export function unsubscribeMessage(
12
+ notificationType: string,
13
+ channel: string,
14
+ timestampSeconds: number,
15
+ ): string {
16
+ return `Unsubscribe ${notificationType} ${channel} ${timestampSeconds}`
17
+ }
18
+
19
+ /** Builds the signed message string for listing subscriptions */
20
+ export function listSubscriptionsMessage(
21
+ pubkey: string,
22
+ timestampSeconds: number,
23
+ ): string {
24
+ return `ListSubscriptions ${pubkey} ${timestampSeconds}`
25
+ }
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@marinade.finance/ts-subscription-client",
3
+ "version": "1.0.0",
4
+ "private": false,
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "devDependencies": {
11
+ "@types/jest": "^29.5.14",
12
+ "jest": "^29.7.0",
13
+ "ts-jest": "^29.2.5",
14
+ "typescript": "^5.8.0"
15
+ },
16
+ "engines": {
17
+ "node": ">=20.0.0"
18
+ },
19
+ "scripts": {
20
+ "build": "tsc --build",
21
+ "test": "jest",
22
+ "clean": "rm -rf dist .tsbuildinfo"
23
+ }
24
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "commonjs",
5
+ "lib": ["ES2020"],
6
+ "declaration": true,
7
+ "outDir": "./dist",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "moduleResolution": "node",
13
+ "resolveJsonModule": true
14
+ },
15
+ "include": ["./**/*.ts"],
16
+ "exclude": [
17
+ "node_modules",
18
+ "dist",
19
+ "**/*.test.ts",
20
+ "**/*.e2e.ts",
21
+ "**/__tests__"
22
+ ]
23
+ }
package/types.ts ADDED
@@ -0,0 +1,81 @@
1
+ /** Minimal logger interface — compatible with pino, console, etc. */
2
+ export interface Logger {
3
+ debug(msg: string): void
4
+ }
5
+
6
+ /** Configuration for the subscription client */
7
+ export interface SubscriptionClientConfig {
8
+ base_url: string
9
+ timeout_ms?: number
10
+ logger?: Logger
11
+ }
12
+
13
+ /** POST /subscriptions request */
14
+ export interface SubscribeRequest {
15
+ pubkey: string
16
+ notification_type: string
17
+ channel: string
18
+ channel_address: string
19
+ signature: string
20
+ message: string
21
+ additional_data?: Record<string, unknown>
22
+ }
23
+
24
+ /** DELETE /subscriptions request */
25
+ export interface UnsubscribeRequest {
26
+ pubkey: string
27
+ notification_type: string
28
+ channel: string
29
+ channel_address?: string
30
+ signature: string
31
+ message: string
32
+ additional_data?: Record<string, unknown>
33
+ }
34
+
35
+ /** GET /subscriptions auth */
36
+ export interface ListSubscriptionsAuth {
37
+ signature: string
38
+ message: string
39
+ }
40
+
41
+ /** GET /subscriptions query */
42
+ export interface ListSubscriptionsQuery {
43
+ pubkey: string
44
+ notification_type?: string
45
+ }
46
+
47
+ /** POST /subscriptions response */
48
+ export interface SubscribeResponse {
49
+ user_id: string
50
+ notification_type: string
51
+ channel: string
52
+ channel_address: string
53
+ created_at: string
54
+ deep_link?: string
55
+ telegram_status?: 'already_activated' | 'bot_not_configured'
56
+ }
57
+
58
+ /** DELETE /subscriptions response */
59
+ export interface UnsubscribeResponse {
60
+ deleted: boolean
61
+ }
62
+
63
+ /** GET /subscriptions response item */
64
+ export interface Subscription {
65
+ user_id: string
66
+ notification_type: string
67
+ channel: string
68
+ channel_address: string
69
+ created_at: string
70
+ }
71
+
72
+ export class NetworkError extends Error {
73
+ constructor(
74
+ message: string,
75
+ public readonly status?: number,
76
+ public readonly response?: unknown,
77
+ ) {
78
+ super(message)
79
+ this.name = 'NetworkError'
80
+ }
81
+ }