tapimo 0.7.2 → 0.9.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 (300) hide show
  1. package/README.md +10 -7
  2. package/dist/ApiKey.d.ts +3 -0
  3. package/dist/ApiKey.d.ts.map +1 -1
  4. package/dist/ApiKey.js +10 -0
  5. package/dist/ApiKey.js.map +1 -1
  6. package/dist/ApiKeys.d.ts +7 -3
  7. package/dist/ApiKeys.d.ts.map +1 -1
  8. package/dist/ApiKeys.js +9 -2
  9. package/dist/ApiKeys.js.map +1 -1
  10. package/dist/App.d.ts +20117 -19183
  11. package/dist/App.d.ts.map +1 -1
  12. package/dist/App.js +61 -4
  13. package/dist/App.js.map +1 -1
  14. package/dist/admin/App.d.ts +84 -74
  15. package/dist/admin/App.d.ts.map +1 -1
  16. package/dist/admin/apps/api-keys.d.ts +14 -1
  17. package/dist/admin/apps/api-keys.d.ts.map +1 -1
  18. package/dist/admin/apps/api-keys.js +6 -2
  19. package/dist/admin/apps/api-keys.js.map +1 -1
  20. package/dist/admin/apps/organizations.d.ts +2 -0
  21. package/dist/admin/apps/organizations.d.ts.map +1 -1
  22. package/dist/admin/apps/verified-tokens.d.ts +11 -11
  23. package/dist/admin/ui.generated.d.ts +1 -1
  24. package/dist/admin/ui.generated.js +67 -67
  25. package/dist/admin/ui.generated.js.map +1 -1
  26. package/dist/analytics/Analytics.d.ts +2 -0
  27. package/dist/analytics/Analytics.d.ts.map +1 -1
  28. package/dist/analytics/Analytics.js +3 -0
  29. package/dist/analytics/Analytics.js.map +1 -1
  30. package/dist/apps/data/App.d.ts +11696 -13937
  31. package/dist/apps/data/App.d.ts.map +1 -1
  32. package/dist/apps/data/App.js +4 -8
  33. package/dist/apps/data/App.js.map +1 -1
  34. package/dist/apps/data/routes/activities.d.ts +271 -271
  35. package/dist/apps/data/routes/addresses.d.ts +398 -167
  36. package/dist/apps/data/routes/addresses.d.ts.map +1 -1
  37. package/dist/apps/data/routes/balances.d.ts +278 -8
  38. package/dist/apps/data/routes/balances.d.ts.map +1 -1
  39. package/dist/apps/data/routes/balances.js +114 -2
  40. package/dist/apps/data/routes/balances.js.map +1 -1
  41. package/dist/apps/data/routes/blocks.d.ts +123 -123
  42. package/dist/apps/data/routes/coingecko.d.ts +170 -170
  43. package/dist/apps/data/routes/earn.d.ts +498 -6
  44. package/dist/apps/data/routes/earn.d.ts.map +1 -1
  45. package/dist/apps/data/routes/earn.js +876 -4
  46. package/dist/apps/data/routes/earn.js.map +1 -1
  47. package/dist/apps/data/routes/exchanges.d.ts +10 -10
  48. package/dist/apps/data/routes/fee-amm.d.ts +2 -2
  49. package/dist/apps/data/routes/indexer.d.ts +6 -6
  50. package/dist/apps/data/routes/indexer.js +4 -1
  51. package/dist/apps/data/routes/indexer.js.map +1 -1
  52. package/dist/apps/data/routes/receipts.d.ts +235 -235
  53. package/dist/apps/data/routes/rpc.d.ts +31 -31
  54. package/dist/apps/data/routes/rpc.js +4 -1
  55. package/dist/apps/data/routes/rpc.js.map +1 -1
  56. package/dist/apps/data/routes/tokenlist.d.ts +36 -36
  57. package/dist/apps/data/routes/tokens.d.ts +449 -449
  58. package/dist/apps/data/routes/transactions.d.ts +2 -2
  59. package/dist/apps/data/routes/transfers.d.ts +81 -81
  60. package/dist/apps/data/routes/valuation.d.ts +1 -1
  61. package/dist/apps/data/routes/verified-tokens.d.ts +36 -36
  62. package/dist/apps/data/routes/webhooks.d.ts +9 -9
  63. package/dist/apps/data/routes/zones.d.ts +14 -14
  64. package/dist/apps/funding/Access.d.ts +16 -0
  65. package/dist/apps/funding/Access.d.ts.map +1 -0
  66. package/dist/apps/funding/Access.js +22 -0
  67. package/dist/apps/funding/Access.js.map +1 -0
  68. package/dist/apps/{data/routes/funding.d.ts → funding/App.d.ts} +1640 -3217
  69. package/dist/apps/funding/App.d.ts.map +1 -0
  70. package/dist/apps/funding/App.js +61 -0
  71. package/dist/apps/funding/App.js.map +1 -0
  72. package/dist/apps/funding/Inventory.d.ts +5 -0
  73. package/dist/apps/funding/Inventory.d.ts.map +1 -0
  74. package/dist/apps/funding/Inventory.js +25 -0
  75. package/dist/apps/funding/Inventory.js.map +1 -0
  76. package/dist/apps/funding/ProviderAttempts.d.ts +5 -0
  77. package/dist/apps/funding/ProviderAttempts.d.ts.map +1 -0
  78. package/dist/apps/funding/ProviderAttempts.js +24 -0
  79. package/dist/apps/funding/ProviderAttempts.js.map +1 -0
  80. package/dist/apps/funding/index.d.ts +2 -0
  81. package/dist/apps/funding/index.d.ts.map +1 -0
  82. package/dist/apps/funding/index.js +2 -0
  83. package/dist/apps/funding/index.js.map +1 -0
  84. package/dist/apps/funding/routes/chains.d.ts +169 -0
  85. package/dist/apps/funding/routes/chains.d.ts.map +1 -0
  86. package/dist/apps/funding/routes/chains.js +80 -0
  87. package/dist/apps/funding/routes/chains.js.map +1 -0
  88. package/dist/apps/funding/routes/deposit-addresses.d.ts +1997 -0
  89. package/dist/apps/funding/routes/deposit-addresses.d.ts.map +1 -0
  90. package/dist/apps/funding/routes/deposit-addresses.js +778 -0
  91. package/dist/apps/funding/routes/deposit-addresses.js.map +1 -0
  92. package/dist/apps/funding/routes/providers.d.ts +91 -0
  93. package/dist/apps/funding/routes/providers.d.ts.map +1 -0
  94. package/dist/apps/funding/routes/providers.js +56 -0
  95. package/dist/apps/funding/routes/providers.js.map +1 -0
  96. package/dist/apps/funding/routes/quotes.d.ts +426 -0
  97. package/dist/apps/funding/routes/quotes.d.ts.map +1 -0
  98. package/dist/apps/funding/routes/quotes.js +188 -0
  99. package/dist/apps/funding/routes/quotes.js.map +1 -0
  100. package/dist/apps/funding/routes/transfers.d.ts +1999 -0
  101. package/dist/apps/funding/routes/transfers.d.ts.map +1 -0
  102. package/dist/apps/funding/routes/transfers.js +614 -0
  103. package/dist/apps/funding/routes/transfers.js.map +1 -0
  104. package/dist/apps/index.d.ts +1 -0
  105. package/dist/apps/index.d.ts.map +1 -1
  106. package/dist/apps/index.js +1 -0
  107. package/dist/apps/index.js.map +1 -1
  108. package/dist/apps/management/App.d.ts +430 -165
  109. package/dist/apps/management/App.d.ts.map +1 -1
  110. package/dist/apps/management/routes/api-keys.d.ts +298 -21
  111. package/dist/apps/management/routes/api-keys.d.ts.map +1 -1
  112. package/dist/apps/management/routes/api-keys.js +75 -1
  113. package/dist/apps/management/routes/api-keys.js.map +1 -1
  114. package/dist/apps/management/routes/billing.d.ts +6 -6
  115. package/dist/apps/management/routes/faucet.d.ts +35 -35
  116. package/dist/apps/management/routes/invitations.d.ts +122 -122
  117. package/dist/apps/management/routes/invite-links.d.ts +38 -38
  118. package/dist/apps/management/routes/members.d.ts +69 -69
  119. package/dist/apps/management/routes/orgs.d.ts +99 -99
  120. package/dist/apps/management/routes/projects.d.ts +25 -25
  121. package/dist/apps/management/routes/scopes.d.ts +1 -1
  122. package/dist/apps/management/routes/usage.d.ts +72 -72
  123. package/dist/apps/management/routes/verified-token-requests.d.ts +77 -77
  124. package/dist/apps/management/routes/webhooks.d.ts +128 -128
  125. package/dist/apps/mcp.d.ts +2 -3
  126. package/dist/apps/mcp.d.ts.map +1 -1
  127. package/dist/apps/mcp.js +16 -14
  128. package/dist/apps/mcp.js.map +1 -1
  129. package/dist/apps/metadata.d.ts +4 -0
  130. package/dist/apps/metadata.d.ts.map +1 -1
  131. package/dist/apps/metadata.js +10 -0
  132. package/dist/apps/metadata.js.map +1 -1
  133. package/dist/apps/mpp/App.d.ts +44 -44
  134. package/dist/cloudflare.d.ts +50 -0
  135. package/dist/cloudflare.d.ts.map +1 -1
  136. package/dist/cloudflare.js +178 -0
  137. package/dist/cloudflare.js.map +1 -1
  138. package/dist/db/Db.d.ts +2 -0
  139. package/dist/db/Db.d.ts.map +1 -1
  140. package/dist/db/Db.js +45 -0
  141. package/dist/db/Db.js.map +1 -1
  142. package/dist/db/Schema.d.ts +12 -0
  143. package/dist/db/Schema.d.ts.map +1 -1
  144. package/dist/db/Schema.js +20 -0
  145. package/dist/db/Schema.js.map +1 -1
  146. package/dist/db/tables/fundingDepositAddresses.d.ts +13 -0
  147. package/dist/db/tables/fundingDepositAddresses.d.ts.map +1 -1
  148. package/dist/db/tables/fundingDepositAddresses.js +12 -0
  149. package/dist/db/tables/fundingDepositAddresses.js.map +1 -1
  150. package/dist/db/tables/fundingDepositRequestObservations.d.ts +45 -0
  151. package/dist/db/tables/fundingDepositRequestObservations.d.ts.map +1 -0
  152. package/dist/db/tables/fundingDepositRequestObservations.js +46 -0
  153. package/dist/db/tables/fundingDepositRequestObservations.js.map +1 -0
  154. package/dist/db/tables/fundingDeposits.d.ts +74 -1
  155. package/dist/db/tables/fundingDeposits.d.ts.map +1 -1
  156. package/dist/db/tables/fundingDeposits.js +126 -0
  157. package/dist/db/tables/fundingDeposits.js.map +1 -1
  158. package/dist/internal/Auth.d.ts +15 -11
  159. package/dist/internal/Auth.d.ts.map +1 -1
  160. package/dist/internal/Auth.js +52 -17
  161. package/dist/internal/Auth.js.map +1 -1
  162. package/dist/internal/Cache.js +1 -1
  163. package/dist/internal/Cache.js.map +1 -1
  164. package/dist/internal/EdgeCache.d.ts +2 -2
  165. package/dist/internal/EdgeCache.d.ts.map +1 -1
  166. package/dist/internal/EdgeCache.js +10 -4
  167. package/dist/internal/EdgeCache.js.map +1 -1
  168. package/dist/internal/IpAllowlist.d.ts +15 -0
  169. package/dist/internal/IpAllowlist.d.ts.map +1 -0
  170. package/dist/internal/IpAllowlist.js +27 -0
  171. package/dist/internal/IpAllowlist.js.map +1 -0
  172. package/dist/internal/OpenApi.d.ts +1 -1
  173. package/dist/internal/OpenApi.js +1 -1
  174. package/dist/internal/OpenApi.js.map +1 -1
  175. package/dist/internal/Tidx.d.ts +6 -0
  176. package/dist/internal/Tidx.d.ts.map +1 -1
  177. package/dist/internal/Tidx.js +13 -3
  178. package/dist/internal/Tidx.js.map +1 -1
  179. package/dist/internal/Viem.d.ts +8 -0
  180. package/dist/internal/Viem.d.ts.map +1 -1
  181. package/dist/internal/Viem.js +12 -5
  182. package/dist/internal/Viem.js.map +1 -1
  183. package/dist/internal/funding/Deposit.d.ts +42 -0
  184. package/dist/internal/funding/Deposit.d.ts.map +1 -1
  185. package/dist/internal/funding/Deposit.js +37 -5
  186. package/dist/internal/funding/Deposit.js.map +1 -1
  187. package/dist/internal/funding/Evidence.d.ts +1 -5
  188. package/dist/internal/funding/Evidence.d.ts.map +1 -1
  189. package/dist/internal/funding/Evidence.js +4 -3
  190. package/dist/internal/funding/Evidence.js.map +1 -1
  191. package/dist/internal/funding/Provider.d.ts +102 -0
  192. package/dist/internal/funding/Provider.d.ts.map +1 -1
  193. package/dist/internal/funding/Provider.js +5 -0
  194. package/dist/internal/funding/Provider.js.map +1 -1
  195. package/dist/internal/funding/Reconciliation.d.ts +16 -2
  196. package/dist/internal/funding/Reconciliation.d.ts.map +1 -1
  197. package/dist/internal/funding/Reconciliation.js +348 -54
  198. package/dist/internal/funding/Reconciliation.js.map +1 -1
  199. package/dist/internal/funding/SourceObservation.d.ts +108 -0
  200. package/dist/internal/funding/SourceObservation.d.ts.map +1 -0
  201. package/dist/internal/funding/SourceObservation.js +143 -0
  202. package/dist/internal/funding/SourceObservation.js.map +1 -0
  203. package/dist/internal/funding/Subsidy.d.ts +2 -2
  204. package/dist/internal/funding/Subsidy.d.ts.map +1 -1
  205. package/dist/internal/funding/Subsidy.js +1 -1
  206. package/dist/internal/funding/Subsidy.js.map +1 -1
  207. package/dist/internal/funding/index.d.ts +1 -0
  208. package/dist/internal/funding/index.d.ts.map +1 -1
  209. package/dist/internal/funding/index.js +1 -0
  210. package/dist/internal/funding/index.js.map +1 -1
  211. package/dist/internal/funding/providers/relay.d.ts +43 -1
  212. package/dist/internal/funding/providers/relay.d.ts.map +1 -1
  213. package/dist/internal/funding/providers/relay.js +438 -27
  214. package/dist/internal/funding/providers/relay.js.map +1 -1
  215. package/package.json +3 -3
  216. package/src/ApiKey.test.ts +35 -0
  217. package/src/ApiKey.ts +27 -0
  218. package/src/ApiKeys.test.ts +29 -0
  219. package/src/ApiKeys.ts +11 -3
  220. package/src/App.test.ts +55 -5
  221. package/src/App.ts +79 -5
  222. package/src/Client.test-d.ts +203 -28
  223. package/src/admin/apps/api-keys.test.ts +25 -1
  224. package/src/admin/apps/api-keys.ts +6 -2
  225. package/src/admin/ui.generated.ts +67 -67
  226. package/src/analytics/Analytics.ts +4 -0
  227. package/src/apps/composition.test.ts +130 -3
  228. package/src/apps/data/App.test.ts +0 -12
  229. package/src/apps/data/App.ts +4 -8
  230. package/src/apps/data/routes/balances.test.ts +42 -0
  231. package/src/apps/data/routes/balances.ts +218 -85
  232. package/src/apps/data/routes/earn.test.ts +363 -0
  233. package/src/apps/data/routes/earn.ts +1141 -3
  234. package/src/apps/data/routes/indexer.ts +2 -1
  235. package/src/apps/data/routes/rpc.ts +2 -0
  236. package/src/apps/data/routes/tokens.test.ts +6 -8
  237. package/src/apps/funding/Access.ts +26 -0
  238. package/src/apps/funding/App.test.ts +78 -0
  239. package/src/apps/funding/App.ts +78 -0
  240. package/src/apps/funding/Inventory.ts +30 -0
  241. package/src/apps/funding/ProviderAttempts.ts +26 -0
  242. package/src/apps/funding/index.ts +1 -0
  243. package/src/apps/funding/routes/chains.test.ts +78 -0
  244. package/src/apps/funding/routes/chains.ts +101 -0
  245. package/src/apps/funding/routes/deposit-addresses.test.ts +1649 -0
  246. package/src/apps/funding/routes/deposit-addresses.ts +932 -0
  247. package/src/apps/funding/routes/providers.test.ts +165 -0
  248. package/src/apps/funding/routes/providers.ts +73 -0
  249. package/src/apps/funding/routes/quotes.test.ts +511 -0
  250. package/src/apps/funding/routes/quotes.ts +205 -0
  251. package/src/apps/funding/routes/transfers.test.ts +1236 -0
  252. package/src/apps/funding/routes/transfers.ts +704 -0
  253. package/src/apps/index.ts +1 -0
  254. package/src/apps/management/routes/api-keys.test.ts +98 -0
  255. package/src/apps/management/routes/api-keys.ts +96 -1
  256. package/src/apps/mcp.test.ts +17 -2
  257. package/src/apps/mcp.ts +18 -13
  258. package/src/apps/metadata.ts +12 -0
  259. package/src/apps/mpp/internal/Policy.test.ts +7 -1
  260. package/src/cloudflare.ts +279 -0
  261. package/src/db/Db.ts +71 -0
  262. package/src/db/Schema.ts +24 -0
  263. package/src/db/tables/fundingDepositAddresses.ts +28 -0
  264. package/src/db/tables/fundingDepositRequestObservations.test.ts +106 -0
  265. package/src/db/tables/fundingDepositRequestObservations.ts +99 -0
  266. package/src/db/tables/fundingDeposits.test.ts +239 -0
  267. package/src/db/tables/fundingDeposits.ts +214 -1
  268. package/src/internal/Auth.test.ts +205 -5
  269. package/src/internal/Auth.ts +63 -19
  270. package/src/internal/Cache.test.ts +37 -3
  271. package/src/internal/Cache.ts +1 -1
  272. package/src/internal/EdgeCache.test.ts +7 -7
  273. package/src/internal/EdgeCache.ts +13 -4
  274. package/src/internal/IpAllowlist.test.ts +52 -0
  275. package/src/internal/IpAllowlist.ts +39 -0
  276. package/src/internal/Log.test.ts +7 -1
  277. package/src/internal/OpenApi.ts +1 -1
  278. package/src/internal/Tidx.test.ts +28 -0
  279. package/src/internal/Tidx.ts +19 -3
  280. package/src/internal/Viem.test.ts +28 -0
  281. package/src/internal/Viem.ts +21 -5
  282. package/src/internal/funding/Deposit.ts +74 -5
  283. package/src/internal/funding/Evidence.test.ts +2 -2
  284. package/src/internal/funding/Evidence.ts +4 -9
  285. package/src/internal/funding/Provider.ts +115 -0
  286. package/src/internal/funding/Reconciliation.local.test.ts +7 -0
  287. package/src/internal/funding/Reconciliation.test.ts +1364 -109
  288. package/src/internal/funding/Reconciliation.ts +467 -88
  289. package/src/internal/funding/SourceObservation.test.ts +301 -0
  290. package/src/internal/funding/SourceObservation.ts +267 -0
  291. package/src/internal/funding/Subsidy.ts +2 -2
  292. package/src/internal/funding/index.test.ts +1 -0
  293. package/src/internal/funding/index.ts +1 -0
  294. package/src/internal/funding/providers/relay.test.ts +94 -4
  295. package/src/internal/funding/providers/relay.ts +533 -27
  296. package/dist/apps/data/routes/funding.d.ts.map +0 -1
  297. package/dist/apps/data/routes/funding.js +0 -1557
  298. package/dist/apps/data/routes/funding.js.map +0 -1
  299. package/src/apps/data/routes/funding.test.ts +0 -2994
  300. package/src/apps/data/routes/funding.ts +0 -1821
