tapimo 0.7.2 → 0.8.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 (294) 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 +20434 -19484
  11. package/dist/App.d.ts.map +1 -1
  12. package/dist/App.js +62 -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 +168 -168
  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 +10305 -12785
  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 +24 -24
  35. package/dist/apps/data/routes/addresses.d.ts +288 -288
  36. package/dist/apps/data/routes/balances.d.ts +6 -6
  37. package/dist/apps/data/routes/blocks.d.ts +39 -39
  38. package/dist/apps/data/routes/coingecko.d.ts +170 -170
  39. package/dist/apps/data/routes/earn.d.ts +436 -6
  40. package/dist/apps/data/routes/earn.d.ts.map +1 -1
  41. package/dist/apps/data/routes/earn.js +631 -4
  42. package/dist/apps/data/routes/earn.js.map +1 -1
  43. package/dist/apps/data/routes/exchanges.d.ts +10 -10
  44. package/dist/apps/data/routes/fee-amm.d.ts +2 -2
  45. package/dist/apps/data/routes/indexer.d.ts +6 -6
  46. package/dist/apps/data/routes/indexer.js +4 -1
  47. package/dist/apps/data/routes/indexer.js.map +1 -1
  48. package/dist/apps/data/routes/receipts.d.ts +235 -235
  49. package/dist/apps/data/routes/rpc.d.ts +1 -1
  50. package/dist/apps/data/routes/rpc.js +4 -1
  51. package/dist/apps/data/routes/rpc.js.map +1 -1
  52. package/dist/apps/data/routes/tokenlist.d.ts +36 -36
  53. package/dist/apps/data/routes/tokens.d.ts +6 -6
  54. package/dist/apps/data/routes/transactions.d.ts +2 -2
  55. package/dist/apps/data/routes/transfers.d.ts +1 -1
  56. package/dist/apps/data/routes/valuation.d.ts +1 -1
  57. package/dist/apps/data/routes/verified-tokens.d.ts +36 -36
  58. package/dist/apps/data/routes/webhooks.d.ts +9 -9
  59. package/dist/apps/data/routes/zones.d.ts +14 -14
  60. package/dist/apps/funding/Access.d.ts +16 -0
  61. package/dist/apps/funding/Access.d.ts.map +1 -0
  62. package/dist/apps/funding/Access.js +22 -0
  63. package/dist/apps/funding/Access.js.map +1 -0
  64. package/dist/apps/{data/routes/funding.d.ts → funding/App.d.ts} +1717 -3035
  65. package/dist/apps/funding/App.d.ts.map +1 -0
  66. package/dist/apps/funding/App.js +46 -0
  67. package/dist/apps/funding/App.js.map +1 -0
  68. package/dist/apps/funding/Inventory.d.ts +5 -0
  69. package/dist/apps/funding/Inventory.d.ts.map +1 -0
  70. package/dist/apps/funding/Inventory.js +25 -0
  71. package/dist/apps/funding/Inventory.js.map +1 -0
  72. package/dist/apps/funding/ProviderAttempts.d.ts +5 -0
  73. package/dist/apps/funding/ProviderAttempts.d.ts.map +1 -0
  74. package/dist/apps/funding/ProviderAttempts.js +24 -0
  75. package/dist/apps/funding/ProviderAttempts.js.map +1 -0
  76. package/dist/apps/funding/index.d.ts +2 -0
  77. package/dist/apps/funding/index.d.ts.map +1 -0
  78. package/dist/apps/funding/index.js +2 -0
  79. package/dist/apps/funding/index.js.map +1 -0
  80. package/dist/apps/funding/routes/chains.d.ts +169 -0
  81. package/dist/apps/funding/routes/chains.d.ts.map +1 -0
  82. package/dist/apps/funding/routes/chains.js +80 -0
  83. package/dist/apps/funding/routes/chains.js.map +1 -0
  84. package/dist/apps/funding/routes/deposit-addresses.d.ts +2388 -0
  85. package/dist/apps/funding/routes/deposit-addresses.d.ts.map +1 -0
  86. package/dist/apps/funding/routes/deposit-addresses.js +860 -0
  87. package/dist/apps/funding/routes/deposit-addresses.js.map +1 -0
  88. package/dist/apps/funding/routes/providers.d.ts +91 -0
  89. package/dist/apps/funding/routes/providers.d.ts.map +1 -0
  90. package/dist/apps/funding/routes/providers.js +56 -0
  91. package/dist/apps/funding/routes/providers.js.map +1 -0
  92. package/dist/apps/funding/routes/quotes.d.ts +426 -0
  93. package/dist/apps/funding/routes/quotes.d.ts.map +1 -0
  94. package/dist/apps/funding/routes/quotes.js +188 -0
  95. package/dist/apps/funding/routes/quotes.js.map +1 -0
  96. package/dist/apps/funding/routes/transfers.d.ts +1999 -0
  97. package/dist/apps/funding/routes/transfers.d.ts.map +1 -0
  98. package/dist/apps/funding/routes/transfers.js +614 -0
  99. package/dist/apps/funding/routes/transfers.js.map +1 -0
  100. package/dist/apps/index.d.ts +1 -0
  101. package/dist/apps/index.d.ts.map +1 -1
  102. package/dist/apps/index.js +1 -0
  103. package/dist/apps/index.js.map +1 -1
  104. package/dist/apps/management/App.d.ts +430 -165
  105. package/dist/apps/management/App.d.ts.map +1 -1
  106. package/dist/apps/management/routes/api-keys.d.ts +298 -21
  107. package/dist/apps/management/routes/api-keys.d.ts.map +1 -1
  108. package/dist/apps/management/routes/api-keys.js +75 -1
  109. package/dist/apps/management/routes/api-keys.js.map +1 -1
  110. package/dist/apps/management/routes/billing.d.ts +6 -6
  111. package/dist/apps/management/routes/faucet.d.ts +35 -35
  112. package/dist/apps/management/routes/invitations.d.ts +48 -48
  113. package/dist/apps/management/routes/invite-links.d.ts +38 -38
  114. package/dist/apps/management/routes/members.d.ts +69 -69
  115. package/dist/apps/management/routes/orgs.d.ts +37 -37
  116. package/dist/apps/management/routes/projects.d.ts +25 -25
  117. package/dist/apps/management/routes/scopes.d.ts +1 -1
  118. package/dist/apps/management/routes/usage.d.ts +72 -72
  119. package/dist/apps/management/routes/verified-token-requests.d.ts +18 -18
  120. package/dist/apps/management/routes/webhooks.d.ts +128 -128
  121. package/dist/apps/mcp.d.ts +2 -3
  122. package/dist/apps/mcp.d.ts.map +1 -1
  123. package/dist/apps/mcp.js +16 -14
  124. package/dist/apps/mcp.js.map +1 -1
  125. package/dist/apps/metadata.d.ts +4 -0
  126. package/dist/apps/metadata.d.ts.map +1 -1
  127. package/dist/apps/metadata.js +10 -0
  128. package/dist/apps/metadata.js.map +1 -1
  129. package/dist/apps/mpp/App.d.ts +2 -2
  130. package/dist/cloudflare.d.ts +50 -0
  131. package/dist/cloudflare.d.ts.map +1 -1
  132. package/dist/cloudflare.js +178 -0
  133. package/dist/cloudflare.js.map +1 -1
  134. package/dist/db/Db.d.ts +2 -0
  135. package/dist/db/Db.d.ts.map +1 -1
  136. package/dist/db/Db.js +37 -0
  137. package/dist/db/Db.js.map +1 -1
  138. package/dist/db/Schema.d.ts +12 -0
  139. package/dist/db/Schema.d.ts.map +1 -1
  140. package/dist/db/Schema.js +20 -0
  141. package/dist/db/Schema.js.map +1 -1
  142. package/dist/db/tables/fundingDepositAddresses.d.ts +13 -0
  143. package/dist/db/tables/fundingDepositAddresses.d.ts.map +1 -1
  144. package/dist/db/tables/fundingDepositAddresses.js +12 -0
  145. package/dist/db/tables/fundingDepositAddresses.js.map +1 -1
  146. package/dist/db/tables/fundingDepositRequestObservations.d.ts +45 -0
  147. package/dist/db/tables/fundingDepositRequestObservations.d.ts.map +1 -0
  148. package/dist/db/tables/fundingDepositRequestObservations.js +46 -0
  149. package/dist/db/tables/fundingDepositRequestObservations.js.map +1 -0
  150. package/dist/db/tables/fundingDeposits.d.ts +57 -1
  151. package/dist/db/tables/fundingDeposits.d.ts.map +1 -1
  152. package/dist/db/tables/fundingDeposits.js +82 -0
  153. package/dist/db/tables/fundingDeposits.js.map +1 -1
  154. package/dist/internal/Auth.d.ts +11 -11
  155. package/dist/internal/Auth.d.ts.map +1 -1
  156. package/dist/internal/Auth.js +41 -15
  157. package/dist/internal/Auth.js.map +1 -1
  158. package/dist/internal/Cache.js +1 -1
  159. package/dist/internal/Cache.js.map +1 -1
  160. package/dist/internal/EdgeCache.d.ts +2 -2
  161. package/dist/internal/EdgeCache.d.ts.map +1 -1
  162. package/dist/internal/EdgeCache.js +10 -4
  163. package/dist/internal/EdgeCache.js.map +1 -1
  164. package/dist/internal/IpAllowlist.d.ts +15 -0
  165. package/dist/internal/IpAllowlist.d.ts.map +1 -0
  166. package/dist/internal/IpAllowlist.js +27 -0
  167. package/dist/internal/IpAllowlist.js.map +1 -0
  168. package/dist/internal/OpenApi.d.ts +1 -1
  169. package/dist/internal/OpenApi.js +1 -1
  170. package/dist/internal/OpenApi.js.map +1 -1
  171. package/dist/internal/Tidx.d.ts +6 -0
  172. package/dist/internal/Tidx.d.ts.map +1 -1
  173. package/dist/internal/Tidx.js +13 -3
  174. package/dist/internal/Tidx.js.map +1 -1
  175. package/dist/internal/Viem.d.ts +8 -0
  176. package/dist/internal/Viem.d.ts.map +1 -1
  177. package/dist/internal/Viem.js +12 -5
  178. package/dist/internal/Viem.js.map +1 -1
  179. package/dist/internal/funding/Deposit.d.ts +42 -0
  180. package/dist/internal/funding/Deposit.d.ts.map +1 -1
  181. package/dist/internal/funding/Deposit.js +37 -5
  182. package/dist/internal/funding/Deposit.js.map +1 -1
  183. package/dist/internal/funding/Evidence.d.ts +1 -5
  184. package/dist/internal/funding/Evidence.d.ts.map +1 -1
  185. package/dist/internal/funding/Evidence.js +4 -3
  186. package/dist/internal/funding/Evidence.js.map +1 -1
  187. package/dist/internal/funding/Provider.d.ts +102 -0
  188. package/dist/internal/funding/Provider.d.ts.map +1 -1
  189. package/dist/internal/funding/Provider.js +5 -0
  190. package/dist/internal/funding/Provider.js.map +1 -1
  191. package/dist/internal/funding/Reconciliation.d.ts +16 -2
  192. package/dist/internal/funding/Reconciliation.d.ts.map +1 -1
  193. package/dist/internal/funding/Reconciliation.js +257 -48
  194. package/dist/internal/funding/Reconciliation.js.map +1 -1
  195. package/dist/internal/funding/SourceObservation.d.ts +108 -0
  196. package/dist/internal/funding/SourceObservation.d.ts.map +1 -0
  197. package/dist/internal/funding/SourceObservation.js +143 -0
  198. package/dist/internal/funding/SourceObservation.js.map +1 -0
  199. package/dist/internal/funding/Subsidy.d.ts +2 -2
  200. package/dist/internal/funding/Subsidy.d.ts.map +1 -1
  201. package/dist/internal/funding/Subsidy.js +1 -1
  202. package/dist/internal/funding/Subsidy.js.map +1 -1
  203. package/dist/internal/funding/index.d.ts +1 -0
  204. package/dist/internal/funding/index.d.ts.map +1 -1
  205. package/dist/internal/funding/index.js +1 -0
  206. package/dist/internal/funding/index.js.map +1 -1
  207. package/dist/internal/funding/providers/relay.d.ts +43 -1
  208. package/dist/internal/funding/providers/relay.d.ts.map +1 -1
  209. package/dist/internal/funding/providers/relay.js +434 -27
  210. package/dist/internal/funding/providers/relay.js.map +1 -1
  211. package/package.json +3 -3
  212. package/src/ApiKey.test.ts +35 -0
  213. package/src/ApiKey.ts +27 -0
  214. package/src/ApiKeys.test.ts +29 -0
  215. package/src/ApiKeys.ts +11 -3
  216. package/src/App.test.ts +23 -5
  217. package/src/App.ts +76 -5
  218. package/src/Client.test-d.ts +171 -19
  219. package/src/admin/apps/api-keys.test.ts +25 -1
  220. package/src/admin/apps/api-keys.ts +6 -2
  221. package/src/admin/ui.generated.ts +67 -67
  222. package/src/analytics/Analytics.ts +4 -0
  223. package/src/apps/composition.test.ts +100 -2
  224. package/src/apps/data/App.test.ts +0 -12
  225. package/src/apps/data/App.ts +4 -8
  226. package/src/apps/data/routes/earn.test.ts +283 -0
  227. package/src/apps/data/routes/earn.ts +800 -3
  228. package/src/apps/data/routes/indexer.ts +2 -1
  229. package/src/apps/data/routes/rpc.ts +2 -0
  230. package/src/apps/data/routes/tokens.test.ts +6 -8
  231. package/src/apps/funding/Access.ts +26 -0
  232. package/src/apps/funding/App.test.ts +78 -0
  233. package/src/apps/funding/App.ts +63 -0
  234. package/src/apps/funding/Inventory.ts +30 -0
  235. package/src/apps/funding/ProviderAttempts.ts +26 -0
  236. package/src/apps/funding/index.ts +1 -0
  237. package/src/apps/funding/routes/chains.test.ts +78 -0
  238. package/src/apps/funding/routes/chains.ts +101 -0
  239. package/src/apps/funding/routes/deposit-addresses.test.ts +1628 -0
  240. package/src/apps/funding/routes/deposit-addresses.ts +1015 -0
  241. package/src/apps/funding/routes/providers.test.ts +165 -0
  242. package/src/apps/funding/routes/providers.ts +73 -0
  243. package/src/apps/funding/routes/quotes.test.ts +511 -0
  244. package/src/apps/funding/routes/quotes.ts +205 -0
  245. package/src/apps/funding/routes/transfers.test.ts +1255 -0
  246. package/src/apps/funding/routes/transfers.ts +704 -0
  247. package/src/apps/index.ts +1 -0
  248. package/src/apps/management/routes/api-keys.test.ts +98 -0
  249. package/src/apps/management/routes/api-keys.ts +96 -1
  250. package/src/apps/mcp.test.ts +17 -2
  251. package/src/apps/mcp.ts +18 -13
  252. package/src/apps/metadata.ts +12 -0
  253. package/src/apps/mpp/internal/Policy.test.ts +7 -1
  254. package/src/cloudflare.ts +279 -0
  255. package/src/db/Db.ts +62 -0
  256. package/src/db/Schema.ts +24 -0
  257. package/src/db/tables/fundingDepositAddresses.ts +28 -0
  258. package/src/db/tables/fundingDepositRequestObservations.test.ts +106 -0
  259. package/src/db/tables/fundingDepositRequestObservations.ts +99 -0
  260. package/src/db/tables/fundingDeposits.test.ts +138 -0
  261. package/src/db/tables/fundingDeposits.ts +145 -1
  262. package/src/internal/Auth.test.ts +177 -5
  263. package/src/internal/Auth.ts +48 -18
  264. package/src/internal/Cache.test.ts +37 -3
  265. package/src/internal/Cache.ts +1 -1
  266. package/src/internal/EdgeCache.test.ts +7 -7
  267. package/src/internal/EdgeCache.ts +13 -4
  268. package/src/internal/IpAllowlist.test.ts +52 -0
  269. package/src/internal/IpAllowlist.ts +39 -0
  270. package/src/internal/Log.test.ts +7 -1
  271. package/src/internal/OpenApi.ts +1 -1
  272. package/src/internal/Tidx.test.ts +28 -0
  273. package/src/internal/Tidx.ts +19 -3
  274. package/src/internal/Viem.test.ts +28 -0
  275. package/src/internal/Viem.ts +21 -5
  276. package/src/internal/funding/Deposit.ts +74 -5
  277. package/src/internal/funding/Evidence.test.ts +2 -2
  278. package/src/internal/funding/Evidence.ts +4 -9
  279. package/src/internal/funding/Provider.ts +115 -0
  280. package/src/internal/funding/Reconciliation.local.test.ts +7 -0
  281. package/src/internal/funding/Reconciliation.test.ts +1052 -35
  282. package/src/internal/funding/Reconciliation.ts +328 -56
  283. package/src/internal/funding/SourceObservation.test.ts +301 -0
  284. package/src/internal/funding/SourceObservation.ts +267 -0
  285. package/src/internal/funding/Subsidy.ts +2 -2
  286. package/src/internal/funding/index.test.ts +1 -0
  287. package/src/internal/funding/index.ts +1 -0
  288. package/src/internal/funding/providers/relay.test.ts +94 -4
  289. package/src/internal/funding/providers/relay.ts +528 -27
  290. package/dist/apps/data/routes/funding.d.ts.map +0 -1
  291. package/dist/apps/data/routes/funding.js +0 -1557
  292. package/dist/apps/data/routes/funding.js.map +0 -1
  293. package/src/apps/data/routes/funding.test.ts +0 -2994
  294. package/src/apps/data/routes/funding.ts +0 -1821
