tapimo 0.8.0 → 0.10.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 (223) hide show
  1. package/dist/App.d.ts +7410 -7613
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/App.js +0 -1
  4. package/dist/App.js.map +1 -1
  5. package/dist/Webhooks.d.ts +21 -20
  6. package/dist/Webhooks.d.ts.map +1 -1
  7. package/dist/Webhooks.js +66 -23
  8. package/dist/Webhooks.js.map +1 -1
  9. package/dist/admin/apps/verified-tokens.d.ts +74 -74
  10. package/dist/apps/Zones.d.ts +34 -22
  11. package/dist/apps/Zones.d.ts.map +1 -1
  12. package/dist/apps/Zones.js +9 -8
  13. package/dist/apps/Zones.js.map +1 -1
  14. package/dist/apps/data/App.d.ts +3120 -2782
  15. package/dist/apps/data/App.d.ts.map +1 -1
  16. package/dist/apps/data/FxOracle.d.ts +4 -5
  17. package/dist/apps/data/FxOracle.d.ts.map +1 -1
  18. package/dist/apps/data/FxOracle.js +19 -4
  19. package/dist/apps/data/FxOracle.js.map +1 -1
  20. package/dist/apps/data/routes/activities.d.ts.map +1 -1
  21. package/dist/apps/data/routes/activities.js +32 -27
  22. package/dist/apps/data/routes/activities.js.map +1 -1
  23. package/dist/apps/data/routes/addresses.d.ts +283 -52
  24. package/dist/apps/data/routes/addresses.d.ts.map +1 -1
  25. package/dist/apps/data/routes/balances.d.ts +291 -21
  26. package/dist/apps/data/routes/balances.d.ts.map +1 -1
  27. package/dist/apps/data/routes/balances.js +232 -66
  28. package/dist/apps/data/routes/balances.js.map +1 -1
  29. package/dist/apps/data/routes/blocks.d.ts +4 -4
  30. package/dist/apps/data/routes/coingecko.d.ts +54 -54
  31. package/dist/apps/data/routes/earn.d.ts +642 -126
  32. package/dist/apps/data/routes/earn.d.ts.map +1 -1
  33. package/dist/apps/data/routes/earn.js +627 -58
  34. package/dist/apps/data/routes/earn.js.map +1 -1
  35. package/dist/apps/data/routes/exchanges.d.ts +120 -120
  36. package/dist/apps/data/routes/exchanges.d.ts.map +1 -1
  37. package/dist/apps/data/routes/exchanges.js +114 -118
  38. package/dist/apps/data/routes/exchanges.js.map +1 -1
  39. package/dist/apps/data/routes/receipts.d.ts.map +1 -1
  40. package/dist/apps/data/routes/receipts.js +68 -47
  41. package/dist/apps/data/routes/receipts.js.map +1 -1
  42. package/dist/apps/data/routes/tokens.d.ts +2 -0
  43. package/dist/apps/data/routes/tokens.d.ts.map +1 -1
  44. package/dist/apps/data/routes/transactions.d.ts +59 -51
  45. package/dist/apps/data/routes/transactions.d.ts.map +1 -1
  46. package/dist/apps/data/routes/transactions.js +25 -23
  47. package/dist/apps/data/routes/transactions.js.map +1 -1
  48. package/dist/apps/data/routes/transfers.d.ts +40 -18
  49. package/dist/apps/data/routes/transfers.d.ts.map +1 -1
  50. package/dist/apps/data/routes/transfers.js +618 -105
  51. package/dist/apps/data/routes/transfers.js.map +1 -1
  52. package/dist/apps/data/routes/valuation.d.ts +21 -21
  53. package/dist/apps/data/routes/valuation.d.ts.map +1 -1
  54. package/dist/apps/data/routes/valuation.js +2 -2
  55. package/dist/apps/data/routes/valuation.js.map +1 -1
  56. package/dist/apps/data/routes/verified-tokens.d.ts +37 -37
  57. package/dist/apps/data/routes/webhooks.d.ts +102 -505
  58. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  59. package/dist/apps/data/routes/webhooks.js +484 -342
  60. package/dist/apps/data/routes/webhooks.js.map +1 -1
  61. package/dist/apps/data/routes/zones.d.ts +12 -12
  62. package/dist/apps/data/routes/zones.d.ts.map +1 -1
  63. package/dist/apps/data/routes/zones.js +3 -1
  64. package/dist/apps/data/routes/zones.js.map +1 -1
  65. package/dist/apps/funding/App.d.ts +127 -386
  66. package/dist/apps/funding/App.d.ts.map +1 -1
  67. package/dist/apps/funding/App.js +20 -5
  68. package/dist/apps/funding/App.js.map +1 -1
  69. package/dist/apps/funding/routes/deposit-addresses.d.ts +141 -532
  70. package/dist/apps/funding/routes/deposit-addresses.d.ts.map +1 -1
  71. package/dist/apps/funding/routes/deposit-addresses.js +21 -103
  72. package/dist/apps/funding/routes/deposit-addresses.js.map +1 -1
  73. package/dist/apps/funding/routes/transfers.d.ts +4 -4
  74. package/dist/apps/funding/routes/transfers.js +5 -5
  75. package/dist/apps/funding/routes/transfers.js.map +1 -1
  76. package/dist/apps/management/App.d.ts +429 -727
  77. package/dist/apps/management/App.d.ts.map +1 -1
  78. package/dist/apps/management/routes/api-keys.d.ts +24 -24
  79. package/dist/apps/management/routes/billing.d.ts +72 -72
  80. package/dist/apps/management/routes/faucet.d.ts +6 -6
  81. package/dist/apps/management/routes/invite-links.d.ts +38 -38
  82. package/dist/apps/management/routes/me.d.ts +6 -6
  83. package/dist/apps/management/routes/members.d.ts +30 -30
  84. package/dist/apps/management/routes/projects.d.ts +20 -20
  85. package/dist/apps/management/routes/usage.d.ts +24 -24
  86. package/dist/apps/management/routes/webhooks.d.ts +158 -573
  87. package/dist/apps/management/routes/webhooks.d.ts.map +1 -1
  88. package/dist/apps/mpp/App.d.ts +12 -0
  89. package/dist/apps/mpp/App.d.ts.map +1 -1
  90. package/dist/apps/mpp/Mpp.d.ts +3 -0
  91. package/dist/apps/mpp/Mpp.d.ts.map +1 -1
  92. package/dist/apps/mpp/Mpp.js +3 -0
  93. package/dist/apps/mpp/Mpp.js.map +1 -1
  94. package/dist/apps/mpp/internal/Adapter.d.ts.map +1 -1
  95. package/dist/apps/mpp/internal/Adapter.js +9 -2
  96. package/dist/apps/mpp/internal/Adapter.js.map +1 -1
  97. package/dist/db/Db.d.ts +2 -2
  98. package/dist/db/Db.d.ts.map +1 -1
  99. package/dist/db/Db.js +25 -15
  100. package/dist/db/Db.js.map +1 -1
  101. package/dist/db/tables/fundingDeposits.d.ts +18 -1
  102. package/dist/db/tables/fundingDeposits.d.ts.map +1 -1
  103. package/dist/db/tables/fundingDeposits.js +46 -2
  104. package/dist/db/tables/fundingDeposits.js.map +1 -1
  105. package/dist/db/tables/sponsoredTransactions.d.ts.map +1 -1
  106. package/dist/db/tables/sponsoredTransactions.js +4 -1
  107. package/dist/db/tables/sponsoredTransactions.js.map +1 -1
  108. package/dist/db/tables/webhookSubscriptions.d.ts +56 -3
  109. package/dist/db/tables/webhookSubscriptions.d.ts.map +1 -1
  110. package/dist/db/tables/webhookSubscriptions.js +76 -3
  111. package/dist/db/tables/webhookSubscriptions.js.map +1 -1
  112. package/dist/internal/Auth.d.ts +4 -0
  113. package/dist/internal/Auth.d.ts.map +1 -1
  114. package/dist/internal/Auth.js +11 -2
  115. package/dist/internal/Auth.js.map +1 -1
  116. package/dist/internal/MetricSink.d.ts +112 -3
  117. package/dist/internal/MetricSink.d.ts.map +1 -1
  118. package/dist/internal/MetricSink.js +116 -3
  119. package/dist/internal/MetricSink.js.map +1 -1
  120. package/dist/internal/Schema.js +1 -1
  121. package/dist/internal/Schema.js.map +1 -1
  122. package/dist/internal/Tidx.d.ts +1 -3
  123. package/dist/internal/Tidx.d.ts.map +1 -1
  124. package/dist/internal/Tidx.js +1 -3
  125. package/dist/internal/Tidx.js.map +1 -1
  126. package/dist/internal/Viem.d.ts.map +1 -1
  127. package/dist/internal/Viem.js +2 -1
  128. package/dist/internal/Viem.js.map +1 -1
  129. package/dist/internal/WebhookDestination.d.ts +2 -0
  130. package/dist/internal/WebhookDestination.d.ts.map +1 -1
  131. package/dist/internal/WebhookDestination.js +11 -0
  132. package/dist/internal/WebhookDestination.js.map +1 -1
  133. package/dist/internal/WebhookTransfer.d.ts +55 -0
  134. package/dist/internal/WebhookTransfer.d.ts.map +1 -0
  135. package/dist/internal/WebhookTransfer.js +296 -0
  136. package/dist/internal/WebhookTransfer.js.map +1 -0
  137. package/dist/internal/Webhooks.d.ts +37 -0
  138. package/dist/internal/Webhooks.d.ts.map +1 -1
  139. package/dist/internal/Webhooks.js +87 -13
  140. package/dist/internal/Webhooks.js.map +1 -1
  141. package/dist/internal/funding/Provider.d.ts +10 -6
  142. package/dist/internal/funding/Provider.d.ts.map +1 -1
  143. package/dist/internal/funding/Provider.js +11 -7
  144. package/dist/internal/funding/Provider.js.map +1 -1
  145. package/dist/internal/funding/Reconciliation.d.ts.map +1 -1
  146. package/dist/internal/funding/Reconciliation.js +229 -16
  147. package/dist/internal/funding/Reconciliation.js.map +1 -1
  148. package/dist/internal/funding/providers/relay.d.ts +3 -0
  149. package/dist/internal/funding/providers/relay.d.ts.map +1 -1
  150. package/dist/internal/funding/providers/relay.js +18 -3
  151. package/dist/internal/funding/providers/relay.js.map +1 -1
  152. package/package.json +3 -3
  153. package/src/App.test.ts +32 -0
  154. package/src/App.ts +4 -1
  155. package/src/Client.test-d.ts +116 -38
  156. package/src/Webhooks.bench.ts +714 -0
  157. package/src/Webhooks.memory.bench.ts +250 -0
  158. package/src/Webhooks.test.ts +77 -6
  159. package/src/Webhooks.ts +83 -29
  160. package/src/apps/Zones.test.ts +5 -4
  161. package/src/apps/Zones.ts +9 -8
  162. package/src/apps/composition.test.ts +39 -10
  163. package/src/apps/data/FxOracle.test.ts +18 -0
  164. package/src/apps/data/FxOracle.ts +28 -5
  165. package/src/apps/data/routes/activities.test.ts +19 -0
  166. package/src/apps/data/routes/activities.ts +34 -30
  167. package/src/apps/data/routes/balances.test.ts +161 -10
  168. package/src/apps/data/routes/balances.ts +339 -147
  169. package/src/apps/data/routes/blocks.test.ts +12 -6
  170. package/src/apps/data/routes/earn.test.ts +286 -9
  171. package/src/apps/data/routes/earn.ts +841 -58
  172. package/src/apps/data/routes/exchanges.test.ts +7 -6
  173. package/src/apps/data/routes/exchanges.ts +124 -117
  174. package/src/apps/data/routes/receipts.test.ts +24 -7
  175. package/src/apps/data/routes/receipts.ts +67 -50
  176. package/src/apps/data/routes/tokens.test.ts +51 -0
  177. package/src/apps/data/routes/transactions.test.ts +42 -0
  178. package/src/apps/data/routes/transactions.ts +44 -39
  179. package/src/apps/data/routes/transfers.test.ts +402 -24
  180. package/src/apps/data/routes/transfers.ts +708 -101
  181. package/src/apps/data/routes/valuation.test.ts +26 -0
  182. package/src/apps/data/routes/valuation.ts +6 -2
  183. package/src/apps/data/routes/webhooks.test.ts +414 -130
  184. package/src/apps/data/routes/webhooks.ts +603 -419
  185. package/src/apps/data/routes/zones.ts +6 -1
  186. package/src/apps/funding/App.ts +20 -5
  187. package/src/apps/funding/routes/deposit-addresses.test.ts +43 -22
  188. package/src/apps/funding/routes/deposit-addresses.ts +34 -117
  189. package/src/apps/funding/routes/quotes.test.ts +1 -1
  190. package/src/apps/funding/routes/transfers.test.ts +6 -25
  191. package/src/apps/funding/routes/transfers.ts +5 -5
  192. package/src/apps/mpp/Mpp.test.ts +13 -1
  193. package/src/apps/mpp/Mpp.ts +8 -0
  194. package/src/apps/mpp/internal/Adapter.ts +10 -2
  195. package/src/db/Db.test.ts +40 -6
  196. package/src/db/Db.ts +42 -19
  197. package/src/db/tables/fundingDeposits.test.ts +101 -0
  198. package/src/db/tables/fundingDeposits.ts +71 -2
  199. package/src/db/tables/sponsoredTransactions.ts +9 -2
  200. package/src/db/tables/webhookSubscriptions.ts +116 -4
  201. package/src/internal/Auth.test.ts +28 -0
  202. package/src/internal/Auth.ts +15 -1
  203. package/src/internal/EarnVaults.test.ts +2 -2
  204. package/src/internal/MetricSink.test.ts +518 -0
  205. package/src/internal/MetricSink.ts +266 -9
  206. package/src/internal/Schema.ts +1 -1
  207. package/src/internal/Tidx.test.ts +4 -4
  208. package/src/internal/Tidx.ts +1 -3
  209. package/src/internal/Viem.test.ts +8 -0
  210. package/src/internal/Viem.ts +2 -1
  211. package/src/internal/WebhookDestination.test.ts +20 -0
  212. package/src/internal/WebhookDestination.ts +10 -0
  213. package/src/internal/WebhookTransfer.ts +426 -0
  214. package/src/internal/Webhooks.test.ts +137 -0
  215. package/src/internal/Webhooks.ts +131 -17
  216. package/src/internal/funding/Provider.test.ts +47 -7
  217. package/src/internal/funding/Provider.ts +16 -11
  218. package/src/internal/funding/Reconciliation.local.test.ts +3 -1
  219. package/src/internal/funding/Reconciliation.test.ts +1532 -54
  220. package/src/internal/funding/Reconciliation.ts +296 -53
  221. package/src/internal/funding/SourceObservation.test.ts +3 -2
  222. package/src/internal/funding/providers/relay.test.ts +1 -4
  223. package/src/internal/funding/providers/relay.ts +23 -3
