@v3finance/monitoring-sdk 0.0.0-stage → 0.2.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 (149) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/LICENSE +21 -0
  3. package/README.md +322 -2
  4. package/dist/cjs/client.d.ts +46 -0
  5. package/dist/cjs/client.d.ts.map +1 -0
  6. package/dist/cjs/client.js +80 -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 +58 -0
  13. package/dist/cjs/core/http.d.ts.map +1 -0
  14. package/dist/cjs/core/http.js +143 -0
  15. package/dist/cjs/core/http.js.map +1 -0
  16. package/dist/cjs/core/pagination.d.ts +12 -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/core/scope.d.ts +8 -0
  21. package/dist/cjs/core/scope.d.ts.map +1 -0
  22. package/dist/cjs/core/scope.js +3 -0
  23. package/dist/cjs/core/scope.js.map +1 -0
  24. package/dist/cjs/generated/types.d.ts +336 -0
  25. package/dist/cjs/generated/types.d.ts.map +1 -0
  26. package/dist/cjs/generated/types.js +5 -0
  27. package/dist/cjs/generated/types.js.map +1 -0
  28. package/dist/cjs/index.d.ts +23 -0
  29. package/dist/cjs/index.d.ts.map +1 -0
  30. package/dist/cjs/index.js +47 -0
  31. package/dist/cjs/index.js.map +1 -0
  32. package/dist/cjs/package.json +1 -0
  33. package/dist/cjs/resources/account.d.ts +22 -0
  34. package/dist/cjs/resources/account.d.ts.map +1 -0
  35. package/dist/cjs/resources/account.js +31 -0
  36. package/dist/cjs/resources/account.js.map +1 -0
  37. package/dist/cjs/resources/addresses.d.ts +93 -0
  38. package/dist/cjs/resources/addresses.d.ts.map +1 -0
  39. package/dist/cjs/resources/addresses.js +128 -0
  40. package/dist/cjs/resources/addresses.js.map +1 -0
  41. package/dist/cjs/resources/catalog.d.ts +37 -0
  42. package/dist/cjs/resources/catalog.d.ts.map +1 -0
  43. package/dist/cjs/resources/catalog.js +52 -0
  44. package/dist/cjs/resources/catalog.js.map +1 -0
  45. package/dist/cjs/resources/deliveries.d.ts +61 -0
  46. package/dist/cjs/resources/deliveries.d.ts.map +1 -0
  47. package/dist/cjs/resources/deliveries.js +53 -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 +34 -0
  52. package/dist/cjs/resources/transactions.js.map +1 -0
  53. package/dist/cjs/resources/webhooks.d.ts +22 -0
  54. package/dist/cjs/resources/webhooks.d.ts.map +1 -0
  55. package/dist/cjs/resources/webhooks.js +35 -0
  56. package/dist/cjs/resources/webhooks.js.map +1 -0
  57. package/dist/cjs/units.d.ts +29 -0
  58. package/dist/cjs/units.d.ts.map +1 -0
  59. package/dist/cjs/units.js +62 -0
  60. package/dist/cjs/units.js.map +1 -0
  61. package/dist/cjs/version.d.ts +5 -0
  62. package/dist/cjs/version.d.ts.map +1 -0
  63. package/dist/cjs/version.js +8 -0
  64. package/dist/cjs/version.js.map +1 -0
  65. package/dist/cjs/webhooks.d.ts +82 -0
  66. package/dist/cjs/webhooks.d.ts.map +1 -0
  67. package/dist/cjs/webhooks.js +133 -0
  68. package/dist/cjs/webhooks.js.map +1 -0
  69. package/dist/esm/client.d.ts +46 -0
  70. package/dist/esm/client.d.ts.map +1 -0
  71. package/dist/esm/client.js +76 -0
  72. package/dist/esm/client.js.map +1 -0
  73. package/dist/esm/core/errors.d.ts +64 -0
  74. package/dist/esm/core/errors.d.ts.map +1 -0
  75. package/dist/esm/core/errors.js +86 -0
  76. package/dist/esm/core/errors.js.map +1 -0
  77. package/dist/esm/core/http.d.ts +58 -0
  78. package/dist/esm/core/http.d.ts.map +1 -0
  79. package/dist/esm/core/http.js +138 -0
  80. package/dist/esm/core/http.js.map +1 -0
  81. package/dist/esm/core/pagination.d.ts +12 -0
  82. package/dist/esm/core/pagination.d.ts.map +1 -0
  83. package/dist/esm/core/pagination.js +12 -0
  84. package/dist/esm/core/pagination.js.map +1 -0
  85. package/dist/esm/core/scope.d.ts +8 -0
  86. package/dist/esm/core/scope.d.ts.map +1 -0
  87. package/dist/esm/core/scope.js +2 -0
  88. package/dist/esm/core/scope.js.map +1 -0
  89. package/dist/esm/generated/types.d.ts +336 -0
  90. package/dist/esm/generated/types.d.ts.map +1 -0
  91. package/dist/esm/generated/types.js +4 -0
  92. package/dist/esm/generated/types.js.map +1 -0
  93. package/dist/esm/index.d.ts +23 -0
  94. package/dist/esm/index.d.ts.map +1 -0
  95. package/dist/esm/index.js +20 -0
  96. package/dist/esm/index.js.map +1 -0
  97. package/dist/esm/resources/account.d.ts +22 -0
  98. package/dist/esm/resources/account.d.ts.map +1 -0
  99. package/dist/esm/resources/account.js +27 -0
  100. package/dist/esm/resources/account.js.map +1 -0
  101. package/dist/esm/resources/addresses.d.ts +93 -0
  102. package/dist/esm/resources/addresses.d.ts.map +1 -0
  103. package/dist/esm/resources/addresses.js +124 -0
  104. package/dist/esm/resources/addresses.js.map +1 -0
  105. package/dist/esm/resources/catalog.d.ts +37 -0
  106. package/dist/esm/resources/catalog.d.ts.map +1 -0
  107. package/dist/esm/resources/catalog.js +47 -0
  108. package/dist/esm/resources/catalog.js.map +1 -0
  109. package/dist/esm/resources/deliveries.d.ts +61 -0
  110. package/dist/esm/resources/deliveries.d.ts.map +1 -0
  111. package/dist/esm/resources/deliveries.js +49 -0
  112. package/dist/esm/resources/deliveries.js.map +1 -0
  113. package/dist/esm/resources/transactions.d.ts +28 -0
  114. package/dist/esm/resources/transactions.d.ts.map +1 -0
  115. package/dist/esm/resources/transactions.js +30 -0
  116. package/dist/esm/resources/transactions.js.map +1 -0
  117. package/dist/esm/resources/webhooks.d.ts +22 -0
  118. package/dist/esm/resources/webhooks.d.ts.map +1 -0
  119. package/dist/esm/resources/webhooks.js +31 -0
  120. package/dist/esm/resources/webhooks.js.map +1 -0
  121. package/dist/esm/units.d.ts +29 -0
  122. package/dist/esm/units.d.ts.map +1 -0
  123. package/dist/esm/units.js +58 -0
  124. package/dist/esm/units.js.map +1 -0
  125. package/dist/esm/version.d.ts +5 -0
  126. package/dist/esm/version.d.ts.map +1 -0
  127. package/dist/esm/version.js +5 -0
  128. package/dist/esm/version.js.map +1 -0
  129. package/dist/esm/webhooks.d.ts +82 -0
  130. package/dist/esm/webhooks.d.ts.map +1 -0
  131. package/dist/esm/webhooks.js +125 -0
  132. package/dist/esm/webhooks.js.map +1 -0
  133. package/package.json +69 -4
  134. package/src/client.ts +83 -0
  135. package/src/core/errors.ts +99 -0
  136. package/src/core/http.ts +183 -0
  137. package/src/core/pagination.ts +20 -0
  138. package/src/core/scope.ts +8 -0
  139. package/src/generated/types.ts +369 -0
  140. package/src/index.ts +43 -0
  141. package/src/resources/account.ts +33 -0
  142. package/src/resources/addresses.ts +165 -0
  143. package/src/resources/catalog.ts +56 -0
  144. package/src/resources/deliveries.ts +82 -0
  145. package/src/resources/transactions.ts +34 -0
  146. package/src/resources/webhooks.ts +36 -0
  147. package/src/units.ts +56 -0
  148. package/src/version.ts +5 -0
  149. package/src/webhooks.ts +138 -0
