tapimo 0.7.0 → 0.7.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 (69) hide show
  1. package/dist/App.d.ts +5 -5
  2. package/dist/admin/ui.generated.d.ts.map +1 -1
  3. package/dist/admin/ui.generated.js +95 -95
  4. package/dist/admin/ui.generated.js.map +1 -1
  5. package/dist/apps/data/App.d.ts +3283 -3283
  6. package/dist/apps/data/routes/exchanges.d.ts +664 -664
  7. package/dist/apps/data/routes/fee-amm.d.ts +108 -108
  8. package/dist/apps/data/routes/indexer.d.ts +34 -34
  9. package/dist/apps/data/routes/receipts.d.ts +2 -2
  10. package/dist/apps/data/routes/tokenlist.d.ts.map +1 -1
  11. package/dist/apps/data/routes/tokenlist.js +12 -3
  12. package/dist/apps/data/routes/tokenlist.js.map +1 -1
  13. package/dist/apps/data/routes/tokens.d.ts +2 -2
  14. package/dist/apps/data/routes/tokens.d.ts.map +1 -1
  15. package/dist/apps/data/routes/tokens.js +4 -0
  16. package/dist/apps/data/routes/tokens.js.map +1 -1
  17. package/dist/apps/data/routes/transactions.d.ts +2 -2
  18. package/dist/apps/data/routes/transfers.d.ts +2 -2
  19. package/dist/apps/data/routes/valuation.d.ts +32 -32
  20. package/dist/apps/data/routes/verified-tokens.d.ts.map +1 -1
  21. package/dist/apps/data/routes/verified-tokens.js +34 -9
  22. package/dist/apps/data/routes/verified-tokens.js.map +1 -1
  23. package/dist/apps/data/routes/webhooks.d.ts +363 -363
  24. package/dist/apps/data/routes/webhooks.js +14 -6
  25. package/dist/apps/data/routes/webhooks.js.map +1 -1
  26. package/dist/apps/management/routes/faucet.d.ts +36 -36
  27. package/dist/apps/management/routes/members.d.ts +78 -78
  28. package/dist/apps/management/routes/usage.d.ts +80 -80
  29. package/dist/apps/relay/App.js +1 -1
  30. package/dist/apps/relay/App.js.map +1 -1
  31. package/dist/apps/relay/Sponsorships.js +1 -1
  32. package/dist/apps/relay/Sponsorships.js.map +1 -1
  33. package/dist/db/tables/organizations.d.ts.map +1 -1
  34. package/dist/db/tables/organizations.js +19 -0
  35. package/dist/db/tables/organizations.js.map +1 -1
  36. package/dist/db/tables/sponsoredTransactions.d.ts +2 -0
  37. package/dist/db/tables/sponsoredTransactions.d.ts.map +1 -1
  38. package/dist/db/tables/sponsoredTransactions.js +13 -3
  39. package/dist/db/tables/sponsoredTransactions.js.map +1 -1
  40. package/dist/internal/EdgeCache.d.ts +1 -1
  41. package/dist/internal/EdgeCache.d.ts.map +1 -1
  42. package/dist/internal/EdgeCache.js +3 -2
  43. package/dist/internal/EdgeCache.js.map +1 -1
  44. package/dist/internal/Schema.d.ts +2 -2
  45. package/dist/internal/Schema.d.ts.map +1 -1
  46. package/dist/internal/Schema.js +4 -2
  47. package/dist/internal/Schema.js.map +1 -1
  48. package/package.json +1 -1
  49. package/src/admin/ui.generated.ts +119 -95
  50. package/src/apps/data/App.test.ts +13 -1
  51. package/src/apps/data/routes/tokenlist.test.ts +15 -0
  52. package/src/apps/data/routes/tokenlist.ts +18 -5
  53. package/src/apps/data/routes/tokens.ts +4 -0
  54. package/src/apps/data/routes/verified-tokens.test.ts +51 -0
  55. package/src/apps/data/routes/verified-tokens.ts +52 -15
  56. package/src/apps/data/routes/webhooks.test.ts +56 -0
  57. package/src/apps/data/routes/webhooks.ts +17 -8
  58. package/src/apps/relay/App.test.ts +2 -2
  59. package/src/apps/relay/App.ts +1 -1
  60. package/src/apps/relay/Sponsorships.test.ts +2 -0
  61. package/src/apps/relay/Sponsorships.ts +1 -1
  62. package/src/db/tables/organizations.test.ts +88 -0
  63. package/src/db/tables/organizations.ts +26 -1
  64. package/src/db/tables/sponsoredTransactions.test.ts +41 -2
  65. package/src/db/tables/sponsoredTransactions.ts +15 -3
  66. package/src/internal/EdgeCache.test.ts +18 -5
  67. package/src/internal/EdgeCache.ts +4 -2
  68. package/src/internal/Schema.test.ts +13 -0
  69. package/src/internal/Schema.ts +14 -7
