@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.
- package/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +247 -2
- package/dist/cjs/client.d.ts +65 -0
- package/dist/cjs/client.d.ts.map +1 -0
- package/dist/cjs/client.js +89 -0
- package/dist/cjs/client.js.map +1 -0
- package/dist/cjs/core/errors.d.ts +64 -0
- package/dist/cjs/core/errors.d.ts.map +1 -0
- package/dist/cjs/core/errors.js +95 -0
- package/dist/cjs/core/errors.js.map +1 -0
- package/dist/cjs/core/http.d.ts +62 -0
- package/dist/cjs/core/http.d.ts.map +1 -0
- package/dist/cjs/core/http.js +151 -0
- package/dist/cjs/core/http.js.map +1 -0
- package/dist/cjs/core/pagination.d.ts +13 -0
- package/dist/cjs/core/pagination.d.ts.map +1 -0
- package/dist/cjs/core/pagination.js +15 -0
- package/dist/cjs/core/pagination.js.map +1 -0
- package/dist/cjs/generated/types.d.ts +324 -0
- package/dist/cjs/generated/types.d.ts.map +1 -0
- package/dist/cjs/generated/types.js +5 -0
- package/dist/cjs/generated/types.js.map +1 -0
- package/dist/cjs/index.d.ts +23 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +50 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/resources/account.d.ts +61 -0
- package/dist/cjs/resources/account.d.ts.map +1 -0
- package/dist/cjs/resources/account.js +75 -0
- package/dist/cjs/resources/account.js.map +1 -0
- package/dist/cjs/resources/addresses.d.ts +109 -0
- package/dist/cjs/resources/addresses.d.ts.map +1 -0
- package/dist/cjs/resources/addresses.js +137 -0
- package/dist/cjs/resources/addresses.js.map +1 -0
- package/dist/cjs/resources/catalog.d.ts +30 -0
- package/dist/cjs/resources/catalog.d.ts.map +1 -0
- package/dist/cjs/resources/catalog.js +46 -0
- package/dist/cjs/resources/catalog.js.map +1 -0
- package/dist/cjs/resources/clients.d.ts +84 -0
- package/dist/cjs/resources/clients.d.ts.map +1 -0
- package/dist/cjs/resources/clients.js +102 -0
- package/dist/cjs/resources/clients.js.map +1 -0
- package/dist/cjs/resources/deliveries.d.ts +50 -0
- package/dist/cjs/resources/deliveries.d.ts.map +1 -0
- package/dist/cjs/resources/deliveries.js +43 -0
- package/dist/cjs/resources/deliveries.js.map +1 -0
- package/dist/cjs/resources/transactions.d.ts +28 -0
- package/dist/cjs/resources/transactions.d.ts.map +1 -0
- package/dist/cjs/resources/transactions.js +35 -0
- package/dist/cjs/resources/transactions.js.map +1 -0
- package/dist/cjs/units.d.ts +29 -0
- package/dist/cjs/units.d.ts.map +1 -0
- package/dist/cjs/units.js +62 -0
- package/dist/cjs/units.js.map +1 -0
- package/dist/cjs/version.d.ts +5 -0
- package/dist/cjs/version.d.ts.map +1 -0
- package/dist/cjs/version.js +8 -0
- package/dist/cjs/version.js.map +1 -0
- package/dist/cjs/webhooks.d.ts +77 -0
- package/dist/cjs/webhooks.d.ts.map +1 -0
- package/dist/cjs/webhooks.js +128 -0
- package/dist/cjs/webhooks.js.map +1 -0
- package/dist/esm/client.d.ts +65 -0
- package/dist/esm/client.d.ts.map +1 -0
- package/dist/esm/client.js +85 -0
- package/dist/esm/client.js.map +1 -0
- package/dist/esm/core/errors.d.ts +64 -0
- package/dist/esm/core/errors.d.ts.map +1 -0
- package/dist/esm/core/errors.js +86 -0
- package/dist/esm/core/errors.js.map +1 -0
- package/dist/esm/core/http.d.ts +62 -0
- package/dist/esm/core/http.d.ts.map +1 -0
- package/dist/esm/core/http.js +146 -0
- package/dist/esm/core/http.js.map +1 -0
- package/dist/esm/core/pagination.d.ts +13 -0
- package/dist/esm/core/pagination.d.ts.map +1 -0
- package/dist/esm/core/pagination.js +12 -0
- package/dist/esm/core/pagination.js.map +1 -0
- package/dist/esm/generated/types.d.ts +324 -0
- package/dist/esm/generated/types.d.ts.map +1 -0
- package/dist/esm/generated/types.js +4 -0
- package/dist/esm/generated/types.js.map +1 -0
- package/dist/esm/index.d.ts +23 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +20 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/resources/account.d.ts +61 -0
- package/dist/esm/resources/account.d.ts.map +1 -0
- package/dist/esm/resources/account.js +69 -0
- package/dist/esm/resources/account.js.map +1 -0
- package/dist/esm/resources/addresses.d.ts +109 -0
- package/dist/esm/resources/addresses.d.ts.map +1 -0
- package/dist/esm/resources/addresses.js +133 -0
- package/dist/esm/resources/addresses.js.map +1 -0
- package/dist/esm/resources/catalog.d.ts +30 -0
- package/dist/esm/resources/catalog.d.ts.map +1 -0
- package/dist/esm/resources/catalog.js +41 -0
- package/dist/esm/resources/catalog.js.map +1 -0
- package/dist/esm/resources/clients.d.ts +84 -0
- package/dist/esm/resources/clients.d.ts.map +1 -0
- package/dist/esm/resources/clients.js +97 -0
- package/dist/esm/resources/clients.js.map +1 -0
- package/dist/esm/resources/deliveries.d.ts +50 -0
- package/dist/esm/resources/deliveries.d.ts.map +1 -0
- package/dist/esm/resources/deliveries.js +39 -0
- package/dist/esm/resources/deliveries.js.map +1 -0
- package/dist/esm/resources/transactions.d.ts +28 -0
- package/dist/esm/resources/transactions.d.ts.map +1 -0
- package/dist/esm/resources/transactions.js +31 -0
- package/dist/esm/resources/transactions.js.map +1 -0
- package/dist/esm/units.d.ts +29 -0
- package/dist/esm/units.d.ts.map +1 -0
- package/dist/esm/units.js +58 -0
- package/dist/esm/units.js.map +1 -0
- package/dist/esm/version.d.ts +5 -0
- package/dist/esm/version.d.ts.map +1 -0
- package/dist/esm/version.js +5 -0
- package/dist/esm/version.js.map +1 -0
- package/dist/esm/webhooks.d.ts +77 -0
- package/dist/esm/webhooks.d.ts.map +1 -0
- package/dist/esm/webhooks.js +120 -0
- package/dist/esm/webhooks.js.map +1 -0
- package/package.json +66 -4
- package/src/client.ts +94 -0
- package/src/core/errors.ts +99 -0
- package/src/core/http.ts +193 -0
- package/src/core/pagination.ts +21 -0
- package/src/generated/types.ts +355 -0
- package/src/index.ts +60 -0
- package/src/resources/account.ts +87 -0
- package/src/resources/addresses.ts +190 -0
- package/src/resources/catalog.ts +47 -0
- package/src/resources/clients.ts +135 -0
- package/src/resources/deliveries.ts +69 -0
- package/src/resources/transactions.ts +33 -0
- package/src/units.ts +56 -0
- package/src/version.ts +5 -0
- 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
package/src/webhooks.ts
ADDED
|
@@ -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
|
+
}
|