@@ -0,0 +1,1628 @@
1
+ import { Hash, Hex } from 'ox'
2
+
3
+ import { tempoMainnet } from 'viem/tempo/chains'
4
+
5
+ import * as FundingDepositAddresses from '../../../db/tables/fundingDepositAddresses.js'
6
+
7
+ import * as FundingIdempotencyTable from '../../../db/tables/fundingIdempotency.js'
8
+
9
+ import * as Cache from '../../../internal/Cache.js'
10
+
11
+ import * as Cursor from '../../../internal/Cursor.js'
12
+
13
+ import * as FundingDeposit from '../../../internal/funding/Deposit.js'
14
+
15
+ import * as FundingDepositAddress from '../../../internal/funding/DepositAddress.js'
16
+
17
+ import * as FundingProvider from '../../../internal/funding/Provider.js'
18
+
19
+ import * as Idempotency from '../../../internal/Idempotency.js'
20
+
21
+ import * as Across from '../../../internal/funding/providers/across.js'
22
+
23
+ import * as Relay from '../../../internal/funding/providers/relay.js'
24
+
25
+ import * as Rhino from '../../../internal/funding/providers/rhino.js'
26
+
27
+ import * as Squid from '../../../internal/funding/providers/squid.js'
28
+
29
+ import * as Symbiosis from '../../../internal/funding/providers/symbiosis.js'
30
+
31
+ import type * as Log from '../../../internal/Log.js'
32
+
33
+ import * as TestApp from '../../../../test/App.js'
34
+
35
+ import * as TestFunding from '../../../../test/Funding.js'
36
+
37
+ import * as Funding from './deposit-addresses.js'
38
+
39
+ const db = TestApp.database()
40
+ const relayApiKey = process.env.RELAY_API_KEY
41
+
42
+ const providers = [
43
+ Across.across({
44
+ apiKey: 'across-api-key',
45
+ integratorId: '0xdead',
46
+ userAddress: '0x1111111111111111111111111111111111111111',
47
+ }),
48
+ Relay.relay({ userAddress: '0x1111111111111111111111111111111111111111' }),
49
+ Rhino.rhino(),
50
+ Squid.squid({
51
+ integratorId: 'squid-integrator-id',
52
+ userAddress: '0x1111111111111111111111111111111111111111',
53
+ }),
54
+ Symbiosis.symbiosis({ userAddress: '0x1111111111111111111111111111111111111111' }),
55
+ ] as const
56
+
57
+ beforeAll(() => TestFunding.publish(db))
58
+
59
+ function create(options: TestApp.create.Options = {}) {
60
+ return TestApp.create({ ...options, db })
61
+ }
62
+
63
+ describe('POST /funding/deposit-addresses', () => {
64
+ test('requires funding:write and an idempotency key', async () => {
65
+ const app = transfersApp({ providers: [depositAddressProvider()] })
66
+ expect((await postDepositAddress(app, { headers: {} })).status).toBe(401)
67
+ expect((await postDepositAddress(app, { key: fundingReader })).status).toBe(403)
68
+ const response = await postDepositAddress(app, {
69
+ headers: { authorization: `Bearer ${fundingWriter.token}` },
70
+ })
71
+ expect(response.status).toBe(400)
72
+ expect(((await response.json()) as { error: { code: string } }).error.code).toBe(
73
+ 'idempotency_key_required',
74
+ )
75
+ })
76
+
77
+ test('requires a source amount', async () => {
78
+ const { amount: _amount, ...body } = depositAddressBody
79
+ const response = await postDepositAddress(
80
+ transfersApp({ providers: [depositAddressProvider()] }),
81
+ { body },
82
+ )
83
+
84
+ expect({ body: await response.json(), status: response.status }).toMatchInlineSnapshot(
85
+ {
86
+ body: { requestId: expect.any(String) },
87
+ },
88
+ `
89
+ {
90
+ "body": {
91
+ "error": {
92
+ "code": "body_invalid",
93
+ "details": [
94
+ {
95
+ "message": "Invalid input: expected string, received undefined",
96
+ "path": [
97
+ "amount",
98
+ ],
99
+ },
100
+ ],
101
+ "message": "Check the request body and try again.",
102
+ },
103
+ "requestId": Any<String>,
104
+ },
105
+ "status": 400,
106
+ }
107
+ `,
108
+ )
109
+ })
110
+
111
+ test('creates a reusable address and persists private provider correlation', async () => {
112
+ let seen: FundingProvider.createDepositAddress.Parameters | undefined
113
+ const registered: FundingDepositAddress.Public[] = []
114
+ const app = transfersApp({
115
+ funding: {
116
+ registerDepositAddress: async (address) => {
117
+ registered.push(address)
118
+ },
119
+ },
120
+ providers: [
121
+ depositAddressProvider('TBXSw8fM4jpQkGc6zZjsVABFpVN7UvXPdV', (input) => {
122
+ seen = input
123
+ }),
124
+ ],
125
+ })
126
+ const response = await postDepositAddress(app, {
127
+ body: { ...depositAddressBody, destinationToken: 'usdt0', sourceToken: 'usdt' },
128
+ })
129
+ expect(response.status).toBe(200)
130
+ expect(response.headers.get('cache-control')).toBe('no-store')
131
+ const body = await TestApp.json(response, Funding.schema.createFundingDepositAddress.Response)
132
+ expect(body).toMatchObject({
133
+ address: 'TBXSw8fM4jpQkGc6zZjsVABFpVN7UvXPdV',
134
+ destinationToken: { symbol: 'USDT0' },
135
+ provider: { id: 'relay', name: 'Relay' },
136
+ recipient: depositAddressBody.recipient,
137
+ refundAddress: depositAddressBody.refundAddress,
138
+ sourceChain: { id: 'tron:0x2b6653dc' },
139
+ sourceToken: { symbol: 'USDT' },
140
+ status: 'active',
141
+ })
142
+ expect(seen).toMatchObject({
143
+ recipient: depositAddressBody.recipient,
144
+ refundAddress: depositAddressBody.refundAddress,
145
+ })
146
+ const record = await FundingDepositAddresses.get(db, body.id)
147
+ expect(record).toMatchObject({
148
+ deliveryStrategy: 'provider',
149
+ providerOutputToken: body.destinationToken,
150
+ providerRequestIds: ['relay_request_1'],
151
+ providerState: { checkEndpoint: '/intents/status/v3?requestId=relay_request_1' },
152
+ })
153
+ expect(registered).toMatchInlineSnapshot(
154
+ [
155
+ {
156
+ createdAt: expect.any(String),
157
+ id: expect.any(String),
158
+ updatedAt: expect.any(String),
159
+ },
160
+ ],
161
+ `
162
+ [
163
+ {
164
+ "address": "TBXSw8fM4jpQkGc6zZjsVABFpVN7UvXPdV",
165
+ "createdAt": Any<String>,
166
+ "destinationChain": {
167
+ "addressFormat": "hex",
168
+ "id": "eip155:4217",
169
+ "kind": "evm",
170
+ "name": "Tempo",
171
+ },
172
+ "destinationToken": {
173
+ "address": "0x20c00000000000000000000014f22ca97301eb73",
174
+ "currency": "USD",
175
+ "decimals": 6,
176
+ "name": "USDT0",
177
+ "standard": "TIP-20",
178
+ "symbol": "USDT0",
179
+ "tokenKey": "eip155:4217/erc20:0x20c00000000000000000000014f22ca97301eb73",
180
+ "verified": true,
181
+ },
182
+ "id": Any<String>,
183
+ "provider": {
184
+ "id": "relay",
185
+ "name": "Relay",
186
+ },
187
+ "recipient": "0x1111111111111111111111111111111111111111",
188
+ "refundAddress": "TJRabPrwbZy45sbavfcjinPJC18kjpRTv8",
189
+ "sourceChain": {
190
+ "addressFormat": "base58check",
191
+ "id": "tron:0x2b6653dc",
192
+ "kind": "tron",
193
+ "name": "Tron",
194
+ },
195
+ "sourceToken": {
196
+ "address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
197
+ "currency": "USD",
198
+ "decimals": 6,
199
+ "name": "Tether USD",
200
+ "standard": "TRC-20",
201
+ "symbol": "USDT",
202
+ "tokenKey": "tron:0x2b6653dc/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
203
+ "verified": true,
204
+ },
205
+ "status": "active",
206
+ "subsidize": false,
207
+ "updatedAt": Any<String>,
208
+ },
209
+ ]
210
+ `,
211
+ )
212
+ })
213
+
214
+ test('resolves token identifiers through the composed route', async () => {
215
+ const app = transfersApp({
216
+ providers: [depositAddressProvider('T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb')],
217
+ })
218
+ const identifiers = [
219
+ { destinationToken: 'usdt0', name: 'alias', sourceToken: 'usdt' },
220
+ {
221
+ destinationToken: depositAddressBody.destinationToken,
222
+ name: 'address',
223
+ sourceToken: depositAddressBody.sourceToken,
224
+ },
225
+ {
226
+ destinationToken: 'eip155:4217/erc20:0x20c00000000000000000000014f22ca97301eb73',
227
+ name: 'token key',
228
+ sourceToken: 'tron:0x2b6653dc/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t',
229
+ },
230
+ ] as const
231
+ const results = []
232
+ for (const identifier of identifiers) {
233
+ const response = await postDepositAddress(app, {
234
+ body: {
235
+ ...depositAddressBody,
236
+ destinationToken: identifier.destinationToken,
237
+ recipient: `0x${'55'.repeat(20)}`,
238
+ refundAddress: 'TD5gsCwxykWsLN9aPrq2TAfNjByuZKYp4E',
239
+ sourceToken: identifier.sourceToken,
240
+ },
241
+ })
242
+ const body = await TestApp.json(response, Funding.schema.createFundingDepositAddress.Response)
243
+ results.push({
244
+ destinationToken: body.destinationToken.symbol,
245
+ name: identifier.name,
246
+ sourceToken: body.sourceToken.symbol,
247
+ status: response.status,
248
+ })
249
+ }
250
+
251
+ expect(results).toMatchInlineSnapshot(`
252
+ [
253
+ {
254
+ "destinationToken": "USDT0",
255
+ "name": "alias",
256
+ "sourceToken": "USDT",
257
+ "status": 200,
258
+ },
259
+ {
260
+ "destinationToken": "USDT0",
261
+ "name": "address",
262
+ "sourceToken": "USDT",
263
+ "status": 200,
264
+ },
265
+ {
266
+ "destinationToken": "USDT0",
267
+ "name": "token key",
268
+ "sourceToken": "USDT",
269
+ "status": 200,
270
+ },
271
+ ]
272
+ `)
273
+ })
274
+
275
+ test('omits newly quoted terms from a matching reusable address', async () => {
276
+ let calls = 0
277
+ const body = { ...depositAddressBody, refundAddress: 'TD5gsCwxykWsLN9aPrq2TAfNjByuZKYp4E' }
278
+ const app = transfersApp({
279
+ providers: [
280
+ depositAddressProvider('TEdvoHEatmDKvTh3o9vBRB9Vdtbhn4QFhy', () => {
281
+ calls += 1
282
+ }),
283
+ ],
284
+ })
285
+ const first = await TestApp.json(
286
+ await postDepositAddress(app, { body, idempotencyKey: nextDepositAddressKey() }),
287
+ Funding.schema.createFundingDepositAddress.Response,
288
+ )
289
+ const second = await TestApp.json(
290
+ await postDepositAddress(app, {
291
+ body: { ...body, amount: '200000000' },
292
+ idempotencyKey: nextDepositAddressKey(),
293
+ }),
294
+ Funding.schema.createFundingDepositAddress.Response,
295
+ )
296
+ if (!('sourceAmount' in first)) throw new Error('Expected newly provisioned terms.')
297
+
298
+ expect({
299
+ calls,
300
+ firstHasQuote: 'quote' in first,
301
+ firstId: first.id,
302
+ firstSourceAmount: first.sourceAmount.baseUnits,
303
+ secondHasQuote: 'quote' in second,
304
+ secondId: second.id,
305
+ }).toMatchInlineSnapshot(
306
+ {
307
+ firstId: expect.any(String),
308
+ secondId: expect.any(String),
309
+ },
310
+ `
311
+ {
312
+ "calls": 2,
313
+ "firstHasQuote": true,
314
+ "firstId": Any<String>,
315
+ "firstSourceAmount": "100000000",
316
+ "secondHasQuote": false,
317
+ "secondId": Any<String>,
318
+ }
319
+ `,
320
+ )
321
+ expect(second.id).toBe(first.id)
322
+ })
323
+
324
+ test('stores subsidized addresses as a separate reusable identity', async () => {
325
+ const inputs: FundingProvider.createDepositAddress.Parameters[] = []
326
+ const app = transfersApp({
327
+ providers: [
328
+ withCreateDepositAddress('relay', async (input) => {
329
+ inputs.push(input)
330
+ return {
331
+ address: inputs.length === 1 ? `0x${'31'.repeat(20)}` : `0x${'32'.repeat(20)}`,
332
+ correlation: { requestId: `relay_request_${inputs.length}` },
333
+ destinationAmount: '1000000',
334
+ destinationAmountMin: '1000000',
335
+ fees: [],
336
+ sampledAt: '2026-08-04T00:00:00.000Z',
337
+ }
338
+ }),
339
+ ],
340
+ })
341
+ const body = {
342
+ ...depositAddressBody,
343
+ refundAddress: `0x${'33'.repeat(20)}`,
344
+ sourceChain: 'base',
345
+ sourceToken: '0xfde4c96c8593536e31f229ea8f37b2ada2699bb2',
346
+ }
347
+ const standard = await TestApp.json(
348
+ await postDepositAddress(app, { body }),
349
+ Funding.schema.createFundingDepositAddress.Response,
350
+ )
351
+ const subsidized = await TestApp.json(
352
+ await postDepositAddress(app, { body: { ...body, subsidize: true } }),
353
+ Funding.schema.createFundingDepositAddress.Response,
354
+ )
355
+
356
+ expect({
357
+ distinct: standard.id !== subsidized.id,
358
+ inputs: inputs.map(({ subsidize }) => subsidize),
359
+ standard: standard.subsidize,
360
+ subsidized: subsidized.subsidize,
361
+ }).toMatchInlineSnapshot(`
362
+ {
363
+ "distinct": true,
364
+ "inputs": [
365
+ false,
366
+ true,
367
+ ],
368
+ "standard": false,
369
+ "subsidized": true,
370
+ }
371
+ `)
372
+ })
373
+
374
+ test('normalizes EVM accounts before provisioning and matching', async () => {
375
+ const inputs: FundingProvider.createDepositAddress.Parameters[] = []
376
+ const recipient = '0xABcdEFABcdEFabcdEfAbCdefabcdeFABcDEFabCD'
377
+ const refundAddress = '0x1234567890AbcdEF1234567890aBcdef12345678'
378
+ const body = {
379
+ ...depositAddressBody,
380
+ recipient,
381
+ refundAddress,
382
+ sourceChain: 'base',
383
+ sourceToken: '0xfde4c96c8593536e31f229ea8f37b2ada2699bb2',
384
+ }
385
+ const app = transfersApp({
386
+ providers: [
387
+ depositAddressProvider(`0x${'99'.repeat(20)}`, (input) => {
388
+ inputs.push(input)
389
+ }),
390
+ ],
391
+ })
392
+ const first = await TestApp.json(
393
+ await postDepositAddress(app, { body, idempotencyKey: nextDepositAddressKey() }),
394
+ Funding.schema.createFundingDepositAddress.Response,
395
+ )
396
+ const second = await TestApp.json(
397
+ await postDepositAddress(app, {
398
+ body: {
399
+ ...body,
400
+ recipient: recipient.toLowerCase(),
401
+ refundAddress: refundAddress.toLowerCase(),
402
+ },
403
+ idempotencyKey: nextDepositAddressKey(),
404
+ }),
405
+ Funding.schema.createFundingDepositAddress.Response,
406
+ )
407
+
408
+ expect({
409
+ first: { recipient: first.recipient, refundAddress: first.refundAddress },
410
+ inputs: inputs.map(({ recipient, refundAddress }) => ({ recipient, refundAddress })),
411
+ reused: first.id === second.id,
412
+ second: { recipient: second.recipient, refundAddress: second.refundAddress },
413
+ }).toMatchInlineSnapshot(`
414
+ {
415
+ "first": {
416
+ "recipient": "0xABcdEFABcdEFabcdEfAbCdefabcdeFABcDEFabCD",
417
+ "refundAddress": "0x1234567890AbcdEF1234567890aBcdef12345678",
418
+ },
419
+ "inputs": [
420
+ {
421
+ "recipient": "0xABcdEFABcdEFabcdEfAbCdefabcdeFABcDEFabCD",
422
+ "refundAddress": "0x1234567890AbcdEF1234567890aBcdef12345678",
423
+ },
424
+ {
425
+ "recipient": "0xABcdEFABcdEFabcdEfAbCdefabcdeFABcDEFabCD",
426
+ "refundAddress": "0x1234567890AbcdEF1234567890aBcdef12345678",
427
+ },
428
+ ],
429
+ "reused": true,
430
+ "second": {
431
+ "recipient": "0xABcdEFABcdEFabcdEfAbCdefabcdeFABcDEFabCD",
432
+ "refundAddress": "0x1234567890AbcdEF1234567890aBcdef12345678",
433
+ },
434
+ }
435
+ `)
436
+ })
437
+
438
+ test('keeps the stored reusable address when the provider rotates addresses', async () => {
439
+ const body = { ...depositAddressBody, recipient: `0x${'22'.repeat(20)}` }
440
+ const first = await TestApp.json(
441
+ await postDepositAddress(
442
+ transfersApp({
443
+ providers: [depositAddressProvider('TGCAjMXComunWZEXCT1LPBdcYbDVuyexBv')],
444
+ }),
445
+ { body, idempotencyKey: nextDepositAddressKey() },
446
+ ),
447
+ Funding.schema.createFundingDepositAddress.Response,
448
+ )
449
+
450
+ let calls = 0
451
+ const app = transfersApp({
452
+ providers: [
453
+ depositAddressProvider('THkQfRopincF6emzbk6VMC7jTHqJ8MP8g7', () => {
454
+ calls += 1
455
+ }),
456
+ ],
457
+ })
458
+ const idempotencyKey = nextDepositAddressKey()
459
+ const response = await postDepositAddress(app, {
460
+ body: { ...body, amount: '200000000' },
461
+ idempotencyKey,
462
+ })
463
+ const second = await TestApp.json(response, Funding.schema.createFundingDepositAddress.Response)
464
+
465
+ expect({
466
+ address: second.address,
467
+ calls,
468
+ hasQuote: 'quote' in second,
469
+ reused: second.id === first.id,
470
+ status: response.status,
471
+ }).toMatchInlineSnapshot(
472
+ {
473
+ address: expect.any(String),
474
+ },
475
+ `
476
+ {
477
+ "address": Any<String>,
478
+ "calls": 1,
479
+ "hasQuote": false,
480
+ "reused": true,
481
+ "status": 200,
482
+ }
483
+ `,
484
+ )
485
+ expect(second.address).toBe(first.address)
486
+ expect(
487
+ await TestApp.json(
488
+ await postDepositAddress(app, {
489
+ body: { ...body, amount: '200000000' },
490
+ idempotencyKey,
491
+ }),
492
+ Funding.schema.createFundingDepositAddress.Response,
493
+ ),
494
+ ).toEqual(second)
495
+ expect(calls).toBe(1)
496
+ })
497
+
498
+ test('rejects a refund address from another chain', async () => {
499
+ const response = await postDepositAddress(
500
+ transfersApp({ providers: [depositAddressProvider()] }),
501
+ { body: { ...depositAddressBody, refundAddress: `0x${'11'.repeat(20)}` } },
502
+ )
503
+
504
+ expect({ body: await response.json(), status: response.status }).toMatchInlineSnapshot(
505
+ {
506
+ body: { requestId: expect.any(String) },
507
+ },
508
+ `
509
+ {
510
+ "body": {
511
+ "error": {
512
+ "code": "body_invalid",
513
+ "message": "Invalid \`refundAddress\`.",
514
+ },
515
+ "requestId": Any<String>,
516
+ },
517
+ "status": 400,
518
+ }
519
+ `,
520
+ )
521
+ })
522
+
523
+ test('replays matching idempotent requests and rejects changed input', async () => {
524
+ const body = { ...depositAddressBody, refundAddress: 'TKJebW6SdoJhgkKU13BeKCbrMzT6HeQhJ6' }
525
+ const app = transfersApp({
526
+ providers: [depositAddressProvider('TLrtXaP4Yp1AGqrwQLGoHD5yGh4tTAvPbe')],
527
+ })
528
+ const idempotencyKey = nextDepositAddressKey()
529
+ const first = await postDepositAddress(app, { body, idempotencyKey })
530
+ const replay = await postDepositAddress(app, { body, idempotencyKey })
531
+ expect(replay.status).toBe(200)
532
+ expect(await replay.json()).toEqual(await first.json())
533
+
534
+ const conflict = await postDepositAddress(app, {
535
+ body: { ...depositAddressBody, refundAddress: 'TNR8TefgTphcrwQQodMxFDa6BPggc2CZeE' },
536
+ idempotencyKey,
537
+ })
538
+ expect(conflict.status).toBe(409)
539
+ expect(((await conflict.json()) as { error: { code: string } }).error.code).toBe(
540
+ 'idempotency_conflict',
541
+ )
542
+ })
543
+
544
+ test('defaults subsidize for legacy idempotency replays', async () => {
545
+ const app = transfersApp({
546
+ providers: [depositAddressProvider(`0x${'45'.repeat(20)}`)],
547
+ })
548
+ const requestBody = {
549
+ ...depositAddressBody,
550
+ refundAddress: `0x${'44'.repeat(20)}`,
551
+ sourceChain: 'base',
552
+ sourceToken: '0xfde4c96c8593536e31f229ea8f37b2ada2699bb2',
553
+ }
554
+ const idempotencyKey = nextDepositAddressKey()
555
+ const first = await postDepositAddress(app, { body: requestBody, idempotencyKey })
556
+ const response = await TestApp.json(first, Funding.schema.createFundingDepositAddress.Response)
557
+ const { subsidize: _, ...legacy } = response
558
+ await db.kysely
559
+ .updateTable('funding_idempotency_requests')
560
+ .set({ response: JSON.stringify(legacy) })
561
+ .where('apiKeyId', '=', fundingWriter.id)
562
+ .where('keyHash', '=', Idempotency.inputHash(idempotencyKey))
563
+ .returningAll()
564
+ .executeTakeFirstOrThrow()
565
+
566
+ const replay = await postDepositAddress(app, { body: requestBody, idempotencyKey })
567
+ const response_replay = await TestApp.json(
568
+ replay,
569
+ Funding.schema.createFundingDepositAddress.Response,
570
+ )
571
+
572
+ expect({ status: replay.status, subsidize: response_replay.subsidize }).toMatchInlineSnapshot(`
573
+ {
574
+ "status": 200,
575
+ "subsidize": false,
576
+ }
577
+ `)
578
+ })
579
+
580
+ test('preserves the idempotency fingerprint when subsidize is omitted', async () => {
581
+ const idempotencyKey = nextDepositAddressKey()
582
+ await FundingIdempotencyTable.claim(db, {
583
+ apiKeyId: fundingWriter.id,
584
+ keyHash: Idempotency.inputHash(idempotencyKey),
585
+ requestHash: Idempotency.inputHash({
586
+ body: depositAddressBody,
587
+ endpoint: 'POST /v1/funding/deposit-addresses',
588
+ }),
589
+ ttlMs: 60_000,
590
+ })
591
+
592
+ const response = await postDepositAddress(transfersApp(), { idempotencyKey })
593
+
594
+ expect({ body: await response.json(), status: response.status }).toMatchInlineSnapshot(
595
+ {
596
+ body: { requestId: expect.any(String) },
597
+ },
598
+ `
599
+ {
600
+ "body": {
601
+ "error": {
602
+ "code": "idempotency_in_progress",
603
+ "message": "An equivalent funding deposit address creation is still running.",
604
+ },
605
+ "requestId": Any<String>,
606
+ },
607
+ "status": 409,
608
+ }
609
+ `,
610
+ )
611
+ })
612
+
613
+ test('includes the creation endpoint in the idempotency fingerprint', async () => {
614
+ const idempotencyKey = nextDepositAddressKey()
615
+ await FundingIdempotencyTable.claim(db, {
616
+ apiKeyId: fundingWriter.id,
617
+ keyHash: Idempotency.inputHash(idempotencyKey),
618
+ requestHash: Idempotency.inputHash(depositAddressBody),
619
+ ttlMs: 60_000,
620
+ })
621
+
622
+ const response = await postDepositAddress(
623
+ transfersApp({ providers: [depositAddressProvider()] }),
624
+ { idempotencyKey },
625
+ )
626
+ expect(response.status).toBe(409)
627
+ expect(((await response.json()) as { error: { code: string } }).error.code).toBe(
628
+ 'idempotency_conflict',
629
+ )
630
+ })
631
+
632
+ test('returns not available without a registered provider', async () => {
633
+ const response = await postDepositAddress(transfersApp({ providers: [] }))
634
+ expect(response.status).toBe(404)
635
+ expect(((await response.json()) as { error: { code: string } }).error.code).toBe(
636
+ 'deposit_address_not_available',
637
+ )
638
+ })
639
+
640
+ test('reports unexpected provider failures', async () => {
641
+ const entries: Log.Entry[] = []
642
+ const app = transfersApp({
643
+ logger: (entry) => {
644
+ entries.push(entry)
645
+ },
646
+ providers: [
647
+ withCreateDepositAddress('relay', async () => {
648
+ throw new Error('upstream failed')
649
+ }),
650
+ ],
651
+ })
652
+ const response = await postDepositAddress(app, {
653
+ body: { ...depositAddressBody, refundAddress: 'T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb' },
654
+ })
655
+ expect(response.status).toBe(502)
656
+ expect(((await response.json()) as { error: { code: string } }).error.code).toBe(
657
+ 'upstream_error',
658
+ )
659
+ expect(entries.at(-1)).toMatchObject({
660
+ provider: {
661
+ failure: 'unknown',
662
+ id: 'relay',
663
+ operation: 'createDepositAddress',
664
+ },
665
+ status: 502,
666
+ })
667
+ })
668
+ })
669
+
670
+ describe('POST /webhooks/funding/relay', () => {
671
+ test('authenticates and enqueues a bounded reconciliation hint', async () => {
672
+ const secret = 'relay-webhook-secret'
673
+ const address = await seedDepositAddress({
674
+ snapshot: TestFunding.depositAddressSnapshot({
675
+ provider: { id: 'relay', name: 'Relay' },
676
+ recipient: `0x${'99'.repeat(20)}`,
677
+ }),
678
+ })
679
+ const dispatched: FundingProvider.Webhook.receive.Dispatchable[] = []
680
+ const app = transfersApp({
681
+ providers: [
682
+ Relay.relay({
683
+ apiKey: secret,
684
+ webhook: {
685
+ db,
686
+ dispatch: async (message) => {
687
+ dispatched.push(message)
688
+ },
689
+ },
690
+ }),
691
+ ],
692
+ })
693
+ const timestamp = String(Date.now())
694
+ const body = JSON.stringify({
695
+ data: {
696
+ depositAddress: { address: address.address },
697
+ requestId: 'relay-child-request',
698
+ status: 'pending',
699
+ updatedAt: Number(timestamp) - 1_000,
700
+ },
701
+ event: 'request.status.updated',
702
+ timestamp: Number(timestamp),
703
+ })
704
+ const signature = Hash.hmac256(
705
+ Hex.fromString(secret),
706
+ Hex.fromString(`${timestamp}.${body}`),
707
+ ).slice(2)
708
+ const response = await app.request('/webhooks/funding/relay', {
709
+ body,
710
+ headers: {
711
+ 'x-signature-sha256': signature,
712
+ 'x-signature-timestamp': timestamp,
713
+ },
714
+ method: 'POST',
715
+ })
716
+
717
+ expect({ dispatched, status: response.status }).toMatchInlineSnapshot(
718
+ {
719
+ dispatched: [
720
+ {
721
+ webhookTiming: {
722
+ enqueuedAt: expect.any(String),
723
+ providerUpdatedAt: expect.any(String),
724
+ receivedAt: expect.any(String),
725
+ sentAt: expect.any(String),
726
+ },
727
+ },
728
+ ],
729
+ },
730
+ `
731
+ {
732
+ "dispatched": [
733
+ {
734
+ "addressId": "${address.id}",
735
+ "requestId": "relay-child-request",
736
+ "trigger": "webhook",
737
+ "type": "funding:deposit-address:reconcile",
738
+ "webhookTiming": {
739
+ "enqueuedAt": Any<String>,
740
+ "providerStatus": "pending",
741
+ "providerUpdatedAt": Any<String>,
742
+ "receivedAt": Any<String>,
743
+ "sentAt": Any<String>,
744
+ },
745
+ },
746
+ ],
747
+ "status": 204,
748
+ }
749
+ `,
750
+ )
751
+ })
752
+
753
+ test('rejects unauthenticated events', async () => {
754
+ const app = transfersApp({
755
+ providers: [
756
+ Relay.relay({
757
+ apiKey: 'relay-webhook-secret',
758
+ webhook: { db, dispatch: async () => {} },
759
+ }),
760
+ ],
761
+ })
762
+ const response = await app.request('/webhooks/funding/relay', {
763
+ body: '{}',
764
+ method: 'POST',
765
+ })
766
+
767
+ expect(response.status).toMatchInlineSnapshot('401')
768
+ })
769
+ })
770
+
771
+ describe.skipIf(!relayApiKey)('POST /funding/deposit-addresses (relay)', () => {
772
+ test('provisions and persists a reusable address through Relay', async () => {
773
+ const app = transfersApp({
774
+ providers: [Relay.relay({ apiKey: relayApiKey })],
775
+ })
776
+ const response = await postDepositAddress(app, {
777
+ body: { ...depositAddressBody, recipient: `0x${'77'.repeat(20)}` },
778
+ })
779
+
780
+ expect(response.status).toBe(200)
781
+ const body = await TestApp.json(response, Funding.schema.createFundingDepositAddress.Response)
782
+ expect(body).toMatchInlineSnapshot(
783
+ {
784
+ address: expect.any(String),
785
+ createdAt: expect.any(String),
786
+ destinationAmount: {
787
+ baseUnits: expect.any(String),
788
+ formatted: expect.any(String),
789
+ },
790
+ destinationAmountMin: {
791
+ baseUnits: expect.any(String),
792
+ formatted: expect.any(String),
793
+ },
794
+ fees: expect.any(Array),
795
+ id: expect.any(String),
796
+ quote: { sampledAt: expect.any(String) },
797
+ updatedAt: expect.any(String),
798
+ },
799
+ `
800
+ {
801
+ "address": Any<String>,
802
+ "createdAt": Any<String>,
803
+ "destinationAmount": {
804
+ "baseUnits": Any<String>,
805
+ "currency": "USD",
806
+ "decimals": 6,
807
+ "formatted": Any<String>,
808
+ },
809
+ "destinationAmountMin": {
810
+ "baseUnits": Any<String>,
811
+ "currency": "USD",
812
+ "decimals": 6,
813
+ "formatted": Any<String>,
814
+ },
815
+ "destinationChain": {
816
+ "addressFormat": "hex",
817
+ "id": "eip155:4217",
818
+ "kind": "evm",
819
+ "name": "Tempo",
820
+ },
821
+ "destinationToken": {
822
+ "address": "0x20c00000000000000000000014f22ca97301eb73",
823
+ "currency": "USD",
824
+ "decimals": 6,
825
+ "name": "USDT0",
826
+ "standard": "TIP-20",
827
+ "symbol": "USDT0",
828
+ "tokenKey": "eip155:4217/erc20:0x20c00000000000000000000014f22ca97301eb73",
829
+ "verified": true,
830
+ },
831
+ "fees": Any<Array>,
832
+ "id": Any<String>,
833
+ "provider": {
834
+ "id": "relay",
835
+ "name": "Relay",
836
+ },
837
+ "quote": {
838
+ "sampledAt": Any<String>,
839
+ },
840
+ "recipient": "0x7777777777777777777777777777777777777777",
841
+ "refundAddress": "TJRabPrwbZy45sbavfcjinPJC18kjpRTv8",
842
+ "sourceAmount": {
843
+ "baseUnits": "100000000",
844
+ "currency": "USD",
845
+ "decimals": 6,
846
+ "formatted": "100",
847
+ },
848
+ "sourceChain": {
849
+ "addressFormat": "base58check",
850
+ "id": "tron:0x2b6653dc",
851
+ "kind": "tron",
852
+ "name": "Tron",
853
+ },
854
+ "sourceToken": {
855
+ "address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
856
+ "currency": "USD",
857
+ "decimals": 6,
858
+ "name": "Tether USD",
859
+ "standard": "TRC-20",
860
+ "symbol": "USDT",
861
+ "tokenKey": "tron:0x2b6653dc/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
862
+ "verified": true,
863
+ },
864
+ "status": "active",
865
+ "subsidize": false,
866
+ "updatedAt": Any<String>,
867
+ }
868
+ `,
869
+ )
870
+ const record = await FundingDepositAddresses.get(db, body.id)
871
+ expect(record).toMatchInlineSnapshot(
872
+ {
873
+ address: expect.any(String),
874
+ createdAt: expect.any(String),
875
+ id: expect.any(String),
876
+ nextPollAt: expect.any(String),
877
+ providerRequestIds: [expect.any(String)],
878
+ providerState: expect.any(Object),
879
+ snapshot: { address: expect.any(String) },
880
+ updatedAt: expect.any(String),
881
+ },
882
+ `
883
+ {
884
+ "address": Any<String>,
885
+ "apiKeyId": "key_funding_writer",
886
+ "createdAt": Any<String>,
887
+ "deliveryStrategy": "provider",
888
+ "destinationTokenKey": "eip155:4217/erc20:0x20c00000000000000000000014f22ca97301eb73",
889
+ "environment": "production",
890
+ "id": Any<String>,
891
+ "lastPolledAt": null,
892
+ "nextPollAt": Any<String>,
893
+ "orgId": "org_funding",
894
+ "pollFailureCount": 0,
895
+ "pollLeaseUntil": null,
896
+ "pollLeaseVersion": 0,
897
+ "projectId": null,
898
+ "providerId": "relay",
899
+ "providerOutputToken": {
900
+ "address": "0x20c00000000000000000000014f22ca97301eb73",
901
+ "currency": "USD",
902
+ "decimals": 6,
903
+ "name": "USDT0",
904
+ "standard": "TIP-20",
905
+ "symbol": "USDT0",
906
+ "tokenKey": "eip155:4217/erc20:0x20c00000000000000000000014f22ca97301eb73",
907
+ "verified": true,
908
+ },
909
+ "providerRequestIds": [
910
+ Any<String>,
911
+ ],
912
+ "providerState": Any<Object>,
913
+ "recipient": "0x7777777777777777777777777777777777777777",
914
+ "refundAddress": "TJRabPrwbZy45sbavfcjinPJC18kjpRTv8",
915
+ "snapshot": {
916
+ "address": Any<String>,
917
+ "destinationChain": {
918
+ "addressFormat": "hex",
919
+ "id": "eip155:4217",
920
+ "kind": "evm",
921
+ "name": "Tempo",
922
+ },
923
+ "destinationToken": {
924
+ "address": "0x20c00000000000000000000014f22ca97301eb73",
925
+ "currency": "USD",
926
+ "decimals": 6,
927
+ "name": "USDT0",
928
+ "standard": "TIP-20",
929
+ "symbol": "USDT0",
930
+ "tokenKey": "eip155:4217/erc20:0x20c00000000000000000000014f22ca97301eb73",
931
+ "verified": true,
932
+ },
933
+ "provider": {
934
+ "id": "relay",
935
+ "name": "Relay",
936
+ },
937
+ "recipient": "0x7777777777777777777777777777777777777777",
938
+ "refundAddress": "TJRabPrwbZy45sbavfcjinPJC18kjpRTv8",
939
+ "sourceChain": {
940
+ "addressFormat": "base58check",
941
+ "id": "tron:0x2b6653dc",
942
+ "kind": "tron",
943
+ "name": "Tron",
944
+ },
945
+ "sourceToken": {
946
+ "address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
947
+ "currency": "USD",
948
+ "decimals": 6,
949
+ "name": "Tether USD",
950
+ "standard": "TRC-20",
951
+ "symbol": "USDT",
952
+ "tokenKey": "tron:0x2b6653dc/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
953
+ "verified": true,
954
+ },
955
+ "subsidize": false,
956
+ },
957
+ "sourceChainId": "tron:0x2b6653dc",
958
+ "sourceTokenKey": "tron:0x2b6653dc/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
959
+ "status": "active",
960
+ "subsidize": false,
961
+ "updatedAt": Any<String>,
962
+ "version": 1,
963
+ }
964
+ `,
965
+ )
966
+ })
967
+ })
968
+
969
+ describe('GET /funding/deposit-addresses/:id', () => {
970
+ test('requires funding:read and returns the owned address', async () => {
971
+ const app = transfersApp()
972
+ const record = await seedDepositAddress()
973
+ const path = `/v1/funding/deposit-addresses/${record.id}`
974
+ expect((await app.request(path)).status).toBe(401)
975
+ expect((await app.request(path, as(TestApp.key))).status).toBe(403)
976
+
977
+ const response = await app.request(path, as(fundingReader))
978
+ expect(response.status).toBe(200)
979
+ expect({
980
+ cacheControl: response.headers.get('cache-control'),
981
+ etag: response.headers.get('etag'),
982
+ }).toMatchInlineSnapshot(`
983
+ {
984
+ "cacheControl": "no-store",
985
+ "etag": null,
986
+ }
987
+ `)
988
+ expect(response.headers.get('vary')).toBe(
989
+ 'Accept-Encoding, Authorization, Tempo-API-Key, X-API-Key',
990
+ )
991
+ const body = await TestApp.json(response, Funding.schema.getFundingDepositAddress.Response)
992
+ expect(body).toEqual(FundingDepositAddress.toPublic(record))
993
+ expect(body).not.toHaveProperty('providerOutputToken')
994
+ expect(body).not.toHaveProperty('providerRequestIds')
995
+ })
996
+
997
+ test('hides foreign, project-inaccessible, and absent addresses', async () => {
998
+ const app = transfersApp()
999
+ const record = await seedDepositAddress()
1000
+ const path = `/v1/funding/deposit-addresses/${record.id}`
1001
+
1002
+ for (const key of [fundingForeign, fundingProjectReader]) {
1003
+ const response = await app.request(path, as(key))
1004
+ expect(response.status).toBe(404)
1005
+ expect(((await response.json()) as { error: { code: string } }).error.code).toBe(
1006
+ 'funding_deposit_address_not_found',
1007
+ )
1008
+ }
1009
+
1010
+ const absent = await app.request(
1011
+ '/v1/funding/deposit-addresses/fda_000000000000000_000000000000000000000000',
1012
+ as(fundingReader),
1013
+ )
1014
+ expect(absent.status).toBe(404)
1015
+ })
1016
+ })
1017
+
1018
+ describe('POST /funding/deposit-addresses/:id/reconcile', () => {
1019
+ test('requires funding:write and queues the owned active address', async () => {
1020
+ const dispatched: FundingProvider.Webhook.receive.Dispatchable[] = []
1021
+ const app = transfersApp({
1022
+ funding: {
1023
+ dispatchReconciliation: async (message) => {
1024
+ dispatched.push(message)
1025
+ },
1026
+ },
1027
+ })
1028
+ const record = await seedDepositAddress()
1029
+ const path = `/v1/funding/deposit-addresses/${record.id}/reconcile`
1030
+ expect((await app.request(path, { method: 'POST' })).status).toBe(401)
1031
+ expect((await app.request(path, { ...as(fundingReader), method: 'POST' })).status).toBe(403)
1032
+
1033
+ const response = await app.request(path, { ...as(fundingWriter), method: 'POST' })
1034
+ expect({
1035
+ body: await response.text(),
1036
+ cacheControl: response.headers.get('cache-control'),
1037
+ dispatched,
1038
+ rateLimit: {
1039
+ limit: response.headers.get('ratelimit-limit'),
1040
+ remaining: response.headers.get('ratelimit-remaining'),
1041
+ scope: response.headers.get('ratelimit-scope'),
1042
+ },
1043
+ status: response.status,
1044
+ vary: response.headers.get('vary'),
1045
+ }).toMatchInlineSnapshot(`
1046
+ {
1047
+ "body": "",
1048
+ "cacheControl": "no-store",
1049
+ "dispatched": [
1050
+ {
1051
+ "addressId": "${record.id}",
1052
+ "trigger": "manual",
1053
+ "type": "funding:deposit-address:reconcile",
1054
+ },
1055
+ ],
1056
+ "rateLimit": {
1057
+ "limit": "10",
1058
+ "remaining": "9",
1059
+ "scope": "funding:write",
1060
+ },
1061
+ "status": 202,
1062
+ "vary": "Accept-Encoding, Authorization, Tempo-API-Key",
1063
+ }
1064
+ `)
1065
+ expect(await FundingDepositAddresses.get(db, record.id)).toEqual(record)
1066
+ })
1067
+
1068
+ test('hides foreign, project-inaccessible, and absent addresses', async () => {
1069
+ const dispatched: FundingProvider.Webhook.receive.Dispatchable[] = []
1070
+ const app = transfersApp({
1071
+ funding: {
1072
+ dispatchReconciliation: async (message) => {
1073
+ dispatched.push(message)
1074
+ },
1075
+ },
1076
+ })
1077
+ const record = await seedDepositAddress()
1078
+ const path = `/v1/funding/deposit-addresses/${record.id}/reconcile`
1079
+
1080
+ for (const key of [fundingForeignWriter, fundingProjectWriter]) {
1081
+ const response = await app.request(path, { ...as(key), method: 'POST' })
1082
+ expect(response.status).toBe(404)
1083
+ expect(((await response.json()) as { error: { code: string } }).error.code).toBe(
1084
+ 'funding_deposit_address_not_found',
1085
+ )
1086
+ }
1087
+
1088
+ const absent = await app.request(
1089
+ '/v1/funding/deposit-addresses/fda_000000000000000_000000000000000000000000/reconcile',
1090
+ { ...as(fundingWriter), method: 'POST' },
1091
+ )
1092
+ expect(absent.status).toBe(404)
1093
+ expect(dispatched).toEqual([])
1094
+ })
1095
+
1096
+ test('rejects inactive addresses and unavailable reconciliation', async () => {
1097
+ const dispatched: FundingProvider.Webhook.receive.Dispatchable[] = []
1098
+ const configured = transfersApp({
1099
+ funding: {
1100
+ dispatchReconciliation: async (message) => {
1101
+ dispatched.push(message)
1102
+ },
1103
+ },
1104
+ })
1105
+ const record = await seedDepositAddress()
1106
+ const transition = await FundingDepositAddress.transition(db, {
1107
+ expectedVersion: record.version,
1108
+ id: record.id,
1109
+ status: 'action-required',
1110
+ })
1111
+ expect(transition.type).toBe('applied')
1112
+
1113
+ const inactive = await configured.request(
1114
+ `/v1/funding/deposit-addresses/${record.id}/reconcile`,
1115
+ { ...as(fundingWriter), method: 'POST' },
1116
+ )
1117
+ expect({
1118
+ code: ((await inactive.json()) as { error: { code: string } }).error.code,
1119
+ status: inactive.status,
1120
+ }).toEqual({ code: 'funding_deposit_address_not_active', status: 409 })
1121
+
1122
+ const active = await seedDepositAddress()
1123
+ const unavailable = await transfersApp().request(
1124
+ `/v1/funding/deposit-addresses/${active.id}/reconcile`,
1125
+ { ...as(fundingWriter), method: 'POST' },
1126
+ )
1127
+ expect({
1128
+ code: ((await unavailable.json()) as { error: { code: string } }).error.code,
1129
+ status: unavailable.status,
1130
+ }).toEqual({ code: 'not_implemented', status: 501 })
1131
+ expect(dispatched).toEqual([])
1132
+ })
1133
+
1134
+ test('returns an upstream error when the queue rejects the message', async () => {
1135
+ const app = transfersApp({
1136
+ funding: {
1137
+ dispatchReconciliation: async () => {
1138
+ throw new Error('queue unavailable')
1139
+ },
1140
+ },
1141
+ })
1142
+ const record = await seedDepositAddress()
1143
+ const response = await app.request(`/v1/funding/deposit-addresses/${record.id}/reconcile`, {
1144
+ ...as(fundingWriter),
1145
+ method: 'POST',
1146
+ })
1147
+
1148
+ expect({
1149
+ code: ((await response.json()) as { error: { code: string } }).error.code,
1150
+ status: response.status,
1151
+ }).toEqual({ code: 'upstream_error', status: 502 })
1152
+ })
1153
+ })
1154
+
1155
+ describe('GET /funding/deposit-addresses/:id/deposits', () => {
1156
+ test('pages deposits newest-first under an owned address', async () => {
1157
+ const app = transfersApp()
1158
+ const address = await seedDepositAddress()
1159
+ const first = await seedDeposit(address)
1160
+ const second = await seedDeposit(address)
1161
+ const third = await seedDeposit(address)
1162
+ const fourth = await seedDeposit(address)
1163
+ const fifth = await seedDeposit(address)
1164
+ const sixth = await seedDeposit(address)
1165
+
1166
+ const response = await app.request(
1167
+ `/v1/funding/deposit-addresses/${address.id}/deposits?limit=5`,
1168
+ as(fundingReader),
1169
+ )
1170
+ expect(response.status).toBe(200)
1171
+ expect({
1172
+ cacheControl: response.headers.get('cache-control'),
1173
+ etag: response.headers.get('etag'),
1174
+ }).toMatchInlineSnapshot(`
1175
+ {
1176
+ "cacheControl": "no-store",
1177
+ "etag": null,
1178
+ }
1179
+ `)
1180
+ const body = await TestApp.json(
1181
+ response,
1182
+ Funding.schema.listFundingDepositAddressDeposits.Response,
1183
+ )
1184
+ expect(body.data.map((deposit) => deposit.id)).toEqual([
1185
+ sixth.id,
1186
+ fifth.id,
1187
+ fourth.id,
1188
+ third.id,
1189
+ second.id,
1190
+ ])
1191
+ expect(body.nextCursor).toBe(Cursor.encode([second.createdAt, second.id]))
1192
+
1193
+ const next = await TestApp.json(
1194
+ await app.request(
1195
+ `/v1/funding/deposit-addresses/${address.id}/deposits?limit=5&cursor=${body.nextCursor}`,
1196
+ as(fundingReader),
1197
+ ),
1198
+ Funding.schema.listFundingDepositAddressDeposits.Response,
1199
+ )
1200
+ expect(next.data.map((deposit) => deposit.id)).toEqual([first.id])
1201
+ expect(next.nextCursor).toBeNull()
1202
+ })
1203
+
1204
+ test('returns a provider-observed deposit before source verification', async () => {
1205
+ const app = transfersApp()
1206
+ const address = await seedDepositAddress({
1207
+ snapshot: TestFunding.depositAddressSnapshot({
1208
+ provider: { id: 'relay_observed', name: 'Relay' },
1209
+ recipient: `0x${'31'.repeat(20)}`,
1210
+ }),
1211
+ })
1212
+ const sourceTransactionHash = `0x${'ab'.repeat(32)}`
1213
+ await FundingDeposit.create(db, {
1214
+ now: new Date('2026-01-04T00:00:00.000Z'),
1215
+ providerRequestId: 'relay-observed-request',
1216
+ snapshot: {
1217
+ depositAddressId: address.id,
1218
+ destinationChain: address.snapshot.destinationChain,
1219
+ destinationToken: address.snapshot.destinationToken,
1220
+ provider: address.snapshot.provider,
1221
+ recipient: address.recipient,
1222
+ refundAddress: address.refundAddress,
1223
+ sourceChain: address.snapshot.sourceChain,
1224
+ sourceToken: address.snapshot.sourceToken,
1225
+ sourceTransactionHashes: [sourceTransactionHash],
1226
+ },
1227
+ sourceTransactionHash,
1228
+ })
1229
+
1230
+ const response = await app.request(
1231
+ `/v1/funding/deposit-addresses/${address.id}/deposits`,
1232
+ as(fundingReader),
1233
+ )
1234
+ expect(response.status).toBe(200)
1235
+ const body = await TestApp.json(
1236
+ response,
1237
+ Funding.schema.listFundingDepositAddressDeposits.Response,
1238
+ )
1239
+ expect(body).toMatchInlineSnapshot(
1240
+ {
1241
+ data: [
1242
+ {
1243
+ createdAt: expect.any(String),
1244
+ depositAddressId: expect.any(String),
1245
+ id: expect.any(String),
1246
+ updatedAt: expect.any(String),
1247
+ },
1248
+ ],
1249
+ },
1250
+ `
1251
+ {
1252
+ "data": [
1253
+ {
1254
+ "createdAt": Any<String>,
1255
+ "depositAddressId": Any<String>,
1256
+ "destinationChain": {
1257
+ "addressFormat": "hex",
1258
+ "id": "eip155:4217",
1259
+ "kind": "evm",
1260
+ "name": "Tempo",
1261
+ },
1262
+ "destinationToken": {
1263
+ "address": "0x20c00000000000000000000014f22ca97301eb73",
1264
+ "currency": "USD",
1265
+ "decimals": 6,
1266
+ "name": "USDT0",
1267
+ "standard": "TIP-20",
1268
+ "symbol": "USDT0",
1269
+ "tokenKey": "eip155:4217/erc20:0x20c00000000000000000000014f22ca97301eb73",
1270
+ "verified": true,
1271
+ },
1272
+ "id": Any<String>,
1273
+ "provider": {
1274
+ "id": "relay_observed",
1275
+ "name": "Relay",
1276
+ },
1277
+ "recipient": "0x3131313131313131313131313131313131313131",
1278
+ "refundAddress": "TJRabPrwbZy45sbavfcjinPJC18kjpRTv8",
1279
+ "sourceChain": {
1280
+ "addressFormat": "base58check",
1281
+ "id": "tron:0x2b6653dc",
1282
+ "kind": "tron",
1283
+ "name": "Tron",
1284
+ },
1285
+ "sourceToken": {
1286
+ "address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
1287
+ "currency": "USD",
1288
+ "decimals": 6,
1289
+ "name": "Tether USD",
1290
+ "standard": "TRC-20",
1291
+ "symbol": "USDT",
1292
+ "tokenKey": "tron:0x2b6653dc/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
1293
+ "verified": true,
1294
+ },
1295
+ "sourceTransactionHashes": [
1296
+ "0xabababababababababababababababababababababababababababababababab",
1297
+ ],
1298
+ "status": "detected",
1299
+ "updatedAt": Any<String>,
1300
+ },
1301
+ ],
1302
+ "nextCursor": null,
1303
+ }
1304
+ `,
1305
+ )
1306
+ })
1307
+
1308
+ test('returns the parent not-found error before listing deposits', async () => {
1309
+ const app = transfersApp()
1310
+ const address = await seedDepositAddress({ orgId: fundingForeign.orgId })
1311
+ const response = await app.request(
1312
+ `/v1/funding/deposit-addresses/${address.id}/deposits`,
1313
+ as(fundingReader),
1314
+ )
1315
+ expect(response.status).toBe(404)
1316
+ expect(((await response.json()) as { error: { code: string } }).error.code).toBe(
1317
+ 'funding_deposit_address_not_found',
1318
+ )
1319
+ })
1320
+ })
1321
+
1322
+ describe('GET /funding/deposits', () => {
1323
+ test('requires funding:read and pages recipient deposits across addresses', async () => {
1324
+ const app = transfersApp()
1325
+ const recipient = '0xABcdEFABcdEFabcdEfAbCdefabcdeFABcDEFabCD'
1326
+ const path = `/v1/funding/deposits?recipient=${recipient.toLowerCase()}`
1327
+ expect((await app.request(path)).status).toBe(401)
1328
+ expect((await app.request(path, as(TestApp.key))).status).toBe(403)
1329
+ expect((await app.request('/v1/funding/deposits', as(fundingReader))).status).toBe(400)
1330
+
1331
+ const firstAddress = await seedDepositAddress({
1332
+ snapshot: TestFunding.depositAddressSnapshot({
1333
+ provider: { id: 'relay_account_first', name: 'Relay' },
1334
+ recipient,
1335
+ }),
1336
+ })
1337
+ const secondAddress = await seedDepositAddress({
1338
+ snapshot: TestFunding.depositAddressSnapshot({
1339
+ provider: { id: 'relay_account_second', name: 'Relay' },
1340
+ recipient,
1341
+ subsidize: true,
1342
+ }),
1343
+ })
1344
+ const first = await seedDeposit(firstAddress)
1345
+ const second = await seedDeposit(secondAddress)
1346
+ const third = await seedDeposit(firstAddress)
1347
+ const fourth = await seedDeposit(secondAddress)
1348
+ const fifth = await seedDeposit(firstAddress)
1349
+ const sixth = await seedDeposit(secondAddress)
1350
+ const unrelatedAddress = await seedDepositAddress()
1351
+ await seedDeposit(unrelatedAddress)
1352
+ const foreignAddress = await seedDepositAddress({
1353
+ orgId: fundingForeign.orgId,
1354
+ snapshot: TestFunding.depositAddressSnapshot({
1355
+ provider: { id: 'relay_account_foreign', name: 'Relay' },
1356
+ recipient,
1357
+ }),
1358
+ })
1359
+ await seedDeposit(foreignAddress)
1360
+
1361
+ const response = await app.request(`${path}&limit=5`, as(fundingReader))
1362
+ expect(response.status).toBe(200)
1363
+ expect({
1364
+ cacheControl: response.headers.get('cache-control'),
1365
+ etag: response.headers.get('etag'),
1366
+ vary: response.headers.get('vary'),
1367
+ }).toMatchInlineSnapshot(`
1368
+ {
1369
+ "cacheControl": "no-store",
1370
+ "etag": null,
1371
+ "vary": "Accept-Encoding, Authorization, Tempo-API-Key",
1372
+ }
1373
+ `)
1374
+ const body = await TestApp.json(response, Funding.schema.listFundingDeposits.Response)
1375
+ expect(body.data.map((deposit) => deposit.id)).toEqual([
1376
+ sixth.id,
1377
+ fifth.id,
1378
+ fourth.id,
1379
+ third.id,
1380
+ second.id,
1381
+ ])
1382
+ expect(body.nextCursor).toBe(Cursor.encode([second.createdAt, second.id]))
1383
+
1384
+ const next = await TestApp.json(
1385
+ await app.request(`${path}&limit=5&cursor=${body.nextCursor}`, as(fundingReader)),
1386
+ Funding.schema.listFundingDeposits.Response,
1387
+ )
1388
+ expect(next.data.map((deposit) => deposit.id)).toEqual([first.id])
1389
+ expect(next.nextCursor).toBeNull()
1390
+ })
1391
+
1392
+ test('narrows project-attributed keys to their project', async () => {
1393
+ const app = transfersApp()
1394
+ const recipient = `0x${'ab'.repeat(20)}`
1395
+ const projectAddress = await seedDepositAddress({
1396
+ projectId: fundingProjectReader.projectId,
1397
+ snapshot: TestFunding.depositAddressSnapshot({
1398
+ provider: { id: 'relay_account_project', name: 'Relay' },
1399
+ recipient,
1400
+ }),
1401
+ })
1402
+ const siblingAddress = await seedDepositAddress({
1403
+ projectId: 'prj_funding_other',
1404
+ snapshot: TestFunding.depositAddressSnapshot({
1405
+ provider: { id: 'relay_account_sibling', name: 'Relay' },
1406
+ recipient,
1407
+ }),
1408
+ })
1409
+ const projectDeposit = await seedDeposit(projectAddress)
1410
+ await seedDeposit(siblingAddress)
1411
+
1412
+ const body = await TestApp.json(
1413
+ await app.request(`/v1/funding/deposits?recipient=${recipient}`, as(fundingProjectReader)),
1414
+ Funding.schema.listFundingDeposits.Response,
1415
+ )
1416
+ expect(body.data.map((deposit) => deposit.id)).toEqual([projectDeposit.id])
1417
+ })
1418
+ })
1419
+
1420
+ describe('GET /funding/deposits/:id', () => {
1421
+ test('returns the owned deposit without private reconciliation state', async () => {
1422
+ const app = transfersApp()
1423
+ const address = await seedDepositAddress()
1424
+ const record = await seedDeposit(address, {
1425
+ providerRequestIds: ['relay-private'],
1426
+ providerState: { request: 'private' },
1427
+ })
1428
+ const response = await app.request(`/v1/funding/deposits/${record.id}`, as(fundingReader))
1429
+ expect(response.status).toBe(200)
1430
+ expect(response.headers.get('cache-control')).toBe(Cache.policies.state)
1431
+ const body = await TestApp.json(response, Funding.schema.getFundingDeposit.Response)
1432
+ expect(body).toEqual(FundingDeposit.toPublic(record))
1433
+ expect(body).not.toHaveProperty('providerRequestIds')
1434
+ expect(body).not.toHaveProperty('providerState')
1435
+ })
1436
+
1437
+ test('hides foreign and absent deposits', async () => {
1438
+ const app = transfersApp()
1439
+ const address = await seedDepositAddress()
1440
+ const record = await seedDeposit(address)
1441
+ expect(
1442
+ (await app.request(`/v1/funding/deposits/${record.id}`, as(fundingForeign))).status,
1443
+ ).toBe(404)
1444
+ expect(
1445
+ (
1446
+ await app.request(
1447
+ '/v1/funding/deposits/fdp_000000000000000_000000000000000000000000',
1448
+ as(fundingReader),
1449
+ )
1450
+ ).status,
1451
+ ).toBe(404)
1452
+ })
1453
+ })
1454
+
1455
+ const depositAddressBody = {
1456
+ amount: '100000000',
1457
+ destinationToken: '0x20c00000000000000000000014f22ca97301eb73',
1458
+ recipient: `0x${'11'.repeat(20)}`,
1459
+ refundAddress: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8',
1460
+ sourceChain: 'tron',
1461
+ sourceToken: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t',
1462
+ }
1463
+
1464
+ let depositAddressIdempotencySequence = 0
1465
+
1466
+ function nextDepositAddressKey() {
1467
+ return `funding_deposit_address_${depositAddressIdempotencySequence++}`
1468
+ }
1469
+
1470
+ function postDepositAddress(
1471
+ app: ReturnType<typeof create>,
1472
+ options: {
1473
+ body?: unknown
1474
+ headers?: Record<string, string> | undefined
1475
+ idempotencyKey?: string | undefined
1476
+ key?: { token: string } | undefined
1477
+ } = {},
1478
+ ) {
1479
+ return app.request('/v1/funding/deposit-addresses', {
1480
+ body: JSON.stringify(options.body ?? depositAddressBody),
1481
+ headers: options.headers ?? {
1482
+ authorization: `Bearer ${(options.key ?? fundingWriter).token}`,
1483
+ 'content-type': 'application/json',
1484
+ 'idempotency-key': options.idempotencyKey ?? nextDepositAddressKey(),
1485
+ },
1486
+ method: 'POST',
1487
+ })
1488
+ }
1489
+
1490
+ function withCreateDepositAddress(
1491
+ id: FundingProvider.ProviderId,
1492
+ createDepositAddress: NonNullable<FundingProvider.Provider['createDepositAddress']>,
1493
+ ) {
1494
+ const provider = providers.find((entry) => entry.id === id)
1495
+ if (!provider) throw new Error(`Missing ${id} provider`)
1496
+ return FundingProvider.from({ ...provider, createDepositAddress })
1497
+ }
1498
+
1499
+ function depositAddressProvider(
1500
+ address = 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8',
1501
+ onCreate: (input: FundingProvider.createDepositAddress.Parameters) => void = () => {},
1502
+ ) {
1503
+ return withCreateDepositAddress('relay', async (input) => {
1504
+ onCreate(input)
1505
+ return {
1506
+ address,
1507
+ correlation: {
1508
+ checkEndpoint: '/intents/status/v3?requestId=relay_request_1',
1509
+ requestId: 'relay_request_1',
1510
+ },
1511
+ destinationAmount: '1000000',
1512
+ destinationAmountMin: '1000000',
1513
+ fees: [],
1514
+ sampledAt: '2026-08-04T00:00:00.000Z',
1515
+ }
1516
+ })
1517
+ }
1518
+
1519
+ const fundingReader = {
1520
+ id: 'key_funding',
1521
+ orgId: 'org_funding',
1522
+ scopes: ['funding:read'],
1523
+ token: 'secret_funding_reader',
1524
+ } satisfies TestApp.kvStore.Key
1525
+
1526
+ const fundingProjectReader = {
1527
+ id: 'key_funding_project',
1528
+ orgId: 'org_funding',
1529
+ projectId: 'prj_funding',
1530
+ scopes: ['funding:read'],
1531
+ token: 'secret_funding_project',
1532
+ } satisfies TestApp.kvStore.Key
1533
+
1534
+ const fundingForeign = {
1535
+ id: 'key_funding_other',
1536
+ orgId: 'org_funding_other',
1537
+ scopes: ['funding:read'],
1538
+ token: 'secret_funding_other',
1539
+ } satisfies TestApp.kvStore.Key
1540
+
1541
+ /** Key with funding write access in a different organization. */
1542
+ const fundingForeignWriter = {
1543
+ id: 'key_funding_other_writer',
1544
+ orgId: fundingForeign.orgId,
1545
+ scopes: ['funding:write'],
1546
+ token: 'secret_funding_other_writer',
1547
+ } satisfies TestApp.kvStore.Key
1548
+
1549
+ /** Project-attributed funding writer in the owning organization. */
1550
+ const fundingProjectWriter = {
1551
+ id: 'key_funding_project_writer',
1552
+ orgId: fundingReader.orgId,
1553
+ projectId: fundingProjectReader.projectId,
1554
+ scopes: ['funding:write'],
1555
+ token: 'secret_funding_project_writer',
1556
+ } satisfies TestApp.kvStore.Key
1557
+
1558
+ const fundingWriter = {
1559
+ id: 'key_funding_writer',
1560
+ orgId: 'org_funding',
1561
+ scopes: ['funding:read', 'funding:write'],
1562
+ token: 'secret_funding_writer',
1563
+ } satisfies TestApp.kvStore.Key
1564
+
1565
+ function transfersApp(options: TestApp.create.Options = {}) {
1566
+ return create({
1567
+ ...options,
1568
+ auth: {
1569
+ keys: [
1570
+ TestApp.key,
1571
+ fundingForeign,
1572
+ fundingForeignWriter,
1573
+ fundingProjectReader,
1574
+ fundingProjectWriter,
1575
+ fundingReader,
1576
+ fundingWriter,
1577
+ ],
1578
+ },
1579
+ defaultChainId: options.defaultChainId ?? tempoMainnet.id,
1580
+ })
1581
+ }
1582
+
1583
+ function as(key: { token: string }) {
1584
+ return { headers: { authorization: `Bearer ${key.token}` } } as const
1585
+ }
1586
+
1587
+ let seedDepositAddressSecond = 0
1588
+
1589
+ function seedDepositAddress(input: Partial<FundingDepositAddress.create.Input> = {}) {
1590
+ const index = seedDepositAddressSecond++
1591
+ const now = new Date(Date.UTC(2026, 0, 3, 0, 0, index))
1592
+ return FundingDepositAddress.create(db, {
1593
+ apiKeyId: fundingReader.id,
1594
+ deliveryStrategy: 'provider',
1595
+ environment: 'production',
1596
+ id: FundingDepositAddress.generateId(now),
1597
+ now,
1598
+ orgId: fundingReader.orgId,
1599
+ providerOutputToken: TestFunding.providerOutputToken(),
1600
+ snapshot: TestFunding.depositAddressSnapshot({
1601
+ provider: { id: `relay_route_${index}`, name: 'Relay' },
1602
+ recipient: `0x${index.toString(16).padStart(40, '0')}`,
1603
+ }),
1604
+ ...input,
1605
+ })
1606
+ }
1607
+
1608
+ let seedDepositSecond = 0
1609
+
1610
+ function seedDeposit(
1611
+ address: Awaited<ReturnType<typeof seedDepositAddress>>,
1612
+ input: Partial<FundingDeposit.create.Input> = {},
1613
+ ) {
1614
+ const index = seedDepositSecond++
1615
+ const now = new Date(Date.UTC(2026, 0, 4, 0, 0, index))
1616
+ const sourceTransactionHash = index.toString(16).padStart(64, '0')
1617
+ return FundingDeposit.create(db, {
1618
+ now,
1619
+ providerRequestId: `relay_request_${index}`,
1620
+ snapshot: TestFunding.depositSnapshot(address.id, {
1621
+ provider: address.snapshot.provider,
1622
+ sourceTransactionHashes: [sourceTransactionHash],
1623
+ }),
1624
+ sourceTransactionHash,
1625
+ sourceTransferIndex: 0,
1626
+ ...input,
1627
+ })
1628
+ }