@@ -0,0 +1,714 @@
1
+ /** @module-tag localnet */
2
+ /**
3
+ * Benchmarks the production poller against disposable PostgreSQL and Tempo services.
4
+ * Set `WEBHOOK_BENCH_COUNTS`, `WEBHOOK_BENCH_ITERATIONS`, and
5
+ * `WEBHOOK_BENCH_SCENARIOS` to focus a run.
6
+ */
7
+ import * as http from 'node:http'
8
+
9
+ import { Webhooks } from 'tapimo'
10
+ import { bench } from 'vite-plus/test'
11
+
12
+ import * as Cursor from './internal/Cursor.js'
13
+ import * as Metrics from './Metrics.js'
14
+ import * as TestApp from '../test/App.js'
15
+ import * as Runtime from '../test/runtime.js'
16
+ import * as TestViem from '../test/Viem.js'
17
+
18
+ const concurrency = 8
19
+ const endOfBlockIndex = 2_147_483_647
20
+ const noMatchAddress = '0xffffffffffffffffffffffffffffffffffffffff'
21
+ const runtime = Runtime.get()
22
+ const scenarioNames = [
23
+ 'caught-up',
24
+ 'empty-log',
25
+ 'empty-transaction',
26
+ 'empty-transfer',
27
+ 'matching-block',
28
+ ] as const
29
+ const transactionIterations = 1
30
+ const warmupIterations = 3
31
+
32
+ type DispatchStats = {
33
+ calls: number
34
+ messages: number
35
+ }
36
+
37
+ type Fixture = {
38
+ count: number
39
+ scenarios: Partial<Record<ScenarioName, Scenario>>
40
+ }
41
+
42
+ type Measurement = {
43
+ cursorWriteMs: number
44
+ enrichMs: number
45
+ rpcCalls: number
46
+ rpcHeadMs: number
47
+ rpcMethods: Record<string, number>
48
+ rpcRequestBytes: number
49
+ rpcRequests: number
50
+ rpcResponseBytes: number
51
+ scanMs: number
52
+ subscriptionReadMs: number
53
+ tickMs: number
54
+ unattributedMs: number
55
+ }
56
+
57
+ type RpcStats = {
58
+ calls: number
59
+ methods: Record<string, number>
60
+ requestBytes: number
61
+ requests: number
62
+ responseBytes: number
63
+ }
64
+
65
+ type Scenario = {
66
+ db: ReturnType<typeof TestApp.database>
67
+ dispatchStats: DispatchStats
68
+ head: number
69
+ ids: readonly string[]
70
+ measurements: Measurement[]
71
+ metrics: ReturnType<typeof createMetricProbe>
72
+ nextHead: number
73
+ poller: Webhooks.createPoller.Poller
74
+ rpc: Awaited<ReturnType<typeof createRpcProbe>>
75
+ samples: number
76
+ startBlockNumber: number
77
+ }
78
+
79
+ type ScenarioName = (typeof scenarioNames)[number]
80
+
81
+ const counts = (() => {
82
+ const input = process.env.WEBHOOK_BENCH_COUNTS
83
+ if (!input) return [1, 100, 1_000]
84
+ const values = [...new Set(input.split(',').map(Number))].sort((a, b) => a - b)
85
+ if (values.length === 0 || values.some((value) => !Number.isSafeInteger(value) || value < 1))
86
+ throw new Error('WEBHOOK_BENCH_COUNTS must be a comma-separated list of positive integers.')
87
+ return values
88
+ })()
89
+
90
+ const iterations = (() => {
91
+ const input = process.env.WEBHOOK_BENCH_ITERATIONS
92
+ if (!input) return 10
93
+ const value = Number(input)
94
+ if (!Number.isSafeInteger(value) || value < 1)
95
+ throw new Error('WEBHOOK_BENCH_ITERATIONS must be a positive integer.')
96
+ return value
97
+ })()
98
+
99
+ const selectedScenarios = (() => {
100
+ const input = process.env.WEBHOOK_BENCH_SCENARIOS
101
+ if (!input) return new Set<ScenarioName>(scenarioNames)
102
+ const values = [...new Set(input.split(','))]
103
+ const unknown = values.filter((value) => !scenarioNames.includes(value as ScenarioName))
104
+ if (values.length === 0 || unknown.length > 0)
105
+ throw new Error(
106
+ `WEBHOOK_BENCH_SCENARIOS must contain: ${scenarioNames.join(', ')}. Unknown: ${unknown.join(', ')}.`,
107
+ )
108
+ return new Set(values as ScenarioName[])
109
+ })()
110
+
111
+ const fixtures = new Map<number, Fixture>()
112
+ let rpcProbe: Awaited<ReturnType<typeof createRpcProbe>> | undefined
113
+
114
+ beforeAll(async () => {
115
+ if (runtime.mode !== 'localnet')
116
+ throw new Error('Run webhook benchmarks with TEMPO_ENV=localnet.')
117
+ rpcProbe = await createRpcProbe()
118
+ for (const count of counts) fixtures.set(count, await createFixture(count, rpcProbe))
119
+ }, 180_000)
120
+
121
+ afterAll(async () => {
122
+ reportMeasurements()
123
+ for (const fixture of fixtures.values())
124
+ for (const scenario of Object.values(fixture.scenarios)) await scenario.db.close()
125
+ await rpcProbe?.close()
126
+ })
127
+
128
+ describe('webhook poller', () => {
129
+ for (const count of counts) {
130
+ if (selectedScenarios.has('caught-up'))
131
+ bench(
132
+ `caught-up tick / ${count} subscriptions`,
133
+ async () => {
134
+ const scenario = getScenario(count, 'caught-up')
135
+ resetDispatchStats(scenario)
136
+ resetMeasurement(scenario)
137
+ const startedAt = performance.now()
138
+ const result = await scenario.poller.tick({
139
+ confirmations: 0,
140
+ eventTypes: ['block:created'],
141
+ headBlockNumber: scenario.head,
142
+ maxBlocks: 1,
143
+ })
144
+ recordMeasurement(scenario, performance.now() - startedAt)
145
+ assertDispatch(scenario, { calls: 0, messages: 0 })
146
+ assertResult(result, { count, dispatched: 0, scanned: 0 })
147
+ },
148
+ {
149
+ iterations,
150
+ setup: async () => {
151
+ const scenario = getScenario(count, 'caught-up')
152
+ await resetCursors(scenario, scenario.head)
153
+ },
154
+ time: 0,
155
+ throws: true,
156
+ warmupIterations,
157
+ warmupTime: 0,
158
+ },
159
+ )
160
+
161
+ if (selectedScenarios.has('empty-transfer'))
162
+ bench(
163
+ `empty selective transfer block / ${count} subscriptions`,
164
+ async () => {
165
+ const scenario = getScenario(count, 'empty-transfer')
166
+ resetDispatchStats(scenario)
167
+ resetMeasurement(scenario)
168
+ const startedAt = performance.now()
169
+ const result = await scenario.poller.tick({
170
+ confirmations: 0,
171
+ eventTypes: ['token:transfer'],
172
+ headBlockNumber: nextHead(scenario),
173
+ maxBlocks: 1,
174
+ })
175
+ recordMeasurement(scenario, performance.now() - startedAt)
176
+ assertDispatch(scenario, { calls: 0, messages: 0 })
177
+ assertResult(result, { count, dispatched: 0, scanned: 0 })
178
+ },
179
+ {
180
+ iterations,
181
+ setup: async () => {
182
+ const scenario = getScenario(count, 'empty-transfer')
183
+ await prepareScan(scenario)
184
+ },
185
+ time: 0,
186
+ throws: true,
187
+ warmupIterations,
188
+ warmupTime: 0,
189
+ },
190
+ )
191
+
192
+ if (selectedScenarios.has('empty-log'))
193
+ bench(
194
+ `empty selective log block / ${count} subscriptions`,
195
+ async () => {
196
+ const scenario = getScenario(count, 'empty-log')
197
+ resetDispatchStats(scenario)
198
+ resetMeasurement(scenario)
199
+ const startedAt = performance.now()
200
+ const result = await scenario.poller.tick({
201
+ confirmations: 0,
202
+ eventTypes: ['log:emitted'],
203
+ headBlockNumber: nextHead(scenario),
204
+ maxBlocks: 1,
205
+ })
206
+ recordMeasurement(scenario, performance.now() - startedAt)
207
+ assertDispatch(scenario, { calls: 0, messages: 0 })
208
+ assertResult(result, { count, dispatched: 0, scanned: 0 })
209
+ },
210
+ {
211
+ iterations,
212
+ setup: async () => {
213
+ const scenario = getScenario(count, 'empty-log')
214
+ await prepareScan(scenario)
215
+ },
216
+ time: 0,
217
+ throws: true,
218
+ warmupIterations,
219
+ warmupTime: 0,
220
+ },
221
+ )
222
+
223
+ if (selectedScenarios.has('empty-transaction'))
224
+ bench(
225
+ `empty selective transaction block / ${count} subscriptions`,
226
+ async () => {
227
+ const scenario = getScenario(count, 'empty-transaction')
228
+ resetDispatchStats(scenario)
229
+ resetMeasurement(scenario)
230
+ const startedAt = performance.now()
231
+ const result = await scenario.poller.tick({
232
+ confirmations: 0,
233
+ eventTypes: ['transaction:included'],
234
+ headBlockNumber: nextHead(scenario),
235
+ maxBlocks: 1,
236
+ })
237
+ recordMeasurement(scenario, performance.now() - startedAt)
238
+ assertDispatch(scenario, { calls: 0, messages: 0 })
239
+ assertResult(result, { count, dispatched: 0, scanned: 0 })
240
+ },
241
+ {
242
+ iterations: transactionIterations,
243
+ setup: async () => {
244
+ const scenario = getScenario(count, 'empty-transaction')
245
+ await prepareScan(scenario)
246
+ },
247
+ time: 0,
248
+ throws: true,
249
+ warmupIterations: 0,
250
+ warmupTime: 0,
251
+ },
252
+ )
253
+
254
+ if (selectedScenarios.has('matching-block'))
255
+ bench(
256
+ `matching block fanout / ${count} subscriptions`,
257
+ async () => {
258
+ const scenario = getScenario(count, 'matching-block')
259
+ resetDispatchStats(scenario)
260
+ resetMeasurement(scenario)
261
+ const startedAt = performance.now()
262
+ const result = await scenario.poller.tick({
263
+ confirmations: 0,
264
+ eventTypes: ['block:created'],
265
+ headBlockNumber: nextHead(scenario),
266
+ maxBlocks: 1,
267
+ })
268
+ recordMeasurement(scenario, performance.now() - startedAt)
269
+ assertDispatch(scenario, { calls: count, messages: count })
270
+ assertResult(result, { count, dispatched: count, scanned: count })
271
+ },
272
+ {
273
+ iterations,
274
+ setup: async () => {
275
+ const scenario = getScenario(count, 'matching-block')
276
+ await prepareScan(scenario)
277
+ },
278
+ time: 0,
279
+ throws: true,
280
+ warmupIterations,
281
+ warmupTime: 0,
282
+ },
283
+ )
284
+ }
285
+ })
286
+
287
+ async function createFixture(
288
+ count: number,
289
+ rpc: Awaited<ReturnType<typeof createRpcProbe>>,
290
+ ): Promise<Fixture> {
291
+ const client = TestViem.getClient()
292
+ const liveHead = Number(await client.getBlockNumber())
293
+ const head = Math.max(1, liveHead - 1)
294
+ const startBlockNumber = Math.max(0, head - Math.max(iterations, warmupIterations) - 1)
295
+ const scenarios: Partial<Record<ScenarioName, Scenario>> = {}
296
+ if (selectedScenarios.has('caught-up'))
297
+ scenarios['caught-up'] = await createScenario({
298
+ count,
299
+ cursorBlockNumber: head,
300
+ eventType: 'block:created',
301
+ filters: {},
302
+ head,
303
+ rpc,
304
+ })
305
+ if (selectedScenarios.has('empty-log'))
306
+ scenarios['empty-log'] = await createScenario({
307
+ count,
308
+ cursorBlockNumber: startBlockNumber,
309
+ eventType: 'log:emitted',
310
+ filters: { address: noMatchAddress },
311
+ head,
312
+ rpc,
313
+ })
314
+ if (selectedScenarios.has('empty-transaction'))
315
+ scenarios['empty-transaction'] = await createScenario({
316
+ count,
317
+ cursorBlockNumber: Math.max(0, head - transactionIterations),
318
+ eventType: 'transaction:included',
319
+ filters: { from: noMatchAddress },
320
+ head,
321
+ rpc,
322
+ samples: transactionIterations,
323
+ })
324
+ if (selectedScenarios.has('empty-transfer'))
325
+ scenarios['empty-transfer'] = await createScenario({
326
+ count,
327
+ cursorBlockNumber: startBlockNumber,
328
+ eventType: 'token:transfer',
329
+ filters: { sender: noMatchAddress },
330
+ head,
331
+ rpc,
332
+ })
333
+ if (selectedScenarios.has('matching-block'))
334
+ scenarios['matching-block'] = await createScenario({
335
+ count,
336
+ cursorBlockNumber: startBlockNumber,
337
+ eventType: 'block:created',
338
+ filters: {},
339
+ head,
340
+ rpc,
341
+ })
342
+ return {
343
+ count,
344
+ scenarios,
345
+ }
346
+ }
347
+
348
+ async function createScenario(options: createScenario.Options): Promise<Scenario> {
349
+ const source = TestApp.databaseFactory()
350
+ const db = source()
351
+ const dispatchStats = { calls: 0, messages: 0 }
352
+ const metrics = createMetricProbe()
353
+ const ids = await createSubscriptions({
354
+ count: options.count,
355
+ cursor: Cursor.encode([options.cursorBlockNumber, endOfBlockIndex]),
356
+ db,
357
+ eventType: options.eventType,
358
+ filters: options.filters,
359
+ })
360
+ return {
361
+ db,
362
+ dispatchStats,
363
+ head: options.head,
364
+ ids,
365
+ measurements: [],
366
+ metrics,
367
+ nextHead: options.cursorBlockNumber + 1,
368
+ poller: Webhooks.createPoller({
369
+ db: source,
370
+ defaultChainId: runtime.chainId,
371
+ dispatchBatch: async (dispatchables) => {
372
+ dispatchStats.calls++
373
+ dispatchStats.messages += dispatchables.length
374
+ return { ok: true }
375
+ },
376
+ metrics: metrics.sink,
377
+ rpc: { ...TestApp.rpc, url: options.rpc.url },
378
+ }),
379
+ rpc: options.rpc,
380
+ samples: options.samples ?? iterations,
381
+ startBlockNumber: options.cursorBlockNumber,
382
+ }
383
+ }
384
+
385
+ declare namespace createScenario {
386
+ type Options = {
387
+ count: number
388
+ cursorBlockNumber: number
389
+ eventType: Webhooks.EventType
390
+ filters: Record<string, unknown>
391
+ head: number
392
+ rpc: Awaited<ReturnType<typeof createRpcProbe>>
393
+ samples?: number | undefined
394
+ }
395
+ }
396
+
397
+ async function createSubscriptions(options: createSubscriptions.Options): Promise<string[]> {
398
+ const ids: string[] = []
399
+ for (let offset = 0; offset < options.count; offset += concurrency) {
400
+ const batch = Array.from(
401
+ { length: Math.min(concurrency, options.count - offset) },
402
+ async (_, index) => {
403
+ const subscription = await Webhooks.createSubscription(options.db, {
404
+ chainId: runtime.chainId,
405
+ destination: {
406
+ type: 'url',
407
+ url: `https://hooks.example.com/bench/${options.eventType}/${offset + index}`,
408
+ },
409
+ eventType: options.eventType,
410
+ filters: options.filters,
411
+ owner: { orgId: 'org_benchmark', type: 'api_key' },
412
+ })
413
+ await Webhooks.setCursor(options.db, subscription.id, options.cursor)
414
+ return subscription.id
415
+ },
416
+ )
417
+ ids.push(...(await Promise.all(batch)))
418
+ }
419
+ return ids
420
+ }
421
+
422
+ declare namespace createSubscriptions {
423
+ type Options = {
424
+ count: number
425
+ cursor: string
426
+ db: ReturnType<typeof TestApp.database>
427
+ eventType: Webhooks.EventType
428
+ filters: Record<string, unknown>
429
+ }
430
+ }
431
+
432
+ const pollStages = ['cursor_write', 'enrich', 'rpc_head', 'scan', 'subscription_read'] as const
433
+
434
+ type PollStage = (typeof pollStages)[number]
435
+
436
+ function createMetricProbe() {
437
+ let stages = emptyStages()
438
+ return {
439
+ reset() {
440
+ stages = emptyStages()
441
+ },
442
+ sink: Metrics.from({
443
+ count() {},
444
+ flush() {},
445
+ gauge() {},
446
+ histogram(name, value, tags) {
447
+ const stage = tags?.['stage']
448
+ if (name !== 'webhook_poller_stage_duration_ms' || !isPollStage(stage)) return
449
+ stages[stage] += value
450
+ },
451
+ }),
452
+ snapshot() {
453
+ return { ...stages }
454
+ },
455
+ }
456
+ }
457
+
458
+ function emptyStages(): Record<PollStage, number> {
459
+ return {
460
+ cursor_write: 0,
461
+ enrich: 0,
462
+ rpc_head: 0,
463
+ scan: 0,
464
+ subscription_read: 0,
465
+ }
466
+ }
467
+
468
+ function isPollStage(value: unknown): value is PollStage {
469
+ return typeof value === 'string' && pollStages.includes(value as PollStage)
470
+ }
471
+
472
+ async function createRpcProbe() {
473
+ const target = runtime.rpcUrl
474
+ if (!target) throw new Error('Localnet runtime is missing its RPC URL.')
475
+
476
+ let stats = emptyRpcStats()
477
+ const server = http.createServer((request, response) => {
478
+ void forwardRpc({ request, response, stats, target }).catch((error) => {
479
+ response.writeHead(502, { 'content-type': 'text/plain' })
480
+ response.end(error instanceof Error ? error.message : 'RPC probe failed.')
481
+ })
482
+ })
483
+ await new Promise<void>((resolve, reject) => {
484
+ const onError = (error: Error) => reject(error)
485
+ server.once('error', onError)
486
+ server.listen(0, '127.0.0.1', () => {
487
+ server.off('error', onError)
488
+ resolve()
489
+ })
490
+ })
491
+ const address = server.address()
492
+ if (!address || typeof address === 'string') throw new Error('RPC probe did not bind TCP.')
493
+
494
+ return {
495
+ close: () =>
496
+ new Promise<void>((resolve, reject) => {
497
+ server.close((error) => (error ? reject(error) : resolve()))
498
+ }),
499
+ reset() {
500
+ stats = emptyRpcStats()
501
+ },
502
+ snapshot(): RpcStats {
503
+ return { ...stats, methods: { ...stats.methods } }
504
+ },
505
+ url: `http://127.0.0.1:${address.port}`,
506
+ }
507
+ }
508
+
509
+ async function forwardRpc(options: {
510
+ request: http.IncomingMessage
511
+ response: http.ServerResponse
512
+ stats: RpcStats
513
+ target: string
514
+ }): Promise<void> {
515
+ const { request, response, stats, target } = options
516
+ const chunks: Uint8Array[] = []
517
+ for await (const chunk of request)
518
+ chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk)
519
+ const body = Buffer.concat(chunks)
520
+ stats.requestBytes += body.byteLength
521
+ stats.requests++
522
+ countRpcCalls(stats, body)
523
+
524
+ const headers = new Headers()
525
+ for (const name of ['authorization', 'content-type']) {
526
+ const value = request.headers[name]
527
+ if (typeof value === 'string') headers.set(name, value)
528
+ }
529
+ const upstream = await fetch(target, {
530
+ body,
531
+ headers,
532
+ method: request.method ?? 'POST',
533
+ })
534
+ const result = new Uint8Array(await upstream.arrayBuffer())
535
+ stats.responseBytes += result.byteLength
536
+ response.writeHead(upstream.status, {
537
+ 'content-type': upstream.headers.get('content-type') ?? 'application/json',
538
+ })
539
+ response.end(result)
540
+ }
541
+
542
+ function emptyRpcStats(): RpcStats {
543
+ return { calls: 0, methods: {}, requestBytes: 0, requests: 0, responseBytes: 0 }
544
+ }
545
+
546
+ function countRpcCalls(stats: RpcStats, body: Uint8Array): void {
547
+ try {
548
+ const parsed = JSON.parse(Buffer.from(body).toString('utf8')) as unknown
549
+ for (const value of Array.isArray(parsed) ? parsed : [parsed]) {
550
+ if (!value || typeof value !== 'object') continue
551
+ const method = (value as Record<string, unknown>)['method']
552
+ if (typeof method !== 'string') continue
553
+ stats.calls++
554
+ stats.methods[method] = (stats.methods[method] ?? 0) + 1
555
+ }
556
+ } catch {}
557
+ }
558
+
559
+ function resetMeasurement(scenario: Scenario): void {
560
+ scenario.metrics.reset()
561
+ scenario.rpc.reset()
562
+ }
563
+
564
+ function recordMeasurement(scenario: Scenario, tickMs: number): void {
565
+ const rpc = scenario.rpc.snapshot()
566
+ const stages = scenario.metrics.snapshot()
567
+ const attributedMs = Object.values(stages).reduce((sum, value) => sum + value, 0)
568
+ scenario.measurements.push({
569
+ cursorWriteMs: stages.cursor_write,
570
+ enrichMs: stages.enrich,
571
+ rpcCalls: rpc.calls,
572
+ rpcHeadMs: stages.rpc_head,
573
+ rpcMethods: rpc.methods,
574
+ rpcRequestBytes: rpc.requestBytes,
575
+ rpcRequests: rpc.requests,
576
+ rpcResponseBytes: rpc.responseBytes,
577
+ scanMs: stages.scan,
578
+ subscriptionReadMs: stages.subscription_read,
579
+ tickMs,
580
+ unattributedMs: Math.max(0, tickMs - attributedMs),
581
+ })
582
+ }
583
+
584
+ function reportMeasurements(): void {
585
+ const summaries: Record<string, unknown>[] = []
586
+ for (const fixture of fixtures.values()) {
587
+ const scenarios = [
588
+ { label: 'caught-up tick', scenario: fixture.scenarios['caught-up'] },
589
+ { label: 'empty selective log block', scenario: fixture.scenarios['empty-log'] },
590
+ {
591
+ label: 'empty selective transaction block',
592
+ scenario: fixture.scenarios['empty-transaction'],
593
+ },
594
+ { label: 'empty selective transfer block', scenario: fixture.scenarios['empty-transfer'] },
595
+ { label: 'matching block fanout', scenario: fixture.scenarios['matching-block'] },
596
+ ]
597
+ for (const { label, scenario } of scenarios) {
598
+ if (!scenario) continue
599
+ const samples = scenario.measurements.slice(-scenario.samples)
600
+ summaries.push({
601
+ count: fixture.count,
602
+ cursorWriteMs: distribution(samples.map((sample) => sample.cursorWriteMs)),
603
+ enrichMs: distribution(samples.map((sample) => sample.enrichMs)),
604
+ label,
605
+ rpcCalls: distribution(samples.map((sample) => sample.rpcCalls)),
606
+ rpcHeadMs: distribution(samples.map((sample) => sample.rpcHeadMs)),
607
+ rpcMethods: summarizeMethods(samples),
608
+ rpcRequestBytes: distribution(samples.map((sample) => sample.rpcRequestBytes)),
609
+ rpcRequests: distribution(samples.map((sample) => sample.rpcRequests)),
610
+ rpcResponseBytes: distribution(samples.map((sample) => sample.rpcResponseBytes)),
611
+ scanMs: distribution(samples.map((sample) => sample.scanMs)),
612
+ subscriptionReadMs: distribution(samples.map((sample) => sample.subscriptionReadMs)),
613
+ tickMs: distribution(samples.map((sample) => sample.tickMs)),
614
+ unattributedMs: distribution(samples.map((sample) => sample.unattributedMs)),
615
+ })
616
+ }
617
+ }
618
+ console.log(`webhook benchmark instrumentation\n${JSON.stringify(summaries, null, 2)}`)
619
+ }
620
+
621
+ function summarizeMethods(samples: readonly Measurement[]): Record<string, number> {
622
+ const methods: Record<string, number> = {}
623
+ for (const sample of samples)
624
+ for (const [method, count] of Object.entries(sample.rpcMethods))
625
+ methods[method] = (methods[method] ?? 0) + count
626
+ for (const method of Object.keys(methods))
627
+ methods[method] = round(methods[method]! / samples.length)
628
+ return methods
629
+ }
630
+
631
+ function distribution(values: readonly number[]) {
632
+ const sorted = [...values].sort((a, b) => a - b)
633
+ const mean = sorted.reduce((sum, value) => sum + value, 0) / sorted.length
634
+ const percentile = (value: number) =>
635
+ sorted[Math.max(0, Math.ceil(sorted.length * value) - 1)] ?? 0
636
+ return {
637
+ mean: round(mean),
638
+ p50: round(percentile(0.5)),
639
+ p95: round(percentile(0.95)),
640
+ }
641
+ }
642
+
643
+ function round(value: number): number {
644
+ return Math.round(value * 100) / 100
645
+ }
646
+
647
+ async function resetCursors(scenario: Scenario, blockNumber: number): Promise<void> {
648
+ const cursor = Cursor.encode([blockNumber, endOfBlockIndex])
649
+ for (let offset = 0; offset < scenario.ids.length; offset += concurrency)
650
+ await Promise.all(
651
+ scenario.ids
652
+ .slice(offset, offset + concurrency)
653
+ .map((subscriptionId) => Webhooks.setCursor(scenario.db, subscriptionId, cursor)),
654
+ )
655
+ }
656
+
657
+ async function prepareScan(scenario: Scenario): Promise<void> {
658
+ scenario.nextHead = scenario.startBlockNumber + 1
659
+ await resetCursors(scenario, scenario.startBlockNumber)
660
+ }
661
+
662
+ function nextHead(scenario: Scenario): number {
663
+ if (scenario.nextHead > scenario.head)
664
+ throw new Error(`Benchmark exhausted its block window at ${scenario.head}.`)
665
+ return scenario.nextHead++
666
+ }
667
+
668
+ function getFixture(count: number): Fixture {
669
+ const fixture = fixtures.get(count)
670
+ if (!fixture) throw new Error(`Missing benchmark fixture for ${count} subscriptions.`)
671
+ return fixture
672
+ }
673
+
674
+ function getScenario(count: number, name: ScenarioName): Scenario {
675
+ const scenario = getFixture(count).scenarios[name]
676
+ if (!scenario) throw new Error(`Missing ${name} benchmark fixture for ${count} subscriptions.`)
677
+ return scenario
678
+ }
679
+
680
+ function assertDispatch(scenario: Scenario, expected: DispatchStats): void {
681
+ if (
682
+ scenario.dispatchStats.calls !== expected.calls ||
683
+ scenario.dispatchStats.messages !== expected.messages
684
+ )
685
+ throw new Error(
686
+ `Unexpected dispatches: ${JSON.stringify({
687
+ actual: scenario.dispatchStats,
688
+ expected,
689
+ })}`,
690
+ )
691
+ }
692
+
693
+ function assertResult(
694
+ result: Webhooks.createPoller.Result,
695
+ expected: { count: number; dispatched: number; scanned: number },
696
+ ): void {
697
+ if (
698
+ result.dispatched !== expected.dispatched ||
699
+ result.failed !== 0 ||
700
+ result.scanned !== expected.scanned ||
701
+ result.subscriptions !== expected.count
702
+ )
703
+ throw new Error(
704
+ `Unexpected poll result: ${JSON.stringify({
705
+ expected,
706
+ result,
707
+ })}`,
708
+ )
709
+ }
710
+
711
+ function resetDispatchStats(scenario: Scenario): void {
712
+ scenario.dispatchStats.calls = 0
713
+ scenario.dispatchStats.messages = 0
714
+ }