tapimo 0.13.0 → 0.14.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 (230) hide show
  1. package/dist/App.d.ts +6064 -4829
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/App.js +1 -0
  4. package/dist/App.js.map +1 -1
  5. package/dist/Webhooks.js +2 -2
  6. package/dist/Webhooks.js.map +1 -1
  7. package/dist/admin/App.d.ts +1008 -917
  8. package/dist/admin/App.d.ts.map +1 -1
  9. package/dist/admin/apps/earn-vaults.d.ts +48 -0
  10. package/dist/admin/apps/earn-vaults.d.ts.map +1 -1
  11. package/dist/admin/apps/earn-vaults.js +2 -1
  12. package/dist/admin/apps/earn-vaults.js.map +1 -1
  13. package/dist/admin/apps/organizations.d.ts +92 -3
  14. package/dist/admin/apps/organizations.d.ts.map +1 -1
  15. package/dist/admin/apps/organizations.js +69 -7
  16. package/dist/admin/apps/organizations.js.map +1 -1
  17. package/dist/admin/apps/verified-tokens.d.ts +74 -74
  18. package/dist/admin/ui.generated.d.ts +1 -1
  19. package/dist/admin/ui.generated.js +69 -69
  20. package/dist/admin/ui.generated.js.map +1 -1
  21. package/dist/apps/Zones.d.ts +12 -12
  22. package/dist/apps/Zones.js +3 -3
  23. package/dist/apps/data/App.d.ts +2909 -2523
  24. package/dist/apps/data/App.d.ts.map +1 -1
  25. package/dist/apps/data/App.js +11 -6
  26. package/dist/apps/data/App.js.map +1 -1
  27. package/dist/apps/data/routes/addresses.d.ts +22 -22
  28. package/dist/apps/data/routes/earn.d.ts +183 -7
  29. package/dist/apps/data/routes/earn.d.ts.map +1 -1
  30. package/dist/apps/data/routes/earn.js +434 -75
  31. package/dist/apps/data/routes/earn.js.map +1 -1
  32. package/dist/apps/data/routes/exchanges.d.ts +15 -0
  33. package/dist/apps/data/routes/exchanges.d.ts.map +1 -1
  34. package/dist/apps/data/routes/exchanges.js +180 -114
  35. package/dist/apps/data/routes/exchanges.js.map +1 -1
  36. package/dist/apps/data/routes/fee-amm.d.ts.map +1 -1
  37. package/dist/apps/data/routes/fee-amm.js +18 -19
  38. package/dist/apps/data/routes/fee-amm.js.map +1 -1
  39. package/dist/apps/data/routes/tokens.d.ts +93 -93
  40. package/dist/apps/data/routes/transfers.d.ts +12 -12
  41. package/dist/apps/data/routes/valuation.d.ts +30 -30
  42. package/dist/apps/data/routes/verified-tokens.d.ts +5 -5
  43. package/dist/apps/data/routes/webhooks.d.ts +805 -102
  44. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  45. package/dist/apps/data/routes/webhooks.js +47 -5
  46. package/dist/apps/data/routes/webhooks.js.map +1 -1
  47. package/dist/apps/funding/App.d.ts +456 -45
  48. package/dist/apps/funding/App.d.ts.map +1 -1
  49. package/dist/apps/funding/App.js +1 -1
  50. package/dist/apps/funding/App.js.map +1 -1
  51. package/dist/apps/funding/routes/deposit-addresses.d.ts +17 -17
  52. package/dist/apps/funding/routes/deposit-addresses.d.ts.map +1 -1
  53. package/dist/apps/funding/routes/deposit-addresses.js +1 -1
  54. package/dist/apps/funding/routes/deposit-addresses.js.map +1 -1
  55. package/dist/apps/funding/routes/transfers.d.ts +654 -73
  56. package/dist/apps/funding/routes/transfers.d.ts.map +1 -1
  57. package/dist/apps/funding/routes/transfers.js +211 -5
  58. package/dist/apps/funding/routes/transfers.js.map +1 -1
  59. package/dist/apps/management/App.d.ts +831 -387
  60. package/dist/apps/management/App.d.ts.map +1 -1
  61. package/dist/apps/management/Billing.d.ts +1 -1
  62. package/dist/apps/management/Billing.d.ts.map +1 -1
  63. package/dist/apps/management/Billing.js +17 -1
  64. package/dist/apps/management/Billing.js.map +1 -1
  65. package/dist/apps/management/routes/api-keys.d.ts +76 -76
  66. package/dist/apps/management/routes/billing.d.ts +76 -76
  67. package/dist/apps/management/routes/faucet.d.ts +12 -12
  68. package/dist/apps/management/routes/invitations.d.ts +24 -24
  69. package/dist/apps/management/routes/invite-links.d.ts +46 -46
  70. package/dist/apps/management/routes/members.d.ts +46 -46
  71. package/dist/apps/management/routes/orgs.d.ts +20 -20
  72. package/dist/apps/management/routes/projects.d.ts +44 -44
  73. package/dist/apps/management/routes/usage.d.ts +32 -32
  74. package/dist/apps/management/routes/verified-token-requests.d.ts +16 -16
  75. package/dist/apps/management/routes/webhooks.d.ts +707 -57
  76. package/dist/apps/management/routes/webhooks.d.ts.map +1 -1
  77. package/dist/apps/management/routes/webhooks.js +21 -21
  78. package/dist/apps/management/routes/webhooks.js.map +1 -1
  79. package/dist/apps/relay/App.d.ts.map +1 -1
  80. package/dist/apps/relay/App.js +115 -23
  81. package/dist/apps/relay/App.js.map +1 -1
  82. package/dist/apps/relay/Sponsorships.d.ts.map +1 -1
  83. package/dist/apps/relay/Sponsorships.js +27 -1
  84. package/dist/apps/relay/Sponsorships.js.map +1 -1
  85. package/dist/db/Db.d.ts.map +1 -1
  86. package/dist/db/Db.js +22 -0
  87. package/dist/db/Db.js.map +1 -1
  88. package/dist/db/Schema.d.ts +8 -3
  89. package/dist/db/Schema.d.ts.map +1 -1
  90. package/dist/db/Schema.js +16 -1
  91. package/dist/db/Schema.js.map +1 -1
  92. package/dist/db/tables/fundingTransferTransactions.d.ts +38 -0
  93. package/dist/db/tables/fundingTransferTransactions.d.ts.map +1 -1
  94. package/dist/db/tables/fundingTransferTransactions.js +44 -3
  95. package/dist/db/tables/fundingTransferTransactions.js.map +1 -1
  96. package/dist/db/tables/fundingTransfers.d.ts +17 -0
  97. package/dist/db/tables/fundingTransfers.d.ts.map +1 -1
  98. package/dist/db/tables/fundingTransfers.js +21 -0
  99. package/dist/db/tables/fundingTransfers.js.map +1 -1
  100. package/dist/db/tables/organizations.d.ts +24 -0
  101. package/dist/db/tables/organizations.d.ts.map +1 -1
  102. package/dist/db/tables/organizations.js +27 -0
  103. package/dist/db/tables/organizations.js.map +1 -1
  104. package/dist/db/tables/projects.d.ts +25 -1
  105. package/dist/db/tables/projects.d.ts.map +1 -1
  106. package/dist/db/tables/projects.js +26 -0
  107. package/dist/db/tables/projects.js.map +1 -1
  108. package/dist/db/tables/sponsoredTransactions.d.ts +44 -0
  109. package/dist/db/tables/sponsoredTransactions.d.ts.map +1 -1
  110. package/dist/db/tables/sponsoredTransactions.js +146 -13
  111. package/dist/db/tables/sponsoredTransactions.js.map +1 -1
  112. package/dist/db/tables/webhookSubscriptions.d.ts +10 -6
  113. package/dist/db/tables/webhookSubscriptions.d.ts.map +1 -1
  114. package/dist/db/tables/webhookSubscriptions.js +9 -6
  115. package/dist/db/tables/webhookSubscriptions.js.map +1 -1
  116. package/dist/internal/EarnVaults.js +5 -5
  117. package/dist/internal/EarnVaults.js.map +1 -1
  118. package/dist/internal/Log.d.ts +1 -1
  119. package/dist/internal/Log.d.ts.map +1 -1
  120. package/dist/internal/Log.js.map +1 -1
  121. package/dist/internal/Viem.d.ts +16 -1
  122. package/dist/internal/Viem.d.ts.map +1 -1
  123. package/dist/internal/Viem.js +12 -2
  124. package/dist/internal/Viem.js.map +1 -1
  125. package/dist/internal/funding/DepositWebhook.d.ts.map +1 -1
  126. package/dist/internal/funding/DepositWebhook.js +2 -1
  127. package/dist/internal/funding/DepositWebhook.js.map +1 -1
  128. package/dist/internal/funding/Evidence.d.ts.map +1 -1
  129. package/dist/internal/funding/Evidence.js +2 -10
  130. package/dist/internal/funding/Evidence.js.map +1 -1
  131. package/dist/internal/funding/Provider.d.ts +40 -0
  132. package/dist/internal/funding/Provider.d.ts.map +1 -1
  133. package/dist/internal/funding/Provider.js +5 -0
  134. package/dist/internal/funding/Provider.js.map +1 -1
  135. package/dist/internal/funding/SourceObservation.d.ts.map +1 -1
  136. package/dist/internal/funding/SourceObservation.js +5 -7
  137. package/dist/internal/funding/SourceObservation.js.map +1 -1
  138. package/dist/internal/funding/Transfer.d.ts +101 -6
  139. package/dist/internal/funding/Transfer.d.ts.map +1 -1
  140. package/dist/internal/funding/Transfer.js +162 -34
  141. package/dist/internal/funding/Transfer.js.map +1 -1
  142. package/dist/internal/funding/TransferReconciliation.d.ts +52 -0
  143. package/dist/internal/funding/TransferReconciliation.d.ts.map +1 -0
  144. package/dist/internal/funding/TransferReconciliation.js +192 -0
  145. package/dist/internal/funding/TransferReconciliation.js.map +1 -0
  146. package/dist/internal/funding/TransferWebhook.d.ts +21 -0
  147. package/dist/internal/funding/TransferWebhook.d.ts.map +1 -0
  148. package/dist/internal/funding/TransferWebhook.js +43 -0
  149. package/dist/internal/funding/TransferWebhook.js.map +1 -0
  150. package/dist/internal/funding/index.d.ts +1 -0
  151. package/dist/internal/funding/index.d.ts.map +1 -1
  152. package/dist/internal/funding/index.js +1 -0
  153. package/dist/internal/funding/index.js.map +1 -1
  154. package/dist/internal/funding/providers/relay.d.ts.map +1 -1
  155. package/dist/internal/funding/providers/relay.js +3 -12
  156. package/dist/internal/funding/providers/relay.js.map +1 -1
  157. package/dist/internal/funding/providers/stargate.d.ts +99 -0
  158. package/dist/internal/funding/providers/stargate.d.ts.map +1 -1
  159. package/dist/internal/funding/providers/stargate.js +337 -35
  160. package/dist/internal/funding/providers/stargate.js.map +1 -1
  161. package/dist/internal/webhookDestinations/slack.d.ts.map +1 -1
  162. package/dist/internal/webhookDestinations/slack.js +16 -0
  163. package/dist/internal/webhookDestinations/slack.js.map +1 -1
  164. package/package.json +1 -1
  165. package/src/App.ts +1 -0
  166. package/src/Client.test-d.ts +83 -2
  167. package/src/Webhooks.ts +2 -2
  168. package/src/admin/apps/earn-vaults.ts +2 -1
  169. package/src/admin/apps/organizations.test.ts +53 -1
  170. package/src/admin/apps/organizations.ts +90 -5
  171. package/src/admin/ui/src/lib/organizations.test.ts +112 -1
  172. package/src/admin/ui/src/lib/organizations.ts +55 -0
  173. package/src/admin/ui/src/routes/organizations_.$orgId.tsx +116 -0
  174. package/src/admin/ui.generated.ts +69 -69
  175. package/src/apps/Zones.test.ts +3 -3
  176. package/src/apps/Zones.ts +3 -3
  177. package/src/apps/composition.test.ts +1 -0
  178. package/src/apps/data/App.ts +15 -6
  179. package/src/apps/data/routes/earn.test.ts +369 -14
  180. package/src/apps/data/routes/earn.ts +548 -79
  181. package/src/apps/data/routes/exchanges.test.ts +740 -15
  182. package/src/apps/data/routes/exchanges.ts +954 -753
  183. package/src/apps/data/routes/fee-amm.ts +17 -18
  184. package/src/apps/data/routes/webhooks.test.ts +21 -5
  185. package/src/apps/data/routes/webhooks.ts +63 -8
  186. package/src/apps/funding/App.ts +12 -2
  187. package/src/apps/funding/routes/deposit-addresses.test.ts +4 -4
  188. package/src/apps/funding/routes/deposit-addresses.ts +2 -2
  189. package/src/apps/funding/routes/transfers.test.ts +510 -19
  190. package/src/apps/funding/routes/transfers.ts +271 -5
  191. package/src/apps/management/Billing.ts +18 -5
  192. package/src/apps/management/routes/webhooks.test.ts +3 -3
  193. package/src/apps/management/routes/webhooks.ts +24 -25
  194. package/src/apps/relay/App.test.ts +212 -1
  195. package/src/apps/relay/App.ts +158 -29
  196. package/src/apps/relay/Sponsorships.ts +24 -1
  197. package/src/db/Db.ts +35 -0
  198. package/src/db/Schema.ts +17 -2
  199. package/src/db/tables/fundingTransferTransactions.test.ts +43 -0
  200. package/src/db/tables/fundingTransferTransactions.ts +79 -3
  201. package/src/db/tables/fundingTransfers.test.ts +26 -0
  202. package/src/db/tables/fundingTransfers.ts +36 -0
  203. package/src/db/tables/organizations.test.ts +41 -0
  204. package/src/db/tables/organizations.ts +49 -0
  205. package/src/db/tables/projects.test.ts +55 -0
  206. package/src/db/tables/projects.ts +46 -1
  207. package/src/db/tables/sponsoredTransactions.test.ts +338 -0
  208. package/src/db/tables/sponsoredTransactions.ts +197 -13
  209. package/src/db/tables/webhookSubscriptions.ts +18 -11
  210. package/src/internal/EarnVaults.ts +5 -5
  211. package/src/internal/Log.test-d.ts +2 -0
  212. package/src/internal/Log.ts +2 -0
  213. package/src/internal/Viem.ts +39 -3
  214. package/src/internal/Webhooks.test.ts +45 -0
  215. package/src/internal/funding/DepositWebhook.ts +2 -1
  216. package/src/internal/funding/Evidence.ts +2 -13
  217. package/src/internal/funding/Provider.ts +43 -0
  218. package/src/internal/funding/SourceObservation.ts +5 -9
  219. package/src/internal/funding/Transfer.test.ts +155 -0
  220. package/src/internal/funding/Transfer.ts +254 -37
  221. package/src/internal/funding/TransferReconciliation.test.ts +147 -0
  222. package/src/internal/funding/TransferReconciliation.ts +264 -0
  223. package/src/internal/funding/TransferWebhook.test.ts +128 -0
  224. package/src/internal/funding/TransferWebhook.ts +67 -0
  225. package/src/internal/funding/index.test.ts +1 -0
  226. package/src/internal/funding/index.ts +1 -0
  227. package/src/internal/funding/providers/relay.ts +3 -15
  228. package/src/internal/funding/providers/stargate.test.ts +169 -4
  229. package/src/internal/funding/providers/stargate.ts +498 -51
  230. package/src/internal/webhookDestinations/slack.ts +15 -0
