tapimo 0.7.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +20117 -19183
- package/dist/App.d.ts.map +1 -1
- package/dist/App.js +61 -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 +11 -11
- 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 +11696 -13937
- 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 +271 -271
- package/dist/apps/data/routes/addresses.d.ts +398 -167
- package/dist/apps/data/routes/addresses.d.ts.map +1 -1
- package/dist/apps/data/routes/balances.d.ts +278 -8
- package/dist/apps/data/routes/balances.d.ts.map +1 -1
- package/dist/apps/data/routes/balances.js +114 -2
- package/dist/apps/data/routes/balances.js.map +1 -1
- package/dist/apps/data/routes/blocks.d.ts +123 -123
- package/dist/apps/data/routes/coingecko.d.ts +170 -170
- package/dist/apps/data/routes/earn.d.ts +498 -6
- package/dist/apps/data/routes/earn.d.ts.map +1 -1
- package/dist/apps/data/routes/earn.js +876 -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 +31 -31
- 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 +449 -449
- package/dist/apps/data/routes/transactions.d.ts +2 -2
- package/dist/apps/data/routes/transfers.d.ts +81 -81
- 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} +1640 -3217
- package/dist/apps/funding/App.d.ts.map +1 -0
- package/dist/apps/funding/App.js +61 -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 +1997 -0
- package/dist/apps/funding/routes/deposit-addresses.d.ts.map +1 -0
- package/dist/apps/funding/routes/deposit-addresses.js +778 -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 +122 -122
- 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 +99 -99
- 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 +77 -77
- 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 +44 -44
- 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 +45 -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 +74 -1
- package/dist/db/tables/fundingDeposits.d.ts.map +1 -1
- package/dist/db/tables/fundingDeposits.js +126 -0
- package/dist/db/tables/fundingDeposits.js.map +1 -1
- package/dist/internal/Auth.d.ts +15 -11
- package/dist/internal/Auth.d.ts.map +1 -1
- package/dist/internal/Auth.js +52 -17
- 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 +348 -54
- 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 +438 -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 +55 -5
- package/src/App.ts +79 -5
- package/src/Client.test-d.ts +203 -28
- 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 +130 -3
- package/src/apps/data/App.test.ts +0 -12
- package/src/apps/data/App.ts +4 -8
- package/src/apps/data/routes/balances.test.ts +42 -0
- package/src/apps/data/routes/balances.ts +218 -85
- package/src/apps/data/routes/earn.test.ts +363 -0
- package/src/apps/data/routes/earn.ts +1141 -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 +78 -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 +1649 -0
- package/src/apps/funding/routes/deposit-addresses.ts +932 -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 +1236 -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 +71 -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 +239 -0
- package/src/db/tables/fundingDeposits.ts +214 -1
- package/src/internal/Auth.test.ts +205 -5
- package/src/internal/Auth.ts +63 -19
- 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 +1364 -109
- package/src/internal/funding/Reconciliation.ts +467 -88
- 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 +533 -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
|
@@ -1,2994 +0,0 @@
|
|
|
1
|
-
import { Hash, Hex, Value } from 'ox'
|
|
2
|
-
import { Schema } from 'tapimo'
|
|
3
|
-
import { decodeFunctionData, erc20Abi, maxUint256 } from 'viem'
|
|
4
|
-
import { base } from 'viem/chains'
|
|
5
|
-
import { tempoMainnet } from 'viem/tempo/chains'
|
|
6
|
-
|
|
7
|
-
import * as Anvil from '../../../../test/Anvil.js'
|
|
8
|
-
import * as FundingDepositAddresses from '../../../db/tables/fundingDepositAddresses.js'
|
|
9
|
-
import * as FundingIdempotencyTable from '../../../db/tables/fundingIdempotency.js'
|
|
10
|
-
import * as Cache from '../../../internal/Cache.js'
|
|
11
|
-
import * as Cursor from '../../../internal/Cursor.js'
|
|
12
|
-
import * as FundingCatalog from '../../../internal/funding/Catalog.js'
|
|
13
|
-
import * as FundingDeposit from '../../../internal/funding/Deposit.js'
|
|
14
|
-
import * as FundingDepositAddress from '../../../internal/funding/DepositAddress.js'
|
|
15
|
-
import * as FundingProvider from '../../../internal/funding/Provider.js'
|
|
16
|
-
import * as FundingTransfer from '../../../internal/funding/Transfer.js'
|
|
17
|
-
import * as Idempotency from '../../../internal/Idempotency.js'
|
|
18
|
-
import * as Across from '../../../internal/funding/providers/across.js'
|
|
19
|
-
import * as Relay from '../../../internal/funding/providers/relay.js'
|
|
20
|
-
import * as Rhino from '../../../internal/funding/providers/rhino.js'
|
|
21
|
-
import * as Squid from '../../../internal/funding/providers/squid.js'
|
|
22
|
-
import * as Symbiosis from '../../../internal/funding/providers/symbiosis.js'
|
|
23
|
-
import type * as Log from '../../../internal/Log.js'
|
|
24
|
-
import * as Store from '../../../internal/Store.js'
|
|
25
|
-
import * as TestApp from '../../../../test/App.js'
|
|
26
|
-
import * as TestFunding from '../../../../test/Funding.js'
|
|
27
|
-
import * as Funding from './funding.js'
|
|
28
|
-
|
|
29
|
-
const db = TestApp.database()
|
|
30
|
-
const relayApiKey = process.env.RELAY_API_KEY
|
|
31
|
-
|
|
32
|
-
const providers = [
|
|
33
|
-
Across.across({
|
|
34
|
-
apiKey: 'across-api-key',
|
|
35
|
-
integratorId: '0xdead',
|
|
36
|
-
userAddress: '0x1111111111111111111111111111111111111111',
|
|
37
|
-
}),
|
|
38
|
-
Relay.relay({ userAddress: '0x1111111111111111111111111111111111111111' }),
|
|
39
|
-
Rhino.rhino(),
|
|
40
|
-
Squid.squid({
|
|
41
|
-
integratorId: 'squid-integrator-id',
|
|
42
|
-
userAddress: '0x1111111111111111111111111111111111111111',
|
|
43
|
-
}),
|
|
44
|
-
Symbiosis.symbiosis({ userAddress: '0x1111111111111111111111111111111111111111' }),
|
|
45
|
-
] as const
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Providers with executable coverage on the static Base USDC → pathUSD
|
|
49
|
-
* corridor. Each entry gets a fork execution suite (live quote, calls executed
|
|
50
|
-
* on an anvil Base fork); covering another provider is one entry here.
|
|
51
|
-
*/
|
|
52
|
-
const executableProviders = [
|
|
53
|
-
{
|
|
54
|
-
create: (sender: string) =>
|
|
55
|
-
Relay.relay({
|
|
56
|
-
...(process.env.RELAY_API_KEY ? { apiKey: process.env.RELAY_API_KEY } : {}),
|
|
57
|
-
userAddress: sender,
|
|
58
|
-
}),
|
|
59
|
-
id: 'relay',
|
|
60
|
-
},
|
|
61
|
-
] as const
|
|
62
|
-
|
|
63
|
-
beforeAll(() => TestFunding.publish(db))
|
|
64
|
-
|
|
65
|
-
function create(options: TestApp.create.Options = {}) {
|
|
66
|
-
return TestApp.create({ ...options, db })
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
describe('funding OpenAPI', () => {
|
|
70
|
-
test('publishes get operation ids for funding inventory', async () => {
|
|
71
|
-
const app = create({ auth: false })
|
|
72
|
-
const spec = await (await app.request('/openapi.json')).json()
|
|
73
|
-
|
|
74
|
-
expect({
|
|
75
|
-
chains: spec.paths['/v1/funding/chains'].get.operationId,
|
|
76
|
-
providers: spec.paths['/v1/funding/providers'].get.operationId,
|
|
77
|
-
}).toEqual({
|
|
78
|
-
chains: 'getFundingChains',
|
|
79
|
-
providers: 'getFundingProviders',
|
|
80
|
-
})
|
|
81
|
-
})
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
describe('GET /funding/quotes', () => {
|
|
85
|
-
test('publishes alphabetically ordered quote example fields', async () => {
|
|
86
|
-
const app = create({ auth: false })
|
|
87
|
-
const spec = await (await app.request('/openapi.json')).json()
|
|
88
|
-
const operation = spec.paths['/v1/funding/quotes'].get
|
|
89
|
-
const response = operation.responses['200'].content['application/json']
|
|
90
|
-
const fields = Object.keys(response.example.data[0])
|
|
91
|
-
|
|
92
|
-
expect(fields).toEqual([...fields].sort())
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
test('returns successful live quotes with output amounts', async () => {
|
|
96
|
-
const app = create({ providers: [relayProvider()] })
|
|
97
|
-
const response = await app.request(quoteUrl(), { headers: TestApp.auth.headers })
|
|
98
|
-
expect(response.status).toBe(200)
|
|
99
|
-
expect(response.headers.get('cache-control')).toBe('no-store')
|
|
100
|
-
const body = await TestApp.json(response, Funding.schema.quoteFunding.Response)
|
|
101
|
-
|
|
102
|
-
expect(body.data).toHaveLength(1)
|
|
103
|
-
expect(body.data[0]).toMatchObject({
|
|
104
|
-
destinationAmount: {
|
|
105
|
-
baseUnits: '990000',
|
|
106
|
-
currency: 'USD',
|
|
107
|
-
decimals: 6,
|
|
108
|
-
formatted: '0.99',
|
|
109
|
-
},
|
|
110
|
-
destinationAmountMin: {
|
|
111
|
-
baseUnits: '980000',
|
|
112
|
-
currency: 'USD',
|
|
113
|
-
decimals: 6,
|
|
114
|
-
formatted: '0.98',
|
|
115
|
-
},
|
|
116
|
-
id: 'base-usdc-tempo-usdce-relay',
|
|
117
|
-
provider: { id: 'relay' },
|
|
118
|
-
quality: { liquiditySource: 'providerQuote', tier: 'liquid' },
|
|
119
|
-
quote: { sampledAt: '2026-01-01T00:00:00Z' },
|
|
120
|
-
sourceAmount: { baseUnits: '1000000', currency: 'USD', decimals: 6, formatted: '1' },
|
|
121
|
-
})
|
|
122
|
-
expect(body.data[0]?.sourceToken).not.toHaveProperty('amount')
|
|
123
|
-
expect(body.data[0]?.destinationToken).not.toHaveProperty('amount')
|
|
124
|
-
})
|
|
125
|
-
|
|
126
|
-
test('resolves token identifiers through the composed route', async () => {
|
|
127
|
-
const app = create({ providers: [relayProvider()] })
|
|
128
|
-
const identifiers = [
|
|
129
|
-
{ destinationToken: 'usdc.e', name: 'symbol', sourceToken: 'USDC' },
|
|
130
|
-
{ destinationToken: 'usdce', name: 'slug', sourceToken: 'usdc' },
|
|
131
|
-
{
|
|
132
|
-
destinationToken: '0x20c000000000000000000000b9537d11c60e8b50',
|
|
133
|
-
name: 'address',
|
|
134
|
-
sourceToken: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913',
|
|
135
|
-
},
|
|
136
|
-
{
|
|
137
|
-
destinationToken: 'eip155:4217/erc20:0x20c000000000000000000000b9537d11c60e8b50',
|
|
138
|
-
name: 'token key',
|
|
139
|
-
sourceToken: 'eip155:8453/erc20:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913',
|
|
140
|
-
},
|
|
141
|
-
] as const
|
|
142
|
-
const results = []
|
|
143
|
-
for (const identifier of identifiers) {
|
|
144
|
-
const response = await app.request(
|
|
145
|
-
quoteUrl({
|
|
146
|
-
destinationToken: identifier.destinationToken,
|
|
147
|
-
sourceAmount: '1000000',
|
|
148
|
-
sourceChain: 'base',
|
|
149
|
-
sourceToken: identifier.sourceToken,
|
|
150
|
-
}),
|
|
151
|
-
{ headers: TestApp.auth.headers },
|
|
152
|
-
)
|
|
153
|
-
const body = await TestApp.json(response, Funding.schema.quoteFunding.Response)
|
|
154
|
-
results.push({
|
|
155
|
-
destinationToken: body.data[0]?.destinationToken.symbol,
|
|
156
|
-
name: identifier.name,
|
|
157
|
-
sourceToken: body.data[0]?.sourceToken.symbol,
|
|
158
|
-
status: response.status,
|
|
159
|
-
})
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
expect(results).toMatchInlineSnapshot(`
|
|
163
|
-
[
|
|
164
|
-
{
|
|
165
|
-
"destinationToken": "USDC.e",
|
|
166
|
-
"name": "symbol",
|
|
167
|
-
"sourceToken": "USDC",
|
|
168
|
-
"status": 200,
|
|
169
|
-
},
|
|
170
|
-
{
|
|
171
|
-
"destinationToken": "USDC.e",
|
|
172
|
-
"name": "slug",
|
|
173
|
-
"sourceToken": "USDC",
|
|
174
|
-
"status": 200,
|
|
175
|
-
},
|
|
176
|
-
{
|
|
177
|
-
"destinationToken": "USDC.e",
|
|
178
|
-
"name": "address",
|
|
179
|
-
"sourceToken": "USDC",
|
|
180
|
-
"status": 200,
|
|
181
|
-
},
|
|
182
|
-
{
|
|
183
|
-
"destinationToken": "USDC.e",
|
|
184
|
-
"name": "token key",
|
|
185
|
-
"sourceToken": "USDC",
|
|
186
|
-
"status": 200,
|
|
187
|
-
},
|
|
188
|
-
]
|
|
189
|
-
`)
|
|
190
|
-
})
|
|
191
|
-
|
|
192
|
-
test('queries only the requested provider', async () => {
|
|
193
|
-
let acrossCalls = 0
|
|
194
|
-
let relayCalls = 0
|
|
195
|
-
const app = create({
|
|
196
|
-
providers: [
|
|
197
|
-
acrossProvider(() => {
|
|
198
|
-
acrossCalls += 1
|
|
199
|
-
}),
|
|
200
|
-
relayProvider(() => {
|
|
201
|
-
relayCalls += 1
|
|
202
|
-
}),
|
|
203
|
-
],
|
|
204
|
-
})
|
|
205
|
-
const response = await app.request(quoteUrl({ ...baseUsdcQuery, provider: 'across' }), {
|
|
206
|
-
headers: TestApp.auth.headers,
|
|
207
|
-
})
|
|
208
|
-
|
|
209
|
-
expect(response.status).toBe(200)
|
|
210
|
-
expect(acrossCalls).toBe(1)
|
|
211
|
-
expect(relayCalls).toBe(0)
|
|
212
|
-
const body = await TestApp.json(response, Funding.schema.quoteFunding.Response)
|
|
213
|
-
expect(body.data[0]?.quote.expiresAt).toBe('2026-01-01T00:01:00Z')
|
|
214
|
-
})
|
|
215
|
-
|
|
216
|
-
test('returns non-EVM Rhino quotes with stable chain and token ids', async () => {
|
|
217
|
-
const app = create({ providers: [rhinoProvider()] })
|
|
218
|
-
const response = await app.request(
|
|
219
|
-
quoteUrl({
|
|
220
|
-
destinationToken: 'USDC.e',
|
|
221
|
-
provider: 'rhino',
|
|
222
|
-
sourceAmount: '1000000',
|
|
223
|
-
sourceChain: 'solana',
|
|
224
|
-
sourceToken: 'USDC',
|
|
225
|
-
}),
|
|
226
|
-
{ headers: TestApp.auth.headers },
|
|
227
|
-
)
|
|
228
|
-
|
|
229
|
-
expect(response.status).toBe(200)
|
|
230
|
-
const body = await TestApp.json(response, Funding.schema.quoteFunding.Response)
|
|
231
|
-
expect(body.data[0]).toMatchObject({
|
|
232
|
-
id: 'solana-usdc-tempo-usdce-rhino',
|
|
233
|
-
provider: { id: 'rhino' },
|
|
234
|
-
sourceChain: {
|
|
235
|
-
addressFormat: 'base58',
|
|
236
|
-
id: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
|
|
237
|
-
kind: 'solana',
|
|
238
|
-
},
|
|
239
|
-
sourceToken: {
|
|
240
|
-
tokenKey:
|
|
241
|
-
'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
|
|
242
|
-
},
|
|
243
|
-
})
|
|
244
|
-
})
|
|
245
|
-
|
|
246
|
-
test('returns an empty list when no provider is available', async () => {
|
|
247
|
-
const response = await create().request(quoteUrl(), {
|
|
248
|
-
headers: TestApp.auth.headers,
|
|
249
|
-
})
|
|
250
|
-
expect(response.status).toBe(200)
|
|
251
|
-
const body = await TestApp.json(response, Funding.schema.quoteFunding.Response)
|
|
252
|
-
expect(body.data).toEqual([])
|
|
253
|
-
})
|
|
254
|
-
|
|
255
|
-
test('returns an empty list when the provider reports no quote', async () => {
|
|
256
|
-
const entries: Log.Entry[] = []
|
|
257
|
-
const app = create({
|
|
258
|
-
logger: (entry) => {
|
|
259
|
-
entries.push(entry)
|
|
260
|
-
},
|
|
261
|
-
providers: [
|
|
262
|
-
withGetQuote('relay', async () => ({
|
|
263
|
-
sampledAt: '2026-01-01T00:00:00Z',
|
|
264
|
-
status: 'unavailable',
|
|
265
|
-
})),
|
|
266
|
-
],
|
|
267
|
-
})
|
|
268
|
-
const response = await app.request(quoteUrl(), { headers: TestApp.auth.headers })
|
|
269
|
-
expect(response.status).toBe(200)
|
|
270
|
-
const body = await TestApp.json(response, Funding.schema.quoteFunding.Response)
|
|
271
|
-
expect(body.data).toEqual([])
|
|
272
|
-
expect(entries[0]?.fundingProviderAttempts).toEqual([
|
|
273
|
-
{
|
|
274
|
-
durationMs: expect.any(Number),
|
|
275
|
-
id: 'relay',
|
|
276
|
-
operation: 'getQuote',
|
|
277
|
-
outcome: 'unavailable',
|
|
278
|
-
},
|
|
279
|
-
])
|
|
280
|
-
})
|
|
281
|
-
|
|
282
|
-
test('returns 502 when every matching provider fails', async () => {
|
|
283
|
-
const app = create({
|
|
284
|
-
providers: [
|
|
285
|
-
withGetQuote('relay', async () => {
|
|
286
|
-
throw new Error('upstream unavailable')
|
|
287
|
-
}),
|
|
288
|
-
],
|
|
289
|
-
})
|
|
290
|
-
const response = await app.request(quoteUrl(), { headers: TestApp.auth.headers })
|
|
291
|
-
expect(response.status).toBe(502)
|
|
292
|
-
const body = await TestApp.json(response, Schema.ErrorResponse)
|
|
293
|
-
expect(body.error.code).toBe('upstream_error')
|
|
294
|
-
})
|
|
295
|
-
|
|
296
|
-
test('logs provider quote deadlines as timeouts', async () => {
|
|
297
|
-
vi.useFakeTimers()
|
|
298
|
-
const entries: Log.Entry[] = []
|
|
299
|
-
const app = create({
|
|
300
|
-
logger: (entry) => {
|
|
301
|
-
entries.push(entry)
|
|
302
|
-
},
|
|
303
|
-
providers: [
|
|
304
|
-
withGetQuote('across', () => new Promise<FundingProvider.getQuote.ReturnType>(() => {})),
|
|
305
|
-
],
|
|
306
|
-
})
|
|
307
|
-
|
|
308
|
-
try {
|
|
309
|
-
const response_promise = app.request(quoteUrl({ ...baseUsdcQuery, provider: 'across' }), {
|
|
310
|
-
headers: TestApp.auth.headers,
|
|
311
|
-
})
|
|
312
|
-
await vi.advanceTimersByTimeAsync(2_000)
|
|
313
|
-
const response = await response_promise
|
|
314
|
-
|
|
315
|
-
expect(response.status).toBe(502)
|
|
316
|
-
expect(entries[0]?.provider).toEqual({
|
|
317
|
-
failure: 'timeout',
|
|
318
|
-
id: 'across',
|
|
319
|
-
operation: 'getQuote',
|
|
320
|
-
})
|
|
321
|
-
expect(entries[0]?.fundingProviderAttempts).toEqual([
|
|
322
|
-
{
|
|
323
|
-
durationMs: expect.any(Number),
|
|
324
|
-
failure: 'timeout',
|
|
325
|
-
id: 'across',
|
|
326
|
-
operation: 'getQuote',
|
|
327
|
-
outcome: 'failed',
|
|
328
|
-
},
|
|
329
|
-
])
|
|
330
|
-
} finally {
|
|
331
|
-
vi.useRealTimers()
|
|
332
|
-
}
|
|
333
|
-
})
|
|
334
|
-
|
|
335
|
-
test('logs every failure when multiple providers fail', async () => {
|
|
336
|
-
const entries: Log.Entry[] = []
|
|
337
|
-
const app = create({
|
|
338
|
-
logger: (entry) => {
|
|
339
|
-
entries.push(entry)
|
|
340
|
-
},
|
|
341
|
-
providers: [
|
|
342
|
-
withGetQuote('across', async () => {
|
|
343
|
-
throw new TypeError('Across network failure')
|
|
344
|
-
}),
|
|
345
|
-
withGetQuote('relay', async () => {
|
|
346
|
-
throw new Error('Relay failure')
|
|
347
|
-
}),
|
|
348
|
-
],
|
|
349
|
-
})
|
|
350
|
-
|
|
351
|
-
const response = await app.request(quoteUrl(), { headers: TestApp.auth.headers })
|
|
352
|
-
|
|
353
|
-
expect(response.status).toBe(502)
|
|
354
|
-
expect(entries[0]?.provider).toEqual({
|
|
355
|
-
failure: 'network',
|
|
356
|
-
id: 'across',
|
|
357
|
-
operation: 'getQuote',
|
|
358
|
-
})
|
|
359
|
-
expect(entries[0]?.providerFailures).toEqual([
|
|
360
|
-
{ failure: 'network', id: 'across', operation: 'getQuote' },
|
|
361
|
-
{ failure: 'unknown', id: 'relay', operation: 'getQuote' },
|
|
362
|
-
])
|
|
363
|
-
expect(entries[0]?.fundingProviderAttempts).toEqual([
|
|
364
|
-
{
|
|
365
|
-
durationMs: expect.any(Number),
|
|
366
|
-
failure: 'network',
|
|
367
|
-
id: 'across',
|
|
368
|
-
operation: 'getQuote',
|
|
369
|
-
outcome: 'failed',
|
|
370
|
-
},
|
|
371
|
-
{
|
|
372
|
-
durationMs: expect.any(Number),
|
|
373
|
-
failure: 'unknown',
|
|
374
|
-
id: 'relay',
|
|
375
|
-
operation: 'getQuote',
|
|
376
|
-
outcome: 'failed',
|
|
377
|
-
},
|
|
378
|
-
])
|
|
379
|
-
})
|
|
380
|
-
|
|
381
|
-
test('logs failed providers when another provider returns a quote', async () => {
|
|
382
|
-
const entries: Log.Entry[] = []
|
|
383
|
-
const app = create({
|
|
384
|
-
logger: (entry) => {
|
|
385
|
-
entries.push(entry)
|
|
386
|
-
},
|
|
387
|
-
providers: [
|
|
388
|
-
withGetQuote('across', async () => {
|
|
389
|
-
throw new TypeError(`wallet ${'0xaa'.repeat(20)} could not reach secret-provider`)
|
|
390
|
-
}),
|
|
391
|
-
relayProvider(),
|
|
392
|
-
],
|
|
393
|
-
})
|
|
394
|
-
|
|
395
|
-
const response = await app.request(quoteUrl(), { headers: TestApp.auth.headers })
|
|
396
|
-
|
|
397
|
-
expect(response.status).toBe(200)
|
|
398
|
-
expect(entries[0]?.fundingProviderAttempts).toEqual([
|
|
399
|
-
{
|
|
400
|
-
durationMs: expect.any(Number),
|
|
401
|
-
failure: 'network',
|
|
402
|
-
id: 'across',
|
|
403
|
-
operation: 'getQuote',
|
|
404
|
-
outcome: 'failed',
|
|
405
|
-
},
|
|
406
|
-
{
|
|
407
|
-
durationMs: expect.any(Number),
|
|
408
|
-
id: 'relay',
|
|
409
|
-
operation: 'getQuote',
|
|
410
|
-
outcome: 'available',
|
|
411
|
-
},
|
|
412
|
-
])
|
|
413
|
-
expect(JSON.stringify(entries[0])).not.toContain('secret-provider')
|
|
414
|
-
expect(JSON.stringify(entries[0])).not.toContain('0xaa')
|
|
415
|
-
})
|
|
416
|
-
|
|
417
|
-
test('rejects successful provider results without output amounts', async () => {
|
|
418
|
-
const app = create({
|
|
419
|
-
providers: [
|
|
420
|
-
withGetQuote('relay', async () => ({
|
|
421
|
-
sampledAt: '2026-01-01T00:00:00Z',
|
|
422
|
-
status: 'available',
|
|
423
|
-
})),
|
|
424
|
-
],
|
|
425
|
-
})
|
|
426
|
-
const response = await app.request(quoteUrl(), { headers: TestApp.auth.headers })
|
|
427
|
-
expect(response.status).toBe(502)
|
|
428
|
-
})
|
|
429
|
-
|
|
430
|
-
test('rejects invalid query parameters', async () => {
|
|
431
|
-
const response = await create().request(
|
|
432
|
-
quoteUrl({ sourceAmount: '1000000', sourceChain: 'base' }),
|
|
433
|
-
{ headers: TestApp.auth.headers },
|
|
434
|
-
)
|
|
435
|
-
expect(response.status).toBe(400)
|
|
436
|
-
const body = await TestApp.json(response, Schema.ErrorResponse)
|
|
437
|
-
expect(body.error.code).toBe('query_invalid')
|
|
438
|
-
})
|
|
439
|
-
|
|
440
|
-
test('rejects unsupported providers', async () => {
|
|
441
|
-
const response = await create().request(quoteUrl({ ...baseUsdcQuery, provider: 'bridge' }), {
|
|
442
|
-
headers: TestApp.auth.headers,
|
|
443
|
-
})
|
|
444
|
-
expect(response.status).toBe(400)
|
|
445
|
-
const body = await TestApp.json(response, Schema.ErrorResponse)
|
|
446
|
-
expect(body.error.code).toBe('query_invalid')
|
|
447
|
-
})
|
|
448
|
-
|
|
449
|
-
test('rejects unsupported source chains', async () => {
|
|
450
|
-
const response = await create().request(
|
|
451
|
-
quoteUrl({ ...baseUsdcQuery, sourceChain: 'bitcoin' }),
|
|
452
|
-
{
|
|
453
|
-
headers: TestApp.auth.headers,
|
|
454
|
-
},
|
|
455
|
-
)
|
|
456
|
-
expect(response.status).toBe(400)
|
|
457
|
-
const body = await TestApp.json(response, Schema.ErrorResponse)
|
|
458
|
-
expect(body.error.code).toBe('query_invalid')
|
|
459
|
-
})
|
|
460
|
-
|
|
461
|
-
test('does not expose the removed routes endpoint', async () => {
|
|
462
|
-
const response = await create().request(
|
|
463
|
-
'/v1/funding/routes?sourceChain=base&sourceToken=USDC&sourceAmount=1000000',
|
|
464
|
-
{ headers: TestApp.auth.headers },
|
|
465
|
-
)
|
|
466
|
-
expect(response.status).toBe(404)
|
|
467
|
-
})
|
|
468
|
-
})
|
|
469
|
-
|
|
470
|
-
describe('GET /funding/chains', () => {
|
|
471
|
-
test('lists only inputs supported by quote providers', async () => {
|
|
472
|
-
const response = await create({ providers }).request('/v1/funding/chains', {
|
|
473
|
-
headers: TestApp.auth.headers,
|
|
474
|
-
})
|
|
475
|
-
expect(response.status).toBe(200)
|
|
476
|
-
const body = await TestApp.json(response, Funding.schema.getFundingChains.Response)
|
|
477
|
-
|
|
478
|
-
expect(body.data.map((chain) => chain.id)).toEqual([
|
|
479
|
-
'eip155:1',
|
|
480
|
-
'eip155:56',
|
|
481
|
-
'eip155:8453',
|
|
482
|
-
'eip155:137',
|
|
483
|
-
'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
|
|
484
|
-
'tron:0x2b6653dc',
|
|
485
|
-
])
|
|
486
|
-
expect(body.data.flatMap((chain) => chain.tokens.map((token) => token.symbol))).toEqual(
|
|
487
|
-
expect.arrayContaining(['USDC', 'USDT']),
|
|
488
|
-
)
|
|
489
|
-
expect(
|
|
490
|
-
body.data.flatMap((chain) => chain.tokens).every((token) => token.currency === 'USD'),
|
|
491
|
-
).toBe(true)
|
|
492
|
-
})
|
|
493
|
-
})
|
|
494
|
-
|
|
495
|
-
describe('GET /funding/providers', () => {
|
|
496
|
-
test('lists supplied quote providers', async () => {
|
|
497
|
-
const app = create({ providers: [relayProvider()] })
|
|
498
|
-
const response = await app.request('/v1/funding/providers', {
|
|
499
|
-
headers: TestApp.auth.headers,
|
|
500
|
-
})
|
|
501
|
-
expect(response.status).toBe(200)
|
|
502
|
-
const body = await TestApp.json(response, Funding.schema.getFundingProviders.Response)
|
|
503
|
-
|
|
504
|
-
expect(body.data).toEqual([
|
|
505
|
-
{
|
|
506
|
-
id: 'relay',
|
|
507
|
-
name: 'Relay',
|
|
508
|
-
},
|
|
509
|
-
])
|
|
510
|
-
})
|
|
511
|
-
|
|
512
|
-
test('isolates cached inventories between apps', async () => {
|
|
513
|
-
const store = Store.memory()
|
|
514
|
-
const relayApp = create({ cache: { store }, providers: [relayProvider()] })
|
|
515
|
-
const acrossApp = create({ cache: { store }, providers: [acrossProvider()] })
|
|
516
|
-
|
|
517
|
-
const relayResponse = await relayApp.request('/v1/funding/providers', {
|
|
518
|
-
headers: TestApp.auth.headers,
|
|
519
|
-
})
|
|
520
|
-
const acrossResponse = await acrossApp.request('/v1/funding/providers', {
|
|
521
|
-
headers: TestApp.auth.headers,
|
|
522
|
-
})
|
|
523
|
-
const relayBody = await TestApp.json(relayResponse, Funding.schema.getFundingProviders.Response)
|
|
524
|
-
const acrossBody = await TestApp.json(
|
|
525
|
-
acrossResponse,
|
|
526
|
-
Funding.schema.getFundingProviders.Response,
|
|
527
|
-
)
|
|
528
|
-
|
|
529
|
-
expect(relayBody.data.map((provider) => provider.id)).toEqual(['relay'])
|
|
530
|
-
expect(acrossBody.data.map((provider) => provider.id)).toEqual(['across'])
|
|
531
|
-
})
|
|
532
|
-
|
|
533
|
-
test('isolates cached inventories between database sources', async () => {
|
|
534
|
-
const first = TestApp.database()
|
|
535
|
-
const second = TestApp.database()
|
|
536
|
-
const store = Store.memory()
|
|
537
|
-
await TestFunding.publish(first)
|
|
538
|
-
await FundingCatalog.publish(second, { ...TestFunding.data, routes: [] })
|
|
539
|
-
const firstApp = TestApp.create({ cache: { store }, db: first, providers: [relayProvider()] })
|
|
540
|
-
const secondApp = TestApp.create({ cache: { store }, db: second, providers: [relayProvider()] })
|
|
541
|
-
|
|
542
|
-
const firstResponse = await firstApp.request('/v1/funding/providers', {
|
|
543
|
-
headers: TestApp.auth.headers,
|
|
544
|
-
})
|
|
545
|
-
const secondResponse = await secondApp.request('/v1/funding/providers', {
|
|
546
|
-
headers: TestApp.auth.headers,
|
|
547
|
-
})
|
|
548
|
-
const firstBody = await TestApp.json(firstResponse, Funding.schema.getFundingProviders.Response)
|
|
549
|
-
const secondBody = await TestApp.json(
|
|
550
|
-
secondResponse,
|
|
551
|
-
Funding.schema.getFundingProviders.Response,
|
|
552
|
-
)
|
|
553
|
-
|
|
554
|
-
expect(firstBody.data.map((provider) => provider.id)).toEqual(['relay'])
|
|
555
|
-
expect(secondBody.data).toEqual([])
|
|
556
|
-
})
|
|
557
|
-
|
|
558
|
-
test('does not expose the removed generic provider endpoint', async () => {
|
|
559
|
-
const response = await create().request('/v1/providers', {
|
|
560
|
-
headers: TestApp.auth.headers,
|
|
561
|
-
})
|
|
562
|
-
expect(response.status).toBe(404)
|
|
563
|
-
})
|
|
564
|
-
})
|
|
565
|
-
|
|
566
|
-
describe('POST /funding/deposit-addresses', () => {
|
|
567
|
-
test('requires funding:write and an idempotency key', async () => {
|
|
568
|
-
const app = transfersApp({ providers: [depositAddressProvider()] })
|
|
569
|
-
expect((await postDepositAddress(app, { headers: {} })).status).toBe(401)
|
|
570
|
-
expect((await postDepositAddress(app, { key: fundingReader })).status).toBe(403)
|
|
571
|
-
const response = await postDepositAddress(app, {
|
|
572
|
-
headers: { authorization: `Bearer ${fundingWriter.token}` },
|
|
573
|
-
})
|
|
574
|
-
expect(response.status).toBe(400)
|
|
575
|
-
expect(((await response.json()) as { error: { code: string } }).error.code).toBe(
|
|
576
|
-
'idempotency_key_required',
|
|
577
|
-
)
|
|
578
|
-
})
|
|
579
|
-
|
|
580
|
-
test('requires a source amount', async () => {
|
|
581
|
-
const { amount: _amount, ...body } = depositAddressBody
|
|
582
|
-
const response = await postDepositAddress(
|
|
583
|
-
transfersApp({ providers: [depositAddressProvider()] }),
|
|
584
|
-
{ body },
|
|
585
|
-
)
|
|
586
|
-
|
|
587
|
-
expect({ body: await response.json(), status: response.status }).toMatchInlineSnapshot(
|
|
588
|
-
{
|
|
589
|
-
body: { requestId: expect.any(String) },
|
|
590
|
-
},
|
|
591
|
-
`
|
|
592
|
-
{
|
|
593
|
-
"body": {
|
|
594
|
-
"error": {
|
|
595
|
-
"code": "body_invalid",
|
|
596
|
-
"details": [
|
|
597
|
-
{
|
|
598
|
-
"message": "Invalid input: expected string, received undefined",
|
|
599
|
-
"path": [
|
|
600
|
-
"amount",
|
|
601
|
-
],
|
|
602
|
-
},
|
|
603
|
-
],
|
|
604
|
-
"message": "Check the request body and try again.",
|
|
605
|
-
},
|
|
606
|
-
"requestId": Any<String>,
|
|
607
|
-
},
|
|
608
|
-
"status": 400,
|
|
609
|
-
}
|
|
610
|
-
`,
|
|
611
|
-
)
|
|
612
|
-
})
|
|
613
|
-
|
|
614
|
-
test('creates a reusable address and persists private provider correlation', async () => {
|
|
615
|
-
let seen: FundingProvider.createDepositAddress.Parameters | undefined
|
|
616
|
-
const app = transfersApp({
|
|
617
|
-
providers: [
|
|
618
|
-
depositAddressProvider('TBXSw8fM4jpQkGc6zZjsVABFpVN7UvXPdV', (input) => {
|
|
619
|
-
seen = input
|
|
620
|
-
}),
|
|
621
|
-
],
|
|
622
|
-
})
|
|
623
|
-
const response = await postDepositAddress(app, {
|
|
624
|
-
body: { ...depositAddressBody, destinationToken: 'usdt0', sourceToken: 'usdt' },
|
|
625
|
-
})
|
|
626
|
-
expect(response.status).toBe(200)
|
|
627
|
-
expect(response.headers.get('cache-control')).toBe('no-store')
|
|
628
|
-
const body = await TestApp.json(response, Funding.schema.createFundingDepositAddress.Response)
|
|
629
|
-
expect(body).toMatchObject({
|
|
630
|
-
address: 'TBXSw8fM4jpQkGc6zZjsVABFpVN7UvXPdV',
|
|
631
|
-
destinationToken: { symbol: 'USDT0' },
|
|
632
|
-
provider: { id: 'relay', name: 'Relay' },
|
|
633
|
-
recipient: depositAddressBody.recipient,
|
|
634
|
-
refundAddress: depositAddressBody.refundAddress,
|
|
635
|
-
sourceChain: { id: 'tron:0x2b6653dc' },
|
|
636
|
-
sourceToken: { symbol: 'USDT' },
|
|
637
|
-
status: 'active',
|
|
638
|
-
})
|
|
639
|
-
expect(seen).toMatchObject({
|
|
640
|
-
recipient: depositAddressBody.recipient,
|
|
641
|
-
refundAddress: depositAddressBody.refundAddress,
|
|
642
|
-
})
|
|
643
|
-
const record = await FundingDepositAddresses.get(db, body.id)
|
|
644
|
-
expect(record).toMatchObject({
|
|
645
|
-
deliveryStrategy: 'provider',
|
|
646
|
-
providerOutputToken: body.destinationToken,
|
|
647
|
-
providerRequestIds: ['relay_request_1'],
|
|
648
|
-
providerState: { checkEndpoint: '/intents/status/v3?requestId=relay_request_1' },
|
|
649
|
-
})
|
|
650
|
-
})
|
|
651
|
-
|
|
652
|
-
test('resolves token identifiers through the composed route', async () => {
|
|
653
|
-
const app = transfersApp({
|
|
654
|
-
providers: [depositAddressProvider('T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb')],
|
|
655
|
-
})
|
|
656
|
-
const identifiers = [
|
|
657
|
-
{ destinationToken: 'usdt0', name: 'alias', sourceToken: 'usdt' },
|
|
658
|
-
{
|
|
659
|
-
destinationToken: depositAddressBody.destinationToken,
|
|
660
|
-
name: 'address',
|
|
661
|
-
sourceToken: depositAddressBody.sourceToken,
|
|
662
|
-
},
|
|
663
|
-
{
|
|
664
|
-
destinationToken: 'eip155:4217/erc20:0x20c00000000000000000000014f22ca97301eb73',
|
|
665
|
-
name: 'token key',
|
|
666
|
-
sourceToken: 'tron:0x2b6653dc/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t',
|
|
667
|
-
},
|
|
668
|
-
] as const
|
|
669
|
-
const results = []
|
|
670
|
-
for (const identifier of identifiers) {
|
|
671
|
-
const response = await postDepositAddress(app, {
|
|
672
|
-
body: {
|
|
673
|
-
...depositAddressBody,
|
|
674
|
-
destinationToken: identifier.destinationToken,
|
|
675
|
-
recipient: `0x${'55'.repeat(20)}`,
|
|
676
|
-
refundAddress: 'TD5gsCwxykWsLN9aPrq2TAfNjByuZKYp4E',
|
|
677
|
-
sourceToken: identifier.sourceToken,
|
|
678
|
-
},
|
|
679
|
-
})
|
|
680
|
-
const body = await TestApp.json(response, Funding.schema.createFundingDepositAddress.Response)
|
|
681
|
-
results.push({
|
|
682
|
-
destinationToken: body.destinationToken.symbol,
|
|
683
|
-
name: identifier.name,
|
|
684
|
-
sourceToken: body.sourceToken.symbol,
|
|
685
|
-
status: response.status,
|
|
686
|
-
})
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
expect(results).toMatchInlineSnapshot(`
|
|
690
|
-
[
|
|
691
|
-
{
|
|
692
|
-
"destinationToken": "USDT0",
|
|
693
|
-
"name": "alias",
|
|
694
|
-
"sourceToken": "USDT",
|
|
695
|
-
"status": 200,
|
|
696
|
-
},
|
|
697
|
-
{
|
|
698
|
-
"destinationToken": "USDT0",
|
|
699
|
-
"name": "address",
|
|
700
|
-
"sourceToken": "USDT",
|
|
701
|
-
"status": 200,
|
|
702
|
-
},
|
|
703
|
-
{
|
|
704
|
-
"destinationToken": "USDT0",
|
|
705
|
-
"name": "token key",
|
|
706
|
-
"sourceToken": "USDT",
|
|
707
|
-
"status": 200,
|
|
708
|
-
},
|
|
709
|
-
]
|
|
710
|
-
`)
|
|
711
|
-
})
|
|
712
|
-
|
|
713
|
-
test('omits newly quoted terms from a matching reusable address', async () => {
|
|
714
|
-
let calls = 0
|
|
715
|
-
const body = { ...depositAddressBody, refundAddress: 'TD5gsCwxykWsLN9aPrq2TAfNjByuZKYp4E' }
|
|
716
|
-
const app = transfersApp({
|
|
717
|
-
providers: [
|
|
718
|
-
depositAddressProvider('TEdvoHEatmDKvTh3o9vBRB9Vdtbhn4QFhy', () => {
|
|
719
|
-
calls += 1
|
|
720
|
-
}),
|
|
721
|
-
],
|
|
722
|
-
})
|
|
723
|
-
const first = await TestApp.json(
|
|
724
|
-
await postDepositAddress(app, { body, idempotencyKey: nextDepositAddressKey() }),
|
|
725
|
-
Funding.schema.createFundingDepositAddress.Response,
|
|
726
|
-
)
|
|
727
|
-
const second = await TestApp.json(
|
|
728
|
-
await postDepositAddress(app, {
|
|
729
|
-
body: { ...body, amount: '200000000' },
|
|
730
|
-
idempotencyKey: nextDepositAddressKey(),
|
|
731
|
-
}),
|
|
732
|
-
Funding.schema.createFundingDepositAddress.Response,
|
|
733
|
-
)
|
|
734
|
-
if (!('sourceAmount' in first)) throw new Error('Expected newly provisioned terms.')
|
|
735
|
-
|
|
736
|
-
expect({
|
|
737
|
-
calls,
|
|
738
|
-
firstHasQuote: 'quote' in first,
|
|
739
|
-
firstId: first.id,
|
|
740
|
-
firstSourceAmount: first.sourceAmount.baseUnits,
|
|
741
|
-
secondHasQuote: 'quote' in second,
|
|
742
|
-
secondId: second.id,
|
|
743
|
-
}).toMatchInlineSnapshot(
|
|
744
|
-
{
|
|
745
|
-
firstId: expect.any(String),
|
|
746
|
-
secondId: expect.any(String),
|
|
747
|
-
},
|
|
748
|
-
`
|
|
749
|
-
{
|
|
750
|
-
"calls": 2,
|
|
751
|
-
"firstHasQuote": true,
|
|
752
|
-
"firstId": Any<String>,
|
|
753
|
-
"firstSourceAmount": "100000000",
|
|
754
|
-
"secondHasQuote": false,
|
|
755
|
-
"secondId": Any<String>,
|
|
756
|
-
}
|
|
757
|
-
`,
|
|
758
|
-
)
|
|
759
|
-
expect(second.id).toBe(first.id)
|
|
760
|
-
})
|
|
761
|
-
|
|
762
|
-
test('stores subsidized addresses as a separate reusable identity', async () => {
|
|
763
|
-
const inputs: FundingProvider.createDepositAddress.Parameters[] = []
|
|
764
|
-
const app = transfersApp({
|
|
765
|
-
providers: [
|
|
766
|
-
withCreateDepositAddress('relay', async (input) => {
|
|
767
|
-
inputs.push(input)
|
|
768
|
-
return {
|
|
769
|
-
address: inputs.length === 1 ? `0x${'31'.repeat(20)}` : `0x${'32'.repeat(20)}`,
|
|
770
|
-
correlation: { requestId: `relay_request_${inputs.length}` },
|
|
771
|
-
destinationAmount: '1000000',
|
|
772
|
-
destinationAmountMin: '1000000',
|
|
773
|
-
fees: [],
|
|
774
|
-
sampledAt: '2026-08-04T00:00:00.000Z',
|
|
775
|
-
}
|
|
776
|
-
}),
|
|
777
|
-
],
|
|
778
|
-
})
|
|
779
|
-
const body = {
|
|
780
|
-
...depositAddressBody,
|
|
781
|
-
refundAddress: `0x${'33'.repeat(20)}`,
|
|
782
|
-
sourceChain: 'base',
|
|
783
|
-
sourceToken: '0xfde4c96c8593536e31f229ea8f37b2ada2699bb2',
|
|
784
|
-
}
|
|
785
|
-
const standard = await TestApp.json(
|
|
786
|
-
await postDepositAddress(app, { body }),
|
|
787
|
-
Funding.schema.createFundingDepositAddress.Response,
|
|
788
|
-
)
|
|
789
|
-
const subsidized = await TestApp.json(
|
|
790
|
-
await postDepositAddress(app, { body: { ...body, subsidize: true } }),
|
|
791
|
-
Funding.schema.createFundingDepositAddress.Response,
|
|
792
|
-
)
|
|
793
|
-
|
|
794
|
-
expect({
|
|
795
|
-
distinct: standard.id !== subsidized.id,
|
|
796
|
-
inputs: inputs.map(({ subsidize }) => subsidize),
|
|
797
|
-
standard: standard.subsidize,
|
|
798
|
-
subsidized: subsidized.subsidize,
|
|
799
|
-
}).toMatchInlineSnapshot(`
|
|
800
|
-
{
|
|
801
|
-
"distinct": true,
|
|
802
|
-
"inputs": [
|
|
803
|
-
false,
|
|
804
|
-
true,
|
|
805
|
-
],
|
|
806
|
-
"standard": false,
|
|
807
|
-
"subsidized": true,
|
|
808
|
-
}
|
|
809
|
-
`)
|
|
810
|
-
})
|
|
811
|
-
|
|
812
|
-
test('normalizes EVM accounts before provisioning and matching', async () => {
|
|
813
|
-
const inputs: FundingProvider.createDepositAddress.Parameters[] = []
|
|
814
|
-
const recipient = '0xABcdEFABcdEFabcdEfAbCdefabcdeFABcDEFabCD'
|
|
815
|
-
const refundAddress = '0x1234567890AbcdEF1234567890aBcdef12345678'
|
|
816
|
-
const body = {
|
|
817
|
-
...depositAddressBody,
|
|
818
|
-
recipient,
|
|
819
|
-
refundAddress,
|
|
820
|
-
sourceChain: 'base',
|
|
821
|
-
sourceToken: '0xfde4c96c8593536e31f229ea8f37b2ada2699bb2',
|
|
822
|
-
}
|
|
823
|
-
const app = transfersApp({
|
|
824
|
-
providers: [
|
|
825
|
-
depositAddressProvider(`0x${'99'.repeat(20)}`, (input) => {
|
|
826
|
-
inputs.push(input)
|
|
827
|
-
}),
|
|
828
|
-
],
|
|
829
|
-
})
|
|
830
|
-
const first = await TestApp.json(
|
|
831
|
-
await postDepositAddress(app, { body, idempotencyKey: nextDepositAddressKey() }),
|
|
832
|
-
Funding.schema.createFundingDepositAddress.Response,
|
|
833
|
-
)
|
|
834
|
-
const second = await TestApp.json(
|
|
835
|
-
await postDepositAddress(app, {
|
|
836
|
-
body: {
|
|
837
|
-
...body,
|
|
838
|
-
recipient: recipient.toLowerCase(),
|
|
839
|
-
refundAddress: refundAddress.toLowerCase(),
|
|
840
|
-
},
|
|
841
|
-
idempotencyKey: nextDepositAddressKey(),
|
|
842
|
-
}),
|
|
843
|
-
Funding.schema.createFundingDepositAddress.Response,
|
|
844
|
-
)
|
|
845
|
-
|
|
846
|
-
expect({
|
|
847
|
-
first: { recipient: first.recipient, refundAddress: first.refundAddress },
|
|
848
|
-
inputs: inputs.map(({ recipient, refundAddress }) => ({ recipient, refundAddress })),
|
|
849
|
-
reused: first.id === second.id,
|
|
850
|
-
second: { recipient: second.recipient, refundAddress: second.refundAddress },
|
|
851
|
-
}).toMatchInlineSnapshot(`
|
|
852
|
-
{
|
|
853
|
-
"first": {
|
|
854
|
-
"recipient": "0xABcdEFABcdEFabcdEfAbCdefabcdeFABcDEFabCD",
|
|
855
|
-
"refundAddress": "0x1234567890AbcdEF1234567890aBcdef12345678",
|
|
856
|
-
},
|
|
857
|
-
"inputs": [
|
|
858
|
-
{
|
|
859
|
-
"recipient": "0xABcdEFABcdEFabcdEfAbCdefabcdeFABcDEFabCD",
|
|
860
|
-
"refundAddress": "0x1234567890AbcdEF1234567890aBcdef12345678",
|
|
861
|
-
},
|
|
862
|
-
{
|
|
863
|
-
"recipient": "0xABcdEFABcdEFabcdEfAbCdefabcdeFABcDEFabCD",
|
|
864
|
-
"refundAddress": "0x1234567890AbcdEF1234567890aBcdef12345678",
|
|
865
|
-
},
|
|
866
|
-
],
|
|
867
|
-
"reused": true,
|
|
868
|
-
"second": {
|
|
869
|
-
"recipient": "0xABcdEFABcdEFabcdEfAbCdefabcdeFABcDEFabCD",
|
|
870
|
-
"refundAddress": "0x1234567890AbcdEF1234567890aBcdef12345678",
|
|
871
|
-
},
|
|
872
|
-
}
|
|
873
|
-
`)
|
|
874
|
-
})
|
|
875
|
-
|
|
876
|
-
test('keeps the stored reusable address when the provider rotates addresses', async () => {
|
|
877
|
-
const body = { ...depositAddressBody, recipient: `0x${'22'.repeat(20)}` }
|
|
878
|
-
const first = await TestApp.json(
|
|
879
|
-
await postDepositAddress(
|
|
880
|
-
transfersApp({
|
|
881
|
-
providers: [depositAddressProvider('TGCAjMXComunWZEXCT1LPBdcYbDVuyexBv')],
|
|
882
|
-
}),
|
|
883
|
-
{ body, idempotencyKey: nextDepositAddressKey() },
|
|
884
|
-
),
|
|
885
|
-
Funding.schema.createFundingDepositAddress.Response,
|
|
886
|
-
)
|
|
887
|
-
|
|
888
|
-
let calls = 0
|
|
889
|
-
const app = transfersApp({
|
|
890
|
-
providers: [
|
|
891
|
-
depositAddressProvider('THkQfRopincF6emzbk6VMC7jTHqJ8MP8g7', () => {
|
|
892
|
-
calls += 1
|
|
893
|
-
}),
|
|
894
|
-
],
|
|
895
|
-
})
|
|
896
|
-
const idempotencyKey = nextDepositAddressKey()
|
|
897
|
-
const response = await postDepositAddress(app, {
|
|
898
|
-
body: { ...body, amount: '200000000' },
|
|
899
|
-
idempotencyKey,
|
|
900
|
-
})
|
|
901
|
-
const second = await TestApp.json(response, Funding.schema.createFundingDepositAddress.Response)
|
|
902
|
-
|
|
903
|
-
expect({
|
|
904
|
-
address: second.address,
|
|
905
|
-
calls,
|
|
906
|
-
hasQuote: 'quote' in second,
|
|
907
|
-
reused: second.id === first.id,
|
|
908
|
-
status: response.status,
|
|
909
|
-
}).toMatchInlineSnapshot(
|
|
910
|
-
{
|
|
911
|
-
address: expect.any(String),
|
|
912
|
-
},
|
|
913
|
-
`
|
|
914
|
-
{
|
|
915
|
-
"address": Any<String>,
|
|
916
|
-
"calls": 1,
|
|
917
|
-
"hasQuote": false,
|
|
918
|
-
"reused": true,
|
|
919
|
-
"status": 200,
|
|
920
|
-
}
|
|
921
|
-
`,
|
|
922
|
-
)
|
|
923
|
-
expect(second.address).toBe(first.address)
|
|
924
|
-
expect(
|
|
925
|
-
await TestApp.json(
|
|
926
|
-
await postDepositAddress(app, {
|
|
927
|
-
body: { ...body, amount: '200000000' },
|
|
928
|
-
idempotencyKey,
|
|
929
|
-
}),
|
|
930
|
-
Funding.schema.createFundingDepositAddress.Response,
|
|
931
|
-
),
|
|
932
|
-
).toEqual(second)
|
|
933
|
-
expect(calls).toBe(1)
|
|
934
|
-
})
|
|
935
|
-
|
|
936
|
-
test('rejects a refund address from another chain', async () => {
|
|
937
|
-
const response = await postDepositAddress(
|
|
938
|
-
transfersApp({ providers: [depositAddressProvider()] }),
|
|
939
|
-
{ body: { ...depositAddressBody, refundAddress: `0x${'11'.repeat(20)}` } },
|
|
940
|
-
)
|
|
941
|
-
|
|
942
|
-
expect({ body: await response.json(), status: response.status }).toMatchInlineSnapshot(
|
|
943
|
-
{
|
|
944
|
-
body: { requestId: expect.any(String) },
|
|
945
|
-
},
|
|
946
|
-
`
|
|
947
|
-
{
|
|
948
|
-
"body": {
|
|
949
|
-
"error": {
|
|
950
|
-
"code": "body_invalid",
|
|
951
|
-
"message": "Invalid \`refundAddress\`.",
|
|
952
|
-
},
|
|
953
|
-
"requestId": Any<String>,
|
|
954
|
-
},
|
|
955
|
-
"status": 400,
|
|
956
|
-
}
|
|
957
|
-
`,
|
|
958
|
-
)
|
|
959
|
-
})
|
|
960
|
-
|
|
961
|
-
test('replays matching idempotent requests and rejects changed input', async () => {
|
|
962
|
-
const body = { ...depositAddressBody, refundAddress: 'TKJebW6SdoJhgkKU13BeKCbrMzT6HeQhJ6' }
|
|
963
|
-
const app = transfersApp({
|
|
964
|
-
providers: [depositAddressProvider('TLrtXaP4Yp1AGqrwQLGoHD5yGh4tTAvPbe')],
|
|
965
|
-
})
|
|
966
|
-
const idempotencyKey = nextDepositAddressKey()
|
|
967
|
-
const first = await postDepositAddress(app, { body, idempotencyKey })
|
|
968
|
-
const replay = await postDepositAddress(app, { body, idempotencyKey })
|
|
969
|
-
expect(replay.status).toBe(200)
|
|
970
|
-
expect(await replay.json()).toEqual(await first.json())
|
|
971
|
-
|
|
972
|
-
const conflict = await postDepositAddress(app, {
|
|
973
|
-
body: { ...depositAddressBody, refundAddress: 'TNR8TefgTphcrwQQodMxFDa6BPggc2CZeE' },
|
|
974
|
-
idempotencyKey,
|
|
975
|
-
})
|
|
976
|
-
expect(conflict.status).toBe(409)
|
|
977
|
-
expect(((await conflict.json()) as { error: { code: string } }).error.code).toBe(
|
|
978
|
-
'idempotency_conflict',
|
|
979
|
-
)
|
|
980
|
-
})
|
|
981
|
-
|
|
982
|
-
test('defaults subsidize for legacy idempotency replays', async () => {
|
|
983
|
-
const app = transfersApp({
|
|
984
|
-
providers: [depositAddressProvider(`0x${'45'.repeat(20)}`)],
|
|
985
|
-
})
|
|
986
|
-
const requestBody = {
|
|
987
|
-
...depositAddressBody,
|
|
988
|
-
refundAddress: `0x${'44'.repeat(20)}`,
|
|
989
|
-
sourceChain: 'base',
|
|
990
|
-
sourceToken: '0xfde4c96c8593536e31f229ea8f37b2ada2699bb2',
|
|
991
|
-
}
|
|
992
|
-
const idempotencyKey = nextDepositAddressKey()
|
|
993
|
-
const first = await postDepositAddress(app, { body: requestBody, idempotencyKey })
|
|
994
|
-
const response = await TestApp.json(first, Funding.schema.createFundingDepositAddress.Response)
|
|
995
|
-
const { subsidize: _, ...legacy } = response
|
|
996
|
-
await db.kysely
|
|
997
|
-
.updateTable('funding_idempotency_requests')
|
|
998
|
-
.set({ response: JSON.stringify(legacy) })
|
|
999
|
-
.where('apiKeyId', '=', fundingWriter.id)
|
|
1000
|
-
.where('keyHash', '=', Idempotency.inputHash(idempotencyKey))
|
|
1001
|
-
.returningAll()
|
|
1002
|
-
.executeTakeFirstOrThrow()
|
|
1003
|
-
|
|
1004
|
-
const replay = await postDepositAddress(app, { body: requestBody, idempotencyKey })
|
|
1005
|
-
const response_replay = await TestApp.json(
|
|
1006
|
-
replay,
|
|
1007
|
-
Funding.schema.createFundingDepositAddress.Response,
|
|
1008
|
-
)
|
|
1009
|
-
|
|
1010
|
-
expect({ status: replay.status, subsidize: response_replay.subsidize }).toMatchInlineSnapshot(`
|
|
1011
|
-
{
|
|
1012
|
-
"status": 200,
|
|
1013
|
-
"subsidize": false,
|
|
1014
|
-
}
|
|
1015
|
-
`)
|
|
1016
|
-
})
|
|
1017
|
-
|
|
1018
|
-
test('preserves the idempotency fingerprint when subsidize is omitted', async () => {
|
|
1019
|
-
const idempotencyKey = nextDepositAddressKey()
|
|
1020
|
-
await FundingIdempotencyTable.claim(db, {
|
|
1021
|
-
apiKeyId: fundingWriter.id,
|
|
1022
|
-
keyHash: Idempotency.inputHash(idempotencyKey),
|
|
1023
|
-
requestHash: Idempotency.inputHash({
|
|
1024
|
-
body: depositAddressBody,
|
|
1025
|
-
endpoint: 'POST /v1/funding/deposit-addresses',
|
|
1026
|
-
}),
|
|
1027
|
-
ttlMs: 60_000,
|
|
1028
|
-
})
|
|
1029
|
-
|
|
1030
|
-
const response = await postDepositAddress(transfersApp(), { idempotencyKey })
|
|
1031
|
-
|
|
1032
|
-
expect({ body: await response.json(), status: response.status }).toMatchInlineSnapshot(
|
|
1033
|
-
{
|
|
1034
|
-
body: { requestId: expect.any(String) },
|
|
1035
|
-
},
|
|
1036
|
-
`
|
|
1037
|
-
{
|
|
1038
|
-
"body": {
|
|
1039
|
-
"error": {
|
|
1040
|
-
"code": "idempotency_in_progress",
|
|
1041
|
-
"message": "An equivalent funding deposit address creation is still running.",
|
|
1042
|
-
},
|
|
1043
|
-
"requestId": Any<String>,
|
|
1044
|
-
},
|
|
1045
|
-
"status": 409,
|
|
1046
|
-
}
|
|
1047
|
-
`,
|
|
1048
|
-
)
|
|
1049
|
-
})
|
|
1050
|
-
|
|
1051
|
-
test('includes the creation endpoint in the idempotency fingerprint', async () => {
|
|
1052
|
-
const idempotencyKey = nextDepositAddressKey()
|
|
1053
|
-
await FundingIdempotencyTable.claim(db, {
|
|
1054
|
-
apiKeyId: fundingWriter.id,
|
|
1055
|
-
keyHash: Idempotency.inputHash(idempotencyKey),
|
|
1056
|
-
requestHash: Idempotency.inputHash(depositAddressBody),
|
|
1057
|
-
ttlMs: 60_000,
|
|
1058
|
-
})
|
|
1059
|
-
|
|
1060
|
-
const response = await postDepositAddress(
|
|
1061
|
-
transfersApp({ providers: [depositAddressProvider()] }),
|
|
1062
|
-
{ idempotencyKey },
|
|
1063
|
-
)
|
|
1064
|
-
expect(response.status).toBe(409)
|
|
1065
|
-
expect(((await response.json()) as { error: { code: string } }).error.code).toBe(
|
|
1066
|
-
'idempotency_conflict',
|
|
1067
|
-
)
|
|
1068
|
-
})
|
|
1069
|
-
|
|
1070
|
-
test('returns not available without a registered provider', async () => {
|
|
1071
|
-
const response = await postDepositAddress(transfersApp({ providers: [] }))
|
|
1072
|
-
expect(response.status).toBe(404)
|
|
1073
|
-
expect(((await response.json()) as { error: { code: string } }).error.code).toBe(
|
|
1074
|
-
'deposit_address_not_available',
|
|
1075
|
-
)
|
|
1076
|
-
})
|
|
1077
|
-
|
|
1078
|
-
test('reports unexpected provider failures', async () => {
|
|
1079
|
-
const entries: Log.Entry[] = []
|
|
1080
|
-
const app = transfersApp({
|
|
1081
|
-
logger: (entry) => {
|
|
1082
|
-
entries.push(entry)
|
|
1083
|
-
},
|
|
1084
|
-
providers: [
|
|
1085
|
-
withCreateDepositAddress('relay', async () => {
|
|
1086
|
-
throw new Error('upstream failed')
|
|
1087
|
-
}),
|
|
1088
|
-
],
|
|
1089
|
-
})
|
|
1090
|
-
const response = await postDepositAddress(app, {
|
|
1091
|
-
body: { ...depositAddressBody, refundAddress: 'T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb' },
|
|
1092
|
-
})
|
|
1093
|
-
expect(response.status).toBe(502)
|
|
1094
|
-
expect(((await response.json()) as { error: { code: string } }).error.code).toBe(
|
|
1095
|
-
'upstream_error',
|
|
1096
|
-
)
|
|
1097
|
-
expect(entries.at(-1)).toMatchObject({
|
|
1098
|
-
provider: {
|
|
1099
|
-
failure: 'unknown',
|
|
1100
|
-
id: 'relay',
|
|
1101
|
-
operation: 'createDepositAddress',
|
|
1102
|
-
},
|
|
1103
|
-
status: 502,
|
|
1104
|
-
})
|
|
1105
|
-
})
|
|
1106
|
-
})
|
|
1107
|
-
|
|
1108
|
-
describe('POST /webhooks/funding/relay', () => {
|
|
1109
|
-
test('authenticates and enqueues a bounded reconciliation hint', async () => {
|
|
1110
|
-
const secret = 'relay-webhook-secret'
|
|
1111
|
-
const address = await seedDepositAddress({
|
|
1112
|
-
snapshot: TestFunding.depositAddressSnapshot({
|
|
1113
|
-
provider: { id: 'relay', name: 'Relay' },
|
|
1114
|
-
recipient: `0x${'99'.repeat(20)}`,
|
|
1115
|
-
}),
|
|
1116
|
-
})
|
|
1117
|
-
const dispatched: FundingProvider.Webhook.receive.Dispatchable[] = []
|
|
1118
|
-
const app = transfersApp({
|
|
1119
|
-
providers: [
|
|
1120
|
-
Relay.relay({
|
|
1121
|
-
apiKey: secret,
|
|
1122
|
-
webhook: {
|
|
1123
|
-
db,
|
|
1124
|
-
dispatch: async (message) => {
|
|
1125
|
-
dispatched.push(message)
|
|
1126
|
-
},
|
|
1127
|
-
},
|
|
1128
|
-
}),
|
|
1129
|
-
],
|
|
1130
|
-
})
|
|
1131
|
-
const body = JSON.stringify({
|
|
1132
|
-
data: {
|
|
1133
|
-
depositAddress: { address: address.address },
|
|
1134
|
-
requestId: 'relay-child-request',
|
|
1135
|
-
},
|
|
1136
|
-
event: 'request.status.updated',
|
|
1137
|
-
})
|
|
1138
|
-
const timestamp = String(Math.floor(Date.now() / 1_000))
|
|
1139
|
-
const signature = Hash.hmac256(
|
|
1140
|
-
Hex.fromString(secret),
|
|
1141
|
-
Hex.fromString(`${timestamp}.${body}`),
|
|
1142
|
-
).slice(2)
|
|
1143
|
-
const response = await app.request('/webhooks/funding/relay', {
|
|
1144
|
-
body,
|
|
1145
|
-
headers: {
|
|
1146
|
-
'x-signature-sha256': signature,
|
|
1147
|
-
'x-signature-timestamp': timestamp,
|
|
1148
|
-
},
|
|
1149
|
-
method: 'POST',
|
|
1150
|
-
})
|
|
1151
|
-
|
|
1152
|
-
expect({ dispatched, status: response.status }).toMatchInlineSnapshot(`
|
|
1153
|
-
{
|
|
1154
|
-
"dispatched": [
|
|
1155
|
-
{
|
|
1156
|
-
"addressId": "${address.id}",
|
|
1157
|
-
"requestId": "relay-child-request",
|
|
1158
|
-
"type": "funding:deposit-address:reconcile",
|
|
1159
|
-
},
|
|
1160
|
-
],
|
|
1161
|
-
"status": 204,
|
|
1162
|
-
}
|
|
1163
|
-
`)
|
|
1164
|
-
})
|
|
1165
|
-
|
|
1166
|
-
test('rejects unauthenticated events', async () => {
|
|
1167
|
-
const app = transfersApp({
|
|
1168
|
-
providers: [
|
|
1169
|
-
Relay.relay({
|
|
1170
|
-
apiKey: 'relay-webhook-secret',
|
|
1171
|
-
webhook: { db, dispatch: async () => {} },
|
|
1172
|
-
}),
|
|
1173
|
-
],
|
|
1174
|
-
})
|
|
1175
|
-
const response = await app.request('/webhooks/funding/relay', {
|
|
1176
|
-
body: '{}',
|
|
1177
|
-
method: 'POST',
|
|
1178
|
-
})
|
|
1179
|
-
|
|
1180
|
-
expect(response.status).toMatchInlineSnapshot('401')
|
|
1181
|
-
})
|
|
1182
|
-
})
|
|
1183
|
-
|
|
1184
|
-
describe.skipIf(!relayApiKey)('POST /funding/deposit-addresses (relay)', () => {
|
|
1185
|
-
test('provisions and persists a reusable address through Relay', async () => {
|
|
1186
|
-
const app = transfersApp({
|
|
1187
|
-
providers: [Relay.relay({ apiKey: relayApiKey })],
|
|
1188
|
-
})
|
|
1189
|
-
const response = await postDepositAddress(app, {
|
|
1190
|
-
body: { ...depositAddressBody, recipient: `0x${'77'.repeat(20)}` },
|
|
1191
|
-
})
|
|
1192
|
-
|
|
1193
|
-
expect(response.status).toBe(200)
|
|
1194
|
-
const body = await TestApp.json(response, Funding.schema.createFundingDepositAddress.Response)
|
|
1195
|
-
expect(body).toMatchInlineSnapshot(
|
|
1196
|
-
{
|
|
1197
|
-
address: expect.any(String),
|
|
1198
|
-
createdAt: expect.any(String),
|
|
1199
|
-
destinationAmount: {
|
|
1200
|
-
baseUnits: expect.any(String),
|
|
1201
|
-
formatted: expect.any(String),
|
|
1202
|
-
},
|
|
1203
|
-
destinationAmountMin: {
|
|
1204
|
-
baseUnits: expect.any(String),
|
|
1205
|
-
formatted: expect.any(String),
|
|
1206
|
-
},
|
|
1207
|
-
fees: expect.any(Array),
|
|
1208
|
-
id: expect.any(String),
|
|
1209
|
-
quote: { sampledAt: expect.any(String) },
|
|
1210
|
-
updatedAt: expect.any(String),
|
|
1211
|
-
},
|
|
1212
|
-
`
|
|
1213
|
-
{
|
|
1214
|
-
"address": Any<String>,
|
|
1215
|
-
"createdAt": Any<String>,
|
|
1216
|
-
"destinationAmount": {
|
|
1217
|
-
"baseUnits": Any<String>,
|
|
1218
|
-
"currency": "USD",
|
|
1219
|
-
"decimals": 6,
|
|
1220
|
-
"formatted": Any<String>,
|
|
1221
|
-
},
|
|
1222
|
-
"destinationAmountMin": {
|
|
1223
|
-
"baseUnits": Any<String>,
|
|
1224
|
-
"currency": "USD",
|
|
1225
|
-
"decimals": 6,
|
|
1226
|
-
"formatted": Any<String>,
|
|
1227
|
-
},
|
|
1228
|
-
"destinationChain": {
|
|
1229
|
-
"addressFormat": "hex",
|
|
1230
|
-
"id": "eip155:4217",
|
|
1231
|
-
"kind": "evm",
|
|
1232
|
-
"name": "Tempo",
|
|
1233
|
-
},
|
|
1234
|
-
"destinationToken": {
|
|
1235
|
-
"address": "0x20c00000000000000000000014f22ca97301eb73",
|
|
1236
|
-
"currency": "USD",
|
|
1237
|
-
"decimals": 6,
|
|
1238
|
-
"name": "USDT0",
|
|
1239
|
-
"standard": "TIP-20",
|
|
1240
|
-
"symbol": "USDT0",
|
|
1241
|
-
"tokenKey": "eip155:4217/erc20:0x20c00000000000000000000014f22ca97301eb73",
|
|
1242
|
-
"verified": true,
|
|
1243
|
-
},
|
|
1244
|
-
"fees": Any<Array>,
|
|
1245
|
-
"id": Any<String>,
|
|
1246
|
-
"provider": {
|
|
1247
|
-
"id": "relay",
|
|
1248
|
-
"name": "Relay",
|
|
1249
|
-
},
|
|
1250
|
-
"quote": {
|
|
1251
|
-
"sampledAt": Any<String>,
|
|
1252
|
-
},
|
|
1253
|
-
"recipient": "0x7777777777777777777777777777777777777777",
|
|
1254
|
-
"refundAddress": "TJRabPrwbZy45sbavfcjinPJC18kjpRTv8",
|
|
1255
|
-
"sourceAmount": {
|
|
1256
|
-
"baseUnits": "100000000",
|
|
1257
|
-
"currency": "USD",
|
|
1258
|
-
"decimals": 6,
|
|
1259
|
-
"formatted": "100",
|
|
1260
|
-
},
|
|
1261
|
-
"sourceChain": {
|
|
1262
|
-
"addressFormat": "base58check",
|
|
1263
|
-
"id": "tron:0x2b6653dc",
|
|
1264
|
-
"kind": "tron",
|
|
1265
|
-
"name": "Tron",
|
|
1266
|
-
},
|
|
1267
|
-
"sourceToken": {
|
|
1268
|
-
"address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
|
|
1269
|
-
"currency": "USD",
|
|
1270
|
-
"decimals": 6,
|
|
1271
|
-
"name": "Tether USD",
|
|
1272
|
-
"standard": "TRC-20",
|
|
1273
|
-
"symbol": "USDT",
|
|
1274
|
-
"tokenKey": "tron:0x2b6653dc/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
|
|
1275
|
-
"verified": true,
|
|
1276
|
-
},
|
|
1277
|
-
"status": "active",
|
|
1278
|
-
"subsidize": false,
|
|
1279
|
-
"updatedAt": Any<String>,
|
|
1280
|
-
}
|
|
1281
|
-
`,
|
|
1282
|
-
)
|
|
1283
|
-
const record = await FundingDepositAddresses.get(db, body.id)
|
|
1284
|
-
expect(record).toMatchInlineSnapshot(
|
|
1285
|
-
{
|
|
1286
|
-
address: expect.any(String),
|
|
1287
|
-
createdAt: expect.any(String),
|
|
1288
|
-
id: expect.any(String),
|
|
1289
|
-
nextPollAt: expect.any(String),
|
|
1290
|
-
providerRequestIds: [expect.any(String)],
|
|
1291
|
-
providerState: expect.any(Object),
|
|
1292
|
-
snapshot: { address: expect.any(String) },
|
|
1293
|
-
updatedAt: expect.any(String),
|
|
1294
|
-
},
|
|
1295
|
-
`
|
|
1296
|
-
{
|
|
1297
|
-
"address": Any<String>,
|
|
1298
|
-
"apiKeyId": "key_funding_writer",
|
|
1299
|
-
"createdAt": Any<String>,
|
|
1300
|
-
"deliveryStrategy": "provider",
|
|
1301
|
-
"destinationTokenKey": "eip155:4217/erc20:0x20c00000000000000000000014f22ca97301eb73",
|
|
1302
|
-
"environment": "production",
|
|
1303
|
-
"id": Any<String>,
|
|
1304
|
-
"lastPolledAt": null,
|
|
1305
|
-
"nextPollAt": Any<String>,
|
|
1306
|
-
"orgId": "org_funding",
|
|
1307
|
-
"pollFailureCount": 0,
|
|
1308
|
-
"pollLeaseUntil": null,
|
|
1309
|
-
"pollLeaseVersion": 0,
|
|
1310
|
-
"projectId": null,
|
|
1311
|
-
"providerId": "relay",
|
|
1312
|
-
"providerOutputToken": {
|
|
1313
|
-
"address": "0x20c00000000000000000000014f22ca97301eb73",
|
|
1314
|
-
"currency": "USD",
|
|
1315
|
-
"decimals": 6,
|
|
1316
|
-
"name": "USDT0",
|
|
1317
|
-
"standard": "TIP-20",
|
|
1318
|
-
"symbol": "USDT0",
|
|
1319
|
-
"tokenKey": "eip155:4217/erc20:0x20c00000000000000000000014f22ca97301eb73",
|
|
1320
|
-
"verified": true,
|
|
1321
|
-
},
|
|
1322
|
-
"providerRequestIds": [
|
|
1323
|
-
Any<String>,
|
|
1324
|
-
],
|
|
1325
|
-
"providerState": Any<Object>,
|
|
1326
|
-
"recipient": "0x7777777777777777777777777777777777777777",
|
|
1327
|
-
"refundAddress": "TJRabPrwbZy45sbavfcjinPJC18kjpRTv8",
|
|
1328
|
-
"snapshot": {
|
|
1329
|
-
"address": Any<String>,
|
|
1330
|
-
"destinationChain": {
|
|
1331
|
-
"addressFormat": "hex",
|
|
1332
|
-
"id": "eip155:4217",
|
|
1333
|
-
"kind": "evm",
|
|
1334
|
-
"name": "Tempo",
|
|
1335
|
-
},
|
|
1336
|
-
"destinationToken": {
|
|
1337
|
-
"address": "0x20c00000000000000000000014f22ca97301eb73",
|
|
1338
|
-
"currency": "USD",
|
|
1339
|
-
"decimals": 6,
|
|
1340
|
-
"name": "USDT0",
|
|
1341
|
-
"standard": "TIP-20",
|
|
1342
|
-
"symbol": "USDT0",
|
|
1343
|
-
"tokenKey": "eip155:4217/erc20:0x20c00000000000000000000014f22ca97301eb73",
|
|
1344
|
-
"verified": true,
|
|
1345
|
-
},
|
|
1346
|
-
"provider": {
|
|
1347
|
-
"id": "relay",
|
|
1348
|
-
"name": "Relay",
|
|
1349
|
-
},
|
|
1350
|
-
"recipient": "0x7777777777777777777777777777777777777777",
|
|
1351
|
-
"refundAddress": "TJRabPrwbZy45sbavfcjinPJC18kjpRTv8",
|
|
1352
|
-
"sourceChain": {
|
|
1353
|
-
"addressFormat": "base58check",
|
|
1354
|
-
"id": "tron:0x2b6653dc",
|
|
1355
|
-
"kind": "tron",
|
|
1356
|
-
"name": "Tron",
|
|
1357
|
-
},
|
|
1358
|
-
"sourceToken": {
|
|
1359
|
-
"address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
|
|
1360
|
-
"currency": "USD",
|
|
1361
|
-
"decimals": 6,
|
|
1362
|
-
"name": "Tether USD",
|
|
1363
|
-
"standard": "TRC-20",
|
|
1364
|
-
"symbol": "USDT",
|
|
1365
|
-
"tokenKey": "tron:0x2b6653dc/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
|
|
1366
|
-
"verified": true,
|
|
1367
|
-
},
|
|
1368
|
-
"subsidize": false,
|
|
1369
|
-
},
|
|
1370
|
-
"sourceChainId": "tron:0x2b6653dc",
|
|
1371
|
-
"sourceTokenKey": "tron:0x2b6653dc/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
|
|
1372
|
-
"status": "active",
|
|
1373
|
-
"subsidize": false,
|
|
1374
|
-
"updatedAt": Any<String>,
|
|
1375
|
-
"version": 1,
|
|
1376
|
-
}
|
|
1377
|
-
`,
|
|
1378
|
-
)
|
|
1379
|
-
})
|
|
1380
|
-
})
|
|
1381
|
-
|
|
1382
|
-
describe('GET /funding/deposit-addresses/:id', () => {
|
|
1383
|
-
test('requires funding:read and returns the owned address', async () => {
|
|
1384
|
-
const app = transfersApp()
|
|
1385
|
-
const record = await seedDepositAddress()
|
|
1386
|
-
const path = `/v1/funding/deposit-addresses/${record.id}`
|
|
1387
|
-
expect((await app.request(path)).status).toBe(401)
|
|
1388
|
-
expect((await app.request(path, as(TestApp.key))).status).toBe(403)
|
|
1389
|
-
|
|
1390
|
-
const response = await app.request(path, as(fundingReader))
|
|
1391
|
-
expect(response.status).toBe(200)
|
|
1392
|
-
expect({
|
|
1393
|
-
cacheControl: response.headers.get('cache-control'),
|
|
1394
|
-
etag: response.headers.get('etag'),
|
|
1395
|
-
}).toMatchInlineSnapshot(`
|
|
1396
|
-
{
|
|
1397
|
-
"cacheControl": "no-store",
|
|
1398
|
-
"etag": null,
|
|
1399
|
-
}
|
|
1400
|
-
`)
|
|
1401
|
-
expect(response.headers.get('vary')).toBe('Accept-Encoding, Authorization, Tempo-API-Key')
|
|
1402
|
-
const body = await TestApp.json(response, Funding.schema.getFundingDepositAddress.Response)
|
|
1403
|
-
expect(body).toEqual(FundingDepositAddress.toPublic(record))
|
|
1404
|
-
expect(body).not.toHaveProperty('providerOutputToken')
|
|
1405
|
-
expect(body).not.toHaveProperty('providerRequestIds')
|
|
1406
|
-
})
|
|
1407
|
-
|
|
1408
|
-
test('hides foreign, project-inaccessible, and absent addresses', async () => {
|
|
1409
|
-
const app = transfersApp()
|
|
1410
|
-
const record = await seedDepositAddress()
|
|
1411
|
-
const path = `/v1/funding/deposit-addresses/${record.id}`
|
|
1412
|
-
|
|
1413
|
-
for (const key of [fundingForeign, fundingProjectReader]) {
|
|
1414
|
-
const response = await app.request(path, as(key))
|
|
1415
|
-
expect(response.status).toBe(404)
|
|
1416
|
-
expect(((await response.json()) as { error: { code: string } }).error.code).toBe(
|
|
1417
|
-
'funding_deposit_address_not_found',
|
|
1418
|
-
)
|
|
1419
|
-
}
|
|
1420
|
-
|
|
1421
|
-
const absent = await app.request(
|
|
1422
|
-
'/v1/funding/deposit-addresses/fda_000000000000000_000000000000000000000000',
|
|
1423
|
-
as(fundingReader),
|
|
1424
|
-
)
|
|
1425
|
-
expect(absent.status).toBe(404)
|
|
1426
|
-
})
|
|
1427
|
-
})
|
|
1428
|
-
|
|
1429
|
-
describe('GET /funding/deposit-addresses/:id/deposits', () => {
|
|
1430
|
-
test('pages deposits newest-first under an owned address', async () => {
|
|
1431
|
-
const app = transfersApp()
|
|
1432
|
-
const address = await seedDepositAddress()
|
|
1433
|
-
const first = await seedDeposit(address)
|
|
1434
|
-
const second = await seedDeposit(address)
|
|
1435
|
-
const third = await seedDeposit(address)
|
|
1436
|
-
const fourth = await seedDeposit(address)
|
|
1437
|
-
const fifth = await seedDeposit(address)
|
|
1438
|
-
const sixth = await seedDeposit(address)
|
|
1439
|
-
|
|
1440
|
-
const response = await app.request(
|
|
1441
|
-
`/v1/funding/deposit-addresses/${address.id}/deposits?limit=5`,
|
|
1442
|
-
as(fundingReader),
|
|
1443
|
-
)
|
|
1444
|
-
expect(response.status).toBe(200)
|
|
1445
|
-
expect({
|
|
1446
|
-
cacheControl: response.headers.get('cache-control'),
|
|
1447
|
-
etag: response.headers.get('etag'),
|
|
1448
|
-
}).toMatchInlineSnapshot(`
|
|
1449
|
-
{
|
|
1450
|
-
"cacheControl": "no-store",
|
|
1451
|
-
"etag": null,
|
|
1452
|
-
}
|
|
1453
|
-
`)
|
|
1454
|
-
const body = await TestApp.json(
|
|
1455
|
-
response,
|
|
1456
|
-
Funding.schema.listFundingDepositAddressDeposits.Response,
|
|
1457
|
-
)
|
|
1458
|
-
expect(body.data.map((deposit) => deposit.id)).toEqual([
|
|
1459
|
-
sixth.id,
|
|
1460
|
-
fifth.id,
|
|
1461
|
-
fourth.id,
|
|
1462
|
-
third.id,
|
|
1463
|
-
second.id,
|
|
1464
|
-
])
|
|
1465
|
-
expect(body.nextCursor).toBe(Cursor.encode([second.createdAt, second.id]))
|
|
1466
|
-
|
|
1467
|
-
const next = await TestApp.json(
|
|
1468
|
-
await app.request(
|
|
1469
|
-
`/v1/funding/deposit-addresses/${address.id}/deposits?limit=5&cursor=${body.nextCursor}`,
|
|
1470
|
-
as(fundingReader),
|
|
1471
|
-
),
|
|
1472
|
-
Funding.schema.listFundingDepositAddressDeposits.Response,
|
|
1473
|
-
)
|
|
1474
|
-
expect(next.data.map((deposit) => deposit.id)).toEqual([first.id])
|
|
1475
|
-
expect(next.nextCursor).toBeNull()
|
|
1476
|
-
})
|
|
1477
|
-
|
|
1478
|
-
test('returns a provider-observed deposit before source verification', async () => {
|
|
1479
|
-
const app = transfersApp()
|
|
1480
|
-
const address = await seedDepositAddress({
|
|
1481
|
-
snapshot: TestFunding.depositAddressSnapshot({
|
|
1482
|
-
provider: { id: 'relay_observed', name: 'Relay' },
|
|
1483
|
-
recipient: `0x${'31'.repeat(20)}`,
|
|
1484
|
-
}),
|
|
1485
|
-
})
|
|
1486
|
-
const sourceTransactionHash = `0x${'ab'.repeat(32)}`
|
|
1487
|
-
await FundingDeposit.create(db, {
|
|
1488
|
-
now: new Date('2026-01-04T00:00:00.000Z'),
|
|
1489
|
-
providerRequestId: 'relay-observed-request',
|
|
1490
|
-
snapshot: {
|
|
1491
|
-
depositAddressId: address.id,
|
|
1492
|
-
destinationChain: address.snapshot.destinationChain,
|
|
1493
|
-
destinationToken: address.snapshot.destinationToken,
|
|
1494
|
-
provider: address.snapshot.provider,
|
|
1495
|
-
recipient: address.recipient,
|
|
1496
|
-
refundAddress: address.refundAddress,
|
|
1497
|
-
sourceChain: address.snapshot.sourceChain,
|
|
1498
|
-
sourceToken: address.snapshot.sourceToken,
|
|
1499
|
-
sourceTransactionHashes: [sourceTransactionHash],
|
|
1500
|
-
},
|
|
1501
|
-
sourceTransactionHash,
|
|
1502
|
-
})
|
|
1503
|
-
|
|
1504
|
-
const response = await app.request(
|
|
1505
|
-
`/v1/funding/deposit-addresses/${address.id}/deposits`,
|
|
1506
|
-
as(fundingReader),
|
|
1507
|
-
)
|
|
1508
|
-
expect(response.status).toBe(200)
|
|
1509
|
-
const body = await TestApp.json(
|
|
1510
|
-
response,
|
|
1511
|
-
Funding.schema.listFundingDepositAddressDeposits.Response,
|
|
1512
|
-
)
|
|
1513
|
-
expect(body).toMatchInlineSnapshot(
|
|
1514
|
-
{
|
|
1515
|
-
data: [
|
|
1516
|
-
{
|
|
1517
|
-
createdAt: expect.any(String),
|
|
1518
|
-
depositAddressId: expect.any(String),
|
|
1519
|
-
id: expect.any(String),
|
|
1520
|
-
updatedAt: expect.any(String),
|
|
1521
|
-
},
|
|
1522
|
-
],
|
|
1523
|
-
},
|
|
1524
|
-
`
|
|
1525
|
-
{
|
|
1526
|
-
"data": [
|
|
1527
|
-
{
|
|
1528
|
-
"createdAt": Any<String>,
|
|
1529
|
-
"depositAddressId": Any<String>,
|
|
1530
|
-
"destinationChain": {
|
|
1531
|
-
"addressFormat": "hex",
|
|
1532
|
-
"id": "eip155:4217",
|
|
1533
|
-
"kind": "evm",
|
|
1534
|
-
"name": "Tempo",
|
|
1535
|
-
},
|
|
1536
|
-
"destinationToken": {
|
|
1537
|
-
"address": "0x20c00000000000000000000014f22ca97301eb73",
|
|
1538
|
-
"currency": "USD",
|
|
1539
|
-
"decimals": 6,
|
|
1540
|
-
"name": "USDT0",
|
|
1541
|
-
"standard": "TIP-20",
|
|
1542
|
-
"symbol": "USDT0",
|
|
1543
|
-
"tokenKey": "eip155:4217/erc20:0x20c00000000000000000000014f22ca97301eb73",
|
|
1544
|
-
"verified": true,
|
|
1545
|
-
},
|
|
1546
|
-
"id": Any<String>,
|
|
1547
|
-
"provider": {
|
|
1548
|
-
"id": "relay_observed",
|
|
1549
|
-
"name": "Relay",
|
|
1550
|
-
},
|
|
1551
|
-
"recipient": "0x3131313131313131313131313131313131313131",
|
|
1552
|
-
"refundAddress": "TJRabPrwbZy45sbavfcjinPJC18kjpRTv8",
|
|
1553
|
-
"sourceChain": {
|
|
1554
|
-
"addressFormat": "base58check",
|
|
1555
|
-
"id": "tron:0x2b6653dc",
|
|
1556
|
-
"kind": "tron",
|
|
1557
|
-
"name": "Tron",
|
|
1558
|
-
},
|
|
1559
|
-
"sourceToken": {
|
|
1560
|
-
"address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
|
|
1561
|
-
"currency": "USD",
|
|
1562
|
-
"decimals": 6,
|
|
1563
|
-
"name": "Tether USD",
|
|
1564
|
-
"standard": "TRC-20",
|
|
1565
|
-
"symbol": "USDT",
|
|
1566
|
-
"tokenKey": "tron:0x2b6653dc/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
|
|
1567
|
-
"verified": true,
|
|
1568
|
-
},
|
|
1569
|
-
"sourceTransactionHashes": [
|
|
1570
|
-
"0xabababababababababababababababababababababababababababababababab",
|
|
1571
|
-
],
|
|
1572
|
-
"status": "detected",
|
|
1573
|
-
"updatedAt": Any<String>,
|
|
1574
|
-
},
|
|
1575
|
-
],
|
|
1576
|
-
"nextCursor": null,
|
|
1577
|
-
}
|
|
1578
|
-
`,
|
|
1579
|
-
)
|
|
1580
|
-
})
|
|
1581
|
-
|
|
1582
|
-
test('returns the parent not-found error before listing deposits', async () => {
|
|
1583
|
-
const app = transfersApp()
|
|
1584
|
-
const address = await seedDepositAddress({ orgId: fundingForeign.orgId })
|
|
1585
|
-
const response = await app.request(
|
|
1586
|
-
`/v1/funding/deposit-addresses/${address.id}/deposits`,
|
|
1587
|
-
as(fundingReader),
|
|
1588
|
-
)
|
|
1589
|
-
expect(response.status).toBe(404)
|
|
1590
|
-
expect(((await response.json()) as { error: { code: string } }).error.code).toBe(
|
|
1591
|
-
'funding_deposit_address_not_found',
|
|
1592
|
-
)
|
|
1593
|
-
})
|
|
1594
|
-
})
|
|
1595
|
-
|
|
1596
|
-
describe('GET /funding/deposits/:id', () => {
|
|
1597
|
-
test('returns the owned deposit without private reconciliation state', async () => {
|
|
1598
|
-
const app = transfersApp()
|
|
1599
|
-
const address = await seedDepositAddress()
|
|
1600
|
-
const record = await seedDeposit(address, {
|
|
1601
|
-
providerRequestIds: ['relay-private'],
|
|
1602
|
-
providerState: { request: 'private' },
|
|
1603
|
-
})
|
|
1604
|
-
const response = await app.request(`/v1/funding/deposits/${record.id}`, as(fundingReader))
|
|
1605
|
-
expect(response.status).toBe(200)
|
|
1606
|
-
expect(response.headers.get('cache-control')).toBe(Cache.policies.state)
|
|
1607
|
-
const body = await TestApp.json(response, Funding.schema.getFundingDeposit.Response)
|
|
1608
|
-
expect(body).toEqual(FundingDeposit.toPublic(record))
|
|
1609
|
-
expect(body).not.toHaveProperty('providerRequestIds')
|
|
1610
|
-
expect(body).not.toHaveProperty('providerState')
|
|
1611
|
-
})
|
|
1612
|
-
|
|
1613
|
-
test('hides foreign and absent deposits', async () => {
|
|
1614
|
-
const app = transfersApp()
|
|
1615
|
-
const address = await seedDepositAddress()
|
|
1616
|
-
const record = await seedDeposit(address)
|
|
1617
|
-
expect(
|
|
1618
|
-
(await app.request(`/v1/funding/deposits/${record.id}`, as(fundingForeign))).status,
|
|
1619
|
-
).toBe(404)
|
|
1620
|
-
expect(
|
|
1621
|
-
(
|
|
1622
|
-
await app.request(
|
|
1623
|
-
'/v1/funding/deposits/fdp_000000000000000_000000000000000000000000',
|
|
1624
|
-
as(fundingReader),
|
|
1625
|
-
)
|
|
1626
|
-
).status,
|
|
1627
|
-
).toBe(404)
|
|
1628
|
-
})
|
|
1629
|
-
})
|
|
1630
|
-
|
|
1631
|
-
describe('GET /funding/transfers', () => {
|
|
1632
|
-
test('requires the funding:read scope', async () => {
|
|
1633
|
-
const app = transfersApp()
|
|
1634
|
-
expect((await app.request('/v1/funding/transfers')).status).toBe(401)
|
|
1635
|
-
const forbidden = await app.request('/v1/funding/transfers', as(TestApp.key))
|
|
1636
|
-
expect(forbidden.status).toBe(403)
|
|
1637
|
-
})
|
|
1638
|
-
|
|
1639
|
-
test('pages own transfers newest-first', async () => {
|
|
1640
|
-
const app = transfersApp()
|
|
1641
|
-
const first = await seedTransfer()
|
|
1642
|
-
const second = await seedTransfer()
|
|
1643
|
-
const third = await seedTransfer()
|
|
1644
|
-
await seedTransfer({ orgId: fundingForeign.orgId })
|
|
1645
|
-
|
|
1646
|
-
const response = await app.request('/v1/funding/transfers?limit=5', {
|
|
1647
|
-
headers: { 'tempo-api-key': fundingReader.token },
|
|
1648
|
-
})
|
|
1649
|
-
expect(response.status).toBe(200)
|
|
1650
|
-
expect(response.headers.get('cache-control')).toBe(Cache.policies.feed)
|
|
1651
|
-
const etag = response.headers.get('etag') ?? ''
|
|
1652
|
-
expect(etag).not.toBe('')
|
|
1653
|
-
expect(response.headers.get('vary')).toBe('Accept-Encoding, Authorization, Tempo-API-Key')
|
|
1654
|
-
const body = await TestApp.json(response, Funding.schema.listFundingTransfers.Response)
|
|
1655
|
-
expect(body.data.map((transfer) => transfer.id)).toEqual([third.id, second.id, first.id])
|
|
1656
|
-
expect(body.nextCursor).toBeNull()
|
|
1657
|
-
expect(body.meta).toBeUndefined()
|
|
1658
|
-
|
|
1659
|
-
const unchanged = await app.request('/v1/funding/transfers?limit=5', {
|
|
1660
|
-
headers: { 'if-none-match': etag, 'tempo-api-key': fundingReader.token },
|
|
1661
|
-
})
|
|
1662
|
-
expect(unchanged.status).toBe(304)
|
|
1663
|
-
expect(unchanged.headers.get('cache-control')).toBe(Cache.policies.feed)
|
|
1664
|
-
expect(unchanged.headers.get('vary')).toBe('Accept-Encoding, Authorization, Tempo-API-Key')
|
|
1665
|
-
|
|
1666
|
-
const paged = await TestApp.json(
|
|
1667
|
-
await app.request(`/v1/funding/transfers?limit=5&cursor=${second.id}`, as(fundingReader)),
|
|
1668
|
-
Funding.schema.listFundingTransfers.Response,
|
|
1669
|
-
)
|
|
1670
|
-
expect(paged.data.map((transfer) => transfer.id)).toEqual([first.id])
|
|
1671
|
-
})
|
|
1672
|
-
|
|
1673
|
-
test('filters by status and embeds the capped count on request', async () => {
|
|
1674
|
-
const app = transfersApp()
|
|
1675
|
-
const expired = await seedTransfer()
|
|
1676
|
-
await FundingTransfer.transition(db, {
|
|
1677
|
-
expectedVersion: 1,
|
|
1678
|
-
id: expired.id,
|
|
1679
|
-
status: 'expired',
|
|
1680
|
-
})
|
|
1681
|
-
|
|
1682
|
-
const response = await app.request(
|
|
1683
|
-
'/v1/funding/transfers?status=expired&include=totalCount',
|
|
1684
|
-
as(fundingReader),
|
|
1685
|
-
)
|
|
1686
|
-
const body = await TestApp.json(response, Funding.schema.listFundingTransfers.Response)
|
|
1687
|
-
expect(body.data.map((transfer) => transfer.id)).toEqual([expired.id])
|
|
1688
|
-
expect(body.data[0]?.status).toBe('expired')
|
|
1689
|
-
expect(body.meta).toEqual({ totalCount: 1, totalCountCapped: false })
|
|
1690
|
-
})
|
|
1691
|
-
|
|
1692
|
-
test('narrows project-attributed keys to their project', async () => {
|
|
1693
|
-
const app = transfersApp()
|
|
1694
|
-
const scoped = await seedTransfer({ projectId: fundingProjectReader.projectId })
|
|
1695
|
-
|
|
1696
|
-
const body = await TestApp.json(
|
|
1697
|
-
await app.request('/v1/funding/transfers?limit=200', as(fundingProjectReader)),
|
|
1698
|
-
Funding.schema.listFundingTransfers.Response,
|
|
1699
|
-
)
|
|
1700
|
-
expect(body.data.map((transfer) => transfer.id)).toEqual([scoped.id])
|
|
1701
|
-
|
|
1702
|
-
// The organization-attributed key sees the project row too.
|
|
1703
|
-
const orgBody = await TestApp.json(
|
|
1704
|
-
await app.request('/v1/funding/transfers?limit=200', as(fundingReader)),
|
|
1705
|
-
Funding.schema.listFundingTransfers.Response,
|
|
1706
|
-
)
|
|
1707
|
-
expect(orgBody.data.map((transfer) => transfer.id)).toContain(scoped.id)
|
|
1708
|
-
})
|
|
1709
|
-
|
|
1710
|
-
test('rejects invalid query parameters', async () => {
|
|
1711
|
-
const app = transfersApp()
|
|
1712
|
-
const response = await app.request('/v1/funding/transfers?status=bogus', as(fundingReader))
|
|
1713
|
-
expect(response.status).toBe(400)
|
|
1714
|
-
})
|
|
1715
|
-
})
|
|
1716
|
-
|
|
1717
|
-
describe('GET /funding/transfers/:id', () => {
|
|
1718
|
-
test('returns the owned transfer directly, without an action', async () => {
|
|
1719
|
-
const app = transfersApp()
|
|
1720
|
-
const record = await seedTransfer()
|
|
1721
|
-
const response = await app.request(`/v1/funding/transfers/${record.id}`, {
|
|
1722
|
-
headers: { 'tempo-api-key': fundingReader.token },
|
|
1723
|
-
})
|
|
1724
|
-
expect(response.status).toBe(200)
|
|
1725
|
-
expect({
|
|
1726
|
-
cacheControl: response.headers.get('cache-control'),
|
|
1727
|
-
etag: response.headers.get('etag'),
|
|
1728
|
-
}).toMatchInlineSnapshot(`
|
|
1729
|
-
{
|
|
1730
|
-
"cacheControl": "no-store",
|
|
1731
|
-
"etag": null,
|
|
1732
|
-
}
|
|
1733
|
-
`)
|
|
1734
|
-
expect(response.headers.get('vary')).toBe('Accept-Encoding, Authorization, Tempo-API-Key')
|
|
1735
|
-
const body = await TestApp.json(response, Funding.schema.getFundingTransfer.Response)
|
|
1736
|
-
expect(body).toEqual(FundingTransfer.toPublic(record))
|
|
1737
|
-
expect('action' in body).toBe(false)
|
|
1738
|
-
|
|
1739
|
-
const repeated = await app.request(`/v1/funding/transfers/${record.id}`, {
|
|
1740
|
-
headers: { 'if-none-match': '"stale"', 'tempo-api-key': fundingReader.token },
|
|
1741
|
-
})
|
|
1742
|
-
expect({
|
|
1743
|
-
cacheControl: repeated.headers.get('cache-control'),
|
|
1744
|
-
etag: repeated.headers.get('etag'),
|
|
1745
|
-
status: repeated.status,
|
|
1746
|
-
}).toMatchInlineSnapshot(`
|
|
1747
|
-
{
|
|
1748
|
-
"cacheControl": "no-store",
|
|
1749
|
-
"etag": null,
|
|
1750
|
-
"status": 200,
|
|
1751
|
-
}
|
|
1752
|
-
`)
|
|
1753
|
-
})
|
|
1754
|
-
|
|
1755
|
-
test('hides foreign and absent transfers behind the same 404', async () => {
|
|
1756
|
-
const app = transfersApp()
|
|
1757
|
-
const record = await seedTransfer()
|
|
1758
|
-
|
|
1759
|
-
const foreign = await app.request(`/v1/funding/transfers/${record.id}`, as(fundingForeign))
|
|
1760
|
-
expect(foreign.status).toBe(404)
|
|
1761
|
-
expect(((await foreign.json()) as { error: { code: string } }).error.code).toBe(
|
|
1762
|
-
'funding_transfer_not_found',
|
|
1763
|
-
)
|
|
1764
|
-
|
|
1765
|
-
const project = await app.request(
|
|
1766
|
-
`/v1/funding/transfers/${record.id}`,
|
|
1767
|
-
as(fundingProjectReader),
|
|
1768
|
-
)
|
|
1769
|
-
expect(project.status).toBe(404)
|
|
1770
|
-
|
|
1771
|
-
const absent = await app.request(
|
|
1772
|
-
'/v1/funding/transfers/ftr_000000000000000_000000000000000000000000',
|
|
1773
|
-
as(fundingReader),
|
|
1774
|
-
)
|
|
1775
|
-
expect(absent.status).toBe(404)
|
|
1776
|
-
})
|
|
1777
|
-
})
|
|
1778
|
-
|
|
1779
|
-
const createBody = {
|
|
1780
|
-
amount: '1000000',
|
|
1781
|
-
destinationToken: '0x20c0000000000000000000000000000000000000',
|
|
1782
|
-
mode: 'exactSource',
|
|
1783
|
-
recipient: `0x${'11'.repeat(20)}`,
|
|
1784
|
-
sender: `0x${'22'.repeat(20)}`,
|
|
1785
|
-
slippageBps: 300,
|
|
1786
|
-
sourceChain: 'base',
|
|
1787
|
-
sourceToken: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913',
|
|
1788
|
-
}
|
|
1789
|
-
|
|
1790
|
-
const vaultTransferBody = {
|
|
1791
|
-
amount: createBody.amount,
|
|
1792
|
-
mode: createBody.mode,
|
|
1793
|
-
recipient: createBody.recipient,
|
|
1794
|
-
sender: createBody.sender,
|
|
1795
|
-
slippageBps: createBody.slippageBps,
|
|
1796
|
-
sourceChain: createBody.sourceChain,
|
|
1797
|
-
sourceToken: createBody.sourceToken,
|
|
1798
|
-
vaultAddress: `0x${'f4'.repeat(20)}`,
|
|
1799
|
-
}
|
|
1800
|
-
|
|
1801
|
-
const zoneTransferBody = {
|
|
1802
|
-
amount: createBody.amount,
|
|
1803
|
-
destinationChain: 'eip155:421700001',
|
|
1804
|
-
destinationToken: createBody.destinationToken,
|
|
1805
|
-
mode: createBody.mode,
|
|
1806
|
-
recipient: createBody.recipient,
|
|
1807
|
-
recipientFallback: `0x${'33'.repeat(20)}`,
|
|
1808
|
-
sender: createBody.sender,
|
|
1809
|
-
slippageBps: createBody.slippageBps,
|
|
1810
|
-
sourceChain: createBody.sourceChain,
|
|
1811
|
-
sourceToken: createBody.sourceToken,
|
|
1812
|
-
}
|
|
1813
|
-
|
|
1814
|
-
const depositAddressBody = {
|
|
1815
|
-
amount: '100000000',
|
|
1816
|
-
destinationToken: '0x20c00000000000000000000014f22ca97301eb73',
|
|
1817
|
-
recipient: `0x${'11'.repeat(20)}`,
|
|
1818
|
-
refundAddress: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8',
|
|
1819
|
-
sourceChain: 'tron',
|
|
1820
|
-
sourceToken: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t',
|
|
1821
|
-
}
|
|
1822
|
-
|
|
1823
|
-
let depositAddressIdempotencySequence = 0
|
|
1824
|
-
function nextDepositAddressKey() {
|
|
1825
|
-
return `funding_deposit_address_${depositAddressIdempotencySequence++}`
|
|
1826
|
-
}
|
|
1827
|
-
|
|
1828
|
-
function postDepositAddress(
|
|
1829
|
-
app: ReturnType<typeof create>,
|
|
1830
|
-
options: {
|
|
1831
|
-
body?: unknown
|
|
1832
|
-
headers?: Record<string, string> | undefined
|
|
1833
|
-
idempotencyKey?: string | undefined
|
|
1834
|
-
key?: { token: string } | undefined
|
|
1835
|
-
} = {},
|
|
1836
|
-
) {
|
|
1837
|
-
return app.request('/v1/funding/deposit-addresses', {
|
|
1838
|
-
body: JSON.stringify(options.body ?? depositAddressBody),
|
|
1839
|
-
headers: options.headers ?? {
|
|
1840
|
-
authorization: `Bearer ${(options.key ?? fundingWriter).token}`,
|
|
1841
|
-
'content-type': 'application/json',
|
|
1842
|
-
'idempotency-key': options.idempotencyKey ?? nextDepositAddressKey(),
|
|
1843
|
-
},
|
|
1844
|
-
method: 'POST',
|
|
1845
|
-
})
|
|
1846
|
-
}
|
|
1847
|
-
|
|
1848
|
-
function postTransfer(
|
|
1849
|
-
app: ReturnType<typeof create>,
|
|
1850
|
-
options: { body?: unknown; idempotencyKey?: string | undefined; key?: { token: string } } = {},
|
|
1851
|
-
) {
|
|
1852
|
-
return app.request('/v1/funding/transfers', {
|
|
1853
|
-
body: JSON.stringify(options.body ?? createBody),
|
|
1854
|
-
headers: {
|
|
1855
|
-
authorization: `Bearer ${(options.key ?? fundingWriter).token}`,
|
|
1856
|
-
'content-type': 'application/json',
|
|
1857
|
-
...(options.idempotencyKey === undefined
|
|
1858
|
-
? {}
|
|
1859
|
-
: { 'idempotency-key': options.idempotencyKey }),
|
|
1860
|
-
},
|
|
1861
|
-
method: 'POST',
|
|
1862
|
-
})
|
|
1863
|
-
}
|
|
1864
|
-
|
|
1865
|
-
describe('POST /funding/transfers', () => {
|
|
1866
|
-
function providerResponse(): FundingProvider.prepareTransfer.ReturnType {
|
|
1867
|
-
const spender = `0x${'44'.repeat(20)}`
|
|
1868
|
-
return {
|
|
1869
|
-
action: {
|
|
1870
|
-
calls: [
|
|
1871
|
-
{
|
|
1872
|
-
data: `0x095ea7b3${spender.slice(2).padStart(64, '0')}${(1_000_000).toString(16).padStart(64, '0')}`,
|
|
1873
|
-
to: createBody.sourceToken,
|
|
1874
|
-
value: '0x0',
|
|
1875
|
-
},
|
|
1876
|
-
{ data: '0xe8017952', to: spender, value: '0x0' },
|
|
1877
|
-
],
|
|
1878
|
-
type: 'evm:calls',
|
|
1879
|
-
},
|
|
1880
|
-
correlation: { requestId: `0x${'ab'.repeat(32)}` },
|
|
1881
|
-
fees: [{ amount: '21050', side: 'source' }],
|
|
1882
|
-
destinationAmountMin: '980000',
|
|
1883
|
-
destinationAmount: '990000',
|
|
1884
|
-
quality: {
|
|
1885
|
-
estimatedSeconds: 4,
|
|
1886
|
-
liquiditySource: 'providerQuote',
|
|
1887
|
-
sourceDetail: 'relay:quote-v2',
|
|
1888
|
-
tier: 'liquid',
|
|
1889
|
-
},
|
|
1890
|
-
sampledAt: '2099-01-01T00:00:00.000Z',
|
|
1891
|
-
}
|
|
1892
|
-
}
|
|
1893
|
-
|
|
1894
|
-
function provider(
|
|
1895
|
-
prepareTransfer: NonNullable<FundingProvider.Provider['prepareTransfer']> = async () =>
|
|
1896
|
-
providerResponse(),
|
|
1897
|
-
) {
|
|
1898
|
-
return FundingProvider.from({
|
|
1899
|
-
...Relay.relay({ userAddress: '0x1111111111111111111111111111111111111111' }),
|
|
1900
|
-
prepareTransfer,
|
|
1901
|
-
})
|
|
1902
|
-
}
|
|
1903
|
-
|
|
1904
|
-
let idempotencySequence = 0
|
|
1905
|
-
function nextIdempotencyKey() {
|
|
1906
|
-
return `funding_test_${idempotencySequence++}`
|
|
1907
|
-
}
|
|
1908
|
-
|
|
1909
|
-
test('requires funding:write and an Idempotency-Key', async () => {
|
|
1910
|
-
const app = transfersApp({
|
|
1911
|
-
providers: [provider()],
|
|
1912
|
-
})
|
|
1913
|
-
const forbidden = await postTransfer(app, {
|
|
1914
|
-
idempotencyKey: nextIdempotencyKey(),
|
|
1915
|
-
key: fundingReader,
|
|
1916
|
-
})
|
|
1917
|
-
expect(forbidden.status).toBe(403)
|
|
1918
|
-
|
|
1919
|
-
const missing = await postTransfer(app)
|
|
1920
|
-
expect(missing.status).toBe(400)
|
|
1921
|
-
expect(((await missing.json()) as { error: { code: string } }).error.code).toBe(
|
|
1922
|
-
'idempotency_key_required',
|
|
1923
|
-
)
|
|
1924
|
-
})
|
|
1925
|
-
|
|
1926
|
-
test('creates a transfer with the selected executable quote', async () => {
|
|
1927
|
-
const inputs: FundingProvider.prepareTransfer.Parameters[] = []
|
|
1928
|
-
const app = transfersApp({
|
|
1929
|
-
providers: [
|
|
1930
|
-
provider(async (input) => {
|
|
1931
|
-
inputs.push(input)
|
|
1932
|
-
return providerResponse()
|
|
1933
|
-
}),
|
|
1934
|
-
],
|
|
1935
|
-
})
|
|
1936
|
-
const response = await postTransfer(app, {
|
|
1937
|
-
body: { ...createBody, destinationToken: 'pathusd', sourceToken: 'usdc' },
|
|
1938
|
-
idempotencyKey: nextIdempotencyKey(),
|
|
1939
|
-
})
|
|
1940
|
-
expect(response.status).toBe(200)
|
|
1941
|
-
expect(response.headers.get('cache-control')).toBe('no-store')
|
|
1942
|
-
const body = await TestApp.json(response, Funding.schema.createFundingTransfer.Response)
|
|
1943
|
-
|
|
1944
|
-
expect(inputs[0]).toMatchObject({
|
|
1945
|
-
method: 'transaction',
|
|
1946
|
-
mode: 'exactSource',
|
|
1947
|
-
recipient: createBody.recipient,
|
|
1948
|
-
sender: createBody.sender,
|
|
1949
|
-
slippageBps: 300,
|
|
1950
|
-
})
|
|
1951
|
-
expect(body).toMatchObject({
|
|
1952
|
-
action: { type: 'evm:calls' },
|
|
1953
|
-
destinationAmountMin: { baseUnits: '980000' },
|
|
1954
|
-
method: 'transaction',
|
|
1955
|
-
mode: 'exactSource',
|
|
1956
|
-
provider: { id: 'relay' },
|
|
1957
|
-
// The provider returned no expiry, so the default validity window applies.
|
|
1958
|
-
quote: { expiresAt: '2099-01-01T00:01:00.000Z', sampledAt: '2099-01-01T00:00:00.000Z' },
|
|
1959
|
-
sourceAmount: { baseUnits: '1000000' },
|
|
1960
|
-
status: 'awaiting-source',
|
|
1961
|
-
version: 1,
|
|
1962
|
-
})
|
|
1963
|
-
expect(body.fees[0]).toMatchObject({ side: 'source', token: { symbol: 'USDC' } })
|
|
1964
|
-
|
|
1965
|
-
// Reads return the same transfer without the action; the private
|
|
1966
|
-
// correlation never leaves the row.
|
|
1967
|
-
const read = await app.request(`/v1/funding/transfers/${body.id}`, as(fundingWriter))
|
|
1968
|
-
const readBody = await TestApp.json(read, Funding.schema.getFundingTransfer.Response)
|
|
1969
|
-
expect('action' in readBody).toBe(false)
|
|
1970
|
-
expect(JSON.stringify(readBody)).not.toContain('ab'.repeat(32))
|
|
1971
|
-
const row = await db.kysely
|
|
1972
|
-
.selectFrom('funding_transfers')
|
|
1973
|
-
.select(['providerState'])
|
|
1974
|
-
.where('id', '=', body.id)
|
|
1975
|
-
.executeTakeFirstOrThrow()
|
|
1976
|
-
expect(row.providerState).toEqual({ requestId: `0x${'ab'.repeat(32)}` })
|
|
1977
|
-
})
|
|
1978
|
-
|
|
1979
|
-
test('resolves token identifiers through the composed route', async () => {
|
|
1980
|
-
const app = transfersApp({ providers: [provider()] })
|
|
1981
|
-
const identifiers = [
|
|
1982
|
-
{ destinationToken: 'pathusd', name: 'alias', sourceToken: 'usdc' },
|
|
1983
|
-
{
|
|
1984
|
-
destinationToken: createBody.destinationToken,
|
|
1985
|
-
name: 'address',
|
|
1986
|
-
sourceToken: createBody.sourceToken,
|
|
1987
|
-
},
|
|
1988
|
-
{
|
|
1989
|
-
destinationToken: 'eip155:4217/erc20:0x20c0000000000000000000000000000000000000',
|
|
1990
|
-
name: 'token key',
|
|
1991
|
-
sourceToken: 'eip155:8453/erc20:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913',
|
|
1992
|
-
},
|
|
1993
|
-
] as const
|
|
1994
|
-
const results = []
|
|
1995
|
-
for (const identifier of identifiers) {
|
|
1996
|
-
const response = await postTransfer(app, {
|
|
1997
|
-
body: {
|
|
1998
|
-
...createBody,
|
|
1999
|
-
destinationToken: identifier.destinationToken,
|
|
2000
|
-
sourceToken: identifier.sourceToken,
|
|
2001
|
-
},
|
|
2002
|
-
idempotencyKey: nextIdempotencyKey(),
|
|
2003
|
-
})
|
|
2004
|
-
const body = await TestApp.json(response, Funding.schema.createFundingTransfer.Response)
|
|
2005
|
-
results.push({
|
|
2006
|
-
destinationToken: body.destinationToken.symbol,
|
|
2007
|
-
name: identifier.name,
|
|
2008
|
-
sourceToken: body.sourceToken.symbol,
|
|
2009
|
-
status: response.status,
|
|
2010
|
-
})
|
|
2011
|
-
}
|
|
2012
|
-
|
|
2013
|
-
expect(results).toMatchInlineSnapshot(`
|
|
2014
|
-
[
|
|
2015
|
-
{
|
|
2016
|
-
"destinationToken": "pathUSD",
|
|
2017
|
-
"name": "alias",
|
|
2018
|
-
"sourceToken": "USDC",
|
|
2019
|
-
"status": 200,
|
|
2020
|
-
},
|
|
2021
|
-
{
|
|
2022
|
-
"destinationToken": "pathUSD",
|
|
2023
|
-
"name": "address",
|
|
2024
|
-
"sourceToken": "USDC",
|
|
2025
|
-
"status": 200,
|
|
2026
|
-
},
|
|
2027
|
-
{
|
|
2028
|
-
"destinationToken": "pathUSD",
|
|
2029
|
-
"name": "token key",
|
|
2030
|
-
"sourceToken": "USDC",
|
|
2031
|
-
"status": 200,
|
|
2032
|
-
},
|
|
2033
|
-
]
|
|
2034
|
-
`)
|
|
2035
|
-
})
|
|
2036
|
-
|
|
2037
|
-
test('replays the exact response for the same key and body', async () => {
|
|
2038
|
-
const app = transfersApp({
|
|
2039
|
-
providers: [provider()],
|
|
2040
|
-
})
|
|
2041
|
-
const idempotencyKey = nextIdempotencyKey()
|
|
2042
|
-
const first = await postTransfer(app, { idempotencyKey })
|
|
2043
|
-
const second = await postTransfer(app, { idempotencyKey })
|
|
2044
|
-
expect(second.status).toBe(200)
|
|
2045
|
-
expect(await second.json()).toEqual(await first.json())
|
|
2046
|
-
})
|
|
2047
|
-
|
|
2048
|
-
test('rejects a reused key with different input', async () => {
|
|
2049
|
-
const app = transfersApp({
|
|
2050
|
-
providers: [provider()],
|
|
2051
|
-
})
|
|
2052
|
-
const idempotencyKey = nextIdempotencyKey()
|
|
2053
|
-
await postTransfer(app, { idempotencyKey })
|
|
2054
|
-
const conflict = await postTransfer(app, {
|
|
2055
|
-
body: { ...createBody, amount: '2000000' },
|
|
2056
|
-
idempotencyKey,
|
|
2057
|
-
})
|
|
2058
|
-
expect(conflict.status).toBe(409)
|
|
2059
|
-
expect(((await conflict.json()) as { error: { code: string } }).error.code).toBe(
|
|
2060
|
-
'idempotency_conflict',
|
|
2061
|
-
)
|
|
2062
|
-
})
|
|
2063
|
-
|
|
2064
|
-
test('rejects an equivalent request while the first is running', async () => {
|
|
2065
|
-
const app = transfersApp({
|
|
2066
|
-
providers: [provider()],
|
|
2067
|
-
})
|
|
2068
|
-
const idempotencyKey = nextIdempotencyKey()
|
|
2069
|
-
await FundingIdempotencyTable.claim(db, {
|
|
2070
|
-
apiKeyId: fundingWriter.id,
|
|
2071
|
-
keyHash: Idempotency.inputHash(idempotencyKey),
|
|
2072
|
-
requestHash: Idempotency.inputHash(createBody),
|
|
2073
|
-
ttlMs: 60_000,
|
|
2074
|
-
})
|
|
2075
|
-
const pending = await postTransfer(app, { idempotencyKey })
|
|
2076
|
-
expect(pending.status).toBe(409)
|
|
2077
|
-
expect(((await pending.json()) as { error: { code: string } }).error.code).toBe(
|
|
2078
|
-
'idempotency_in_progress',
|
|
2079
|
-
)
|
|
2080
|
-
})
|
|
2081
|
-
|
|
2082
|
-
test('rolls back creation after losing the idempotency lease', async () => {
|
|
2083
|
-
const idempotencyKey = nextIdempotencyKey()
|
|
2084
|
-
const keyHash = Idempotency.inputHash(idempotencyKey)
|
|
2085
|
-
const before = await db.kysely
|
|
2086
|
-
.selectFrom('funding_transfers')
|
|
2087
|
-
.select(({ fn }) => fn.countAll<number>().as('count'))
|
|
2088
|
-
.executeTakeFirstOrThrow()
|
|
2089
|
-
const app = transfersApp({
|
|
2090
|
-
providers: [
|
|
2091
|
-
provider(async () => {
|
|
2092
|
-
await db.kysely
|
|
2093
|
-
.updateTable('funding_idempotency_requests')
|
|
2094
|
-
.set({ expiresAt: new Date(0).toISOString() })
|
|
2095
|
-
.where('apiKeyId', '=', fundingWriter.id)
|
|
2096
|
-
.where('keyHash', '=', keyHash)
|
|
2097
|
-
.execute()
|
|
2098
|
-
await FundingIdempotencyTable.claim(db, {
|
|
2099
|
-
apiKeyId: fundingWriter.id,
|
|
2100
|
-
keyHash,
|
|
2101
|
-
requestHash: Idempotency.inputHash(createBody),
|
|
2102
|
-
ttlMs: 60_000,
|
|
2103
|
-
})
|
|
2104
|
-
return providerResponse()
|
|
2105
|
-
}),
|
|
2106
|
-
],
|
|
2107
|
-
})
|
|
2108
|
-
|
|
2109
|
-
const response = await postTransfer(app, { idempotencyKey })
|
|
2110
|
-
expect(response.status).toBe(500)
|
|
2111
|
-
const after = await db.kysely
|
|
2112
|
-
.selectFrom('funding_transfers')
|
|
2113
|
-
.select(({ fn }) => fn.countAll<number>().as('count'))
|
|
2114
|
-
.executeTakeFirstOrThrow()
|
|
2115
|
-
expect(after.count).toBe(before.count)
|
|
2116
|
-
})
|
|
2117
|
-
|
|
2118
|
-
test('rejects non-account fields and gates disabled corridors and methods', async () => {
|
|
2119
|
-
const app = transfersApp({
|
|
2120
|
-
providers: [provider()],
|
|
2121
|
-
})
|
|
2122
|
-
|
|
2123
|
-
const ignoredRefund = await postTransfer(app, {
|
|
2124
|
-
body: { ...createBody, refundAddress: createBody.sender },
|
|
2125
|
-
idempotencyKey: nextIdempotencyKey(),
|
|
2126
|
-
})
|
|
2127
|
-
expect(ignoredRefund.status).toBe(400)
|
|
2128
|
-
expect(((await ignoredRefund.json()) as { error: { code: string } }).error.code).toBe(
|
|
2129
|
-
'body_invalid',
|
|
2130
|
-
)
|
|
2131
|
-
|
|
2132
|
-
const unsupportedDestination = await postTransfer(app, {
|
|
2133
|
-
body: { ...createBody, destinationVault: `0x${'f4'.repeat(20)}`, type: 'vault' },
|
|
2134
|
-
idempotencyKey: nextIdempotencyKey(),
|
|
2135
|
-
})
|
|
2136
|
-
expect(unsupportedDestination.status).toBe(400)
|
|
2137
|
-
expect(((await unsupportedDestination.json()) as { error: { code: string } }).error.code).toBe(
|
|
2138
|
-
'body_invalid',
|
|
2139
|
-
)
|
|
2140
|
-
|
|
2141
|
-
const unknownToken = await postTransfer(app, {
|
|
2142
|
-
body: { ...createBody, destinationToken: `0x${'99'.repeat(20)}` },
|
|
2143
|
-
idempotencyKey: nextIdempotencyKey(),
|
|
2144
|
-
})
|
|
2145
|
-
expect(unknownToken.status).toBe(404)
|
|
2146
|
-
})
|
|
2147
|
-
|
|
2148
|
-
test('releases the claim after a provider failure', async () => {
|
|
2149
|
-
const failing = transfersApp({
|
|
2150
|
-
providers: [
|
|
2151
|
-
provider(async () => {
|
|
2152
|
-
throw new Error('upstream unavailable')
|
|
2153
|
-
}),
|
|
2154
|
-
],
|
|
2155
|
-
})
|
|
2156
|
-
const idempotencyKey = nextIdempotencyKey()
|
|
2157
|
-
const failed = await postTransfer(failing, { idempotencyKey })
|
|
2158
|
-
expect(failed.status).toBe(502)
|
|
2159
|
-
expect(((await failed.json()) as { error: { code: string } }).error.code).toBe('upstream_error')
|
|
2160
|
-
|
|
2161
|
-
// The key was released, so the same request retries successfully.
|
|
2162
|
-
const working = transfersApp({
|
|
2163
|
-
providers: [provider()],
|
|
2164
|
-
})
|
|
2165
|
-
expect((await postTransfer(working, { idempotencyKey })).status).toBe(200)
|
|
2166
|
-
})
|
|
2167
|
-
|
|
2168
|
-
test('rejects preparations with unbounded approvals', async () => {
|
|
2169
|
-
const app = transfersApp({
|
|
2170
|
-
providers: [
|
|
2171
|
-
provider(async () => {
|
|
2172
|
-
const result = providerResponse()
|
|
2173
|
-
const spender = `0x${'44'.repeat(20)}`
|
|
2174
|
-
return {
|
|
2175
|
-
...result,
|
|
2176
|
-
action: {
|
|
2177
|
-
calls: [
|
|
2178
|
-
{
|
|
2179
|
-
data: `0x095ea7b3${spender.slice(2).padStart(64, '0')}${'f'.repeat(64)}`,
|
|
2180
|
-
to: createBody.sourceToken,
|
|
2181
|
-
value: '0x0',
|
|
2182
|
-
},
|
|
2183
|
-
],
|
|
2184
|
-
type: 'evm:calls',
|
|
2185
|
-
},
|
|
2186
|
-
}
|
|
2187
|
-
}),
|
|
2188
|
-
],
|
|
2189
|
-
})
|
|
2190
|
-
const response = await postTransfer(app, { idempotencyKey: nextIdempotencyKey() })
|
|
2191
|
-
expect(response.status).toBe(502)
|
|
2192
|
-
})
|
|
2193
|
-
})
|
|
2194
|
-
|
|
2195
|
-
// Quotes come from the live provider API; the returned calls execute on a
|
|
2196
|
-
// disposable anvil fork of Base, proving the action without funding a real
|
|
2197
|
-
// account. Skips without the `anvil` binary.
|
|
2198
|
-
describe.skipIf(!Anvil.available)('POST /funding/transfers (forked localnet)', () => {
|
|
2199
|
-
/**
|
|
2200
|
-
* Storage slot of Circle FiatToken's `balanceAndBlacklistStates` mapping.
|
|
2201
|
-
* `anvil_dealERC20` cannot probe it because balances pack a blacklist bit,
|
|
2202
|
-
* so the fork suite writes the slot directly. Every curated USDC is a
|
|
2203
|
-
* FiatToken.
|
|
2204
|
-
*/
|
|
2205
|
-
const fiatTokenBalanceSlot = 9n
|
|
2206
|
-
|
|
2207
|
-
for (const executable of executableProviders) {
|
|
2208
|
-
describe(executable.id, () => {
|
|
2209
|
-
const route = TestFunding.snapshot.routes.find(
|
|
2210
|
-
(entry) =>
|
|
2211
|
-
entry.route.source.chain.id === `eip155:${base.id}` &&
|
|
2212
|
-
entry.route.source.symbol === 'USDC' &&
|
|
2213
|
-
entry.route.destination.slug === 'pathusd',
|
|
2214
|
-
)!
|
|
2215
|
-
const usdc = route.route.source
|
|
2216
|
-
const amount = Value.from('10', usdc.decimals)
|
|
2217
|
-
const sender = Anvil.getAccount(0)
|
|
2218
|
-
|
|
2219
|
-
let anvil: Anvil.start.ReturnType
|
|
2220
|
-
|
|
2221
|
-
beforeAll(async () => {
|
|
2222
|
-
anvil = await Anvil.start({ chain: base, forkUrl: base.rpcUrls.default.http[0]! })
|
|
2223
|
-
})
|
|
2224
|
-
|
|
2225
|
-
afterAll(async () => {
|
|
2226
|
-
await anvil?.stop()
|
|
2227
|
-
})
|
|
2228
|
-
|
|
2229
|
-
function createTransfer(idempotencyKey: string) {
|
|
2230
|
-
const app = transfersApp({ providers: [executable.create(sender.address)] })
|
|
2231
|
-
return postTransfer(app, {
|
|
2232
|
-
body: { ...createBody, amount: amount.toString(), sender: sender.address },
|
|
2233
|
-
idempotencyKey,
|
|
2234
|
-
})
|
|
2235
|
-
}
|
|
2236
|
-
|
|
2237
|
-
test('executes the prepared calls and returns successful receipts', async () => {
|
|
2238
|
-
const { client } = anvil
|
|
2239
|
-
await Anvil.setErc20Balance(client, {
|
|
2240
|
-
address: sender.address,
|
|
2241
|
-
amount,
|
|
2242
|
-
slot: fiatTokenBalanceSlot,
|
|
2243
|
-
token: usdc.address as `0x${string}`,
|
|
2244
|
-
})
|
|
2245
|
-
|
|
2246
|
-
const response = await createTransfer(`bridge_${executable.id}_${sender.address}`)
|
|
2247
|
-
expect(response.status).toBe(200)
|
|
2248
|
-
const transfer = await TestApp.json(response, Funding.schema.createFundingTransfer.Response)
|
|
2249
|
-
expect(transfer.action.type).toBe('evm:calls')
|
|
2250
|
-
if (transfer.action.type !== 'evm:calls') return
|
|
2251
|
-
expect(transfer.action.calls.length).toBeGreaterThan(0)
|
|
2252
|
-
|
|
2253
|
-
const receipts = []
|
|
2254
|
-
for (const call of transfer.action.calls) {
|
|
2255
|
-
receipts.push(
|
|
2256
|
-
await client.sendTransactionSync({
|
|
2257
|
-
account: sender,
|
|
2258
|
-
data: call.data as `0x${string}`,
|
|
2259
|
-
to: call.to as `0x${string}`,
|
|
2260
|
-
value: BigInt(call.value),
|
|
2261
|
-
}),
|
|
2262
|
-
)
|
|
2263
|
-
}
|
|
2264
|
-
|
|
2265
|
-
expect(receipts.map((receipt) => receipt.status)).toEqual(
|
|
2266
|
-
transfer.action.calls.map(() => 'success'),
|
|
2267
|
-
)
|
|
2268
|
-
// The deposit is the last call and must emit; an approval alone proves nothing.
|
|
2269
|
-
expect(receipts.at(-1)!.logs.length).toBeGreaterThan(0)
|
|
2270
|
-
})
|
|
2271
|
-
|
|
2272
|
-
test('bounds the approval to the quoted source spend', async () => {
|
|
2273
|
-
const response = await createTransfer(`bridge_bounds_${executable.id}_${sender.address}`)
|
|
2274
|
-
expect(response.status).toBe(200)
|
|
2275
|
-
const transfer = await TestApp.json(response, Funding.schema.createFundingTransfer.Response)
|
|
2276
|
-
if (transfer.action.type !== 'evm:calls') return
|
|
2277
|
-
|
|
2278
|
-
const approval = transfer.action.calls.find((call) =>
|
|
2279
|
-
call.data.toLowerCase().startsWith('0x095ea7b3'),
|
|
2280
|
-
)
|
|
2281
|
-
expect(approval).toBeDefined()
|
|
2282
|
-
if (!approval) return
|
|
2283
|
-
|
|
2284
|
-
// Decoded from the live action, so a provider that starts returning
|
|
2285
|
-
// an unlimited allowance fails here.
|
|
2286
|
-
const decoded = decodeFunctionData({ abi: erc20Abi, data: approval.data as `0x${string}` })
|
|
2287
|
-
expect(decoded.functionName).toBe('approve')
|
|
2288
|
-
if (decoded.functionName !== 'approve') return
|
|
2289
|
-
const [, approved] = decoded.args
|
|
2290
|
-
expect(approved).toBeLessThanOrEqual(BigInt(transfer.sourceAmount.baseUnits))
|
|
2291
|
-
expect(approved).toBeLessThan(maxUint256)
|
|
2292
|
-
})
|
|
2293
|
-
})
|
|
2294
|
-
}
|
|
2295
|
-
})
|
|
2296
|
-
|
|
2297
|
-
describe('POST /funding/transfers/{destination}', () => {
|
|
2298
|
-
test.each([
|
|
2299
|
-
{ body: vaultTransferBody, destination: 'vault' },
|
|
2300
|
-
{ body: zoneTransferBody, destination: 'zone' },
|
|
2301
|
-
] as const)('validates and returns 501 for $destination transfers', async (options) => {
|
|
2302
|
-
const app = transfersApp()
|
|
2303
|
-
const before = await db.kysely
|
|
2304
|
-
.selectFrom('funding_transfers')
|
|
2305
|
-
.select(({ fn }) => fn.countAll<number>().as('count'))
|
|
2306
|
-
.executeTakeFirstOrThrow()
|
|
2307
|
-
const response = await app.request(`/v1/funding/transfers/${options.destination}`, {
|
|
2308
|
-
body: JSON.stringify(options.body),
|
|
2309
|
-
headers: {
|
|
2310
|
-
authorization: `Bearer ${fundingWriter.token}`,
|
|
2311
|
-
'content-type': 'application/json',
|
|
2312
|
-
'idempotency-key': `funding_${options.destination}_unimplemented`,
|
|
2313
|
-
},
|
|
2314
|
-
method: 'POST',
|
|
2315
|
-
})
|
|
2316
|
-
|
|
2317
|
-
expect(response.status).toBe(501)
|
|
2318
|
-
expect(response.headers.get('cache-control')).toBe('no-store')
|
|
2319
|
-
expect(((await response.json()) as { error: { code: string } }).error.code).toBe(
|
|
2320
|
-
'not_implemented',
|
|
2321
|
-
)
|
|
2322
|
-
const after = await db.kysely
|
|
2323
|
-
.selectFrom('funding_transfers')
|
|
2324
|
-
.select(({ fn }) => fn.countAll<number>().as('count'))
|
|
2325
|
-
.executeTakeFirstOrThrow()
|
|
2326
|
-
expect(after.count).toBe(before.count)
|
|
2327
|
-
})
|
|
2328
|
-
|
|
2329
|
-
test('requires the header and validates destination fields', async () => {
|
|
2330
|
-
const app = transfersApp()
|
|
2331
|
-
const missingHeader = await app.request('/v1/funding/transfers/vault', {
|
|
2332
|
-
body: JSON.stringify(vaultTransferBody),
|
|
2333
|
-
headers: {
|
|
2334
|
-
authorization: `Bearer ${fundingWriter.token}`,
|
|
2335
|
-
'content-type': 'application/json',
|
|
2336
|
-
},
|
|
2337
|
-
method: 'POST',
|
|
2338
|
-
})
|
|
2339
|
-
expect(missingHeader.status).toBe(400)
|
|
2340
|
-
expect(((await missingHeader.json()) as { error: { code: string } }).error.code).toBe(
|
|
2341
|
-
'idempotency_key_required',
|
|
2342
|
-
)
|
|
2343
|
-
|
|
2344
|
-
const invalidZone = await app.request('/v1/funding/transfers/zone', {
|
|
2345
|
-
body: JSON.stringify({ ...zoneTransferBody, destinationChain: '421700001' }),
|
|
2346
|
-
headers: {
|
|
2347
|
-
authorization: `Bearer ${fundingWriter.token}`,
|
|
2348
|
-
'content-type': 'application/json',
|
|
2349
|
-
'idempotency-key': 'funding_zone_invalid',
|
|
2350
|
-
},
|
|
2351
|
-
method: 'POST',
|
|
2352
|
-
})
|
|
2353
|
-
expect(invalidZone.status).toBe(400)
|
|
2354
|
-
expect(((await invalidZone.json()) as { error: { code: string } }).error.code).toBe(
|
|
2355
|
-
'body_invalid',
|
|
2356
|
-
)
|
|
2357
|
-
})
|
|
2358
|
-
})
|
|
2359
|
-
|
|
2360
|
-
describe('funding transfers OpenAPI', () => {
|
|
2361
|
-
test('publishes an executable account transfer example', async () => {
|
|
2362
|
-
const app = create({ auth: false })
|
|
2363
|
-
type TokenSchema = { examples: string[] }
|
|
2364
|
-
type TransferExample = { destinationToken: { address: string } }
|
|
2365
|
-
type Operation = {
|
|
2366
|
-
requestBody: {
|
|
2367
|
-
content: Record<string, { schema: { properties: { destinationToken: TokenSchema } } }>
|
|
2368
|
-
}
|
|
2369
|
-
responses: Record<string, { content: Record<string, { example: TransferExample }> }>
|
|
2370
|
-
}
|
|
2371
|
-
type Spec = { paths: Record<string, { post: Operation }> }
|
|
2372
|
-
const spec = (await (await app.request('/openapi.json')).json()) as Spec
|
|
2373
|
-
const operation = spec.paths['/v1/funding/transfers']!.post
|
|
2374
|
-
|
|
2375
|
-
expect({
|
|
2376
|
-
request:
|
|
2377
|
-
operation.requestBody.content['application/json']!.schema.properties.destinationToken
|
|
2378
|
-
.examples,
|
|
2379
|
-
response:
|
|
2380
|
-
operation.responses['200']!.content['application/json']!.example.destinationToken.address,
|
|
2381
|
-
}).toEqual({
|
|
2382
|
-
request: ['pathusd'],
|
|
2383
|
-
response: '0x20c0000000000000000000000000000000000000',
|
|
2384
|
-
})
|
|
2385
|
-
})
|
|
2386
|
-
|
|
2387
|
-
test('publishes human-readable deposit address route examples', async () => {
|
|
2388
|
-
const app = create({ auth: false })
|
|
2389
|
-
type Property = { examples: string[] }
|
|
2390
|
-
type Operation = {
|
|
2391
|
-
requestBody: {
|
|
2392
|
-
content: Record<
|
|
2393
|
-
string,
|
|
2394
|
-
{
|
|
2395
|
-
schema: {
|
|
2396
|
-
properties: {
|
|
2397
|
-
destinationToken: Property
|
|
2398
|
-
sourceChain: Property
|
|
2399
|
-
sourceToken: Property
|
|
2400
|
-
}
|
|
2401
|
-
}
|
|
2402
|
-
}
|
|
2403
|
-
>
|
|
2404
|
-
}
|
|
2405
|
-
}
|
|
2406
|
-
type Spec = { paths: Record<string, { post: Operation }> }
|
|
2407
|
-
const spec = (await (await app.request('/openapi.json')).json()) as Spec
|
|
2408
|
-
const properties =
|
|
2409
|
-
spec.paths['/v1/funding/deposit-addresses']!.post.requestBody.content['application/json']!
|
|
2410
|
-
.schema.properties
|
|
2411
|
-
|
|
2412
|
-
expect({
|
|
2413
|
-
destinationToken: properties.destinationToken.examples,
|
|
2414
|
-
sourceChain: properties.sourceChain.examples,
|
|
2415
|
-
sourceToken: properties.sourceToken.examples,
|
|
2416
|
-
}).toMatchInlineSnapshot(`
|
|
2417
|
-
{
|
|
2418
|
-
"destinationToken": [
|
|
2419
|
-
"usdt0",
|
|
2420
|
-
],
|
|
2421
|
-
"sourceChain": [
|
|
2422
|
-
"tron",
|
|
2423
|
-
"tron:0x2b6653dc",
|
|
2424
|
-
],
|
|
2425
|
-
"sourceToken": [
|
|
2426
|
-
"usdt",
|
|
2427
|
-
],
|
|
2428
|
-
}
|
|
2429
|
-
`)
|
|
2430
|
-
})
|
|
2431
|
-
|
|
2432
|
-
test('publishes concise operation documentation', async () => {
|
|
2433
|
-
const app = create({ auth: false })
|
|
2434
|
-
type Operation = { description: string; summary: string; tags: string[] }
|
|
2435
|
-
type Spec = {
|
|
2436
|
-
paths: Record<string, { get?: Operation; post?: Operation }>
|
|
2437
|
-
tags: { description?: string; name: string }[]
|
|
2438
|
-
}
|
|
2439
|
-
const spec = (await (await app.request('/openapi.json')).json()) as Spec
|
|
2440
|
-
const detailPath = Object.keys(spec.paths).find((path) =>
|
|
2441
|
-
path.includes('/v1/funding/transfers/{'),
|
|
2442
|
-
)!
|
|
2443
|
-
const depositAddressPath = Object.keys(spec.paths).find(
|
|
2444
|
-
(path) => path.includes('/v1/funding/deposit-addresses/{') && !path.endsWith('/deposits'),
|
|
2445
|
-
)!
|
|
2446
|
-
const depositAddressDepositsPath = Object.keys(spec.paths).find((path) =>
|
|
2447
|
-
path.endsWith('/deposits'),
|
|
2448
|
-
)!
|
|
2449
|
-
const depositPath = Object.keys(spec.paths).find((path) =>
|
|
2450
|
-
path.includes('/v1/funding/deposits/{'),
|
|
2451
|
-
)!
|
|
2452
|
-
const summarize = (operation: Operation) => ({
|
|
2453
|
-
description: operation.description,
|
|
2454
|
-
summary: operation.summary,
|
|
2455
|
-
})
|
|
2456
|
-
const operations = {
|
|
2457
|
-
account: summarize(spec.paths['/v1/funding/transfers']!.post!),
|
|
2458
|
-
chains: summarize(spec.paths['/v1/funding/chains']!.get!),
|
|
2459
|
-
deposit: summarize(spec.paths[depositPath]!.get!),
|
|
2460
|
-
depositAddress: summarize(spec.paths[depositAddressPath]!.get!),
|
|
2461
|
-
depositAddressCreate: summarize(spec.paths['/v1/funding/deposit-addresses']!.post!),
|
|
2462
|
-
depositAddressDeposits: summarize(spec.paths[depositAddressDepositsPath]!.get!),
|
|
2463
|
-
detail: summarize(spec.paths[detailPath]!.get!),
|
|
2464
|
-
list: summarize(spec.paths['/v1/funding/transfers']!.get!),
|
|
2465
|
-
providers: summarize(spec.paths['/v1/funding/providers']!.get!),
|
|
2466
|
-
quotes: summarize(spec.paths['/v1/funding/quotes']!.get!),
|
|
2467
|
-
vault: summarize(spec.paths['/v1/funding/transfers/vault']!.post!),
|
|
2468
|
-
zone: summarize(spec.paths['/v1/funding/transfers/zone']!.post!),
|
|
2469
|
-
}
|
|
2470
|
-
const sidebar = Object.values(spec.paths).flatMap((path) =>
|
|
2471
|
-
[path.get, path.post].flatMap((operation) =>
|
|
2472
|
-
operation?.tags.includes('Funding') ? [operation.summary] : [],
|
|
2473
|
-
),
|
|
2474
|
-
)
|
|
2475
|
-
|
|
2476
|
-
for (const operation of Object.values(operations))
|
|
2477
|
-
expect(operation.description.split(/\s+/).length).toBeLessThan(15)
|
|
2478
|
-
|
|
2479
|
-
expect({
|
|
2480
|
-
category: spec.tags.find((tag) => tag.name === 'Funding')?.description,
|
|
2481
|
-
operations,
|
|
2482
|
-
sidebar,
|
|
2483
|
-
}).toMatchInlineSnapshot(`
|
|
2484
|
-
{
|
|
2485
|
-
"category": "Quote, transfer, and deposit management for funding Tempo from other chains.",
|
|
2486
|
-
"operations": {
|
|
2487
|
-
"account": {
|
|
2488
|
-
"description": "Creates a transfer to a Tempo account.",
|
|
2489
|
-
"summary": "Create transfer",
|
|
2490
|
-
},
|
|
2491
|
-
"chains": {
|
|
2492
|
-
"description": "Lists supported source chains and tokens.",
|
|
2493
|
-
"summary": "Get chains",
|
|
2494
|
-
},
|
|
2495
|
-
"deposit": {
|
|
2496
|
-
"description": "Returns one detected deposit and its delivery status.",
|
|
2497
|
-
"summary": "Get deposit",
|
|
2498
|
-
},
|
|
2499
|
-
"depositAddress": {
|
|
2500
|
-
"description": "Returns one reusable funding deposit address.",
|
|
2501
|
-
"summary": "Get deposit address",
|
|
2502
|
-
},
|
|
2503
|
-
"depositAddressCreate": {
|
|
2504
|
-
"description": "Creates or returns a reusable deposit address for funding a Tempo account.",
|
|
2505
|
-
"summary": "Create deposit address",
|
|
2506
|
-
},
|
|
2507
|
-
"depositAddressDeposits": {
|
|
2508
|
-
"description": "Lists deposits detected for one funding deposit address, newest first.",
|
|
2509
|
-
"summary": "List deposits",
|
|
2510
|
-
},
|
|
2511
|
-
"detail": {
|
|
2512
|
-
"description": "Returns a transfer by ID.",
|
|
2513
|
-
"summary": "Get transfer",
|
|
2514
|
-
},
|
|
2515
|
-
"list": {
|
|
2516
|
-
"description": "Lists transfers from newest to oldest.",
|
|
2517
|
-
"summary": "List transfers",
|
|
2518
|
-
},
|
|
2519
|
-
"providers": {
|
|
2520
|
-
"description": "Lists available transfer providers.",
|
|
2521
|
-
"summary": "Get providers",
|
|
2522
|
-
},
|
|
2523
|
-
"quotes": {
|
|
2524
|
-
"description": "Returns live quotes for transferring stablecoins to Tempo.",
|
|
2525
|
-
"summary": "Get quotes",
|
|
2526
|
-
},
|
|
2527
|
-
"vault": {
|
|
2528
|
-
"description": "Creates a transfer into a Tempo Earn vault.",
|
|
2529
|
-
"summary": "Create transfer into vault",
|
|
2530
|
-
},
|
|
2531
|
-
"zone": {
|
|
2532
|
-
"description": "Creates a transfer into a Tempo Zone.",
|
|
2533
|
-
"summary": "Create transfer into zone",
|
|
2534
|
-
},
|
|
2535
|
-
},
|
|
2536
|
-
"sidebar": [
|
|
2537
|
-
"Get quotes",
|
|
2538
|
-
"Get chains",
|
|
2539
|
-
"Get providers",
|
|
2540
|
-
"Get deposit address",
|
|
2541
|
-
"Get deposit",
|
|
2542
|
-
"List deposits",
|
|
2543
|
-
"Create deposit address",
|
|
2544
|
-
"List transfers",
|
|
2545
|
-
"Create transfer",
|
|
2546
|
-
"Create transfer into vault",
|
|
2547
|
-
"Create transfer into zone",
|
|
2548
|
-
"Get transfer",
|
|
2549
|
-
],
|
|
2550
|
-
}
|
|
2551
|
-
`)
|
|
2552
|
-
})
|
|
2553
|
-
|
|
2554
|
-
test('publishes concrete creation schemas', async () => {
|
|
2555
|
-
const app = create({ auth: false })
|
|
2556
|
-
const spec = (await (await app.request('/openapi.json')).json()) as {
|
|
2557
|
-
paths: Record<
|
|
2558
|
-
string,
|
|
2559
|
-
{
|
|
2560
|
-
post?: {
|
|
2561
|
-
requestBody: {
|
|
2562
|
-
content: Record<
|
|
2563
|
-
string,
|
|
2564
|
-
{
|
|
2565
|
-
schema: {
|
|
2566
|
-
additionalProperties?: boolean
|
|
2567
|
-
properties: object
|
|
2568
|
-
required?: readonly string[] | undefined
|
|
2569
|
-
}
|
|
2570
|
-
}
|
|
2571
|
-
>
|
|
2572
|
-
}
|
|
2573
|
-
responses: Record<string, unknown>
|
|
2574
|
-
}
|
|
2575
|
-
}
|
|
2576
|
-
>
|
|
2577
|
-
}
|
|
2578
|
-
const creationSchema = (path: string) =>
|
|
2579
|
-
spec.paths[path]!.post!.requestBody.content['application/json']!.schema
|
|
2580
|
-
expect(
|
|
2581
|
-
creationSchema('/v1/funding/deposit-addresses').required?.includes('subsidize') ?? false,
|
|
2582
|
-
).toMatchInlineSnapshot(`false`)
|
|
2583
|
-
const summarize = (path: string) => {
|
|
2584
|
-
const schema = creationSchema(path)
|
|
2585
|
-
return {
|
|
2586
|
-
additionalProperties: schema.additionalProperties,
|
|
2587
|
-
properties: Object.keys(schema.properties),
|
|
2588
|
-
}
|
|
2589
|
-
}
|
|
2590
|
-
|
|
2591
|
-
expect({
|
|
2592
|
-
account: summarize('/v1/funding/transfers'),
|
|
2593
|
-
depositAddress: summarize('/v1/funding/deposit-addresses'),
|
|
2594
|
-
depositAddressResponses: Object.keys(
|
|
2595
|
-
spec.paths['/v1/funding/deposit-addresses']!.post!.responses,
|
|
2596
|
-
),
|
|
2597
|
-
vault: summarize('/v1/funding/transfers/vault'),
|
|
2598
|
-
vaultResponses: Object.keys(spec.paths['/v1/funding/transfers/vault']!.post!.responses),
|
|
2599
|
-
zone: summarize('/v1/funding/transfers/zone'),
|
|
2600
|
-
zoneResponses: Object.keys(spec.paths['/v1/funding/transfers/zone']!.post!.responses),
|
|
2601
|
-
}).toMatchInlineSnapshot(`
|
|
2602
|
-
{
|
|
2603
|
-
"account": {
|
|
2604
|
-
"additionalProperties": false,
|
|
2605
|
-
"properties": [
|
|
2606
|
-
"amount",
|
|
2607
|
-
"destinationToken",
|
|
2608
|
-
"mode",
|
|
2609
|
-
"recipient",
|
|
2610
|
-
"sender",
|
|
2611
|
-
"slippageBps",
|
|
2612
|
-
"sourceChain",
|
|
2613
|
-
"sourceToken",
|
|
2614
|
-
],
|
|
2615
|
-
},
|
|
2616
|
-
"depositAddress": {
|
|
2617
|
-
"additionalProperties": false,
|
|
2618
|
-
"properties": [
|
|
2619
|
-
"amount",
|
|
2620
|
-
"destinationToken",
|
|
2621
|
-
"recipient",
|
|
2622
|
-
"refundAddress",
|
|
2623
|
-
"sourceChain",
|
|
2624
|
-
"sourceToken",
|
|
2625
|
-
"subsidize",
|
|
2626
|
-
],
|
|
2627
|
-
},
|
|
2628
|
-
"depositAddressResponses": [
|
|
2629
|
-
"200",
|
|
2630
|
-
"400",
|
|
2631
|
-
"401",
|
|
2632
|
-
"404",
|
|
2633
|
-
"409",
|
|
2634
|
-
"429",
|
|
2635
|
-
"500",
|
|
2636
|
-
"502",
|
|
2637
|
-
],
|
|
2638
|
-
"vault": {
|
|
2639
|
-
"additionalProperties": false,
|
|
2640
|
-
"properties": [
|
|
2641
|
-
"amount",
|
|
2642
|
-
"mode",
|
|
2643
|
-
"recipient",
|
|
2644
|
-
"sender",
|
|
2645
|
-
"slippageBps",
|
|
2646
|
-
"sourceChain",
|
|
2647
|
-
"sourceToken",
|
|
2648
|
-
"vaultAddress",
|
|
2649
|
-
],
|
|
2650
|
-
},
|
|
2651
|
-
"vaultResponses": [
|
|
2652
|
-
"400",
|
|
2653
|
-
"401",
|
|
2654
|
-
"429",
|
|
2655
|
-
"500",
|
|
2656
|
-
"501",
|
|
2657
|
-
],
|
|
2658
|
-
"zone": {
|
|
2659
|
-
"additionalProperties": false,
|
|
2660
|
-
"properties": [
|
|
2661
|
-
"amount",
|
|
2662
|
-
"destinationChain",
|
|
2663
|
-
"destinationToken",
|
|
2664
|
-
"mode",
|
|
2665
|
-
"recipient",
|
|
2666
|
-
"recipientFallback",
|
|
2667
|
-
"sender",
|
|
2668
|
-
"slippageBps",
|
|
2669
|
-
"sourceChain",
|
|
2670
|
-
"sourceToken",
|
|
2671
|
-
],
|
|
2672
|
-
},
|
|
2673
|
-
"zoneResponses": [
|
|
2674
|
-
"400",
|
|
2675
|
-
"401",
|
|
2676
|
-
"429",
|
|
2677
|
-
"500",
|
|
2678
|
-
"501",
|
|
2679
|
-
],
|
|
2680
|
-
}
|
|
2681
|
-
`)
|
|
2682
|
-
})
|
|
2683
|
-
|
|
2684
|
-
test('publishes alphabetically ordered example fields and parameter examples', async () => {
|
|
2685
|
-
const app = create({ auth: false })
|
|
2686
|
-
const spec = (await (await app.request('/openapi.json')).json()) as {
|
|
2687
|
-
paths: Record<
|
|
2688
|
-
string,
|
|
2689
|
-
{
|
|
2690
|
-
get: {
|
|
2691
|
-
parameters?: { example?: unknown; name: string; schema?: { examples?: unknown } }[]
|
|
2692
|
-
responses: Record<string, { content: Record<string, { example: { data: {}[] } }> }>
|
|
2693
|
-
}
|
|
2694
|
-
}
|
|
2695
|
-
>
|
|
2696
|
-
}
|
|
2697
|
-
const operation = spec.paths['/v1/funding/transfers']!.get
|
|
2698
|
-
const fields = Object.keys(operation.responses['200']!.content['application/json']!.example.data[0]!) // prettier-ignore
|
|
2699
|
-
expect(fields).toEqual([...fields].sort())
|
|
2700
|
-
for (const parameter of operation.parameters ?? [])
|
|
2701
|
-
expect(parameter.example ?? parameter.schema?.examples, parameter.name).toBeDefined()
|
|
2702
|
-
})
|
|
2703
|
-
|
|
2704
|
-
test('publishes operation ids and required scopes', async () => {
|
|
2705
|
-
const app = create({ auth: false })
|
|
2706
|
-
const spec = (await (await app.request('/openapi.json')).json()) as {
|
|
2707
|
-
paths: Record<
|
|
2708
|
-
string,
|
|
2709
|
-
{
|
|
2710
|
-
get: { operationId: string; 'x-required-scopes': string[] }
|
|
2711
|
-
post?: {
|
|
2712
|
-
operationId: string
|
|
2713
|
-
parameters: { in: string; name: string; required: boolean }[]
|
|
2714
|
-
'x-required-scopes': string[]
|
|
2715
|
-
}
|
|
2716
|
-
}
|
|
2717
|
-
>
|
|
2718
|
-
}
|
|
2719
|
-
const detailPath = Object.keys(spec.paths).find((path) =>
|
|
2720
|
-
path.includes('/v1/funding/transfers/{'),
|
|
2721
|
-
)
|
|
2722
|
-
const depositAddressPath = Object.keys(spec.paths).find(
|
|
2723
|
-
(path) => path.includes('/v1/funding/deposit-addresses/{') && !path.endsWith('/deposits'),
|
|
2724
|
-
)
|
|
2725
|
-
const depositAddressDepositsPath = Object.keys(spec.paths).find((path) =>
|
|
2726
|
-
path.endsWith('/deposits'),
|
|
2727
|
-
)
|
|
2728
|
-
const depositPath = Object.keys(spec.paths).find((path) =>
|
|
2729
|
-
path.includes('/v1/funding/deposits/{'),
|
|
2730
|
-
)
|
|
2731
|
-
expect({
|
|
2732
|
-
deposit: depositPath && spec.paths[depositPath]?.get.operationId,
|
|
2733
|
-
depositAddress: depositAddressPath && spec.paths[depositAddressPath]?.get.operationId,
|
|
2734
|
-
depositAddressCreate: spec.paths['/v1/funding/deposit-addresses']?.post?.operationId,
|
|
2735
|
-
depositAddressCreateScopes:
|
|
2736
|
-
spec.paths['/v1/funding/deposit-addresses']?.post?.['x-required-scopes'],
|
|
2737
|
-
depositAddressDeposits:
|
|
2738
|
-
depositAddressDepositsPath && spec.paths[depositAddressDepositsPath]?.get.operationId,
|
|
2739
|
-
depositAddressDepositsScopes:
|
|
2740
|
-
depositAddressDepositsPath &&
|
|
2741
|
-
spec.paths[depositAddressDepositsPath]?.get['x-required-scopes'],
|
|
2742
|
-
depositAddressScopes:
|
|
2743
|
-
depositAddressPath && spec.paths[depositAddressPath]?.get['x-required-scopes'],
|
|
2744
|
-
depositScopes: depositPath && spec.paths[depositPath]?.get['x-required-scopes'],
|
|
2745
|
-
detail: detailPath && spec.paths[detailPath]?.get.operationId,
|
|
2746
|
-
detailScopes: detailPath && spec.paths[detailPath]?.get['x-required-scopes'],
|
|
2747
|
-
list: spec.paths['/v1/funding/transfers']?.get.operationId,
|
|
2748
|
-
listScopes: spec.paths['/v1/funding/transfers']?.get['x-required-scopes'],
|
|
2749
|
-
requiredCreateHeader: spec.paths['/v1/funding/transfers']?.post?.parameters.find(
|
|
2750
|
-
(parameter) => parameter.name === 'idempotency-key',
|
|
2751
|
-
),
|
|
2752
|
-
vault: spec.paths['/v1/funding/transfers/vault']?.post?.operationId,
|
|
2753
|
-
vaultScopes: spec.paths['/v1/funding/transfers/vault']?.post?.['x-required-scopes'],
|
|
2754
|
-
zone: spec.paths['/v1/funding/transfers/zone']?.post?.operationId,
|
|
2755
|
-
zoneScopes: spec.paths['/v1/funding/transfers/zone']?.post?.['x-required-scopes'],
|
|
2756
|
-
}).toEqual({
|
|
2757
|
-
deposit: 'getFundingDeposit',
|
|
2758
|
-
depositAddress: 'getFundingDepositAddress',
|
|
2759
|
-
depositAddressCreate: 'createFundingDepositAddress',
|
|
2760
|
-
depositAddressCreateScopes: ['funding:write'],
|
|
2761
|
-
depositAddressDeposits: 'listFundingDepositAddressDeposits',
|
|
2762
|
-
depositAddressDepositsScopes: ['funding:read'],
|
|
2763
|
-
depositAddressScopes: ['funding:read'],
|
|
2764
|
-
depositScopes: ['funding:read'],
|
|
2765
|
-
detail: 'getFundingTransfer',
|
|
2766
|
-
detailScopes: ['funding:read'],
|
|
2767
|
-
list: 'listFundingTransfers',
|
|
2768
|
-
listScopes: ['funding:read'],
|
|
2769
|
-
requiredCreateHeader: expect.objectContaining({
|
|
2770
|
-
description: expect.stringContaining('24 hours'),
|
|
2771
|
-
in: 'header',
|
|
2772
|
-
name: 'idempotency-key',
|
|
2773
|
-
required: true,
|
|
2774
|
-
schema: expect.any(Object),
|
|
2775
|
-
}),
|
|
2776
|
-
vault: 'createFundingTransferVault',
|
|
2777
|
-
vaultScopes: ['funding:write'],
|
|
2778
|
-
zone: 'createFundingTransferZone',
|
|
2779
|
-
zoneScopes: ['funding:write'],
|
|
2780
|
-
})
|
|
2781
|
-
})
|
|
2782
|
-
})
|
|
2783
|
-
|
|
2784
|
-
const baseUsdcQuery = {
|
|
2785
|
-
sourceAmount: '1000000',
|
|
2786
|
-
sourceChain: 'base',
|
|
2787
|
-
sourceToken: 'USDC',
|
|
2788
|
-
} as const
|
|
2789
|
-
|
|
2790
|
-
type QuoteQuery = Record<string, string>
|
|
2791
|
-
|
|
2792
|
-
function quoteUrl(query: QuoteQuery = baseUsdcQuery) {
|
|
2793
|
-
return `/v1/funding/quotes?${new URLSearchParams(
|
|
2794
|
-
Object.entries(query).map(([key, value]) => [key, String(value)]),
|
|
2795
|
-
)}`
|
|
2796
|
-
}
|
|
2797
|
-
|
|
2798
|
-
function relayProvider(onGetQuote: () => void = () => {}) {
|
|
2799
|
-
return withGetQuote('relay', async (input, signal) => {
|
|
2800
|
-
onGetQuote()
|
|
2801
|
-
expect(signal.aborted).toBe(false)
|
|
2802
|
-
expect(input.candidate.id).toBe('base-usdc-tempo-usdce-relay')
|
|
2803
|
-
return {
|
|
2804
|
-
destinationAmountMin: '980000',
|
|
2805
|
-
destinationAmount: '990000',
|
|
2806
|
-
quality: {
|
|
2807
|
-
estimatedSeconds: 4,
|
|
2808
|
-
liquiditySource: 'providerQuote',
|
|
2809
|
-
sourceDetail: 'relay:quote-v2',
|
|
2810
|
-
tier: 'liquid',
|
|
2811
|
-
},
|
|
2812
|
-
sampledAt: '2026-01-01T00:00:00Z',
|
|
2813
|
-
status: 'available',
|
|
2814
|
-
}
|
|
2815
|
-
})
|
|
2816
|
-
}
|
|
2817
|
-
|
|
2818
|
-
function acrossProvider(onGetQuote: () => void = () => {}) {
|
|
2819
|
-
return withGetQuote('across', async (input, signal) => {
|
|
2820
|
-
onGetQuote()
|
|
2821
|
-
expect(signal.aborted).toBe(false)
|
|
2822
|
-
expect(input.candidate.id).toBe('base-usdc-tempo-usdce-across')
|
|
2823
|
-
return {
|
|
2824
|
-
expiresAt: '2026-01-01T00:01:00Z',
|
|
2825
|
-
destinationAmountMin: '988000',
|
|
2826
|
-
destinationAmount: '990000',
|
|
2827
|
-
quality: {
|
|
2828
|
-
estimatedSeconds: 2,
|
|
2829
|
-
liquiditySource: 'providerQuote',
|
|
2830
|
-
sourceDetail: 'across:swap-approval',
|
|
2831
|
-
tier: 'liquid',
|
|
2832
|
-
},
|
|
2833
|
-
sampledAt: '2026-01-01T00:00:00Z',
|
|
2834
|
-
status: 'available',
|
|
2835
|
-
}
|
|
2836
|
-
})
|
|
2837
|
-
}
|
|
2838
|
-
|
|
2839
|
-
function rhinoProvider() {
|
|
2840
|
-
return withGetQuote('rhino', async (input, signal) => {
|
|
2841
|
-
expect(signal.aborted).toBe(false)
|
|
2842
|
-
expect(input.candidate.id).toBe('solana-usdc-tempo-usdce-rhino')
|
|
2843
|
-
return {
|
|
2844
|
-
destinationAmount: '983000',
|
|
2845
|
-
quality: {
|
|
2846
|
-
liquiditySource: 'providerQuote',
|
|
2847
|
-
sourceDetail: 'rhino:bridge-swap-public',
|
|
2848
|
-
tier: 'liquid',
|
|
2849
|
-
},
|
|
2850
|
-
sampledAt: '2026-01-01T00:00:00Z',
|
|
2851
|
-
status: 'available',
|
|
2852
|
-
}
|
|
2853
|
-
})
|
|
2854
|
-
}
|
|
2855
|
-
|
|
2856
|
-
function withGetQuote(
|
|
2857
|
-
id: FundingProvider.ProviderId,
|
|
2858
|
-
getQuote: FundingProvider.Provider['getQuote'],
|
|
2859
|
-
) {
|
|
2860
|
-
const provider = providers.find((entry) => entry.id === id)
|
|
2861
|
-
if (!provider) throw new Error(`Missing ${id} provider`)
|
|
2862
|
-
return FundingProvider.from({ ...provider, getQuote })
|
|
2863
|
-
}
|
|
2864
|
-
|
|
2865
|
-
function withCreateDepositAddress(
|
|
2866
|
-
id: FundingProvider.ProviderId,
|
|
2867
|
-
createDepositAddress: NonNullable<FundingProvider.Provider['createDepositAddress']>,
|
|
2868
|
-
) {
|
|
2869
|
-
const provider = providers.find((entry) => entry.id === id)
|
|
2870
|
-
if (!provider) throw new Error(`Missing ${id} provider`)
|
|
2871
|
-
return FundingProvider.from({ ...provider, createDepositAddress })
|
|
2872
|
-
}
|
|
2873
|
-
|
|
2874
|
-
function depositAddressProvider(
|
|
2875
|
-
address = 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8',
|
|
2876
|
-
onCreate: (input: FundingProvider.createDepositAddress.Parameters) => void = () => {},
|
|
2877
|
-
) {
|
|
2878
|
-
return withCreateDepositAddress('relay', async (input) => {
|
|
2879
|
-
onCreate(input)
|
|
2880
|
-
return {
|
|
2881
|
-
address,
|
|
2882
|
-
correlation: {
|
|
2883
|
-
checkEndpoint: '/intents/status/v3?requestId=relay_request_1',
|
|
2884
|
-
requestId: 'relay_request_1',
|
|
2885
|
-
},
|
|
2886
|
-
destinationAmount: '1000000',
|
|
2887
|
-
destinationAmountMin: '1000000',
|
|
2888
|
-
fees: [],
|
|
2889
|
-
sampledAt: '2026-08-04T00:00:00.000Z',
|
|
2890
|
-
}
|
|
2891
|
-
})
|
|
2892
|
-
}
|
|
2893
|
-
|
|
2894
|
-
/** API key with funding read access, owned by `org_funding`. */
|
|
2895
|
-
const fundingReader = {
|
|
2896
|
-
id: 'key_funding',
|
|
2897
|
-
orgId: 'org_funding',
|
|
2898
|
-
scopes: ['funding:read'],
|
|
2899
|
-
token: 'secret_funding_reader',
|
|
2900
|
-
} satisfies TestApp.kvStore.Key
|
|
2901
|
-
|
|
2902
|
-
/** Project-attributed key in the same org, used to prove project narrowing. */
|
|
2903
|
-
const fundingProjectReader = {
|
|
2904
|
-
id: 'key_funding_project',
|
|
2905
|
-
orgId: 'org_funding',
|
|
2906
|
-
projectId: 'prj_funding',
|
|
2907
|
-
scopes: ['funding:read'],
|
|
2908
|
-
token: 'secret_funding_project',
|
|
2909
|
-
} satisfies TestApp.kvStore.Key
|
|
2910
|
-
|
|
2911
|
-
/** Key in a different org, used to prove owner isolation. */
|
|
2912
|
-
const fundingForeign = {
|
|
2913
|
-
id: 'key_funding_other',
|
|
2914
|
-
orgId: 'org_funding_other',
|
|
2915
|
-
scopes: ['funding:read'],
|
|
2916
|
-
token: 'secret_funding_other',
|
|
2917
|
-
} satisfies TestApp.kvStore.Key
|
|
2918
|
-
|
|
2919
|
-
/** Key with funding write access, owned by `org_funding`. */
|
|
2920
|
-
const fundingWriter = {
|
|
2921
|
-
id: 'key_funding_writer',
|
|
2922
|
-
orgId: 'org_funding',
|
|
2923
|
-
scopes: ['funding:read', 'funding:write'],
|
|
2924
|
-
token: 'secret_funding_writer',
|
|
2925
|
-
} satisfies TestApp.kvStore.Key
|
|
2926
|
-
|
|
2927
|
-
function transfersApp(options: TestApp.create.Options = {}) {
|
|
2928
|
-
return create({
|
|
2929
|
-
...options,
|
|
2930
|
-
auth: {
|
|
2931
|
-
keys: [TestApp.key, fundingReader, fundingProjectReader, fundingForeign, fundingWriter],
|
|
2932
|
-
},
|
|
2933
|
-
defaultChainId: options.defaultChainId ?? tempoMainnet.id,
|
|
2934
|
-
})
|
|
2935
|
-
}
|
|
2936
|
-
|
|
2937
|
-
/** RequestInit carrying a bearer token for the given key. */
|
|
2938
|
-
function as(key: { token: string }) {
|
|
2939
|
-
return { headers: { authorization: `Bearer ${key.token}` } } as const
|
|
2940
|
-
}
|
|
2941
|
-
|
|
2942
|
-
let seedDepositAddressSecond = 0
|
|
2943
|
-
function seedDepositAddress(input: Partial<FundingDepositAddress.create.Input> = {}) {
|
|
2944
|
-
const index = seedDepositAddressSecond++
|
|
2945
|
-
const now = new Date(Date.UTC(2026, 0, 3, 0, 0, index))
|
|
2946
|
-
return FundingDepositAddress.create(db, {
|
|
2947
|
-
apiKeyId: fundingReader.id,
|
|
2948
|
-
deliveryStrategy: 'provider',
|
|
2949
|
-
environment: 'production',
|
|
2950
|
-
id: FundingDepositAddress.generateId(now),
|
|
2951
|
-
now,
|
|
2952
|
-
orgId: fundingReader.orgId,
|
|
2953
|
-
providerOutputToken: TestFunding.providerOutputToken(),
|
|
2954
|
-
snapshot: TestFunding.depositAddressSnapshot({
|
|
2955
|
-
provider: { id: `relay_route_${index}`, name: 'Relay' },
|
|
2956
|
-
recipient: `0x${index.toString(16).padStart(40, '0')}`,
|
|
2957
|
-
}),
|
|
2958
|
-
...input,
|
|
2959
|
-
})
|
|
2960
|
-
}
|
|
2961
|
-
|
|
2962
|
-
let seedDepositSecond = 0
|
|
2963
|
-
function seedDeposit(
|
|
2964
|
-
address: Awaited<ReturnType<typeof seedDepositAddress>>,
|
|
2965
|
-
input: Partial<FundingDeposit.create.Input> = {},
|
|
2966
|
-
) {
|
|
2967
|
-
const index = seedDepositSecond++
|
|
2968
|
-
const now = new Date(Date.UTC(2026, 0, 4, 0, 0, index))
|
|
2969
|
-
const sourceTransactionHash = index.toString(16).padStart(64, '0')
|
|
2970
|
-
return FundingDeposit.create(db, {
|
|
2971
|
-
now,
|
|
2972
|
-
providerRequestId: `relay_request_${index}`,
|
|
2973
|
-
snapshot: TestFunding.depositSnapshot(address.id, {
|
|
2974
|
-
provider: address.snapshot.provider,
|
|
2975
|
-
sourceTransactionHashes: [sourceTransactionHash],
|
|
2976
|
-
}),
|
|
2977
|
-
sourceTransactionHash,
|
|
2978
|
-
sourceTransferIndex: 0,
|
|
2979
|
-
...input,
|
|
2980
|
-
})
|
|
2981
|
-
}
|
|
2982
|
-
|
|
2983
|
-
let seedSecond = 0
|
|
2984
|
-
function seedTransfer(input: Partial<FundingTransfer.create.Input> = {}) {
|
|
2985
|
-
// Distinct create times keep ids strictly ordered for paging assertions.
|
|
2986
|
-
return FundingTransfer.create(db, {
|
|
2987
|
-
apiKeyId: fundingReader.id,
|
|
2988
|
-
environment: 'production',
|
|
2989
|
-
id: FundingTransfer.generateId(new Date(Date.UTC(2026, 0, 2, 0, 0, seedSecond++))),
|
|
2990
|
-
orgId: fundingReader.orgId,
|
|
2991
|
-
snapshot: TestFunding.transferSnapshot(),
|
|
2992
|
-
...input,
|
|
2993
|
-
})
|
|
2994
|
-
}
|