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.
- package/README.md +10 -7
- package/dist/ApiKey.d.ts +3 -0
- package/dist/ApiKey.d.ts.map +1 -1
- package/dist/ApiKey.js +10 -0
- package/dist/ApiKey.js.map +1 -1
- package/dist/ApiKeys.d.ts +7 -3
- package/dist/ApiKeys.d.ts.map +1 -1
- package/dist/ApiKeys.js +9 -2
- package/dist/ApiKeys.js.map +1 -1
- package/dist/App.d.ts +20434 -19484
- package/dist/App.d.ts.map +1 -1
- package/dist/App.js +62 -4
- package/dist/App.js.map +1 -1
- package/dist/admin/App.d.ts +84 -74
- package/dist/admin/App.d.ts.map +1 -1
- package/dist/admin/apps/api-keys.d.ts +14 -1
- package/dist/admin/apps/api-keys.d.ts.map +1 -1
- package/dist/admin/apps/api-keys.js +6 -2
- package/dist/admin/apps/api-keys.js.map +1 -1
- package/dist/admin/apps/organizations.d.ts +2 -0
- package/dist/admin/apps/organizations.d.ts.map +1 -1
- package/dist/admin/apps/verified-tokens.d.ts +168 -168
- package/dist/admin/ui.generated.d.ts +1 -1
- package/dist/admin/ui.generated.js +67 -67
- package/dist/admin/ui.generated.js.map +1 -1
- package/dist/analytics/Analytics.d.ts +2 -0
- package/dist/analytics/Analytics.d.ts.map +1 -1
- package/dist/analytics/Analytics.js +3 -0
- package/dist/analytics/Analytics.js.map +1 -1
- package/dist/apps/data/App.d.ts +10305 -12785
- package/dist/apps/data/App.d.ts.map +1 -1
- package/dist/apps/data/App.js +4 -8
- package/dist/apps/data/App.js.map +1 -1
- package/dist/apps/data/routes/activities.d.ts +24 -24
- package/dist/apps/data/routes/addresses.d.ts +288 -288
- package/dist/apps/data/routes/balances.d.ts +6 -6
- package/dist/apps/data/routes/blocks.d.ts +39 -39
- package/dist/apps/data/routes/coingecko.d.ts +170 -170
- package/dist/apps/data/routes/earn.d.ts +436 -6
- package/dist/apps/data/routes/earn.d.ts.map +1 -1
- package/dist/apps/data/routes/earn.js +631 -4
- package/dist/apps/data/routes/earn.js.map +1 -1
- package/dist/apps/data/routes/exchanges.d.ts +10 -10
- package/dist/apps/data/routes/fee-amm.d.ts +2 -2
- package/dist/apps/data/routes/indexer.d.ts +6 -6
- package/dist/apps/data/routes/indexer.js +4 -1
- package/dist/apps/data/routes/indexer.js.map +1 -1
- package/dist/apps/data/routes/receipts.d.ts +235 -235
- package/dist/apps/data/routes/rpc.d.ts +1 -1
- package/dist/apps/data/routes/rpc.js +4 -1
- package/dist/apps/data/routes/rpc.js.map +1 -1
- package/dist/apps/data/routes/tokenlist.d.ts +36 -36
- package/dist/apps/data/routes/tokens.d.ts +6 -6
- package/dist/apps/data/routes/transactions.d.ts +2 -2
- package/dist/apps/data/routes/transfers.d.ts +1 -1
- package/dist/apps/data/routes/valuation.d.ts +1 -1
- package/dist/apps/data/routes/verified-tokens.d.ts +36 -36
- package/dist/apps/data/routes/webhooks.d.ts +9 -9
- package/dist/apps/data/routes/zones.d.ts +14 -14
- package/dist/apps/funding/Access.d.ts +16 -0
- package/dist/apps/funding/Access.d.ts.map +1 -0
- package/dist/apps/funding/Access.js +22 -0
- package/dist/apps/funding/Access.js.map +1 -0
- package/dist/apps/{data/routes/funding.d.ts → funding/App.d.ts} +1717 -3035
- package/dist/apps/funding/App.d.ts.map +1 -0
- package/dist/apps/funding/App.js +46 -0
- package/dist/apps/funding/App.js.map +1 -0
- package/dist/apps/funding/Inventory.d.ts +5 -0
- package/dist/apps/funding/Inventory.d.ts.map +1 -0
- package/dist/apps/funding/Inventory.js +25 -0
- package/dist/apps/funding/Inventory.js.map +1 -0
- package/dist/apps/funding/ProviderAttempts.d.ts +5 -0
- package/dist/apps/funding/ProviderAttempts.d.ts.map +1 -0
- package/dist/apps/funding/ProviderAttempts.js +24 -0
- package/dist/apps/funding/ProviderAttempts.js.map +1 -0
- package/dist/apps/funding/index.d.ts +2 -0
- package/dist/apps/funding/index.d.ts.map +1 -0
- package/dist/apps/funding/index.js +2 -0
- package/dist/apps/funding/index.js.map +1 -0
- package/dist/apps/funding/routes/chains.d.ts +169 -0
- package/dist/apps/funding/routes/chains.d.ts.map +1 -0
- package/dist/apps/funding/routes/chains.js +80 -0
- package/dist/apps/funding/routes/chains.js.map +1 -0
- package/dist/apps/funding/routes/deposit-addresses.d.ts +2388 -0
- package/dist/apps/funding/routes/deposit-addresses.d.ts.map +1 -0
- package/dist/apps/funding/routes/deposit-addresses.js +860 -0
- package/dist/apps/funding/routes/deposit-addresses.js.map +1 -0
- package/dist/apps/funding/routes/providers.d.ts +91 -0
- package/dist/apps/funding/routes/providers.d.ts.map +1 -0
- package/dist/apps/funding/routes/providers.js +56 -0
- package/dist/apps/funding/routes/providers.js.map +1 -0
- package/dist/apps/funding/routes/quotes.d.ts +426 -0
- package/dist/apps/funding/routes/quotes.d.ts.map +1 -0
- package/dist/apps/funding/routes/quotes.js +188 -0
- package/dist/apps/funding/routes/quotes.js.map +1 -0
- package/dist/apps/funding/routes/transfers.d.ts +1999 -0
- package/dist/apps/funding/routes/transfers.d.ts.map +1 -0
- package/dist/apps/funding/routes/transfers.js +614 -0
- package/dist/apps/funding/routes/transfers.js.map +1 -0
- package/dist/apps/index.d.ts +1 -0
- package/dist/apps/index.d.ts.map +1 -1
- package/dist/apps/index.js +1 -0
- package/dist/apps/index.js.map +1 -1
- package/dist/apps/management/App.d.ts +430 -165
- package/dist/apps/management/App.d.ts.map +1 -1
- package/dist/apps/management/routes/api-keys.d.ts +298 -21
- package/dist/apps/management/routes/api-keys.d.ts.map +1 -1
- package/dist/apps/management/routes/api-keys.js +75 -1
- package/dist/apps/management/routes/api-keys.js.map +1 -1
- package/dist/apps/management/routes/billing.d.ts +6 -6
- package/dist/apps/management/routes/faucet.d.ts +35 -35
- package/dist/apps/management/routes/invitations.d.ts +48 -48
- package/dist/apps/management/routes/invite-links.d.ts +38 -38
- package/dist/apps/management/routes/members.d.ts +69 -69
- package/dist/apps/management/routes/orgs.d.ts +37 -37
- package/dist/apps/management/routes/projects.d.ts +25 -25
- package/dist/apps/management/routes/scopes.d.ts +1 -1
- package/dist/apps/management/routes/usage.d.ts +72 -72
- package/dist/apps/management/routes/verified-token-requests.d.ts +18 -18
- package/dist/apps/management/routes/webhooks.d.ts +128 -128
- package/dist/apps/mcp.d.ts +2 -3
- package/dist/apps/mcp.d.ts.map +1 -1
- package/dist/apps/mcp.js +16 -14
- package/dist/apps/mcp.js.map +1 -1
- package/dist/apps/metadata.d.ts +4 -0
- package/dist/apps/metadata.d.ts.map +1 -1
- package/dist/apps/metadata.js +10 -0
- package/dist/apps/metadata.js.map +1 -1
- package/dist/apps/mpp/App.d.ts +2 -2
- package/dist/cloudflare.d.ts +50 -0
- package/dist/cloudflare.d.ts.map +1 -1
- package/dist/cloudflare.js +178 -0
- package/dist/cloudflare.js.map +1 -1
- package/dist/db/Db.d.ts +2 -0
- package/dist/db/Db.d.ts.map +1 -1
- package/dist/db/Db.js +37 -0
- package/dist/db/Db.js.map +1 -1
- package/dist/db/Schema.d.ts +12 -0
- package/dist/db/Schema.d.ts.map +1 -1
- package/dist/db/Schema.js +20 -0
- package/dist/db/Schema.js.map +1 -1
- package/dist/db/tables/fundingDepositAddresses.d.ts +13 -0
- package/dist/db/tables/fundingDepositAddresses.d.ts.map +1 -1
- package/dist/db/tables/fundingDepositAddresses.js +12 -0
- package/dist/db/tables/fundingDepositAddresses.js.map +1 -1
- package/dist/db/tables/fundingDepositRequestObservations.d.ts +45 -0
- package/dist/db/tables/fundingDepositRequestObservations.d.ts.map +1 -0
- package/dist/db/tables/fundingDepositRequestObservations.js +46 -0
- package/dist/db/tables/fundingDepositRequestObservations.js.map +1 -0
- package/dist/db/tables/fundingDeposits.d.ts +57 -1
- package/dist/db/tables/fundingDeposits.d.ts.map +1 -1
- package/dist/db/tables/fundingDeposits.js +82 -0
- package/dist/db/tables/fundingDeposits.js.map +1 -1
- package/dist/internal/Auth.d.ts +11 -11
- package/dist/internal/Auth.d.ts.map +1 -1
- package/dist/internal/Auth.js +41 -15
- package/dist/internal/Auth.js.map +1 -1
- package/dist/internal/Cache.js +1 -1
- package/dist/internal/Cache.js.map +1 -1
- package/dist/internal/EdgeCache.d.ts +2 -2
- package/dist/internal/EdgeCache.d.ts.map +1 -1
- package/dist/internal/EdgeCache.js +10 -4
- package/dist/internal/EdgeCache.js.map +1 -1
- package/dist/internal/IpAllowlist.d.ts +15 -0
- package/dist/internal/IpAllowlist.d.ts.map +1 -0
- package/dist/internal/IpAllowlist.js +27 -0
- package/dist/internal/IpAllowlist.js.map +1 -0
- package/dist/internal/OpenApi.d.ts +1 -1
- package/dist/internal/OpenApi.js +1 -1
- package/dist/internal/OpenApi.js.map +1 -1
- package/dist/internal/Tidx.d.ts +6 -0
- package/dist/internal/Tidx.d.ts.map +1 -1
- package/dist/internal/Tidx.js +13 -3
- package/dist/internal/Tidx.js.map +1 -1
- package/dist/internal/Viem.d.ts +8 -0
- package/dist/internal/Viem.d.ts.map +1 -1
- package/dist/internal/Viem.js +12 -5
- package/dist/internal/Viem.js.map +1 -1
- package/dist/internal/funding/Deposit.d.ts +42 -0
- package/dist/internal/funding/Deposit.d.ts.map +1 -1
- package/dist/internal/funding/Deposit.js +37 -5
- package/dist/internal/funding/Deposit.js.map +1 -1
- package/dist/internal/funding/Evidence.d.ts +1 -5
- package/dist/internal/funding/Evidence.d.ts.map +1 -1
- package/dist/internal/funding/Evidence.js +4 -3
- package/dist/internal/funding/Evidence.js.map +1 -1
- package/dist/internal/funding/Provider.d.ts +102 -0
- package/dist/internal/funding/Provider.d.ts.map +1 -1
- package/dist/internal/funding/Provider.js +5 -0
- package/dist/internal/funding/Provider.js.map +1 -1
- package/dist/internal/funding/Reconciliation.d.ts +16 -2
- package/dist/internal/funding/Reconciliation.d.ts.map +1 -1
- package/dist/internal/funding/Reconciliation.js +257 -48
- package/dist/internal/funding/Reconciliation.js.map +1 -1
- package/dist/internal/funding/SourceObservation.d.ts +108 -0
- package/dist/internal/funding/SourceObservation.d.ts.map +1 -0
- package/dist/internal/funding/SourceObservation.js +143 -0
- package/dist/internal/funding/SourceObservation.js.map +1 -0
- package/dist/internal/funding/Subsidy.d.ts +2 -2
- package/dist/internal/funding/Subsidy.d.ts.map +1 -1
- package/dist/internal/funding/Subsidy.js +1 -1
- package/dist/internal/funding/Subsidy.js.map +1 -1
- package/dist/internal/funding/index.d.ts +1 -0
- package/dist/internal/funding/index.d.ts.map +1 -1
- package/dist/internal/funding/index.js +1 -0
- package/dist/internal/funding/index.js.map +1 -1
- package/dist/internal/funding/providers/relay.d.ts +43 -1
- package/dist/internal/funding/providers/relay.d.ts.map +1 -1
- package/dist/internal/funding/providers/relay.js +434 -27
- package/dist/internal/funding/providers/relay.js.map +1 -1
- package/package.json +3 -3
- package/src/ApiKey.test.ts +35 -0
- package/src/ApiKey.ts +27 -0
- package/src/ApiKeys.test.ts +29 -0
- package/src/ApiKeys.ts +11 -3
- package/src/App.test.ts +23 -5
- package/src/App.ts +76 -5
- package/src/Client.test-d.ts +171 -19
- package/src/admin/apps/api-keys.test.ts +25 -1
- package/src/admin/apps/api-keys.ts +6 -2
- package/src/admin/ui.generated.ts +67 -67
- package/src/analytics/Analytics.ts +4 -0
- package/src/apps/composition.test.ts +100 -2
- package/src/apps/data/App.test.ts +0 -12
- package/src/apps/data/App.ts +4 -8
- package/src/apps/data/routes/earn.test.ts +283 -0
- package/src/apps/data/routes/earn.ts +800 -3
- package/src/apps/data/routes/indexer.ts +2 -1
- package/src/apps/data/routes/rpc.ts +2 -0
- package/src/apps/data/routes/tokens.test.ts +6 -8
- package/src/apps/funding/Access.ts +26 -0
- package/src/apps/funding/App.test.ts +78 -0
- package/src/apps/funding/App.ts +63 -0
- package/src/apps/funding/Inventory.ts +30 -0
- package/src/apps/funding/ProviderAttempts.ts +26 -0
- package/src/apps/funding/index.ts +1 -0
- package/src/apps/funding/routes/chains.test.ts +78 -0
- package/src/apps/funding/routes/chains.ts +101 -0
- package/src/apps/funding/routes/deposit-addresses.test.ts +1628 -0
- package/src/apps/funding/routes/deposit-addresses.ts +1015 -0
- package/src/apps/funding/routes/providers.test.ts +165 -0
- package/src/apps/funding/routes/providers.ts +73 -0
- package/src/apps/funding/routes/quotes.test.ts +511 -0
- package/src/apps/funding/routes/quotes.ts +205 -0
- package/src/apps/funding/routes/transfers.test.ts +1255 -0
- package/src/apps/funding/routes/transfers.ts +704 -0
- package/src/apps/index.ts +1 -0
- package/src/apps/management/routes/api-keys.test.ts +98 -0
- package/src/apps/management/routes/api-keys.ts +96 -1
- package/src/apps/mcp.test.ts +17 -2
- package/src/apps/mcp.ts +18 -13
- package/src/apps/metadata.ts +12 -0
- package/src/apps/mpp/internal/Policy.test.ts +7 -1
- package/src/cloudflare.ts +279 -0
- package/src/db/Db.ts +62 -0
- package/src/db/Schema.ts +24 -0
- package/src/db/tables/fundingDepositAddresses.ts +28 -0
- package/src/db/tables/fundingDepositRequestObservations.test.ts +106 -0
- package/src/db/tables/fundingDepositRequestObservations.ts +99 -0
- package/src/db/tables/fundingDeposits.test.ts +138 -0
- package/src/db/tables/fundingDeposits.ts +145 -1
- package/src/internal/Auth.test.ts +177 -5
- package/src/internal/Auth.ts +48 -18
- package/src/internal/Cache.test.ts +37 -3
- package/src/internal/Cache.ts +1 -1
- package/src/internal/EdgeCache.test.ts +7 -7
- package/src/internal/EdgeCache.ts +13 -4
- package/src/internal/IpAllowlist.test.ts +52 -0
- package/src/internal/IpAllowlist.ts +39 -0
- package/src/internal/Log.test.ts +7 -1
- package/src/internal/OpenApi.ts +1 -1
- package/src/internal/Tidx.test.ts +28 -0
- package/src/internal/Tidx.ts +19 -3
- package/src/internal/Viem.test.ts +28 -0
- package/src/internal/Viem.ts +21 -5
- package/src/internal/funding/Deposit.ts +74 -5
- package/src/internal/funding/Evidence.test.ts +2 -2
- package/src/internal/funding/Evidence.ts +4 -9
- package/src/internal/funding/Provider.ts +115 -0
- package/src/internal/funding/Reconciliation.local.test.ts +7 -0
- package/src/internal/funding/Reconciliation.test.ts +1052 -35
- package/src/internal/funding/Reconciliation.ts +328 -56
- package/src/internal/funding/SourceObservation.test.ts +301 -0
- package/src/internal/funding/SourceObservation.ts +267 -0
- package/src/internal/funding/Subsidy.ts +2 -2
- package/src/internal/funding/index.test.ts +1 -0
- package/src/internal/funding/index.ts +1 -0
- package/src/internal/funding/providers/relay.test.ts +94 -4
- package/src/internal/funding/providers/relay.ts +528 -27
- package/dist/apps/data/routes/funding.d.ts.map +0 -1
- package/dist/apps/data/routes/funding.js +0 -1557
- package/dist/apps/data/routes/funding.js.map +0 -1
- package/src/apps/data/routes/funding.test.ts +0 -2994
- package/src/apps/data/routes/funding.ts +0 -1821
|
@@ -0,0 +1,1255 @@
|
|
|
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 depositAddressDepositsPath = Object.keys(spec.paths).find(
|
|
834
|
+
(path) => path.includes('/v1/funding/deposit-addresses/{') && path.endsWith('/deposits'),
|
|
835
|
+
)!
|
|
836
|
+
const depositPath = Object.keys(spec.paths).find((path) =>
|
|
837
|
+
path.includes('/v1/funding/deposits/{'),
|
|
838
|
+
)!
|
|
839
|
+
const reconcilePath = Object.keys(spec.paths).find((path) => path.endsWith('/reconcile'))!
|
|
840
|
+
const summarize = (operation: Operation) => ({
|
|
841
|
+
description: operation.description,
|
|
842
|
+
summary: operation.summary,
|
|
843
|
+
})
|
|
844
|
+
const operations = {
|
|
845
|
+
account: summarize(spec.paths['/v1/funding/transfers']!.post!),
|
|
846
|
+
chains: summarize(spec.paths['/v1/funding/chains']!.get!),
|
|
847
|
+
deposit: summarize(spec.paths[depositPath]!.get!),
|
|
848
|
+
depositAddress: summarize(spec.paths[depositAddressPath]!.get!),
|
|
849
|
+
depositAddressCreate: summarize(spec.paths['/v1/funding/deposit-addresses']!.post!),
|
|
850
|
+
depositAddressDeposits: summarize(spec.paths[depositAddressDepositsPath]!.get!),
|
|
851
|
+
depositAddressReconcile: summarize(spec.paths[reconcilePath]!.post!),
|
|
852
|
+
deposits: summarize(spec.paths['/v1/funding/deposits']!.get!),
|
|
853
|
+
detail: summarize(spec.paths[detailPath]!.get!),
|
|
854
|
+
list: summarize(spec.paths['/v1/funding/transfers']!.get!),
|
|
855
|
+
providers: summarize(spec.paths['/v1/funding/providers']!.get!),
|
|
856
|
+
quotes: summarize(spec.paths['/v1/funding/quotes']!.get!),
|
|
857
|
+
vault: summarize(spec.paths['/v1/funding/transfers/vault']!.post!),
|
|
858
|
+
zone: summarize(spec.paths['/v1/funding/transfers/zone']!.post!),
|
|
859
|
+
}
|
|
860
|
+
const category = spec['x-tagGroups'].find((group) => group.name === 'Funding & Bridge API')
|
|
861
|
+
const categoryTags = new Set(category?.tags)
|
|
862
|
+
const sidebar = Object.values(spec.paths).flatMap((path) =>
|
|
863
|
+
[path.get, path.post].flatMap((operation) =>
|
|
864
|
+
operation?.tags.some((tag) => categoryTags.has(tag)) ? [operation.summary] : [],
|
|
865
|
+
),
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
for (const operation of Object.values(operations))
|
|
869
|
+
expect(operation.description.split(/\s+/).length).toBeLessThan(15)
|
|
870
|
+
|
|
871
|
+
expect({
|
|
872
|
+
category: category?.name,
|
|
873
|
+
operations,
|
|
874
|
+
sidebar,
|
|
875
|
+
}).toMatchInlineSnapshot(`
|
|
876
|
+
{
|
|
877
|
+
"category": "Funding & Bridge API",
|
|
878
|
+
"operations": {
|
|
879
|
+
"account": {
|
|
880
|
+
"description": "Creates a transfer to a Tempo account.",
|
|
881
|
+
"summary": "Create transfer",
|
|
882
|
+
},
|
|
883
|
+
"chains": {
|
|
884
|
+
"description": "Lists supported source chains and tokens.",
|
|
885
|
+
"summary": "Get chains",
|
|
886
|
+
},
|
|
887
|
+
"deposit": {
|
|
888
|
+
"description": "Returns one detected deposit and its delivery status.",
|
|
889
|
+
"summary": "Get deposit",
|
|
890
|
+
},
|
|
891
|
+
"depositAddress": {
|
|
892
|
+
"description": "Returns one reusable funding deposit address.",
|
|
893
|
+
"summary": "Get deposit address",
|
|
894
|
+
},
|
|
895
|
+
"depositAddressCreate": {
|
|
896
|
+
"description": "Creates or returns a reusable deposit address for funding a Tempo account.",
|
|
897
|
+
"summary": "Create deposit address",
|
|
898
|
+
},
|
|
899
|
+
"depositAddressDeposits": {
|
|
900
|
+
"description": "Lists deposits detected for one funding deposit address, newest first.",
|
|
901
|
+
"summary": "List deposits by deposit address",
|
|
902
|
+
},
|
|
903
|
+
"depositAddressReconcile": {
|
|
904
|
+
"description": "Queues an immediate provider reconciliation for one reusable funding deposit address.",
|
|
905
|
+
"summary": "Reconcile deposit address",
|
|
906
|
+
},
|
|
907
|
+
"deposits": {
|
|
908
|
+
"description": "Lists deposits received by one Tempo account, newest first.",
|
|
909
|
+
"summary": "List deposits by recipient",
|
|
910
|
+
},
|
|
911
|
+
"detail": {
|
|
912
|
+
"description": "Returns a transfer by ID.",
|
|
913
|
+
"summary": "Get transfer",
|
|
914
|
+
},
|
|
915
|
+
"list": {
|
|
916
|
+
"description": "Lists transfers from newest to oldest.",
|
|
917
|
+
"summary": "List transfers",
|
|
918
|
+
},
|
|
919
|
+
"providers": {
|
|
920
|
+
"description": "Lists available transfer providers.",
|
|
921
|
+
"summary": "Get providers",
|
|
922
|
+
},
|
|
923
|
+
"quotes": {
|
|
924
|
+
"description": "Returns live quotes for transferring stablecoins to Tempo.",
|
|
925
|
+
"summary": "Get quotes",
|
|
926
|
+
},
|
|
927
|
+
"vault": {
|
|
928
|
+
"description": "Creates a transfer into a Tempo Earn vault.",
|
|
929
|
+
"summary": "Create transfer into vault",
|
|
930
|
+
},
|
|
931
|
+
"zone": {
|
|
932
|
+
"description": "Creates a transfer into a Tempo Zone.",
|
|
933
|
+
"summary": "Create transfer into zone",
|
|
934
|
+
},
|
|
935
|
+
},
|
|
936
|
+
"sidebar": [
|
|
937
|
+
"List transfers",
|
|
938
|
+
"Get quotes",
|
|
939
|
+
"Get chains",
|
|
940
|
+
"Get providers",
|
|
941
|
+
"Create deposit address",
|
|
942
|
+
"List transfers",
|
|
943
|
+
"Create transfer",
|
|
944
|
+
"Create transfer into vault",
|
|
945
|
+
"Create transfer into zone",
|
|
946
|
+
"Reconcile deposit address",
|
|
947
|
+
"Get deposit address",
|
|
948
|
+
"Get transfer",
|
|
949
|
+
"Get deposit",
|
|
950
|
+
"List deposits by deposit address",
|
|
951
|
+
"List deposits by recipient",
|
|
952
|
+
],
|
|
953
|
+
}
|
|
954
|
+
`)
|
|
955
|
+
})
|
|
956
|
+
|
|
957
|
+
test('publishes concrete creation schemas', async () => {
|
|
958
|
+
const app = create({ auth: false })
|
|
959
|
+
const spec = (await (await app.request('/openapi.json')).json()) as {
|
|
960
|
+
paths: Record<
|
|
961
|
+
string,
|
|
962
|
+
{
|
|
963
|
+
post?: {
|
|
964
|
+
requestBody: {
|
|
965
|
+
content: Record<
|
|
966
|
+
string,
|
|
967
|
+
{
|
|
968
|
+
schema: {
|
|
969
|
+
additionalProperties?: boolean
|
|
970
|
+
properties: object
|
|
971
|
+
required?: readonly string[] | undefined
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
>
|
|
975
|
+
}
|
|
976
|
+
responses: Record<string, unknown>
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
>
|
|
980
|
+
}
|
|
981
|
+
const creationSchema = (path: string) =>
|
|
982
|
+
spec.paths[path]!.post!.requestBody.content['application/json']!.schema
|
|
983
|
+
expect(
|
|
984
|
+
creationSchema('/v1/funding/deposit-addresses').required?.includes('subsidize') ?? false,
|
|
985
|
+
).toMatchInlineSnapshot(`false`)
|
|
986
|
+
const summarize = (path: string) => {
|
|
987
|
+
const schema = creationSchema(path)
|
|
988
|
+
return {
|
|
989
|
+
additionalProperties: schema.additionalProperties,
|
|
990
|
+
properties: Object.keys(schema.properties),
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
expect({
|
|
995
|
+
account: summarize('/v1/funding/transfers'),
|
|
996
|
+
depositAddress: summarize('/v1/funding/deposit-addresses'),
|
|
997
|
+
depositAddressResponses: Object.keys(
|
|
998
|
+
spec.paths['/v1/funding/deposit-addresses']!.post!.responses,
|
|
999
|
+
),
|
|
1000
|
+
vault: summarize('/v1/funding/transfers/vault'),
|
|
1001
|
+
vaultResponses: Object.keys(spec.paths['/v1/funding/transfers/vault']!.post!.responses),
|
|
1002
|
+
zone: summarize('/v1/funding/transfers/zone'),
|
|
1003
|
+
zoneResponses: Object.keys(spec.paths['/v1/funding/transfers/zone']!.post!.responses),
|
|
1004
|
+
}).toMatchInlineSnapshot(`
|
|
1005
|
+
{
|
|
1006
|
+
"account": {
|
|
1007
|
+
"additionalProperties": false,
|
|
1008
|
+
"properties": [
|
|
1009
|
+
"amount",
|
|
1010
|
+
"destinationToken",
|
|
1011
|
+
"mode",
|
|
1012
|
+
"recipient",
|
|
1013
|
+
"sender",
|
|
1014
|
+
"slippageBps",
|
|
1015
|
+
"sourceChain",
|
|
1016
|
+
"sourceToken",
|
|
1017
|
+
],
|
|
1018
|
+
},
|
|
1019
|
+
"depositAddress": {
|
|
1020
|
+
"additionalProperties": false,
|
|
1021
|
+
"properties": [
|
|
1022
|
+
"amount",
|
|
1023
|
+
"destinationToken",
|
|
1024
|
+
"recipient",
|
|
1025
|
+
"refundAddress",
|
|
1026
|
+
"sourceChain",
|
|
1027
|
+
"sourceToken",
|
|
1028
|
+
"subsidize",
|
|
1029
|
+
],
|
|
1030
|
+
},
|
|
1031
|
+
"depositAddressResponses": [
|
|
1032
|
+
"200",
|
|
1033
|
+
"400",
|
|
1034
|
+
"401",
|
|
1035
|
+
"404",
|
|
1036
|
+
"409",
|
|
1037
|
+
"429",
|
|
1038
|
+
"500",
|
|
1039
|
+
"502",
|
|
1040
|
+
],
|
|
1041
|
+
"vault": {
|
|
1042
|
+
"additionalProperties": false,
|
|
1043
|
+
"properties": [
|
|
1044
|
+
"amount",
|
|
1045
|
+
"mode",
|
|
1046
|
+
"recipient",
|
|
1047
|
+
"sender",
|
|
1048
|
+
"slippageBps",
|
|
1049
|
+
"sourceChain",
|
|
1050
|
+
"sourceToken",
|
|
1051
|
+
"vaultAddress",
|
|
1052
|
+
],
|
|
1053
|
+
},
|
|
1054
|
+
"vaultResponses": [
|
|
1055
|
+
"400",
|
|
1056
|
+
"401",
|
|
1057
|
+
"429",
|
|
1058
|
+
"500",
|
|
1059
|
+
"501",
|
|
1060
|
+
],
|
|
1061
|
+
"zone": {
|
|
1062
|
+
"additionalProperties": false,
|
|
1063
|
+
"properties": [
|
|
1064
|
+
"amount",
|
|
1065
|
+
"destinationChain",
|
|
1066
|
+
"destinationToken",
|
|
1067
|
+
"mode",
|
|
1068
|
+
"recipient",
|
|
1069
|
+
"recipientFallback",
|
|
1070
|
+
"sender",
|
|
1071
|
+
"slippageBps",
|
|
1072
|
+
"sourceChain",
|
|
1073
|
+
"sourceToken",
|
|
1074
|
+
],
|
|
1075
|
+
},
|
|
1076
|
+
"zoneResponses": [
|
|
1077
|
+
"400",
|
|
1078
|
+
"401",
|
|
1079
|
+
"429",
|
|
1080
|
+
"500",
|
|
1081
|
+
"501",
|
|
1082
|
+
],
|
|
1083
|
+
}
|
|
1084
|
+
`)
|
|
1085
|
+
})
|
|
1086
|
+
|
|
1087
|
+
test('publishes alphabetically ordered example fields and parameter examples', async () => {
|
|
1088
|
+
const app = create({ auth: false })
|
|
1089
|
+
const spec = (await (await app.request('/openapi.json')).json()) as {
|
|
1090
|
+
paths: Record<
|
|
1091
|
+
string,
|
|
1092
|
+
{
|
|
1093
|
+
get: {
|
|
1094
|
+
parameters?: { example?: unknown; name: string; schema?: { examples?: unknown } }[]
|
|
1095
|
+
responses: Record<string, { content: Record<string, { example: { data: {}[] } }> }>
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
>
|
|
1099
|
+
}
|
|
1100
|
+
const operation = spec.paths['/v1/funding/transfers']!.get
|
|
1101
|
+
const fields = Object.keys(operation.responses['200']!.content['application/json']!.example.data[0]!) // prettier-ignore
|
|
1102
|
+
expect(fields).toEqual([...fields].sort())
|
|
1103
|
+
for (const parameter of operation.parameters ?? [])
|
|
1104
|
+
expect(parameter.example ?? parameter.schema?.examples, parameter.name).toBeDefined()
|
|
1105
|
+
})
|
|
1106
|
+
|
|
1107
|
+
test('publishes operation ids and required scopes', async () => {
|
|
1108
|
+
const app = create({ auth: false })
|
|
1109
|
+
const spec = (await (await app.request('/openapi.json')).json()) as {
|
|
1110
|
+
paths: Record<
|
|
1111
|
+
string,
|
|
1112
|
+
{
|
|
1113
|
+
get: { operationId: string; 'x-required-scopes': string[] }
|
|
1114
|
+
post?: {
|
|
1115
|
+
operationId: string
|
|
1116
|
+
parameters: { in: string; name: string; required: boolean }[]
|
|
1117
|
+
'x-required-scopes': string[]
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
>
|
|
1121
|
+
}
|
|
1122
|
+
const detailPath = Object.keys(spec.paths).find((path) =>
|
|
1123
|
+
path.includes('/v1/funding/transfers/{'),
|
|
1124
|
+
)
|
|
1125
|
+
const depositAddressPath = Object.keys(spec.paths).find(
|
|
1126
|
+
(path) =>
|
|
1127
|
+
path.includes('/v1/funding/deposit-addresses/{') &&
|
|
1128
|
+
!path.endsWith('/deposits') &&
|
|
1129
|
+
!path.endsWith('/reconcile'),
|
|
1130
|
+
)
|
|
1131
|
+
const depositAddressDepositsPath = Object.keys(spec.paths).find(
|
|
1132
|
+
(path) => path.includes('/v1/funding/deposit-addresses/{') && path.endsWith('/deposits'),
|
|
1133
|
+
)
|
|
1134
|
+
const depositPath = Object.keys(spec.paths).find((path) =>
|
|
1135
|
+
path.includes('/v1/funding/deposits/{'),
|
|
1136
|
+
)
|
|
1137
|
+
const reconcilePath = Object.keys(spec.paths).find((path) => path.endsWith('/reconcile'))
|
|
1138
|
+
expect({
|
|
1139
|
+
deposit: depositPath && spec.paths[depositPath]?.get.operationId,
|
|
1140
|
+
depositAddress: depositAddressPath && spec.paths[depositAddressPath]?.get.operationId,
|
|
1141
|
+
depositAddressCreate: spec.paths['/v1/funding/deposit-addresses']?.post?.operationId,
|
|
1142
|
+
depositAddressCreateScopes:
|
|
1143
|
+
spec.paths['/v1/funding/deposit-addresses']?.post?.['x-required-scopes'],
|
|
1144
|
+
depositAddressDeposits:
|
|
1145
|
+
depositAddressDepositsPath && spec.paths[depositAddressDepositsPath]?.get.operationId,
|
|
1146
|
+
depositAddressDepositsScopes:
|
|
1147
|
+
depositAddressDepositsPath &&
|
|
1148
|
+
spec.paths[depositAddressDepositsPath]?.get['x-required-scopes'],
|
|
1149
|
+
depositAddressReconcile: reconcilePath && spec.paths[reconcilePath]?.post?.operationId,
|
|
1150
|
+
depositAddressReconcileScopes:
|
|
1151
|
+
reconcilePath && spec.paths[reconcilePath]?.post?.['x-required-scopes'],
|
|
1152
|
+
depositAddressScopes:
|
|
1153
|
+
depositAddressPath && spec.paths[depositAddressPath]?.get['x-required-scopes'],
|
|
1154
|
+
depositScopes: depositPath && spec.paths[depositPath]?.get['x-required-scopes'],
|
|
1155
|
+
deposits: spec.paths['/v1/funding/deposits']?.get.operationId,
|
|
1156
|
+
depositsScopes: spec.paths['/v1/funding/deposits']?.get['x-required-scopes'],
|
|
1157
|
+
detail: detailPath && spec.paths[detailPath]?.get.operationId,
|
|
1158
|
+
detailScopes: detailPath && spec.paths[detailPath]?.get['x-required-scopes'],
|
|
1159
|
+
list: spec.paths['/v1/funding/transfers']?.get.operationId,
|
|
1160
|
+
listScopes: spec.paths['/v1/funding/transfers']?.get['x-required-scopes'],
|
|
1161
|
+
requiredCreateHeader: spec.paths['/v1/funding/transfers']?.post?.parameters.find(
|
|
1162
|
+
(parameter) => parameter.name === 'idempotency-key',
|
|
1163
|
+
),
|
|
1164
|
+
vault: spec.paths['/v1/funding/transfers/vault']?.post?.operationId,
|
|
1165
|
+
vaultScopes: spec.paths['/v1/funding/transfers/vault']?.post?.['x-required-scopes'],
|
|
1166
|
+
zone: spec.paths['/v1/funding/transfers/zone']?.post?.operationId,
|
|
1167
|
+
zoneScopes: spec.paths['/v1/funding/transfers/zone']?.post?.['x-required-scopes'],
|
|
1168
|
+
}).toEqual({
|
|
1169
|
+
deposit: 'getFundingDeposit',
|
|
1170
|
+
depositAddress: 'getFundingDepositAddress',
|
|
1171
|
+
depositAddressCreate: 'createFundingDepositAddress',
|
|
1172
|
+
depositAddressCreateScopes: ['funding:write'],
|
|
1173
|
+
depositAddressDeposits: 'listFundingDepositAddressDeposits',
|
|
1174
|
+
depositAddressDepositsScopes: ['funding:read'],
|
|
1175
|
+
depositAddressReconcile: 'reconcileFundingDepositAddress',
|
|
1176
|
+
depositAddressReconcileScopes: ['funding:write'],
|
|
1177
|
+
depositAddressScopes: ['funding:read'],
|
|
1178
|
+
depositScopes: ['funding:read'],
|
|
1179
|
+
deposits: 'listFundingDeposits',
|
|
1180
|
+
depositsScopes: ['funding:read'],
|
|
1181
|
+
detail: 'getFundingTransfer',
|
|
1182
|
+
detailScopes: ['funding:read'],
|
|
1183
|
+
list: 'listFundingTransfers',
|
|
1184
|
+
listScopes: ['funding:read'],
|
|
1185
|
+
requiredCreateHeader: expect.objectContaining({
|
|
1186
|
+
description: expect.stringContaining('24 hours'),
|
|
1187
|
+
in: 'header',
|
|
1188
|
+
name: 'idempotency-key',
|
|
1189
|
+
required: true,
|
|
1190
|
+
schema: expect.any(Object),
|
|
1191
|
+
}),
|
|
1192
|
+
vault: 'createFundingTransferVault',
|
|
1193
|
+
vaultScopes: ['funding:write'],
|
|
1194
|
+
zone: 'createFundingTransferZone',
|
|
1195
|
+
zoneScopes: ['funding:write'],
|
|
1196
|
+
})
|
|
1197
|
+
})
|
|
1198
|
+
})
|
|
1199
|
+
|
|
1200
|
+
const fundingReader = {
|
|
1201
|
+
id: 'key_funding',
|
|
1202
|
+
orgId: 'org_funding',
|
|
1203
|
+
scopes: ['funding:read'],
|
|
1204
|
+
token: 'secret_funding_reader',
|
|
1205
|
+
} satisfies TestApp.kvStore.Key
|
|
1206
|
+
|
|
1207
|
+
const fundingProjectReader = {
|
|
1208
|
+
id: 'key_funding_project',
|
|
1209
|
+
orgId: 'org_funding',
|
|
1210
|
+
projectId: 'prj_funding',
|
|
1211
|
+
scopes: ['funding:read'],
|
|
1212
|
+
token: 'secret_funding_project',
|
|
1213
|
+
} satisfies TestApp.kvStore.Key
|
|
1214
|
+
|
|
1215
|
+
const fundingForeign = {
|
|
1216
|
+
id: 'key_funding_other',
|
|
1217
|
+
orgId: 'org_funding_other',
|
|
1218
|
+
scopes: ['funding:read'],
|
|
1219
|
+
token: 'secret_funding_other',
|
|
1220
|
+
} satisfies TestApp.kvStore.Key
|
|
1221
|
+
|
|
1222
|
+
const fundingWriter = {
|
|
1223
|
+
id: 'key_funding_writer',
|
|
1224
|
+
orgId: 'org_funding',
|
|
1225
|
+
scopes: ['funding:read', 'funding:write'],
|
|
1226
|
+
token: 'secret_funding_writer',
|
|
1227
|
+
} satisfies TestApp.kvStore.Key
|
|
1228
|
+
|
|
1229
|
+
function transfersApp(options: TestApp.create.Options = {}) {
|
|
1230
|
+
return create({
|
|
1231
|
+
...options,
|
|
1232
|
+
auth: {
|
|
1233
|
+
keys: [TestApp.key, fundingReader, fundingProjectReader, fundingForeign, fundingWriter],
|
|
1234
|
+
},
|
|
1235
|
+
defaultChainId: options.defaultChainId ?? tempoMainnet.id,
|
|
1236
|
+
})
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
function as(key: { token: string }) {
|
|
1240
|
+
return { headers: { authorization: `Bearer ${key.token}` } } as const
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
let seedSecond = 0
|
|
1244
|
+
|
|
1245
|
+
function seedTransfer(input: Partial<FundingTransfer.create.Input> = {}) {
|
|
1246
|
+
// Distinct create times keep ids strictly ordered for paging assertions.
|
|
1247
|
+
return FundingTransfer.create(db, {
|
|
1248
|
+
apiKeyId: fundingReader.id,
|
|
1249
|
+
environment: 'production',
|
|
1250
|
+
id: FundingTransfer.generateId(new Date(Date.UTC(2026, 0, 2, 0, 0, seedSecond++))),
|
|
1251
|
+
orgId: fundingReader.orgId,
|
|
1252
|
+
snapshot: TestFunding.transferSnapshot(),
|
|
1253
|
+
...input,
|
|
1254
|
+
})
|
|
1255
|
+
}
|