tapimo 0.5.0 → 0.5.2

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 (197) hide show
  1. package/dist/App.d.ts +4854 -4829
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/App.js +20 -2
  4. package/dist/App.js.map +1 -1
  5. package/dist/Assets.js +3 -3
  6. package/dist/Assets.js.map +1 -1
  7. package/dist/Scope.d.ts.map +1 -1
  8. package/dist/Scope.js +1 -2
  9. package/dist/Scope.js.map +1 -1
  10. package/dist/Webhooks.d.ts +1 -1
  11. package/dist/Webhooks.d.ts.map +1 -1
  12. package/dist/Webhooks.js +5 -10
  13. package/dist/Webhooks.js.map +1 -1
  14. package/dist/admin/App.d.ts +6 -34
  15. package/dist/admin/App.d.ts.map +1 -1
  16. package/dist/admin/apps/api-keys.d.ts +3 -3
  17. package/dist/admin/apps/api-keys.d.ts.map +1 -1
  18. package/dist/admin/apps/api-keys.js +4 -1
  19. package/dist/admin/apps/api-keys.js.map +1 -1
  20. package/dist/admin/apps/scopes.d.ts +6 -34
  21. package/dist/admin/apps/scopes.d.ts.map +1 -1
  22. package/dist/admin/apps/scopes.js +2 -2
  23. package/dist/admin/apps/scopes.js.map +1 -1
  24. package/dist/admin/apps/verified-tokens.d.ts +2 -2
  25. package/dist/admin/ui.generated.d.ts +1 -1
  26. package/dist/admin/ui.generated.js +33 -33
  27. package/dist/admin/ui.generated.js.map +1 -1
  28. package/dist/apps/data/App.d.ts +1398 -1405
  29. package/dist/apps/data/App.d.ts.map +1 -1
  30. package/dist/apps/data/App.js +25 -38
  31. package/dist/apps/data/App.js.map +1 -1
  32. package/dist/apps/data/FundingQuoteProviders.d.ts +108 -3
  33. package/dist/apps/data/FundingQuoteProviders.d.ts.map +1 -1
  34. package/dist/apps/data/FundingQuoteProviders.js +409 -2
  35. package/dist/apps/data/FundingQuoteProviders.js.map +1 -1
  36. package/dist/apps/data/routes/activities.d.ts +245 -245
  37. package/dist/apps/data/routes/blocks.d.ts +123 -123
  38. package/dist/apps/data/routes/funding.d.ts +236 -146
  39. package/dist/apps/data/routes/funding.d.ts.map +1 -1
  40. package/dist/apps/data/routes/funding.js +23 -12
  41. package/dist/apps/data/routes/funding.js.map +1 -1
  42. package/dist/apps/data/routes/indexer.d.ts.map +1 -1
  43. package/dist/apps/data/routes/indexer.js +9 -2
  44. package/dist/apps/data/routes/indexer.js.map +1 -1
  45. package/dist/apps/data/routes/rpc.d.ts +31 -31
  46. package/dist/apps/data/routes/tokenlist.d.ts +36 -36
  47. package/dist/apps/data/routes/transactions.d.ts +281 -281
  48. package/dist/apps/data/routes/transfers.d.ts +66 -73
  49. package/dist/apps/data/routes/transfers.d.ts.map +1 -1
  50. package/dist/apps/data/routes/transfers.js +21 -27
  51. package/dist/apps/data/routes/transfers.js.map +1 -1
  52. package/dist/apps/data/routes/verified-tokens.d.ts +86 -86
  53. package/dist/apps/data/routes/webhooks.d.ts +35 -35
  54. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  55. package/dist/apps/data/routes/webhooks.js +176 -42
  56. package/dist/apps/data/routes/webhooks.js.map +1 -1
  57. package/dist/apps/management/App.d.ts +520 -504
  58. package/dist/apps/management/App.d.ts.map +1 -1
  59. package/dist/apps/management/Billing.d.ts.map +1 -1
  60. package/dist/apps/management/Billing.js +5 -4
  61. package/dist/apps/management/Billing.js.map +1 -1
  62. package/dist/apps/management/routes/api-keys.d.ts +120 -120
  63. package/dist/apps/management/routes/billing.d.ts +211 -211
  64. package/dist/apps/management/routes/billing.d.ts.map +1 -1
  65. package/dist/apps/management/routes/billing.js +1 -1
  66. package/dist/apps/management/routes/billing.js.map +1 -1
  67. package/dist/apps/management/routes/invitations.d.ts +72 -72
  68. package/dist/apps/management/routes/invite-links.d.ts +159 -159
  69. package/dist/apps/management/routes/me.d.ts +17 -17
  70. package/dist/apps/management/routes/members.d.ts +80 -80
  71. package/dist/apps/management/routes/orgs.d.ts +59 -43
  72. package/dist/apps/management/routes/orgs.d.ts.map +1 -1
  73. package/dist/apps/management/routes/orgs.js +18 -1
  74. package/dist/apps/management/routes/orgs.js.map +1 -1
  75. package/dist/apps/management/routes/projects.d.ts +109 -109
  76. package/dist/apps/management/routes/scopes.d.ts +11 -11
  77. package/dist/apps/management/routes/usage.d.ts +79 -79
  78. package/dist/apps/mcp.js +2 -2
  79. package/dist/apps/mcp.js.map +1 -1
  80. package/dist/apps/relay/App.d.ts +2 -2
  81. package/dist/apps/relay/App.js +2 -2
  82. package/dist/apps/relay/Sponsorships.d.ts +3 -0
  83. package/dist/apps/relay/Sponsorships.d.ts.map +1 -1
  84. package/dist/apps/relay/Sponsorships.js +15 -1
  85. package/dist/apps/relay/Sponsorships.js.map +1 -1
  86. package/dist/db/Db.d.ts.map +1 -1
  87. package/dist/db/Db.js +4 -0
  88. package/dist/db/Db.js.map +1 -1
  89. package/dist/db/Schema.js +1 -1
  90. package/dist/db/Schema.js.map +1 -1
  91. package/dist/db/tables/organizations.d.ts +6 -0
  92. package/dist/db/tables/organizations.d.ts.map +1 -1
  93. package/dist/db/tables/organizations.js +26 -0
  94. package/dist/db/tables/organizations.js.map +1 -1
  95. package/dist/db/tables/requestUsage.d.ts +2 -11
  96. package/dist/db/tables/requestUsage.d.ts.map +1 -1
  97. package/dist/db/tables/requestUsage.js +1 -20
  98. package/dist/db/tables/requestUsage.js.map +1 -1
  99. package/dist/db/tables/sponsoredTransactions.d.ts +2 -1
  100. package/dist/db/tables/sponsoredTransactions.d.ts.map +1 -1
  101. package/dist/db/tables/sponsoredTransactions.js +52 -10
  102. package/dist/db/tables/sponsoredTransactions.js.map +1 -1
  103. package/dist/internal/Auth.d.ts +4 -0
  104. package/dist/internal/Auth.d.ts.map +1 -1
  105. package/dist/internal/Auth.js +15 -8
  106. package/dist/internal/Auth.js.map +1 -1
  107. package/dist/internal/Cache.d.ts.map +1 -1
  108. package/dist/internal/Cache.js +9 -1
  109. package/dist/internal/Cache.js.map +1 -1
  110. package/dist/internal/EdgeCache.d.ts.map +1 -1
  111. package/dist/internal/EdgeCache.js +12 -3
  112. package/dist/internal/EdgeCache.js.map +1 -1
  113. package/dist/internal/FundingQuotes.d.ts +176 -56
  114. package/dist/internal/FundingQuotes.d.ts.map +1 -1
  115. package/dist/internal/FundingQuotes.js +100 -5
  116. package/dist/internal/FundingQuotes.js.map +1 -1
  117. package/dist/internal/Log.d.ts +18 -12
  118. package/dist/internal/Log.d.ts.map +1 -1
  119. package/dist/internal/Log.js +2 -0
  120. package/dist/internal/Log.js.map +1 -1
  121. package/dist/internal/RateLimit.d.ts +2 -0
  122. package/dist/internal/RateLimit.d.ts.map +1 -1
  123. package/dist/internal/RateLimit.js +4 -4
  124. package/dist/internal/RateLimit.js.map +1 -1
  125. package/dist/internal/Tidx.d.ts +9 -3
  126. package/dist/internal/Tidx.d.ts.map +1 -1
  127. package/dist/internal/Tidx.js +13 -7
  128. package/dist/internal/Tidx.js.map +1 -1
  129. package/dist/internal/Viem.d.ts +14 -1
  130. package/dist/internal/Viem.d.ts.map +1 -1
  131. package/dist/internal/Viem.js +19 -1
  132. package/dist/internal/Viem.js.map +1 -1
  133. package/package.json +7 -2
  134. package/src/ApiKeys.test.ts +2 -0
  135. package/src/App.test.ts +15 -4
  136. package/src/App.ts +31 -5
  137. package/src/Assets.ts +3 -3
  138. package/src/Client.test-d.ts +30 -3
  139. package/src/Scope.ts +4 -2
  140. package/src/Webhooks.ts +5 -10
  141. package/src/admin/apps/api-keys.test.ts +27 -0
  142. package/src/admin/apps/api-keys.ts +8 -1
  143. package/src/admin/apps/scopes.test.ts +7 -2
  144. package/src/admin/apps/scopes.ts +2 -2
  145. package/src/admin/ui/src/components/Dialog.tsx +8 -2
  146. package/src/admin/ui/src/lib/scopes.test.ts +19 -0
  147. package/src/admin/ui/src/lib/scopes.ts +12 -0
  148. package/src/admin/ui/src/routes/api-keys.tsx +128 -27
  149. package/src/admin/ui.generated.ts +33 -33
  150. package/src/apps/composition.test.ts +77 -0
  151. package/src/apps/data/App.ts +25 -45
  152. package/src/apps/data/FundingQuoteProviders.test.ts +615 -5
  153. package/src/apps/data/FundingQuoteProviders.ts +570 -4
  154. package/src/apps/data/routes/funding.test.ts +181 -7
  155. package/src/apps/data/routes/funding.ts +27 -12
  156. package/src/apps/data/routes/indexer.test.ts +82 -20
  157. package/src/apps/data/routes/indexer.ts +8 -2
  158. package/src/apps/data/routes/tokens.test.ts +54 -0
  159. package/src/apps/data/routes/transfers.test.ts +73 -1
  160. package/src/apps/data/routes/transfers.ts +24 -30
  161. package/src/apps/data/routes/webhooks.test.ts +203 -0
  162. package/src/apps/data/routes/webhooks.ts +191 -54
  163. package/src/apps/management/Billing.test.ts +52 -3
  164. package/src/apps/management/Billing.ts +5 -4
  165. package/src/apps/management/routes/billing.ts +6 -1
  166. package/src/apps/management/routes/orgs.ts +19 -1
  167. package/src/apps/mcp.test.ts +18 -0
  168. package/src/apps/mcp.ts +2 -2
  169. package/src/apps/relay/App.test.ts +6 -5
  170. package/src/apps/relay/App.ts +2 -2
  171. package/src/apps/relay/Sponsorships.test.ts +2 -2
  172. package/src/apps/relay/Sponsorships.ts +24 -1
  173. package/src/db/Db.test.ts +5 -5
  174. package/src/db/Db.ts +4 -0
  175. package/src/db/Schema.ts +1 -1
  176. package/src/db/tables/billingSettings.test.ts +2 -2
  177. package/src/db/tables/organizations.test.ts +32 -2
  178. package/src/db/tables/organizations.ts +28 -0
  179. package/src/db/tables/requestUsage.ts +3 -23
  180. package/src/db/tables/sponsoredTransactions.test.ts +60 -2
  181. package/src/db/tables/sponsoredTransactions.ts +67 -21
  182. package/src/db/tables/stripeCustomers.test.ts +2 -2
  183. package/src/internal/Auth.test.ts +63 -10
  184. package/src/internal/Auth.ts +21 -9
  185. package/src/internal/Cache.test.ts +19 -0
  186. package/src/internal/Cache.ts +13 -1
  187. package/src/internal/EdgeCache.test.ts +40 -0
  188. package/src/internal/EdgeCache.ts +11 -3
  189. package/src/internal/FundingQuotes.test.ts +130 -3
  190. package/src/internal/FundingQuotes.ts +113 -6
  191. package/src/internal/Log.ts +21 -14
  192. package/src/internal/RateLimit.test.ts +12 -0
  193. package/src/internal/RateLimit.ts +4 -4
  194. package/src/internal/Tidx.test.ts +48 -0
  195. package/src/internal/Tidx.ts +21 -7
  196. package/src/internal/Viem.test.ts +46 -0
  197. package/src/internal/Viem.ts +34 -1