@@ -4,27 +4,24 @@ import { base } from 'viem/chains'
4
4
  import { tempoMainnet } from 'viem/tempo/chains'
5
5
 
6
6
  import * as Anvil from '../../../../test/Anvil.js'
7
+ import * as TestApp from '../../../../test/App.js'
8
+ import * as TestFunding from '../../../../test/Funding.js'
7
9
  import * as FundingIdempotencyTable from '../../../db/tables/fundingIdempotency.js'
8
-
9
10
  import * as Cache from '../../../internal/Cache.js'
10
-
11
+ import * as FundingCatalog from '../../../internal/funding/Catalog.js'
12
+ import * as FundingChain from '../../../internal/funding/Chain.js'
11
13
  import * as FundingProvider from '../../../internal/funding/Provider.js'
12
-
13
14
  import * as FundingTransfer from '../../../internal/funding/Transfer.js'
14
-
15
+ import * as FundingTransferReconciliation from '../../../internal/funding/TransferReconciliation.js'
15
16
  import * as Idempotency from '../../../internal/Idempotency.js'
16
-
17
17
  import * as Relay from '../../../internal/funding/providers/relay.js'
18
-
19
18
  import * as Stargate from '../../../internal/funding/providers/stargate.js'
20
-
21
- import * as TestApp from '../../../../test/App.js'
22
-
23
- import * as TestFunding from '../../../../test/Funding.js'
24
-
19
+ import * as Webhooks from '../../../internal/Webhooks.js'
25
20
  import * as Funding from './transfers.js'
