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,250 @@
1
+ /** @module-tag localnet */
2
+ /**
3
+ * Measures retained database-factory memory and dense transfer-scan allocations.
4
+ * Set `WEBHOOK_MEMORY_SNAPSHOT_DIR` to write V8 heap snapshots for retained objects.
5
+ */
6
+ import * as fs from 'node:fs'
7
+ import * as path from 'node:path'
8
+ import * as v8 from 'node:v8'
9
+ import { nanoid } from 'nanoid'
10
+ import { sendTransactionSync } from 'viem/actions'
11
+ import { Actions, Addresses } from 'viem/tempo'
12
+ import { Webhooks } from 'tapimo'
13
+ import { bench } from 'vite-plus/test'
14
+
15
+ import * as Db from './db/Db.js'
16
+ import * as Cursor from './internal/Cursor.js'
17
+ import * as Runtime from '../test/runtime.js'
18
+ import * as TestApp from '../test/App.js'
19
+ import * as Tempo from '../test/Tempo.js'
20
+ import * as TestViem from '../test/Viem.js'
21
+
22
+ const batchSize = 200
23
+ const endOfBlockIndex = 2_147_483_647
24
+ const factoryTicks = positiveInteger('WEBHOOK_MEMORY_FACTORY_TICKS', 25)
25
+ const mebibyte = 1024 * 1024
26
+ const runtime = Runtime.get()
27
+ const transferCallsPerTransaction = 32
28
+ const transferCount = positiveInteger('WEBHOOK_MEMORY_TRANSFER_COUNT', 3_600)
29
+
30
+ type Memory = {
31
+ arrayBuffers: number
32
+ external: number
33
+ heapUsed: number
34
+ rss: number
35
+ }
36
+
37
+ type Report = {
38
+ after: Memory
39
+ afterOperation: Memory
40
+ afterOperationDelta: Memory
41
+ before: Memory
42
+ delta: Memory
43
+ details?: Record<string, number> | undefined
44
+ label: string
45
+ }
46
+
47
+ const reports: Report[] = []
48
+
49
+ beforeAll(() => {
50
+ if (runtime.mode !== 'localnet')
51
+ throw new Error('Run webhook memory benchmarks with TEMPO_ENV=localnet.')
52
+ if (!globalThis.gc)
53
+ throw new Error('Run webhook memory benchmarks with NODE_OPTIONS=--expose-gc.')
54
+ })
55
+
56
+ afterAll(() => {
57
+ process.stdout.write(`WEBHOOK_MEMORY_REPORT ${JSON.stringify(reports)}\n`)
58
+ })
59
+
60
+ describe('webhook poller memory', () => {
61
+ bench(
62
+ `factory-created databases / ${factoryTicks} ticks`,
63
+ async () => {
64
+ reports.push(await measureFactoryRetention())
65
+ },
66
+ { iterations: 1, time: 0, warmupIterations: 0, warmupTime: 0 },
67
+ )
68
+
69
+ bench(
70
+ `transfer firehose / ${transferCount} generated transfers`,
71
+ async () => {
72
+ reports.push(await measureTransferFirehose())
73
+ },
74
+ { iterations: 1, time: 0, warmupIterations: 0, warmupTime: 0 },
75
+ )
76
+ })
77
+
78
+ async function measureFactoryRetention(): Promise<Report> {
79
+ const databases: Db.Db[] = []
80
+ const schema = `memory_${nanoid()}`
81
+ const database = Db.postgres({ connectionString: Runtime.postgresUrl, schema })
82
+ await database.migrate()
83
+ await Webhooks.createSubscription(database, {
84
+ chainId: runtime.chainId,
85
+ destination: { type: 'url', url: 'https://hooks.example.com/bench/memory/factory' },
86
+ eventType: 'block:created',
87
+ owner: { orgId: 'org_benchmark', type: 'api_key' },
88
+ })
89
+ const headBlockNumber = Number(await TestViem.getClient().getBlockNumber())
90
+ const poller = Webhooks.createPoller({
91
+ db: () => {
92
+ const database = Db.postgres({ connectionString: Runtime.postgresUrl, schema })
93
+ databases.push(database)
94
+ return database
95
+ },
96
+ defaultChainId: runtime.chainId,
97
+ dispatchBatch: async () => ({ ok: true }),
98
+ rpc: TestApp.rpc,
99
+ })
100
+
101
+ try {
102
+ const report = await measure('factory-retained', async () => {
103
+ for (let index = 0; index < factoryTicks; index++)
104
+ await poller.tick({
105
+ confirmations: 0,
106
+ eventTypes: ['block:created'],
107
+ headBlockNumber,
108
+ maxBlocks: 1,
109
+ })
110
+ })
111
+ writeHeapSnapshot('factory-retained')
112
+ return { ...report, details: { databases: databases.length, ticks: factoryTicks } }
113
+ } finally {
114
+ await Promise.allSettled(databases.map((database) => database.close()))
115
+ await database.close()
116
+ }
117
+ }
118
+
119
+ async function measureTransferFirehose(): Promise<Report> {
120
+ const database = TestApp.database()
121
+ const client = Tempo.getClient({ account: Tempo.accounts[0] })
122
+ const startBlockNumber = Number(await TestViem.getClient().getBlockNumber())
123
+
124
+ try {
125
+ for (let offset = 0; offset < transferCount; offset += transferCallsPerTransaction) {
126
+ const length = Math.min(transferCallsPerTransaction, transferCount - offset)
127
+ await sendTransactionSync(client, {
128
+ account: Tempo.accounts[0],
129
+ calls: Array.from({ length }, () =>
130
+ Actions.token.transfer.call(client, {
131
+ amount: 1n,
132
+ to: Tempo.accounts[1].address,
133
+ token: Addresses.pathUsd,
134
+ }),
135
+ ),
136
+ })
137
+ }
138
+
139
+ const headBlockNumber = Number(await TestViem.getClient().getBlockNumber())
140
+ const subscription = await Webhooks.createSubscription(database, {
141
+ chainId: runtime.chainId,
142
+ destination: { type: 'url', url: 'https://hooks.example.com/bench/memory/firehose' },
143
+ eventType: 'token:transfer',
144
+ filters: {},
145
+ owner: { orgId: 'org_benchmark', type: 'api_key' },
146
+ })
147
+ await Webhooks.setCursor(
148
+ database,
149
+ subscription.id,
150
+ Cursor.encode([startBlockNumber, endOfBlockIndex]),
151
+ )
152
+
153
+ let dispatched = 0
154
+ let serializedBytes = 0
155
+ const poller = Webhooks.createPoller({
156
+ db: database,
157
+ defaultChainId: runtime.chainId,
158
+ dispatchBatch: async (dispatchables) => {
159
+ dispatched += dispatchables.length
160
+ for (const body of dispatchables)
161
+ serializedBytes += new TextEncoder().encode(JSON.stringify({ body })).byteLength
162
+ return { ok: true }
163
+ },
164
+ rpc: TestApp.rpc,
165
+ })
166
+
167
+ const report = await measure('transfer-firehose', async () => {
168
+ const result = await poller.tick({
169
+ batchSize,
170
+ confirmations: 0,
171
+ eventTypes: ['token:transfer'],
172
+ headBlockNumber,
173
+ maxBlocks: 500,
174
+ })
175
+ if (result.dispatched === 0 || result.dispatched !== dispatched)
176
+ throw new Error(`Unexpected dispatch count: ${result.dispatched} / ${dispatched}.`)
177
+ if (result.dispatched > batchSize)
178
+ throw new Error(`Dispatch count ${result.dispatched} exceeded batch size ${batchSize}.`)
179
+ if (serializedBytes === 0) throw new Error('Expected serialized webhook queue messages.')
180
+ })
181
+ writeHeapSnapshot('transfer-firehose-retained')
182
+ return {
183
+ ...report,
184
+ details: { dispatched, serializedBytes, transferCount },
185
+ }
186
+ } finally {
187
+ await database.close()
188
+ }
189
+ }
190
+
191
+ async function measure(label: string, fn: () => Promise<void>): Promise<Report> {
192
+ collectGarbage()
193
+ const before = memoryUsage()
194
+ await fn()
195
+ const afterOperation = memoryUsage()
196
+ collectGarbage()
197
+ const after = memoryUsage()
198
+ return {
199
+ after,
200
+ afterOperation,
201
+ afterOperationDelta: subtract(afterOperation, before),
202
+ before,
203
+ delta: subtract(after, before),
204
+ label,
205
+ }
206
+ }
207
+
208
+ function collectGarbage(): void {
209
+ globalThis.gc?.()
210
+ globalThis.gc?.()
211
+ }
212
+
213
+ function memoryUsage(): Memory {
214
+ const usage = process.memoryUsage()
215
+ return {
216
+ arrayBuffers: mebibytes(usage.arrayBuffers),
217
+ external: mebibytes(usage.external),
218
+ heapUsed: mebibytes(usage.heapUsed),
219
+ rss: mebibytes(usage.rss),
220
+ }
221
+ }
222
+
223
+ function mebibytes(bytes: number): number {
224
+ return Math.round((bytes / mebibyte) * 100) / 100
225
+ }
226
+
227
+ function positiveInteger(name: string, fallback: number): number {
228
+ const input = process.env[name]
229
+ if (!input) return fallback
230
+ const value = Number(input)
231
+ if (!Number.isSafeInteger(value) || value < 1)
232
+ throw new Error(`${name} must be a positive integer.`)
233
+ return value
234
+ }
235
+
236
+ function subtract(after: Memory, before: Memory): Memory {
237
+ return {
238
+ arrayBuffers: after.arrayBuffers - before.arrayBuffers,
239
+ external: after.external - before.external,
240
+ heapUsed: after.heapUsed - before.heapUsed,
241
+ rss: after.rss - before.rss,
242
+ }
243
+ }
244
+
245
+ function writeHeapSnapshot(label: string): void {
246
+ const directory = process.env.WEBHOOK_MEMORY_SNAPSHOT_DIR
247
+ if (!directory) return
248
+ fs.mkdirSync(directory, { recursive: true })
249
+ v8.writeHeapSnapshot(path.join(directory, `${label}.heapsnapshot`))
250
+ }
@@ -1,4 +1,4 @@
1
- import { Webhooks } from 'tapimo'
1
+ import { Metrics, Webhooks } from 'tapimo'
2
2
  import * as TestApp from '../test/App.js'