@@ -271,7 +271,24 @@ describe('GET /transfers', () => {
271
271
  },
272
272
  )
273
273
 
274
- test('embeds a capped total count on demand via include=totalCount', async () => {
274
+ test('embeds a capped unscoped total count on demand', async () => {
275
+ const client = TestApp.client()
276
+ const response = await client.v1.transfers.$get(
277
+ { query: { include: 'totalCount', limit: '5' } },
278
+ TestApp.auth,
279
+ )
280
+ const body = await TestApp.json(response, Transfers.schema.getTransfers.Response)
281
+
282
+ expect(response.status).toBe(200)
283
+ expect(response.headers.get('server-timing')?.includes('transfers_count;dur=')).toBe(true)
284
+ expect(body.meta).toBeDefined()
285
+ expect(Number.isInteger(body.meta?.totalCount)).toBe(true)
286
+ expect(body.meta!.totalCount).toBeGreaterThanOrEqual(body.data.length)
287
+ expect(body.meta!.totalCount).toBeLessThanOrEqual(Schema.countCap)
288
+ expect(body.meta!.totalCountCapped).toBe(body.meta!.totalCount >= Schema.countCap)
289
+ })
290
+
291
+ test('embeds a capped token-scoped total count on demand', async () => {
275
292
  const client = TestApp.client()
276
293
 
277
294
  if (Runtime.get().mode === 'localnet') expect(TestApp.token).toBeDefined()
@@ -350,6 +367,61 @@ describe('GET /transfers', () => {
350
367
  },
351
368
  )
352
369
 
370
+ test.skipIf(Runtime.get().mode !== 'localnet')(
371
+ 'counts unscoped cross-token transfers',
372
+ async () => {
373
+ const crossToken = Runtime.get().fixtures?.crossTokenTransfer
374
+ expect(crossToken).toBeDefined()
375
+ if (!crossToken) return
376
+
377
+ const client = TestApp.client()
378
+ const response = await client.v1.transfers.$get(
379
+ {
380
+ query: {
381
+ address: crossToken.sender,
382
+ include: 'crossToken,totalCount',
383
+ limit: '50',
384
+ },
385
+ },
386
+ TestApp.auth,
387
+ )
388
+ const body = await TestApp.json(response, Transfers.schema.getTransfers.Response)
389
+
390
+ expect(response.status).toBe(200)
391
+ expect(response.headers.get('server-timing')?.includes('transfers_count;dur=')).toBe(true)
392
+ expect(body.data.some((entry) => entry.transactionHash === crossToken.hash)).toBe(true)
393
+ expect(body.meta).toBeDefined()
394
+ expect(body.meta!.totalCount).toBeGreaterThanOrEqual(body.data.length)
395
+ },
396
+ )
397
+
398
+ test.skipIf(Runtime.get().mode !== 'localnet')(
399
+ 'counts token-scoped cross-token transfers',
400
+ async () => {
401
+ const crossToken = Runtime.get().fixtures?.crossTokenTransfer
402
+ expect(crossToken).toBeDefined()
403
+ if (!crossToken) return
404
+
405
+ const client = TestApp.client()
406
+ const response = await client.v1.transfers.$get(
407
+ {
408
+ query: {
409
+ include: 'crossToken,totalCount',
410
+ limit: '50',
411
+ token: crossToken.destinationToken,
412
+ },
413
+ },
414
+ TestApp.auth,
415
+ )
416
+ const body = await TestApp.json(response, Transfers.schema.getTransfers.Response)
417
+
418
+ expect(response.status).toBe(200)
419
+ expect(response.headers.get('server-timing')?.includes('transfers_count;dur=')).toBe(true)
420
+ expect(body.meta).toBeDefined()
421
+ expect(body.meta!.totalCount).toBeGreaterThanOrEqual(body.data.length)
422
+ },
423
+ )
424
+
353
425
  test('surfaces a 502 when transfer data is unavailable', async () => {
354
426
  // Point at an unreachable indexer so the upstream query fails deterministically.
355
427
  const client = TestApp.client({ tidx: { baseUrl: 'http://127.0.0.1:1' } })
@@ -565,15 +565,15 @@ export declare namespace query {
565
565
  }
566
566
 
567
567
  /**
568
- * Context-free core of {@link query}: builds the signature-decoded `Transfer`
569
- * CTE query, applies keyset pagination on `(block_num, log_idx)`, and decodes
570
- * rows. Used by the Hono routes (via {@link query}, which adds Server-Timing +
571
- * memoization) and by the webhook poller (uncached, ascending, per-row cursors).
568
+ * Context-free core of {@link query}: reads token-scoped rows from
569
+ * `token_transfers`, otherwise the decoded `Transfer` CTE, then paginates and
570
+ * decodes them. Also used by the webhook poller.
572
571
  */
573
572
  export async function scan(deps: scan.Deps, options: scan.Options): Promise<scan.Page> {
574
573
  const { chainId, limit, order } = options
575
574
  const { tidx } = deps
576
575
  const direction = order === 'asc' ? 'ASC' : 'DESC'
576
+ const tokenScoped = options.token !== undefined
577
577
  // Keyset pagination: anchor the page below the previous row's `(block, log_idx)`
578
578
  // position instead of a numeric offset, so rows arriving at the head can't
579
579
  // shift items across pages. A malformed cursor falls back to the head page.
@@ -598,19 +598,17 @@ export async function scan(deps: scan.Deps, options: scan.Options): Promise<scan
598
598
  )
599
599
  const where = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : ''
600
600
 
601
- // Fetch one extra row to detect `hasMore` without a separate count query. The
602
- // global minimum page size (`Schema.minLimit`) keeps this at or above the
603
- // indexer's floor for signature-decoded `Transfer` queries.
601
+ // Fetch one extra row to detect `hasMore` without a separate count query.
604
602
  const result = await tidx.fetch({
605
603
  chainId,
604
+ ...(tokenScoped ? { engine: 'clickhouse' } : { signatures: [signature] }),
606
605
  query: `
607
- SELECT "from", "to", address, value, tx_hash, block_num, log_idx, block_timestamp
608
- FROM Transfer
606
+ SELECT "from", "to", ${tokenScoped ? 'token AS address, amount AS value' : 'address, value'}, tx_hash, block_num, log_idx, block_timestamp
607
+ FROM ${tokenScoped ? 'token_transfers' : 'Transfer'}
609
608
  ${where}
610
609
  ORDER BY block_num ${direction}, log_idx ${direction}
611
610
  LIMIT ${limit + 1}${offset !== undefined ? ` OFFSET ${offset}` : ''}
612
- `,
613
- signatures: [signature],
611
+ ` as string,
614
612
  })
615
613
 
616
614
  // The next page anchors below the last fetched row's `(block, log_idx)`.
@@ -687,17 +685,14 @@ export declare namespace scan {
687
685
  }
688
686
 
689
687
  /**
690
- * Builds the shared `WHERE` filter clauses for the `Transfer` feed: contract /
691
- * subject / sender / recipient / block / timestamp predicates. Shared verbatim
692
- * by the page query ({@link scan}) and the capped count ({@link countScan}) so a
693
- * `totalCount` always matches the page it annotates; the keyset cursor clause is
694
- * page-only and added by {@link scan} on top of these.
688
+ * Builds shared transfer filters. A token predicate targets `token_transfers`'s
689
+ * physical `token` column; unscoped queries omit it and use the `Transfer` CTE.
695
690
  */
696
691
  function filterClauses(options: filterClauses.Options): string[] {
697
692
  const { eitherSide, fromBlock, fromTimestamp, recipient, sender, toBlock, toTimestamp, token } =
698
693
  options
699
694
  const filters: string[] = []
700
- if (token !== undefined) filters.push(`address = '${token}'`)
695
+ if (token !== undefined) filters.push(`token = '${token}'`)
701
696
  if (eitherSide !== undefined) filters.push(`("from" = '${eitherSide}' OR "to" = '${eitherSide}')`)
702
697
  if (sender !== undefined) filters.push(`"from" = '${sender}'`)
703
698
  if (recipient !== undefined) filters.push(`"to" = '${recipient}'`)
@@ -766,12 +761,9 @@ export declare namespace count {
766
761
  }
767
762
 
768
763
  /**
769
- * Context-free core of {@link count}: runs a capped `count(*)` over the
770
- * signature-decoded `Transfer` CTE under the shared {@link filterClauses}. The
771
- * inner `LIMIT Schema.countCap` bounds the scan so the aggregate can't exceed
772
- * the indexer's execution budget on the 10⁹-plus row table (an unbounded count
773
- * there blows the 5s `max_execution_time`); a result equal to the cap is a
774
- * lower bound, surfaced as `totalCountCapped: true`.
764
+ * Context-free core of {@link count}: counts token-scoped rows from
765
+ * `token_transfers`, otherwise the decoded `Transfer` CTE. The capped inner
766
+ * scan bounds work; the SQL aggregate runs on ClickHouse.
775
767
  */
776
768
  export async function countScan(
777
769
  deps: scan.Deps,
@@ -780,6 +772,7 @@ export async function countScan(
780
772
  const { chainId } = options
781
773
  const { tidx } = deps
782
774
  const filters = filterClauses(options)
775
+ const tokenScoped = options.token !== undefined
783
776
  const where = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : ''
784
777
 
785
778
  // Folded count: cross-token transfers collapse several `Transfer` legs into
@@ -789,14 +782,14 @@ export async function countScan(
789
782
  if (options.crossToken) {
790
783
  const result = await tidx.fetch({
791
784
  chainId,
785
+ ...(tokenScoped ? { engine: 'clickhouse' } : { signatures: [signature] }),
792
786
  query: `
793
- SELECT tx_hash, log_idx, "from", "to", address, value
794
- FROM Transfer
787
+ SELECT tx_hash, log_idx, "from", "to", ${tokenScoped ? 'token AS address, amount AS value' : 'address, value'}
788
+ FROM ${tokenScoped ? 'token_transfers' : 'Transfer'}
795
789
  ${where}
796
790
  ORDER BY block_num, log_idx
797
791
  LIMIT ${Schema.countCap}
798
- `,
799
- signatures: [signature],
792
+ ` as string,
800
793
  })
801
794
  const rawCount = result.rows.length
802
795
  let suppressed = 0
@@ -809,16 +802,17 @@ export async function countScan(
809
802
 
810
803
  const result = await tidx.fetch({
811
804
  chainId,
805
+ engine: 'clickhouse',
806
+ ...(tokenScoped ? {} : { signatures: [signature] }),
812
807
  query: `
813
808
  SELECT count(*) AS total
814
809
  FROM (
815
810
  SELECT 1
816
- FROM Transfer
811
+ FROM ${tokenScoped ? 'token_transfers' : 'Transfer'}
817
812
  ${where}
818
813
  LIMIT ${Schema.countCap}
819
814
  ) AS capped
820
- `,
821
- signatures: [signature],
815
+ ` as string,
822
816
  })
823
817
 
824
818
  const totalCount = Value.toNumber(result.rows[0]?.['total']) ?? 0
@@ -50,6 +50,49 @@ const validBody = {
50
50
  eventType: 'token:transfer',
51
51
  } as const
52
52
 
53
+ describe('log filter selectivity', () => {
54
+ const address = '0x0000000000000000000000000000000000000001'
55
+ const signature = 'event Transfer(address indexed from, address indexed to, uint256 value)'
56
+ const topic0 = `0x${'11'.repeat(32)}`
57
+ const topic1 = `0x${'22'.repeat(32)}`
58
+
59
+ test('rejects a negated address as the only firehose guard', () => {
60
+ const parsed = Webhooks.schema.LogFilters.safeParse({
61
+ address: { not: address },
62
+ })
63
+ expect(parsed.success).toBe(false)
64
+ })
65
+
66
+ test('accepts pushdown-capable address filters', () => {
67
+ expect(Webhooks.schema.LogFilters.safeParse({ address }).success).toBe(true)
68
+ expect(Webhooks.schema.LogFilters.safeParse({ address: { eq: address } }).success).toBe(true)
69
+ expect(Webhooks.schema.LogFilters.safeParse({ address: { in: [address] } }).success).toBe(true)
70
+ })
71
+
72
+ test('requires pushdown-capable raw topics', () => {
73
+ expect(Webhooks.schema.LogFilters.safeParse({ topic0, topic1 }).success).toBe(true)
74
+ expect(Webhooks.schema.LogFilters.safeParse({ topic0, topic1: { not: topic1 } }).success).toBe(
75
+ false,
76
+ )
77
+ })
78
+
79
+ test('requires a pushdown-capable indexed argument', () => {
80
+ expect(
81
+ Webhooks.schema.LogFilters.safeParse({ args: { from: address }, signature }).success,
82
+ ).toBe(true)
83
+ expect(Webhooks.schema.LogFilters.safeParse({ args: {}, signature }).success).toBe(false)
84
+ expect(
85
+ Webhooks.schema.LogFilters.safeParse({ args: { value: '0x1' }, signature }).success,
86
+ ).toBe(false)
87
+ expect(
88
+ Webhooks.schema.LogFilters.safeParse({
89
+ args: { from: { eq: { and: [{ eq: address }] } } },
90
+ signature,
91
+ }).success,
92
+ ).toBe(false)
93
+ })
94
+ })
95
+
53
96
  describe('feature gate', () => {
54
97
  test('returns 404 when webhooks are disabled', async () => {
55
98
  // Routes stay mounted for a stable schema, but the resource is disabled.
@@ -631,6 +674,26 @@ describe('slack destination', () => {
631
674
  })
632
675
 
633
676
  describe('betterstack destination', () => {
677
+ test('separates redacted read destinations from write destinations', () => {
678
+ const slack = { type: 'slack', url: 'https://hooks.slack.com/…' } as const
679
+ const betterstack = {
680
+ token: '[redacted]',
681
+ type: 'betterstack',
682
+ url: 'https://s1234567.eu-nbg-2.betterstackdata.com',
683
+ } as const
684
+
685
+ expect(Webhooks.schema.getWebhook.Response.shape.destination.safeParse(slack).success).toBe(
686
+ true,
687
+ )
688
+ expect(
689
+ Webhooks.schema.getWebhook.Response.shape.destination.safeParse(betterstack).success,
690
+ ).toBe(true)
691
+ expect(Webhooks.schema.updateWebhook.Body.safeParse({ destination: slack }).success).toBe(false)
692
+ expect(Webhooks.schema.updateWebhook.Body.safeParse({ destination: betterstack }).success).toBe(
693
+ false,
694
+ )
695
+ })
696
+
634
697
  test('creates a subscription with a Better Stack source', async () => {
635
698
  const app = TestApp.client({
636
699
  auth: { keys: [writer] },
@@ -659,6 +722,109 @@ describe('betterstack destination', () => {
659
722
  `)
660
723
  })
661
724
 
725
+ test('redacts destination bearer credentials from read responses', async () => {
726
+ const app = TestApp.client({ auth: { keys: [writer, reader] } })
727
+ const slack = await app.v1.webhooks.$post(
728
+ {
729
+ json: {
730
+ destination: {
731
+ type: 'slack',
732
+ url: 'https://hooks.slack.com/services/T000/B000/secret',
733
+ },
734
+ eventType: 'token:transfer',
735
+ },
736
+ },
737
+ as(writer),
738
+ )
739
+ const betterstack = await app.v1.webhooks.$post(
740
+ {
741
+ json: {
742
+ destination: {
743
+ token: 'src_secret',
744
+ type: 'betterstack',
745
+ url: 'https://s1234567.eu-nbg-2.betterstackdata.com',
746
+ },
747
+ eventType: 'token:transfer',
748
+ },
749
+ },
750
+ as(writer),
751
+ )
752
+ const slackCreated = await TestApp.json(slack, Webhooks.schema.createWebhook.Response)
753
+ const betterstackCreated = await TestApp.json(
754
+ betterstack,
755
+ Webhooks.schema.createWebhook.Response,
756
+ )
757
+ expect(slackCreated.destination).toStrictEqual({
758
+ type: 'slack',
759
+ url: 'https://hooks.slack.com/services/T000/B000/secret',
760
+ })
761
+ expect(betterstackCreated.destination).toStrictEqual({
762
+ token: 'src_secret',
763
+ type: 'betterstack',
764
+ url: 'https://s1234567.eu-nbg-2.betterstackdata.com',
765
+ })
766
+
767
+ const slackRead = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$get(
768
+ { param: { id: slackCreated.id } },
769
+ as(reader),
770
+ )
771
+ const betterstackRead = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$get(
772
+ { param: { id: betterstackCreated.id } },
773
+ as(reader),
774
+ )
775
+ const slackBody = await TestApp.json(slackRead, Webhooks.schema.getWebhook.Response)
776
+ const betterstackBody = await TestApp.json(betterstackRead, Webhooks.schema.getWebhook.Response)
777
+
778
+ expect(slackBody.destination).toStrictEqual({
779
+ type: 'slack',
780
+ url: 'https://hooks.slack.com/…',
781
+ })
782
+ expect(betterstackBody.destination).toStrictEqual({
783
+ token: '[redacted]',
784
+ type: 'betterstack',
785
+ url: 'https://s1234567.eu-nbg-2.betterstackdata.com',
786
+ })
787
+
788
+ const listed = await app.v1.webhooks.$get({ query: {} }, as(reader))
789
+ const listedBody = await TestApp.json(listed, Webhooks.schema.listWebhooks.Response)
790
+ expect(
791
+ listedBody.data.find((subscription) => subscription.id === slackCreated.id)?.destination,
792
+ ).toStrictEqual(slackBody.destination)
793
+ expect(
794
+ listedBody.data.find((subscription) => subscription.id === betterstackCreated.id)
795
+ ?.destination,
796
+ ).toStrictEqual(betterstackBody.destination)
797
+
798
+ const slackUpdated = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$patch(
799
+ { json: { status: 'paused' }, param: { id: slackCreated.id } },
800
+ as(writer),
801
+ )
802
+ const betterstackUpdated = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$patch(
803
+ { json: { status: 'paused' }, param: { id: betterstackCreated.id } },
804
+ as(writer),
805
+ )
806
+ expect(
807
+ (await TestApp.json(slackUpdated, Webhooks.schema.updateWebhook.Response)).destination,
808
+ ).toStrictEqual(slackBody.destination)
809
+ expect(
810
+ (await TestApp.json(betterstackUpdated, Webhooks.schema.updateWebhook.Response)).destination,
811
+ ).toStrictEqual(betterstackBody.destination)
812
+
813
+ const slackRoundTrip = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$patch(
814
+ { json: { destination: slackBody.destination }, param: { id: slackCreated.id } },
815
+ as(writer),
816
+ )
817
+ const betterstackRoundTrip = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$patch(
818
+ {
819
+ json: { destination: betterstackBody.destination },
820
+ param: { id: betterstackCreated.id },
821
+ },
822
+ as(writer),
823
+ )
824
+ expect(slackRoundTrip.status).toBe(400)
825
+ expect(betterstackRoundTrip.status).toBe(400)
826
+ })
827
+
662
828
  test('rejects a Better Stack destination whose host is not betterstackdata.com', async () => {
663
829
  const app = TestApp.client({
664
830
  auth: { keys: [writer] },
@@ -1570,6 +1736,38 @@ describe.skipIf(Runtime.get().mode === 'localnet')('poll', () => {
1570
1736
  expect(tx?.sender).toBe(windowSender)
1571
1737
  })
1572
1738
 
1739
+ test('transaction:included matches exact empty calldata through the poller', async () => {
1740
+ const db = TestApp.database()
1741
+ const subscription = await Core.createSubscription(db, {
1742
+ chainId: 42431,
1743
+ eventType: 'transaction:included',
1744
+ filters: { input: { eq: '0x' } },
1745
+ owner: { orgId: 'org_test', type: 'api_key' },
1746
+ destination: { type: 'url', url: 'https://hooks.example.com/x' },
1747
+ })
1748
+ await Core.setCursor(db, subscription.id, windowCursor)
1749
+
1750
+ const dispatched: Webhooks.poll.Dispatchable[] = []
1751
+ const result = await Webhooks.poll({
1752
+ chainId: 42431,
1753
+ dispatch: async (input) => {
1754
+ dispatched.push(input)
1755
+ return { ok: true }
1756
+ },
1757
+ eventTypes: ['transaction:included'],
1758
+ headBlockNumber: windowHead,
1759
+ client,
1760
+ db,
1761
+ tidx: realTidx,
1762
+ })
1763
+
1764
+ expect(result.scanned).toMatchInlineSnapshot(`1`)
1765
+ expect(dispatched[0]?.envelope.data).toMatchObject({
1766
+ hash: '0x359a47d5a7a582ae412e8ba8bb729b326464f2e97d00bcabf28694e919327f33',
1767
+ input: '0x',
1768
+ })
1769
+ })
1770
+
1573
1771
  test('transaction:included routes a status filter through receipts', async () => {
1574
1772
  const db = TestApp.database()
1575
1773
  const subscription = await Core.createSubscription(db, {
@@ -1882,6 +2080,11 @@ describe('matchesOperator', () => {
1882
2080
  expect(Webhooks.matchesOperator(addr, { eq: addr })).toBe(true)
1883
2081
  })
1884
2082
 
2083
+ test('bytes equality handles empty calldata without numeric conversion', () => {
2084
+ expect(Webhooks.matchesOperator('0x', { eq: '0x' })).toBe(true)
2085
+ expect(Webhooks.matchesOperator('0x', { eq: '0x00' })).toBe(false)
2086
+ })
2087
+
1885
2088
  test('{ in } matches membership', () => {
1886
2089
  expect(Webhooks.matchesOperator(addr, { in: [addr2, addr] })).toBe(true)
1887
2090
  expect(Webhooks.matchesOperator(addr, { in: [addr2] })).toBe(false)