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,426 @@
1
+ import { encodeEventTopics, parseAbiItem, type RpcLog } from 'viem'
2
+ import * as z from 'zod/mini'
3
+
4
+ import * as Transfers from '../apps/data/routes/transfers.js'
5
+ import * as Schema from './Schema.js'
6
+ import * as Value from './Value.js'
7
+ import type * as Viem from './Viem.js'
8
+
9
+ /** Subscriptions matched per shared transfer scan. */
10
+ export const scanCohortSize = 32
11
+
12
+ /**
13
+ * Blocks per transfer `eth_getLogs` request. Firehose scans stop after a
14
+ * 100-row page, so small windows bound the raw RPC body retained before that cap.
15
+ */
16
+ const scanChunkBlocks = 8
17
+
18
+ const event = parseAbiItem(Transfers.signature)
19
+ const topic = encodeEventTopics({ abi: [event] })[0]
20
+
21
+ type Filters = z.output<typeof Transfers.schema.Predicates>
22
+
23
+ type EventData = {
24
+ address: string
25
+ amount: string
26
+ blockNumber: number
27
+ logIndex: number
28
+ recipient: string
29
+ sender: string
30
+ timestamp: string
31
+ transactionHash: string
32
+ }
33
+
34
+ type Candidate = {
35
+ index: number
36
+ key: string
37
+ state: scan.State & { window: scan.Window }
38
+ }
39
+
40
+ type Cohort = {
41
+ key: string
42
+ states: Candidate[]
43
+ window: { from: number; to: number }
44
+ }
45
+
46
+ type Accumulator = {
47
+ full: boolean
48
+ items: scan.Item[]
49
+ state: Candidate['state']
50
+ }
51
+
52
+ /** Returns a stable key for transfer filters with equivalent RPC pushdowns. */
53
+ function scanKey(filters_input: Record<string, unknown>): string {
54
+ const filters = filters_input as Filters
55
+ return JSON.stringify(
56
+ normalizeRpcValue({
57
+ args: logArgs(filters),
58
+ token: filters.token,
59
+ }),
60
+ )
61
+ }
62
+
63
+ /** Scans transfer subscriptions through shared, memory-bounded RPC cohorts. */
64
+ export async function scan(options: scan.Options): Promise<PromiseSettledResult<scan.Page>[]> {
65
+ const results: PromiseSettledResult<scan.Page>[] = []
66
+ const candidates: Candidate[] = []
67
+ for (let index = 0; index < options.states.length; index++) {
68
+ const state = options.states[index]!
69
+ if (!state.window) {
70
+ results[index] = { status: 'fulfilled', value: { items: [] } }
71
+ continue
72
+ }
73
+ candidates.push({
74
+ index,
75
+ key: scanKey(state.filters),
76
+ state: { ...state, window: state.window },
77
+ })
78
+ }
79
+
80
+ candidates.sort(
81
+ (a, b) =>
82
+ a.key.localeCompare(b.key) ||
83
+ a.state.window.from - b.state.window.from ||
84
+ a.state.window.to - b.state.window.to,
85
+ )
86
+ const cohorts: Cohort[] = []
87
+ for (const candidate of candidates) {
88
+ const previous = cohorts.at(-1)
89
+ const combinedBlocks = previous
90
+ ? Math.max(previous.window.to, candidate.state.window.to) - previous.window.from + 1
91
+ : 0
92
+ if (
93
+ !previous ||
94
+ candidate.key !== previous.key ||
95
+ candidate.state.window.from > previous.window.to ||
96
+ combinedBlocks > options.maxBlocks ||
97
+ previous.states.length >= scanCohortSize
98
+ ) {
99
+ cohorts.push({
100
+ key: candidate.key,
101
+ states: [candidate],
102
+ window: { from: candidate.state.window.from, to: candidate.state.window.to },
103
+ })
104
+ continue
105
+ }
106
+ previous.states.push(candidate)
107
+ previous.window.to = Math.max(previous.window.to, candidate.state.window.to)
108
+ }
109
+
110
+ const settled = await mapWithLimit(cohorts, options.concurrency, (cohort) =>
111
+ options.schedule(async () => ({
112
+ cohort,
113
+ pages: await scanCohort({ client: options.client, cohort }),
114
+ })),
115
+ )
116
+ for (const entry of settled) {
117
+ if (entry.status === 'rejected') continue
118
+ for (let index = 0; index < entry.value.cohort.states.length; index++)
119
+ results[entry.value.cohort.states[index]!.index] = {
120
+ status: 'fulfilled',
121
+ value: entry.value.pages[index]!,
122
+ }
123
+ }
124
+ for (let index = 0; index < settled.length; index++) {
125
+ const entry = settled[index]!
126
+ if (entry.status !== 'rejected') continue
127
+ for (const candidate of cohorts[index]!.states)
128
+ results[candidate.index] = { reason: entry.reason, status: 'rejected' }
129
+ }
130
+ return results
131
+ }
132
+
133
+ export declare namespace scan {
134
+ /** One transfer match paired with its keyset position. */
135
+ type Item = {
136
+ /** Transfer position as `[blockNumber, logIndex]`. */
137
+ cursor: readonly [number, number]
138
+ /** Decoded webhook transfer payload. */
139
+ data: unknown
140
+ }
141
+
142
+ /** One bounded page returned to the poller. */
143
+ type Page = {
144
+ /** Whether another matching page remains. */
145
+ hasMore?: boolean | undefined
146
+ /** Transfer matches in ascending chain order. */
147
+ items: readonly Item[]
148
+ /** Highest block fully scanned when no matched cursor must be retained. */
149
+ scannedThrough?: number | undefined
150
+ }
151
+
152
+ /** One subscription's inputs for a shared transfer scan. */
153
+ type State = {
154
+ /** Validated transfer subscription filters. */
155
+ filters: Record<string, unknown>
156
+ /** Maximum matches retained for this fair scan page. */
157
+ limit: number
158
+ /** Bounded chain window, or undefined when the cursor is caught up. */
159
+ window?: Window | undefined
160
+ }
161
+
162
+ /** One subscription's chain range and already-dispatched position. */
163
+ type Window = {
164
+ /** First block included in the upstream scan. */
165
+ from: number
166
+ /** Last dispatched `[blockNumber, logIndex]` position. */
167
+ skip: readonly [number, number]
168
+ /** Last block included in the upstream scan. */
169
+ to: number
170
+ }
171
+
172
+ /** Inputs for scanning transfer subscriptions. */
173
+ type Options = {
174
+ /** Maximum concurrent cohort RPC scans. */
175
+ concurrency: number
176
+ /** Viem client for raw chain RPC requests. */
177
+ client: Viem.getClient.ReturnType
178
+ /** Maximum combined block range for one cohort. */
179
+ maxBlocks: number
180
+ /** Shared scheduler that bounds upstream scans across event types. */
181
+ schedule: <value>(fn: () => Promise<value>) => Promise<value>
182
+ /** Subscription states aligned with the returned results. */
183
+ states: readonly State[]
184
+ }
185
+ }
186
+
187
+ /** Runs ordered async work with bounded concurrency. */
188
+ async function mapWithLimit<item, value>(
189
+ items: readonly item[],
190
+ limit: number,
191
+ fn: (item: item) => Promise<value>,
192
+ ): Promise<PromiseSettledResult<value>[]> {
193
+ const results: PromiseSettledResult<value>[] = []
194
+ let next = 0
195
+ await Promise.all(
196
+ Array.from({ length: Math.min(limit, items.length) }, async () => {
197
+ while (next < items.length) {
198
+ const index = next++
199
+ try {
200
+ results[index] = { status: 'fulfilled', value: await fn(items[index]!) }
201
+ } catch (reason) {
202
+ results[index] = { reason, status: 'rejected' }
203
+ }
204
+ }
205
+ }),
206
+ )
207
+ return results
208
+ }
209
+
210
+ /** Recursively canonicalizes an RPC filter value for query grouping. */
211
+ function normalizeRpcValue(value: unknown): unknown {
212
+ if (typeof value === 'bigint') return `bigint:${value}`
213
+ if (Array.isArray(value)) return value.map(normalizeRpcValue)
214
+ if (value && typeof value === 'object')
215
+ return Object.fromEntries(
216
+ Object.entries(value)
217
+ .filter(([, item]) => item !== undefined)
218
+ .sort(([a], [b]) => a.localeCompare(b))
219
+ .map(([name, item]) => [name, normalizeRpcValue(item)]),
220
+ )
221
+ return value
222
+ }
223
+
224
+ /** Returns indexed argument combinations pushed into Transfer log reads. */
225
+ function logArgs(filters: Filters) {
226
+ const args = {
227
+ ...(filters.sender ? { from: filters.sender } : {}),
228
+ ...(filters.recipient ? { to: filters.recipient } : {}),
229
+ }
230
+ return filters.address && !filters.sender && !filters.recipient
231
+ ? [{ from: filters.address }, { to: filters.address }]
232
+ : [args]
233
+ }
234
+
235
+ /** Encodes one indexed address as its left-padded event topic. */
236
+ function addressTopic(address: string): `0x${string}` {
237
+ return `0x${'0'.repeat(24)}${address.slice(2).toLowerCase()}`
238
+ }
239
+
240
+ /** Builds the exact Transfer topic filter for one indexed-argument combination. */
241
+ function topics(args: { from?: string | undefined; to?: string | undefined }) {
242
+ const values: (`0x${string}` | null)[] = [
243
+ topic,
244
+ args.from ? addressTopic(args.from) : null,
245
+ args.to ? addressTopic(args.to) : null,
246
+ ]
247
+ while (values.at(-1) === null) values.pop()
248
+ return values
249
+ }
250
+
251
+ /** Renders a block number as a JSON-RPC block tag. */
252
+ function blockTag(value: number): `0x${string}` {
253
+ return `0x${value.toString(16)}`
254
+ }
255
+
256
+ /** Issues an untyped JSON-RPC request through the Viem client. */
257
+ function rpc<result>(
258
+ client: Viem.getClient.ReturnType,
259
+ method: string,
260
+ params: readonly unknown[],
261
+ ): Promise<result> {
262
+ return (client.request as (args: { method: string; params: unknown[] }) => Promise<result>)({
263
+ method,
264
+ params: [...params],
265
+ })
266
+ }
267
+
268
+ /** Reads raw Transfer logs, splitting the either-side shortcut into two RPC filters. */
269
+ async function getLogs(
270
+ client: Viem.getClient.ReturnType,
271
+ filters: Filters,
272
+ window: { from: number; to: number },
273
+ ): Promise<RpcLog[]> {
274
+ const pages = await Promise.all(
275
+ logArgs(filters).map((args) =>
276
+ rpc<RpcLog[]>(client, 'eth_getLogs', [
277
+ {
278
+ ...(filters.token ? { address: filters.token } : {}),
279
+ fromBlock: blockTag(window.from),
280
+ topics: topics(args),
281
+ toBlock: blockTag(window.to),
282
+ },
283
+ ]),
284
+ ),
285
+ )
286
+ const logs = new Map<string, RpcLog>()
287
+ for (const page of pages)
288
+ for (const log of page)
289
+ logs.set(`${log.blockNumber}:${log.logIndex}:${log.transactionHash}`, log)
290
+ return [...logs.values()]
291
+ }
292
+
293
+ /** Sorts raw logs into ascending `(blockNumber, logIndex)` order. */
294
+ function byPosition(a: RpcLog, b: RpcLog): number {
295
+ const block = (Value.hexToNumber(a.blockNumber) ?? 0) - (Value.hexToNumber(b.blockNumber) ?? 0)
296
+ return block || (Value.hexToNumber(a.logIndex) ?? 0) - (Value.hexToNumber(b.logIndex) ?? 0)
297
+ }
298
+
299
+ /** True when an address is in Tempo's deterministic TIP-20 contract range. */
300
+ function isTip20Address(address: string): boolean {
301
+ const value = address.toLowerCase()
302
+ return value >= Transfers.tip20AddressRange[0] && value < Transfers.tip20AddressRange[1]
303
+ }
304
+
305
+ /** Decodes one strict TIP-20 Transfer wire payload. */
306
+ function decode(log: RpcLog, timestamp: string): EventData | undefined {
307
+ if (!isTip20Address(log.address)) return undefined
308
+ if (
309
+ log.topics.length !== 3 ||
310
+ log.topics[0]?.toLowerCase() !== topic.toLowerCase() ||
311
+ !/^0x0{24}[0-9a-fA-F]{40}$/.test(log.topics[1] ?? '') ||
312
+ !/^0x0{24}[0-9a-fA-F]{40}$/.test(log.topics[2] ?? '') ||
313
+ !/^0x[0-9a-fA-F]{64}$/.test(log.data)
314
+ )
315
+ return undefined
316
+ const address = Schema.TokenAddress.safeParse(log.address)
317
+ const recipient = Schema.Address.safeParse(`0x${log.topics[2]!.slice(-40)}`)
318
+ const sender = Schema.Address.safeParse(`0x${log.topics[1]!.slice(-40)}`)
319
+ const transactionHash = Schema.Hash.safeParse(log.transactionHash)
320
+ const amount = Value.fromHex(log.data)
321
+ const blockNumber = Value.hexToNumber(log.blockNumber)
322
+ const logIndex = Value.hexToNumber(log.logIndex)
323
+ if (
324
+ !address.success ||
325
+ !recipient.success ||
326
+ !sender.success ||
327
+ !transactionHash.success ||
328
+ amount === undefined ||
329
+ blockNumber === undefined ||
330
+ logIndex === undefined
331
+ )
332
+ return undefined
333
+ return {
334
+ address: address.data,
335
+ amount,
336
+ blockNumber,
337
+ logIndex,
338
+ recipient: recipient.data,
339
+ sender: sender.data,
340
+ timestamp,
341
+ transactionHash: transactionHash.data,
342
+ }
343
+ }
344
+
345
+ /** Re-checks every transfer filter after RPC topic pushdown. */
346
+ function matches(data: EventData, filters: Filters): boolean {
347
+ if (filters.address && data.sender !== filters.address && data.recipient !== filters.address)
348
+ return false
349
+ if (filters.recipient && data.recipient !== filters.recipient) return false
350
+ if (filters.sender && data.sender !== filters.sender) return false
351
+ if (filters.token && data.address !== filters.token) return false
352
+ return true
353
+ }
354
+
355
+ /** Adds a matched row until one subscription reaches its fair-page limit. */
356
+ function addMatch(accumulator: Accumulator, item: scan.Item): void {
357
+ if (accumulator.full) return
358
+ accumulator.items.push(item)
359
+ accumulator.full = accumulator.items.length >= accumulator.state.limit
360
+ }
361
+
362
+ /** Checks whether a canonical row remains eligible for one subscription. */
363
+ function canMatch(accumulator: Accumulator, blockNumber: number, logIndex: number): boolean {
364
+ const { skip, to } = accumulator.state.window
365
+ return (
366
+ !accumulator.full &&
367
+ blockNumber <= to &&
368
+ (blockNumber > skip[0] || (blockNumber === skip[0] && logIndex > skip[1]))
369
+ )
370
+ }
371
+
372
+ /** Converts match buffers into cursor-aware poller pages. */
373
+ function finish(accumulators: readonly Accumulator[]): scan.Page[] {
374
+ return accumulators.map((accumulator) => {
375
+ if (accumulator.full) return { hasMore: true, items: accumulator.items }
376
+ if (accumulator.items.length > 0) return { items: accumulator.items }
377
+ return { items: accumulator.items, scannedThrough: accumulator.state.window.to }
378
+ })
379
+ }
380
+
381
+ /** Scans one canonical transfer stream and fans rows into matching subscriptions. */
382
+ async function scanCohort(options: {
383
+ client: Viem.getClient.ReturnType
384
+ cohort: Cohort
385
+ }): Promise<scan.Page[]> {
386
+ const { client, cohort } = options
387
+ const accumulators = cohort.states.map<Accumulator>(({ state }) => ({
388
+ full: false,
389
+ items: [],
390
+ state,
391
+ }))
392
+ const timestamps = new Map<number, Promise<string>>()
393
+ const timestamp = (blockNumber: number) => {
394
+ const existing = timestamps.get(blockNumber)
395
+ if (existing) return existing
396
+ const pending = client
397
+ .getBlock({ blockNumber: BigInt(blockNumber), includeTransactions: false })
398
+ .then((block) => new Date(Number(block.timestamp) * 1_000).toISOString())
399
+ timestamps.set(blockNumber, pending)
400
+ return pending
401
+ }
402
+ const filters_source = cohort.states[0]!.state.filters as Filters
403
+ // RPC pushdowns bound selective responses; only the unfiltered firehose needs small range chunks.
404
+ const chunkBlocks = Object.values(filters_source).some(Boolean)
405
+ ? cohort.window.to - cohort.window.from + 1
406
+ : scanChunkBlocks
407
+
408
+ for (let from = cohort.window.from; from <= cohort.window.to; from += chunkBlocks) {
409
+ const to = Math.min(cohort.window.to, from + chunkBlocks - 1)
410
+ const logs = (await getLogs(client, filters_source, { from, to })).sort(byPosition)
411
+ for (const log of logs) {
412
+ const blockNumber = Value.hexToNumber(log.blockNumber)
413
+ const logIndex = Value.hexToNumber(log.logIndex)
414
+ if (blockNumber === undefined || logIndex === undefined) continue
415
+ const data = decode(log, await timestamp(blockNumber))
416
+ if (!data) continue
417
+ for (const accumulator of accumulators) {
418
+ if (!canMatch(accumulator, blockNumber, logIndex)) continue
419
+ if (matches(data, accumulator.state.filters as Filters))
420
+ addMatch(accumulator, { cursor: [blockNumber, logIndex], data })
421
+ }
422
+ if (accumulators.every((accumulator) => accumulator.full)) return finish(accumulators)
423
+ }
424
+ }
425
+ return finish(accumulators)
426
+ }
@@ -1,5 +1,6 @@
1
1
  import { Webhooks } from 'tapimo'
