tapimo 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. package/dist/App.d.ts +7410 -7613
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/App.js +0 -1
  4. package/dist/App.js.map +1 -1
  5. package/dist/Webhooks.d.ts +21 -20
  6. package/dist/Webhooks.d.ts.map +1 -1
  7. package/dist/Webhooks.js +66 -23
  8. package/dist/Webhooks.js.map +1 -1
  9. package/dist/admin/apps/verified-tokens.d.ts +74 -74
  10. package/dist/apps/Zones.d.ts +34 -22
  11. package/dist/apps/Zones.d.ts.map +1 -1
  12. package/dist/apps/Zones.js +9 -8
  13. package/dist/apps/Zones.js.map +1 -1
  14. package/dist/apps/data/App.d.ts +3120 -2782
  15. package/dist/apps/data/App.d.ts.map +1 -1
  16. package/dist/apps/data/FxOracle.d.ts +4 -5
  17. package/dist/apps/data/FxOracle.d.ts.map +1 -1
  18. package/dist/apps/data/FxOracle.js +19 -4
  19. package/dist/apps/data/FxOracle.js.map +1 -1
  20. package/dist/apps/data/routes/activities.d.ts.map +1 -1
  21. package/dist/apps/data/routes/activities.js +32 -27
  22. package/dist/apps/data/routes/activities.js.map +1 -1
  23. package/dist/apps/data/routes/addresses.d.ts +283 -52
  24. package/dist/apps/data/routes/addresses.d.ts.map +1 -1
  25. package/dist/apps/data/routes/balances.d.ts +291 -21
  26. package/dist/apps/data/routes/balances.d.ts.map +1 -1
  27. package/dist/apps/data/routes/balances.js +232 -66
  28. package/dist/apps/data/routes/balances.js.map +1 -1
  29. package/dist/apps/data/routes/blocks.d.ts +4 -4
  30. package/dist/apps/data/routes/coingecko.d.ts +54 -54
  31. package/dist/apps/data/routes/earn.d.ts +642 -126
  32. package/dist/apps/data/routes/earn.d.ts.map +1 -1
  33. package/dist/apps/data/routes/earn.js +627 -58
  34. package/dist/apps/data/routes/earn.js.map +1 -1
  35. package/dist/apps/data/routes/exchanges.d.ts +120 -120
  36. package/dist/apps/data/routes/exchanges.d.ts.map +1 -1
  37. package/dist/apps/data/routes/exchanges.js +114 -118
  38. package/dist/apps/data/routes/exchanges.js.map +1 -1
  39. package/dist/apps/data/routes/receipts.d.ts.map +1 -1
  40. package/dist/apps/data/routes/receipts.js +68 -47
  41. package/dist/apps/data/routes/receipts.js.map +1 -1
  42. package/dist/apps/data/routes/tokens.d.ts +2 -0
  43. package/dist/apps/data/routes/tokens.d.ts.map +1 -1
  44. package/dist/apps/data/routes/transactions.d.ts +59 -51
  45. package/dist/apps/data/routes/transactions.d.ts.map +1 -1
  46. package/dist/apps/data/routes/transactions.js +25 -23
  47. package/dist/apps/data/routes/transactions.js.map +1 -1
  48. package/dist/apps/data/routes/transfers.d.ts +40 -18
  49. package/dist/apps/data/routes/transfers.d.ts.map +1 -1
  50. package/dist/apps/data/routes/transfers.js +618 -105
  51. package/dist/apps/data/routes/transfers.js.map +1 -1
  52. package/dist/apps/data/routes/valuation.d.ts +21 -21
  53. package/dist/apps/data/routes/valuation.d.ts.map +1 -1
  54. package/dist/apps/data/routes/valuation.js +2 -2
  55. package/dist/apps/data/routes/valuation.js.map +1 -1
  56. package/dist/apps/data/routes/verified-tokens.d.ts +37 -37
  57. package/dist/apps/data/routes/webhooks.d.ts +102 -505
  58. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  59. package/dist/apps/data/routes/webhooks.js +484 -342
  60. package/dist/apps/data/routes/webhooks.js.map +1 -1
  61. package/dist/apps/data/routes/zones.d.ts +12 -12
  62. package/dist/apps/data/routes/zones.d.ts.map +1 -1
  63. package/dist/apps/data/routes/zones.js +3 -1
  64. package/dist/apps/data/routes/zones.js.map +1 -1
  65. package/dist/apps/funding/App.d.ts +127 -386
  66. package/dist/apps/funding/App.d.ts.map +1 -1
  67. package/dist/apps/funding/App.js +20 -5
  68. package/dist/apps/funding/App.js.map +1 -1
  69. package/dist/apps/funding/routes/deposit-addresses.d.ts +141 -532
  70. package/dist/apps/funding/routes/deposit-addresses.d.ts.map +1 -1
  71. package/dist/apps/funding/routes/deposit-addresses.js +21 -103
  72. package/dist/apps/funding/routes/deposit-addresses.js.map +1 -1
  73. package/dist/apps/funding/routes/transfers.d.ts +4 -4
  74. package/dist/apps/funding/routes/transfers.js +5 -5
  75. package/dist/apps/funding/routes/transfers.js.map +1 -1
  76. package/dist/apps/management/App.d.ts +429 -727
  77. package/dist/apps/management/App.d.ts.map +1 -1
  78. package/dist/apps/management/routes/api-keys.d.ts +24 -24
  79. package/dist/apps/management/routes/billing.d.ts +72 -72
  80. package/dist/apps/management/routes/faucet.d.ts +6 -6
  81. package/dist/apps/management/routes/invite-links.d.ts +38 -38
  82. package/dist/apps/management/routes/me.d.ts +6 -6
  83. package/dist/apps/management/routes/members.d.ts +30 -30
  84. package/dist/apps/management/routes/projects.d.ts +20 -20
  85. package/dist/apps/management/routes/usage.d.ts +24 -24
  86. package/dist/apps/management/routes/webhooks.d.ts +158 -573
  87. package/dist/apps/management/routes/webhooks.d.ts.map +1 -1
  88. package/dist/apps/mpp/App.d.ts +12 -0
  89. package/dist/apps/mpp/App.d.ts.map +1 -1
  90. package/dist/apps/mpp/Mpp.d.ts +3 -0
  91. package/dist/apps/mpp/Mpp.d.ts.map +1 -1
  92. package/dist/apps/mpp/Mpp.js +3 -0
  93. package/dist/apps/mpp/Mpp.js.map +1 -1
  94. package/dist/apps/mpp/internal/Adapter.d.ts.map +1 -1
  95. package/dist/apps/mpp/internal/Adapter.js +9 -2
  96. package/dist/apps/mpp/internal/Adapter.js.map +1 -1
  97. package/dist/db/Db.d.ts +2 -2
  98. package/dist/db/Db.d.ts.map +1 -1
  99. package/dist/db/Db.js +25 -15
  100. package/dist/db/Db.js.map +1 -1
  101. package/dist/db/tables/fundingDeposits.d.ts +18 -1
  102. package/dist/db/tables/fundingDeposits.d.ts.map +1 -1
  103. package/dist/db/tables/fundingDeposits.js +46 -2
  104. package/dist/db/tables/fundingDeposits.js.map +1 -1
  105. package/dist/db/tables/sponsoredTransactions.d.ts.map +1 -1
  106. package/dist/db/tables/sponsoredTransactions.js +4 -1
  107. package/dist/db/tables/sponsoredTransactions.js.map +1 -1
  108. package/dist/db/tables/webhookSubscriptions.d.ts +56 -3
  109. package/dist/db/tables/webhookSubscriptions.d.ts.map +1 -1
  110. package/dist/db/tables/webhookSubscriptions.js +76 -3
  111. package/dist/db/tables/webhookSubscriptions.js.map +1 -1
  112. package/dist/internal/Auth.d.ts +4 -0
  113. package/dist/internal/Auth.d.ts.map +1 -1
  114. package/dist/internal/Auth.js +11 -2
  115. package/dist/internal/Auth.js.map +1 -1
  116. package/dist/internal/MetricSink.d.ts +112 -3
  117. package/dist/internal/MetricSink.d.ts.map +1 -1
  118. package/dist/internal/MetricSink.js +116 -3
  119. package/dist/internal/MetricSink.js.map +1 -1
  120. package/dist/internal/Schema.js +1 -1
  121. package/dist/internal/Schema.js.map +1 -1
  122. package/dist/internal/Tidx.d.ts +1 -3
  123. package/dist/internal/Tidx.d.ts.map +1 -1
  124. package/dist/internal/Tidx.js +1 -3
  125. package/dist/internal/Tidx.js.map +1 -1
  126. package/dist/internal/Viem.d.ts.map +1 -1
  127. package/dist/internal/Viem.js +2 -1
  128. package/dist/internal/Viem.js.map +1 -1
  129. package/dist/internal/WebhookDestination.d.ts +2 -0
  130. package/dist/internal/WebhookDestination.d.ts.map +1 -1
  131. package/dist/internal/WebhookDestination.js +11 -0
  132. package/dist/internal/WebhookDestination.js.map +1 -1
  133. package/dist/internal/WebhookTransfer.d.ts +55 -0
  134. package/dist/internal/WebhookTransfer.d.ts.map +1 -0
  135. package/dist/internal/WebhookTransfer.js +296 -0
  136. package/dist/internal/WebhookTransfer.js.map +1 -0
  137. package/dist/internal/Webhooks.d.ts +37 -0
  138. package/dist/internal/Webhooks.d.ts.map +1 -1
  139. package/dist/internal/Webhooks.js +87 -13
  140. package/dist/internal/Webhooks.js.map +1 -1
  141. package/dist/internal/funding/Provider.d.ts +10 -6
  142. package/dist/internal/funding/Provider.d.ts.map +1 -1
  143. package/dist/internal/funding/Provider.js +11 -7
  144. package/dist/internal/funding/Provider.js.map +1 -1
  145. package/dist/internal/funding/Reconciliation.d.ts.map +1 -1
  146. package/dist/internal/funding/Reconciliation.js +229 -16
  147. package/dist/internal/funding/Reconciliation.js.map +1 -1
  148. package/dist/internal/funding/providers/relay.d.ts +3 -0
  149. package/dist/internal/funding/providers/relay.d.ts.map +1 -1
  150. package/dist/internal/funding/providers/relay.js +18 -3
  151. package/dist/internal/funding/providers/relay.js.map +1 -1
  152. package/package.json +3 -3
  153. package/src/App.test.ts +32 -0
  154. package/src/App.ts +4 -1
  155. package/src/Client.test-d.ts +116 -38
  156. package/src/Webhooks.bench.ts +714 -0
  157. package/src/Webhooks.memory.bench.ts +250 -0
  158. package/src/Webhooks.test.ts +77 -6
  159. package/src/Webhooks.ts +83 -29
  160. package/src/apps/Zones.test.ts +5 -4
  161. package/src/apps/Zones.ts +9 -8
  162. package/src/apps/composition.test.ts +39 -10
  163. package/src/apps/data/FxOracle.test.ts +18 -0
  164. package/src/apps/data/FxOracle.ts +28 -5
  165. package/src/apps/data/routes/activities.test.ts +19 -0
  166. package/src/apps/data/routes/activities.ts +34 -30
  167. package/src/apps/data/routes/balances.test.ts +161 -10
  168. package/src/apps/data/routes/balances.ts +339 -147
  169. package/src/apps/data/routes/blocks.test.ts +12 -6
  170. package/src/apps/data/routes/earn.test.ts +286 -9
  171. package/src/apps/data/routes/earn.ts +841 -58
  172. package/src/apps/data/routes/exchanges.test.ts +7 -6
  173. package/src/apps/data/routes/exchanges.ts +124 -117
  174. package/src/apps/data/routes/receipts.test.ts +24 -7
  175. package/src/apps/data/routes/receipts.ts +67 -50
  176. package/src/apps/data/routes/tokens.test.ts +51 -0
  177. package/src/apps/data/routes/transactions.test.ts +42 -0
  178. package/src/apps/data/routes/transactions.ts +44 -39
  179. package/src/apps/data/routes/transfers.test.ts +402 -24
  180. package/src/apps/data/routes/transfers.ts +708 -101
  181. package/src/apps/data/routes/valuation.test.ts +26 -0
  182. package/src/apps/data/routes/valuation.ts +6 -2
  183. package/src/apps/data/routes/webhooks.test.ts +414 -130
  184. package/src/apps/data/routes/webhooks.ts +603 -419
  185. package/src/apps/data/routes/zones.ts +6 -1
  186. package/src/apps/funding/App.ts +20 -5
  187. package/src/apps/funding/routes/deposit-addresses.test.ts +43 -22
  188. package/src/apps/funding/routes/deposit-addresses.ts +34 -117
  189. package/src/apps/funding/routes/quotes.test.ts +1 -1
  190. package/src/apps/funding/routes/transfers.test.ts +6 -25
  191. package/src/apps/funding/routes/transfers.ts +5 -5
  192. package/src/apps/mpp/Mpp.test.ts +13 -1
  193. package/src/apps/mpp/Mpp.ts +8 -0
  194. package/src/apps/mpp/internal/Adapter.ts +10 -2
  195. package/src/db/Db.test.ts +40 -6
  196. package/src/db/Db.ts +42 -19
  197. package/src/db/tables/fundingDeposits.test.ts +101 -0
  198. package/src/db/tables/fundingDeposits.ts +71 -2
  199. package/src/db/tables/sponsoredTransactions.ts +9 -2
  200. package/src/db/tables/webhookSubscriptions.ts +116 -4
  201. package/src/internal/Auth.test.ts +28 -0
  202. package/src/internal/Auth.ts +15 -1
  203. package/src/internal/EarnVaults.test.ts +2 -2
  204. package/src/internal/MetricSink.test.ts +518 -0
  205. package/src/internal/MetricSink.ts +266 -9
  206. package/src/internal/Schema.ts +1 -1
  207. package/src/internal/Tidx.test.ts +4 -4
  208. package/src/internal/Tidx.ts +1 -3
  209. package/src/internal/Viem.test.ts +8 -0
  210. package/src/internal/Viem.ts +2 -1
  211. package/src/internal/WebhookDestination.test.ts +20 -0
  212. package/src/internal/WebhookDestination.ts +10 -0
  213. package/src/internal/WebhookTransfer.ts +426 -0
  214. package/src/internal/Webhooks.test.ts +137 -0
  215. package/src/internal/Webhooks.ts +131 -17
  216. package/src/internal/funding/Provider.test.ts +47 -7
  217. package/src/internal/funding/Provider.ts +16 -11
  218. package/src/internal/funding/Reconciliation.local.test.ts +3 -1
  219. package/src/internal/funding/Reconciliation.test.ts +1532 -54
  220. package/src/internal/funding/Reconciliation.ts +296 -53
  221. package/src/internal/funding/SourceObservation.test.ts +3 -2
  222. package/src/internal/funding/providers/relay.test.ts +1 -4
  223. package/src/internal/funding/providers/relay.ts +23 -3
