tapimo 0.10.0 → 0.10.1

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 (224) hide show
  1. package/dist/ApiKey.d.ts +2 -1
  2. package/dist/ApiKey.d.ts.map +1 -1
  3. package/dist/ApiKeys.d.ts +4 -2
  4. package/dist/ApiKeys.d.ts.map +1 -1
  5. package/dist/ApiKeys.js +26 -1
  6. package/dist/ApiKeys.js.map +1 -1
  7. package/dist/App.d.ts +3346 -2575
  8. package/dist/App.d.ts.map +1 -1
  9. package/dist/App.js +1 -0
  10. package/dist/App.js.map +1 -1
  11. package/dist/Webhooks.d.ts.map +1 -1
  12. package/dist/Webhooks.js +5 -5
  13. package/dist/Webhooks.js.map +1 -1
  14. package/dist/admin/App.d.ts +91 -84
  15. package/dist/admin/App.d.ts.map +1 -1
  16. package/dist/admin/apps/api-keys.d.ts +26 -17
  17. package/dist/admin/apps/api-keys.d.ts.map +1 -1
  18. package/dist/admin/apps/api-keys.js +6 -0
  19. package/dist/admin/apps/api-keys.js.map +1 -1
  20. package/dist/admin/apps/organizations.d.ts +4 -2
  21. package/dist/admin/apps/organizations.d.ts.map +1 -1
  22. package/dist/admin/ui.generated.d.ts +1 -1
  23. package/dist/admin/ui.generated.js +69 -69
  24. package/dist/admin/ui.generated.js.map +1 -1
  25. package/dist/analytics/Analytics.d.ts.map +1 -1
  26. package/dist/analytics/Analytics.js +26 -0
  27. package/dist/analytics/Analytics.js.map +1 -1
  28. package/dist/apps/Zones.d.ts +4266 -82
  29. package/dist/apps/Zones.d.ts.map +1 -1
  30. package/dist/apps/Zones.js +27 -8
  31. package/dist/apps/Zones.js.map +1 -1
  32. package/dist/apps/data/App.d.ts +3467 -2696
  33. package/dist/apps/data/App.d.ts.map +1 -1
  34. package/dist/apps/data/routes/activities.d.ts +2999 -100
  35. package/dist/apps/data/routes/activities.d.ts.map +1 -1
  36. package/dist/apps/data/routes/activities.js +340 -2
  37. package/dist/apps/data/routes/activities.js.map +1 -1
  38. package/dist/apps/data/routes/addresses.d.ts +850 -336
  39. package/dist/apps/data/routes/addresses.d.ts.map +1 -1
  40. package/dist/apps/data/routes/balances.d.ts +26 -26
  41. package/dist/apps/data/routes/balances.d.ts.map +1 -1
  42. package/dist/apps/data/routes/balances.js +11 -3
  43. package/dist/apps/data/routes/balances.js.map +1 -1
  44. package/dist/apps/data/routes/blocks.d.ts +4 -4
  45. package/dist/apps/data/routes/coingecko.d.ts +176 -176
  46. package/dist/apps/data/routes/earn.d.ts +75 -75
  47. package/dist/apps/data/routes/earn.js +2 -2
  48. package/dist/apps/data/routes/earn.js.map +1 -1
  49. package/dist/apps/data/routes/exchanges.d.ts +120 -120
  50. package/dist/apps/data/routes/fee-amm.d.ts +24 -24
  51. package/dist/apps/data/routes/indexer.d.ts +35 -34
  52. package/dist/apps/data/routes/indexer.d.ts.map +1 -1
  53. package/dist/apps/data/routes/indexer.js +3 -2
  54. package/dist/apps/data/routes/indexer.js.map +1 -1
  55. package/dist/apps/data/routes/rpc.d.ts.map +1 -1
  56. package/dist/apps/data/routes/rpc.js +5 -1
  57. package/dist/apps/data/routes/rpc.js.map +1 -1
  58. package/dist/apps/data/routes/tokenlist.d.ts +12 -12
  59. package/dist/apps/data/routes/transactions.d.ts +281 -281
  60. package/dist/apps/data/routes/verified-tokens.d.ts +37 -37
  61. package/dist/apps/data/routes/webhooks.d.ts +418 -364
  62. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  63. package/dist/apps/data/routes/webhooks.js +161 -14
  64. package/dist/apps/data/routes/webhooks.js.map +1 -1
  65. package/dist/apps/data/routes/zones.d.ts +31 -31
  66. package/dist/apps/data/routes/zones.js +1 -1
  67. package/dist/apps/data/routes/zones.js.map +1 -1
  68. package/dist/apps/funding/App.d.ts +96 -96
  69. package/dist/apps/funding/routes/deposit-addresses.d.ts +52 -52
  70. package/dist/apps/funding/routes/deposit-addresses.js +1 -1
  71. package/dist/apps/funding/routes/deposit-addresses.js.map +1 -1
  72. package/dist/apps/funding/routes/providers.d.ts +13 -13
  73. package/dist/apps/funding/routes/transfers.d.ts +32 -32
  74. package/dist/apps/funding/routes/transfers.js +1 -1
  75. package/dist/apps/funding/routes/transfers.js.map +1 -1
  76. package/dist/apps/management/App.d.ts +418 -418
  77. package/dist/apps/management/routes/api-keys.d.ts +50 -50
  78. package/dist/apps/management/routes/billing.d.ts +72 -72
  79. package/dist/apps/management/routes/billing.js +1 -1
  80. package/dist/apps/management/routes/billing.js.map +1 -1
  81. package/dist/apps/management/routes/faucet.d.ts +37 -37
  82. package/dist/apps/management/routes/faucet.js +2 -2
  83. package/dist/apps/management/routes/faucet.js.map +1 -1
  84. package/dist/apps/management/routes/invite-links.d.ts +42 -42
  85. package/dist/apps/management/routes/me.d.ts +6 -6
  86. package/dist/apps/management/routes/members.d.ts +75 -75
  87. package/dist/apps/management/routes/projects.d.ts +40 -40
  88. package/dist/apps/management/routes/usage.d.ts +76 -76
  89. package/dist/apps/management/routes/webhooks.d.ts +49 -49
  90. package/dist/apps/mcp.d.ts.map +1 -1
  91. package/dist/apps/mcp.js +4 -1
  92. package/dist/apps/mcp.js.map +1 -1
  93. package/dist/db/Db.d.ts +2 -0
  94. package/dist/db/Db.d.ts.map +1 -1
  95. package/dist/db/Db.js +58 -0
  96. package/dist/db/Db.js.map +1 -1
  97. package/dist/db/Schema.d.ts +19 -0
  98. package/dist/db/Schema.d.ts.map +1 -1
  99. package/dist/db/Schema.js +24 -0
  100. package/dist/db/Schema.js.map +1 -1
  101. package/dist/db/tables/webhookQueueEvents.d.ts +122 -0
  102. package/dist/db/tables/webhookQueueEvents.d.ts.map +1 -0
  103. package/dist/db/tables/webhookQueueEvents.js +160 -0
  104. package/dist/db/tables/webhookQueueEvents.js.map +1 -0
  105. package/dist/db/tables/webhookSubscriptions.d.ts +2 -0
  106. package/dist/db/tables/webhookSubscriptions.d.ts.map +1 -1
  107. package/dist/db/tables/webhookSubscriptions.js +10 -0
  108. package/dist/db/tables/webhookSubscriptions.js.map +1 -1
  109. package/dist/internal/Auth.d.ts +3 -0
  110. package/dist/internal/Auth.d.ts.map +1 -1
  111. package/dist/internal/Auth.js +6 -4
  112. package/dist/internal/Auth.js.map +1 -1
  113. package/dist/internal/MetricSink.d.ts +27 -0
  114. package/dist/internal/MetricSink.d.ts.map +1 -1
  115. package/dist/internal/MetricSink.js +29 -2
  116. package/dist/internal/MetricSink.js.map +1 -1
  117. package/dist/internal/RateLimit.d.ts +4 -6
  118. package/dist/internal/RateLimit.d.ts.map +1 -1
  119. package/dist/internal/RateLimit.js +15 -14
  120. package/dist/internal/RateLimit.js.map +1 -1
  121. package/dist/internal/Store.d.ts +2 -1
  122. package/dist/internal/Store.d.ts.map +1 -1
  123. package/dist/internal/Store.js +57 -18
  124. package/dist/internal/Store.js.map +1 -1
  125. package/dist/internal/Value.d.ts +2 -0
  126. package/dist/internal/Value.d.ts.map +1 -1
  127. package/dist/internal/Value.js +25 -0
  128. package/dist/internal/Value.js.map +1 -1
  129. package/dist/internal/WebhookTransfer.d.ts +20 -0
  130. package/dist/internal/WebhookTransfer.d.ts.map +1 -1
  131. package/dist/internal/WebhookTransfer.js +7 -4
  132. package/dist/internal/WebhookTransfer.js.map +1 -1
  133. package/dist/internal/Webhooks.d.ts +139 -0
  134. package/dist/internal/Webhooks.d.ts.map +1 -1
  135. package/dist/internal/Webhooks.js +129 -7
  136. package/dist/internal/Webhooks.js.map +1 -1
  137. package/dist/internal/funding/providers/relay.d.ts +3 -4
  138. package/dist/internal/funding/providers/relay.d.ts.map +1 -1
  139. package/dist/internal/funding/providers/relay.js +59 -8
  140. package/dist/internal/funding/providers/relay.js.map +1 -1
  141. package/package.json +3 -3
  142. package/src/ApiKeys.test.ts +20 -0
  143. package/src/ApiKeys.ts +28 -1
  144. package/src/App.test.ts +10 -8
  145. package/src/App.ts +1 -0
  146. package/src/Client.test-d.ts +1 -0
  147. package/src/Webhooks.ts +19 -17
  148. package/src/admin/apps/api-keys.test.ts +9 -0
  149. package/src/admin/apps/api-keys.ts +15 -0
  150. package/src/admin/ui/src/lib/query-client.ts +3 -2
  151. package/src/admin/ui/src/routes/api-keys.tsx +31 -9
  152. package/src/admin/ui.generated.ts +69 -69
  153. package/src/analytics/Analytics.test.ts +8 -0
  154. package/src/analytics/Analytics.ts +26 -0
  155. package/src/apps/Zones.test.ts +20 -3
  156. package/src/apps/Zones.ts +28 -8
  157. package/src/apps/composition.test.ts +1 -0
  158. package/src/apps/data/App.test.ts +2 -2
  159. package/src/apps/data/routes/activities.bench.ts +41 -0
  160. package/src/apps/data/routes/activities.test.ts +103 -2
  161. package/src/apps/data/routes/activities.ts +401 -2
  162. package/src/apps/data/routes/balances.bench.ts +21 -0
  163. package/src/apps/data/routes/balances.ts +11 -3
  164. package/src/apps/data/routes/blocks.bench.ts +34 -0
  165. package/src/apps/data/routes/coingecko.bench.ts +53 -0
  166. package/src/apps/data/routes/earn.bench.ts +71 -0
  167. package/src/apps/data/routes/earn.ts +2 -2
  168. package/src/apps/data/routes/exchanges.bench.ts +85 -0
  169. package/src/apps/data/routes/fee-amm.bench.ts +13 -0
  170. package/src/apps/data/routes/indexer.bench.ts +8 -0
  171. package/src/apps/data/routes/indexer.test.ts +2 -1
  172. package/src/apps/data/routes/indexer.ts +3 -2
  173. package/src/apps/data/routes/receipts.bench.ts +39 -0
  174. package/src/apps/data/routes/rpc.bench.ts +14 -0
  175. package/src/apps/data/routes/rpc.test.ts +27 -0
  176. package/src/apps/data/routes/rpc.ts +5 -1
  177. package/src/apps/data/routes/tokenlist.bench.ts +3 -0
  178. package/src/apps/data/routes/tokens.bench.ts +76 -0
  179. package/src/apps/data/routes/transactions.bench.ts +40 -0
  180. package/src/apps/data/routes/transfers.bench.ts +28 -0
  181. package/src/apps/data/routes/valuation.bench.ts +14 -0
  182. package/src/apps/data/routes/verified-tokens.bench.ts +8 -0
  183. package/src/apps/data/routes/webhooks.bench.ts +26 -0
  184. package/src/apps/data/routes/webhooks.scan.test.ts +326 -0
  185. package/src/apps/data/routes/webhooks.test.ts +53 -6
  186. package/src/apps/data/routes/webhooks.ts +221 -14
  187. package/src/apps/data/routes/zones.bench.ts +32 -0
  188. package/src/apps/data/routes/zones.test.ts +1 -1
  189. package/src/apps/data/routes/zones.ts +1 -1
  190. package/src/apps/funding/routes/chains.test.ts +1 -1
  191. package/src/apps/funding/routes/deposit-addresses.test.ts +1 -1
  192. package/src/apps/funding/routes/deposit-addresses.ts +1 -1
  193. package/src/apps/funding/routes/providers.test.ts +1 -1
  194. package/src/apps/funding/routes/quotes.test.ts +1 -1
  195. package/src/apps/funding/routes/transfers.test.ts +3 -6
  196. package/src/apps/funding/routes/transfers.ts +1 -1
  197. package/src/apps/management/routes/billing.ts +1 -1
  198. package/src/apps/management/routes/faucet.ts +2 -2
  199. package/src/apps/management/routes/me.test.ts +31 -0
  200. package/src/apps/mcp.ts +4 -1
  201. package/src/db/Db.ts +81 -0
  202. package/src/db/Schema.ts +32 -0
  203. package/src/db/tables/webhookQueueEvents.ts +285 -0
  204. package/src/db/tables/webhookSubscriptions.ts +15 -0
  205. package/src/internal/Auth.test.ts +61 -50
  206. package/src/internal/Auth.ts +9 -4
  207. package/src/internal/EarnVaults.test.ts +2 -2
  208. package/src/internal/MetricSink.test.ts +58 -1
  209. package/src/internal/MetricSink.ts +60 -2
  210. package/src/internal/RateLimit.test.ts +23 -18
  211. package/src/internal/RateLimit.ts +23 -15
  212. package/src/internal/Store.test.ts +99 -13
  213. package/src/internal/Store.ts +52 -17
  214. package/src/internal/Tidx.test.ts +4 -4
  215. package/src/internal/Value.test.ts +25 -0
  216. package/src/internal/Value.ts +29 -0
  217. package/src/internal/WebhookTransfer.test.ts +89 -0
  218. package/src/internal/WebhookTransfer.ts +11 -7
  219. package/src/internal/Webhooks.test.ts +479 -0
  220. package/src/internal/Webhooks.ts +281 -7
  221. package/src/internal/funding/Provider.test.ts +1 -1
  222. package/src/internal/funding/providers/index.test.ts +73 -3
  223. package/src/internal/funding/providers/relay.test.ts +1 -5
  224. package/src/internal/funding/providers/relay.ts +56 -12
