tapimo 0.10.1 → 0.11.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 (225) hide show
  1. package/dist/App.d.ts +5249 -3572
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/App.js.map +1 -1
  4. package/dist/Webhooks.d.ts +1 -76
  5. package/dist/Webhooks.d.ts.map +1 -1
  6. package/dist/Webhooks.js +2 -144
  7. package/dist/Webhooks.js.map +1 -1
  8. package/dist/admin/App.d.ts +74 -74
  9. package/dist/admin/apps/earn-vaults.d.ts +1 -1
  10. package/dist/admin/apps/organizations.d.ts +3 -3
  11. package/dist/admin/apps/verified-tokens.d.ts +74 -74
  12. package/dist/analytics/Analytics.d.ts.map +1 -1
  13. package/dist/analytics/Analytics.js +206 -0
  14. package/dist/analytics/Analytics.js.map +1 -1
  15. package/dist/analytics/tables/requestEvents.d.ts +4 -0
  16. package/dist/analytics/tables/requestEvents.d.ts.map +1 -1
  17. package/dist/analytics/tables/requestEvents.js +2 -0
  18. package/dist/analytics/tables/requestEvents.js.map +1 -1
  19. package/dist/apps/data/App.d.ts +3618 -2954
  20. package/dist/apps/data/App.d.ts.map +1 -1
  21. package/dist/apps/data/App.js +7 -2
  22. package/dist/apps/data/App.js.map +1 -1
  23. package/dist/apps/data/routes/activities.d.ts +1 -1
  24. package/dist/apps/data/routes/activities.d.ts.map +1 -1
  25. package/dist/apps/data/routes/activities.js +55 -27
  26. package/dist/apps/data/routes/activities.js.map +1 -1
  27. package/dist/apps/data/routes/balances.d.ts +11 -11
  28. package/dist/apps/data/routes/balances.d.ts.map +1 -1
  29. package/dist/apps/data/routes/balances.js +2 -6
  30. package/dist/apps/data/routes/balances.js.map +1 -1
  31. package/dist/apps/data/routes/blocks.d.ts +5 -5
  32. package/dist/apps/data/routes/earn.d.ts +4 -4
  33. package/dist/apps/data/routes/earn.d.ts.map +1 -1
  34. package/dist/apps/data/routes/earn.js +63 -34
  35. package/dist/apps/data/routes/earn.js.map +1 -1
  36. package/dist/apps/data/routes/exchanges.d.ts +8 -4
  37. package/dist/apps/data/routes/exchanges.d.ts.map +1 -1
  38. package/dist/apps/data/routes/exchanges.js +115 -77
  39. package/dist/apps/data/routes/exchanges.js.map +1 -1
  40. package/dist/apps/data/routes/fee-amm.d.ts +26 -26
  41. package/dist/apps/data/routes/receipts.d.ts +240 -239
  42. package/dist/apps/data/routes/receipts.d.ts.map +1 -1
  43. package/dist/apps/data/routes/receipts.js +15 -14
  44. package/dist/apps/data/routes/receipts.js.map +1 -1
  45. package/dist/apps/data/routes/tokenlist.d.ts +12 -12
  46. package/dist/apps/data/routes/tokens.d.ts +4 -4
  47. package/dist/apps/data/routes/tokens.d.ts.map +1 -1
  48. package/dist/apps/data/routes/tokens.js +4 -3
  49. package/dist/apps/data/routes/tokens.js.map +1 -1
  50. package/dist/apps/data/routes/transactions.d.ts +282 -282
  51. package/dist/apps/data/routes/transfers.d.ts +13 -13
  52. package/dist/apps/data/routes/transfers.d.ts.map +1 -1
  53. package/dist/apps/data/routes/transfers.js +25 -7
  54. package/dist/apps/data/routes/transfers.js.map +1 -1
  55. package/dist/apps/data/routes/valuation.d.ts +21 -21
  56. package/dist/apps/data/routes/verified-tokens.d.ts +5 -5
  57. package/dist/apps/data/routes/webhooks.d.ts +1519 -643
  58. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  59. package/dist/apps/data/routes/webhooks.js +134 -740
  60. package/dist/apps/data/routes/webhooks.js.map +1 -1
  61. package/dist/apps/data/routes/zones.d.ts +3 -3
  62. package/dist/apps/funding/App.d.ts +233 -0
  63. package/dist/apps/funding/App.d.ts.map +1 -1
  64. package/dist/apps/funding/routes/deposit-addresses.d.ts +331 -1
  65. package/dist/apps/funding/routes/deposit-addresses.d.ts.map +1 -1
  66. package/dist/apps/funding/routes/deposit-addresses.js +207 -11
  67. package/dist/apps/funding/routes/deposit-addresses.js.map +1 -1
  68. package/dist/apps/funding/routes/transfers.d.ts +33 -33
  69. package/dist/apps/management/App.d.ts +793 -16
  70. package/dist/apps/management/App.d.ts.map +1 -1
  71. package/dist/apps/management/routes/api-keys.d.ts +50 -50
  72. package/dist/apps/management/routes/faucet.d.ts +2 -2
  73. package/dist/apps/management/routes/invite-links.d.ts +42 -42
  74. package/dist/apps/management/routes/members.d.ts +2 -2
  75. package/dist/apps/management/routes/projects.d.ts +40 -40
  76. package/dist/apps/management/routes/webhooks.d.ts +1031 -59
  77. package/dist/apps/management/routes/webhooks.d.ts.map +1 -1
  78. package/dist/apps/management/routes/webhooks.js +27 -4
  79. package/dist/apps/management/routes/webhooks.js.map +1 -1
  80. package/dist/db/Db.d.ts.map +1 -1
  81. package/dist/db/Db.js +53 -0
  82. package/dist/db/Db.js.map +1 -1
  83. package/dist/db/Schema.d.ts +7 -1
  84. package/dist/db/Schema.d.ts.map +1 -1
  85. package/dist/db/Schema.js +9 -0
  86. package/dist/db/Schema.js.map +1 -1
  87. package/dist/db/tables/fundingDepositAddresses.d.ts +20 -0
  88. package/dist/db/tables/fundingDepositAddresses.d.ts.map +1 -1
  89. package/dist/db/tables/fundingDepositAddresses.js +17 -0
  90. package/dist/db/tables/fundingDepositAddresses.js.map +1 -1
  91. package/dist/db/tables/fundingDeposits.d.ts +41 -0
  92. package/dist/db/tables/fundingDeposits.d.ts.map +1 -1
  93. package/dist/db/tables/fundingDeposits.js +69 -0
  94. package/dist/db/tables/fundingDeposits.js.map +1 -1
  95. package/dist/db/tables/webhookQueueEvents.d.ts +3 -1
  96. package/dist/db/tables/webhookQueueEvents.d.ts.map +1 -1
  97. package/dist/db/tables/webhookQueueEvents.js +4 -0
  98. package/dist/db/tables/webhookQueueEvents.js.map +1 -1
  99. package/dist/db/tables/webhookSubscriptions.d.ts +33 -69
  100. package/dist/db/tables/webhookSubscriptions.d.ts.map +1 -1
  101. package/dist/db/tables/webhookSubscriptions.js +63 -100
  102. package/dist/db/tables/webhookSubscriptions.js.map +1 -1
  103. package/dist/internal/Auth.d.ts +0 -26
  104. package/dist/internal/Auth.d.ts.map +1 -1
  105. package/dist/internal/Auth.js +0 -33
  106. package/dist/internal/Auth.js.map +1 -1
  107. package/dist/internal/Credential.d.ts +27 -0
  108. package/dist/internal/Credential.d.ts.map +1 -0
  109. package/dist/internal/Credential.js +34 -0
  110. package/dist/internal/Credential.js.map +1 -0
  111. package/dist/internal/EarnRates.js +11 -7
  112. package/dist/internal/EarnRates.js.map +1 -1
  113. package/dist/internal/Log.d.ts +8 -0
  114. package/dist/internal/Log.d.ts.map +1 -1
  115. package/dist/internal/Log.js +4 -0
  116. package/dist/internal/Log.js.map +1 -1
  117. package/dist/internal/MetricSink.d.ts +50 -116
  118. package/dist/internal/MetricSink.d.ts.map +1 -1
  119. package/dist/internal/MetricSink.js +60 -106
  120. package/dist/internal/MetricSink.js.map +1 -1
  121. package/dist/internal/Schema.d.ts +3 -1
  122. package/dist/internal/Schema.d.ts.map +1 -1
  123. package/dist/internal/Schema.js +4 -2
  124. package/dist/internal/Schema.js.map +1 -1
  125. package/dist/internal/Tidx.d.ts +4 -3
  126. package/dist/internal/Tidx.d.ts.map +1 -1
  127. package/dist/internal/Tidx.js +2 -2
  128. package/dist/internal/Tidx.js.map +1 -1
  129. package/dist/internal/Viem.d.ts +4 -3
  130. package/dist/internal/Viem.d.ts.map +1 -1
  131. package/dist/internal/Viem.js +3 -2
  132. package/dist/internal/Viem.js.map +1 -1
  133. package/dist/internal/WebhookTransfer.d.ts +1 -55
  134. package/dist/internal/WebhookTransfer.d.ts.map +1 -1
  135. package/dist/internal/WebhookTransfer.js +1 -239
  136. package/dist/internal/WebhookTransfer.js.map +1 -1
  137. package/dist/internal/Webhooks.d.ts +85 -44
  138. package/dist/internal/Webhooks.d.ts.map +1 -1
  139. package/dist/internal/Webhooks.js +94 -73
  140. package/dist/internal/Webhooks.js.map +1 -1
  141. package/dist/internal/funding/DepositWebhook.d.ts +25 -0
  142. package/dist/internal/funding/DepositWebhook.d.ts.map +1 -0
  143. package/dist/internal/funding/DepositWebhook.js +74 -0
  144. package/dist/internal/funding/DepositWebhook.js.map +1 -0
  145. package/dist/internal/funding/Reconciliation.d.ts +6 -0
  146. package/dist/internal/funding/Reconciliation.d.ts.map +1 -1
  147. package/dist/internal/funding/Reconciliation.js +42 -10
  148. package/dist/internal/funding/Reconciliation.js.map +1 -1
  149. package/dist/internal/funding/index.d.ts +1 -0
  150. package/dist/internal/funding/index.d.ts.map +1 -1
  151. package/dist/internal/funding/index.js +1 -0
  152. package/dist/internal/funding/index.js.map +1 -1
  153. package/dist/internal/index.d.ts +1 -0
  154. package/dist/internal/index.d.ts.map +1 -1
  155. package/dist/internal/index.js +1 -0
  156. package/dist/internal/index.js.map +1 -1
  157. package/dist/internal/webhookDestinations/slack.d.ts.map +1 -1
  158. package/dist/internal/webhookDestinations/slack.js +16 -0
  159. package/dist/internal/webhookDestinations/slack.js.map +1 -1
  160. package/package.json +1 -1
  161. package/src/App.test.ts +30 -0
  162. package/src/App.ts +3 -0
  163. package/src/Client.test-d.ts +55 -2
  164. package/src/Webhooks.ts +2 -221
  165. package/src/analytics/Analytics.test.ts +34 -0
  166. package/src/analytics/Analytics.ts +206 -0
  167. package/src/analytics/tables/requestEvents.ts +6 -0
  168. package/src/apps/composition.test.ts +1 -0
  169. package/src/apps/data/App.ts +10 -2
  170. package/src/apps/data/routes/activities.test.ts +107 -11
  171. package/src/apps/data/routes/activities.ts +64 -32
  172. package/src/apps/data/routes/balances.ts +2 -6
  173. package/src/apps/data/routes/earn.ts +64 -34
  174. package/src/apps/data/routes/exchanges.test.ts +8 -3
  175. package/src/apps/data/routes/exchanges.ts +134 -82
  176. package/src/apps/data/routes/receipts.ts +16 -14
  177. package/src/apps/data/routes/tokens.ts +3 -2
  178. package/src/apps/data/routes/transactions.bench.ts +8 -0
  179. package/src/apps/data/routes/transfers.ts +36 -10
  180. package/src/apps/data/routes/webhooks.test.ts +179 -1497
  181. package/src/apps/data/routes/webhooks.ts +172 -975
  182. package/src/apps/funding/routes/deposit-addresses.test.ts +216 -2
  183. package/src/apps/funding/routes/deposit-addresses.ts +254 -18
  184. package/src/apps/funding/routes/transfers.test.ts +17 -2
  185. package/src/apps/management/routes/webhooks.test.ts +79 -1
  186. package/src/apps/management/routes/webhooks.ts +40 -4
  187. package/src/db/Db.test.ts +1 -5
  188. package/src/db/Db.ts +59 -0
  189. package/src/db/Schema.ts +13 -0
  190. package/src/db/tables/fundingDepositAddresses.test.ts +28 -0
  191. package/src/db/tables/fundingDepositAddresses.ts +39 -0
  192. package/src/db/tables/fundingDeposits.test.ts +82 -0
  193. package/src/db/tables/fundingDeposits.ts +130 -0
  194. package/src/db/tables/organizations.test.ts +5 -2
  195. package/src/db/tables/webhookQueueEvents.ts +7 -1
  196. package/src/db/tables/webhookSubscriptions.ts +89 -138
  197. package/src/internal/Auth.test.ts +0 -48
  198. package/src/internal/Auth.ts +0 -59
  199. package/src/internal/Credential.test.ts +51 -0
  200. package/src/internal/Credential.ts +58 -0
  201. package/src/internal/EarnRates.ts +13 -7
  202. package/src/internal/Log.test.ts +3 -1
  203. package/src/internal/Log.ts +14 -0
  204. package/src/internal/MetricSink.test.ts +75 -358
  205. package/src/internal/MetricSink.ts +117 -246
  206. package/src/internal/Schema.test.ts +18 -0
  207. package/src/internal/Schema.ts +13 -2
  208. package/src/internal/Tidx.ts +5 -4
  209. package/src/internal/Viem.test.ts +4 -0
  210. package/src/internal/Viem.ts +6 -4
  211. package/src/internal/WebhookTransfer.ts +1 -351
  212. package/src/internal/Webhooks.test.ts +111 -139
  213. package/src/internal/Webhooks.ts +181 -105
  214. package/src/internal/funding/DepositWebhook.test.ts +131 -0
  215. package/src/internal/funding/DepositWebhook.ts +101 -0
  216. package/src/internal/funding/Reconciliation.test.ts +14 -0
  217. package/src/internal/funding/Reconciliation.ts +161 -86
  218. package/src/internal/funding/index.test.ts +1 -0
  219. package/src/internal/funding/index.ts +1 -0
  220. package/src/internal/index.test.ts +1 -0
  221. package/src/internal/index.ts +1 -0
  222. package/src/internal/webhookDestinations/slack.ts +15 -0
  223. package/src/Webhooks.bench.ts +0 -714
  224. package/src/Webhooks.memory.bench.ts +0 -250
  225. package/src/Webhooks.test.ts +0 -146