26
21
 
27
22
  const db = TestApp.database()
23
+ const destinationTransactionHash =
24
+ '0x3dfaba0a78b8f4bc09e55e05211c8120729e7b6879efe32613883befbca666da'
28
25
 
29
26
  /** Providers exercised against executable calls on a forked source chain. */
30
27
  const executableProviders = [
@@ -40,6 +37,9 @@ const executableProviders = [
40
37
  id: 'stargate',
41
38
  },
42
39
  ] as const
40
+ const stargateConfiguration = TestFunding.snapshot.routesByProvider
41
+ .get('stargate')!
42
+ .find((candidate) => candidate.route.source.chain.id === 'eip155:8453')!.configuration!
43
43
 
44
44
  beforeAll(() => TestFunding.publish(db))
45
45
 
@@ -224,6 +224,54 @@ describe('GET /funding/transfers/:id', () => {
224
224
  })
225
225
  })
226
226
 
227
+ describe('POST /funding/transfers/:id/source-transactions', () => {
228
+ test('requires funding write access and validates the transaction hash', async () => {
229
+ const app = transfersApp()
230
+ const transfer = await seedTransfer()
231
+ const path = `/v1/funding/transfers/${transfer.id}/source-transactions`
232
+ expect((await app.request(path, { method: 'POST' })).status).toBe(401)
233
+ expect((await app.request(path, { ...as(fundingReader), method: 'POST' })).status).toBe(403)
234
+ expect(
235
+ (
236
+ await app.request(path, {
237
+ body: JSON.stringify({ transactionHash: '0xinvalid' }),
238
+ headers: { ...as(fundingWriter).headers, 'content-type': 'application/json' },
239
+ method: 'POST',
240
+ })
241
+ ).status,
242
+ ).toBe(400)
243
+ })
244
+
245
+ test('reports when transfer reconciliation is not configured', async () => {
246
+ const app = transfersApp()
247
+ const transfer = await seedTransfer()
248
+ const response = await app.request(`/v1/funding/transfers/${transfer.id}/source-transactions`, {
249
+ body: JSON.stringify({ transactionHash: `0x${'aa'.repeat(32)}` }),
250
+ headers: { ...as(fundingWriter).headers, 'content-type': 'application/json' },
251
+ method: 'POST',
252
+ })
253
+ expect(response.status).toBe(501)
254
+ })
255
+
256
+ test('does not expose a transfer owned by another organization', async () => {
257
+ const tracker = FundingTransferReconciliation.createTracker({ db })
258
+ const app = transfersApp({
259
+ funding: {
260
+ async dispatchTransferReconciliation(message) {
261
+ await tracker.reconcile(message)
262
+ },
263
+ },
264
+ })
265
+ const transfer = await seedTransfer()
266
+ const response = await app.request(`/v1/funding/transfers/${transfer.id}/source-transactions`, {
267
+ body: JSON.stringify({ transactionHash: `0x${'aa'.repeat(32)}` }),
268
+ headers: { ...as(fundingForeign).headers, 'content-type': 'application/json' },
269
+ method: 'POST',
270
+ })
271
+ expect(response.status).toBe(404)
272
+ })
273
+ })
274
+
227
275
  const createBody = {
228
276
  amount: '1000000',
229
277
  destinationToken: '0x20c0000000000000000000000000000000000000',
@@ -390,6 +438,40 @@ describe('POST /funding/transfers', () => {
390
438
  expect(row.providerState).toEqual({ requestId: `0x${'ab'.repeat(32)}` })
391
439
  })
392
440
 
441
+ test('dispatches staged webhook updates after creation commits', async () => {
442
+ const subscription = await Webhooks.createSubscription(db, {
443
+ chainId: tempoMainnet.id,
444
+ destination: { type: 'url', url: 'https://example.com/webhooks/funding-transfers' },
445
+ environment: 'production',
446
+ eventType: 'funding:transfer.updated',
447
+ filters: { status: 'awaiting-source' },
448
+ owner: { orgId: fundingWriter.orgId, type: 'api_key' },
449
+ })
450
+ const references: Webhooks.QueueReference[] = []
451
+ const app = transfersApp({
452
+ funding: {
453
+ async dispatchTransferUpdates(staged) {
454
+ const reference = staged[0]!
455
+ const queued = await db.kysely
456
+ .selectFrom('webhook_queue_events')
457
+ .select('eventId')
458
+ .where('eventId', '=', reference.eventId)
459
+ .where('subscriptionId', '=', reference.subscriptionId)
460
+ .executeTakeFirst()
461
+ expect(queued).toBeDefined()
462
+ references.push(...staged)
463
+ return staged.length
464
+ },
465
+ },
466
+ providers: [provider()],
467
+ })
468
+
469
+ const response = await postTransfer(app, { idempotencyKey: nextIdempotencyKey() })
470
+
471
+ expect(response.status).toBe(200)
472
+ expect(references).toEqual([{ eventId: expect.any(String), subscriptionId: subscription.id }])
473
+ })
474
+
393
475
  test('creates a transfer with the requested provider', async () => {
394
476
  const app = transfersApp({ providers: [provider()] })
395
477
  const response = await postTransfer(app, {
@@ -728,7 +810,7 @@ describe.skipIf(!Anvil.available)('POST /funding/transfers (forked localnet)', (
728
810
  expect(transfer.action.type).toBe('evm:calls')
729
811
  if (transfer.action.type !== 'evm:calls') return
730
812
  expect(transfer.action.calls.length).toBeGreaterThan(0)
731
- if (executable.id === 'stargate')
813
+ if (executable.id === 'stargate') {
732
814
  expect(transfer.fees).toMatchObject([
733
815
  {
734
816
  amount: { baseUnits: BigInt(transfer.action.calls.at(-1)!.value).toString() },
@@ -736,6 +818,16 @@ describe.skipIf(!Anvil.available)('POST /funding/transfers (forked localnet)', (
736
818
  token: { standard: 'native', symbol: 'ETH' },
737
819
  },
738
820
  ])
821
+ const row = await db.kysely
822
+ .selectFrom('funding_transfers')
823
+ .select('providerState')
824
+ .where('id', '=', transfer.id)
825
+ .executeTakeFirstOrThrow()
826
+ expect(row.providerState).toEqual({
827
+ destinationBlockNumber: expect.stringMatching(/^\d+$/),
828
+ routeConfiguration: stargateConfiguration,
829
+ })
830
+ }
739
831
 
740
832
  const receipts = []
741
833
  for (const call of transfer.action.calls) {
@@ -754,8 +846,158 @@ describe.skipIf(!Anvil.available)('POST /funding/transfers (forked localnet)', (
754
846
  )
755
847
  // The deposit is the last call and must emit; an approval alone proves nothing.
756
848
  expect(receipts.at(-1)!.logs.length).toBeGreaterThan(0)
849
+ if (executable.id === 'stargate') {
850
+ const receipt = receipts.at(-1)!
851
+ const finalized = await client.getBlock({ blockTag: 'finalized' })
852
+ expect(receipt.blockNumber).toBeGreaterThan(finalized.number)
853
+ await expect(
854
+ Stargate.verifySourceTransaction({
855
+ configuration: stargateConfiguration,
856
+ destinationTokenAddress: transfer.destinationToken.address,
857
+ recipient: transfer.recipient,
858
+ sender: sender.address,
859
+ sourceAmount: transfer.sourceAmount.baseUnits,
860
+ sourceChain: FundingChain.from({
861
+ id: 'eip155:8453',
862
+ name: 'Base',
863
+ rpcUrls: [anvil.rpcUrl],
864
+ slug: 'base',
865
+ }),
866
+ sourceTokenAddress: transfer.sourceToken.address,
867
+ transactionHash: receipt.transactionHash,
868
+ validAfter: transfer.quote.sampledAt,
869
+ }),
870
+ ).resolves.toMatchObject({ type: 'verified' })
871
+ }
757
872
  })
758
873
 
874
+ if (executable.id === 'stargate')
875
+ test('reopens registration when the source transaction is reorged out', async () => {
876
+ await Anvil.setErc20Balance(anvil.client, {
877
+ address: sender.address,
878
+ amount,
879
+ slot: fiatTokenBalanceSlot,
880
+ token: usdc.address as `0x${string}`,
881
+ })
882
+ const response = await createTransfer(`bridge_reorg_${sender.address}`)
883
+ expect(response.status).toBe(200)
884
+ const transfer = await TestApp.json(
885
+ response,
886
+ Funding.schema.createFundingTransfer.Response,
887
+ )
888
+ expect(transfer.action.type).toBe('evm:calls')
889
+ if (transfer.action.type !== 'evm:calls') return
890
+
891
+ const checkpoint = await anvil.client.snapshot()
892
+ // Anvil increments fork timestamps instead of following wall time between tests.
893
+ const block = await anvil.client.getBlock()
894
+ const sampledAt = BigInt(Math.floor(Date.parse(transfer.quote.sampledAt) / 1_000))
895
+ if (block.timestamp < sampledAt)
896
+ await anvil.client.setNextBlockTimestamp({ timestamp: sampledAt })
897
+ let reverted = false
898
+ try {
899
+ const receipts = []
900
+ for (const call of transfer.action.calls)
901
+ receipts.push(
902
+ await anvil.client.sendTransactionSync({
903
+ account: sender,
904
+ data: call.data as `0x${string}`,
905
+ to: call.to as `0x${string}`,
906
+ value: BigInt(call.value),
907
+ }),
908
+ )
909
+ const sourceReceipt = receipts.at(-1)!
910
+ const transactionHash = sourceReceipt.transactionHash
911
+ await FundingCatalog.publish(db, {
912
+ ...TestFunding.data,
913
+ chains: TestFunding.data.chains.map((chain) =>
914
+ chain.id === 'eip155:8453' ? { ...chain, rpcUrls: [anvil.rpcUrl] } : chain,
915
+ ),
916
+ })
917
+ const messages: FundingTransferReconciliation.Message[] = []
918
+ const app = transfersApp({
919
+ funding: {
920
+ async dispatchTransferReconciliation(message) {
921
+ messages.push(message)
922
+ },
923
+ },
924
+ providers: [Stargate.stargate()],
925
+ })
926
+ const registered = await app.request(
927
+ `/v1/funding/transfers/${transfer.id}/source-transactions`,
928
+ {
929
+ body: JSON.stringify({ transactionHash }),
930
+ headers: { ...as(fundingWriter).headers, 'content-type': 'application/json' },
931
+ method: 'POST',
932
+ },
933
+ )
934
+ expect(registered.status).toBe(200)
935
+ expect(messages).toEqual([
936
+ { transferId: transfer.id, type: 'funding:transfer:reconcile' },
937
+ ])
938
+
939
+ await anvil.client.revert({ id: checkpoint })
940
+ reverted = true
941
+ const tracker = FundingTransferReconciliation.createTracker({
942
+ db,
943
+ async dispatch(message) {
944
+ messages.push(message)
945
+ },
946
+ })
947
+ await expect(tracker.reconcile(messages[0]!)).resolves.toEqual({ type: 'pending' })
948
+ expect(messages[1]).toEqual({
949
+ attempt: 1,
950
+ transferId: transfer.id,
951
+ type: 'funding:transfer:reconcile',
952
+ })
953
+
954
+ const pending = await db.kysely
955
+ .selectFrom('funding_transfers')
956
+ .select(['snapshot', 'status', 'version'])
957
+ .where('id', '=', transfer.id)
958
+ .executeTakeFirstOrThrow()
959
+ expect({
960
+ sourceTransactionHashes: pending.snapshot.sourceTransactionHashes,
961
+ status: pending.status,
962
+ version: pending.version,
963
+ }).toEqual({
964
+ sourceTransactionHashes: [transactionHash],
965
+ status: 'processing',
966
+ version: 2,
967
+ })
968
+
969
+ await anvil.client.mine({ blocks: 128 })
970
+ const finalized = await anvil.client.getBlock({ blockTag: 'finalized' })
971
+ expect(finalized.number).toBeGreaterThanOrEqual(sourceReceipt.blockNumber)
972
+ await expect(tracker.reconcile(messages[1]!)).resolves.toEqual({ type: 'reopened' })
973
+
974
+ const record = await db.kysely
975
+ .selectFrom('funding_transfers')
976
+ .select(['snapshot', 'status', 'version'])
977
+ .where('id', '=', transfer.id)
978
+ .executeTakeFirstOrThrow()
979
+ expect({
980
+ sourceTransactionHashes: record.snapshot.sourceTransactionHashes,
981
+ status: record.status,
982
+ version: record.version,
983
+ }).toEqual({
984
+ sourceTransactionHashes: undefined,
985
+ status: 'awaiting-source',
986
+ version: 3,
987
+ })
988
+ expect(
989
+ await db.kysely
990
+ .selectFrom('funding_transfer_transactions')
991
+ .selectAll()
992
+ .where('transferId', '=', transfer.id)
993
+ .execute(),
994
+ ).toEqual([])
995
+ } finally {
996
+ if (!reverted) await anvil.client.revert({ id: checkpoint })
997
+ await TestFunding.publish(db)
998
+ }
999
+ })
1000
+
759
1001
  test('bounds the approval to the quoted source spend', async () => {
760
1002
  const response = await createTransfer(`bridge_bounds_${executable.id}_${sender.address}`)
761
1003
  expect(response.status).toBe(200)
@@ -781,6 +1023,195 @@ describe.skipIf(!Anvil.available)('POST /funding/transfers (forked localnet)', (
781
1023
  }
782
1024
  })
783
1025
 
1026
+ describe.skipIf(!Anvil.available)(
1027
+ 'POST /funding/transfers/:id/source-transactions (forked localnet)',
1028
+ () => {
1029
+ const trackingDb = TestApp.database()
1030
+ let destination: Anvil.start.ReturnType
1031
+ let source: Anvil.start.ReturnType
1032
+
1033
+ beforeAll(async () => {
1034
+ ;[destination, source] = await Promise.all([
1035
+ Anvil.start({ chain: tempoMainnet, forkUrl: tempoMainnet.rpcUrls.default.http[0]! }),
1036
+ Anvil.start({ chain: base, forkUrl: base.rpcUrls.default.http[0]! }),
1037
+ ])
1038
+ await FundingCatalog.publish(trackingDb, {
1039
+ ...TestFunding.data,
1040
+ chains: TestFunding.data.chains.map((chain) => {
1041
+ if (chain.id === 'eip155:4217') return { ...chain, rpcUrls: [destination.rpcUrl] }
1042
+ if (chain.id === 'eip155:8453') return { ...chain, rpcUrls: [source.rpcUrl] }
1043
+ return chain
1044
+ }),
1045
+ })
1046
+ })
1047
+
1048
+ afterAll(async () => {
1049
+ await Promise.all([destination?.stop(), source?.stop()])
1050
+ })
1051
+
1052
+ test('backfills legacy state and completes from destination evidence', async () => {
1053
+ const destinationReceipt = await destination.client.getTransactionReceipt({
1054
+ hash: destinationTransactionHash,
1055
+ })
1056
+ const sourceTransactionHash =
1057
+ '0xfd71359adf5095f2ab512a19e0a2df6c8cc0db14ac8a52286d61287daabdccd4'
1058
+ const sourceReceipt = await source.client.getTransactionReceipt({
1059
+ hash: sourceTransactionHash,
1060
+ })
1061
+ const sourceBlock = await source.client.getBlock({ blockNumber: sourceReceipt.blockNumber })
1062
+ const sampledAt = new Date(Number(sourceBlock.timestamp - 1n) * 1_000).toISOString()
1063
+ const amount = {
1064
+ baseUnits: '200000',
1065
+ currency: 'USD',
1066
+ decimals: 6,
1067
+ formatted: '0.2',
1068
+ } as const
1069
+ const snapshot = {
1070
+ ...TestFunding.transferSnapshot({
1071
+ destinationAmount: amount,
1072
+ destinationAmountMin: amount,
1073
+ provider: { id: 'stargate', name: 'Stargate' },
1074
+ recipient: '0x7212623e4cff9ad010d28aa065fad2bf58578783',
1075
+ sender: '0x52b650a62e384f68880d0a1dc2b84dfed47d5660',
1076
+ sourceAmount: amount,
1077
+ }),
1078
+ quote: {
1079
+ expiresAt: new Date(Date.parse(sampledAt) + 60_000).toISOString(),
1080
+ sampledAt,
1081
+ },
1082
+ }
1083
+ const transfer = await FundingTransfer.create(trackingDb, {
1084
+ apiKeyId: fundingWriter.id,
1085
+ environment: 'production',
1086
+ orgId: fundingWriter.orgId,
1087
+ snapshot,
1088
+ })
1089
+ const tracker = FundingTransferReconciliation.createTracker({ db: trackingDb })
1090
+ const messages: FundingTransferReconciliation.Message[] = []
1091
+ let dispatchEnabled = true
1092
+ const app = TestApp.create({
1093
+ auth: { keys: [fundingWriter] },
1094
+ db: trackingDb,
1095
+ defaultChainId: tempoMainnet.id,
1096
+ funding: {
1097
+ async dispatchTransferReconciliation(message) {
1098
+ if (!dispatchEnabled) throw new Error('reconciliation unavailable')
1099
+ messages.push(message)
1100
+ },
1101
+ },
1102
+ providers: [Stargate.stargate()],
1103
+ })
1104
+ const path = `/v1/funding/transfers/${transfer.id}/source-transactions`
1105
+ const registration = {
1106
+ body: JSON.stringify({
1107
+ transactionHash: sourceTransactionHash,
1108
+ }),
1109
+ headers: { ...as(fundingWriter).headers, 'content-type': 'application/json' },
1110
+ method: 'POST',
1111
+ }
1112
+ const response = await app.request(path, registration)
1113
+ expect(response.status).toBe(200)
1114
+ const registered = await TestApp.json(
1115
+ response,
1116
+ Funding.schema.registerFundingTransferSourceTransaction.Response,
1117
+ )
1118
+ expect({
1119
+ sourceTransactionHashes: registered.sourceTransactionHashes,
1120
+ status: registered.status,
1121
+ version: registered.version,
1122
+ }).toEqual({
1123
+ sourceTransactionHashes: [
1124
+ '0xfd71359adf5095f2ab512a19e0a2df6c8cc0db14ac8a52286d61287daabdccd4',
1125
+ ],
1126
+ status: 'processing',
1127
+ version: 2,
1128
+ })
1129
+ const stored = await trackingDb.kysely
1130
+ .selectFrom('funding_transfers')
1131
+ .select('providerState')
1132
+ .where('id', '=', transfer.id)
1133
+ .executeTakeFirstOrThrow()
1134
+ expect(stored).toEqual({
1135
+ providerState: {
1136
+ destinationBlockNumber: expect.stringMatching(/^\d+$/),
1137
+ routeConfiguration: stargateConfiguration,
1138
+ stargate: {
1139
+ guid: '0x5549d117c8b23f0ecdf05c2604ed989cf0034f378707bd81319e93834f2f85f2',
1140
+ sourceBlock: {
1141
+ hash: sourceReceipt.blockHash,
1142
+ number: sourceReceipt.blockNumber.toString(),
1143
+ },
1144
+ sourceEid: 30_184,
1145
+ },
1146
+ },
1147
+ })
1148
+ const destinationBlockNumber = stored.providerState?.['destinationBlockNumber']
1149
+ if (typeof destinationBlockNumber !== 'string')
1150
+ throw new Error('Expected a backfilled destination block number.')
1151
+ expect(BigInt(destinationBlockNumber)).toBeLessThanOrEqual(destinationReceipt.blockNumber)
1152
+ expect(messages).toEqual([{ transferId: transfer.id, type: 'funding:transfer:reconcile' }])
1153
+ await tracker.reconcile(messages[0]!)
1154
+
1155
+ const completed = await TestApp.json(
1156
+ await app.request(`/v1/funding/transfers/${transfer.id}`, as(fundingWriter)),
1157
+ Funding.schema.getFundingTransfer.Response,
1158
+ )
1159
+ expect({
1160
+ destinationTransactionHashes: completed.destinationTransactionHashes,
1161
+ status: completed.status,
1162
+ version: completed.version,
1163
+ }).toEqual({
1164
+ destinationTransactionHashes: [destinationTransactionHash],
1165
+ status: 'completed',
1166
+ version: 3,
1167
+ })
1168
+
1169
+ dispatchEnabled = false
1170
+ const replayed = await TestApp.json(
1171
+ await app.request(path, registration),
1172
+ Funding.schema.registerFundingTransferSourceTransaction.Response,
1173
+ )
1174
+ expect({ status: replayed.status, version: replayed.version }).toEqual({
1175
+ status: 'completed',
1176
+ version: 3,
1177
+ })
1178
+
1179
+ const other = await FundingTransfer.create(trackingDb, {
1180
+ apiKeyId: fundingWriter.id,
1181
+ environment: 'production',
1182
+ orgId: fundingWriter.orgId,
1183
+ snapshot,
1184
+ })
1185
+ const conflict = await app.request(
1186
+ `/v1/funding/transfers/${other.id}/source-transactions`,
1187
+ registration,
1188
+ )
1189
+ expect(conflict.status).toBe(409)
1190
+ expect(((await conflict.json()) as { error: { code: string } }).error.code).toBe(
1191
+ 'source_transaction_conflict',
1192
+ )
1193
+
1194
+ const pending = await FundingTransfer.create(trackingDb, {
1195
+ apiKeyId: fundingWriter.id,
1196
+ environment: 'production',
1197
+ orgId: fundingWriter.orgId,
1198
+ snapshot,
1199
+ })
1200
+ const pendingResponse = await app.request(
1201
+ `/v1/funding/transfers/${pending.id}/source-transactions`,
1202
+ {
1203
+ ...registration,
1204
+ body: JSON.stringify({ transactionHash: `0x${'99'.repeat(32)}` }),
1205
+ },
1206
+ )
1207
+ expect(pendingResponse.status).toBe(409)
1208
+ expect(((await pendingResponse.json()) as { error: { code: string } }).error.code).toBe(
1209
+ 'source_transaction_pending',
1210
+ )
1211
+ })
1212
+ },
1213
+ )
1214
+
784
1215
  describe('POST /funding/transfers/{destination}', () => {
785
1216
  test.each([
786
1217
  { body: vaultTransferBody, destination: 'vault' },
@@ -847,8 +1278,11 @@ describe('POST /funding/transfers/{destination}', () => {
847
1278
  describe('funding transfers OpenAPI', () => {
848
1279
  test('publishes generator-ready transfer contracts', async () => {
849
1280
  const spec = await (await create({ auth: false }).request('/openapi.json')).json()
850
- const detailPath = Object.keys(spec.paths).find((path) =>
851
- path.includes('/v1/funding/transfers/{'),
1281
+ const detailPath = Object.keys(spec.paths).find(
1282
+ (path) => path.includes('/v1/funding/transfers/{') && !path.endsWith('/source-transactions'),
1283
+ )!
1284
+ const sourceTransactionPath = Object.keys(spec.paths).find((path) =>
1285
+ path.endsWith('/source-transactions'),
852
1286
  )!
853
1287
  const operations = {
854
1288
  create: spec.paths['/v1/funding/transfers'].post,
@@ -856,6 +1290,7 @@ describe('funding transfers OpenAPI', () => {
856
1290
  createZone: spec.paths['/v1/funding/transfers/zone'].post,
857
1291
  get: spec.paths[detailPath].get,
858
1292
  list: spec.paths['/v1/funding/transfers'].get,
1293
+ registerSourceTransaction: spec.paths[sourceTransactionPath].post,
859
1294
  }
860
1295
  const summarize = (operation: (typeof operations)[keyof typeof operations]) => ({
861
1296
  errors: Object.fromEntries(
@@ -885,6 +1320,7 @@ describe('funding transfers OpenAPI', () => {
885
1320
  'FundingTransferList',
886
1321
  'FundingTransferQuote',
887
1322
  'FundingTransferStatusReason',
1323
+ 'RegisterFundingTransferSourceTransactionRequest',
888
1324
  ].filter((name) => spec.components.schemas[name]),
889
1325
  formats: {
890
1326
  createdAt: spec.components.schemas.FundingTransfer.properties.createdAt.format,
@@ -907,6 +1343,7 @@ describe('funding transfers OpenAPI', () => {
907
1343
  createZone: summarize(operations.createZone),
908
1344
  get: summarize(operations.get),
909
1345
  list: summarize(operations.list),
1346
+ registerSourceTransaction: summarize(operations.registerSourceTransaction),
910
1347
  },
911
1348
  operationErrorCodes: {
912
1349
  createInvalid:
@@ -944,6 +1381,7 @@ describe('funding transfers OpenAPI', () => {
944
1381
  "FundingTransferList",
945
1382
  "FundingTransferQuote",
946
1383
  "FundingTransferStatusReason",
1384
+ "RegisterFundingTransferSourceTransactionRequest",
947
1385
  ],
948
1386
  "formats": {
949
1387
  "createdAt": "date-time",
@@ -1136,6 +1574,41 @@ describe('funding transfers OpenAPI', () => {
1136
1574
  "$ref": "#/components/schemas/FundingTransferList",
1137
1575
  },
1138
1576
  },
1577
+ "registerSourceTransaction": {
1578
+ "errors": {
1579
+ "400": {
1580
+ "$ref": "#/components/schemas/ApiKeyMalformedOrBodyInvalidOrParamInvalidOrSourceTransactionInvalidError",
1581
+ },
1582
+ "401": {
1583
+ "$ref": "#/components/schemas/AuthenticationError",
1584
+ },
1585
+ "403": {
1586
+ "$ref": "#/components/schemas/ForbiddenError",
1587
+ },
1588
+ "404": {
1589
+ "$ref": "#/components/schemas/FundingTransferNotFoundError",
1590
+ },
1591
+ "409": {
1592
+ "$ref": "#/components/schemas/FundingTransferNotAwaitingSourceOrSourceTransactionConflictOrSourceTransactionPendingError",
1593
+ },
1594
+ "429": "#/components/responses/RateLimited",
1595
+ "500": "#/components/responses/InternalError",
1596
+ "501": {
1597
+ "$ref": "#/components/schemas/NotImplemented501Error",
1598
+ },
1599
+ "502": {
1600
+ "$ref": "#/components/schemas/UpstreamError",
1601
+ },
1602
+ "504": "#/components/responses/RequestTimeout",
1603
+ },
1604
+ "operationId": "registerFundingTransferSourceTransaction",
1605
+ "request": {
1606
+ "$ref": "#/components/schemas/RegisterFundingTransferSourceTransactionRequest",
1607
+ },
1608
+ "response": {
1609
+ "$ref": "#/components/schemas/FundingTransfer",
1610
+ },
1611
+ },
1139
1612
  },
1140
1613
  }
1141
1614
  `)
@@ -1209,8 +1682,11 @@ describe('funding transfers OpenAPI', () => {
1209
1682
  'x-tagGroups': { name: string; tags: string[] }[]
1210
1683
  }
1211
1684
  const spec = (await (await app.request('/openapi.json')).json()) as Spec
1212
- const detailPath = Object.keys(spec.paths).find((path) =>
1213
- path.includes('/v1/funding/transfers/{'),
1685
+ const detailPath = Object.keys(spec.paths).find(
1686
+ (path) => path.includes('/v1/funding/transfers/{') && !path.endsWith('/source-transactions'),
1687
+ )!
1688
+ const sourceTransactionPath = Object.keys(spec.paths).find((path) =>
1689
+ path.endsWith('/source-transactions'),
1214
1690
  )!
1215
1691
  const depositAddressPath = Object.keys(spec.paths).find(
1216
1692
  (path) =>
@@ -1239,6 +1715,7 @@ describe('funding transfers OpenAPI', () => {
1239
1715
  list: summarize(spec.paths['/v1/funding/transfers']!.get!),
1240
1716
  providers: summarize(spec.paths['/v1/funding/providers']!.get!),
1241
1717
  quotes: summarize(spec.paths['/v1/funding/quotes']!.get!),
1718
+ sourceTransaction: summarize(spec.paths[sourceTransactionPath]!.post!),
1242
1719
  vault: summarize(spec.paths['/v1/funding/transfers/vault']!.post!),
1243
1720
  zone: summarize(spec.paths['/v1/funding/transfers/zone']!.post!),
1244
1721
  }
@@ -1309,6 +1786,10 @@ describe('funding transfers OpenAPI', () => {
1309
1786
  "description": "Returns live quotes for transferring stablecoins to Tempo.",
1310
1787
  "summary": "Get quotes",
1311
1788
  },
1789
+ "sourceTransaction": {
1790
+ "description": "Registers and verifies a funding source transaction.",
1791
+ "summary": "Register source transaction",
1792
+ },
1312
1793
  "vault": {
1313
1794
  "description": "Creates a transfer into a Tempo Earn vault.",
1314
1795
  "summary": "Create transfer into vault",
@@ -1324,6 +1805,7 @@ describe('funding transfers OpenAPI', () => {
1324
1805
  "Get providers",
1325
1806
  "List deposit addresses",
1326
1807
  "Create deposit address",
1808
+ "Register source transaction",
1327
1809
  "List transfers",
1328
1810
  "Create transfer",
1329
1811
  "Create transfer into vault",
@@ -1507,8 +1989,11 @@ describe('funding transfers OpenAPI', () => {
1507
1989
  }
1508
1990
  >
1509
1991
  }
1510
- const detailPath = Object.keys(spec.paths).find((path) =>
1511
- path.includes('/v1/funding/transfers/{'),
1992
+ const detailPath = Object.keys(spec.paths).find(
1993
+ (path) => path.includes('/v1/funding/transfers/{') && !path.endsWith('/source-transactions'),
1994
+ )
1995
+ const sourceTransactionPath = Object.keys(spec.paths).find((path) =>
1996
+ path.endsWith('/source-transactions'),
1512
1997
  )
1513
1998
  const depositAddressPath = Object.keys(spec.paths).find(
1514
1999
  (path) =>
@@ -1544,6 +2029,10 @@ describe('funding transfers OpenAPI', () => {
1544
2029
  requiredCreateHeader: spec.paths['/v1/funding/transfers']?.post?.parameters.find(
1545
2030
  (parameter) => parameter.name === 'idempotency-key',
1546
2031
  ),
2032
+ sourceTransaction:
2033
+ sourceTransactionPath && spec.paths[sourceTransactionPath]?.post?.operationId,
2034
+ sourceTransactionScopes:
2035
+ sourceTransactionPath && spec.paths[sourceTransactionPath]?.post?.['x-required-scopes'],
1547
2036
  vault: spec.paths['/v1/funding/transfers/vault']?.post?.operationId,
1548
2037
  vaultScopes: spec.paths['/v1/funding/transfers/vault']?.post?.['x-required-scopes'],
1549
2038
  zone: spec.paths['/v1/funding/transfers/zone']?.post?.operationId,
@@ -1572,6 +2061,8 @@ describe('funding transfers OpenAPI', () => {
1572
2061
  required: true,
1573
2062
  schema: expect.any(Object),
1574
2063
  }),
2064
+ sourceTransaction: 'registerFundingTransferSourceTransaction',
2065
+ sourceTransactionScopes: ['funding:write'],
1575
2066
  vault: 'createFundingTransferVault',
1576
2067
  vaultScopes: ['funding:write'],
1577
2068
  zone: 'createFundingTransferZone',
@@ -1598,7 +2089,7 @@ const fundingProjectReader = {
1598
2089
  const fundingForeign = {
1599
2090
  id: 'key_funding_other',
1600
2091
  orgId: 'org_funding_other',
1601
- scopes: ['funding:read'],
2092
+ scopes: ['funding:read', 'funding:write'],
1602
2093
  token: 'secret_funding_other',
1603
2094
  } satisfies TestApp.kvStore.Key
1604
2095