@@ -0,0 +1,89 @@
1
+ import type { Hex } from 'ox'
2
+
3
+ import * as WebhookTransfer from './WebhookTransfer.js'
4
+
5
+ const token: Hex.Hex = '0x20c0000000000000000000000000000000000001'
6
+ const sender: Hex.Hex = `0x${'aa'.repeat(20)}`
7
+ const recipient: Hex.Hex = `0x${'bb'.repeat(20)}`
8
+ const other: Hex.Hex = `0x${'cc'.repeat(20)}`
9
+ const transactionHash = `0x${'ef'.repeat(32)}`
10
+ const transferTopic = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'
11
+ const timestamp = '2026-01-01T00:00:00.000Z'
12
+
13
+ const addressTopic = (address: string) => `0x${'0'.repeat(24)}${address.slice(2)}`
14
+
15
+ function transferLog(overrides: Record<string, unknown> = {}) {
16
+ return {
17
+ address: token,
18
+ blockNumber: '0x7b',
19
+ data: `0x${(10n ** 18n).toString(16).padStart(64, '0')}`,
20
+ logIndex: '0x4',
21
+ topics: [transferTopic, addressTopic(sender), addressTopic(recipient)],
22
+ transactionHash,
23
+ transactionIndex: '0x0',
24
+ ...overrides,
25
+ } as never
26
+ }
27
+
28
+ describe('decode', () => {
29
+ test('decodes a strict TIP-20 Transfer log', () => {
30
+ expect(WebhookTransfer.decode(transferLog(), timestamp)).toMatchInlineSnapshot(`
31
+ {
32
+ "address": "0x20c0000000000000000000000000000000000001",
33
+ "amount": "1000000000000000000",
34
+ "blockNumber": 123,
35
+ "logIndex": 4,
36
+ "recipient": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
37
+ "sender": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
38
+ "timestamp": "2026-01-01T00:00:00.000Z",
39
+ "transactionHash": "0xefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefef",
40
+ }
41
+ `)
42
+ })
43
+
44
+ test('rejects non-TIP-20 addresses and malformed payloads', () => {
45
+ const cases: readonly Record<string, unknown>[] = [
46
+ // Outside the deterministic TIP-20 address range.
47
+ { address: other },
48
+ // Missing recipient topic.
49
+ { topics: [transferTopic, addressTopic(sender)] },
50
+ // Wrong event selector.
51
+ { topics: [`0x${'12'.repeat(32)}`, addressTopic(sender), addressTopic(recipient)] },
52
+ // Sender topic without the address left-padding.
53
+ { topics: [transferTopic, `0x${'ff'.repeat(32)}`, addressTopic(recipient)] },
54
+ // Amount payload is not one word.
55
+ { data: '0x01' },
56
+ // Pending-log positions cannot form a cursor.
57
+ { blockNumber: null },
58
+ { logIndex: null },
59
+ ]
60
+ for (const overrides of cases)
61
+ expect(
62
+ WebhookTransfer.decode(transferLog(overrides), timestamp),
63
+ JSON.stringify(overrides),
64
+ ).toBeUndefined()
65
+ })
66
+ })
67
+
68
+ describe('matches', () => {
69
+ const data = WebhookTransfer.decode(transferLog(), timestamp)!
70
+
71
+ test('AND-combines side, either-side address, and token filters', () => {
72
+ const cases: readonly [WebhookTransfer.Filters, boolean][] = [
73
+ [{}, true],
74
+ [{ sender }, true],
75
+ [{ sender: recipient }, false],
76
+ [{ recipient }, true],
77
+ [{ recipient: sender }, false],
78
+ [{ address: sender }, true],
79
+ [{ address: recipient }, true],
80
+ [{ address: other }, false],
81
+ [{ token }, true],
82
+ [{ token: '0x20c0000000000000000000000000000000000002' }, false],
83
+ [{ recipient, sender, token }, true],
84
+ [{ recipient: sender, sender, token }, false],
85
+ ]
86
+ for (const [filters, expected] of cases)
87
+ expect(WebhookTransfer.matches(data, filters), JSON.stringify(filters)).toBe(expected)
88
+ })
89
+ })
@@ -18,9 +18,11 @@ const scanChunkBlocks = 8
18
18
  const event = parseAbiItem(Transfers.signature)
