@marinade.finance/ts-subscription-client 1.0.0 → 1.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/README.md CHANGED
@@ -33,25 +33,20 @@ binds a user to a channel + address pair.
33
33
 
34
34
  ### telegram
35
35
 
36
- Telegram subscriptions use a two-phase activation flow:
36
+ Telegram subscriptions use a deep-link activation flow:
37
37
 
38
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.
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>`).
43
42
 
44
43
  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.
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.
49
47
 
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.
48
+ The `telegram_status` field tracks delivery state:
49
+ `pending` `active` `inactive` / `unsubscribed` / `blocked`
55
50
 
56
51
  ### api
57
52
 
@@ -61,12 +56,12 @@ No activation step is needed — subscribing is sufficient.
61
56
 
62
57
  ## Notification types
63
58
 
64
- ### bonds
59
+ ### sam_auction
65
60
 
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.
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.
70
65
 
71
66
  The `additional_data` field for subscribe/unsubscribe should include:
72
67
 
@@ -80,3 +75,33 @@ The `additional_data` field for subscribe/unsubscribe should include:
80
75
 
81
76
  For listing subscriptions (`GET /subscriptions`), no `additional_data`
82
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.
@@ -63,7 +63,7 @@ describe('SubscriptionClient E2E', () => {
63
63
  res.end(
64
64
  JSON.stringify({
65
65
  user_id: 'u1',
66
- notification_type: 'bonds',
66
+ notification_type: 'sam_auction',
67
67
  channel: 'telegram',
68
68
  channel_address: '@test',
69
69
  created_at: '2024-01-01',
@@ -75,22 +75,22 @@ describe('SubscriptionClient E2E', () => {
75
75
 
76
76
  const result = await client.subscribe({
77
77
  pubkey: 'pk1',
78
- notification_type: 'bonds',
78
+ notification_type: 'sam_auction',
79
79
  channel: 'telegram',
80
80
  channel_address: '@test',
81
81
  signature: 'sig1',
82
- message: 'Subscribe bonds telegram 1710000000',
82
+ message: 'Subscribe sam_auction telegram 1710000000',
83
83
  additional_data: { vote_account: 'va1' },
84
84
  })
85
85
 
86
86
  expect(receivedMethod).toBe('POST')
87
87
  expect(receivedBody).toEqual({
88
88
  pubkey: 'pk1',
89
- notification_type: 'bonds',
89
+ notification_type: 'sam_auction',
90
90
  channel: 'telegram',
91
91
  channel_address: '@test',
92
92
  signature: 'sig1',
93
- message: 'Subscribe bonds telegram 1710000000',
93
+ message: 'Subscribe sam_auction telegram 1710000000',
94
94
  additional_data: { vote_account: 'va1' },
95
95
  })
96
96
  expect(result.deep_link).toBe('https://t.me/bot?start=abc')
@@ -114,10 +114,10 @@ describe('SubscriptionClient E2E', () => {
114
114
 
115
115
  const result = await client.unsubscribe({
116
116
  pubkey: 'pk1',
117
- notification_type: 'bonds',
117
+ notification_type: 'sam_auction',
118
118
  channel: 'telegram',
119
119
  signature: 'sig1',
120
- message: 'Unsubscribe bonds telegram 1710000000',
120
+ message: 'Unsubscribe sam_auction telegram 1710000000',
121
121
  })
122
122
 
123
123
  expect(receivedMethod).toBe('DELETE')
@@ -138,11 +138,11 @@ describe('SubscriptionClient E2E', () => {
138
138
 
139
139
  await client.unsubscribe({
140
140
  pubkey: 'pk1',
141
- notification_type: 'bonds',
141
+ notification_type: 'sam_auction',
142
142
  channel: 'telegram',
143
143
  channel_address: '@testuser',
144
144
  signature: 'sig1',
145
- message: 'Unsubscribe bonds telegram 1710000000',
145
+ message: 'Unsubscribe sam_auction telegram 1710000000',
146
146
  })
147
147
 
148
148
  expect(receivedBody?.channel_address).toBe('@testuser')
@@ -165,7 +165,7 @@ describe('SubscriptionClient E2E', () => {
165
165
  JSON.stringify([
166
166
  {
167
167
  user_id: 'u1',
168
- notification_type: 'bonds',
168
+ notification_type: 'sam_auction',
169
169
  channel: 'telegram',
170
170
  channel_address: '@test',
171
171
  created_at: '2024-01-01',
@@ -177,7 +177,7 @@ describe('SubscriptionClient E2E', () => {
177
177
  const result = await client.listSubscriptions(
178
178
  {
179
179
  pubkey: 'pk1',
180
- notification_type: 'bonds',
180
+ notification_type: 'sam_auction',
181
181
  },
182
182
  {
183
183
  signature: 'sig1',
@@ -186,7 +186,7 @@ describe('SubscriptionClient E2E', () => {
186
186
  )
187
187
 
188
188
  expect(receivedUrl).toBe(
189
- '/subscriptions?pubkey=pk1&notification_type=bonds',
189
+ '/subscriptions?pubkey=pk1&notification_type=sam_auction',
190
190
  )
191
191
  expect(receivedHeaders).toEqual({
192
192
  'x-solana-signature': 'sig1',
@@ -209,7 +209,7 @@ describe('SubscriptionClient E2E', () => {
209
209
  const error = await client
210
210
  .subscribe({
211
211
  pubkey: 'p',
212
- notification_type: 'bonds',
212
+ notification_type: 'sam_auction',
213
213
  channel: 'telegram',
214
214
  channel_address: '@t',
215
215
  signature: 's',
@@ -233,7 +233,7 @@ describe('SubscriptionClient E2E', () => {
233
233
  await expect(
234
234
  unreachableClient.subscribe({
235
235
  pubkey: 'p',
236
- notification_type: 'bonds',
236
+ notification_type: 'sam_auction',
237
237
  channel: 'telegram',
238
238
  channel_address: '@t',
239
239
  signature: 's',
@@ -16,7 +16,7 @@ describe('SubscriptionClient', () => {
16
16
  const mockFetch = global.fetch as jest.Mock
17
17
  const responseBody = {
18
18
  user_id: 'user-1',
19
- notification_type: 'bonds',
19
+ notification_type: 'sam_auction',
20
20
  channel: 'telegram',
21
21
  channel_address: '@testuser',
22
22
  created_at: '2024-01-01T00:00:00Z',
@@ -33,11 +33,11 @@ describe('SubscriptionClient', () => {
33
33
 
34
34
  const request = {
35
35
  pubkey: 'pubkey123',
36
- notification_type: 'bonds',
36
+ notification_type: 'sam_auction',
37
37
  channel: 'telegram',
38
38
  channel_address: '@testuser',
39
39
  signature: 'sig123',
40
- message: 'Subscribe bonds telegram 1710000000',
40
+ message: 'Subscribe sam_auction telegram 1710000000',
41
41
  additional_data: { vote_account: 'vote123' },
42
42
  }
43
43
 
@@ -68,7 +68,7 @@ describe('SubscriptionClient', () => {
68
68
  await expect(
69
69
  client.subscribe({
70
70
  pubkey: 'p',
71
- notification_type: 'bonds',
71
+ notification_type: 'sam_auction',
72
72
  channel: 'telegram',
73
73
  channel_address: '@test',
74
74
  signature: 's',
@@ -88,7 +88,7 @@ describe('SubscriptionClient', () => {
88
88
  await expect(
89
89
  client.subscribe({
90
90
  pubkey: 'p',
91
- notification_type: 'bonds',
91
+ notification_type: 'sam_auction',
92
92
  channel: 'telegram',
93
93
  channel_address: '@test',
94
94
  signature: 's',
@@ -112,10 +112,10 @@ describe('SubscriptionClient', () => {
112
112
 
113
113
  const request = {
114
114
  pubkey: 'pubkey123',
115
- notification_type: 'bonds',
115
+ notification_type: 'sam_auction',
116
116
  channel: 'telegram',
117
117
  signature: 'sig123',
118
- message: 'Unsubscribe bonds telegram 1710000000',
118
+ message: 'Unsubscribe sam_auction telegram 1710000000',
119
119
  }
120
120
 
121
121
  const result = await client.unsubscribe(request)
@@ -140,11 +140,11 @@ describe('SubscriptionClient', () => {
140
140
 
141
141
  const request = {
142
142
  pubkey: 'pubkey123',
143
- notification_type: 'bonds',
143
+ notification_type: 'sam_auction',
144
144
  channel: 'telegram',
145
145
  channel_address: '@testuser',
146
146
  signature: 'sig123',
147
- message: 'Unsubscribe bonds telegram 1710000000',
147
+ message: 'Unsubscribe sam_auction telegram 1710000000',
148
148
  }
149
149
 
150
150
  await client.unsubscribe(request)
@@ -170,7 +170,7 @@ describe('SubscriptionClient', () => {
170
170
  await expect(
171
171
  client.unsubscribe({
172
172
  pubkey: 'p',
173
- notification_type: 'bonds',
173
+ notification_type: 'sam_auction',
174
174
  channel: 'telegram',
175
175
  signature: 's',
176
176
  message: 'm',
@@ -185,7 +185,7 @@ describe('SubscriptionClient', () => {
185
185
  const responseBody = [
186
186
  {
187
187
  user_id: 'user-1',
188
- notification_type: 'bonds',
188
+ notification_type: 'sam_auction',
189
189
  channel: 'telegram',
190
190
  channel_address: '@testuser',
191
191
  created_at: '2024-01-01T00:00:00Z',
@@ -203,7 +203,7 @@ describe('SubscriptionClient', () => {
203
203
  const result = await client.listSubscriptions(
204
204
  {
205
205
  pubkey: 'pubkey123',
206
- notification_type: 'bonds',
206
+ notification_type: 'sam_auction',
207
207
  },
208
208
  {
209
209
  signature: 'sig123',
@@ -215,7 +215,7 @@ describe('SubscriptionClient', () => {
215
215
  expect(mockFetch).toHaveBeenCalledTimes(1)
216
216
  const [url, options] = mockFetch.mock.calls[0]
217
217
  expect(url).toBe(
218
- 'http://localhost:3000/subscriptions?pubkey=pubkey123&notification_type=bonds',
218
+ 'http://localhost:3000/subscriptions?pubkey=pubkey123&notification_type=sam_auction',
219
219
  )
220
220
  expect(options?.method).toBe('GET')
221
221
  expect(options?.headers).toEqual({
@@ -224,6 +224,54 @@ describe('SubscriptionClient', () => {
224
224
  })
225
225
  })
226
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
+
227
275
  it('should omit optional query params when not provided', async () => {
228
276
  const mockFetch = global.fetch as jest.Mock
229
277
  mockFetch.mockResolvedValue({
@@ -288,7 +336,7 @@ describe('SubscriptionClient', () => {
288
336
  await expect(
289
337
  client.subscribe({
290
338
  pubkey: 'p',
291
- notification_type: 'bonds',
339
+ notification_type: 'sam_auction',
292
340
  channel: 'telegram',
293
341
  channel_address: '@test',
294
342
  signature: 's',
@@ -6,14 +6,14 @@ import {
6
6
 
7
7
  describe('message helpers', () => {
8
8
  it('subscribeMessage', () => {
9
- expect(subscribeMessage('bonds', 'telegram', 1710000000)).toBe(
10
- 'Subscribe bonds telegram 1710000000',
9
+ expect(subscribeMessage('sam_auction', 'telegram', 1710000000)).toBe(
10
+ 'Subscribe sam_auction telegram 1710000000',
11
11
  )
12
12
  })
13
13
 
14
14
  it('unsubscribeMessage', () => {
15
- expect(unsubscribeMessage('bonds', 'email', 1710000000)).toBe(
16
- 'Unsubscribe bonds email 1710000000',
15
+ expect(unsubscribeMessage('sam_auction', 'email', 1710000000)).toBe(
16
+ 'Unsubscribe sam_auction email 1710000000',
17
17
  )
18
18
  })
19
19
 
package/client.ts CHANGED
@@ -61,6 +61,12 @@ export class SubscriptionClient {
61
61
  if (query.notification_type) {
62
62
  params.set('notification_type', query.notification_type)
63
63
  }
64
+ if (
65
+ query.additional_data &&
66
+ Object.keys(query.additional_data).length > 0
67
+ ) {
68
+ params.set('additional_data', JSON.stringify(query.additional_data))
69
+ }
64
70
  const path = `${PATH_SUBSCRIPTIONS}?${params.toString()}`
65
71
 
66
72
  this.logger?.debug(`GET ${path} signature: [redacted]`)
package/dist/client.js CHANGED
@@ -37,6 +37,10 @@ class SubscriptionClient {
37
37
  if (query.notification_type) {
38
38
  params.set('notification_type', query.notification_type);
39
39
  }
40
+ if (query.additional_data &&
41
+ Object.keys(query.additional_data).length > 0) {
42
+ params.set('additional_data', JSON.stringify(query.additional_data));
43
+ }
40
44
  const path = `${PATH_SUBSCRIPTIONS}?${params.toString()}`;
41
45
  this.logger?.debug(`GET ${path} signature: [redacted]`);
42
46
  return this.fetch(path, {
package/dist/index.d.ts CHANGED
@@ -4,4 +4,5 @@ export { SubscriptionClient };
4
4
  export { subscribeMessage, unsubscribeMessage, listSubscriptionsMessage, } from './message';
5
5
  export { NetworkError } from './types';
6
6
  export type { Logger, SubscriptionClientConfig, SubscribeRequest, SubscribeResponse, UnsubscribeRequest, UnsubscribeResponse, ListSubscriptionsQuery, ListSubscriptionsAuth, Subscription, } from './types';
7
+ export declare const NOTIFICATION_TYPE_SAM_AUCTION = "sam_auction";
7
8
  export declare function createSubscriptionClient(config: SubscriptionClientConfig): SubscriptionClient;
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.NetworkError = exports.listSubscriptionsMessage = exports.unsubscribeMessage = exports.subscribeMessage = exports.SubscriptionClient = void 0;
3
+ exports.NOTIFICATION_TYPE_SAM_AUCTION = exports.NetworkError = exports.listSubscriptionsMessage = exports.unsubscribeMessage = exports.subscribeMessage = exports.SubscriptionClient = void 0;
4
4
  exports.createSubscriptionClient = createSubscriptionClient;
5
5
  const client_1 = require("./client");
6
6
  Object.defineProperty(exports, "SubscriptionClient", { enumerable: true, get: function () { return client_1.SubscriptionClient; } });
@@ -10,6 +10,7 @@ Object.defineProperty(exports, "unsubscribeMessage", { enumerable: true, get: fu
10
10
  Object.defineProperty(exports, "listSubscriptionsMessage", { enumerable: true, get: function () { return message_1.listSubscriptionsMessage; } });
11
11
  var types_1 = require("./types");
12
12
  Object.defineProperty(exports, "NetworkError", { enumerable: true, get: function () { return types_1.NetworkError; } });
13
+ exports.NOTIFICATION_TYPE_SAM_AUCTION = 'sam_auction';
13
14
  function createSubscriptionClient(config) {
14
15
  return new client_1.SubscriptionClient(config);
15
16
  }
package/dist/types.d.ts CHANGED
@@ -37,6 +37,7 @@ export interface ListSubscriptionsAuth {
37
37
  export interface ListSubscriptionsQuery {
38
38
  pubkey: string;
39
39
  notification_type?: string;
40
+ additional_data?: Record<string, unknown>;
40
41
  }
41
42
  /** POST /subscriptions response */
42
43
  export interface SubscribeResponse {
@@ -46,7 +47,7 @@ export interface SubscribeResponse {
46
47
  channel_address: string;
47
48
  created_at: string;
48
49
  deep_link?: string;
49
- telegram_status?: 'already_activated' | 'bot_not_configured';
50
+ telegram_status?: 'pending' | 'active' | 'inactive' | 'unsubscribed' | 'blocked' | 'already_activated' | 'bot_not_configured';
50
51
  }
51
52
  /** DELETE /subscriptions response */
52
53
  export interface UnsubscribeResponse {
@@ -59,6 +60,7 @@ export interface Subscription {
59
60
  channel: string;
60
61
  channel_address: string;
61
62
  created_at: string;
63
+ telegram_status?: 'pending' | 'active' | 'inactive' | 'unsubscribed' | 'blocked';
62
64
  }
63
65
  export declare class NetworkError extends Error {
64
66
  readonly status?: number | undefined;
package/index.ts CHANGED
@@ -21,6 +21,8 @@ export type {
21
21
  Subscription,
22
22
  } from './types'
23
23
 
24
+ export const NOTIFICATION_TYPE_SAM_AUCTION = 'sam_auction'
25
+
24
26
  export function createSubscriptionClient(
25
27
  config: SubscriptionClientConfig,
26
28
  ): SubscriptionClient {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marinade.finance/ts-subscription-client",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "private": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/types.ts CHANGED
@@ -42,6 +42,7 @@ export interface ListSubscriptionsAuth {
42
42
  export interface ListSubscriptionsQuery {
43
43
  pubkey: string
44
44
  notification_type?: string
45
+ additional_data?: Record<string, unknown>
45
46
  }
46
47
 
47
48
  /** POST /subscriptions response */
@@ -52,7 +53,14 @@ export interface SubscribeResponse {
52
53
  channel_address: string
53
54
  created_at: string
54
55
  deep_link?: string
55
- telegram_status?: 'already_activated' | 'bot_not_configured'
56
+ telegram_status?:
57
+ | 'pending'
58
+ | 'active'
59
+ | 'inactive'
60
+ | 'unsubscribed'
61
+ | 'blocked'
62
+ | 'already_activated'
63
+ | 'bot_not_configured'
56
64
  }
57
65
 
58
66
  /** DELETE /subscriptions response */
@@ -67,6 +75,12 @@ export interface Subscription {
67
75
  channel: string
68
76
  channel_address: string
69
77
  created_at: string
78
+ telegram_status?:
79
+ | 'pending'
80
+ | 'active'
81
+ | 'inactive'
82
+ | 'unsubscribed'
83
+ | 'blocked'
70
84
  }
71
85
 
72
86
  export class NetworkError extends Error {