@@ -0,0 +1,1236 @@
1
+ import { Value } from 'ox'
2
+ import { decodeFunctionData, erc20Abi, maxUint256 } from 'viem'
3
+ import { base } from 'viem/chains'
4
+ import { tempoMainnet } from 'viem/tempo/chains'
5
+
6
+ import * as Anvil from '../../../../test/Anvil.js'
7
+ import * as FundingIdempotencyTable from '../../../db/tables/fundingIdempotency.js'
8
+
9
+ import * as Cache from '../../../internal/Cache.js'
10
+
11
+ import * as FundingProvider from '../../../internal/funding/Provider.js'
12
+
13
+ import * as FundingTransfer from '../../../internal/funding/Transfer.js'
14
+
15
+ import * as Idempotency from '../../../internal/Idempotency.js'
16
+
17
+ import * as Relay from '../../../internal/funding/providers/relay.js'
18
+
19
+ import * as TestApp from '../../../../test/App.js'
20
+
21
+ import * as TestFunding from '../../../../test/Funding.js'
22
+
23
+ import * as Funding from './transfers.js'
24
+
25
+ const db = TestApp.database()
26
+
27
+ /** Providers exercised against executable calls on a forked source chain. */
28
+ const executableProviders = [
29
+ {
30
+ create: (sender: string) =>
31
+ Relay.relay({
32
+ ...(process.env.RELAY_API_KEY ? { apiKey: process.env.RELAY_API_KEY } : {}),
33
+ userAddress: sender,
34
+ }),
35
+ id: 'relay',
36
+ },
37
+ ] as const
38
+
39
+ beforeAll(() => TestFunding.publish(db))
40
+
41
+ function create(options: TestApp.create.Options = {}) {
42
+ return TestApp.create({ ...options, db })
43
+ }
44
+
45
+ describe('GET /funding/transfers', () => {
46
+ test('requires the funding:read scope', async () => {
47
+ const app = transfersApp()
48
+ expect((await app.request('/v1/funding/transfers')).status).toBe(401)
49
+ const forbidden = await app.request('/v1/funding/transfers', as(TestApp.key))
50
+ expect(forbidden.status).toBe(403)
51
+ })
52
+
53
+ test('pages own transfers newest-first', async () => {
54
+ const app = transfersApp()
55
+ const first = await seedTransfer()
56
+ const second = await seedTransfer()
57
+ const third = await seedTransfer()
58
+ await seedTransfer({ orgId: fundingForeign.orgId })
59
+
60
+ const response = await app.request('/v1/funding/transfers?limit=5', {
61
+ headers: { 'tempo-api-key': fundingReader.token },
62
+ })
63
+ expect(response.status).toBe(200)
64
+ expect(response.headers.get('cache-control')).toBe(Cache.policies.feed)
65
+ const etag = response.headers.get('etag') ?? ''
66
+ expect(etag).not.toBe('')
67
+ expect(response.headers.get('vary')).toBe(
68
+ 'Accept-Encoding, Authorization, Tempo-API-Key, X-API-Key',
69
+ )
70
+ const body = await TestApp.json(response, Funding.schema.listFundingTransfers.Response)
71
+ expect(body.data.map((transfer) => transfer.id)).toEqual([third.id, second.id, first.id])
72
+ expect(body.nextCursor).toBeNull()
73
+ expect(body.meta).toBeUndefined()
74
+
75
+ const unchanged = await app.request('/v1/funding/transfers?limit=5', {
76
+ headers: { 'if-none-match': etag, 'tempo-api-key': fundingReader.token },
77
+ })
78
+ expect(unchanged.status).toBe(304)
79
+ expect(unchanged.headers.get('cache-control')).toBe(Cache.policies.feed)
80
+ expect(unchanged.headers.get('vary')).toBe(
81
+ 'Accept-Encoding, Authorization, Tempo-API-Key, X-API-Key',
82
+ )
83
+
84
+ const paged = await TestApp.json(
85
+ await app.request(`/v1/funding/transfers?limit=5&cursor=${second.id}`, as(fundingReader)),
86
+ Funding.schema.listFundingTransfers.Response,
87
+ )
88
+ expect(paged.data.map((transfer) => transfer.id)).toEqual([first.id])
89
+ })
90
+
91
+ test('filters by status and embeds the capped count on request', async () => {
92
+ const app = transfersApp()
93
+ const expired = await seedTransfer()
94
+ await FundingTransfer.transition(db, {
95
+ expectedVersion: 1,
96
+ id: expired.id,
97
+ status: 'expired',
98
+ })
99
+
100
+ const response = await app.request(
101
+ '/v1/funding/transfers?status=expired&include=totalCount',
102
+ as(fundingReader),
103
+ )
104
+ const body = await TestApp.json(response, Funding.schema.listFundingTransfers.Response)
105
+ expect(body.data.map((transfer) => transfer.id)).toEqual([expired.id])
106
+ expect(body.data[0]?.status).toBe('expired')
107
+ expect(body.meta).toEqual({ totalCount: 1, totalCountCapped: false })
108
+ })
109
+
110
+ test('narrows project-attributed keys to their project', async () => {
111
+ const app = transfersApp()
112
+ const scoped = await seedTransfer({ projectId: fundingProjectReader.projectId })
113
+
114
+ const body = await TestApp.json(
115
+ await app.request('/v1/funding/transfers?limit=200', as(fundingProjectReader)),
116
+ Funding.schema.listFundingTransfers.Response,
117
+ )
118
+ expect(body.data.map((transfer) => transfer.id)).toEqual([scoped.id])
119
+
120
+ // The organization-attributed key sees the project row too.
121
+ const orgBody = await TestApp.json(
122
+ await app.request('/v1/funding/transfers?limit=200', as(fundingReader)),
123
+ Funding.schema.listFundingTransfers.Response,
124
+ )
125
+ expect(orgBody.data.map((transfer) => transfer.id)).toContain(scoped.id)
126
+ })
127
+
128
+ test('rejects invalid query parameters', async () => {
129
+ const app = transfersApp()
130
+ const response = await app.request('/v1/funding/transfers?status=bogus', as(fundingReader))
131
+ expect(response.status).toBe(400)
132
+ })
133
+ })
134
+
135
+ describe('GET /funding/transfers/:id', () => {
136
+ test('returns the owned transfer directly, without an action', async () => {
137
+ const app = transfersApp()
138
+ const record = await seedTransfer()
139
+ const response = await app.request(`/v1/funding/transfers/${record.id}`, {
140
+ headers: { 'tempo-api-key': fundingReader.token },
141
+ })
142
+ expect(response.status).toBe(200)
143
+ expect({
144
+ cacheControl: response.headers.get('cache-control'),
145
+ etag: response.headers.get('etag'),
146
+ }).toMatchInlineSnapshot(`
147
+ {
148
+ "cacheControl": "no-store",
149
+ "etag": null,
150
+ }
151
+ `)
152
+ expect(response.headers.get('vary')).toBe(
153
+ 'Accept-Encoding, Authorization, Tempo-API-Key, X-API-Key',
154
+ )
155
+ const body = await TestApp.json(response, Funding.schema.getFundingTransfer.Response)
156
+ expect(body).toEqual(FundingTransfer.toPublic(record))
157
+ expect('action' in body).toBe(false)
158
+
159
+ const repeated = await app.request(`/v1/funding/transfers/${record.id}`, {
160
+ headers: { 'if-none-match': '"stale"', 'tempo-api-key': fundingReader.token },
161
+ })
162
+ expect({
163
+ cacheControl: repeated.headers.get('cache-control'),
164
+ etag: repeated.headers.get('etag'),
165
+ status: repeated.status,
166
+ }).toMatchInlineSnapshot(`
167
+ {
168
+ "cacheControl": "no-store",
169
+ "etag": null,
170
+ "status": 200,
171
+ }
172
+ `)
173
+ })
174
+
175
+ test('hides foreign and absent transfers behind the same 404', async () => {
176
+ const app = transfersApp()
177
+ const record = await seedTransfer()
178
+
179
+ const foreign = await app.request(`/v1/funding/transfers/${record.id}`, as(fundingForeign))
180
+ expect(foreign.status).toBe(404)
181
+ expect(((await foreign.json()) as { error: { code: string } }).error.code).toBe(
182
+ 'funding_transfer_not_found',
183
+ )
184
+
185
+ const project = await app.request(
186
+ `/v1/funding/transfers/${record.id}`,
187
+ as(fundingProjectReader),
188
+ )
189
+ expect(project.status).toBe(404)
190
+
191
+ const absent = await app.request(
192
+ '/v1/funding/transfers/ftr_000000000000000_000000000000000000000000',
193
+ as(fundingReader),
194
+ )
195
+ expect(absent.status).toBe(404)
196
+ })
197
+ })
198
+
199
+ const createBody = {
200
+ amount: '1000000',
201
+ destinationToken: '0x20c0000000000000000000000000000000000000',
202
+ mode: 'exactSource',
203
+ recipient: `0x${'11'.repeat(20)}`,
204
+ sender: `0x${'22'.repeat(20)}`,
205
+ slippageBps: 300,
206
+ sourceChain: 'base',
207
+ sourceToken: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913',
208
+ }
209
+
210
+ const vaultTransferBody = {
211
+ amount: createBody.amount,
212
+ mode: createBody.mode,
213
+ recipient: createBody.recipient,
214
+ sender: createBody.sender,
215
+ slippageBps: createBody.slippageBps,
216
+ sourceChain: createBody.sourceChain,
217
+ sourceToken: createBody.sourceToken,
218
+ vaultAddress: `0x${'f4'.repeat(20)}`,
219
+ }
220
+
221
+ const zoneTransferBody = {
222
+ amount: createBody.amount,
223
+ destinationChain: 'eip155:421700001',
224
+ destinationToken: createBody.destinationToken,
225
+ mode: createBody.mode,
226
+ recipient: createBody.recipient,
227
+ recipientFallback: `0x${'33'.repeat(20)}`,
228
+ sender: createBody.sender,
229
+ slippageBps: createBody.slippageBps,
230
+ sourceChain: createBody.sourceChain,
231
+ sourceToken: createBody.sourceToken,
232
+ }
233
+
234
+ function postTransfer(
235
+ app: ReturnType<typeof create>,
236
+ options: { body?: unknown; idempotencyKey?: string | undefined; key?: { token: string } } = {},
237
+ ) {
238
+ return app.request('/v1/funding/transfers', {
239
+ body: JSON.stringify(options.body ?? createBody),
240
+ headers: {
241
+ authorization: `Bearer ${(options.key ?? fundingWriter).token}`,
242
+ 'content-type': 'application/json',
243
+ ...(options.idempotencyKey === undefined
244
+ ? {}
245
+ : { 'idempotency-key': options.idempotencyKey }),
246
+ },
247
+ method: 'POST',
248
+ })
249
+ }
250
+
251
+ describe('POST /funding/transfers', () => {
252
+ function providerResponse(): FundingProvider.prepareTransfer.ReturnType {
253
+ const spender = `0x${'44'.repeat(20)}`
254
+ return {
255
+ action: {
256
+ calls: [
257
+ {
258
+ data: `0x095ea7b3${spender.slice(2).padStart(64, '0')}${(1_000_000).toString(16).padStart(64, '0')}`,
259
+ to: createBody.sourceToken,
260
+ value: '0x0',
261
+ },
262
+ { data: '0xe8017952', to: spender, value: '0x0' },
263
+ ],
264
+ type: 'evm:calls',
265
+ },
266
+ correlation: { requestId: `0x${'ab'.repeat(32)}` },
267
+ fees: [{ amount: '21050', side: 'source' }],
268
+ destinationAmountMin: '980000',
269
+ destinationAmount: '990000',
270
+ quality: {
271
+ estimatedSeconds: 4,
272
+ liquiditySource: 'providerQuote',
273
+ sourceDetail: 'relay:quote-v2',
274
+ tier: 'liquid',
275
+ },
276
+ sampledAt: '2099-01-01T00:00:00.000Z',
277
+ }
278
+ }
279
+
280
+ function provider(
281
+ prepareTransfer: NonNullable<FundingProvider.Provider['prepareTransfer']> = async () =>
282
+ providerResponse(),
283
+ ) {
284
+ return FundingProvider.from({
285
+ ...Relay.relay({ userAddress: '0x1111111111111111111111111111111111111111' }),
286
+ prepareTransfer,
287
+ })
288
+ }
289
+
290
+ let idempotencySequence = 0
291
+ function nextIdempotencyKey() {
292
+ return `funding_test_${idempotencySequence++}`
293
+ }
294
+
295
+ test('requires funding:write and an Idempotency-Key', async () => {
296
+ const app = transfersApp({
297
+ providers: [provider()],
298
+ })
299
+ const forbidden = await postTransfer(app, {
300
+ idempotencyKey: nextIdempotencyKey(),
301
+ key: fundingReader,
302
+ })
303
+ expect(forbidden.status).toBe(403)
304
+
305
+ const missing = await postTransfer(app)
306
+ expect(missing.status).toBe(400)
307
+ expect(((await missing.json()) as { error: { code: string } }).error.code).toBe(
308
+ 'idempotency_key_required',
309
+ )
310
+ })
311
+
312
+ test('creates a transfer with the selected executable quote', async () => {
313
+ const inputs: FundingProvider.prepareTransfer.Parameters[] = []
314
+ const app = transfersApp({
315
+ providers: [
316
+ provider(async (input) => {
317
+ inputs.push(input)
318
+ return providerResponse()
319
+ }),
320
+ ],
321
+ })
322
+ const response = await postTransfer(app, {
323
+ body: { ...createBody, destinationToken: 'pathusd', sourceToken: 'usdc' },
324
+ idempotencyKey: nextIdempotencyKey(),
325
+ })
326
+ expect(response.status).toBe(200)
327
+ expect(response.headers.get('cache-control')).toBe('no-store')
328
+ const body = await TestApp.json(response, Funding.schema.createFundingTransfer.Response)
329
+
330
+ expect(inputs[0]).toMatchObject({
331
+ method: 'transaction',
332
+ mode: 'exactSource',
333
+ recipient: createBody.recipient,
334
+ sender: createBody.sender,
335
+ slippageBps: 300,
336
+ })
337
+ expect(body).toMatchObject({
338
+ action: { type: 'evm:calls' },
339
+ destinationAmountMin: { baseUnits: '980000' },
340
+ method: 'transaction',
341
+ mode: 'exactSource',
342
+ provider: { id: 'relay' },
343
+ // The provider returned no expiry, so the default validity window applies.
344
+ quote: { expiresAt: '2099-01-01T00:01:00.000Z', sampledAt: '2099-01-01T00:00:00.000Z' },
345
+ sourceAmount: { baseUnits: '1000000' },
346
+ status: 'awaiting-source',
347
+ version: 1,
348
+ })
349
+ expect(body.fees[0]).toMatchObject({ side: 'source', token: { symbol: 'USDC' } })
350
+
351
+ // Reads return the same transfer without the action; the private
352
+ // correlation never leaves the row.
353
+ const read = await app.request(`/v1/funding/transfers/${body.id}`, as(fundingWriter))
354
+ const readBody = await TestApp.json(read, Funding.schema.getFundingTransfer.Response)
355
+ expect('action' in readBody).toBe(false)
356
+ expect(JSON.stringify(readBody)).not.toContain('ab'.repeat(32))
357
+ const row = await db.kysely
358
+ .selectFrom('funding_transfers')
359
+ .select(['providerState'])
360
+ .where('id', '=', body.id)
361
+ .executeTakeFirstOrThrow()
362
+ expect(row.providerState).toEqual({ requestId: `0x${'ab'.repeat(32)}` })
363
+ })
364
+
365
+ test('resolves token identifiers through the composed route', async () => {
366
+ const app = transfersApp({ providers: [provider()] })
367
+ const identifiers = [
368
+ { destinationToken: 'pathusd', name: 'alias', sourceToken: 'usdc' },
369
+ {
370
+ destinationToken: createBody.destinationToken,
371
+ name: 'address',
372
+ sourceToken: createBody.sourceToken,
373
+ },
374
+ {
375
+ destinationToken: 'eip155:4217/erc20:0x20c0000000000000000000000000000000000000',
376
+ name: 'token key',
377
+ sourceToken: 'eip155:8453/erc20:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913',
378
+ },
379
+ ] as const
380
+ const results = []
381
+ for (const identifier of identifiers) {
382
+ const response = await postTransfer(app, {
383
+ body: {
384
+ ...createBody,
385
+ destinationToken: identifier.destinationToken,
386
+ sourceToken: identifier.sourceToken,
387
+ },
388
+ idempotencyKey: nextIdempotencyKey(),
389
+ })
390
+ const body = await TestApp.json(response, Funding.schema.createFundingTransfer.Response)
391
+ results.push({
392
+ destinationToken: body.destinationToken.symbol,
393
+ name: identifier.name,
394
+ sourceToken: body.sourceToken.symbol,
395
+ status: response.status,
396
+ })
397
+ }
398
+
399
+ expect(results).toMatchInlineSnapshot(`
400
+ [
401
+ {
402
+ "destinationToken": "pathUSD",
403
+ "name": "alias",
404
+ "sourceToken": "USDC",
405
+ "status": 200,
406
+ },
407
+ {
408
+ "destinationToken": "pathUSD",
409
+ "name": "address",
410
+ "sourceToken": "USDC",
411
+ "status": 200,
412
+ },
413
+ {
414
+ "destinationToken": "pathUSD",
415
+ "name": "token key",
416
+ "sourceToken": "USDC",
417
+ "status": 200,
418
+ },
419
+ ]
420
+ `)
421
+ })
422
+
423
+ test('replays the exact response for the same key and body', async () => {
424
+ const app = transfersApp({
425
+ providers: [provider()],
426
+ })
427
+ const idempotencyKey = nextIdempotencyKey()
428
+ const first = await postTransfer(app, { idempotencyKey })
429
+ const second = await postTransfer(app, { idempotencyKey })
430
+ expect(second.status).toBe(200)
431
+ expect(await second.json()).toEqual(await first.json())
432
+ })
433
+
434
+ test('rejects a reused key with different input', async () => {
435
+ const app = transfersApp({
436
+ providers: [provider()],
437
+ })
438
+ const idempotencyKey = nextIdempotencyKey()
439
+ await postTransfer(app, { idempotencyKey })
440
+ const conflict = await postTransfer(app, {
441
+ body: { ...createBody, amount: '2000000' },
442
+ idempotencyKey,
443
+ })
444
+ expect(conflict.status).toBe(409)
445
+ expect(((await conflict.json()) as { error: { code: string } }).error.code).toBe(
446
+ 'idempotency_conflict',
447
+ )
448
+ })
449
+
450
+ test('rejects an equivalent request while the first is running', async () => {
451
+ const app = transfersApp({
452
+ providers: [provider()],
453
+ })
454
+ const idempotencyKey = nextIdempotencyKey()
455
+ await FundingIdempotencyTable.claim(db, {
456
+ apiKeyId: fundingWriter.id,
457
+ keyHash: Idempotency.inputHash(idempotencyKey),
458
+ requestHash: Idempotency.inputHash(createBody),
459
+ ttlMs: 60_000,
460
+ })
461
+ const pending = await postTransfer(app, { idempotencyKey })
462
+ expect(pending.status).toBe(409)
463
+ expect(((await pending.json()) as { error: { code: string } }).error.code).toBe(
464
+ 'idempotency_in_progress',
465
+ )
466
+ })
467
+
468
+ test('rolls back creation after losing the idempotency lease', async () => {
469
+ const idempotencyKey = nextIdempotencyKey()
470
+ const keyHash = Idempotency.inputHash(idempotencyKey)
471
+ const before = await db.kysely
472
+ .selectFrom('funding_transfers')
473
+ .select(({ fn }) => fn.countAll<number>().as('count'))
474
+ .executeTakeFirstOrThrow()
475
+ const app = transfersApp({
476
+ providers: [
477
+ provider(async () => {
478
+ await db.kysely
479
+ .updateTable('funding_idempotency_requests')
480
+ .set({ expiresAt: new Date(0).toISOString() })
481
+ .where('apiKeyId', '=', fundingWriter.id)
482
+ .where('keyHash', '=', keyHash)
483
+ .execute()
484
+ await FundingIdempotencyTable.claim(db, {
485
+ apiKeyId: fundingWriter.id,
486
+ keyHash,
487
+ requestHash: Idempotency.inputHash(createBody),
488
+ ttlMs: 60_000,
489
+ })
490
+ return providerResponse()
491
+ }),
492
+ ],
493
+ })
494
+
495
+ const response = await postTransfer(app, { idempotencyKey })
496
+ expect(response.status).toBe(500)
497
+ const after = await db.kysely
498
+ .selectFrom('funding_transfers')
499
+ .select(({ fn }) => fn.countAll<number>().as('count'))
500
+ .executeTakeFirstOrThrow()
501
+ expect(after.count).toBe(before.count)
502
+ })
503
+
504
+ test('rejects non-account fields and gates disabled corridors and methods', async () => {
505
+ const app = transfersApp({
506
+ providers: [provider()],
507
+ })
508
+
509
+ const ignoredRefund = await postTransfer(app, {
510
+ body: { ...createBody, refundAddress: createBody.sender },
511
+ idempotencyKey: nextIdempotencyKey(),
512
+ })
513
+ expect(ignoredRefund.status).toBe(400)
514
+ expect(((await ignoredRefund.json()) as { error: { code: string } }).error.code).toBe(
515
+ 'body_invalid',
516
+ )
517
+
518
+ const unsupportedDestination = await postTransfer(app, {
519
+ body: { ...createBody, destinationVault: `0x${'f4'.repeat(20)}`, type: 'vault' },
520
+ idempotencyKey: nextIdempotencyKey(),
521
+ })
522
+ expect(unsupportedDestination.status).toBe(400)
523
+ expect(((await unsupportedDestination.json()) as { error: { code: string } }).error.code).toBe(
524
+ 'body_invalid',
525
+ )
526
+
527
+ const unknownToken = await postTransfer(app, {
528
+ body: { ...createBody, destinationToken: `0x${'99'.repeat(20)}` },
529
+ idempotencyKey: nextIdempotencyKey(),
530
+ })
531
+ expect(unknownToken.status).toBe(404)
532
+ })
533
+
534
+ test('releases the claim after a provider failure', async () => {
535
+ const failing = transfersApp({
536
+ providers: [
537
+ provider(async () => {
538
+ throw new Error('upstream unavailable')
539
+ }),
540
+ ],
541
+ })
542
+ const idempotencyKey = nextIdempotencyKey()
543
+ const failed = await postTransfer(failing, { idempotencyKey })
544
+ expect(failed.status).toBe(502)
545
+ expect(((await failed.json()) as { error: { code: string } }).error.code).toBe('upstream_error')
546
+
547
+ // The key was released, so the same request retries successfully.
548
+ const working = transfersApp({
549
+ providers: [provider()],
550
+ })
551
+ expect((await postTransfer(working, { idempotencyKey })).status).toBe(200)
552
+ })
553
+
554
+ test('rejects preparations with unbounded approvals', async () => {
555
+ const app = transfersApp({
556
+ providers: [
557
+ provider(async () => {
558
+ const result = providerResponse()
559
+ const spender = `0x${'44'.repeat(20)}`
560
+ return {
561
+ ...result,
562
+ action: {
563
+ calls: [
564
+ {
565
+ data: `0x095ea7b3${spender.slice(2).padStart(64, '0')}${'f'.repeat(64)}`,
566
+ to: createBody.sourceToken,
567
+ value: '0x0',
568
+ },
569
+ ],
570
+ type: 'evm:calls',
571
+ },
572
+ }
573
+ }),
574
+ ],
575
+ })
576
+ const response = await postTransfer(app, { idempotencyKey: nextIdempotencyKey() })
577
+ expect(response.status).toBe(502)
578
+ })
579
+ })
580
+
581
+ describe.skipIf(!Anvil.available)('POST /funding/transfers (forked localnet)', () => {
582
+ /**
583
+ * Storage slot of Circle FiatToken's `balanceAndBlacklistStates` mapping.
584
+ * `anvil_dealERC20` cannot probe it because balances pack a blacklist bit,
585
+ * so the fork suite writes the slot directly. Every curated USDC is a
586
+ * FiatToken.
587
+ */
588
+ const fiatTokenBalanceSlot = 9n
589
+
590
+ for (const executable of executableProviders) {
591
+ describe(executable.id, () => {
592
+ const route = TestFunding.snapshot.routes.find(
593
+ (entry) =>
594
+ entry.route.source.chain.id === `eip155:${base.id}` &&
595
+ entry.route.source.symbol === 'USDC' &&
596
+ entry.route.destination.slug === 'pathusd',
597
+ )!
598
+ const usdc = route.route.source
599
+ const amount = Value.from('10', usdc.decimals)
600
+ const sender = Anvil.getAccount(0)
601
+
602
+ let anvil: Anvil.start.ReturnType
603
+
604
+ beforeAll(async () => {
605
+ anvil = await Anvil.start({ chain: base, forkUrl: base.rpcUrls.default.http[0]! })
606
+ })
607
+
608
+ afterAll(async () => {
609
+ await anvil?.stop()
610
+ })
611
+
612
+ function createTransfer(idempotencyKey: string) {
613
+ const app = transfersApp({ providers: [executable.create(sender.address)] })
614
+ return postTransfer(app, {
615
+ body: { ...createBody, amount: amount.toString(), sender: sender.address },
616
+ idempotencyKey,
617
+ })
618
+ }
619
+
620
+ test('executes the prepared calls and returns successful receipts', async () => {
621
+ const { client } = anvil
622
+ await Anvil.setErc20Balance(client, {
623
+ address: sender.address,
624
+ amount,
625
+ slot: fiatTokenBalanceSlot,
626
+ token: usdc.address as `0x${string}`,
627
+ })
628
+
629
+ const response = await createTransfer(`bridge_${executable.id}_${sender.address}`)
630
+ expect(response.status).toBe(200)
631
+ const transfer = await TestApp.json(response, Funding.schema.createFundingTransfer.Response)
632
+ expect(transfer.action.type).toBe('evm:calls')
633
+ if (transfer.action.type !== 'evm:calls') return
634
+ expect(transfer.action.calls.length).toBeGreaterThan(0)
635
+
636
+ const receipts = []
637
+ for (const call of transfer.action.calls) {
638
+ receipts.push(
639
+ await client.sendTransactionSync({
640
+ account: sender,
641
+ data: call.data as `0x${string}`,
642
+ to: call.to as `0x${string}`,
643
+ value: BigInt(call.value),
644
+ }),
645
+ )
646
+ }
647
+
648
+ expect(receipts.map((receipt) => receipt.status)).toEqual(
649
+ transfer.action.calls.map(() => 'success'),
650
+ )
651
+ // The deposit is the last call and must emit; an approval alone proves nothing.
652
+ expect(receipts.at(-1)!.logs.length).toBeGreaterThan(0)
653
+ })
654
+
655
+ test('bounds the approval to the quoted source spend', async () => {
656
+ const response = await createTransfer(`bridge_bounds_${executable.id}_${sender.address}`)
657
+ expect(response.status).toBe(200)
658
+ const transfer = await TestApp.json(response, Funding.schema.createFundingTransfer.Response)
659
+ if (transfer.action.type !== 'evm:calls') return
660
+
661
+ const approval = transfer.action.calls.find((call) =>
662
+ call.data.toLowerCase().startsWith('0x095ea7b3'),
663
+ )
664
+ expect(approval).toBeDefined()
665
+ if (!approval) return
666
+
667
+ // Decoded from the live action, so a provider that starts returning
668
+ // an unlimited allowance fails here.
669
+ const decoded = decodeFunctionData({ abi: erc20Abi, data: approval.data as `0x${string}` })
670
+ expect(decoded.functionName).toBe('approve')
671
+ if (decoded.functionName !== 'approve') return
672
+ const [, approved] = decoded.args
673
+ expect(approved).toBeLessThanOrEqual(BigInt(transfer.sourceAmount.baseUnits))
674
+ expect(approved).toBeLessThan(maxUint256)
675
+ })
676
+ })
677
+ }
678
+ })
679
+
680
+ describe('POST /funding/transfers/{destination}', () => {
681
+ test.each([
682
+ { body: vaultTransferBody, destination: 'vault' },
683
+ { body: zoneTransferBody, destination: 'zone' },
684
+ ] as const)('validates and returns 501 for $destination transfers', async (options) => {
685
+ const app = transfersApp()
686
+ const before = await db.kysely
687
+ .selectFrom('funding_transfers')
688
+ .select(({ fn }) => fn.countAll<number>().as('count'))
689
+ .executeTakeFirstOrThrow()
690
+ const response = await app.request(`/v1/funding/transfers/${options.destination}`, {
691
+ body: JSON.stringify(options.body),
692
+ headers: {
693
+ authorization: `Bearer ${fundingWriter.token}`,
694
+ 'content-type': 'application/json',
695
+ 'idempotency-key': `funding_${options.destination}_unimplemented`,
696
+ },
697
+ method: 'POST',
698
+ })
699
+
700
+ expect(response.status).toBe(501)
701
+ expect(response.headers.get('cache-control')).toBe('no-store')
702
+ expect(((await response.json()) as { error: { code: string } }).error.code).toBe(
703
+ 'not_implemented',
704
+ )
705
+ const after = await db.kysely
706
+ .selectFrom('funding_transfers')
707
+ .select(({ fn }) => fn.countAll<number>().as('count'))
708
+ .executeTakeFirstOrThrow()
709
+ expect(after.count).toBe(before.count)
710
+ })
711
+
712
+ test('requires the header and validates destination fields', async () => {
713
+ const app = transfersApp()
714
+ const missingHeader = await app.request('/v1/funding/transfers/vault', {
715
+ body: JSON.stringify(vaultTransferBody),
716
+ headers: {
717
+ authorization: `Bearer ${fundingWriter.token}`,
718
+ 'content-type': 'application/json',
719
+ },
720
+ method: 'POST',
721
+ })
722
+ expect(missingHeader.status).toBe(400)
723
+ expect(((await missingHeader.json()) as { error: { code: string } }).error.code).toBe(
724
+ 'idempotency_key_required',
725
+ )
726
+
727
+ const invalidZone = await app.request('/v1/funding/transfers/zone', {
728
+ body: JSON.stringify({ ...zoneTransferBody, destinationChain: '421700001' }),
729
+ headers: {
730
+ authorization: `Bearer ${fundingWriter.token}`,
731
+ 'content-type': 'application/json',
732
+ 'idempotency-key': 'funding_zone_invalid',
733
+ },
734
+ method: 'POST',
735
+ })
736
+ expect(invalidZone.status).toBe(400)
737
+ expect(((await invalidZone.json()) as { error: { code: string } }).error.code).toBe(
738
+ 'body_invalid',
739
+ )
740
+ })
741
+ })
742
+
743
+ describe('funding transfers OpenAPI', () => {
744
+ test('publishes an executable account transfer example', async () => {
745
+ const app = create({ auth: false })
746
+ type TokenSchema = { examples: string[] }
747
+ type TransferExample = { destinationToken: { address: string } }
748
+ type Operation = {
749
+ requestBody: {
750
+ content: Record<string, { schema: { properties: { destinationToken: TokenSchema } } }>
751
+ }
752
+ responses: Record<string, { content: Record<string, { example: TransferExample }> }>
753
+ }
754
+ type Spec = { paths: Record<string, { post: Operation }> }
755
+ const spec = (await (await app.request('/openapi.json')).json()) as Spec
756
+ const operation = spec.paths['/v1/funding/transfers']!.post
757
+
758
+ expect({
759
+ request:
760
+ operation.requestBody.content['application/json']!.schema.properties.destinationToken
761
+ .examples,
762
+ response:
763
+ operation.responses['200']!.content['application/json']!.example.destinationToken.address,
764
+ }).toEqual({
765
+ request: ['pathusd'],
766
+ response: '0x20c0000000000000000000000000000000000000',
767
+ })
768
+ })
769
+
770
+ test('publishes human-readable deposit address route examples', async () => {
771
+ const app = create({ auth: false })
772
+ type Property = { examples: string[] }
773
+ type Operation = {
774
+ requestBody: {
775
+ content: Record<
776
+ string,
777
+ {
778
+ schema: {
779
+ properties: {
780
+ destinationToken: Property
781
+ sourceChain: Property
782
+ sourceToken: Property
783
+ }
784
+ }
785
+ }
786
+ >
787
+ }
788
+ }
789
+ type Spec = { paths: Record<string, { post: Operation }> }
790
+ const spec = (await (await app.request('/openapi.json')).json()) as Spec
791
+ const properties =
792
+ spec.paths['/v1/funding/deposit-addresses']!.post.requestBody.content['application/json']!
793
+ .schema.properties
794
+
795
+ expect({
796
+ destinationToken: properties.destinationToken.examples,
797
+ sourceChain: properties.sourceChain.examples,
798
+ sourceToken: properties.sourceToken.examples,
799
+ }).toMatchInlineSnapshot(`
800
+ {
801
+ "destinationToken": [
802
+ "usdt0",
803
+ ],
804
+ "sourceChain": [
805
+ "tron",
806
+ "tron:0x2b6653dc",
807
+ ],
808
+ "sourceToken": [
809
+ "usdt",
810
+ ],
811
+ }
812
+ `)
813
+ })
814
+
815
+ test('publishes concise operation documentation', async () => {
816
+ const app = create({ auth: false })
817
+ type Operation = { description: string; summary: string; tags: string[] }
818
+ type Spec = {
819
+ paths: Record<string, { get?: Operation; post?: Operation }>
820
+ tags: { description?: string; name: string }[]
821
+ 'x-tagGroups': { name: string; tags: string[] }[]
822
+ }
823
+ const spec = (await (await app.request('/openapi.json')).json()) as Spec
824
+ const detailPath = Object.keys(spec.paths).find((path) =>
825
+ path.includes('/v1/funding/transfers/{'),
826
+ )!
827
+ const depositAddressPath = Object.keys(spec.paths).find(
828
+ (path) =>
829
+ path.includes('/v1/funding/deposit-addresses/{') &&
830
+ !path.endsWith('/deposits') &&
831
+ !path.endsWith('/reconcile'),
832
+ )!
833
+ const depositPath = Object.keys(spec.paths).find((path) =>
834
+ path.includes('/v1/funding/deposits/{'),
835
+ )!
836
+ const reconcilePath = Object.keys(spec.paths).find((path) => path.endsWith('/reconcile'))!
837
+ const summarize = (operation: Operation) => ({
838
+ description: operation.description,
839
+ summary: operation.summary,
840
+ })
841
+ const operations = {
842
+ account: summarize(spec.paths['/v1/funding/transfers']!.post!),
843
+ chains: summarize(spec.paths['/v1/funding/chains']!.get!),
844
+ deposit: summarize(spec.paths[depositPath]!.get!),
845
+ depositAddress: summarize(spec.paths[depositAddressPath]!.get!),
846
+ depositAddressCreate: summarize(spec.paths['/v1/funding/deposit-addresses']!.post!),
847
+ depositAddressReconcile: summarize(spec.paths[reconcilePath]!.post!),
848
+ deposits: summarize(spec.paths['/v1/funding/deposits']!.get!),
849
+ detail: summarize(spec.paths[detailPath]!.get!),
850
+ list: summarize(spec.paths['/v1/funding/transfers']!.get!),
851
+ providers: summarize(spec.paths['/v1/funding/providers']!.get!),
852
+ quotes: summarize(spec.paths['/v1/funding/quotes']!.get!),
853
+ vault: summarize(spec.paths['/v1/funding/transfers/vault']!.post!),
854
+ zone: summarize(spec.paths['/v1/funding/transfers/zone']!.post!),
855
+ }
856
+ const category = spec['x-tagGroups'].find((group) => group.name === 'Funding & Bridge API')
857
+ const categoryTags = new Set(category?.tags)
858
+ const sidebar = Object.values(spec.paths).flatMap((path) =>
859
+ [path.get, path.post].flatMap((operation) =>
860
+ operation?.tags.some((tag) => categoryTags.has(tag)) ? [operation.summary] : [],
861
+ ),
862
+ )
863
+
864
+ for (const operation of Object.values(operations))
865
+ expect(operation.description.split(/\s+/).length).toBeLessThan(15)
866
+
867
+ expect({
868
+ category: category?.name,
869
+ operations,
870
+ sidebar,
871
+ }).toMatchInlineSnapshot(`
872
+ {
873
+ "category": "Funding & Bridge API",
874
+ "operations": {
875
+ "account": {
876
+ "description": "Creates a transfer to a Tempo account.",
877
+ "summary": "Create transfer",
878
+ },
879
+ "chains": {
880
+ "description": "Lists supported source chains and tokens.",
881
+ "summary": "Get chains",
882
+ },
883
+ "deposit": {
884
+ "description": "Returns one detected deposit and its delivery status.",
885
+ "summary": "Get deposit",
886
+ },
887
+ "depositAddress": {
888
+ "description": "Returns one reusable funding deposit address.",
889
+ "summary": "Get deposit address",
890
+ },
891
+ "depositAddressCreate": {
892
+ "description": "Creates or returns a reusable deposit address for funding a Tempo account.",
893
+ "summary": "Create deposit address",
894
+ },
895
+ "depositAddressReconcile": {
896
+ "description": "Queues an immediate provider reconciliation for one reusable funding deposit address.",
897
+ "summary": "Reconcile deposit address",
898
+ },
899
+ "deposits": {
900
+ "description": "Lists deposits by deposit address or Tempo recipient, newest first.",
901
+ "summary": "List deposits",
902
+ },
903
+ "detail": {
904
+ "description": "Returns a transfer by ID.",
905
+ "summary": "Get transfer",
906
+ },
907
+ "list": {
908
+ "description": "Lists transfers from newest to oldest.",
909
+ "summary": "List transfers",
910
+ },
911
+ "providers": {
912
+ "description": "Lists available transfer providers.",
913
+ "summary": "Get providers",
914
+ },
915
+ "quotes": {
916
+ "description": "Returns live quotes for transferring stablecoins to Tempo.",
917
+ "summary": "Get quotes",
918
+ },
919
+ "vault": {
920
+ "description": "Creates a transfer into a Tempo Earn vault.",
921
+ "summary": "Create transfer into vault",
922
+ },
923
+ "zone": {
924
+ "description": "Creates a transfer into a Tempo Zone.",
925
+ "summary": "Create transfer into zone",
926
+ },
927
+ },
928
+ "sidebar": [
929
+ "Get quotes",
930
+ "Get chains",
931
+ "Get providers",
932
+ "Create deposit address",
933
+ "List transfers",
934
+ "Create transfer",
935
+ "Create transfer into vault",
936
+ "Create transfer into zone",
937
+ "Reconcile deposit address",
938
+ "Get deposit address",
939
+ "Get transfer",
940
+ "Get deposit",
941
+ "List deposits",
942
+ ],
943
+ }
944
+ `)
945
+ })
946
+
947
+ test('publishes concrete creation schemas', async () => {
948
+ const app = create({ auth: false })
949
+ const spec = (await (await app.request('/openapi.json')).json()) as {
950
+ paths: Record<
951
+ string,
952
+ {
953
+ post?: {
954
+ requestBody: {
955
+ content: Record<
956
+ string,
957
+ {
958
+ schema: {
959
+ additionalProperties?: boolean
960
+ properties: object
961
+ required?: readonly string[] | undefined
962
+ }
963
+ }
964
+ >
965
+ }
966
+ responses: Record<string, unknown>
967
+ }
968
+ }
969
+ >
970
+ }
971
+ const creationSchema = (path: string) =>
972
+ spec.paths[path]!.post!.requestBody.content['application/json']!.schema
973
+ expect(
974
+ creationSchema('/v1/funding/deposit-addresses').required?.includes('subsidize') ?? false,
975
+ ).toMatchInlineSnapshot(`false`)
976
+ const summarize = (path: string) => {
977
+ const schema = creationSchema(path)
978
+ return {
979
+ additionalProperties: schema.additionalProperties,
980
+ properties: Object.keys(schema.properties),
981
+ }
982
+ }
983
+
984
+ expect({
985
+ account: summarize('/v1/funding/transfers'),
986
+ depositAddress: summarize('/v1/funding/deposit-addresses'),
987
+ depositAddressResponses: Object.keys(
988
+ spec.paths['/v1/funding/deposit-addresses']!.post!.responses,
989
+ ),
990
+ vault: summarize('/v1/funding/transfers/vault'),
991
+ vaultResponses: Object.keys(spec.paths['/v1/funding/transfers/vault']!.post!.responses),
992
+ zone: summarize('/v1/funding/transfers/zone'),
993
+ zoneResponses: Object.keys(spec.paths['/v1/funding/transfers/zone']!.post!.responses),
994
+ }).toMatchInlineSnapshot(`
995
+ {
996
+ "account": {
997
+ "additionalProperties": false,
998
+ "properties": [
999
+ "amount",
1000
+ "destinationToken",
1001
+ "mode",
1002
+ "recipient",
1003
+ "sender",
1004
+ "slippageBps",
1005
+ "sourceChain",
1006
+ "sourceToken",
1007
+ ],
1008
+ },
1009
+ "depositAddress": {
1010
+ "additionalProperties": false,
1011
+ "properties": [
1012
+ "amount",
1013
+ "destinationToken",
1014
+ "recipient",
1015
+ "refundAddress",
1016
+ "sourceChain",
1017
+ "sourceToken",
1018
+ "subsidize",
1019
+ ],
1020
+ },
1021
+ "depositAddressResponses": [
1022
+ "200",
1023
+ "400",
1024
+ "401",
1025
+ "404",
1026
+ "409",
1027
+ "429",
1028
+ "500",
1029
+ "502",
1030
+ ],
1031
+ "vault": {
1032
+ "additionalProperties": false,
1033
+ "properties": [
1034
+ "amount",
1035
+ "mode",
1036
+ "recipient",
1037
+ "sender",
1038
+ "slippageBps",
1039
+ "sourceChain",
1040
+ "sourceToken",
1041
+ "vaultAddress",
1042
+ ],
1043
+ },
1044
+ "vaultResponses": [
1045
+ "400",
1046
+ "401",
1047
+ "429",
1048
+ "500",
1049
+ "501",
1050
+ ],
1051
+ "zone": {
1052
+ "additionalProperties": false,
1053
+ "properties": [
1054
+ "amount",
1055
+ "destinationChain",
1056
+ "destinationToken",
1057
+ "mode",
1058
+ "recipient",
1059
+ "recipientFallback",
1060
+ "sender",
1061
+ "slippageBps",
1062
+ "sourceChain",
1063
+ "sourceToken",
1064
+ ],
1065
+ },
1066
+ "zoneResponses": [
1067
+ "400",
1068
+ "401",
1069
+ "429",
1070
+ "500",
1071
+ "501",
1072
+ ],
1073
+ }
1074
+ `)
1075
+ })
1076
+
1077
+ test('publishes alphabetically ordered example fields and parameter examples', async () => {
1078
+ const app = create({ auth: false })
1079
+ const spec = (await (await app.request('/openapi.json')).json()) as {
1080
+ paths: Record<
1081
+ string,
1082
+ {
1083
+ get: {
1084
+ parameters?: { example?: unknown; name: string; schema?: { examples?: unknown } }[]
1085
+ responses: Record<string, { content: Record<string, { example: { data: {}[] } }> }>
1086
+ }
1087
+ }
1088
+ >
1089
+ }
1090
+ const operation = spec.paths['/v1/funding/transfers']!.get
1091
+ const fields = Object.keys(operation.responses['200']!.content['application/json']!.example.data[0]!) // prettier-ignore
1092
+ expect(fields).toEqual([...fields].sort())
1093
+ for (const route of [operation, spec.paths['/v1/funding/deposits']!.get])
1094
+ for (const parameter of route.parameters ?? [])
1095
+ expect(parameter.example ?? parameter.schema?.examples, parameter.name).toBeDefined()
1096
+ })
1097
+
1098
+ test('publishes operation ids and required scopes', async () => {
1099
+ const app = create({ auth: false })
1100
+ const spec = (await (await app.request('/openapi.json')).json()) as {
1101
+ paths: Record<
1102
+ string,
1103
+ {
1104
+ get: { operationId: string; 'x-required-scopes': string[] }
1105
+ post?: {
1106
+ operationId: string
1107
+ parameters: { in: string; name: string; required: boolean }[]
1108
+ 'x-required-scopes': string[]
1109
+ }
1110
+ }
1111
+ >
1112
+ }
1113
+ const detailPath = Object.keys(spec.paths).find((path) =>
1114
+ path.includes('/v1/funding/transfers/{'),
1115
+ )
1116
+ const depositAddressPath = Object.keys(spec.paths).find(
1117
+ (path) =>
1118
+ path.includes('/v1/funding/deposit-addresses/{') &&
1119
+ !path.endsWith('/deposits') &&
1120
+ !path.endsWith('/reconcile'),
1121
+ )
1122
+ const depositPath = Object.keys(spec.paths).find((path) =>
1123
+ path.includes('/v1/funding/deposits/{'),
1124
+ )
1125
+ const reconcilePath = Object.keys(spec.paths).find((path) => path.endsWith('/reconcile'))
1126
+ expect({
1127
+ deposit: depositPath && spec.paths[depositPath]?.get.operationId,
1128
+ depositAddress: depositAddressPath && spec.paths[depositAddressPath]?.get.operationId,
1129
+ depositAddressCreate: spec.paths['/v1/funding/deposit-addresses']?.post?.operationId,
1130
+ depositAddressCreateScopes:
1131
+ spec.paths['/v1/funding/deposit-addresses']?.post?.['x-required-scopes'],
1132
+ depositAddressReconcile: reconcilePath && spec.paths[reconcilePath]?.post?.operationId,
1133
+ depositAddressReconcileScopes:
1134
+ reconcilePath && spec.paths[reconcilePath]?.post?.['x-required-scopes'],
1135
+ depositAddressScopes:
1136
+ depositAddressPath && spec.paths[depositAddressPath]?.get['x-required-scopes'],
1137
+ depositScopes: depositPath && spec.paths[depositPath]?.get['x-required-scopes'],
1138
+ deposits: spec.paths['/v1/funding/deposits']?.get.operationId,
1139
+ depositsScopes: spec.paths['/v1/funding/deposits']?.get['x-required-scopes'],
1140
+ detail: detailPath && spec.paths[detailPath]?.get.operationId,
1141
+ detailScopes: detailPath && spec.paths[detailPath]?.get['x-required-scopes'],
1142
+ list: spec.paths['/v1/funding/transfers']?.get.operationId,
1143
+ listScopes: spec.paths['/v1/funding/transfers']?.get['x-required-scopes'],
1144
+ requiredCreateHeader: spec.paths['/v1/funding/transfers']?.post?.parameters.find(
1145
+ (parameter) => parameter.name === 'idempotency-key',
1146
+ ),
1147
+ vault: spec.paths['/v1/funding/transfers/vault']?.post?.operationId,
1148
+ vaultScopes: spec.paths['/v1/funding/transfers/vault']?.post?.['x-required-scopes'],
1149
+ zone: spec.paths['/v1/funding/transfers/zone']?.post?.operationId,
1150
+ zoneScopes: spec.paths['/v1/funding/transfers/zone']?.post?.['x-required-scopes'],
1151
+ }).toEqual({
1152
+ deposit: 'getFundingDeposit',
1153
+ depositAddress: 'getFundingDepositAddress',
1154
+ depositAddressCreate: 'createFundingDepositAddress',
1155
+ depositAddressCreateScopes: ['funding:write'],
1156
+ depositAddressReconcile: 'reconcileFundingDepositAddress',
1157
+ depositAddressReconcileScopes: ['funding:write'],
1158
+ depositAddressScopes: ['funding:read'],
1159
+ depositScopes: ['funding:read'],
1160
+ deposits: 'listFundingDeposits',
1161
+ depositsScopes: ['funding:read'],
1162
+ detail: 'getFundingTransfer',
1163
+ detailScopes: ['funding:read'],
1164
+ list: 'listFundingTransfers',
1165
+ listScopes: ['funding:read'],
1166
+ requiredCreateHeader: expect.objectContaining({
1167
+ description: expect.stringContaining('24 hours'),
1168
+ in: 'header',
1169
+ name: 'idempotency-key',
1170
+ required: true,
1171
+ schema: expect.any(Object),
1172
+ }),
1173
+ vault: 'createFundingTransferVault',
1174
+ vaultScopes: ['funding:write'],
1175
+ zone: 'createFundingTransferZone',
1176
+ zoneScopes: ['funding:write'],
1177
+ })
1178
+ })
1179
+ })
1180
+
1181
+ const fundingReader = {
1182
+ id: 'key_funding',
1183
+ orgId: 'org_funding',
1184
+ scopes: ['funding:read'],
1185
+ token: 'secret_funding_reader',
1186
+ } satisfies TestApp.kvStore.Key
1187
+
1188
+ const fundingProjectReader = {
1189
+ id: 'key_funding_project',
1190
+ orgId: 'org_funding',
1191
+ projectId: 'prj_funding',
1192
+ scopes: ['funding:read'],
1193
+ token: 'secret_funding_project',
1194
+ } satisfies TestApp.kvStore.Key
1195
+
1196
+ const fundingForeign = {
1197
+ id: 'key_funding_other',
1198
+ orgId: 'org_funding_other',
1199
+ scopes: ['funding:read'],
1200
+ token: 'secret_funding_other',
1201
+ } satisfies TestApp.kvStore.Key
1202
+
1203
+ const fundingWriter = {
1204
+ id: 'key_funding_writer',
1205
+ orgId: 'org_funding',
1206
+ scopes: ['funding:read', 'funding:write'],
1207
+ token: 'secret_funding_writer',
1208
+ } satisfies TestApp.kvStore.Key
1209
+
1210
+ function transfersApp(options: TestApp.create.Options = {}) {
1211
+ return create({
1212
+ ...options,
1213
+ auth: {
1214
+ keys: [TestApp.key, fundingReader, fundingProjectReader, fundingForeign, fundingWriter],
1215
+ },
1216
+ defaultChainId: options.defaultChainId ?? tempoMainnet.id,
1217
+ })
1218
+ }
1219
+
1220
+ function as(key: { token: string }) {
1221
+ return { headers: { authorization: `Bearer ${key.token}` } } as const
1222
+ }
1223
+
1224
+ let seedSecond = 0
1225
+
1226
+ function seedTransfer(input: Partial<FundingTransfer.create.Input> = {}) {
1227
+ // Distinct create times keep ids strictly ordered for paging assertions.
1228
+ return FundingTransfer.create(db, {
1229
+ apiKeyId: fundingReader.id,
1230
+ environment: 'production',
1231
+ id: FundingTransfer.generateId(new Date(Date.UTC(2026, 0, 2, 0, 0, seedSecond++))),
1232
+ orgId: fundingReader.orgId,
1233
+ snapshot: TestFunding.transferSnapshot(),
1234
+ ...input,
1235
+ })
1236
+ }