19
19
  const topic = encodeEventTopics({ abi: [event] })[0]
20
20
 
21
- type Filters = z.output<typeof Transfers.schema.Predicates>
21
+ /** Parsed transfer filter predicates. */
22
+ export type Filters = z.output<typeof Transfers.schema.Predicates>
22
23
 
23
- type EventData = {
24
+ /** One decoded TIP-20 Transfer row, as the read endpoint returns it. */
25
+ export type EventData = {
24
26
  address: string
25
27
  amount: string
26
28
  blockNumber: number
@@ -303,7 +305,7 @@ function isTip20Address(address: string): boolean {
303
305
  }
304
306
 
305
307
  /** Decodes one strict TIP-20 Transfer wire payload. */
306
- function decode(log: RpcLog, timestamp: string): EventData | undefined {
308
+ export function decode(log: RpcLog, timestamp: string): EventData | undefined {
307
309
  if (!isTip20Address(log.address)) return undefined
308
310
  if (
309
311
  log.topics.length !== 3 ||
@@ -343,7 +345,7 @@ function decode(log: RpcLog, timestamp: string): EventData | undefined {
343
345
  }
344
346
 
345
347
  /** Re-checks every transfer filter after RPC topic pushdown. */
346
- function matches(data: EventData, filters: Filters): boolean {
348
+ export function matches(data: EventData, filters: Filters): boolean {
347
349
  if (filters.address && data.sender !== filters.address && data.recipient !== filters.address)
348
350
  return false
349
351
  if (filters.recipient && data.recipient !== filters.recipient) return false
@@ -389,13 +391,13 @@ async function scanCohort(options: {
389
391
  items: [],
390
392
  state,
391
393
  }))
392
- const timestamps = new Map<number, Promise<string>>()
394
+ const timestamps = new Map<number, Promise<string | undefined>>()
393
395
  const timestamp = (blockNumber: number) => {
394
396
  const existing = timestamps.get(blockNumber)
395
397
  if (existing) return existing
396
398
  const pending = client
397
399
  .getBlock({ blockNumber: BigInt(blockNumber), includeTransactions: false })
398
- .then((block) => new Date(Number(block.timestamp) * 1_000).toISOString())
400
+ .then((block) => Value.blockToIso(block))
399
401
  timestamps.set(blockNumber, pending)
400
402
  return pending
401
403
  }
@@ -412,7 +414,9 @@ async function scanCohort(options: {
412
414
  const blockNumber = Value.hexToNumber(log.blockNumber)
413
415
  const logIndex = Value.hexToNumber(log.logIndex)
414
416
  if (blockNumber === undefined || logIndex === undefined) continue
415
- const data = decode(log, await timestamp(blockNumber))
417
+ const blockTimestamp = await timestamp(blockNumber)
418
+ if (blockTimestamp === undefined) continue
419
+ const data = decode(log, blockTimestamp)
416
420
  if (!data) continue
417
421
  for (const accumulator of accumulators) {
418
422
  if (!canMatch(accumulator, blockNumber, logIndex)) continue
@@ -804,6 +804,25 @@ describe('delivery log', () => {
804
804
  `)
805
805
  })
806
806
 
807
+ test('deliverAndRecord records envelope serialization failures', async () => {
808
+ const db = TestApp.database()
809
+ const created = await Webhooks.createSubscription(db, createInput())
810
+ const envelope = {
811
+ chainId: 4217,
812
+ createdAt: '2026-01-01T00:00:00.000Z',
813
+ data: { amount: 1n },
814
+ id: 'evt_invalid',
815
+ subscriptionId: created.id,
816
+ type: 'token:transfer',
817
+ } satisfies Webhooks.Envelope
818
+
819
+ const result = await Webhooks.deliverAndRecord(db, created, envelope, { now: fixedNow })
820
+
821
+ expect(result).toEqual({ error: 'delivery failed before request', ok: false })
822
+ const after = await Webhooks.getSubscription(db, apiKeyOwner, created.id)
823
+ expect(after?.failureCount).toBe(1)
824
+ })
825
+
807
826
  test('deliverCurrentAndRecord skips paused and deleted queued subscriptions', async () => {
808
827
  const db = TestApp.database()
809
828
  const paused = await Webhooks.createSubscription(db, createInput())
@@ -909,6 +928,466 @@ describe('delivery log', () => {
909
928
  })
910
929
  })
911
930
 
931
+ async function stagedObligation(db: ReturnType<typeof TestApp.database>, logIndex = 4) {
932
+ const subscription = await Webhooks.createSubscription(db, createInput())
933
+ const envelope = Webhooks.buildEnvelope({
934
+ blockNumber: 123,
935
+ createdAt: new Date(),
936
+ data: { amount: '1' },
937
+ logIndex,
938
+ subscription,
939
+ })
940
+ const { created, references } = await Webhooks.ensureQueueEvents(db, [{ envelope, subscription }])
941
+ return { created, envelope, reference: references[0]!, subscription }
942
+ }
943
+
944
+ describe('ensureQueueEvents', () => {
945
+ test('keeps oversized envelopes behind compact idempotent Queue references', async () => {
946
+ const db = TestApp.database()
947
+ const subscription = await Webhooks.createSubscription(
948
+ db,
949
+ createInput({ eventType: 'transaction:included' }),
950
+ )
951
+ const input = `0x${'ab'.repeat(70_000)}`
952
+ const envelope = Webhooks.buildEnvelope({
953
+ blockNumber: 123,
954
+ createdAt: fixedNow(),
955
+ data: { input, meta: { rpc: { input } } },
956
+ logIndex: 4,
957
+ subscription,
958
+ })
959
+ const dispatchable = { envelope, subscription }
960
+
961
+ const { references: [reference] } = await Webhooks.ensureQueueEvents(db, [dispatchable]) // prettier-ignore
962
+ await Webhooks.ensureQueueEvents(db, [dispatchable])
963
+
964
+ expect(
965
+ new TextEncoder().encode(JSON.stringify({ body: dispatchable })).byteLength,
966
+ ).toBeGreaterThan(128_000)
967
+ expect(new TextEncoder().encode(JSON.stringify({ body: reference })).byteLength).toBeLessThan(
968
+ 1_000,
969
+ )
970
+ expect((await Webhooks.getQueueEvent(db, reference!))?.envelope).toEqual(envelope)
971
+ })
972
+
973
+ test('delivers a staged oversized envelope from its compact reference', async () => {
974
+ const db = TestApp.database()
975
+ const subscription = await Webhooks.createSubscription(
976
+ db,
977
+ createInput({ eventType: 'transaction:included' }),
978
+ )
979
+ const input = `0x${'ab'.repeat(70_000)}`
980
+ const envelope = Webhooks.buildEnvelope({
981
+ blockNumber: 123,
982
+ data: { input, meta: { rpc: { input } } },
983
+ logIndex: 4,
984
+ subscription,
985
+ })
986
+ const { references: [reference] } = await Webhooks.ensureQueueEvents(db, [{ envelope, subscription }]) // prettier-ignore
987
+ const histograms = new Map<string, number>()
988
+ const metrics = Metrics.from({
989
+ count() {},
990
+ flush() {},
991
+ gauge() {},
992
+ histogram(name, value) {
993
+ histograms.set(name, value)
994
+ },
995
+ })
996
+ let deliveredBytes = 0
997
+ const fetch = (async (_url, init) => {
998
+ deliveredBytes = new TextEncoder().encode(
999
+ typeof init?.body === 'string' ? init.body : '',
1000
+ ).byteLength
1001
+ return new Response(null, { status: 200 })
1002
+ }) satisfies typeof globalThis.fetch
1003
+
1004
+ const result = await Webhooks.deliverQueueEventAndRecord(db, reference!, {
1005
+ fetch,
1006
+ metrics,
1007
+ queuedAt: Date.now(),
1008
+ })
1009
+
1010
+ expect(result.status).toBe('delivered')
1011
+ expect(deliveredBytes).toBeGreaterThan(128_000)
1012
+ expect(histograms.has('webhook_delivery_processing_ms')).toBe(true)
1013
+ expect(histograms.has('webhook_delivery_queue_wait_ms')).toBe(true)
1014
+ expect(histograms.has('webhook_delivery_subscription_read_ms')).toBe(true)
1015
+ })
1016
+
1017
+ test('skips a staged event after its subscription expires', async () => {
1018
+ vi.useFakeTimers({ now: fixedNow(), toFake: ['Date'] })
1019
+ try {
1020
+ const db = TestApp.database()
1021
+ const subscription = await Webhooks.createSubscription(
1022
+ db,
1023
+ createInput({ eventType: 'transaction:included', owner: mppOwner, ttl: 60_000 }),
1024
+ { now: fixedNow },
1025
+ )
1026
+ const envelope = Webhooks.buildEnvelope({
1027
+ blockNumber: 123,
1028
+ data: {},
1029
+ logIndex: 4,
1030
+ subscription,
1031
+ })
1032
+ const { references: [reference] } = await Webhooks.ensureQueueEvents(db, [{ envelope, subscription }]) // prettier-ignore
1033
+
1034
+ vi.setSystemTime(new Date('2026-01-01T00:01:00.000Z'))
1035
+
1036
+ await expect(Webhooks.deliverQueueEventAndRecord(db, reference!)).resolves
1037
+ .toMatchInlineSnapshot(`
1038
+ {
1039
+ "status": "skipped",
1040
+ }
1041
+ `)
1042
+ } finally {
1043
+ vi.useRealTimers()
1044
+ }
1045
+ })
1046
+
1047
+ test('stages once and keeps the first envelope', async () => {
1048
+ const db = TestApp.database()
1049
+ const { created, envelope, reference, subscription } = await stagedObligation(db)
1050
+ const replacement = { ...envelope, data: { amount: '2' } }
1051
+
1052
+ const second = await Webhooks.ensureQueueEvents(db, [{ envelope: replacement, subscription }])
1053
+
1054
+ expect(created).toBe(1)
1055
+ expect(second.created).toBe(0)
1056
+ expect((await Webhooks.getQueueEvent(db, reference))?.envelope).toEqual(envelope)
1057
+ })
1058
+
1059
+ test('never resurrects a terminal obligation', async () => {
1060
+ const db = TestApp.database()
1061
+ const { envelope, reference, subscription } = await stagedObligation(db)
1062
+ const claim = await Webhooks.claimQueueEvent(db, reference)
1063
+ expect(claim.type).toBe('claimed')
1064
+ if (claim.type !== 'claimed') return
1065
+ expect(
1066
+ await Webhooks.completeQueueEvent(db, reference, {
1067
+ claimedAt: claim.record.attemptingAt!,
1068
+ status: 'succeeded',
1069
+ }),
1070
+ ).toBe(true)
1071
+
1072
+ const again = await Webhooks.ensureQueueEvents(db, [{ envelope, subscription }])
1073
+ expect(again.created).toBe(0)
1074
+ await expect(Webhooks.claimQueueEvent(db, reference)).resolves.toEqual({
1075
+ status: 'succeeded',
1076
+ type: 'terminal',
1077
+ })
1078
+ // Terminal rows read as absent, so no consumer path can redeliver them.
1079
+ expect(await Webhooks.getQueueEvent(db, reference)).toBeNull()
1080
+ })
1081
+
1082
+ test('replaces an expired terminal row so replays stay deliverable', async () => {
1083
+ vi.useFakeTimers({ toFake: ['Date'] })
1084
+ vi.setSystemTime(fixedNow())
1085
+ try {
1086
+ const db = TestApp.database()
1087
+ const { envelope, reference, subscription } = await stagedObligation(db)
1088
+ const claim = await Webhooks.claimQueueEvent(db, reference)
1089
+ expect(claim.type).toBe('claimed')
1090
+ if (claim.type !== 'claimed') return
1091
+ expect(
1092
+ await Webhooks.completeQueueEvent(db, reference, {
1093
+ claimedAt: claim.record.attemptingAt!,
1094
+ status: 'succeeded',
1095
+ }),
1096
+ ).toBe(true)
1097
+
1098
+ // Past the dedupe window an unpruned terminal row must not suppress a
1099
+ // replay.
1100
+ vi.setSystemTime(new Date(fixedNow().getTime() + Webhooks.deliveryRetentionMs + 1_000))
1101
+ const replay = await Webhooks.ensureQueueEvents(db, [{ envelope, subscription }])
1102
+ expect(replay.created).toBe(1)
1103
+ await expect(Webhooks.claimQueueEvent(db, reference)).resolves.toMatchObject({
1104
+ type: 'claimed',
1105
+ })
1106
+ } finally {
1107
+ vi.useRealTimers()
1108
+ }
1109
+ })
1110
+ })
1111
+
1112
+ describe('claimQueueEvent', () => {
1113
+ beforeEach(() => {
1114
+ vi.useFakeTimers({ toFake: ['Date'] })
1115
+ vi.setSystemTime(fixedNow())
1116
+ })
1117
+
1118
+ afterEach(() => {
1119
+ vi.useRealTimers()
1120
+ })
1121
+
1122
+ test('claims a due obligation once and reclaims after staleness', async () => {
1123
+ const db = TestApp.database()
1124
+ const { reference } = await stagedObligation(db)
1125
+
1126
+ const first = await Webhooks.claimQueueEvent(db, reference)
1127
+ expect(first.type).toBe('claimed')
1128
+ if (first.type !== 'claimed') return
1129
+ expect(first.record.attemptCount).toBe(1)
1130
+
1131
+ await expect(Webhooks.claimQueueEvent(db, reference)).resolves.toEqual({
1132
+ type: 'attempting',
1133
+ })
1134
+
1135
+ vi.setSystemTime(new Date(fixedNow().getTime() + Webhooks.deliveryAttemptStaleMs + 1))
1136
+ const reclaimed = await Webhooks.claimQueueEvent(db, reference)
1137
+ expect(reclaimed.type).toBe('claimed')
1138
+ if (reclaimed.type !== 'claimed') return
1139
+ expect(reclaimed.record.attemptCount).toBe(2)
1140
+ })
1141
+
1142
+ test('classifies missing, scheduled, and terminal obligations', async () => {
1143
+ const db = TestApp.database()
1144
+ const { reference } = await stagedObligation(db)
1145
+
1146
+ await expect(
1147
+ Webhooks.claimQueueEvent(db, { eventId: 'evt_absent', subscriptionId: 'wh_absent' }),
1148
+ ).resolves.toEqual({ type: 'missing' })
1149
+
1150
+ const claim = await Webhooks.claimQueueEvent(db, reference)
1151
+ expect(claim.type).toBe('claimed')
1152
+ if (claim.type !== 'claimed') return
1153
+ expect(
1154
+ await Webhooks.scheduleQueueEventRetry(db, reference, {
1155
+ attemptCount: 1,
1156
+ claimedAt: claim.record.attemptingAt!,
1157
+ }),
1158
+ ).toBe(true)
1159
+ await expect(Webhooks.claimQueueEvent(db, reference)).resolves.toEqual({
1160
+ nextAttemptAt: new Date(fixedNow().getTime() + Webhooks.retryDelayMs(1)).toISOString(),
1161
+ type: 'scheduled',
1162
+ })
1163
+
1164
+ vi.setSystemTime(new Date(fixedNow().getTime() + Webhooks.retryDelayMs(1) + 1))
1165
+ const retried = await Webhooks.claimQueueEvent(db, reference)
1166
+ expect(retried.type).toBe('claimed')
1167
+ if (retried.type !== 'claimed') return
1168
+ expect(
1169
+ await Webhooks.completeQueueEvent(db, reference, {
1170
+ claimedAt: retried.record.attemptingAt!,
1171
+ status: 'failed',
1172
+ }),
1173
+ ).toBe(true)
1174
+ await expect(Webhooks.claimQueueEvent(db, reference)).resolves.toEqual({
1175
+ status: 'failed',
1176
+ type: 'terminal',
1177
+ })
1178
+ })
1179
+
1180
+ test('keeps owed work claimable indefinitely', async () => {
1181
+ const db = TestApp.database()
1182
+ const { reference } = await stagedObligation(db)
1183
+
1184
+ // Pending obligations never expire; only terminal transitions stamp a
1185
+ // dedupe deadline.
1186
+ vi.setSystemTime(new Date(fixedNow().getTime() + Webhooks.deliveryRetentionMs * 3))
1187
+ await expect(Webhooks.claimQueueEvent(db, reference)).resolves.toMatchObject({
1188
+ type: 'claimed',
1189
+ })
1190
+ })
1191
+
1192
+ test('never prunes pending work', async () => {
1193
+ const db = TestApp.database()
1194
+ const { reference } = await stagedObligation(db)
1195
+
1196
+ vi.setSystemTime(new Date(fixedNow().getTime() + Webhooks.deliveryRetentionMs + 1_000))
1197
+ await Webhooks.pruneExpired(db)
1198
+ const claim = await Webhooks.claimQueueEvent(db, reference)
1199
+ expect(claim.type).toBe('claimed')
1200
+ if (claim.type !== 'claimed') return
1201
+ expect(
1202
+ await Webhooks.completeQueueEvent(db, reference, {
1203
+ claimedAt: claim.record.attemptingAt!,
1204
+ status: 'succeeded',
1205
+ }),
1206
+ ).toBe(true)
1207
+ })
1208
+
1209
+ test('fences a stale claimant after a reclaim', async () => {
1210
+ const db = TestApp.database()
1211
+ const { reference } = await stagedObligation(db)
1212
+ const stale = await Webhooks.claimQueueEvent(db, reference)
1213
+ expect(stale.type).toBe('claimed')
1214
+ if (stale.type !== 'claimed') return
1215
+
1216
+ vi.setSystemTime(new Date(fixedNow().getTime() + Webhooks.deliveryAttemptStaleMs + 1))
1217
+ const current = await Webhooks.claimQueueEvent(db, reference)
1218
+ expect(current.type).toBe('claimed')
1219
+ if (current.type !== 'claimed') return
1220
+
1221
+ const staleClaimedAt = stale.record.attemptingAt!
1222
+ expect(
1223
+ await Webhooks.completeQueueEvent(db, reference, {
1224
+ claimedAt: staleClaimedAt,
1225
+ status: 'failed',
1226
+ }),
1227
+ ).toBe(false)
1228
+ expect(
1229
+ await Webhooks.scheduleQueueEventRetry(db, reference, {
1230
+ attemptCount: 1,
1231
+ claimedAt: staleClaimedAt,
1232
+ }),
1233
+ ).toBe(false)
1234
+ expect(
1235
+ await Webhooks.completeQueueEvent(db, reference, {
1236
+ claimedAt: current.record.attemptingAt!,
1237
+ status: 'succeeded',
1238
+ }),
1239
+ ).toBe(true)
1240
+ })
1241
+ })
1242
+
1243
+ describe('retryDelayMs', () => {
1244
+ test('backs off exponentially and caps hourly', () => {
1245
+ expect([1, 2, 3, 4, 5, 6, 10].map(Webhooks.retryDelayMs)).toMatchInlineSnapshot(`
1246
+ [
1247
+ 5000,
1248
+ 25000,
1249
+ 125000,
1250
+ 625000,
1251
+ 3125000,
1252
+ 3600000,
1253
+ 3600000,
1254
+ ]
1255
+ `)
1256
+ })
1257
+ })
1258
+
1259
+ describe('dueQueueEvents', () => {
1260
+ beforeEach(() => {
1261
+ vi.useFakeTimers({ toFake: ['Date'] })
1262
+ vi.setSystemTime(fixedNow())
1263
+ })
1264
+
1265
+ afterEach(() => {
1266
+ vi.useRealTimers()
1267
+ })
1268
+
1269
+ test('lists due pending work and skips in-flight, scheduled, and terminal rows', async () => {
1270
+ const db = TestApp.database()
1271
+ // Distinct staging times so the due order (next_attempt_at) is deterministic.
1272
+ const due = await stagedObligation(db, 1)
1273
+ vi.setSystemTime(new Date(fixedNow().getTime() + 1))
1274
+ const inFlight = await stagedObligation(db, 2)
1275
+ vi.setSystemTime(new Date(fixedNow().getTime() + 2))
1276
+ const scheduled = await stagedObligation(db, 3)
1277
+ vi.setSystemTime(new Date(fixedNow().getTime() + 3))
1278
+ const terminal = await stagedObligation(db, 4)
1279
+
1280
+ expect((await Webhooks.claimQueueEvent(db, inFlight.reference)).type).toBe('claimed')
1281
+ const scheduledClaim = await Webhooks.claimQueueEvent(db, scheduled.reference)
1282
+ expect(scheduledClaim.type).toBe('claimed')
1283
+ if (scheduledClaim.type !== 'claimed') return
1284
+ expect(
1285
+ await Webhooks.scheduleQueueEventRetry(db, scheduled.reference, {
1286
+ attemptCount: 3,
1287
+ claimedAt: scheduledClaim.record.attemptingAt!,
1288
+ }),
1289
+ ).toBe(true)
1290
+ const terminalClaim = await Webhooks.claimQueueEvent(db, terminal.reference)
1291
+ expect(terminalClaim.type).toBe('claimed')
1292
+ if (terminalClaim.type !== 'claimed') return
1293
+ expect(
1294
+ await Webhooks.completeQueueEvent(db, terminal.reference, {
1295
+ claimedAt: terminalClaim.record.attemptingAt!,
1296
+ status: 'succeeded',
1297
+ }),
1298
+ ).toBe(true)
1299
+
1300
+ await expect(Webhooks.dueQueueEvents(db, 10)).resolves.toEqual([due.reference])
1301
+
1302
+ vi.setSystemTime(new Date(fixedNow().getTime() + Webhooks.deliveryAttemptStaleMs + 10))
1303
+ await expect(Webhooks.dueQueueEvents(db, 10)).resolves.toEqual([
1304
+ due.reference,
1305
+ inFlight.reference,
1306
+ ])
1307
+ })
1308
+ })
1309
+
1310
+ describe('pendingQueueEvents', () => {
1311
+ beforeEach(() => {
1312
+ vi.useFakeTimers({ toFake: ['Date'] })
1313
+ vi.setSystemTime(fixedNow())
1314
+ })
1315
+
1316
+ afterEach(() => {
1317
+ vi.useRealTimers()
1318
+ })
1319
+
1320
+ test('counts pending obligations and reports the oldest staging time', async () => {
1321
+ const db = TestApp.database()
1322
+ await stagedObligation(db, 1)
1323
+ vi.setSystemTime(new Date(fixedNow().getTime() + 1_000))
1324
+ const done = await stagedObligation(db, 2)
1325
+ const doneClaim = await Webhooks.claimQueueEvent(db, done.reference)
1326
+ expect(doneClaim.type).toBe('claimed')
1327
+ if (doneClaim.type !== 'claimed') return
1328
+ expect(
1329
+ await Webhooks.completeQueueEvent(db, done.reference, {
1330
+ claimedAt: doneClaim.record.attemptingAt!,
1331
+ status: 'succeeded',
1332
+ }),
1333
+ ).toBe(true)
1334
+
1335
+ await expect(Webhooks.pendingQueueEvents(db)).resolves.toEqual({
1336
+ count: 1,
1337
+ dueCount: 1,
1338
+ oldestAt: fixedNow().toISOString(),
1339
+ oldestDueAt: fixedNow().toISOString(),
1340
+ })
1341
+ })
1342
+
1343
+ test('excludes in-flight claims and scheduled retries from due work', async () => {
1344
+ const db = TestApp.database()
1345
+ const inFlight = await stagedObligation(db, 1)
1346
+ const claim = await Webhooks.claimQueueEvent(db, inFlight.reference)
1347
+ expect(claim.type).toBe('claimed')
1348
+
1349
+ await expect(Webhooks.pendingQueueEvents(db)).resolves.toMatchObject({
1350
+ count: 1,
1351
+ dueCount: 0,
1352
+ oldestDueAt: null,
1353
+ })
1354
+ })
1355
+ })
1356
+
1357
+ describe('deliverClaimedAndRecord', () => {
1358
+ test('delivers a claimed envelope and skips an inactive subscription', async () => {
1359
+ const db = TestApp.database()
1360
+ const { envelope, reference, subscription } = await stagedObligation(db)
1361
+ const claim = await Webhooks.claimQueueEvent(db, reference)
1362
+ expect(claim.type).toBe('claimed')
1363
+ if (claim.type !== 'claimed') return
1364
+
1365
+ let delivered: unknown
1366
+ const fetch = (async (_url, init) => {
1367
+ delivered = JSON.parse(typeof init?.body === 'string' ? init.body : '{}')
1368
+ return new Response(null, { status: 200 })
1369
+ }) satisfies typeof globalThis.fetch
1370
+
1371
+ const delivery = await Webhooks.deliverClaimedAndRecord(
1372
+ db,
1373
+ { envelope: claim.record.envelope, subscriptionId: reference.subscriptionId },
1374
+ { fetch },
1375
+ )
1376
+ expect(delivery.status).toBe('delivered')
1377
+ if (delivery.status !== 'delivered') return
1378
+ expect(delivery.result.ok).toBe(true)
1379
+ expect(delivered).toEqual(envelope)
1380
+
1381
+ await Webhooks.deleteSubscription(db, apiKeyOwner, subscription.id)
1382
+ await expect(
1383
+ Webhooks.deliverClaimedAndRecord(db, {
1384
+ envelope: claim.record.envelope,
1385
+ subscriptionId: reference.subscriptionId,
1386
+ }),
1387
+ ).resolves.toEqual({ status: 'skipped' })
1388
+ })
1389
+ })
1390
+
912
1391
  describe('buildPingEnvelope', () => {
913
1392
  test('builds a synthetic ping envelope with a deterministic id from the nonce', async () => {
914
1393
  const db = TestApp.database()