@@ -372,7 +372,7 @@ describe('zone chain gate', () => {
372
372
  test('public chains remain edge-cacheable on a Zone deployment', async () => {
373
373
  const store = Store.memory()
374
374
  const app = TestApp.create({ auth: false, cache: { store }, zones: [zoneOptions(zone)] })
375
- const path = `/v1/tokenlist?chainId=${Viem.chainId.testnet}`
375
+ const path = '/v1/funding/chains'
376
376
 
377
377
  const first = await app.request(path)
378
378
  const second = await app.request(path)
@@ -380,6 +380,18 @@ describe('zone chain gate', () => {
380
380
  expect(second.status).toBe(200)
381
381
  expect(second.headers.get('cache-control')).toContain('public')
382
382
  })
383
+
384
+ test('preserves route-specific edge-cache opt-outs on a Zone deployment', async () => {
385
+ const store = Store.memory()
386
+ const app = TestApp.create({ auth: false, cache: { store }, zones: [zoneOptions(zone)] })
387
+ const path = `/v1/tokenlist?chainId=${Viem.chainId.testnet}`
388
+
389
+ const first = await app.request(path)
390
+ const second = await app.request(path)
391
+ expect(first.status).toBe(200)
392
+ expect(second.status).toBe(200)
393
+ expect(second.headers.get('cache-control')).toContain('private')
394
+ })
383
395
  })
384
396
 
385
397
  describe('sandbox chain guard', () => {
@@ -75,4 +75,19 @@ describe('GET /tokenlist', () => {
75
75
  expect(body.tokens).toEqual([])
76
76
  expect(body.version.patch).toBe(0)
77
77
  })
78
+
79
+ test('refreshes cached reads after a snapshot publish', async () => {
80
+ const db = TestApp.database()
81
+ await VerifiedTokens.replace(db, chainId, seedList)
82
+ const client = TestApp.client({ auth: { keys: [reader] }, db })
83
+
84
+ await client.v1.tokenlist.$get({ query: {} }, as(reader))
85
+ await VerifiedTokens.replace(db, chainId, seedList.slice(1))
86
+
87
+ const response = await client.v1.tokenlist.$get({ query: {} }, as(reader))
88
+ const body = await TestApp.json(response, Tokenlist.schema.getTokenList.Response)
89
+ expect(body.tokens.map((token) => token.address)).toEqual(
90
+ seedList.slice(1).map((token) => token.address),
91
+ )
92
+ })
78
93
  })
@@ -4,6 +4,7 @@ import * as z from 'zod/mini'
4
4
  import type * as App from '../../../App.js'
5
5
  import * as Auth from '../../../internal/Auth.js'
6
6
  import * as Cache from '../../../internal/Cache.js'
7
+ import * as EdgeCache from '../../../internal/EdgeCache.js'
7
8
  import * as OpenApi from '../../../internal/OpenApi.js'
8
9
  import * as Response from '../../../internal/Response.js'
9
10
  import * as Schema from '../../../internal/Schema.js'
@@ -159,11 +160,23 @@ export function tokenlist() {
159
160
  summary: 'Get token list',
160
161
  tags: ['Verified Tokens'],
161
162
  }),
162
- Cache.response<App.Environment>({
163
- cacheControl: Cache.policies.stable,
164
- name: 'tempo-api:tokenlist:v1',
165
- key: (c) => Cache.urlKey(c, schema.getTokenList.Query),
166
- }),
163
+ // The shared edge cache is URL-keyed and cannot include the snapshot version.
164
+ EdgeCache.setEligibility(
165
+ Cache.response<App.Environment>({
166
+ cacheControl: Cache.policies.stable,
167
+ name: 'tempo-api:tokenlist:v1',
168
+ key: async (c) => {
169
+ const base = Cache.urlKey(c, schema.getTokenList.Query)
170
+ const query = schema.getTokenList.Query.parse(
171
+ Object.fromEntries(new URL(c.req.url).searchParams),
172
+ )
173
+ const chainId = query.chainId ?? c.get('chainId')
174
+ const { version } = await VerifiedTokens.snapshot(c, chainId)
175
+ return `${base}:v:${version}`
176
+ },
177
+ }),
178
+ () => false,
179
+ ),
167
180
  async (c) => {
168
181
  if (Auth.narrowAccess) return Auth.paidAccessError(c)
169
182
  if (OpenApi.narrowValidation)
@@ -1821,6 +1821,8 @@ export async function getTokenCreatedAtByAddress(
1821
1821
  try {
1822
1822
  const result = await tidx.fetch({
1823
1823
  chainId: options.chainId,
1824
+ // TODO: Remove the explicit engine after TIDX 1.0 is released.
1825
+ engine: 'clickhouse',
1824
1826
  query: `
1825
1827
  SELECT token, block_timestamp
1826
1828
  FROM tokencreated
@@ -1916,6 +1918,8 @@ async function getTokenCreatedByAddress(
1916
1918
  // needs no event signature for the `logs` table.
1917
1919
  const result = await tidx.fetch({
1918
1920
  chainId: options.chainId,
1921
+ // TODO: Remove the explicit engine after TIDX 1.0 is released.
1922
+ engine: 'clickhouse',
1919
1923
  query: `
1920
1924
  SELECT topic1, data, block_timestamp
1921
1925
  FROM logs
@@ -87,6 +87,21 @@ describe('GET /verified-tokens', () => {
87
87
  )
88
88
  expect(second.status).toBe(304)
89
89
  })
90
+
91
+ test('refreshes cached reads after a snapshot publish', async () => {
92
+ const db = TestApp.database()
93
+ await VerifiedTokens.replace(db, chainId, seedList)
94
+ const client = TestApp.client({ auth: { keys: [reader] }, db })
95
+
96
+ await client.v1['verified-tokens'].$get({ query: {} }, as(reader))
97
+ await VerifiedTokens.replace(db, chainId, seedList.slice(1))
98
+
99
+ const response = await client.v1['verified-tokens'].$get({ query: {} }, as(reader))
100
+ const body = await TestApp.json(response, Verified.schema.listVerifiedTokens.Response)
101
+ expect(body.data.map((token) => token.address)).toEqual(
102
+ seedList.slice(1).map((token) => token.address),
103
+ )
104
+ })
90
105
  })
91
106
 
92
107
  describe('GET /verified-tokens/currencies', () => {
@@ -97,6 +112,23 @@ describe('GET /verified-tokens/currencies', () => {
97
112
  const body = await TestApp.json(response, Verified.schema.getVerifiedTokenCurrencies.Response)
98
113
  expect([...body.data]).toEqual(['EUR', 'USD'])
99
114
  })
115
+
116
+ test('refreshes cached reads after a snapshot publish', async () => {
117
+ const db = TestApp.database()
118
+ await VerifiedTokens.replace(db, chainId, seedList)
119
+ const client = TestApp.client({ auth: { keys: [reader] }, db })
120
+
121
+ await client.v1['verified-tokens'].currencies.$get({ query: {} }, as(reader))
122
+ await VerifiedTokens.replace(
123
+ db,
124
+ chainId,
125
+ seedList.filter((token) => token.currency === 'USD'),
126
+ )
127
+
128
+ const response = await client.v1['verified-tokens'].currencies.$get({ query: {} }, as(reader))
129
+ const body = await TestApp.json(response, Verified.schema.getVerifiedTokenCurrencies.Response)
130
+ expect([...body.data]).toEqual(['USD'])
131
+ })
100
132
  })
101
133
 
102
134
  describe('GET /verified-tokens/:address', () => {
@@ -123,4 +155,23 @@ describe('GET /verified-tokens/:address', () => {
123
155
  const body = (await response.json()) as { error?: { code?: string } }
124
156
  expect(body.error?.code).toBe('verified_token_not_found')
125
157
  })
158
+
159
+ test('refreshes cached reads after a snapshot publish', async () => {
160
+ const db = TestApp.database()
161
+ await VerifiedTokens.replace(db, chainId, seedList)
162
+ const client = TestApp.client({ auth: { keys: [reader] }, db })
163
+ const address = seedList[0].address
164
+
165
+ await client.v1['verified-tokens'][':address'].$get(
166
+ { param: { address }, query: {} },
167
+ as(reader),
168
+ )
169
+ await VerifiedTokens.replace(db, chainId, seedList.slice(1))
170
+
171
+ const response = await client.v1['verified-tokens'][':address'].$get(
172
+ { param: { address }, query: {} },
173
+ as(reader),
174
+ )
175
+ expect(response.status).toBe(404)
176
+ })
126
177
  })
@@ -4,6 +4,7 @@ import * as z from 'zod/mini'
4
4
  import type * as App from '../../../App.js'
5
5
  import * as Auth from '../../../internal/Auth.js'
6
6
  import * as Cache from '../../../internal/Cache.js'
7
+ import * as EdgeCache from '../../../internal/EdgeCache.js'
7
8
  import * as OpenApi from '../../../internal/OpenApi.js'
8
9
  import * as Response from '../../../internal/Response.js'
9
10
  import * as Schema from '../../../internal/Schema.js'
@@ -168,11 +169,23 @@ export function verifiedTokens() {
168
169
  summary: 'List verified tokens',
169
170
  tags: ['Verified Tokens'],
170
171
  }),
171
- Cache.response<App.Environment>({
172
- cacheControl: Cache.policies.stable,
173
- name: 'tempo-api:verified-tokens:v1',
174
- key: (c) => Cache.urlKey(c, schema.listVerifiedTokens.Query),
175
- }),
172
+ // The shared edge cache is URL-keyed and cannot include the snapshot version.
173
+ EdgeCache.setEligibility(
174
+ Cache.response<App.Environment>({
175
+ cacheControl: Cache.policies.stable,
176
+ name: 'tempo-api:verified-tokens:v1',
177
+ key: async (c) => {
178
+ const base = Cache.urlKey(c, schema.listVerifiedTokens.Query)
179
+ const query = schema.listVerifiedTokens.Query.parse(
180
+ Object.fromEntries(new URL(c.req.url).searchParams),
181
+ )
182
+ const chainId = query.chainId ?? c.get('chainId')
183
+ const { version } = await VerifiedTokens.snapshot(c, chainId)
184
+ return `${base}:v:${version}`
185
+ },
186
+ }),
187
+ () => false,
188
+ ),
176
189
  async (c) => {
177
190
  if (Auth.narrowAccess) return Auth.paidAccessError(c)
178
191
  if (OpenApi.narrowValidation)
@@ -213,11 +226,23 @@ export function verifiedTokens() {
213
226
  summary: 'List verified currencies',
214
227
  tags: ['Verified Tokens'],
215
228
  }),
216
- Cache.response<App.Environment>({
217
- cacheControl: Cache.policies.stable,
218
- name: 'tempo-api:verified-tokens:v1',
219
- key: (c) => Cache.urlKey(c, schema.getVerifiedTokenCurrencies.Query),
220
- }),
229
+ // The shared edge cache is URL-keyed and cannot include the snapshot version.
230
+ EdgeCache.setEligibility(
231
+ Cache.response<App.Environment>({
232
+ cacheControl: Cache.policies.stable,
233
+ name: 'tempo-api:verified-tokens:v1',
234
+ key: async (c) => {
235
+ const base = Cache.urlKey(c, schema.getVerifiedTokenCurrencies.Query)
236
+ const query = schema.getVerifiedTokenCurrencies.Query.parse(
237
+ Object.fromEntries(new URL(c.req.url).searchParams),
238
+ )
239
+ const chainId = query.chainId ?? c.get('chainId')
240
+ const { version } = await VerifiedTokens.snapshot(c, chainId)
241
+ return `${base}:v:${version}`
242
+ },
243
+ }),
244
+ () => false,
245
+ ),
221
246
  async (c) => {
222
247
  if (Auth.narrowAccess) return Auth.paidAccessError(c)
223
248
  if (OpenApi.narrowValidation)
@@ -265,11 +290,23 @@ export function verifiedTokens() {
265
290
  summary: 'Get verified token',
266
291
  tags: ['Verified Tokens'],
267
292
  }),
268
- Cache.response<App.Environment>({
269
- cacheControl: Cache.policies.stable,
270
- name: 'tempo-api:verified-tokens:v1',
271
- key: (c) => Cache.urlKey(c, schema.getVerifiedToken.Query),
272
- }),
293
+ // The shared edge cache is URL-keyed and cannot include the snapshot version.
294
+ EdgeCache.setEligibility(
295
+ Cache.response<App.Environment>({
296
+ cacheControl: Cache.policies.stable,
297
+ name: 'tempo-api:verified-tokens:v1',
298
+ key: async (c) => {
299
+ const base = Cache.urlKey(c, schema.getVerifiedToken.Query)
300
+ const query = schema.getVerifiedToken.Query.parse(
301
+ Object.fromEntries(new URL(c.req.url).searchParams),
302
+ )
303
+ const chainId = query.chainId ?? c.get('chainId')
304
+ const { version } = await VerifiedTokens.snapshot(c, chainId)
305
+ return `${base}:v:${version}`
306
+ },
307
+ }),
308
+ () => false,
309
+ ),
273
310
  async (c) => {
274
311
  if (Auth.narrowAccess) return Auth.paidAccessError(c)
275
312
  if (OpenApi.narrowValidation)
@@ -1736,6 +1736,33 @@ describe.skipIf(Runtime.get().mode === 'localnet')('poll', () => {
1736
1736
  `)
1737
1737
  })
1738
1738
 
1739
+ test('block:created preserves the cursor when a block header cannot be read', async () => {
1740
+ const db = TestApp.database()
1741
+ const missingBlock = 4_000_000_000
1742
+ const cursor = encodeCursor([missingBlock - 1, endOfBlockIndex])
1743
+ const subscription = await Core.createSubscription(db, {
1744
+ chainId: 42431,
1745
+ destination: { type: 'url', url: 'https://hooks.example.com/x' },
1746
+ eventType: 'block:created',
1747
+ filters: { number: { eq: `0x${missingBlock.toString(16)}` } },
1748
+ owner: { orgId: 'org_test', type: 'api_key' },
1749
+ })
1750
+ await Core.setCursor(db, subscription.id, cursor)
1751
+
1752
+ await expect(
1753
+ Webhooks.poll({
1754
+ chainId: 42431,
1755
+ eventTypes: ['block:created'],
1756
+ headBlockNumber: missingBlock + 2,
1757
+ client,
1758
+ db,
1759
+ tidx: realTidx,
1760
+ }),
1761
+ ).rejects.toThrow()
1762
+
1763
+ expect(await Core.getCursor(db, subscription.id)).toBe(cursor)
1764
+ })
1765
+
1739
1766
  test('transaction:included filters on txs columns and inner calls', async () => {
1740
1767
  const db = TestApp.database()
1741
1768
  const subscription = await Core.createSubscription(db, {
@@ -1778,6 +1805,35 @@ describe.skipIf(Runtime.get().mode === 'localnet')('poll', () => {
1778
1805
  } else expect(tx?.feeToken).toBeUndefined()
1779
1806
  })
1780
1807
 
1808
+ test('transaction:included preserves the cursor when a confirmed block is unavailable', async () => {
1809
+ const db = TestApp.database()
1810
+ const missingBlock = 4_000_000_000
1811
+ const cursor = encodeCursor([missingBlock - 1, endOfBlockIndex])
1812
+ const subscription = await Core.createSubscription(db, {
1813
+ chainId: 42431,
1814
+ destination: { type: 'url', url: 'https://hooks.example.com/x' },
1815
+ eventType: 'transaction:included',
1816
+ filters: { blockNumber: { eq: `0x${missingBlock.toString(16)}` } },
1817
+ owner: { orgId: 'org_test', type: 'api_key' },
1818
+ })
1819
+ await Core.setCursor(db, subscription.id, cursor)
1820
+
1821
+ await expect(
1822
+ Webhooks.poll({
1823
+ chainId: 42431,
1824
+ eventTypes: ['transaction:included'],
1825
+ headBlockNumber: missingBlock + 2,
1826
+ client,
1827
+ db,
1828
+ tidx: realTidx,
1829
+ }),
1830
+ ).rejects.toThrowErrorMatchingInlineSnapshot(`
1831
+ [Webhooks.BlockUnavailableError: Block 4000000000 is unavailable.]
1832
+ `)
1833
+
1834
+ expect(await Core.getCursor(db, subscription.id)).toBe(cursor)
1835
+ })
1836
+
1781
1837
  test('transaction:included matches exact empty calldata through the poller', async () => {
1782
1838
  const db = TestApp.database()
1783
1839
  const subscription = await Core.createSubscription(db, {
@@ -2485,7 +2485,7 @@ type RpcBlock = Awaited<ReturnType<Viem.getClient.ReturnType['getBlock']>>
2485
2485
  /** Per-tick lazy cache of fetched block headers, full blocks, and receipts. */
2486
2486
  type ChainCache = {
2487
2487
  /** Full raw block (with transactions) via `eth_getBlockByNumber(n, true)`. */
2488
- block(n: number): Promise<Record<string, unknown> | undefined>
2488
+ block(n: number): Promise<Record<string, unknown>>
2489
2489
  /** Viem-formatted block header (transaction hashes only) via `getBlock`. */
2490
2490
  header(n: number): Promise<RpcBlock | undefined>
2491
2491
  /** Receipts for a block keyed by lowercased transaction hash. */
@@ -2495,7 +2495,7 @@ type ChainCache = {
2495
2495
  /** Builds a per-tick {@link ChainCache} bound to a viem client. */
2496
2496
  function createChainCache(client: Viem.getClient.ReturnType): ChainCache {
2497
2497
  const headers = new Map<number, Promise<RpcBlock | undefined>>()
2498
- const blocks = new Map<number, Promise<Record<string, unknown> | undefined>>()
2498
+ const blocks = new Map<number, Promise<Record<string, unknown>>>()
2499
2499
  const receipts = new Map<number, Promise<Map<string, Record<string, unknown>>>>()
2500
2500
  return {
2501
2501
  block(n) {
@@ -2506,7 +2506,10 @@ function createChainCache(client: Viem.getClient.ReturnType): ChainCache {
2506
2506
  p = rpc<Record<string, unknown> | null>(client, 'eth_getBlockByNumber', [
2507
2507
  blockTag(n),
2508
2508
  true,
2509
- ]).then((b) => b ?? undefined)
2509
+ ]).then((block) => {
2510
+ if (!block) throw new BlockUnavailableError(n)
2511
+ return block
2512
+ })
2510
2513
  blocks.set(n, p)
2511
2514
  }
2512
2515
  return p
@@ -2514,9 +2517,7 @@ function createChainCache(client: Viem.getClient.ReturnType): ChainCache {
2514
2517
  header(n) {
2515
2518
  let p = headers.get(n)
2516
2519
  if (!p) {
2517
- p = client
2518
- .getBlock({ blockNumber: BigInt(n), includeTransactions: false })
2519
- .catch(() => undefined)
2520
+ p = client.getBlock({ blockNumber: BigInt(n), includeTransactions: false })
2520
2521
  headers.set(n, p)
2521
2522
  }
2522
2523
  return p
@@ -2999,10 +3000,10 @@ async function scanTxs(options: {
2999
3000
  const items: ScanItem[] = []
3000
3001
  for (let n = window.from; n <= window.to; n++) {
3001
3002
  const block = await cache.block(n)
3002
- const txs = Array.isArray(block?.['transactions']) ? (block!['transactions'] as unknown[]) : []
3003
+ const txs = Array.isArray(block['transactions']) ? (block['transactions'] as unknown[]) : []
3003
3004
  if (txs.length === 0) continue
3004
3005
  const receipts = needsReceipt || includeReceipt ? await cache.receipts(n) : undefined
3005
- const timestamp = asString(block?.['timestamp'])
3006
+ const timestamp = asString(block['timestamp'])
3006
3007
 
3007
3008
  for (let i = 0; i < txs.length; i++) {
3008
3009
  const tx = txs[i] as Record<string, unknown>
@@ -3195,3 +3196,11 @@ function unauthorized(c: Context<App.Environment>) {
3195
3196
  status: 401,
3196
3197
  })
3197
3198
  }
3199
+
3200
+ class BlockUnavailableError extends Error {
3201
+ override name = 'Webhooks.BlockUnavailableError'
3202
+
3203
+ constructor(blockNumber: number) {
3204
+ super(`Block ${blockNumber} is unavailable.`)
3205
+ }
3206
+ }
@@ -450,7 +450,7 @@ describe.skipIf(runtime.mode !== 'localnet')('behavior: sponsorship', () => {
450
450
  `)
451
451
  })
452
452
 
453
- test('behavior: non-mainnet production keys record billable rows', async () => {
453
+ test('behavior: non-mainnet production keys record non-billable rows', async () => {
454
454
  const db = TestApp.database()
455
455
  const app = TestApp.create({
456
456
  auth: { keys },
@@ -466,7 +466,7 @@ describe.skipIf(runtime.mode !== 'localnet')('behavior: sponsorship', () => {
466
466
  expect(body.error).toBeUndefined()
467
467
 
468
468
  const [row] = await SponsoredTransactions.listPending(db)
469
- expect(row?.billable).toBe(true)
469
+ expect(row?.billable).toBe(false)
470
470
  expect(row?.environment).toMatchInlineSnapshot(`"production"`)
471
471
  })
472
472
 
@@ -238,7 +238,7 @@ function middleware(options: middleware.Options) {
238
238
 
239
239
  const input = {
240
240
  apiKeyId: attribution.apiKeyId,
241
- billable: attribution.environment === 'production',
241
+ billable: mainnet && attribution.environment === 'production',
242
242
  chainId: event.chainId,
243
243
  environment: attribution.environment,
244
244
  orgId: attribution.orgId,
@@ -4,6 +4,7 @@ import type { Client } from 'viem'
4
4
 
5
5
  import * as Runtime from '../../../test/runtime.js'
6
6
  import * as Db from '../../db/Db.js'
7
+ import * as Organizations from '../../db/tables/organizations.js'
7
8
  import * as SponsoredTransactions from '../../db/tables/sponsoredTransactions.js'
8
9
  import * as Sponsorships from './Sponsorships.js'
9
10
 
@@ -269,6 +270,7 @@ describe('reconcile', () => {
269
270
  test('behavior: recovers recently failed intents by sign payload', async () => {
270
271
  const db = create()
271
272
  await db.migrate()
273
+ await Organizations.create(db, { id: 'org_1', name: 'One' })
272
274
  const { transactionHash: _, ...rest } = input
273
275
  const intent = await SponsoredTransactions.upsert(db, { ...rest, signPayload })
274
276
  await SponsoredTransactions.fail(db, intent.id, '2026-01-01T00:00:00.000Z')
@@ -126,7 +126,7 @@ export async function reconcile(db: Db.Db, options: reconcile.Options): Promise<
126
126
  const {
127
127
  batchSize = 100,
128
128
  candidateCap = 500,
129
- failedIntentTtlMs = 86_400_000,
129
+ failedIntentTtlMs = SponsoredTransactions.failedIntentRecoveryTtlMs,
130
130
  feePayer,
131
131
  getClient,
132
132
  getTidx,
@@ -2,6 +2,7 @@ import { nanoid } from 'nanoid'
2
2
 
3
3
  import * as Runtime from '../../../test/runtime.js'
4
4
  import * as Webhooks from '../../internal/Webhooks.js'
5
+ import * as Viem from '../../internal/Viem.js'
5
6
  import * as Db from '../Db.js'
6
7
  import * as EnabledBillingSources from './enabledBillingSources.js'
7
8
  import * as Memberships from './memberships.js'
@@ -216,6 +217,7 @@ describe('deleteOrganization', () => {
216
217
  transactionHash: `0x${'aa'.repeat(32)}`,
217
218
  })
218
219
 
220
+ expect(await Organizations.hasUnreportedSponsorships(db, 'org_1')).toBe(true)
219
221
  await expect(Organizations.deleteOrganization(db, 'org_1')).rejects.toThrow(
220
222
  Organizations.UnreportedSponsorshipsError,
221
223
  )
@@ -227,6 +229,92 @@ describe('deleteOrganization', () => {
227
229
  await db.close()
228
230
  })
229
231
 
232
+ test('behavior: deletes an org with only failed or non-mainnet sponsorships', async () => {
233
+ const db = create()
234
+ await db.migrate()
235
+ await Organizations.create(db, { id: 'org_1', name: 'One' })
236
+ const failed = await SponsoredTransactions.upsert(db, {
237
+ apiKeyId: 'key_1',
238
+ billable: true,
239
+ chainId: Viem.chainId.mainnet,
240
+ environment: 'production',
241
+ orgId: 'org_1',
242
+ projectId: 'prj_1',
243
+ signPayload: `0x${'bb'.repeat(32)}`,
244
+ transaction: `0x76${'cc'.repeat(16)}`,
245
+ transactionHash: `0x${'aa'.repeat(32)}`,
246
+ })
247
+ await SponsoredTransactions.fail(db, failed.id, '2026-01-01T00:00:00.000Z')
248
+ const testnet = await SponsoredTransactions.upsert(db, {
249
+ apiKeyId: 'key_1',
250
+ billable: true,
251
+ chainId: Viem.chainId.testnet,
252
+ environment: 'production',
253
+ orgId: 'org_1',
254
+ projectId: 'prj_1',
255
+ signPayload: `0x${'dd'.repeat(32)}`,
256
+ transaction: `0x76${'ee'.repeat(16)}`,
257
+ transactionHash: `0x${'ff'.repeat(32)}`,
258
+ })
259
+ await SponsoredTransactions.finalize(db, testnet.id, {
260
+ feeAmount: '100',
261
+ finalizedAt: '2026-01-01T00:00:00.000Z',
262
+ })
263
+
264
+ expect(await Organizations.hasUnreportedSponsorships(db, 'org_1')).toBe(false)
265
+ expect(await Organizations.deleteOrganization(db, 'org_1')).toBe(true)
266
+ await db.close()
267
+ })
268
+
269
+ test('behavior: preserves an org with a recoverable failed fill intent', async () => {
270
+ const db = create()
271
+ await db.migrate()
272
+ await Organizations.create(db, { id: 'org_1', name: 'One' })
273
+ const failed = await SponsoredTransactions.upsert(db, {
274
+ apiKeyId: 'key_1',
275
+ billable: true,
276
+ chainId: Viem.chainId.mainnet,
277
+ environment: 'production',
278
+ orgId: 'org_1',
279
+ projectId: 'prj_1',
280
+ signPayload: `0x${'bb'.repeat(32)}`,
281
+ transaction: `0x76${'cc'.repeat(16)}`,
282
+ })
283
+ await SponsoredTransactions.fail(db, failed.id, new Date().toISOString())
284
+
285
+ expect(await Organizations.hasUnreportedSponsorships(db, 'org_1')).toBe(true)
286
+ await expect(Organizations.deleteOrganization(db, 'org_1')).rejects.toThrow(
287
+ Organizations.UnreportedSponsorshipsError,
288
+ )
289
+ expect(await Organizations.get(db, 'org_1')).toBeDefined()
290
+ await db.close()
291
+ })
292
+
293
+ test('behavior: deletes an org with only expired failed fill intents', async () => {
294
+ const db = create()
295
+ await db.migrate()
296
+ await Organizations.create(db, { id: 'org_1', name: 'One' })
297
+ const failed = await SponsoredTransactions.upsert(db, {
298
+ apiKeyId: 'key_1',
299
+ billable: true,
300
+ chainId: Viem.chainId.mainnet,
301
+ environment: 'production',
302
+ orgId: 'org_1',
303
+ projectId: 'prj_1',
304
+ signPayload: `0x${'bb'.repeat(32)}`,
305
+ transaction: `0x76${'cc'.repeat(16)}`,
306
+ })
307
+ await SponsoredTransactions.fail(
308
+ db,
309
+ failed.id,
310
+ new Date(Date.now() - SponsoredTransactions.failedIntentRecoveryTtlMs - 1).toISOString(),
311
+ )
312
+
313
+ expect(await Organizations.hasUnreportedSponsorships(db, 'org_1')).toBe(false)
314
+ expect(await Organizations.deleteOrganization(db, 'org_1')).toBe(true)
315
+ await db.close()
316
+ })
317
+
230
318
  test('behavior: deletes enabled billing sources', async () => {
231
319
  const db = create()
232
320
  await db.migrate()
@@ -1,6 +1,8 @@
1
- import { sql, type Selectable } from 'kysely'
1
+ import { sql, type ExpressionBuilder, type Selectable } from 'kysely'
2
2
 
3
3
  import * as Id from '../../internal/Id.js'
4
+ import * as Viem from '../../internal/Viem.js'
5
+ import * as SponsoredTransactions from './sponsoredTransactions.js'
4
6
  import type * as Db from '../Db.js'
5
7
  import type * as db_Schema from '../Schema.js'
6
8
  import type * as EnabledBillingSources from './enabledBillingSources.js'
@@ -120,12 +122,17 @@ export declare namespace createOwned {
120
122
  */
121
123
  export function deleteOrganization(db: Db.Db, id: string): Promise<boolean> {
122
124
  return db.kysely.transaction().execute(async (trx) => {
125
+ const failedSince = new Date(
126
+ Date.now() - SponsoredTransactions.failedIntentRecoveryTtlMs,
127
+ ).toISOString()
123
128
  const unreported = await trx
124
129
  .selectFrom('sponsored_transactions')
125
130
  .select('id')
126
131
  .where('orgId', '=', id)
127
132
  .where('billable', '=', true)
133
+ .where('chainId', '=', Viem.chainId.mainnet)
128
134
  .where('meterReportedAt', 'is', null)
135
+ .where(unreportedSponsorshipStatus(failedSince))
129
136
  .limit(1)
130
137
  .executeTakeFirst()
131
138
  if (unreported) throw new UnreportedSponsorshipsError()
@@ -152,12 +159,17 @@ export function deleteOrganization(db: Db.Db, id: string): Promise<boolean> {
152
159
 
153
160
  /** Whether an organization has billable sponsorships awaiting settlement. */
154
161
  export async function hasUnreportedSponsorships(db: Db.Db, id: string): Promise<boolean> {
162
+ const failedSince = new Date(
163
+ Date.now() - SponsoredTransactions.failedIntentRecoveryTtlMs,
164
+ ).toISOString()
155
165
  const record = await db.kysely
156
166
  .selectFrom('sponsored_transactions')
157
167
  .select('id')
158
168
  .where('orgId', '=', id)
159
169
  .where('billable', '=', true)
170
+ .where('chainId', '=', Viem.chainId.mainnet)
160
171
  .where('meterReportedAt', 'is', null)
172
+ .where(unreportedSponsorshipStatus(failedSince))
161
173
  .limit(1)
162
174
  .executeTakeFirst()
163
175
  return record !== undefined
@@ -429,6 +441,19 @@ export declare namespace upsert {
429
441
  }
430
442
  }
431
443
 
444
+ /** Blocks rows that can still settle or recover into billable mainnet spend. */
445
+ function unreportedSponsorshipStatus(failedSince: string) {
446
+ return (eb: ExpressionBuilder<Db.Database, 'sponsored_transactions'>) =>
447
+ eb.or([
448
+ eb('status', '!=', 'failed'),
449
+ eb.and([
450
+ eb('status', '=', 'failed'),
451
+ eb('transactionHash', 'is', null),
452
+ eb('finalizedAt', '>=', failedSince),
453
+ ]),
454
+ ])
455
+ }
456
+
432
457
  /** Organization deletion is blocked until billable sponsorships settle. */
433
458
  export class UnreportedSponsorshipsError extends Error {
434
459
  override name = 'Organizations.UnreportedSponsorshipsError'