package/src/db/Db.test.ts CHANGED
@@ -5,6 +5,7 @@ import * as Runtime from '../../test/runtime.js'
5
5
  import * as Db from './Db.js'
6
6
  import * as Invitations from './tables/invitations.js'
7
7
  import * as Organizations from './tables/organizations.js'
8
+ import * as WebhookSubscriptions from './tables/webhookSubscriptions.js'
8
9
 
9
10
  const create = () => Db.postgres({ connectionString: Runtime.postgresUrl, schema: `t_${nanoid()}` })
10
11
 
@@ -39,14 +40,47 @@ describe('postgres', () => {
39
40
  await db.close()
40
41
  })
41
42
 
42
- test('behavior: concurrent migrate tolerates the ledger race', async () => {
43
+ test('behavior: defers schema creation until migration', async () => {
43
44
  const schema = `t_${nanoid()}`
44
- // Pre-create the schema so concurrent migrate races only on the
45
- // `migrations` ledger, not `CREATE SCHEMA`.
46
- const seed = Db.postgres({ connectionString: Runtime.postgresUrl, schema })
47
- await Organizations.get(seed, 'x').catch(() => {})
48
- await seed.close()
45
+ const db = Db.postgres({ connectionString: Runtime.postgresUrl, schema })
46
+ const exists = async () =>
47
+ (
48
+ await sql<{
49
+ exists: boolean
50
+ }>`SELECT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = ${schema}) AS exists`.execute(
51
+ db.kysely,
52
+ )
53
+ ).rows[0]!.exists
54
+
55
+ await sql`SELECT 1`.execute(db.kysely)
56
+ expect(await exists()).toBe(false)
57
+ await db.migrate()
58
+ expect(await exists()).toBe(true)
59
+ await db.close()
60
+ })
61
+
62
+ test('behavior: qualifies repository queries with the configured schema', async () => {
63
+ const db = Db.postgres({
64
+ connectionString: Runtime.postgresUrl,
65
+ schema: `t_MixedCase_${nanoid()}`,
66
+ })
67
+ await sql`SET search_path TO public`.execute(db.kysely)
68
+ await db.migrate()
69
+ await sql`SET search_path TO public`.execute(db.kysely)
70
+ await db.transaction(async (tx) => {
71
+ await Organizations.create(tx, { id: 'org_qualified', name: 'Qualified' })
72
+ })
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())
79
+ await db.close()
80
+ })
49
81
 
82
+ test('behavior: concurrent migrate tolerates the ledger race', async () => {
83
+ const schema = `t_${nanoid()}`
50
84
  // Several runners migrate the same fresh schema at once (mirrors parallel
51
85
  // deploys). The session advisory lock serializes them; none throw.
52
86
  const dbs = Array.from({ length: 5 }, () =>
package/src/db/Db.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  PostgresDialect,
8
8
  type RawBuilder,
9
9
  sql,
10
+ WithSchemaPlugin,
10
11
  } from 'kysely'
11
12
  import * as pg from 'pg'
12
13
 
@@ -1271,6 +1272,15 @@ export const migrations: readonly Migration[] = [
1271
1272
  sql`ALTER TABLE funding_deposits DROP CONSTRAINT IF EXISTS funding_deposits_detection_trigger_check, ADD CONSTRAINT funding_deposits_detection_trigger_check CHECK (detection_trigger IN ('chain', 'manual', 'poll', 'webhook'))`,
1272
1273
  ),
1273
1274
  },
1275
+ {
1276
+ name: '0114_funding_deposit_addresses_owner_address_index',
1277
+ up: (db) =>
1278
+ db.schema
1279
+ .createIndex('funding_deposit_addresses_owner_address_idx')
1280
+ .ifNotExists()
1281
+ .on('fundingDepositAddresses')
1282
+ .columns(['orgId', 'environment', 'address', 'projectId']),
1283
+ },
1274
1284
  ]
1275
1285
 
1276
1286
  /**
@@ -1292,6 +1302,15 @@ function raw(db: Kysely<Database>, statement: RawBuilder<unknown>) {
1292
1302
  * @returns The database.
1293
1303
  */
1294
1304
  export function from(dialect: Dialect): Db {
1305
+ return fromDialect(dialect)
1306
+ }
1307
+
1308
+ /** Builds a database with internal dialect-specific options. */
1309
+ function fromDialect(dialect: Dialect, options: fromDialect.Options = {}): Db {
1310
+ const root = new Kysely<Database>({ dialect, plugins: [new CamelCasePlugin()] })
1311
+ // Qualify repository queries instead of relying on session search_path;
1312
+ // transaction poolers reset session state between statements.
1313
+ const kysely = options.schema ? root.withPlugin(new WithSchemaPlugin(options.schema)) : root
1295
1314
  // Builds Db over a top-level Kysely handle or transaction-scoped Kysely handle.
1296
1315
  function build(kysely: Kysely<Database>): Db {
1297
1316
  // Prevents same-handle nested transactions from deadlocking capped pools.
@@ -1305,9 +1324,19 @@ export function from(dialect: Dialect): Db {
1305
1324
  async migrate() {
1306
1325
  // A session advisory lock serializes the full run, including bootstrap
1307
1326
  // DDL and ledger inserts. Pin one connection and unlock in `finally`.
1308
- await kysely.connection().execute(async (db) => {
1327
+ await root.connection().execute(async (db) => {
1309
1328
  await sql`SELECT pg_advisory_lock(${migrateLockKey})`.execute(db)
1310
1329
  try {
1330
+ if (options.schema) {
1331
+ await db.executeQuery(
1332
+ CompiledQuery.raw(
1333
+ `CREATE SCHEMA IF NOT EXISTS ${pg.escapeIdentifier(options.schema)}`,
1334
+ ),
1335
+ )
1336
+ await sql`SELECT set_config('search_path', quote_ident(${options.schema}), false)`.execute(
1337
+ db,
1338
+ )
1339
+ }
1311
1340
  await sql`CREATE TABLE IF NOT EXISTS migrations (name TEXT PRIMARY KEY, checksum TEXT NOT NULL, applied_at TEXT NOT NULL)`.execute(
1312
1341
  db,
1313
1342
  )
@@ -1369,12 +1398,20 @@ export function from(dialect: Dialect): Db {
1369
1398
  },
1370
1399
  }
1371
1400
  }
1372
- return build(new Kysely<Database>({ dialect, plugins: [new CamelCasePlugin()] }))
1401
+ return build(kysely)
1402
+ }
1403
+
1404
+ declare namespace fromDialect {
1405
+ /** Internal database construction options. */
1406
+ type Options = {
1407
+ /** Schema that Kysely qualifies on every repository query. */
1408
+ schema?: string | undefined
1409
+ }
1373
1410
  }
1374
1411
 
1375
1412
  /**
1376
1413
  * Creates a Postgres-backed {@link Db} via Kysely's `PostgresDialect`. Optional
1377
- * `schema` scopes every pooled connection and caps the pool for test isolation.
1414
+ * `schema` qualifies repository queries, scopes migrations, and caps the pool.
1378
1415
  *
1379
1416
  * @param options - Postgres options.
1380
1417
  * @returns The database.
@@ -1382,21 +1419,7 @@ export function from(dialect: Dialect): Db {
1382
1419
  export function postgres(options: postgres.Options): Db {
1383
1420
  const { connectionString, schema } = options
1384
1421
  const pool = new pg.Pool(schema ? { connectionString, max: 1 } : { connectionString })
1385
- return from(
1386
- new PostgresDialect({
1387
- pool,
1388
- ...(schema
1389
- ? {
1390
- onCreateConnection: async (connection) => {
1391
- await connection.executeQuery(
1392
- CompiledQuery.raw(`CREATE SCHEMA IF NOT EXISTS "${schema}"`),
1393
- )
1394
- await connection.executeQuery(CompiledQuery.raw(`SET search_path TO "${schema}"`))
1395
- },
1396
- }
1397
- : {}),
1398
- }),
1399
- )
1422
+ return fromDialect(new PostgresDialect({ pool }), { schema })
1400
1423
  }
1401
1424
 
1402
1425
  export declare namespace postgres {
@@ -1404,7 +1427,7 @@ export declare namespace postgres {
1404
1427
  type Options = {
1405
1428
  /** Postgres connection string (`postgresql://…`). */
1406
1429
  connectionString: string
1407
- /** Schema to create and scope the session to (isolates concurrent tests). */
1430
+ /** Schema to qualify and create during migration (isolates concurrent tests). */
1408
1431
  schema?: string | undefined
1409
1432
  }
1410
1433
  }
@@ -77,6 +77,107 @@ describe('listByAddress', () => {
77
77
  })
78
78
  })
79
79
 
80
+ describe('listByDepositAddress', () => {
81
+ const seedAddressDeposit = (address: Awaited<ReturnType<typeof seedAddress>>, index: number) => {
82
+ const sourceTransactionHash = `fe${index.toString(16).padStart(62, '0')}`
83
+ return Deposit.create(db, {
84
+ now: new Date(Date.UTC(2026, 1, 2, 0, 0, index)),
85
+ providerRequestId: `relay_deposit_address_request_${index}`,
86
+ snapshot: TestFunding.depositSnapshot(address.id, {
87
+ provider: address.snapshot.provider,
88
+ sourceTransactionHashes: [sourceTransactionHash],
89
+ }),
90
+ sourceTransactionHash,
91
+ sourceTransferIndex: 0,
92
+ })
93
+ }
94
+
95
+ test('pages one reusable address across provider records newest-first', async () => {
96
+ const depositAddress = 'TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE'
97
+ const firstAddress = await seedAddress({
98
+ snapshot: TestFunding.depositAddressSnapshot({
99
+ address: depositAddress,
100
+ provider: { id: 'relay_deposit_address_first', name: 'Relay' },
101
+ }),
102
+ })
103
+ const secondAddress = await seedAddress({
104
+ snapshot: TestFunding.depositAddressSnapshot({
105
+ address: depositAddress,
106
+ provider: { id: 'relay_deposit_address_second', name: 'Relay' },
107
+ subsidize: true,
108
+ }),
109
+ })
110
+ const first = await seedAddressDeposit(firstAddress, 1)
111
+ const second = await seedAddressDeposit(secondAddress, 2)
112
+ const third = await seedAddressDeposit(firstAddress, 3)
113
+ const unrelatedAddress = await seedAddress({
114
+ snapshot: TestFunding.depositAddressSnapshot({
115
+ address: 'TF17BgPaZYbz8oxbjhriubPDsA7ArKoLX3',
116
+ provider: { id: 'relay_deposit_address_unrelated', name: 'Relay' },
117
+ recipient: `0x${'fe'.repeat(20)}`,
118
+ }),
119
+ })
120
+ await seedAddressDeposit(unrelatedAddress, 4)
121
+
122
+ const page = await FundingDeposits.listByDepositAddress(db, {
123
+ depositAddress,
124
+ limit: 2,
125
+ owner,
126
+ })
127
+ expect(page.map((row) => row.id)).toEqual([third.id, second.id])
128
+ const rest = await FundingDeposits.listByDepositAddress(db, {
129
+ cursor: { createdAt: second.createdAt, id: second.id },
130
+ depositAddress,
131
+ limit: 2,
132
+ owner,
133
+ })
134
+ expect(rest.map((row) => row.id)).toEqual([first.id])
135
+ })
136
+
137
+ test('narrows project-attributed owners to their project', async () => {
138
+ const depositAddress = 'TWd4WrZ9wn84f5x1hZhL4DHvk738ns5jwb'
139
+ const address = await seedAddress({
140
+ projectId: 'prj_deposit',
141
+ snapshot: TestFunding.depositAddressSnapshot({
142
+ address: depositAddress,
143
+ provider: { id: 'relay_deposit_address_project', name: 'Relay' },
144
+ }),
145
+ })
146
+ await seedAddressDeposit(address, 5)
147
+
148
+ expect(
149
+ await FundingDeposits.listByDepositAddress(db, {
150
+ depositAddress,
151
+ limit: 10,
152
+ owner: { ...owner, projectId: 'prj_other' },
153
+ }),
154
+ ).toEqual([])
155
+ })
156
+
157
+ test('normalizes EVM address casing', async () => {
158
+ const depositAddress = `0x${'Ab'.repeat(20)}`
159
+ const source = TestFunding.transferSnapshot()
160
+ const address = await seedAddress({
161
+ snapshot: TestFunding.depositAddressSnapshot({
162
+ address: depositAddress,
163
+ provider: { id: 'relay_deposit_address_evm', name: 'Relay' },
164
+ recipient: `0x${'fd'.repeat(20)}`,
165
+ refundAddress: depositAddress,
166
+ sourceChain: source.sourceChain,
167
+ sourceToken: source.sourceToken,
168
+ }),
169
+ })
170
+ const record = await seedAddressDeposit(address, 6)
171
+
172
+ const rows = await FundingDeposits.listByDepositAddress(db, {
173
+ depositAddress,
174
+ limit: 10,
175
+ owner,
176
+ })
177
+ expect(rows.map((row) => row.id)).toEqual([record.id])
178
+ })
179
+ })
180
+
80
181
  describe('listByRecipient', () => {
81
182
  test('pages recipient deposits across addresses and hides foreign ownership', async () => {
82
183
  const recipient = '0xABcdEFABcdEFabcdEfAbCdefabcdeFABcDEFabCD'
@@ -187,7 +187,20 @@ export declare namespace listBySourceTransaction {
187
187
  }
188
188
  }
189
189
 
190
- /** Returns whether an address has a nonterminal source transfer awaiting provider attribution. */
190
+ /** Lists nonterminal source transfers that await provider attribution. */
191
+ export function listUnattributed(db: Db.Db, depositAddressId: string): Promise<Record[]> {
192
+ return db.kysely
193
+ .selectFrom('funding_deposits')
194
+ .selectAll()
195
+ .where('depositAddressId', '=', depositAddressId)
196
+ .where('providerRequestId', 'is', null)
197
+ .where('status', 'not in', ['completed', 'refunded'])
198
+ .orderBy('createdAt', 'asc')
199
+ .orderBy('id', 'asc')
200
+ .execute()
201
+ }
202
+
203
+ /** Returns whether an address has a source transfer awaiting provider attribution. */
191
204
  export async function hasUnattributed(db: Db.Db, depositAddressId: string): Promise<boolean> {
192
205
  return Boolean(
193
206
  await db.kysely
@@ -195,7 +208,6 @@ export async function hasUnattributed(db: Db.Db, depositAddressId: string): Prom
195
208
  .select('id')
196
209
  .where('depositAddressId', '=', depositAddressId)
197
210
  .where('providerRequestId', 'is', null)
198
- .where('status', 'not in', ['completed', 'refunded'])
199
211
  .limit(1)
200
212
  .executeTakeFirst(),
201
213
  )
@@ -265,6 +277,63 @@ export declare namespace listByAddress {
265
277
  }
266
278
  }
267
279
 
280
+ /** Lists deposits for one reusable source-chain address newest-first. */
281
+ export function listByDepositAddress(
282
+ db: Db.Db,
283
+ options: listByDepositAddress.Options,
284
+ ): Promise<Record[]> {
285
+ const { cursor, depositAddress, limit, owner } = options
286
+ const address = /^0x[0-9a-fA-F]{40}$/.test(depositAddress)
287
+ ? depositAddress.toLowerCase()
288
+ : depositAddress
289
+ let query = db.kysely
290
+ .selectFrom('funding_deposits')
291
+ .innerJoin(
292
+ 'funding_deposit_addresses',
293
+ 'funding_deposit_addresses.id',
294
+ 'funding_deposits.depositAddressId',
295
+ )
296
+ .selectAll('funding_deposits')
297
+ .where('funding_deposit_addresses.address', '=', address)
298
+ .where('funding_deposit_addresses.environment', '=', owner.environment)
299
+ .where('funding_deposit_addresses.orgId', '=', owner.orgId)
300
+ .where('funding_deposits.environment', '=', owner.environment)
301
+ .where('funding_deposits.orgId', '=', owner.orgId)
302
+ if (owner.projectId !== undefined)
303
+ query = query
304
+ .where('funding_deposit_addresses.projectId', '=', owner.projectId)
305
+ .where('funding_deposits.projectId', '=', owner.projectId)
306
+ if (cursor)
307
+ query = query.where((eb) =>
308
+ eb.or([
309
+ eb('funding_deposits.createdAt', '<', cursor.createdAt),
310
+ eb.and([
311
+ eb('funding_deposits.createdAt', '=', cursor.createdAt),
312
+ eb('funding_deposits.id', '<', cursor.id),
313
+ ]),
314
+ ]),
315
+ )
316
+ return query
317
+ .orderBy('funding_deposits.createdAt', 'desc')
318
+ .orderBy('funding_deposits.id', 'desc')
319
+ .limit(limit)
320
+ .execute()
321
+ }
322
+
323
+ export declare namespace listByDepositAddress {
324
+ /** Deposit address filter, ownership, and paging options. */
325
+ type Options = {
326
+ /** Exclusive lower bound from the previous page. */
327
+ cursor?: listByAddress.Cursor | undefined
328
+ /** Reusable source-chain address that received the funds. */
329
+ depositAddress: string
330
+ /** Maximum rows to return. */
331
+ limit: number
332
+ /** Ownership filter derived from the authenticated API key. */
333
+ owner: Owner
334
+ }
335
+ }
336
+
268
337
  /** Lists one recipient's owner-visible deposits newest-first. */
269
338
  export function listByRecipient(db: Db.Db, options: listByRecipient.Options): Promise<Record[]> {
270
339
  const { cursor, limit, owner, recipient } = options
@@ -1,4 +1,4 @@
1
- import type { ExpressionBuilder, Selectable, SqlBool } from 'kysely'
1
+ import type { ExpressionBuilder, Selectable } from 'kysely'
2
2
  import { sql } from 'kysely'
3
3
 
4
4
  import * as Id from '../../internal/Id.js'
@@ -222,7 +222,14 @@ export async function assignTransactionHash(
222
222
  .where('status', '=', record.status)
223
223
  .where('transactionHash', 'is', null)
224
224
  if (record.status === 'failed')
225
- query = query.where(sql<SqlBool>`exists (select 1 from organizations where organizations.id = sponsored_transactions.org_id)`) // prettier-ignore
225
+ query = query.where((eb) =>
226
+ eb.exists(
227
+ eb
228
+ .selectFrom('organizations')
229
+ .select('organizations.id')
230
+ .whereRef('organizations.id', '=', 'sponsored_transactions.orgId'),
231
+ ),
232
+ )
226
233
  const updated = await query.executeTakeFirst()
227
234
  if (updated.numUpdatedRows === 0n) return
228
235
  if (record.status === 'failed' && record.billable && limit) {
@@ -273,20 +273,21 @@ export async function remove(
273
273
 
274
274
  /**
275
275
  * Lists live `active` subscriptions for a `(chainId, eventType)` stream — the
276
- * poller's per-stream lookup, served by the partial status index.
276
+ * poller's per-stream lookup, served by the partial status index. Each row
277
+ * carries its poller cursor so the poller avoids a per-subscription read.
277
278
  *
278
279
  * @param db - The database.
279
280
  * @param chainId - Chain the stream belongs to.
280
281
  * @param eventType - Event type of the stream.
281
282
  * @param now - ISO timestamp expiry is evaluated against.
282
- * @returns The subscriptions, in id (chronological) order.
283
+ * @returns Cursor-and-subscription pairs, in id (chronological) order.
283
284
  */
284
285
  export async function listActive(
285
286
  db: Db.Db,
286
287
  chainId: number,
287
288
  eventType: Webhooks.EventType,
288
289
  now: string,
289
- ): Promise<Webhooks.Subscription[]> {
290
+ ): Promise<{ cursor: string | null; subscription: Webhooks.Subscription }[]> {
290
291
  const rows = await db.kysely
291
292
  .selectFrom('webhook_subscriptions')
292
293
  .selectAll()
@@ -296,7 +297,69 @@ export async function listActive(
296
297
  .where(live(now))
297
298
  .orderBy('id')
298
299
  .execute()
299
- return rows.map(toSubscription)
300
+ return rows.map((row) => ({ cursor: row.pollerCursor, subscription: toSubscription(row) }))
301
+ }
302
+
303
+ /**
304
+ * Lists every live `active` subscription on a chain in one read. The poller
305
+ * groups the rows by event type so empty streams cost no extra database reads.
306
+ *
307
+ * @param db - The database.
308
+ * @param options - Chain, configured event types, and expiry time.
309
+ * @returns Cursor-and-subscription pairs ordered by event type, then id.
310
+ */
311
+ export async function listActiveForChain(
312
+ db: Db.Db,
313
+ options: listActiveForChain.Options,
314
+ ): Promise<{ cursor: string | null; subscription: Webhooks.Subscription }[]> {
315
+ if (options.eventTypes.length === 0) return []
316
+ const rows = await db.kysely
317
+ .selectFrom('webhook_subscriptions')
318
+ .selectAll()
319
+ .where('chainId', '=', options.chainId)
320
+ .where('eventType', 'in', options.eventTypes)
321
+ .where('status', '=', 'active')
322
+ .where(live(options.now))
323
+ .orderBy('eventType')
324
+ .orderBy('id')
325
+ .execute()
326
+ return rows.map((row) => ({ cursor: row.pollerCursor, subscription: toSubscription(row) }))
327
+ }
328
+
329
+ export declare namespace listActiveForChain {
330
+ /** Filters for the poller's chain-level subscription read. */
331
+ type Options = {
332
+ /** Chain whose subscriptions to read. */
333
+ chainId: number
334
+ /** Event types configured for this poller tick. */
335
+ eventTypes: readonly Webhooks.EventType[]
336
+ /** ISO timestamp expiry is evaluated against. */
337
+ now: string
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Reads the poller cursors of every live `active` subscription on a chain,
343
+ * across event types. Feeds the cron's cursor-freshness fallback gate.
344
+ *
345
+ * @param db - The database.
346
+ * @param chainId - Chain whose cursors to read.
347
+ * @param now - ISO timestamp expiry is evaluated against.
348
+ * @returns The non-null cursors, unordered.
349
+ */
350
+ export async function listActiveCursors(
351
+ db: Db.Db,
352
+ chainId: number,
353
+ now: string,
354
+ ): Promise<string[]> {
355
+ const rows = await db.kysely
356
+ .selectFrom('webhook_subscriptions')
357
+ .select('pollerCursor')
358
+ .where('chainId', '=', chainId)
359
+ .where('status', '=', 'active')
360
+ .where(live(now))
361
+ .execute()
362
+ return rows.flatMap((row) => (row.pollerCursor === null ? [] : [row.pollerCursor]))
300
363
  }
301
364
 
302
365
  /**
@@ -367,6 +430,55 @@ export declare namespace compareAndSetCursor {
367
430
  }
368
431
  }
369
432
 
433
+ /**
434
+ * Writes multiple poller cursors when each stored value still matches the
435
+ * caller's observed value.
436
+ *
437
+ * @param db - The database.
438
+ * @param options - Candidate cursor writes.
439
+ * @returns Subscription ids whose cursors were updated.
440
+ */
441
+ export async function compareAndSetCursors(
442
+ db: Db.Db,
443
+ options: compareAndSetCursors.Options,
444
+ ): Promise<Set<string>> {
445
+ if (options.cursors.length === 0) return new Set()
446
+ const values = JSON.stringify(options.cursors)
447
+ type Candidate = {
448
+ cursor: string
449
+ expectedCursor: string | null
450
+ subscriptionId: string
451
+ }
452
+ const result = await db.kysely
453
+ .with(
454
+ 'candidates',
455
+ () => sql<Candidate>`(
456
+ SELECT
457
+ candidate."subscriptionId" AS subscription_id,
458
+ candidate."expectedCursor" AS expected_cursor,
459
+ candidate.cursor
460
+ FROM jsonb_to_recordset(${values}::jsonb)
461
+ AS candidate("subscriptionId" text, "expectedCursor" text, cursor text)
462
+ )`,
463
+ )
464
+ .updateTable('webhook_subscriptions as subscription')
465
+ .from('candidates as candidate')
466
+ .set((eb) => ({ pollerCursor: eb.ref('candidate.cursor') }))
467
+ .whereRef('subscription.id', '=', 'candidate.subscriptionId')
468
+ .whereRef('subscription.pollerCursor', 'is not distinct from', 'candidate.expectedCursor')
469
+ .returning('subscription.id as subscriptionId')
470
+ .execute()
471
+ return new Set(result.map((row) => row.subscriptionId))
472
+ }
473
+
474
+ export declare namespace compareAndSetCursors {
475
+ /** Options for {@link compareAndSetCursors}. */
476
+ type Options = {
477
+ /** Candidate cursor writes. */
478
+ cursors: readonly compareAndSetCursor.Options[]
479
+ }
480
+ }
481
+
370
482
  /**
371
483
  * Records a successful delivery: resets the consecutive-failure counter and
372
484
  * stamps `lastDeliveryAt`.
@@ -1256,6 +1256,34 @@ describe('require', () => {
1256
1256
  })
1257
1257
  })
1258
1258
 
1259
+ test('session challenge rejects a chain outside the configured allowlist', async () => {
1260
+ const mpp = Object.assign(
1261
+ createMppServer({
1262
+ chainId: Viem.chainId.mainnet,
1263
+ getClient: ({ chainId = Viem.chainId.mainnet }) => Viem.getClient({ chainId }),
1264
+ }),
1265
+ { sessionChainIds: new Set([Viem.chainId.mainnet, Viem.chainId.testnet]) },
1266
+ )
1267
+ const app = createApp({
1268
+ auth: {
1269
+ apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) },
1270
+ mpp,
1271
+ rateLimit: overQuota(),
1272
+ },
1273
+ policy: {
1274
+ mpp: { session: { amount: '1' } },
1275
+ public: { rateLimit: { perMinute: 1 } },
1276
+ },
1277
+ })
1278
+
1279
+ const response = await app.request('/protected?chainId=31318')
1280
+ const body = await response.json()
1281
+
1282
+ expect(response.status).toBe(429)
1283
+ expect(response.headers.has('www-authenticate')).toBe(false)
1284
+ expect(body).toMatchObject({ error: { code: 'payment_required' } })
1285
+ })
1286
+
1259
1287
  test('route session overrides propagate to the challenge', async () => {
1260
1288
  const mpp = createMppServer()
1261
1289
  const app = createApp({
@@ -117,6 +117,8 @@ type MppHandler = {
117
117
  session(options: Mpp.Session): MiddlewareHandler
118
118
  /** Resolves the chain from a challenge issued by this server. */
119
119
  sessionChainId?: ((c: hono_Context) => number | undefined) | undefined
120
+ /** Chains accepted from request-driven Tempo Session payment overrides. */
121
+ sessionChainIds?: ReadonlySet<number> | undefined
120
122
  }
121
123
 
122
124
  /**
@@ -504,6 +506,7 @@ export function middleware<
504
506
  const {
505
507
  rateLimit: mppRateLimit = defaultMppRateLimit,
506
508
  session: session_ = {},
509
+ sessionChainIds,
507
510
  ...mpp
508
511
  } = options.mpp === false ? {} : (options.mpp ?? {})
509
512
  const secretKey = resolveMppSecretKey(mpp.secretKey)
@@ -565,6 +568,7 @@ export function middleware<
565
568
  ],
566
569
  }).session,
567
570
  sessionChainId: (c) => paymentSessionChainId(c, secretKey),
571
+ ...(sessionChainIds ? { sessionChainIds: new Set(sessionChainIds) } : {}),
568
572
  },
569
573
  }),
570
574
  rateLimit: RateLimit.memory(options.rateLimit),
@@ -666,6 +670,8 @@ export declare namespace middleware {
666
670
  rateLimit?: RateLimit.Limit | undefined
667
671
  /** Global Tempo Session options passed to the MPP method instantiator. */
668
672
  session?: NonNullable<Parameters<typeof tempo.session>[0]> | undefined
673
+ /** Chains accepted from request-driven Tempo Session payment overrides. Omit to preserve the MPP client's chain support. */
674
+ sessionChainIds?: readonly number[] | undefined
669
675
  }
670
676
  }
671
677
 
@@ -1991,8 +1997,16 @@ async function requirePayment<environment extends Environment>(
1991
1997
  }
1992
1998
 
1993
1999
  const session = options.policy.mpp?.session ?? {}
2000
+ const explicit = explicitChainId(c)
2001
+ const sessionChainIds = options.auth.mpp?.sessionChainIds
2002
+ if (explicit !== undefined && sessionChainIds && !sessionChainIds.has(explicit))
2003
+ return jsonError(c, {
2004
+ code: 'payment_required',
2005
+ message: 'Payment is not available for the requested chain',
2006
+ status: 429,
2007
+ })
1994
2008
  const chainId =
1995
- explicitChainId(c) ??
2009
+ explicit ??
1996
2010
  (c.req.method === 'POST' && session.scope?.startsWith('GET ')
1997
2011
  ? options.auth.mpp?.sessionChainId?.(c)
1998
2012
  : undefined)
@@ -76,7 +76,7 @@ describe('resolve', () => {
76
76
  const getClient = Viem.createGetClient({
77
77
  defaultChainId: tempoTestnet.id,
78
78
  rpc: TestViem.rpc,
79
- zones: [Zones.zoneE],
79
+ zones: [Zones.zoneModerato],
80
80
  })
81
81
  const { instantLiquidity, sharePrice, state, ...discovery } = await EarnVaults.resolve({
82
82
  getClient,
@@ -84,7 +84,7 @@ describe('resolve', () => {
84
84
  chainId: tempoTestnet.id,
85
85
  vaultAddress,
86
86
  },
87
- zones: [Zones.zoneE],
87
+ zones: [Zones.zoneModerato],
88
88
  })
89
89
  expect({
90
90
  depositsPaused: state.depositsPaused,