2
2
  import * as TestApp from '../../test/App.js'
3
+ import * as Metrics from '../Metrics.js'
3
4
  import * as Cursor from './Cursor.js'
4
5
 
5
6
  const apiKeyOwner = { orgId: 'org_1', type: 'api_key' } as const
@@ -327,6 +328,78 @@ describe('listActive', () => {
327
328
  })
328
329
  })
329
330
 
331
+ describe('listActiveWithCursors', () => {
332
+ test('pairs each subscription with its stored cursor from one read', async () => {
333
+ const db = TestApp.database()
334
+ const anchored = await Webhooks.createSubscription(db, createInput())
335
+ const fresh = await Webhooks.createSubscription(db, createInput({ owner: mppOwner }))
336
+ await Webhooks.setCursor(db, anchored.id, Cursor.encode([100, 5]))
337
+
338
+ const pairs = await Webhooks.listActiveWithCursors(db, 4217, 'token:transfer')
339
+ const byId = new Map(pairs.map((pair) => [pair.subscription.id, pair.cursor]))
340
+ expect(Cursor.decode(byId.get(anchored.id)!, ['int', 'int'])).toEqual([100, 5])
341
+ expect(byId.get(fresh.id)).toBeNull()
342
+ })
343
+ })
344
+
345
+ describe('listActiveForChain', () => {
346
+ test('returns active subscriptions across event types in one read', async () => {
347
+ const db = TestApp.database()
348
+ const transfer = await Webhooks.createSubscription(db, createInput())
349
+ const transaction = await Webhooks.createSubscription(
350
+ db,
351
+ createInput({ eventType: 'transaction:included' }),
352
+ )
353
+ await Webhooks.createSubscription(
354
+ db,
355
+ createInput({ eventType: 'block:created', owner: mppOwner }),
356
+ )
357
+ await Webhooks.createSubscription(db, createInput({ chainId: 42431, owner: mppOwner }))
358
+ await Webhooks.updateSubscription(db, apiKeyOwner, transfer.id, { status: 'paused' })
359
+
360
+ const pairs = await Webhooks.listActiveForChain(db, {
361
+ chainId: 4217,
362
+ eventTypes: ['transaction:included'],
363
+ })
364
+ expect(pairs.map(({ subscription }) => subscription.id)).toEqual([transaction.id])
365
+ })
366
+ })
367
+
368
+ describe('maxCursorBlock', () => {
369
+ test('returns the freshest cursor block across event types', async () => {
370
+ const db = TestApp.database()
371
+ const transfer = await Webhooks.createSubscription(db, createInput())
372
+ const tx = await Webhooks.createSubscription(
373
+ db,
374
+ createInput({ eventType: 'transaction:included' }),
375
+ )
376
+ await Webhooks.setCursor(db, transfer.id, Cursor.encode([100, 5]))
377
+ await Webhooks.setCursor(db, tx.id, Cursor.encode([250, 0]))
378
+
379
+ expect(await Webhooks.maxCursorBlock(db, 4217)).toBe(250)
380
+ })
381
+
382
+ test('counts creation checkpoints at their recorded head', async () => {
383
+ const db = TestApp.database()
384
+ await Webhooks.createSubscription(db, createInput(), { startBlockNumber: 900 })
385
+
386
+ expect(await Webhooks.maxCursorBlock(db, 4217)).toBe(900)
387
+ })
388
+
389
+ test('ignores other chains and returns null without cursors', async () => {
390
+ const db = TestApp.database()
391
+ expect(await Webhooks.maxCursorBlock(db, 4217)).toBeNull()
392
+
393
+ const other = await Webhooks.createSubscription(db, createInput({ chainId: 42431 }))
394
+ await Webhooks.setCursor(db, other.id, Cursor.encode([999, 0]))
395
+ // A cursorless subscription on the queried chain still reads as null.
396
+ await Webhooks.createSubscription(db, createInput())
397
+
398
+ expect(await Webhooks.maxCursorBlock(db, 4217)).toBeNull()
399
+ expect(await Webhooks.maxCursorBlock(db, 42431)).toBe(999)
400
+ })
401
+ })
402
+
330
403
  describe('cursors', () => {
331
404
  test('round-trips a per-subscription cursor', async () => {
332
405
  const db = TestApp.database()
@@ -362,6 +435,32 @@ describe('cursors', () => {
362
435
  })
363
436
  })
364
437
 
438
+ describe('compareAndSetCursors', () => {
439
+ test('atomically advances a batch and reports stale rows', async () => {
440
+ const db = TestApp.database()
441
+ const initialized = await Webhooks.createSubscription(db, createInput())
442
+ const stale = await Webhooks.createSubscription(db, createInput())
443
+ const uninitialized = await Webhooks.createSubscription(db, createInput())
444
+ const initial = Cursor.encode([100, 1])
445
+ const next = Cursor.encode([101, 1])
446
+ await Webhooks.setCursor(db, initialized.id, initial)
447
+ await Webhooks.setCursor(db, stale.id, initial)
448
+
449
+ const committed = await Webhooks.compareAndSetCursors(db, {
450
+ cursors: [
451
+ { cursor: next, expectedCursor: initial, subscriptionId: initialized.id },
452
+ { cursor: next, expectedCursor: null, subscriptionId: stale.id },
453
+ { cursor: next, expectedCursor: null, subscriptionId: uninitialized.id },
454
+ ],
455
+ })
456
+
457
+ expect([...committed].toSorted()).toEqual([initialized.id, uninitialized.id].toSorted())
458
+ expect(await Webhooks.getCursor(db, initialized.id)).toBe(next)
459
+ expect(await Webhooks.getCursor(db, stale.id)).toBe(initial)
460
+ expect(await Webhooks.getCursor(db, uninitialized.id)).toBe(next)
461
+ })
462
+ })
463
+
365
464
  describe('recordSuccess / recordFailure', () => {
366
465
  test('recordSuccess resets failures and stamps lastDeliveryAt', async () => {
367
466
  const db = TestApp.database()
@@ -624,6 +723,44 @@ describe('delivery log', () => {
624
723
  `)
625
724
  })
626
725
 
726
+ test('deliverAndRecord partitions event, enqueue, and queue latency', async () => {
727
+ const db = TestApp.database()
728
+ const created = await Webhooks.createSubscription(db, createInput())
729
+ const histograms = new Map<string, number>()
730
+ const metrics = Metrics.from({
731
+ count() {},
732
+ flush() {},
733
+ gauge() {},
734
+ histogram(name, value) {
735
+ histograms.set(name, value)
736
+ },
737
+ })
738
+ const envelope = {
739
+ chainId: 4217,
740
+ createdAt: '1970-01-01T00:00:00.500Z',
741
+ data: { timestamp: '1970-01-01T00:00:00.000Z' },
742
+ id: 'evt_null',
743
+ subscriptionId: created.id,
744
+ type: 'token:transfer',
745
+ } satisfies Webhooks.Envelope
746
+ const fetch = (async () =>
747
+ new Response(null, { status: 200 })) satisfies typeof globalThis.fetch
748
+
749
+ await expect(
750
+ Webhooks.deliverAndRecord(db, created, envelope, {
751
+ dequeuedAt: 1_250,
752
+ fetch,
753
+ metrics,
754
+ now: fixedNow,
755
+ queueAttempt: 2,
756
+ queuedAt: 1_000,
757
+ }),
758
+ ).resolves.toMatchObject({ ok: true })
759
+ expect(histograms.get('webhook_delivery_envelope_to_queue_ms')).toBe(500)
760
+ expect(histograms.get('webhook_delivery_event_to_envelope_ms')).toBe(500)
761
+ expect(histograms.get('webhook_delivery_queue_wait_ms')).toBe(250)
762
+ })
763
+
627
764
  test('deliverAndRecord appends a failed row with the error and status', async () => {
628
765
  const db = TestApp.database()
629
766
  const created = await Webhooks.createSubscription(db, createInput())