3
3
 
4
4
  describe('createPoller', () => {
@@ -25,9 +25,8 @@ describe('createPoller', () => {
25
25
 
26
26
  // A brand-new subscription anchors at the confirmed head on its first tick
27
27
  // (no backfill), so this exercises the createPoller → poll wiring (db
28
- // binding, default chain id, injected head, result shape) without touching
29
- // TIDX. Detailed poll/getIndexedHead behavior lives in
30
- // src/apps/webhooks.test.ts.
28
+ // binding, default chain id, injected head, and result shape) without an
29
+ // upstream request. Detailed scanner behavior lives in the route test.
31
30
  const result = await poller.tick({ headBlockNumber: 200 })
32
31
 
33
32
  expect(result).toMatchInlineSnapshot(`
@@ -35,19 +34,70 @@ describe('createPoller', () => {
35
34
  "chainId": 42431,
36
35
  "dispatched": 0,
37
36
  "failed": 0,
38
- "safeBlock": 198,
37
+ "safeBlock": 199,
39
38
  "scanned": 0,
40
39
  "subscriptions": 1,
41
40
  }
42
41
  `)
43
42
  expect(decodeCursor(await Webhooks.getCursor(db, subscription.id))).toMatchInlineSnapshot(`
44
43
  [
45
- 198,
44
+ 199,
46
45
  2147483647,
47
46
  ]
48
47
  `)
49
48
  })
50
49
 
50
+ test('records active subscriptions before scanning', async () => {
51
+ const db = TestApp.database()
52
+ await Webhooks.createSubscription(db, {
53
+ chainId: 42431,
54
+ destination: { type: 'url', url: 'https://hooks.example.com/x' },
55
+ eventType: 'block:created',
56
+ owner: { orgId: 'org_test', type: 'api_key' },
57
+ })
58
+ const gauges: { name: string; tags: Metrics.Tags; value: number }[] = []
59
+ const poller = Webhooks.createPoller({
60
+ db,
61
+ defaultChainId: 42431,
62
+ metrics: Metrics.from({
63
+ count() {},
64
+ flush() {},
65
+ gauge(name, value, tags = {}) {
66
+ gauges.push({ name, tags, value })
67
+ },
68
+ histogram() {},
69
+ }),
70
+ })
71
+
72
+ await poller.tick({ eventTypes: ['block:created'], headBlockNumber: 200 })
73
+
74
+ expect(gauges).toMatchInlineSnapshot(`
75
+ [
76
+ {
77
+ "name": "webhook_poller_active_subscription_count",
78
+ "tags": {
79
+ "chain_id": "42431",
80
+ },
81
+ "value": 1,
82
+ },
83
+ {
84
+ "name": "webhook_subscription_cursor_lag_blocks",
85
+ "tags": {
86
+ "chain_id": "42431",
87
+ },
88
+ "value": 0,
89
+ },
90
+ {
91
+ "name": "webhook_poller_safe_block",
92
+ "tags": {
93
+ "chain_id": "42431",
94
+ },
95
+ "value": 199,
96
+ },
97
+ ]
98
+ `)
99
+ })
100
+
51
101
  test('clamps a creation checkpoint to genesis', async () => {
52
102
  const db = TestApp.database()
53
103
  const subscription = await Webhooks.createSubscription(
@@ -72,4 +122,25 @@ describe('createPoller', () => {
72
122
 
73
123
  expect(decodeCursor(await Webhooks.getCursor(db, subscription.id))).toEqual([0, 2_147_483_647])
74
124
  })
125
+
126
+ test('keeps a factory-created database open after each tick', async () => {
127
+ const db = TestApp.database()
128
+ await Webhooks.createSubscription(db, {
129
+ chainId: 42431,
130
+ destination: { type: 'url', url: 'https://hooks.example.com/x' },
131
+ eventType: 'block:created',
132
+ owner: { orgId: 'org_test', type: 'api_key' },
133
+ })
134
+ const poller = Webhooks.createPoller({
135
+ db: () => db,
136
+ defaultChainId: 42431,
137
+ })
138
+
139
+ await poller.tick({
140
+ eventTypes: ['block:created'],
141
+ headBlockNumber: 1,
142
+ })
143
+
144
+ await expect(Webhooks.maxCursorBlock(db, 42431)).resolves.toBe(0)
145
+ })
75
146
  })
package/src/Webhooks.ts CHANGED
@@ -1,11 +1,14 @@
1
1
  import * as AppWebhooks from './apps/data/routes/webhooks.js'
2
2
  import * as Db from './db/Db.js'
3
+ import * as core_Webhooks from './internal/Webhooks.js'
3
4
  import * as MetricSink from './internal/MetricSink.js'
4
5
  import * as Store from './internal/Store.js'
5
6
  import type * as Metrics from './Metrics.js'
6
- import * as Tidx from './internal/Tidx.js'
7
7
  import * as Viem from './internal/Viem.js'
8
8
 
9
+ /** How often a poller reclaims expired rows, independent of tick cadence. */
10
+ const pruneIntervalMs = 60_000
11
+
9
12
  // Re-export the runtime-agnostic primitives (subscriptions, signing, delivery,
10
13
  // `deliverAndRecord`, cursors, …) so consumers reach everything through one
11
14
  // `Webhooks` namespace. The apps-layer poller stays private behind
@@ -19,33 +22,29 @@ export * from './internal/Webhooks.js'
19
22
  export { eventTypes, schema } from './apps/data/routes/webhooks.js'
20
23
 
21
24
  /**
22
- * Builds a scheduled webhook poller bound to a state store and chain config.
25
+ * Builds a webhook poller bound to a state store and chain config.
23
26
  *
24
- * This is the entry point a host runtime drives on a timer (a Cloudflare
25
- * `scheduled` cron handler, a self-host `setInterval`, …). Each {@link
26
- * Poller.tick} scans active subscriptions from their cursors up to `head -
27
- * confirmations` and dispatches matching events.
27
+ * This is the entry point a host runtime drives from chain-head notifications
28
+ * or a timer. Each {@link Poller.tick} scans active subscriptions from their
29
+ * cursors up to `head - confirmations` and dispatches matching events.
28
30
  *
29
- * The monitoring event types (`log:emitted`, `block:created`,
30
- * `transaction:included`) scan a confirmed window directly over JSON-RPC, so the
31
- * poller builds memoized viem clients (via {@link Viem.createGetClient}); the legacy
32
- * `token:transfer` type still reads from TIDX, so it
33
- * also builds memoized TIDX clients (via {@link Tidx.createGetClient}) when a
34
- * `tidx` config is supplied. The poller keeps `App.create` a pure Hono app: it
35
- * constructs its own clients instead of reaching into app internals, and owns no
36
- * request state.
31
+ * Every onchain event type scans a confirmed window directly over JSON-RPC, so
32
+ * the poller builds memoized viem clients via {@link Viem.createGetClient}. The
33
+ * poller keeps `App.create` a pure Hono app: it constructs its own clients
34
+ * instead of reaching into app internals, and owns no request state.
37
35
  */
38
36
  export function createPoller(options: createPoller.Options): createPoller.Poller {
39
37
  // Persistent memoize cache for token metadata, shared across ticks (the
40
38
  // webhook state store used to double-duty as this cache).
41
- const cache = Store.memory()
42
- const getTidx = options.tidx
43
- ? Tidx.createGetClient({ defaultChainId: options.defaultChainId, tidx: options.tidx })
44
- : undefined
39
+ const cache = options.cache ?? Store.memory()
45
40
  const getClient = Viem.createGetClient({
46
41
  defaultChainId: options.defaultChainId,
47
42
  rpc: options.rpc,
48
43
  })
44
+ // Chain-head ticks run about once per block, so the janitor is throttled here
45
+ // rather than issuing deletes per block. Reads filter expiry at query time, so
46
+ // reclaiming late only costs space.
47
+ let prunedAt: number | undefined
49
48
 
50
49
  const metrics = options.metrics ? MetricSink.webhooks(options.metrics) : undefined
51
50
 
@@ -58,6 +57,8 @@ export function createPoller(options: createPoller.Options): createPoller.Poller
58
57
  // returns the underlying result unchanged and is a no-op when no metrics
59
58
  // backend is configured.
60
59
  const inner = input.dispatch ?? options.dispatch
60
+ const innerBatch =
61
+ input.dispatchBatch ?? (input.dispatch === undefined ? options.dispatchBatch : undefined)
61
62
  const dispatch =
62
63
  metrics && inner
63
64
  ? async (dispatchable: AppWebhooks.poll.Dispatchable) => {
@@ -73,8 +74,35 @@ export function createPoller(options: createPoller.Options): createPoller.Poller
73
74
  return result
74
75
  }
75
76
  : inner
77
+ const dispatchBatch =
78
+ metrics && innerBatch
79
+ ? async (dispatchables: readonly AppWebhooks.poll.Dispatchable[]) => {
80
+ const start = Date.now()
81
+ const result = await innerBatch(dispatchables)
82
+ const durationMs = Date.now() - start
83
+ for (const dispatchable of dispatchables)
84
+ metrics.record({
85
+ chainId: dispatchable.envelope.chainId,
86
+ durationMs,
87
+ eventType: dispatchable.envelope.type,
88
+ ok: result.ok,
89
+ type: 'webhook:enqueue',
90
+ })
91
+ return result
92
+ }
93
+ : innerBatch
94
+
95
+ // Resolve per tick so Workers factories build a fresh Hyperdrive-pooled
96
+ // connection each run.
97
+ const db = Db.get(options.db)
98
+ const now = input.now ?? options.now
99
+ if (prunedAt === undefined || Date.now() - prunedAt >= pruneIntervalMs) {
100
+ prunedAt = Date.now()
101
+ await core_Webhooks.pruneExpired(db, { now }).catch(() => {})
102
+ }
76
103
 
77
104
  const start = Date.now()
105
+ let cursorLagBlocks: number | undefined
78
106
  try {
79
107
  const result = await AppWebhooks.poll({
80
108
  batchSize: input.batchSize ?? options.batchSize,
@@ -82,19 +110,37 @@ export function createPoller(options: createPoller.Options): createPoller.Poller
82
110
  chainId,
83
111
  client: getClient(chainId),
84
112
  confirmations: input.confirmations ?? options.confirmations,
85
- // Resolve per tick so Workers factories build a fresh
86
- // Hyperdrive-pooled connection each run.
87
- db: Db.get(options.db),
113
+ db,
88
114
  dispatch,
115
+ dispatchBatch,
89
116
  eventTypes: input.eventTypes ?? options.eventTypes,
90
117
  ...(input.headBlockNumber === undefined
91
118
  ? {}
92
119
  : { headBlockNumber: input.headBlockNumber }),
93
120
  maxBlocks: input.maxBlocks ?? options.maxBlocks,
94
- now: input.now ?? options.now,
95
- ...(getTidx ? { tidx: getTidx(chainId) } : {}),
121
+ now,
122
+ signal: input.signal,
123
+ ...(metrics
124
+ ? {
125
+ onCursorLag(blocks: number) {
126
+ cursorLagBlocks = blocks
127
+ },
128
+ onScanCheckpoint(checkpoint: AppWebhooks.poll.ScanCheckpoint) {
129
+ metrics.record({ chainId, type: 'webhook:poll-scan', ...checkpoint })
130
+ // Ship the start boundary before the RPC allocation can terminate the isolate.
131
+ metrics.flush()
132
+ },
133
+ onStage(stage: AppWebhooks.poll.Stage) {
134
+ metrics.record({ chainId, type: 'webhook:poll', ...stage })
135
+ },
136
+ onSubscriptions(count: number) {
137
+ metrics.record({ chainId, count, type: 'webhook:poll-subscriptions' })
138
+ },
139
+ }
140
+ : {}),
96
141
  })
97
142
  metrics?.record({
143
+ cursorLagBlocks,
98
144
  meta: { durationMs: Date.now() - start, outcome: 'ok' },
99
145
  result,
100
146
  type: 'webhook:tick',
@@ -102,6 +148,7 @@ export function createPoller(options: createPoller.Options): createPoller.Poller
102
148
  return result
103
149
  } catch (error) {
104
150
  metrics?.record({
151
+ cursorLagBlocks,
105
152
  meta: { chainId, durationMs: Date.now() - start, outcome: 'error' },
106
153
  result: undefined,
107
154
  type: 'webhook:tick',
@@ -121,6 +168,9 @@ export declare namespace createPoller {
121
168
  /** Delivery transport: hands a matched event to a queue or delivers inline. */
122
169
  type Dispatch = NonNullable<AppWebhooks.poll.Options['dispatch']>
123
170
 
171
+ /** Batch delivery transport: confirms all rows together or leaves their cursor unchanged. */
172
+ type DispatchBatch = NonNullable<AppWebhooks.poll.Options['dispatchBatch']>
173
+
124
174
  /** Summary counters returned by {@link Poller.tick}. */
125
175
  type Result = AppWebhooks.poll.Result
126
176
 
@@ -128,13 +178,15 @@ export declare namespace createPoller {
128
178
  type Defaults = {
129
179
  /** Max matching rows scanned per subscription per tick (default 200). */
130
180
  batchSize?: number | undefined
131
- /** Reorg-safety window: only emit blocks this far behind head (default 2). */
181
+ /** Reorg-safety window: only emit blocks this far behind head (default 1). */
132
182
  confirmations?: number | undefined
133
183
  /**
134
184
  * Delivery transport. Defaults to inline `deliverAndRecord` (self-host); on
135
185
  * Cloudflare pass a Queue producer so retry/backoff/DLQ are native.
136
186
  */
137
187
  dispatch?: Dispatch | undefined
188
+ /** Batch delivery transport used when the runtime can enqueue multiple rows atomically. */
189
+ dispatchBatch?: DispatchBatch | undefined
138
190
  /** Event types to poll (default all). */
139
191
  eventTypes?: AppWebhooks.poll.Options['eventTypes']
140
192
  /** Backfill cap: most blocks an RPC scanner inspects per tick (default 1000). */
@@ -145,16 +197,16 @@ export declare namespace createPoller {
145
197
 
146
198
  /** Options for {@link createPoller}. */
147
199
  type Options = Defaults & {
200
+ /** Cache for token metadata shared across ticks. */
201
+ cache?: Store.Store | undefined
148
202
  /** Database holding subscriptions + cursors, or a factory resolved per tick (Workers/Hyperdrive). */
149
203
  db: Db.Source
150
204
  /** Tempo chain id polled when a tick omits one. */
151
205
  defaultChainId?: Viem.ChainId | undefined
152
206
  /** Metrics backend; emits `webhook:tick` per tick and `webhook:enqueue` per dispatch. */
153
207
  metrics?: Metrics.Metrics | undefined
154
- /** RPC options for the viem clients the monitoring scanners read from. */
208
+ /** RPC options for the viem clients that scan chain events. */
155
209
  rpc?: Viem.getClient.Rpc | undefined
156
- /** TIDX query client options for the legacy `token:transfer` type. */
157
- tidx?: Tidx.getClient.Tidx | undefined
158
210
  }
159
211
 
160
212
  /** Per-tick overrides; falls back to the {@link Options} defaults. */
@@ -163,11 +215,13 @@ export declare namespace createPoller {
163
215
  chainId?: Viem.ChainId | undefined
164
216
  /** Chain head to work back from; resolved from RPC when omitted. */
165
217
  headBlockNumber?: bigint | number | undefined
218
+ /** Stops scanning and dispatch when the caller loses its execution lease. */
219
+ signal?: AbortSignal | undefined
166
220
  }
167
221
 
168
- /** A scheduled webhook poller. */
222
+ /** A chain-head-driven webhook poller. */
169
223
  type Poller = {
170
- /** Runs one poll tick, resolving the indexed head when not supplied. */
224
+ /** Runs one poll tick, resolving the RPC head when not supplied. */
171
225
  tick(options?: TickOptions): Promise<Result>
172
226
  }
173
227
  }
@@ -5,13 +5,14 @@ describe('chains', () => {
5
5
  expect(Zones.chains).toMatchObject([
6
6
  {
7
7
  contracts: {
8
- messenger: { address: '0x254356112cCf6f32fAd84F16CC5E0A0cCA17Beb7' },
9
- portal: { address: '0x59831A17340EE14FE136d751EfbeA8b630470fD2' },
8
+ messenger: { address: '0xc9C88278eA934A03CE8c90793F0B903e1C63B735' },
9
+ portal: { address: '0xd8fDA2e3f421F272C56752cBCf5Ae0782c8B9ed2' },
10
10
  },
11
11
  id: 421_700_001,
12
12
  rpcUrls: {
13
- default: { http: ['https://rpc-zone-e.testnet.tempo.xyz'] },
14
- internal: { http: ['https://rpc-zone-e.testnet.tempo.xyz'] },
13
+ default: { http: ['https://zone-internal-rpc.testnet.tempo.xyz'] },
14
+ internal: { http: ['https://zone-internal-rpc.testnet.tempo.xyz'] },
15
+ tidx: { http: ['https://indexer-zone-internal.testnet.tempo.xyz'] },
15
16
  },
16
17
  sourceId: 42_431,
17
18
  },
package/src/apps/Zones.ts CHANGED
@@ -1,19 +1,20 @@
1
1
  import { defineChain } from 'viem'
2
2
  import { tempoDevnet, tempoTestnet } from 'viem/tempo/chains'
3
3
 
4
- /** Zone E on Tempo testnet. */
5
- export const zoneE = defineChain({
4
+ /** Moderato Zone on Tempo testnet. */
5
+ export const zoneModerato = defineChain({
6
6
  ...tempoTestnet,
7
7
  contracts: {
8
8
  ...tempoTestnet.contracts,
9
- messenger: { address: '0x254356112cCf6f32fAd84F16CC5E0A0cCA17Beb7' },
10
- portal: { address: '0x59831A17340EE14FE136d751EfbeA8b630470fD2' },
9
+ messenger: { address: '0xc9C88278eA934A03CE8c90793F0B903e1C63B735' },
10
+ portal: { address: '0xd8fDA2e3f421F272C56752cBCf5Ae0782c8B9ed2' },
11
11
  },
12
12
  id: 421_700_001,
13
- name: 'zone-e',
13
+ name: 'zone-moderato',
14
14
  rpcUrls: {
15
- default: { http: ['https://rpc-zone-e.testnet.tempo.xyz'] },
16
- internal: { http: ['https://rpc-zone-e.testnet.tempo.xyz'] },
15
+ default: { http: ['https://zone-internal-rpc.testnet.tempo.xyz'] },
16
+ internal: { http: ['https://zone-internal-rpc.testnet.tempo.xyz'] },
17
+ tidx: { http: ['https://indexer-zone-internal.testnet.tempo.xyz'] },
17
18
  },
18
19
  sourceId: tempoTestnet.id,
19
20
  })
@@ -37,4 +38,4 @@ export const zoneStable = defineChain({
37
38
  })
38
39
 
39
40
  /** Hosted Zone chains served by the API and admin. */
40
- export const chains = [zoneE, zoneStable] as const
41
+ export const chains = [zoneModerato, zoneStable] as const