@@ -0,0 +1,82 @@
1
+ import { seg, type RequestOptions } from '../core/http.js'
2
+ import { paginate } from '../core/pagination.js'
3
+ import type { Scope } from '../core/scope.js'
4
+ import type { Delivery, DeliveryDetail, DeliveryPage, DeliveryStatus } from '../generated/types.js'
5
+
6
+ /** Filters for webhook deliveries. All are optional and can be combined. */
7
+ export interface DeliveryFilter {
8
+ /** `delivered`, `retrying` or `failed`. */
9
+ status?: DeliveryStatus
10
+ /** Network slug or CAIP-2 ID. */
11
+ network?: string
12
+ /** Asset symbol. */
13
+ asset?: string
14
+ /** Transaction hash. */
15
+ tx?: string
16
+ /** Sender or recipient address. */
17
+ address?: string
18
+ /** First attempt at or after this time. */
19
+ since?: Date | string
20
+ /** First attempt before this time. */
21
+ until?: Date | string
22
+ }
23
+
24
+ /** Parameters of {@link DeliveriesResource.list}. */
25
+ export interface ListDeliveriesParams extends DeliveryFilter {
26
+ /** Page size, up to 500. Defaults to 100. */
27
+ limit?: number
28
+ /** Cursor: `next` of the previous page. */
29
+ before?: number
30
+ }
31
+
32
+ const iso = (value: Date | string | undefined) => (value instanceof Date ? value.toISOString() : value)
33
+
34
+ /**
35
+ * Every webhook sent to you: status, attempts and your endpoint's response. Retries of the same webhook are one
36
+ * delivery with an attempt count. Deliveries are kept for 90 days.
37
+ */
38
+ export class DeliveriesResource {
39
+ readonly #scope: Scope
40
+
41
+ /** @internal */
42
+ constructor(scope: Scope) {
43
+ this.#scope = scope
44
+ }
45
+
46
+ /**
47
+ * Lists deliveries, newest first, one page at a time.
48
+ *
49
+ * @example
50
+ * ```ts
51
+ * const { items } = await monitoring.deliveries.list({ status: 'failed', since: new Date(Date.now() - 86_400_000) })
52
+ * ```
53
+ */
54
+ async list(params: ListDeliveriesParams = {}, options?: RequestOptions): Promise<DeliveryPage> {
55
+ return this.#scope.http.request({
56
+ method: 'GET',
57
+ path: `${await this.#scope.clientPath()}/deliveries`,
58
+ query: { ...params, since: iso(params.since), until: iso(params.until) },
59
+ idempotent: true,
60
+ options,
61
+ })
62
+ }
63
+
64
+ /**
65
+ * Iterates over all deliveries matching the filter, newest first.
66
+ *
67
+ * @example
68
+ * ```ts
69
+ * for await (const delivery of monitoring.deliveries.iterate({ status: 'failed' })) {
70
+ * console.log(delivery.txId, delivery.attempts, delivery.lastHttpStatus ?? delivery.lastError)
71
+ * }
72
+ * ```
73
+ */
74
+ iterate(filter: DeliveryFilter & { limit?: number } = {}, options?: RequestOptions): AsyncGenerator<Delivery, void, undefined> {
75
+ return paginate<Delivery>((before) => this.list({ ...filter, before }, options))
76
+ }
77
+
78
+ /** Retrieves a delivery with the URL of the last attempt and the exact request body. */
79
+ async get(deliveryId: number, options?: RequestOptions): Promise<DeliveryDetail> {
80
+ return this.#scope.http.request({ method: 'GET', path: `${await this.#scope.clientPath()}/deliveries/${seg(deliveryId)}`, idempotent: true, options })
81
+ }
82
+ }
@@ -0,0 +1,34 @@
1
+ import { seg, type RequestOptions } from '../core/http.js'
2
+ import type { Scope } from '../core/scope.js'
3
+ import type { Transaction } from '../generated/types.js'
4
+
5
+ /** Any transaction on a supported network, by hash. */
6
+ export class TransactionsResource {
7
+ readonly #scope: Scope
8
+
9
+ /** @internal */
10
+ constructor(scope: Scope) {
11
+ this.#scope = scope
12
+ }
13
+
14
+ /**
15
+ * Retrieves a transaction — not only the ones you were notified about: status, confirmations, finality, fee and
16
+ * every transfer inside it, plus your monitored addresses and the webhooks sent to you for it.
17
+ *
18
+ * `final: true` means the transaction can no longer be reverted: on TRON the block is solidified (about 1 minute),
19
+ * on EVM networks it is finalized (about 13 minutes on Ethereum). Credit large amounts once the transaction is final.
20
+ *
21
+ * @param network - Network slug (`tron`, `ethereum`) or CAIP-2 ID.
22
+ * @param hash - EVM: `0x` followed by 64 hex characters. TRON: 64 hex characters.
23
+ * @throws {@link MonitoringApiError} `404` when the transaction does not exist on the network.
24
+ *
25
+ * @example
26
+ * ```ts
27
+ * const tx = await monitoring.transactions.get('tron', event.tx.id)
28
+ * if (tx.status === 'success' && tx.final) await credit(event.tx)
29
+ * ```
30
+ */
31
+ get(network: string, hash: string, options?: RequestOptions): Promise<Transaction> {
32
+ return this.#scope.http.request({ method: 'GET', path: `/v1/networks/${seg(network)}/transactions/${seg(hash)}`, idempotent: true, options })
33
+ }
34
+ }
@@ -0,0 +1,36 @@
1
+ import type { RequestOptions } from '../core/http.js'
2
+ import type { Scope } from '../core/scope.js'
3
+ import type { TestWebhookRequest, TestWebhookResult } from '../generated/types.js'
4
+
5
+ /** Test your webhook endpoint. To receive webhooks, see {@link parseWebhook}. */
6
+ export class WebhooksResource {
7
+ readonly #scope: Scope
8
+
9
+ /** @internal */
10
+ constructor(scope: Scope) {
11
+ this.#scope = scope
12
+ }
13
+
14
+ /**
15
+ * Sends a signed test webhook to your webhook URL and returns the outcome, the signature and the exact body sent.
16
+ * Test webhooks have `"test": true` in the body and the `x-webhook-test: 1` header — do not credit them.
17
+ * Limited to 10 per minute.
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * const { delivery } = await monitoring.webhooks.sendTest({ asset: 'USDT' })
22
+ * if (!delivery.ok) console.error('Endpoint responded with', delivery.status ?? delivery.error)
23
+ * ```
24
+ */
25
+ async sendTest(params: TestWebhookRequest = {}, options?: RequestOptions): Promise<TestWebhookResult> {
26
+ return this.#scope.http.request({
27
+ method: 'POST',
28
+ path: `${await this.#scope.clientPath()}/webhooks/test`,
29
+ body: params,
30
+ idempotent: false,
31
+ // 502 means "your endpoint did not accept it": an outcome, not a failed request
32
+ acceptStatuses: [502],
33
+ options,
34
+ })
35
+ }
36
+ }
package/src/units.ts ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Amounts in the API and in webhooks are decimal strings in the asset's smallest unit (sun, wei; USDT has 6 decimals),
3
+ * because uint256 values do not fit in a `number`. These helpers convert them without losing precision. The decimals
4
+ * of every asset are returned by `monitoring.assets.list()`.
5
+ */
6
+
7
+ const INTEGER = /^-?\d+$/
8
+ const DECIMAL = /^(\d+)(?:\.(\d+))?$/
9
+
10
+ /**
11
+ * Converts an amount in the smallest unit to a decimal string.
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: expected an integer amount in the smallest unit, 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
+ * Converts a decimal string to an amount in the smallest unit.
36
+ *
37
+ * @throws RangeError when the amount is not a non-negative decimal or has more decimal places than the asset.
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: expected a non-negative decimal, 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: expected an integer from 0 to 255, got ${decimals}`)
56
+ }
package/src/version.ts ADDED
@@ -0,0 +1,5 @@
1
+ /** SDK version, sent in the `user-agent` header. */
2
+ export const SDK_VERSION = '0.2.0'
3
+
4
+ /** Default API base URL. */
5
+ export const DEFAULT_BASE_URL = 'https://monitoring.v3.finance'
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Receiving webhooks: signature verification and parsing. No API key or network access is needed — only your
3
+ * webhook secret. Uses Web Crypto, so it runs on Node.js 20+, Bun, Deno and Cloudflare Workers.
4
+ */
5
+
6
+ import { MonitoringError, WebhookSignatureError } from './core/errors.js'
7
+ import type { WebhookPayload } from './generated/types.js'
8
+
9
+ /** Headers sent with every webhook. */
10
+ export const WEBHOOK_HEADERS = {
11
+ /** HMAC-SHA256 of the raw body keyed with your webhook secret, hex-encoded. */
12
+ signature: 'x-signature',
13
+ /** Your client ID. */
14
+ clientId: 'x-client-id',
15
+ /** `1` for test webhooks. */
16
+ test: 'x-webhook-test',
17
+ } as const
18
+
19
+ /**
20
+ * The raw request body, exactly as received. Do not parse and re-serialize the JSON: the signature is computed over
21
+ * the original bytes.
22
+ */
23
+ export type WebhookBody = string | Uint8Array | ArrayBuffer
24
+
25
+ /**
26
+ * Where to read the signature from: the value of the `x-signature` header, or all request headers — a Fetch
27
+ * `Headers` object (Next.js, Hono, Cloudflare Workers) or a Node.js headers object (`req.headers` in 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
+ // copy: Web Crypto does not accept views over a SharedArrayBuffer (a Node.js Buffer is a Uint8Array too)
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
+ /** Constant-time comparison. */
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
+ * Computes the signature of a body the way the API does. Useful for testing your webhook handler.
57
+ *
58
+ * @example
59
+ * ```ts
60
+ * const body = JSON.stringify({ tx: { network: 'TRON', asset: 'USDT', … } })
61
+ * await fetch('http://localhost:3000/webhooks/monitoring', {
62
+ * method: 'POST',
63
+ * body,
64
+ * headers: { 'content-type': 'application/json', 'x-signature': await signWebhookBody(body, secret) },
65
+ * })
66
+ * ```
67
+ */
68
+ export async function signWebhookBody(body: WebhookBody, secret: string): Promise<string> {
69
+ const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
70
+ const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, bytesOf(body)))
71
+ return Array.from(mac, (b) => b.toString(16).padStart(2, '0')).join('')
72
+ }
73
+
74
+ /**
75
+ * Verifies a webhook signature. Returns `true` when the body was sent by the API and was not modified.
76
+ *
77
+ * @param body - Raw request body.
78
+ * @param signature - The `x-signature` header, or all request headers.
79
+ * @param secret - Your webhook secret.
80
+ */
81
+ export async function verifyWebhookSignature(body: WebhookBody, signature: SignatureSource, secret: string): Promise<boolean> {
82
+ const given = signatureOf(signature)?.trim().toLowerCase()
83
+ if (!given) return false
84
+ return safeEqual(await signWebhookBody(body, secret), given)
85
+ }
86
+
87
+ /**
88
+ * Verifies the signature and returns the parsed webhook.
89
+ *
90
+ * Respond with a 2xx status within 15 seconds. Process webhooks idempotently by {@link webhookIdempotencyKey} — the
91
+ * same webhook may arrive more than once — and do not credit test webhooks ({@link isTestWebhook}). Webhooks are sent
92
+ * as soon as a transfer is included in a block; before crediting large amounts, check that the transaction is final
93
+ * with `monitoring.transactions.get()`.
94
+ *
95
+ * @throws {@link WebhookSignatureError} when the signature does not match. Respond with `401` and do not process it.
96
+ *
97
+ * @example Express (use the raw body)
98
+ * ```ts
99
+ * app.post('/webhooks/monitoring', express.raw({ type: 'application/json' }), async (req, res) => {
100
+ * const event = await parseWebhook(req.body, req.headers, process.env.MONITORING_WEBHOOK_SECRET!)
101
+ * if (!isTestWebhook(event)) await creditOnce(webhookIdempotencyKey(event), event.tx)
102
+ * res.sendStatus(200)
103
+ * })
104
+ * ```
105
+ *
106
+ * @example Next.js route handlers, Hono, Cloudflare Workers
107
+ * ```ts
108
+ * const event = await parseWebhook(await request.text(), request.headers, env.MONITORING_WEBHOOK_SECRET)
109
+ * ```
110
+ */
111
+ export async function parseWebhook(body: WebhookBody, signature: SignatureSource, secret: string): Promise<WebhookPayload> {
112
+ if (!(await verifyWebhookSignature(body, signature, secret))) throw new WebhookSignatureError()
113
+ let payload: unknown
114
+ try {
115
+ payload = JSON.parse(typeof body === 'string' ? body : new TextDecoder().decode(bytesOf(body)))
116
+ } catch (error) {
117
+ throw new MonitoringError('webhook body is not valid JSON', { cause: error })
118
+ }
119
+ const tx = (payload as { tx?: { id?: unknown } } | null)?.tx
120
+ if (typeof tx !== 'object' || tx === null || typeof tx.id !== 'string') throw new MonitoringError('webhook body has no tx')
121
+ return payload as WebhookPayload
122
+ }
123
+
124
+ /** `true` for test webhooks. Do not credit them. */
125
+ export function isTestWebhook(event: WebhookPayload): boolean {
126
+ return event.test === true
127
+ }
128
+
129
+ /**
130
+ * Idempotency key of a webhook: network, transaction hash and log index. A transfer always has the same key, however
131
+ * many times its webhook is delivered. An EVM transaction can contain several token transfers; they differ by
132
+ * `logIndex`.
133
+ *
134
+ * @example `"TRON:1249cb…a5fc45:"`, `"ETHEREUM:0x3fbc…1d57:7"`
135
+ */
136
+ export function webhookIdempotencyKey(event: WebhookPayload): string {
137
+ return `${event.tx.network}:${event.tx.id}:${event.tx.logIndex ?? ''}`
138
+ }