@@ -10,7 +10,10 @@ import * as Orgs from './orgs.js'
10
10
  import * as Webhooks from './webhooks.js'
11
11
 
12
12
  const origin = 'http://localhost'
13
- const webhook = { supportedChainIds: [4217, 42431] } satisfies App.Webhook
13
+ const webhook = {
14
+ applicationEventTypes: ['funding:deposit.updated'],
15
+ supportedChainIds: [4217, 42431],
16
+ } satisfies App.Webhook
14
17
 
15
18
  function json(method: 'PATCH' | 'POST', cookie: string, body: unknown) {
16
19
  return {
@@ -47,6 +50,81 @@ async function organization(app: ReturnType<typeof createApp>, cookie: string) {
47
50
  }
48
51
 
49
52
  describe('webhooks', () => {
53
+ test('creates funding deposit subscriptions without reading the RPC head', async () => {
54
+ const db = TestApp.database()
55
+ const app = createApp({ db })
56
+ const cookie = await session(app)
57
+ const org = await organization(app, cookie)
58
+ const response = await app.request(
59
+ `/v1/orgs/${org.id}/webhooks`,
60
+ json('POST', cookie, {
61
+ chainId: 4217,
62
+ destination: { type: 'url', url: 'https://example.com/funding' },
63
+ environment: 'production',
64
+ eventType: 'funding:deposit.updated',
65
+ filters: { status: 'completed' },
66
+ }),
67
+ )
68
+ const created = await TestApp.json(response, Webhooks.schema.createWebhook.Response)
69
+ const stored = await core_Webhooks.getSubscription(
70
+ db,
71
+ { orgId: org.id, type: 'api_key' },
72
+ created.id,
73
+ )
74
+ const listedResponse = await app.request(`/v1/orgs/${org.id}/webhooks`, {
75
+ headers: { cookie },
76
+ })
77
+ const listed = await TestApp.json(listedResponse, Webhooks.schema.ListResponse)
78
+
79
+ expect(response.status).toBe(200)
80
+ expect(created.environment).toBe('production')
81
+ expect(listed.data[0]?.environment).toBe('production')
82
+ expect(stored?.environment).toBe('production')
83
+ expect(await core_Webhooks.getCursor(db, created.id)).toBeNull()
84
+ })
85
+
86
+ test('preserves a production funding environment on testnet', async () => {
87
+ const db = TestApp.database()
88
+ const app = createApp({ db })
89
+ const cookie = await session(app)
90
+ const org = await organization(app, cookie)
91
+ const response = await app.request(
92
+ `/v1/orgs/${org.id}/webhooks`,
93
+ json('POST', cookie, {
94
+ chainId: 42431,
95
+ destination: { type: 'url', url: 'https://example.com/funding-testnet' },
96
+ environment: 'production',
97
+ eventType: 'funding:deposit.updated',
98
+ }),
99
+ )
100
+ const created = await TestApp.json(response, Webhooks.schema.createWebhook.Response)
101
+ const stored = await core_Webhooks.getSubscription(
102
+ db,
103
+ { orgId: org.id, type: 'api_key' },
104
+ created.id,
105
+ )
106
+
107
+ expect(response.status).toBe(200)
108
+ expect(created.environment).toBe('production')
109
+ expect(stored?.environment).toBe('production')
110
+ })
111
+
112
+ test('requires an explicit environment for funding deposit subscriptions', async () => {
113
+ const app = createApp()
114
+ const cookie = await session(app)
115
+ const org = await organization(app, cookie)
116
+ const response = await app.request(
117
+ `/v1/orgs/${org.id}/webhooks`,
118
+ json('POST', cookie, {
119
+ chainId: 4217,
120
+ destination: { type: 'url', url: 'https://example.com/funding' },
121
+ eventType: 'funding:deposit.updated',
122
+ }),
123
+ )
124
+
125
+ expect(response.status).toBe(400)
126
+ })
127
+
50
128
  test('creates, lists, pauses, and deletes an organization webhook', async () => {
51
129
  const db = TestApp.database()
52
130
  const app = createApp({ db })
@@ -22,13 +22,29 @@ export namespace schema {
22
22
 
23
23
  /** Schemas for creating an organization webhook. */
24
24
  export namespace createWebhook {
25
+ const Environment = z
26
+ .enum(['production', 'sandbox'])
27
+ .check(
28
+ z.describe('API-key environment whose private funding deposits are delivered.'),
29
+ z.meta({ examples: ['production'] }),
30
+ )
31
+
25
32
  /** Organization webhook creation requires an explicit network. */
26
33
  export const Body = z
27
34
  .intersection(
28
35
  webhookSchema.createWebhook.Body,
29
- z.object({ chainId: webhookSchema.Subscription.shape.chainId }),
36
+ z.object({
37
+ chainId: webhookSchema.Subscription.shape.chainId,
38
+ environment: z.optional(Environment),
39
+ }),
40
+ )
41
+ .check(
42
+ z.refine(
43
+ (body) => body.eventType !== 'funding:deposit.updated' || body.environment !== undefined,
44
+ { error: '`environment` is required for funding deposit subscriptions.' },
45
+ ),
46
+ z.describe('Details for creating an organization webhook subscription.'),
30
47
  )
31
- .check(z.describe('Details for creating an organization webhook subscription.'))
32
48
 
33
49
  const secret = webhookSchema.createWebhook.Response.shape.secret
34
50
  const urlDestination = z.object({
@@ -181,6 +197,7 @@ export function webhooks(options: webhooks.Options = {}) {
181
197
  codes: [
182
198
  'body_invalid',
183
199
  'chain_id_unsupported',
200
+ 'event_type_unsupported',
184
201
  'filters_invalid',
185
202
  'param_invalid',
186
203
  'url_invalid',
@@ -213,6 +230,15 @@ export function webhooks(options: webhooks.Options = {}) {
213
230
  if (!options.webhook) return notEnabled(c)
214
231
 
215
232
  const body = c.req.valid('json')
233
+ if (
234
+ body.eventType === 'funding:deposit.updated' &&
235
+ !options.webhook.applicationEventTypes?.includes(body.eventType)
236
+ )
237
+ return Response.error(c, {
238
+ code: 'event_type_unsupported',
239
+ message: 'Webhook event type is not available in this deployment',
240
+ status: 400,
241
+ })
216
242
  const chainId = body.chainId
217
243
  const supportedChainIds = new Set(options.webhook.supportedChainIds)
218
244
  if (!supportedChainIds.has(chainId))
@@ -225,20 +251,26 @@ export function webhooks(options: webhooks.Options = {}) {
225
251
  const owner_ = owner(Auth.org(c).id)
226
252
  if ((await Webhooks.countSubscriptions(db, owner_)) >= maxPerOwner)
227
253
  throw new Webhooks.LimitExceededError(maxPerOwner)
228
- const startBlockNumber = Number(await c.get('getClient')(chainId).getBlockNumber())
254
+ const startBlockNumber =
255
+ body.eventType === 'funding:deposit.updated'
256
+ ? undefined
257
+ : Number(await c.get('getClient')(chainId).getBlockNumber())
229
258
  const subscription = await Webhooks.createSubscription(
230
259
  db,
231
260
  {
232
261
  chainId,
233
262
  ...(body.context === undefined ? {} : { context: body.context }),
234
263
  destination: body.destination,
264
+ ...(body.eventType === 'funding:deposit.updated' && body.environment !== undefined
265
+ ? { environment: body.environment }
266
+ : {}),
235
267
  eventType: body.eventType,
236
268
  filters: body.filters,
237
269
  owner: owner_,
238
270
  },
239
271
  {
240
272
  maxPerOwner,
241
- startBlockNumber,
273
+ ...(startBlockNumber === undefined ? {} : { startBlockNumber }),
242
274
  },
243
275
  )
244
276
  return c.json(
@@ -609,6 +641,10 @@ function publicSubscription(subscription: Webhooks.Subscription) {
609
641
  ...(subscription.context === undefined ? {} : { context: subscription.context }),
610
642
  createdAt: subscription.createdAt,
611
643
  destination,
644
+ ...(subscription.eventType === 'funding:deposit.updated' &&
645
+ subscription.environment !== undefined
646
+ ? { environment: subscription.environment }
647
+ : {}),
612
648
  eventType: subscription.eventType,
613
649
  ...(subscription.expiresAt === undefined ? {} : { expiresAt: subscription.expiresAt }),
614
650
  failureCount: subscription.failureCount,
package/src/db/Db.test.ts CHANGED
@@ -71,11 +71,7 @@ describe('postgres', () => {
71
71
  await Organizations.create(tx, { id: 'org_qualified', name: 'Qualified' })
72
72
  })
73
73
  expect((await Organizations.get(db, 'org_qualified'))?.name).toBe('Qualified')
74
- expect(
75
- await WebhookSubscriptions.compareAndSetCursors(db, {
76
- cursors: [{ cursor: 'next', expectedCursor: null, subscriptionId: 'wh_missing' }],
77
- }),
78
- ).toEqual(new Set())
74
+ expect(await WebhookSubscriptions.getCursor(db, 'wh_missing')).toBeNull()
79
75
  await db.close()
80
76
  })
81
77
 
package/src/db/Db.ts CHANGED
@@ -1362,6 +1362,65 @@ export const migrations: readonly Migration[] = [
1362
1362
  sql`DELETE FROM webhook_queue_events WHERE status = 'pending' AND attempting_at IS NULL AND expires_at <= to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`,
1363
1363
  ),
1364
1364
  },
1365
+ {
1366
+ name: '0123_funding_deposit_addresses_owner_list_index',
1367
+ up: (db) =>
1368
+ db.schema
1369
+ .createIndex('funding_deposit_addresses_owner_list_idx')
1370
+ .ifNotExists()
1371
+ .on('fundingDepositAddresses')
1372
+ .columns(['orgId', 'environment', 'createdAt', 'id']),
1373
+ },
1374
+ {
1375
+ name: '0124_funding_deposit_addresses_project_list_index',
1376
+ up: (db) =>
1377
+ db.schema
1378
+ .createIndex('funding_deposit_addresses_project_list_idx')
1379
+ .ifNotExists()
1380
+ .on('fundingDepositAddresses')
1381
+ .columns(['orgId', 'environment', 'projectId', 'createdAt', 'id']),
1382
+ },
1383
+ {
1384
+ name: '0125_funding_deposits_owner_list_index',
1385
+ up: (db) =>
1386
+ db.schema
1387
+ .createIndex('funding_deposits_owner_list_idx')
1388
+ .ifNotExists()
1389
+ .on('fundingDeposits')
1390
+ .columns(['orgId', 'environment', 'createdAt', 'id']),
1391
+ },
1392
+ {
1393
+ name: '0126_funding_deposits_project_list_index',
1394
+ up: (db) =>
1395
+ db.schema
1396
+ .createIndex('funding_deposits_project_list_idx')
1397
+ .ifNotExists()
1398
+ .on('fundingDeposits')
1399
+ .columns(['orgId', 'environment', 'projectId', 'createdAt', 'id']),
1400
+ },
1401
+ {
1402
+ name: '0127_webhook_subscriptions_environment',
1403
+ up: (db) => raw(db, sql`ALTER TABLE webhook_subscriptions ADD COLUMN IF NOT EXISTS environment text`), // prettier-ignore
1404
+ },
1405
+ {
1406
+ name: '0128_webhook_subscriptions_project',
1407
+ up: (db) => raw(db, sql`ALTER TABLE webhook_subscriptions ADD COLUMN IF NOT EXISTS project_id text`), // prettier-ignore
1408
+ },
1409
+ {
1410
+ name: '0129_webhook_subscriptions_funding_deposit_index',
1411
+ up: (db) =>
1412
+ db.schema
1413
+ .createIndex('webhook_subscriptions_funding_deposit_idx')
1414
+ .ifNotExists()
1415
+ .on('webhookSubscriptions')
1416
+ .columns(['eventType', 'ownerType', 'ownerId', 'environment', 'projectId', 'chainId'])
1417
+ .where(sql.ref('status'), '=', sql.lit('active')),
1418
+ },
1419
+ {
1420
+ name: '0130_webhook_queue_events_observed_at',
1421
+ up: (db) =>
1422
+ raw(db, sql`ALTER TABLE webhook_queue_events ADD COLUMN IF NOT EXISTS observed_at text`),
1423
+ },
1365
1424
  ]
1366
1425
 
1367
1426
  /**
package/src/db/Schema.ts CHANGED
@@ -792,6 +792,9 @@ export const WebhookSubscription = z.object({
792
792
  destination: z
793
793
  .custom<Webhooks.Destination>()
794
794
  .check(z.describe('Delivery destination (`jsonb`).')),
795
+ environment: z
796
+ .nullable(z.enum(['production', 'sandbox']))
797
+ .check(z.describe('API-key environment for private resource events, or null.')),
795
798
  eventType: z
796
799
  .custom<Webhooks.EventType>()
797
800
  .check(z.describe('Event type the subscription listens to.')),
@@ -820,6 +823,9 @@ export const WebhookSubscription = z.object({
820
823
  pollerCursor: z
821
824
  .nullable(z.string())
822
825
  .check(z.describe('Poller keyset cursor, or null before the first tick.')),
826
+ projectId: z
827
+ .nullable(z.string())
828
+ .check(z.describe('API-key project attribution for private resource events, or null.')),
823
829
  secret: z.string().check(z.describe('HMAC signing secret.')),
824
830
  status: z.custom<Webhooks.Status>().check(z.describe('Lifecycle status.')),
825
831
  updatedAt: z
@@ -896,6 +902,13 @@ export const WebhookQueueEvent = z.object({
896
902
  nextAttemptAt: z
897
903
  .nullable(z.string())
898
904
  .check(z.describe('ISO retry due time, or null when due immediately.')),
905
+ observedAt: z
906
+ .nullable(z.string())
907
+ .check(
908
+ z.describe(
909
+ 'ISO timestamp the coordinator observed the head owing this event, or null on replays.',
910
+ ),
911
+ ),
899
912
  status: z
900
913
  .enum(['failed', 'pending', 'skipped', 'succeeded'])
901
914
  .check(z.describe('Delivery-obligation lifecycle status.')),
@@ -578,3 +578,31 @@ describe('renewPoll', () => {
578
578
  `)
579
579
  })
580
580
  })
581
+
582
+ describe('list', () => {
583
+ test('pages organization addresses and narrows project-attributed owners', async () => {
584
+ const listOwner = { environment: owner.environment, orgId: 'org_address_list' } as const
585
+ const first = await seed({ orgId: listOwner.orgId })
586
+ const project = await seed({ orgId: listOwner.orgId, projectId: 'prj_address_list' })
587
+ const sibling = await seed({ orgId: listOwner.orgId, projectId: 'prj_address_sibling' })
588
+ await seed({ orgId: 'org_address_list_foreign' })
589
+ await seed({ environment: 'sandbox', orgId: listOwner.orgId })
590
+
591
+ const page = await FundingDepositAddresses.list(db, { limit: 2, owner: listOwner })
592
+ expect(page.map((record) => record.id)).toEqual([sibling.id, project.id])
593
+ const rest = await FundingDepositAddresses.list(db, {
594
+ cursor: { createdAt: project.createdAt, id: project.id },
595
+ limit: 2,
596
+ owner: listOwner,
597
+ })
598
+ expect(rest.map((record) => record.id)).toEqual([first.id])
599
+ expect(
600
+ (
601
+ await FundingDepositAddresses.list(db, {
602
+ limit: 10,
603
+ owner: { ...listOwner, projectId: 'prj_address_list' },
604
+ })
605
+ ).map((record) => record.id),
606
+ ).toEqual([project.id])
607
+ })
608
+ })
@@ -148,6 +148,45 @@ export function getOwned(db: Db.Db, owner: Owner, id: string): Promise<Record |
148
148
  return query.executeTakeFirst()
149
149
  }
150
150
 
151
+ /** Lists owner-visible funding deposit addresses newest-first. */
152
+ export function list(db: Db.Db, options: list.Options): Promise<Record[]> {
153
+ const { cursor, limit, owner } = options
154
+ let query = db.kysely
155
+ .selectFrom('funding_deposit_addresses')
156
+ .selectAll()
157
+ .where('environment', '=', owner.environment)
158
+ .where('orgId', '=', owner.orgId)
159
+ if (owner.projectId !== undefined) query = query.where('projectId', '=', owner.projectId)
160
+ if (cursor)
161
+ query = query.where((eb) =>
162
+ eb.or([
163
+ eb('createdAt', '<', cursor.createdAt),
164
+ eb.and([eb('createdAt', '=', cursor.createdAt), eb('id', '<', cursor.id)]),
165
+ ]),
166
+ )
167
+ return query.orderBy('createdAt', 'desc').orderBy('id', 'desc').limit(limit).execute()
168
+ }
169
+
170
+ export declare namespace list {
171
+ /** Ownership and paging options for funding deposit addresses. */
172
+ type Options = {
173
+ /** Exclusive lower bound from the previous page. */
174
+ cursor?: Cursor | undefined
175
+ /** Maximum rows to return. */
176
+ limit: number
177
+ /** Ownership filter derived from the authenticated API key. */
178
+ owner: Owner
179
+ }
180
+
181
+ /** Cursor fields for the last address returned by the previous page. */
182
+ type Cursor = {
183
+ /** Deposit address creation time. */
184
+ createdAt: string
185
+ /** Deposit address id, used as a deterministic tie-breaker. */
186
+ id: string
187
+ }
188
+ }
189
+
151
190
  /** Gets the reusable address that matches one owner and route identity. */
152
191
  export function getMatching(db: Db.Db, match: Match): Promise<Record | undefined> {
153
192
  let query = db.kysely
@@ -498,3 +498,85 @@ describe('toPublic', () => {
498
498
  })
499
499
  })
500
500
  })
501
+
502
+ describe('list', () => {
503
+ test('pages organization deposits and narrows project-attributed owners', async () => {
504
+ const listOwner = { environment: owner.environment, orgId: 'org_deposit_list' } as const
505
+ const firstAddress = await seedAddress({ orgId: listOwner.orgId })
506
+ const projectAddress = await seedAddress({
507
+ orgId: listOwner.orgId,
508
+ projectId: 'prj_deposit_list',
509
+ })
510
+ const siblingAddress = await seedAddress({
511
+ orgId: listOwner.orgId,
512
+ projectId: 'prj_deposit_sibling',
513
+ })
514
+ const first = await seedDeposit(firstAddress)
515
+ const project = await seedDeposit(projectAddress)
516
+ const sibling = await seedDeposit(siblingAddress)
517
+ const foreignAddress = await seedAddress({ orgId: 'org_deposit_list_foreign' })
518
+ await seedDeposit(foreignAddress)
519
+ const sandboxAddress = await seedAddress({
520
+ environment: 'sandbox',
521
+ orgId: listOwner.orgId,
522
+ })
523
+ await seedDeposit(sandboxAddress)
524
+
525
+ const page = await FundingDeposits.list(db, { limit: 2, owner: listOwner })
526
+ expect(page.map((record) => record.id)).toEqual([sibling.id, project.id])
527
+ const rest = await FundingDeposits.list(db, {
528
+ cursor: { createdAt: project.createdAt, id: project.id },
529
+ limit: 2,
530
+ owner: listOwner,
531
+ })
532
+ expect(rest.map((record) => record.id)).toEqual([first.id])
533
+ expect(
534
+ (
535
+ await FundingDeposits.list(db, {
536
+ limit: 10,
537
+ owner: { ...listOwner, projectId: 'prj_deposit_list' },
538
+ })
539
+ ).map((record) => record.id),
540
+ ).toEqual([project.id])
541
+ })
542
+
543
+ test('AND-combines filters and counts through the same ownership scope', async () => {
544
+ const listOwner = { environment: owner.environment, orgId: 'org_deposit_filters' } as const
545
+ const recipient = `0x${'ab'.repeat(20)}`
546
+ const address = await seedAddress({
547
+ orgId: listOwner.orgId,
548
+ snapshot: TestFunding.depositAddressSnapshot({
549
+ provider: { id: 'relay_filters', name: 'Relay' },
550
+ recipient,
551
+ }),
552
+ })
553
+ const match = await seedDeposit(address)
554
+ const unrelatedAddress = await seedAddress({ orgId: listOwner.orgId })
555
+ await seedDeposit(unrelatedAddress)
556
+ const filters = {
557
+ depositAddress: address.address,
558
+ destinationTokenKeys: [address.destinationTokenKey],
559
+ providerId: address.providerId,
560
+ recipient: recipient.toLowerCase(),
561
+ sourceChainIds: [address.sourceChainId],
562
+ sourceTokenKeys: [address.sourceTokenKey],
563
+ status: 'detected',
564
+ } as const
565
+
566
+ expect(
567
+ (await FundingDeposits.list(db, { ...filters, limit: 10, owner: listOwner })).map(
568
+ (record) => record.id,
569
+ ),
570
+ ).toEqual([match.id])
571
+ expect(await FundingDeposits.count(db, { ...filters, cap: 10, owner: listOwner })).toBe(1)
572
+ expect(await FundingDeposits.count(db, { ...filters, cap: 0, owner: listOwner })).toBe(1)
573
+ expect(
574
+ await FundingDeposits.count(db, {
575
+ ...filters,
576
+ destinationTokenKeys: [],
577
+ cap: 10,
578
+ owner: listOwner,
579
+ }),
580
+ ).toBe(0)
581
+ })
582
+ })
@@ -27,6 +27,24 @@ export type Owner = {
27
27
  projectId?: string | undefined
28
28
  }
29
29
 
30
+ /** Optional filters shared by funding deposit list and count queries. */
31
+ export type Filters = {
32
+ /** Reusable source-chain address that received the funds. */
33
+ depositAddress?: string | undefined
34
+ /** Canonical destination token keys accepted by the filter. */
35
+ destinationTokenKeys?: readonly string[] | undefined
36
+ /** Funding provider id. */
37
+ providerId?: string | undefined
38
+ /** Tempo account that receives completed deposits. */
39
+ recipient?: string | undefined
40
+ /** Canonical source chain ids accepted by the filter. */
41
+ sourceChainIds?: readonly string[] | undefined
42
+ /** Canonical source token keys accepted by the filter. */
43
+ sourceTokenKeys?: readonly string[] | undefined
44
+ /** Deposit lifecycle status. */
45
+ status?: Record['status'] | undefined
46
+ }
47
+
30
48
  /** Inserts one provider-observed source transfer as a funding deposit. */
31
49
  export function insert(db: Db.Db, record: Record): Promise<Record> {
32
50
  return db.kysely
@@ -234,6 +252,118 @@ export function getOwned(db: Db.Db, owner: Owner, id: string): Promise<Record |
234
252
  return query.executeTakeFirst()
235
253
  }
236
254
 
255
+ /** Lists owner-visible funding deposits newest-first. */
256
+ export function list(db: Db.Db, options: list.Options): Promise<Record[]> {
257
+ const { cursor, limit } = options
258
+ let builder = query(db, options).selectAll('funding_deposits')
259
+ if (cursor)
260
+ builder = builder.where((eb) =>
261
+ eb.or([
262
+ eb('funding_deposits.createdAt', '<', cursor.createdAt),
263
+ eb.and([
264
+ eb('funding_deposits.createdAt', '=', cursor.createdAt),
265
+ eb('funding_deposits.id', '<', cursor.id),
266
+ ]),
267
+ ]),
268
+ )
269
+ return builder
270
+ .orderBy('funding_deposits.createdAt', 'desc')
271
+ .orderBy('funding_deposits.id', 'desc')
272
+ .limit(limit)
273
+ .execute()
274
+ }
275
+
276
+ export declare namespace list {
277
+ /** Ownership and paging options for funding deposits. */
278
+ type Options = Filters & {
279
+ /** Exclusive lower bound from the previous page. */
280
+ cursor?: listByAddress.Cursor | undefined
281
+ /** Maximum rows to return. */
282
+ limit: number
283
+ /** Ownership filter derived from the authenticated API key. */
284
+ owner: Owner
285
+ }
286
+ }
287
+
288
+ /** Counts owner-visible funding deposits through the list's shared filters. */
289
+ export async function count(db: Db.Db, options: count.Options): Promise<number> {
290
+ const rows = query(db, options)
291
+ .select('funding_deposits.id')
292
+ .limit(options.cap + 1)
293
+ .as('rows')
294
+ const result = await db.kysely
295
+ .selectFrom(rows)
296
+ .select(sql<number>`count(*)::int`.as('count'))
297
+ .executeTakeFirstOrThrow()
298
+ return result.count
299
+ }
300
+
301
+ export declare namespace count {
302
+ /** Ownership, filter, and cap options for counting funding deposits. */
303
+ type Options = Filters & {
304
+ /** Maximum exact count before returning the cap plus one. */
305
+ cap: number
306
+ /** Ownership filter derived from the authenticated API key. */
307
+ owner: Owner
308
+ }
309
+ }
310
+
311
+ function query(db: Db.Db, options: query.Options) {
312
+ const owner = options.owner
313
+ let builder = db.kysely
314
+ .selectFrom('funding_deposits')
315
+ .innerJoin(
316
+ 'funding_deposit_addresses',
317
+ 'funding_deposit_addresses.id',
318
+ 'funding_deposits.depositAddressId',
319
+ )
320
+ .where('funding_deposit_addresses.environment', '=', owner.environment)
321
+ .where('funding_deposit_addresses.orgId', '=', owner.orgId)
322
+ .where('funding_deposits.environment', '=', owner.environment)
323
+ .where('funding_deposits.orgId', '=', owner.orgId)
324
+ if (owner.projectId !== undefined)
325
+ builder = builder
326
+ .where('funding_deposit_addresses.projectId', '=', owner.projectId)
327
+ .where('funding_deposits.projectId', '=', owner.projectId)
328
+ if (options.depositAddress !== undefined) {
329
+ const address = /^0x[0-9a-fA-F]{40}$/.test(options.depositAddress)
330
+ ? options.depositAddress.toLowerCase()
331
+ : options.depositAddress
332
+ builder = builder.where('funding_deposit_addresses.address', '=', address)
333
+ }
334
+ if (options.destinationTokenKeys !== undefined)
335
+ builder = options.destinationTokenKeys.length
336
+ ? builder.where(
337
+ 'funding_deposit_addresses.destinationTokenKey',
338
+ 'in',
339
+ options.destinationTokenKeys,
340
+ )
341
+ : builder.where(sql<boolean>`false`)
342
+ if (options.providerId !== undefined)
343
+ builder = builder.where('funding_deposit_addresses.providerId', '=', options.providerId)
344
+ if (options.recipient !== undefined)
345
+ builder = builder.where(
346
+ sql<boolean>`lower(funding_deposit_addresses.recipient) = ${options.recipient.toLowerCase()}`,
347
+ )
348
+ if (options.sourceChainIds !== undefined)
349
+ builder = options.sourceChainIds.length
350
+ ? builder.where('funding_deposit_addresses.sourceChainId', 'in', options.sourceChainIds)
351
+ : builder.where(sql<boolean>`false`)
352
+ if (options.sourceTokenKeys !== undefined)
353
+ builder = options.sourceTokenKeys.length
354
+ ? builder.where('funding_deposit_addresses.sourceTokenKey', 'in', options.sourceTokenKeys)
355
+ : builder.where(sql<boolean>`false`)
356
+ if (options.status !== undefined)
357
+ builder = builder.where('funding_deposits.status', '=', options.status)
358
+ return builder
359
+ }
360
+
361
+ declare namespace query {
362
+ type Options = Filters & {
363
+ owner: Owner
364
+ }
365
+ }
366
+
237
367
  /** Lists one owner-visible address's deposits newest-first. */
238
368
  export function listByAddress(
239
369
  db: Db.Db,
@@ -363,8 +363,11 @@ describe('deleteOrganization', () => {
363
363
  expect(await Organizations.deleteOrganization(db, 'org_1')).toBe(true)
364
364
 
365
365
  expect(await Webhooks.listSubscriptions(db, deletedOrg)).toEqual([])
366
- const active = await Webhooks.listActive(db, 4217, 'token:transfer')
367
- expect(active.map(({ id }) => id).sort()).toEqual(
366
+ const active = await Webhooks.listActiveForChain(db, {
367
+ chainId: 4217,
368
+ eventTypes: ['token:transfer'],
369
+ })
370
+ expect(active.map(({ subscription }) => subscription.id).sort()).toEqual(
368
371
  [keptApiKey.id, keptMppSubscription.id].sort(),
369
372
  )
370
373
  await db.close()
@@ -7,7 +7,7 @@ import type * as db_Schema from '../Schema.js'
7
7
  /** Columns of the `webhook_queue_events` table, derived from `Schema.WebhookQueueEvent`. */
8
8
  export type Table = Omit<
9
9
  db_Schema.WebhookQueueEvent,
10
- 'attemptCount' | 'attemptingAt' | 'nextAttemptAt' | 'status'
10
+ 'attemptCount' | 'attemptingAt' | 'nextAttemptAt' | 'observedAt' | 'status'
11
11
  > & {
12
12
  /** Delivery attempts claimed so far; database default 0. */
13
13
  attemptCount: Generated<number>
@@ -15,6 +15,8 @@ export type Table = Omit<
15
15
  attemptingAt: Generated<string | null>
16
16
  /** ISO retry due time; database default null (due immediately). */
17
17
  nextAttemptAt: Generated<string | null>
18
+ /** ISO head-observation stamp; database default null (replays carry none). */
19
+ observedAt: Generated<string | null>
18
20
  /** Delivery-obligation lifecycle status; database default `pending`. */
19
21
  status: Generated<db_Schema.WebhookQueueEvent['status']>
20
22
  }
@@ -65,6 +67,10 @@ export async function ensure(db: Db.Db, rows: readonly Insertable<Table>[]): Pro
65
67
  envelope: eb.ref('excluded.envelope'),
66
68
  expiresAt: eb.ref('excluded.expiresAt'),
67
69
  nextAttemptAt: eb.ref('excluded.nextAttemptAt'),
70
+ // Refreshed with the rest: a replacement keeping the replaced row's
71
+ // stamp would measure observation-to-attempt from an observation
72
+ // that belongs to a delivery made days earlier.
73
+ observedAt: eb.ref('excluded.observedAt'),
68
74
  status: 'pending',
69
75
  }))
70
76
  .where((eb) =>