@v3finance/monitoring-sdk 0.0.0-stage → 0.1.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.
Files changed (140) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +247 -2
  4. package/dist/cjs/client.d.ts +65 -0
  5. package/dist/cjs/client.d.ts.map +1 -0
  6. package/dist/cjs/client.js +89 -0
  7. package/dist/cjs/client.js.map +1 -0
  8. package/dist/cjs/core/errors.d.ts +64 -0
  9. package/dist/cjs/core/errors.d.ts.map +1 -0
  10. package/dist/cjs/core/errors.js +95 -0
  11. package/dist/cjs/core/errors.js.map +1 -0
  12. package/dist/cjs/core/http.d.ts +62 -0
  13. package/dist/cjs/core/http.d.ts.map +1 -0
  14. package/dist/cjs/core/http.js +151 -0
  15. package/dist/cjs/core/http.js.map +1 -0
  16. package/dist/cjs/core/pagination.d.ts +13 -0
  17. package/dist/cjs/core/pagination.d.ts.map +1 -0
  18. package/dist/cjs/core/pagination.js +15 -0
  19. package/dist/cjs/core/pagination.js.map +1 -0
  20. package/dist/cjs/generated/types.d.ts +324 -0
  21. package/dist/cjs/generated/types.d.ts.map +1 -0
  22. package/dist/cjs/generated/types.js +5 -0
  23. package/dist/cjs/generated/types.js.map +1 -0
  24. package/dist/cjs/index.d.ts +23 -0
  25. package/dist/cjs/index.d.ts.map +1 -0
  26. package/dist/cjs/index.js +50 -0
  27. package/dist/cjs/index.js.map +1 -0
  28. package/dist/cjs/package.json +1 -0
  29. package/dist/cjs/resources/account.d.ts +61 -0
  30. package/dist/cjs/resources/account.d.ts.map +1 -0
  31. package/dist/cjs/resources/account.js +75 -0
  32. package/dist/cjs/resources/account.js.map +1 -0
  33. package/dist/cjs/resources/addresses.d.ts +109 -0
  34. package/dist/cjs/resources/addresses.d.ts.map +1 -0
  35. package/dist/cjs/resources/addresses.js +137 -0
  36. package/dist/cjs/resources/addresses.js.map +1 -0
  37. package/dist/cjs/resources/catalog.d.ts +30 -0
  38. package/dist/cjs/resources/catalog.d.ts.map +1 -0
  39. package/dist/cjs/resources/catalog.js +46 -0
  40. package/dist/cjs/resources/catalog.js.map +1 -0
  41. package/dist/cjs/resources/clients.d.ts +84 -0
  42. package/dist/cjs/resources/clients.d.ts.map +1 -0
  43. package/dist/cjs/resources/clients.js +102 -0
  44. package/dist/cjs/resources/clients.js.map +1 -0
  45. package/dist/cjs/resources/deliveries.d.ts +50 -0
  46. package/dist/cjs/resources/deliveries.d.ts.map +1 -0
  47. package/dist/cjs/resources/deliveries.js +43 -0
  48. package/dist/cjs/resources/deliveries.js.map +1 -0
  49. package/dist/cjs/resources/transactions.d.ts +28 -0
  50. package/dist/cjs/resources/transactions.d.ts.map +1 -0
  51. package/dist/cjs/resources/transactions.js +35 -0
  52. package/dist/cjs/resources/transactions.js.map +1 -0
  53. package/dist/cjs/units.d.ts +29 -0
  54. package/dist/cjs/units.d.ts.map +1 -0
  55. package/dist/cjs/units.js +62 -0
  56. package/dist/cjs/units.js.map +1 -0
  57. package/dist/cjs/version.d.ts +5 -0
  58. package/dist/cjs/version.d.ts.map +1 -0
  59. package/dist/cjs/version.js +8 -0
  60. package/dist/cjs/version.js.map +1 -0
  61. package/dist/cjs/webhooks.d.ts +77 -0
  62. package/dist/cjs/webhooks.d.ts.map +1 -0
  63. package/dist/cjs/webhooks.js +128 -0
  64. package/dist/cjs/webhooks.js.map +1 -0
  65. package/dist/esm/client.d.ts +65 -0
  66. package/dist/esm/client.d.ts.map +1 -0
  67. package/dist/esm/client.js +85 -0
  68. package/dist/esm/client.js.map +1 -0
  69. package/dist/esm/core/errors.d.ts +64 -0
  70. package/dist/esm/core/errors.d.ts.map +1 -0
  71. package/dist/esm/core/errors.js +86 -0
  72. package/dist/esm/core/errors.js.map +1 -0
  73. package/dist/esm/core/http.d.ts +62 -0
  74. package/dist/esm/core/http.d.ts.map +1 -0
  75. package/dist/esm/core/http.js +146 -0
  76. package/dist/esm/core/http.js.map +1 -0
  77. package/dist/esm/core/pagination.d.ts +13 -0
  78. package/dist/esm/core/pagination.d.ts.map +1 -0
  79. package/dist/esm/core/pagination.js +12 -0
  80. package/dist/esm/core/pagination.js.map +1 -0
  81. package/dist/esm/generated/types.d.ts +324 -0
  82. package/dist/esm/generated/types.d.ts.map +1 -0
  83. package/dist/esm/generated/types.js +4 -0
  84. package/dist/esm/generated/types.js.map +1 -0
  85. package/dist/esm/index.d.ts +23 -0
  86. package/dist/esm/index.d.ts.map +1 -0
  87. package/dist/esm/index.js +20 -0
  88. package/dist/esm/index.js.map +1 -0
  89. package/dist/esm/resources/account.d.ts +61 -0
  90. package/dist/esm/resources/account.d.ts.map +1 -0
  91. package/dist/esm/resources/account.js +69 -0
  92. package/dist/esm/resources/account.js.map +1 -0
  93. package/dist/esm/resources/addresses.d.ts +109 -0
  94. package/dist/esm/resources/addresses.d.ts.map +1 -0
  95. package/dist/esm/resources/addresses.js +133 -0
  96. package/dist/esm/resources/addresses.js.map +1 -0
  97. package/dist/esm/resources/catalog.d.ts +30 -0
  98. package/dist/esm/resources/catalog.d.ts.map +1 -0
  99. package/dist/esm/resources/catalog.js +41 -0
  100. package/dist/esm/resources/catalog.js.map +1 -0
  101. package/dist/esm/resources/clients.d.ts +84 -0
  102. package/dist/esm/resources/clients.d.ts.map +1 -0
  103. package/dist/esm/resources/clients.js +97 -0
  104. package/dist/esm/resources/clients.js.map +1 -0
  105. package/dist/esm/resources/deliveries.d.ts +50 -0
  106. package/dist/esm/resources/deliveries.d.ts.map +1 -0
  107. package/dist/esm/resources/deliveries.js +39 -0
  108. package/dist/esm/resources/deliveries.js.map +1 -0
  109. package/dist/esm/resources/transactions.d.ts +28 -0
  110. package/dist/esm/resources/transactions.d.ts.map +1 -0
  111. package/dist/esm/resources/transactions.js +31 -0
  112. package/dist/esm/resources/transactions.js.map +1 -0
  113. package/dist/esm/units.d.ts +29 -0
  114. package/dist/esm/units.d.ts.map +1 -0
  115. package/dist/esm/units.js +58 -0
  116. package/dist/esm/units.js.map +1 -0
  117. package/dist/esm/version.d.ts +5 -0
  118. package/dist/esm/version.d.ts.map +1 -0
  119. package/dist/esm/version.js +5 -0
  120. package/dist/esm/version.js.map +1 -0
  121. package/dist/esm/webhooks.d.ts +77 -0
  122. package/dist/esm/webhooks.d.ts.map +1 -0
  123. package/dist/esm/webhooks.js +120 -0
  124. package/dist/esm/webhooks.js.map +1 -0
  125. package/package.json +66 -4
  126. package/src/client.ts +94 -0
  127. package/src/core/errors.ts +99 -0
  128. package/src/core/http.ts +193 -0
  129. package/src/core/pagination.ts +21 -0
  130. package/src/generated/types.ts +355 -0
  131. package/src/index.ts +60 -0
  132. package/src/resources/account.ts +87 -0
  133. package/src/resources/addresses.ts +190 -0
  134. package/src/resources/catalog.ts +47 -0
  135. package/src/resources/clients.ts +135 -0
  136. package/src/resources/deliveries.ts +69 -0
  137. package/src/resources/transactions.ts +33 -0
  138. package/src/units.ts +56 -0
  139. package/src/version.ts +5 -0
  140. package/src/webhooks.ts +133 -0
@@ -0,0 +1,47 @@
1
+ import { seg, type HttpClient, type RequestOptions } from '../core/http.js'
2
+ import type { Asset, Network } from '../generated/types.js'
3
+
4
+ /** Сети: TRON и EVM-сети с идентификаторами CAIP-2 */
5
+ export class NetworksResource {
6
+ readonly #http: HttpClient
7
+ constructor(http: HttpClient) {
8
+ this.#http = http
9
+ }
10
+
11
+ /** Все сети. `enabled: false` — сеть описана, но не читается: вебхуков по ней нет */
12
+ async list(options?: RequestOptions): Promise<Network[]> {
13
+ const r = await this.#http.request<{ items: Network[] }>({ method: 'GET', path: '/v1/networks', idempotent: true, options })
14
+ return r.items
15
+ }
16
+
17
+ /** Сеть по slug (`tron`) или CAIP-2 (`eip155:1`) */
18
+ get(network: string, options?: RequestOptions): Promise<Network> {
19
+ return this.#http.request({ method: 'GET', path: `/v1/networks/${seg(network)}`, idempotent: true, options })
20
+ }
21
+ }
22
+
23
+ /** Активы: нативные монеты и токены. Вебхуки приходят о переводах включённых активов не меньше порога `minAmount` */
24
+ export class AssetsResource {
25
+ readonly #http: HttpClient
26
+ constructor(http: HttpClient) {
27
+ this.#http = http
28
+ }
29
+
30
+ /**
31
+ * Активы — все или одной сети.
32
+ *
33
+ * @example
34
+ * ```ts
35
+ * const usdt = (await monitoring.assets.list({ network: 'tron' })).find((a) => a.symbol === 'USDT')
36
+ * formatUnits(webhook.tx.value, usdt!.decimals)
37
+ * ```
38
+ */
39
+ async list(params: { network?: string } = {}, options?: RequestOptions): Promise<Asset[]> {
40
+ const r = await this.#http.request<{ items: Asset[] }>({ method: 'GET', path: '/v1/assets', query: params, idempotent: true, options })
41
+ return r.items
42
+ }
43
+
44
+ get(assetId: number, options?: RequestOptions): Promise<Asset> {
45
+ return this.#http.request({ method: 'GET', path: `/v1/assets/${seg(assetId)}`, idempotent: true, options })
46
+ }
47
+ }
@@ -0,0 +1,135 @@
1
+ import { seg, type HttpClient, type RequestOptions } from '../core/http.js'
2
+ import { paginate, type Page } from '../core/pagination.js'
3
+ import type { ApiKey, Client, IssueKeyRequest, IssuedKey, Member, TestWebhookRequest, TestWebhookResult } from '../generated/types.js'
4
+
5
+ export interface ListClientsParams {
6
+ /** Размер страницы, до 1000; по умолчанию 100 */
7
+ limit?: number
8
+ /** Курсор: `next` прошлой страницы */
9
+ after?: string
10
+ }
11
+
12
+ /** Что изменить у клиента; не переданное не меняется */
13
+ export interface UpdateClientParams {
14
+ /** Название; `null` — убрать */
15
+ name?: string | null
16
+ /** URL вебхука: только https, без логина и пароля */
17
+ webhookUrl?: string
18
+ }
19
+
20
+ export interface RotateSecretParams {
21
+ /** Свой секрет (от 16 символов); без него — сгенерируется */
22
+ secretKey?: string
23
+ }
24
+
25
+ export interface RotateSecretResult {
26
+ /** Новый секрет подписи вебхуков — показывается один раз, сохраните его */
27
+ secretKey: string
28
+ }
29
+
30
+ /** Ключи клиента: доступ к адресам, истории и транзакциям этого клиента. Управляют owner и admin клиента */
31
+ export class ClientKeysResource {
32
+ readonly #http: HttpClient
33
+ constructor(http: HttpClient) {
34
+ this.#http = http
35
+ }
36
+
37
+ /** Ключи клиента (без самих ключей — только начало) */
38
+ async list(clientId: string, options?: RequestOptions): Promise<ApiKey[]> {
39
+ const r = await this.#http.request<{ items: ApiKey[] }>({ method: 'GET', path: `/v1/clients/${seg(clientId)}/keys`, idempotent: true, options })
40
+ return r.items
41
+ }
42
+
43
+ /**
44
+ * Выпускает ключ клиента. Полный ключ — в `key`, показывается один раз.
45
+ *
46
+ * @example
47
+ * ```ts
48
+ * const { key } = await monitoring.clients.keys.create(clientId, { name: 'backend', expiresInDays: 365 })
49
+ * ```
50
+ */
51
+ create(clientId: string, params: IssueKeyRequest = {}, options?: RequestOptions): Promise<IssuedKey> {
52
+ return this.#http.request({ method: 'POST', path: `/v1/clients/${seg(clientId)}/keys`, body: params, idempotent: false, options })
53
+ }
54
+
55
+ /** Отзывает ключ — он перестаёт работать сразу */
56
+ async revoke(clientId: string, keyId: string, options?: RequestOptions): Promise<void> {
57
+ await this.#http.request({ method: 'DELETE', path: `/v1/clients/${seg(clientId)}/keys/${seg(keyId)}`, idempotent: true, options })
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Ваш клиент: URL вебхука, секрет подписи, ключи, участники, тестовый вебхук.
63
+ *
64
+ * Права: смотреть — любой участник и ключ клиента; менять — owner и admin клиента по сессии или личному ключу.
65
+ */
66
+ export class ClientsResource {
67
+ readonly #http: HttpClient
68
+ /** Ключи клиента */
69
+ readonly keys: ClientKeysResource
70
+
71
+ constructor(http: HttpClient) {
72
+ this.#http = http
73
+ this.keys = new ClientKeysResource(http)
74
+ }
75
+
76
+ /** Страница ваших клиентов: где вы участник; для ключа клиента — он сам */
77
+ list(params: ListClientsParams = {}, options?: RequestOptions): Promise<Page<Client, string>> {
78
+ return this.#http.request({ method: 'GET', path: '/v1/clients', query: { ...params }, idempotent: true, options })
79
+ }
80
+
81
+ /** Все ваши клиенты — страница за страницей */
82
+ iterate(params: Omit<ListClientsParams, 'after'> = {}, options?: RequestOptions): AsyncGenerator<Client, void, undefined> {
83
+ return paginate<Client, string>((after) => this.list({ ...params, after }, options))
84
+ }
85
+
86
+ get(clientId: string, options?: RequestOptions): Promise<Client> {
87
+ return this.#http.request({ method: 'GET', path: `/v1/clients/${seg(clientId)}`, idempotent: true, options })
88
+ }
89
+
90
+ /**
91
+ * Меняет название или URL вебхука.
92
+ *
93
+ * @example
94
+ * ```ts
95
+ * await monitoring.clients.update(clientId, { webhookUrl: 'https://example.com/webhooks/monitoring' })
96
+ * ```
97
+ */
98
+ update(clientId: string, params: UpdateClientParams, options?: RequestOptions): Promise<Client> {
99
+ return this.#http.request({ method: 'PATCH', path: `/v1/clients/${seg(clientId)}`, body: params, idempotent: true, options })
100
+ }
101
+
102
+ /** Новый секрет подписи вебхуков. Старый перестаёт действовать сразу — обновите его у себя до следующего вебхука */
103
+ rotateSecret(clientId: string, params: RotateSecretParams = {}, options?: RequestOptions): Promise<RotateSecretResult> {
104
+ return this.#http.request({ method: 'POST', path: `/v1/clients/${seg(clientId)}/secret`, body: params, idempotent: false, options })
105
+ }
106
+
107
+ /**
108
+ * Отправляет тестовый вебхук на URL клиента и возвращает результат: доставлен ли, ваш ответ, подпись и точное
109
+ * тело. Тестовый вебхук помечен `"test": true` и заголовком `x-webhook-test: 1` — не зачисляйте его.
110
+ * Не больше 10 в минуту.
111
+ *
112
+ * @example
113
+ * ```ts
114
+ * const r = await monitoring.clients.sendTestWebhook(clientId, { asset: 'USDT' })
115
+ * if (!r.delivery.ok) console.log('ваш сервер ответил', r.delivery.status ?? r.delivery.error)
116
+ * ```
117
+ */
118
+ sendTestWebhook(clientId: string, params: TestWebhookRequest = {}, options?: RequestOptions): Promise<TestWebhookResult> {
119
+ return this.#http.request({
120
+ method: 'POST',
121
+ path: `/v1/clients/${seg(clientId)}/webhooks/test`,
122
+ body: params,
123
+ idempotent: false,
124
+ // 502 — «не доставлен»: это результат проверки, а не ошибка запроса
125
+ acceptStatuses: [502],
126
+ options,
127
+ })
128
+ }
129
+
130
+ /** Участники клиента и их роли */
131
+ async members(clientId: string, options?: RequestOptions): Promise<Member[]> {
132
+ const r = await this.#http.request<{ items: Member[] }>({ method: 'GET', path: `/v1/clients/${seg(clientId)}/members`, idempotent: true, options })
133
+ return r.items
134
+ }
135
+ }
@@ -0,0 +1,69 @@
1
+ import { seg, type HttpClient, type RequestOptions } from '../core/http.js'
2
+ import { paginate, type Page } from '../core/pagination.js'
3
+ import type { Delivery, DeliveryDetail, DeliveryStatus } from '../generated/types.js'
4
+
5
+ /** Фильтры истории; все необязательны и сочетаются */
6
+ export interface DeliveryFilter {
7
+ /** `delivered` — вы ответили 2xx, `retrying` — будет повтор, `failed` — повторы исчерпаны */
8
+ status?: DeliveryStatus
9
+ /** Сеть: slug или CAIP-2 */
10
+ network?: string
11
+ /** Символ актива */
12
+ asset?: string
13
+ /** Хеш транзакции */
14
+ tx?: string
15
+ /** Отправитель или получатель */
16
+ address?: string
17
+ /** Первая попытка не раньше (дата или время) */
18
+ since?: Date | string
19
+ /** Первая попытка раньше (не включая) */
20
+ until?: Date | string
21
+ }
22
+
23
+ export interface ListDeliveriesParams extends DeliveryFilter {
24
+ /** Размер страницы, до 500; по умолчанию 100 */
25
+ limit?: number
26
+ /** Курсор: `next` прошлой страницы */
27
+ before?: number
28
+ }
29
+
30
+ const iso = (v: Date | string | undefined) => (v instanceof Date ? v.toISOString() : v)
31
+
32
+ /**
33
+ * История вебхуков клиента: что, когда и куда отправлено, сколько было попыток и что вы ответили.
34
+ * Повторы одного вебхука — одна запись. Хранится 90 дней.
35
+ */
36
+ export class DeliveriesResource {
37
+ readonly #http: HttpClient
38
+ constructor(http: HttpClient) {
39
+ this.#http = http
40
+ }
41
+
42
+ /**
43
+ * Страница истории, новые сверху.
44
+ *
45
+ * @example
46
+ * ```ts
47
+ * const { items } = await monitoring.deliveries.list(clientId, { status: 'failed', since: new Date(Date.now() - 86_400_000) })
48
+ * ```
49
+ */
50
+ list(clientId: string, params: ListDeliveriesParams = {}, options?: RequestOptions): Promise<Page<Delivery, number>> {
51
+ return this.#http.request({
52
+ method: 'GET',
53
+ path: `/v1/clients/${seg(clientId)}/deliveries`,
54
+ query: { ...params, since: iso(params.since), until: iso(params.until) },
55
+ idempotent: true,
56
+ options,
57
+ })
58
+ }
59
+
60
+ /** Вся история по фильтру — от новых к старым, страница за страницей */
61
+ iterate(clientId: string, filter: DeliveryFilter & { limit?: number } = {}, options?: RequestOptions): AsyncGenerator<Delivery, void, undefined> {
62
+ return paginate<Delivery, number>((before) => this.list(clientId, { ...filter, before }, options))
63
+ }
64
+
65
+ /** Одна запись с URL последней попытки и точным телом запроса */
66
+ get(clientId: string, deliveryId: number, options?: RequestOptions): Promise<DeliveryDetail> {
67
+ return this.#http.request({ method: 'GET', path: `/v1/clients/${seg(clientId)}/deliveries/${seg(deliveryId)}`, idempotent: true, options })
68
+ }
69
+ }
@@ -0,0 +1,33 @@
1
+ import { seg, type HttpClient, type RequestOptions } from '../core/http.js'
2
+ import type { Transaction } from '../generated/types.js'
3
+
4
+ /** Любая транзакция сети по хешу — не только те, о которых пришёл вебхук */
5
+ export class TransactionsResource {
6
+ readonly #http: HttpClient
7
+ constructor(http: HttpClient) {
8
+ this.#http = http
9
+ }
10
+
11
+ /**
12
+ * Статус, блок, подтверждения, финальность, комиссия и все переводы внутри транзакции; для ваших адресов —
13
+ * направление и вебхуки, которые мы по ней отправили.
14
+ *
15
+ * `final: true` — транзакцию уже не отменить: в TRON блок солидифицирован (около минуты), в EVM —
16
+ * финализирован (Ethereum — около 13 минут). Крупные суммы зачисляйте после `final`.
17
+ *
18
+ * Не больше 60 запросов в минуту на ключ; при превышении SDK подождёт и повторит.
19
+ *
20
+ * @param network - slug (`tron`, `ethereum`) или CAIP-2
21
+ * @param hash - EVM — `0x` + 64 hex; TRON — 64 hex (можно с `0x`)
22
+ * @throws {@link MonitoringApiError} 404 — такой транзакции в сети нет
23
+ *
24
+ * @example
25
+ * ```ts
26
+ * const tx = await monitoring.transactions.get('tron', webhook.tx.id)
27
+ * if (tx.status === 'success' && tx.final) credit(webhook)
28
+ * ```
29
+ */
30
+ get(network: string, hash: string, options?: RequestOptions): Promise<Transaction> {
31
+ return this.#http.request({ method: 'GET', path: `/v1/networks/${seg(network)}/transactions/${seg(hash)}`, idempotent: true, options })
32
+ }
33
+ }
package/src/units.ts ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Суммы в API и вебхуках — строки в минимальных единицах (sun, wei; у USDT 6 знаков после запятой): uint256 не
3
+ * помещается в `number`. Эти функции переводят их в привычный вид и обратно без потери точности.
4
+ * Знаки после запятой актива — `decimals` из `monitoring.assets.list()`.
5
+ */
6
+
7
+ const INTEGER = /^-?\d+$/
8
+ const DECIMAL = /^(\d+)(?:\.(\d+))?$/
9
+
10
+ /**
11
+ * Минимальные единицы → строка в монетах.
12
+ *
13
+ * @example
14
+ * ```ts
15
+ * formatUnits('100000000', 6) // '100' — 100 USDT
16
+ * formatUnits('1500000', 6) // '1.5'
17
+ * formatUnits('10090000000000000', 18) // '0.01009' — ETH
18
+ * ```
19
+ */
20
+ export function formatUnits(value: string | bigint, decimals: number): string {
21
+ assertDecimals(decimals)
22
+ const raw = typeof value === 'bigint' ? value.toString() : value.trim()
23
+ if (!INTEGER.test(raw)) throw new RangeError(`formatUnits: integer in minimal units expected, got ${JSON.stringify(value)}`)
24
+ const negative = raw.startsWith('-')
25
+ const digits = (negative ? raw.slice(1) : raw).replace(/^0+(?=\d)/, '')
26
+ if (decimals === 0) return (negative && digits !== '0' ? '-' : '') + digits
27
+ const padded = digits.padStart(decimals + 1, '0')
28
+ const whole = padded.slice(0, -decimals)
29
+ const fraction = padded.slice(-decimals).replace(/0+$/, '')
30
+ const sign = negative && (whole !== '0' || fraction) ? '-' : ''
31
+ return `${sign}${whole}${fraction ? `.${fraction}` : ''}`
32
+ }
33
+
34
+ /**
35
+ * Строка в монетах → минимальные единицы.
36
+ *
37
+ * @throws RangeError — больше знаков после запятой, чем у актива, или не число
38
+ *
39
+ * @example
40
+ * ```ts
41
+ * parseUnits('1.5', 6) // '1500000'
42
+ * parseUnits('100', 6) // '100000000'
43
+ * ```
44
+ */
45
+ export function parseUnits(amount: string, decimals: number): string {
46
+ assertDecimals(decimals)
47
+ const m = DECIMAL.exec(amount.trim())
48
+ if (!m) throw new RangeError(`parseUnits: non-negative decimal expected, got ${JSON.stringify(amount)}`)
49
+ const fraction = m[2] ?? ''
50
+ if (fraction.length > decimals) throw new RangeError(`parseUnits: ${amount} has more than ${decimals} decimal places`)
51
+ return BigInt(m[1]! + fraction.padEnd(decimals, '0')).toString()
52
+ }
53
+
54
+ function assertDecimals(decimals: number): void {
55
+ if (!Number.isInteger(decimals) || decimals < 0 || decimals > 255) throw new RangeError(`decimals: integer 0..255 expected, got ${decimals}`)
56
+ }
package/src/version.ts ADDED
@@ -0,0 +1,5 @@
1
+ /** Версия SDK: уходит в заголовке `user-agent`. Тест сверяет её с package.json */
2
+ export const SDK_VERSION = '0.1.0'
3
+
4
+ /** Адрес API по умолчанию */
5
+ export const DEFAULT_BASE_URL = 'https://monitoring.v3.finance'
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Приём вебхуков: проверка подписи и разбор тела. Работает без клиента API и без сети — нужен только секрет
3
+ * подписи клиента. Использует WebCrypto: Node.js 20+, Bun, Deno, Cloudflare Workers.
4
+ */
5
+
6
+ import { MonitoringError, WebhookSignatureError } from './core/errors.js'
7
+ import type { WebhookPayload } from './generated/types.js'
8
+
9
+ /** Заголовки вебхука */
10
+ export const WEBHOOK_HEADERS = {
11
+ /** HMAC-SHA256 сырого тела секретом клиента, hex в нижнем регистре */
12
+ signature: 'x-signature',
13
+ /** id клиента */
14
+ clientId: 'x-client-id',
15
+ /** `1` у тестового вебхука — такой не зачислять */
16
+ test: 'x-webhook-test',
17
+ } as const
18
+
19
+ /**
20
+ * Тело вебхука ровно как пришло — сырые байты или строка. Не `JSON.parse` + `JSON.stringify`: подпись считается
21
+ * по исходным байтам, и любое переформатирование её ломает.
22
+ */
23
+ export type WebhookBody = string | Uint8Array | ArrayBuffer
24
+
25
+ /**
26
+ * Откуда взять подпись: значение заголовка `x-signature` или все заголовки запроса — Fetch `Headers`
27
+ * (Hono, Next.js, Workers) или объект Node.js (`req.headers` в Express, Fastify).
28
+ */
29
+ export type SignatureSource = string | null | undefined | Headers | Record<string, string | string[] | undefined>
30
+
31
+ const encoder = new TextEncoder()
32
+
33
+ function bytesOf(body: WebhookBody): Uint8Array<ArrayBuffer> {
34
+ if (typeof body === 'string') return encoder.encode(body)
35
+ // копия: WebCrypto не принимает view над SharedArrayBuffer (Buffer в Node.js — тоже Uint8Array)
36
+ return body instanceof Uint8Array ? new Uint8Array(body) : new Uint8Array(body)
37
+ }
38
+
39
+ function signatureOf(source: SignatureSource): string | undefined {
40
+ if (source === null || source === undefined) return undefined
41
+ if (typeof source === 'string') return source
42
+ if (typeof (source as Headers).get === 'function') return (source as Headers).get(WEBHOOK_HEADERS.signature) ?? undefined
43
+ const value = (source as Record<string, string | string[] | undefined>)[WEBHOOK_HEADERS.signature]
44
+ return Array.isArray(value) ? value[0] : value
45
+ }
46
+
47
+ /** Сравнение без утечки по времени: время не зависит от того, где строки расходятся */
48
+ function safeEqual(a: string, b: string): boolean {
49
+ if (a.length !== b.length) return false
50
+ let diff = 0
51
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
52
+ return diff === 0
53
+ }
54
+
55
+ /**
56
+ * Подпись тела секретом клиента — как её считает сервис. Нужна в тестах вашего обработчика вебхуков.
57
+ *
58
+ * @example
59
+ * ```ts
60
+ * const body = JSON.stringify({ tx: { …, id: 'test' } })
61
+ * await fetch('http://localhost:3000/webhooks', { method: 'POST', body, headers: { 'x-signature': await signWebhookBody(body, secret) } })
62
+ * ```
63
+ */
64
+ export async function signWebhookBody(body: WebhookBody, secret: string): Promise<string> {
65
+ const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
66
+ const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, bytesOf(body)))
67
+ return Array.from(mac, (b) => b.toString(16).padStart(2, '0')).join('')
68
+ }
69
+
70
+ /**
71
+ * Проверяет подпись вебхука: `true` — тело пришло от сервиса и не изменено.
72
+ *
73
+ * @param body - сырое тело запроса
74
+ * @param signature - заголовок `x-signature` или все заголовки запроса
75
+ * @param secret - секрет подписи клиента
76
+ */
77
+ export async function verifyWebhookSignature(body: WebhookBody, signature: SignatureSource, secret: string): Promise<boolean> {
78
+ const given = signatureOf(signature)?.trim().toLowerCase()
79
+ if (!given) return false
80
+ return safeEqual(await signWebhookBody(body, secret), given)
81
+ }
82
+
83
+ /**
84
+ * Проверяет подпись и возвращает разобранный вебхук.
85
+ *
86
+ * Дальше — по правилам приёма: тестовый вебхук ({@link isTestWebhook}) не зачислять; один и тот же вебхук может
87
+ * прийти повторно — обрабатывайте идемпотентно по {@link webhookIdempotencyKey}; ответьте 2xx в течение 15 секунд.
88
+ * Вебхук уходит, как только перевод попал в блок, — для крупных сумм дождитесь финальности
89
+ * (`monitoring.transactions.get(...).final`).
90
+ *
91
+ * @throws {@link WebhookSignatureError} подпись не совпала — ответьте 401 и ничего не зачисляйте
92
+ *
93
+ * @example Express — нужен сырой body
94
+ * ```ts
95
+ * app.post('/webhooks/monitoring', express.raw({ type: 'application/json' }), async (req, res) => {
96
+ * const event = await parseWebhook(req.body, req.headers, process.env.MONITORING_WEBHOOK_SECRET!)
97
+ * if (!isTestWebhook(event)) await creditOnce(webhookIdempotencyKey(event), event.tx)
98
+ * res.sendStatus(200)
99
+ * })
100
+ * ```
101
+ *
102
+ * @example Hono, Next.js route handler, Cloudflare Workers
103
+ * ```ts
104
+ * const event = await parseWebhook(await request.text(), request.headers, env.MONITORING_WEBHOOK_SECRET)
105
+ * ```
106
+ */
107
+ export async function parseWebhook(body: WebhookBody, signature: SignatureSource, secret: string): Promise<WebhookPayload> {
108
+ if (!(await verifyWebhookSignature(body, signature, secret))) throw new WebhookSignatureError()
109
+ let payload: unknown
110
+ try {
111
+ payload = JSON.parse(typeof body === 'string' ? body : new TextDecoder().decode(bytesOf(body)))
112
+ } catch (e) {
113
+ throw new MonitoringError('webhook body is not valid JSON', { cause: e })
114
+ }
115
+ const tx = (payload as { tx?: { id?: unknown } } | null)?.tx
116
+ if (typeof tx !== 'object' || tx === null || typeof tx.id !== 'string') throw new MonitoringError('webhook body has no tx')
117
+ return payload as WebhookPayload
118
+ }
119
+
120
+ /** Тестовый вебхук (из панели или `clients.sendTestWebhook`) — такой не зачислять */
121
+ export function isTestWebhook(event: WebhookPayload): boolean {
122
+ return event.test === true
123
+ }
124
+
125
+ /**
126
+ * Ключ идемпотентности вебхука: сеть, транзакция и номер лога. Один перевод — один ключ, даже если вебхук пришёл
127
+ * несколько раз. В одной транзакции EVM может быть несколько переводов токенов — у них разный `logIndex`.
128
+ *
129
+ * @example `"TRON:1249cb…a5fc45:"`, `"ETHEREUM:0x3fbc…1d57:7"`
130
+ */
131
+ export function webhookIdempotencyKey(event: WebhookPayload): string {
132
+ return `${event.tx.network}:${event.tx.id}:${event.tx.logIndex ?? ''}`
133
+ }