tapimo 0.5.0 → 0.5.1
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/dist/App.d.ts +4879 -4870
- package/dist/App.d.ts.map +1 -1
- package/dist/App.js +20 -2
- package/dist/App.js.map +1 -1
- package/dist/Assets.js +3 -3
- package/dist/Assets.js.map +1 -1
- package/dist/Scope.d.ts.map +1 -1
- package/dist/Scope.js +1 -2
- package/dist/Scope.js.map +1 -1
- package/dist/Webhooks.d.ts +1 -1
- package/dist/Webhooks.d.ts.map +1 -1
- package/dist/Webhooks.js +5 -10
- package/dist/Webhooks.js.map +1 -1
- package/dist/admin/App.d.ts +6 -34
- package/dist/admin/App.d.ts.map +1 -1
- package/dist/admin/apps/api-keys.d.ts +3 -3
- package/dist/admin/apps/api-keys.d.ts.map +1 -1
- package/dist/admin/apps/api-keys.js +4 -1
- package/dist/admin/apps/api-keys.js.map +1 -1
- package/dist/admin/apps/scopes.d.ts +6 -34
- package/dist/admin/apps/scopes.d.ts.map +1 -1
- package/dist/admin/apps/scopes.js +2 -2
- package/dist/admin/apps/scopes.js.map +1 -1
- package/dist/admin/apps/verified-tokens.d.ts +116 -116
- package/dist/admin/ui.generated.d.ts +1 -1
- package/dist/admin/ui.generated.js +33 -33
- package/dist/admin/ui.generated.js.map +1 -1
- package/dist/apps/data/App.d.ts +3199 -3206
- package/dist/apps/data/App.d.ts.map +1 -1
- package/dist/apps/data/App.js +25 -38
- package/dist/apps/data/App.js.map +1 -1
- package/dist/apps/data/FundingQuoteProviders.d.ts +76 -3
- package/dist/apps/data/FundingQuoteProviders.d.ts.map +1 -1
- package/dist/apps/data/FundingQuoteProviders.js +276 -2
- package/dist/apps/data/FundingQuoteProviders.js.map +1 -1
- package/dist/apps/data/routes/activities.d.ts +245 -245
- package/dist/apps/data/routes/addresses.d.ts +206 -206
- package/dist/apps/data/routes/balances.d.ts +46 -46
- package/dist/apps/data/routes/blocks.d.ts +123 -123
- package/dist/apps/data/routes/coingecko.d.ts +169 -169
- package/dist/apps/data/routes/funding.d.ts +230 -146
- package/dist/apps/data/routes/funding.d.ts.map +1 -1
- package/dist/apps/data/routes/funding.js +23 -12
- package/dist/apps/data/routes/funding.js.map +1 -1
- package/dist/apps/data/routes/indexer.d.ts +34 -34
- package/dist/apps/data/routes/indexer.d.ts.map +1 -1
- package/dist/apps/data/routes/indexer.js +9 -2
- package/dist/apps/data/routes/indexer.js.map +1 -1
- package/dist/apps/data/routes/receipts.d.ts +228 -228
- package/dist/apps/data/routes/tokenlist.d.ts +36 -36
- package/dist/apps/data/routes/transactions.d.ts +281 -281
- package/dist/apps/data/routes/transfers.d.ts +58 -58
- package/dist/apps/data/routes/verified-tokens.d.ts +86 -86
- package/dist/apps/management/App.d.ts +1042 -1042
- package/dist/apps/management/routes/api-keys.d.ts +120 -120
- package/dist/apps/management/routes/billing.d.ts +211 -211
- package/dist/apps/management/routes/invitations.d.ts +132 -132
- package/dist/apps/management/routes/invite-links.d.ts +62 -62
- package/dist/apps/management/routes/me.d.ts +17 -17
- package/dist/apps/management/routes/members.d.ts +80 -80
- package/dist/apps/management/routes/orgs.d.ts +96 -96
- package/dist/apps/management/routes/projects.d.ts +61 -61
- package/dist/apps/management/routes/scopes.d.ts +11 -11
- package/dist/apps/management/routes/usage.d.ts +79 -79
- package/dist/apps/mcp.js +2 -2
- package/dist/apps/mcp.js.map +1 -1
- package/dist/apps/relay/App.d.ts +2 -2
- package/dist/apps/relay/App.js +2 -2
- package/dist/db/tables/sponsoredTransactions.d.ts.map +1 -1
- package/dist/db/tables/sponsoredTransactions.js +6 -2
- package/dist/db/tables/sponsoredTransactions.js.map +1 -1
- package/dist/internal/Auth.d.ts.map +1 -1
- package/dist/internal/Auth.js +8 -0
- package/dist/internal/Auth.js.map +1 -1
- package/dist/internal/FundingQuotes.d.ts +170 -56
- package/dist/internal/FundingQuotes.d.ts.map +1 -1
- package/dist/internal/FundingQuotes.js +91 -5
- package/dist/internal/FundingQuotes.js.map +1 -1
- package/dist/internal/Log.d.ts +18 -12
- package/dist/internal/Log.d.ts.map +1 -1
- package/dist/internal/Log.js +2 -0
- package/dist/internal/Log.js.map +1 -1
- package/dist/internal/Tidx.d.ts +9 -3
- package/dist/internal/Tidx.d.ts.map +1 -1
- package/dist/internal/Tidx.js +13 -7
- package/dist/internal/Tidx.js.map +1 -1
- package/dist/internal/Viem.d.ts +14 -1
- package/dist/internal/Viem.d.ts.map +1 -1
- package/dist/internal/Viem.js +19 -1
- package/dist/internal/Viem.js.map +1 -1
- package/package.json +2 -2
- package/src/ApiKeys.test.ts +2 -0
- package/src/App.test.ts +13 -2
- package/src/App.ts +31 -5
- package/src/Assets.ts +3 -3
- package/src/Client.test-d.ts +3 -2
- package/src/Scope.ts +4 -2
- package/src/Webhooks.ts +5 -10
- package/src/admin/apps/api-keys.test.ts +27 -0
- package/src/admin/apps/api-keys.ts +8 -1
- package/src/admin/apps/scopes.test.ts +7 -2
- package/src/admin/apps/scopes.ts +2 -2
- package/src/admin/ui/src/components/Dialog.tsx +8 -2
- package/src/admin/ui/src/lib/scopes.test.ts +19 -0
- package/src/admin/ui/src/lib/scopes.ts +12 -0
- package/src/admin/ui/src/routes/api-keys.tsx +128 -27
- package/src/admin/ui.generated.ts +33 -33
- package/src/apps/composition.test.ts +77 -0
- package/src/apps/data/App.ts +25 -45
- package/src/apps/data/FundingQuoteProviders.test.ts +431 -5
- package/src/apps/data/FundingQuoteProviders.ts +386 -4
- package/src/apps/data/routes/funding.test.ts +174 -7
- package/src/apps/data/routes/funding.ts +27 -12
- package/src/apps/data/routes/indexer.test.ts +82 -20
- package/src/apps/data/routes/indexer.ts +8 -2
- package/src/apps/management/Billing.test.ts +2 -2
- package/src/apps/mcp.test.ts +18 -0
- package/src/apps/mcp.ts +2 -2
- package/src/apps/relay/App.test.ts +6 -5
- package/src/apps/relay/App.ts +2 -2
- package/src/apps/relay/Sponsorships.test.ts +2 -2
- package/src/db/Db.test.ts +5 -5
- package/src/db/tables/billingSettings.test.ts +2 -2
- package/src/db/tables/organizations.test.ts +2 -2
- package/src/db/tables/sponsoredTransactions.test.ts +19 -2
- package/src/db/tables/sponsoredTransactions.ts +21 -14
- package/src/db/tables/stripeCustomers.test.ts +2 -2
- package/src/internal/Auth.ts +10 -0
- package/src/internal/FundingQuotes.test.ts +97 -3
- package/src/internal/FundingQuotes.ts +104 -6
- package/src/internal/Log.ts +21 -14
- package/src/internal/Tidx.test.ts +48 -0
- package/src/internal/Tidx.ts +21 -7
- package/src/internal/Viem.test.ts +46 -0
- package/src/internal/Viem.ts +34 -1
|
@@ -25,6 +25,46 @@ async function paths(app: { request: (path: string) => Promise<Response> | Respo
|
|
|
25
25
|
return Object.keys((await document(app)).paths)
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
describe('App.create()', () => {
|
|
29
|
+
test('publishes shared chain context without data routes', async () => {
|
|
30
|
+
const rpc = { url: () => 'https://rpc.example' }
|
|
31
|
+
const group = new Hono<App.Environment>().get('/context', (c) => {
|
|
32
|
+
const client = c.get('getClient')()
|
|
33
|
+
return c.json({
|
|
34
|
+
chainId: c.get('chainId'),
|
|
35
|
+
clientChainId: client.chain.id,
|
|
36
|
+
memoized: client === c.get('getClient')(),
|
|
37
|
+
rpc: c.get('rpc') === rpc,
|
|
38
|
+
supportedChainIds: [...c.get('supportedChainIds')].sort((a, b) => a - b),
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
const app = App.create({
|
|
42
|
+
auth: false,
|
|
43
|
+
db: TestApp.database(),
|
|
44
|
+
defaultChainId: 1337,
|
|
45
|
+
rpc,
|
|
46
|
+
supportedChainIds: [999],
|
|
47
|
+
}).route('/', group)
|
|
48
|
+
|
|
49
|
+
const response = await app.request('/context')
|
|
50
|
+
|
|
51
|
+
expect(await response.json()).toMatchInlineSnapshot(`
|
|
52
|
+
{
|
|
53
|
+
"chainId": 1337,
|
|
54
|
+
"clientChainId": 1337,
|
|
55
|
+
"memoized": true,
|
|
56
|
+
"rpc": true,
|
|
57
|
+
"supportedChainIds": [
|
|
58
|
+
999,
|
|
59
|
+
1337,
|
|
60
|
+
4217,
|
|
61
|
+
42431,
|
|
62
|
+
],
|
|
63
|
+
}
|
|
64
|
+
`)
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
28
68
|
describe('App.from()', () => {
|
|
29
69
|
test('contributes external route-group metadata', async () => {
|
|
30
70
|
const group = new Hono().get(
|
|
@@ -110,6 +150,43 @@ describe('data()', () => {
|
|
|
110
150
|
const orgs = await app.request('/v1/orgs')
|
|
111
151
|
expect(orgs.status).toBe(404)
|
|
112
152
|
})
|
|
153
|
+
|
|
154
|
+
test('inherits shared chain context', async () => {
|
|
155
|
+
const rpc = { url: () => 'https://rpc.example' }
|
|
156
|
+
const context = new Hono<App.Environment>().get('/context', (c) =>
|
|
157
|
+
c.json({
|
|
158
|
+
chainId: c.get('chainId'),
|
|
159
|
+
clientChainId: c.get('getClient')().chain.id,
|
|
160
|
+
rpc: c.get('rpc') === rpc,
|
|
161
|
+
supportedChainIds: [...c.get('supportedChainIds')].sort((a, b) => a - b),
|
|
162
|
+
}),
|
|
163
|
+
)
|
|
164
|
+
const app = App.create({
|
|
165
|
+
auth: false,
|
|
166
|
+
db: TestApp.database(),
|
|
167
|
+
defaultChainId: 333,
|
|
168
|
+
rpc,
|
|
169
|
+
supportedChainIds: [444],
|
|
170
|
+
})
|
|
171
|
+
.route('/', data())
|
|
172
|
+
.route('/', context)
|
|
173
|
+
|
|
174
|
+
const response = await app.request('/context')
|
|
175
|
+
|
|
176
|
+
expect(await response.json()).toMatchInlineSnapshot(`
|
|
177
|
+
{
|
|
178
|
+
"chainId": 333,
|
|
179
|
+
"clientChainId": 333,
|
|
180
|
+
"rpc": true,
|
|
181
|
+
"supportedChainIds": [
|
|
182
|
+
333,
|
|
183
|
+
444,
|
|
184
|
+
4217,
|
|
185
|
+
42431,
|
|
186
|
+
],
|
|
187
|
+
}
|
|
188
|
+
`)
|
|
189
|
+
})
|
|
113
190
|
})
|
|
114
191
|
|
|
115
192
|
describe('relay()', () => {
|
package/src/apps/data/App.ts
CHANGED
|
@@ -33,59 +33,36 @@ import { verifiedTokens } from './routes/verified-tokens.js'
|
|
|
33
33
|
import { schema as webhookSchema, webhooks } from './routes/webhooks.js'
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* The chain-data route group
|
|
37
|
-
*
|
|
38
|
-
* configuration and publishes the chain clients on request context.
|
|
36
|
+
* The chain-data route group extends app context with TIDX, assets, zones,
|
|
37
|
+
* verified tokens, and webhooks, and exposes indexed and RPC-backed reads.
|
|
39
38
|
*
|
|
40
39
|
* ```ts
|
|
41
40
|
* App.create(options).route('/', data())
|
|
42
41
|
* ```
|
|
43
42
|
*/
|
|
44
43
|
export function data(options: data.Options = {}) {
|
|
45
|
-
const { assets,
|
|
44
|
+
const { assets, tidx } = options
|
|
46
45
|
|
|
47
46
|
// Zone endpoints are explicit trust boundaries; the API never derives them.
|
|
48
47
|
const zones = new Map((options.zones ?? []).map((zone) => [zone.chainId, zone]))
|
|
49
48
|
const dataRouteHandlers = new Set<unknown>()
|
|
50
49
|
|
|
51
|
-
|
|
52
|
-
// `defaultChainId` and any explicit `supportedChainIds`). Published on context
|
|
53
|
-
// so handlers with a path `chainId` can guard it too.
|
|
54
|
-
const supportedChainIds = new Set<number>([
|
|
55
|
-
...Object.keys(Viem.url).map(Number),
|
|
56
|
-
...Object.keys(Tidx.url).map(Number),
|
|
57
|
-
defaultChainId,
|
|
58
|
-
...(options.supportedChainIds ?? []),
|
|
59
|
-
...zones.keys(),
|
|
60
|
-
])
|
|
61
|
-
|
|
62
|
-
// Memoize one viem client per resolved upstream (stateless HTTP transports).
|
|
63
|
-
// Keying on the resolved output (`url`+`basicAuth`+`chainId`), not the
|
|
64
|
-
// principal, bounds cardinality by the number of distinct upstreams.
|
|
65
|
-
const clients = new Map<string, ReturnType<typeof Viem.getClient>>()
|
|
66
|
-
const getClient = (chainId = defaultChainId, principal: Auth.Principal | null = null) => {
|
|
67
|
-
const { url, basicAuth } = Viem.resolve(rpc_option, { chainId, principal })
|
|
68
|
-
const cacheKey = `${url}|${basicAuth ?? ''}|${chainId}`
|
|
69
|
-
const existing = clients.get(cacheKey)
|
|
70
|
-
if (existing) return existing
|
|
71
|
-
const client = Viem.getClient({ chainId, principal, rpc: rpc_option })
|
|
72
|
-
clients.set(cacheKey, client)
|
|
73
|
-
return client
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Memoize TIDX clients per resolved upstream.
|
|
77
|
-
const getTidx = Tidx.createGetClient({ defaultChainId, tidx })
|
|
50
|
+
const getTidx = Tidx.createGetClient({ tidx })
|
|
78
51
|
|
|
79
52
|
const app = new Hono<App.Environment>()
|
|
80
53
|
// Publish the chain-data context vars for this group's handlers. Runs after
|
|
81
54
|
// `App.create`'s middleware, so `getClient`/`getTidx` can key on the
|
|
82
55
|
// resolved principal and `getAsset` can read `basePath` off context.
|
|
83
56
|
.use('*', async (c, next) => {
|
|
84
|
-
c.
|
|
57
|
+
const getClient = c.get('getClient')
|
|
58
|
+
const supportedChainIds = new Set<number>([
|
|
59
|
+
...c.get('supportedChainIds'),
|
|
60
|
+
...Object.keys(Tidx.url).map(Number),
|
|
61
|
+
...zones.keys(),
|
|
62
|
+
])
|
|
85
63
|
c.set('getAsset', createGetAsset({ assets, basePath: c.get('basePath'), origin: new URL(c.req.url).origin })) // prettier-ignore
|
|
86
|
-
c.set('getClient', (chainId) => getClient(chainId
|
|
87
|
-
c.set('getTidx', (chainId) => getTidx(chainId, Auth.getPrincipal(c)))
|
|
88
|
-
c.set('rpc', rpc_option)
|
|
64
|
+
c.set('getClient', (chainId) => getClient(chainId ?? c.get('chainId')))
|
|
65
|
+
c.set('getTidx', (chainId) => getTidx(chainId ?? c.get('chainId'), Auth.getPrincipal(c)))
|
|
89
66
|
c.set('supportedChainIds', supportedChainIds)
|
|
90
67
|
c.set('tidx', tidx)
|
|
91
68
|
c.set('verifiedTokens', options.verifiedTokens === false ? undefined : (options.verifiedTokens ?? {})) // prettier-ignore
|
|
@@ -95,6 +72,8 @@ export function data(options: data.Options = {}) {
|
|
|
95
72
|
})
|
|
96
73
|
// Resolve one chain for every data route before auth and handlers use it.
|
|
97
74
|
.use('*', async (c, next) => {
|
|
75
|
+
const defaultChainId = c.get('chainId')
|
|
76
|
+
const supportedChainIds = c.get('supportedChainIds')
|
|
98
77
|
const selection = resolveChainSelector({
|
|
99
78
|
c,
|
|
100
79
|
defaultChainId,
|
|
@@ -118,7 +97,7 @@ export function data(options: data.Options = {}) {
|
|
|
118
97
|
if (zones.size === 0) return next()
|
|
119
98
|
const selection = resolveChainSelector({
|
|
120
99
|
c,
|
|
121
|
-
defaultChainId,
|
|
100
|
+
defaultChainId: c.get('chainId'),
|
|
122
101
|
handlers: dataRouteHandlers,
|
|
123
102
|
})
|
|
124
103
|
if (!selection.matched) return next()
|
|
@@ -155,7 +134,7 @@ export function data(options: data.Options = {}) {
|
|
|
155
134
|
|
|
156
135
|
const selection = resolveChainSelector({
|
|
157
136
|
c,
|
|
158
|
-
defaultChainId,
|
|
137
|
+
defaultChainId: c.get('chainId'),
|
|
159
138
|
handlers: dataRouteHandlers,
|
|
160
139
|
})
|
|
161
140
|
if (!selection.matched || !Viem.isMainnet(c.get('chainId'))) return next()
|
|
@@ -197,7 +176,11 @@ export function data(options: data.Options = {}) {
|
|
|
197
176
|
.route('/', coingecko())
|
|
198
177
|
.route('/', blocksByTimestampCompat())
|
|
199
178
|
const edgeCacheEligible = (c: Context<App.Environment>) => {
|
|
200
|
-
const selection = resolveChainSelector({
|
|
179
|
+
const selection = resolveChainSelector({
|
|
180
|
+
c,
|
|
181
|
+
defaultChainId: c.get('chainId'),
|
|
182
|
+
handlers: dataRouteHandlers,
|
|
183
|
+
})
|
|
201
184
|
return (
|
|
202
185
|
selection.matched &&
|
|
203
186
|
!selection.conflict &&
|
|
@@ -210,7 +193,10 @@ export function data(options: data.Options = {}) {
|
|
|
210
193
|
dataRouteHandlers.add(route.handler)
|
|
211
194
|
if (zones.size > 0) EdgeCache.setEligibility(route.handler, edgeCacheEligible)
|
|
212
195
|
}
|
|
213
|
-
return Metadata.attach(app,
|
|
196
|
+
return Metadata.attach(app, {
|
|
197
|
+
...openapi,
|
|
198
|
+
supportedChainIds: [...Object.keys(Tidx.url).map(Number), ...zones.keys()],
|
|
199
|
+
})
|
|
214
200
|
}
|
|
215
201
|
|
|
216
202
|
export declare namespace data {
|
|
@@ -218,14 +204,8 @@ export declare namespace data {
|
|
|
218
204
|
type Options = {
|
|
219
205
|
/** Static API asset store for the current runtime (e.g. `Assets.cloudflareR2(...)`). Omit to skip static assets — token logo URIs go unresolved. */
|
|
220
206
|
assets?: Assets.Assets | undefined
|
|
221
|
-
/** Default Tempo chain id for requests that omit `chainId`. */
|
|
222
|
-
defaultChainId?: Viem.ChainId | undefined
|
|
223
207
|
/** Funding quote provider configuration. */
|
|
224
208
|
funding?: funding.Options | undefined
|
|
225
|
-
/** Tempo RPC options: a static config, or a per-request resolver that routes by request principal to a separate (e.g. public, no-auth) RPC. */
|
|
226
|
-
rpc?: Viem.getClient.Rpc | undefined
|
|
227
|
-
/** Extra chain ids this deployment serves, beyond the built-in public chains and `defaultChainId`. A `chainId` outside this set is rejected with a 400. */
|
|
228
|
-
supportedChainIds?: readonly number[] | undefined
|
|
229
209
|
/** TIDX query client options. */
|
|
230
210
|
tidx?: Tidx.getClient.Tidx | undefined
|
|
231
211
|
/** Verified-token feature configuration. Enabled by default (token rows live in `db`; empty until published); pass `false` to always serve an empty verified list. */
|
|
@@ -4,6 +4,124 @@ import * as FundingQuoteProviders from './FundingQuoteProviders.js'
|
|
|
4
4
|
const now = () => new Date('2026-01-01T00:00:00.000Z')
|
|
5
5
|
|
|
6
6
|
describe('FundingQuoteProviders', () => {
|
|
7
|
+
test('requires every Across deployment binding when any is configured', () => {
|
|
8
|
+
expect(FundingQuoteProviders.configureAcross()).toBeUndefined()
|
|
9
|
+
expect(() =>
|
|
10
|
+
FundingQuoteProviders.configureAcross({ apiKey: 'across-api-key' }),
|
|
11
|
+
).toThrowErrorMatchingInlineSnapshot(`[FundingQuoteProviders.ProviderConfigurationError]`)
|
|
12
|
+
expect(
|
|
13
|
+
FundingQuoteProviders.configureAcross({
|
|
14
|
+
apiKey: 'across-api-key',
|
|
15
|
+
integratorId: '0xdead',
|
|
16
|
+
userAddress: '0x1111111111111111111111111111111111111111',
|
|
17
|
+
}),
|
|
18
|
+
).toEqual({
|
|
19
|
+
apiKey: 'across-api-key',
|
|
20
|
+
integratorId: '0xdead',
|
|
21
|
+
userAddress: '0x1111111111111111111111111111111111111111',
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('maps successful Across quotes to available quote probes', async () => {
|
|
26
|
+
const requests: RecordedRequest[] = []
|
|
27
|
+
const candidate = quoteCandidate('across')
|
|
28
|
+
const fetch = recordingFetch(requests, async () => jsonResponse(acrossQuote(candidate)))
|
|
29
|
+
const adapter = FundingQuoteProviders.across({
|
|
30
|
+
apiKey: 'across-api-key',
|
|
31
|
+
baseUrl: 'https://across.test/api/',
|
|
32
|
+
fetch,
|
|
33
|
+
integratorId: '0xdead',
|
|
34
|
+
now,
|
|
35
|
+
userAddress: '0x1111111111111111111111111111111111111111',
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const result = await adapter.probeQuote(probeInput(candidate), new AbortController().signal)
|
|
39
|
+
|
|
40
|
+
expect(requests).toHaveLength(1)
|
|
41
|
+
expect(requests[0]?.url.pathname).toBe('/api/swap/approval')
|
|
42
|
+
expect(requests[0]?.url.searchParams.get('originChainId')).toBe('8453')
|
|
43
|
+
expect(requests[0]?.url.searchParams.get('destinationChainId')).toBe('4217')
|
|
44
|
+
expect(requests[0]?.url.searchParams.get('amount')).toBe('1000000')
|
|
45
|
+
expect(requests[0]?.url.searchParams.get('integratorId')).toBe('0xdead')
|
|
46
|
+
expect(requests[0]?.url.searchParams.get('tradeType')).toBe('exactInput')
|
|
47
|
+
expect(requests[0]?.url.searchParams.get('skipOriginTxEstimation')).toBe('true')
|
|
48
|
+
expect(requests[0]?.url.searchParams.get('strictTradeType')).toBe('true')
|
|
49
|
+
expect(requests[0]?.url.searchParams.get('depositor')).toBe(
|
|
50
|
+
'0x1111111111111111111111111111111111111111',
|
|
51
|
+
)
|
|
52
|
+
expect(requests[0]?.url.searchParams.get('recipient')).toBe(
|
|
53
|
+
'0x1111111111111111111111111111111111111111',
|
|
54
|
+
)
|
|
55
|
+
expect(new Headers(requests[0]?.init?.headers).get('authorization')).toBe(
|
|
56
|
+
'Bearer across-api-key',
|
|
57
|
+
)
|
|
58
|
+
expect(result).toMatchObject({
|
|
59
|
+
expiresAt: '2026-01-01T00:01:00.000Z',
|
|
60
|
+
minimumOutputAmount: '988000',
|
|
61
|
+
outputAmount: '990000',
|
|
62
|
+
outputAmountFormatted: '0.99',
|
|
63
|
+
quality: {
|
|
64
|
+
estimatedSeconds: 2,
|
|
65
|
+
liquiditySource: 'providerQuote',
|
|
66
|
+
sourceDetail: 'across:swap-approval',
|
|
67
|
+
tier: 'liquid',
|
|
68
|
+
},
|
|
69
|
+
sampledAt: '2026-01-01T00:00:00.000Z',
|
|
70
|
+
status: 'available',
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('maps recognized Across quote errors to unavailable probes', async () => {
|
|
75
|
+
const candidate = quoteCandidate('across')
|
|
76
|
+
const fetch = recordingFetch([], async () => jsonResponse({ code: 'NO_ROUTE' }, 400))
|
|
77
|
+
const adapter = FundingQuoteProviders.across({
|
|
78
|
+
apiKey: 'across-api-key',
|
|
79
|
+
fetch,
|
|
80
|
+
integratorId: '0xdead',
|
|
81
|
+
now,
|
|
82
|
+
userAddress: '0x1111111111111111111111111111111111111111',
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
await expect(
|
|
86
|
+
adapter.probeQuote(probeInput(candidate), new AbortController().signal),
|
|
87
|
+
).resolves.toMatchObject({
|
|
88
|
+
quality: {
|
|
89
|
+
liquiditySource: 'providerQuote',
|
|
90
|
+
sourceDetail: 'across:swap-approval:NO_ROUTE',
|
|
91
|
+
tier: 'unavailable',
|
|
92
|
+
},
|
|
93
|
+
status: 'unavailable',
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('rejects malformed Across quote payloads', async () => {
|
|
98
|
+
const candidate = quoteCandidate('across')
|
|
99
|
+
const fetch = recordingFetch([], async () =>
|
|
100
|
+
jsonResponse({ ...acrossQuote(candidate), expectedOutputAmount: '0' }),
|
|
101
|
+
)
|
|
102
|
+
const adapter = FundingQuoteProviders.across({
|
|
103
|
+
apiKey: 'across-api-key',
|
|
104
|
+
fetch,
|
|
105
|
+
integratorId: '0xdead',
|
|
106
|
+
now,
|
|
107
|
+
userAddress: '0x1111111111111111111111111111111111111111',
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
await expect(
|
|
111
|
+
adapter.probeQuote(probeInput(candidate), new AbortController().signal),
|
|
112
|
+
).rejects.toThrowErrorMatchingInlineSnapshot(`[FundingQuoteProviders.ProviderPayloadError]`)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('rejects invalid Across configuration', () => {
|
|
116
|
+
expect(() =>
|
|
117
|
+
FundingQuoteProviders.across({
|
|
118
|
+
apiKey: 'across-api-key',
|
|
119
|
+
integratorId: 'tempo',
|
|
120
|
+
userAddress: '0x1111111111111111111111111111111111111111',
|
|
121
|
+
}),
|
|
122
|
+
).toThrowErrorMatchingInlineSnapshot(`[FundingQuoteProviders.ProviderConfigurationError]`)
|
|
123
|
+
})
|
|
124
|
+
|
|
7
125
|
test('maps successful Relay quotes to available quote probes', async () => {
|
|
8
126
|
const requests: RecordedRequest[] = []
|
|
9
127
|
const candidate = quoteCandidate()
|
|
@@ -54,6 +172,191 @@ describe('FundingQuoteProviders', () => {
|
|
|
54
172
|
})
|
|
55
173
|
})
|
|
56
174
|
|
|
175
|
+
test('maps successful Rhino quotes to available quote probes', async () => {
|
|
176
|
+
const requests: RecordedRequest[] = []
|
|
177
|
+
const candidate = quoteCandidate('rhino')
|
|
178
|
+
const fetch = recordingFetch(requests, async () =>
|
|
179
|
+
jsonResponse({
|
|
180
|
+
_tag: 'bridge',
|
|
181
|
+
chainIn: 'BASE',
|
|
182
|
+
chainOut: 'TEMPO',
|
|
183
|
+
estimatedDuration: 1_500,
|
|
184
|
+
payAmount: '1.0000000',
|
|
185
|
+
receiveAmount: '0.9830000',
|
|
186
|
+
token: 'USDC',
|
|
187
|
+
}),
|
|
188
|
+
)
|
|
189
|
+
const adapter = FundingQuoteProviders.rhino({
|
|
190
|
+
baseUrl: 'https://rhino.test/bridge/',
|
|
191
|
+
fetch,
|
|
192
|
+
now,
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
const result = await adapter.probeQuote(probeInput(candidate), new AbortController().signal)
|
|
196
|
+
|
|
197
|
+
expect(requests).toHaveLength(1)
|
|
198
|
+
expect(requests[0]?.url.pathname).toBe('/bridge/quote/bridge-swap/public')
|
|
199
|
+
expect(Object.fromEntries(requests[0]!.url.searchParams)).toEqual({
|
|
200
|
+
amount: '1',
|
|
201
|
+
amountNative: '0',
|
|
202
|
+
chainIn: 'BASE',
|
|
203
|
+
chainOut: 'TEMPO',
|
|
204
|
+
mode: 'pay',
|
|
205
|
+
tokenIn: 'USDC',
|
|
206
|
+
tokenOut: 'USDC',
|
|
207
|
+
})
|
|
208
|
+
expect(result).toEqual({
|
|
209
|
+
outputAmount: '983000',
|
|
210
|
+
outputAmountFormatted: '0.983',
|
|
211
|
+
quality: {
|
|
212
|
+
estimatedSeconds: 2,
|
|
213
|
+
liquiditySource: 'providerQuote',
|
|
214
|
+
sourceDetail: 'rhino:bridge-swap-public',
|
|
215
|
+
tier: 'liquid',
|
|
216
|
+
},
|
|
217
|
+
sampledAt: '2026-01-01T00:00:00.000Z',
|
|
218
|
+
status: 'available',
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
test('maps unavailable Rhino routes to unavailable quote probes', async () => {
|
|
223
|
+
const candidate = quoteCandidate('rhino')
|
|
224
|
+
const adapter = FundingQuoteProviders.rhino({
|
|
225
|
+
fetch: recordingFetch([], async () => jsonResponse({ _tag: 'NoRouteFoundError' }, 404)),
|
|
226
|
+
now,
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
await expect(
|
|
230
|
+
adapter.probeQuote(probeInput(candidate), new AbortController().signal),
|
|
231
|
+
).resolves.toMatchObject({
|
|
232
|
+
quality: {
|
|
233
|
+
sourceDetail: 'rhino:bridge-swap-public:NoRouteFoundError',
|
|
234
|
+
tier: 'unavailable',
|
|
235
|
+
},
|
|
236
|
+
status: 'unavailable',
|
|
237
|
+
})
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test('maps Solana and Tron identifiers into Rhino quote requests', async () => {
|
|
241
|
+
const cases = [
|
|
242
|
+
{
|
|
243
|
+
candidate: quoteCandidate('rhino', { sourceChain: 'solana' }),
|
|
244
|
+
chainIn: 'SOLANA',
|
|
245
|
+
tokenOut: 'USDC',
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
candidate: quoteCandidate('rhino', {
|
|
249
|
+
destinationToken: 'USDT0',
|
|
250
|
+
sourceChain: 'tron',
|
|
251
|
+
sourceToken: 'USDT',
|
|
252
|
+
}),
|
|
253
|
+
chainIn: 'TRON',
|
|
254
|
+
tokenOut: 'USDT',
|
|
255
|
+
},
|
|
256
|
+
] as const
|
|
257
|
+
|
|
258
|
+
for (const testCase of cases) {
|
|
259
|
+
const requests: RecordedRequest[] = []
|
|
260
|
+
const adapter = FundingQuoteProviders.rhino({
|
|
261
|
+
fetch: recordingFetch(requests, async () =>
|
|
262
|
+
jsonResponse({
|
|
263
|
+
_tag: 'bridge',
|
|
264
|
+
chainIn: testCase.chainIn,
|
|
265
|
+
chainOut: 'TEMPO',
|
|
266
|
+
payAmount: '1',
|
|
267
|
+
receiveAmount: '0.983',
|
|
268
|
+
token: testCase.tokenOut,
|
|
269
|
+
}),
|
|
270
|
+
),
|
|
271
|
+
now,
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
await expect(
|
|
275
|
+
adapter.probeQuote(probeInput(testCase.candidate), new AbortController().signal),
|
|
276
|
+
).resolves.toMatchObject({ outputAmount: '983000', status: 'available' })
|
|
277
|
+
expect(requests[0]?.url.searchParams.get('chainIn')).toBe(testCase.chainIn)
|
|
278
|
+
expect(requests[0]?.url.searchParams.get('tokenOut')).toBe(testCase.tokenOut)
|
|
279
|
+
}
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
test('surfaces Rhino request decoding failures', async () => {
|
|
283
|
+
const candidate = quoteCandidate('rhino')
|
|
284
|
+
const adapter = FundingQuoteProviders.rhino({
|
|
285
|
+
fetch: recordingFetch([], async () => jsonResponse({ _tag: 'HttpApiDecodeError' }, 400)),
|
|
286
|
+
now,
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
await expect(
|
|
290
|
+
adapter.probeQuote(probeInput(candidate), new AbortController().signal),
|
|
291
|
+
).rejects.toThrowErrorMatchingInlineSnapshot(
|
|
292
|
+
`[FundingQuoteProviders.ProviderResponseError: Provider request failed with status 400]`,
|
|
293
|
+
)
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
test('surfaces unrecognized Rhino route errors', async () => {
|
|
297
|
+
const candidate = quoteCandidate('rhino')
|
|
298
|
+
const failures = [
|
|
299
|
+
{ body: null, status: 404 },
|
|
300
|
+
{ body: { _tag: 'InvalidRequest' }, status: 422 },
|
|
301
|
+
] as const
|
|
302
|
+
|
|
303
|
+
for (const failure of failures) {
|
|
304
|
+
const adapter = FundingQuoteProviders.rhino({
|
|
305
|
+
fetch: recordingFetch([], async () => jsonResponse(failure.body, failure.status)),
|
|
306
|
+
now,
|
|
307
|
+
})
|
|
308
|
+
const cause = await adapter
|
|
309
|
+
.probeQuote(probeInput(candidate), new AbortController().signal)
|
|
310
|
+
.catch((error: unknown) => error)
|
|
311
|
+
expect(cause).toMatchObject({
|
|
312
|
+
name: 'FundingQuoteProviders.ProviderResponseError',
|
|
313
|
+
status: failure.status,
|
|
314
|
+
})
|
|
315
|
+
}
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
test('rejects mismatched Rhino quote payloads', async () => {
|
|
319
|
+
const candidate = quoteCandidate('rhino')
|
|
320
|
+
const adapter = FundingQuoteProviders.rhino({
|
|
321
|
+
fetch: recordingFetch([], async () =>
|
|
322
|
+
jsonResponse({
|
|
323
|
+
_tag: 'bridge',
|
|
324
|
+
chainIn: 'SOLANA',
|
|
325
|
+
chainOut: 'TEMPO',
|
|
326
|
+
payAmount: '1',
|
|
327
|
+
receiveAmount: '0.983',
|
|
328
|
+
token: 'USDC',
|
|
329
|
+
}),
|
|
330
|
+
),
|
|
331
|
+
now,
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
await expect(
|
|
335
|
+
adapter.probeQuote(probeInput(candidate), new AbortController().signal),
|
|
336
|
+
).rejects.toThrowErrorMatchingInlineSnapshot(`[FundingQuoteProviders.ProviderPayloadError]`)
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
test('rejects non-representable Rhino output precision', async () => {
|
|
340
|
+
const candidate = quoteCandidate('rhino')
|
|
341
|
+
const adapter = FundingQuoteProviders.rhino({
|
|
342
|
+
fetch: recordingFetch([], async () =>
|
|
343
|
+
jsonResponse({
|
|
344
|
+
_tag: 'bridge',
|
|
345
|
+
chainIn: 'BASE',
|
|
346
|
+
chainOut: 'TEMPO',
|
|
347
|
+
payAmount: '1',
|
|
348
|
+
receiveAmount: '0.9830001',
|
|
349
|
+
token: 'USDC',
|
|
350
|
+
}),
|
|
351
|
+
),
|
|
352
|
+
now,
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
await expect(
|
|
356
|
+
adapter.probeQuote(probeInput(candidate), new AbortController().signal),
|
|
357
|
+
).rejects.toThrowErrorMatchingInlineSnapshot(`[FundingQuoteProviders.ProviderPayloadError]`)
|
|
358
|
+
})
|
|
359
|
+
|
|
57
360
|
test('calls the runtime fetch method with its global receiver', async () => {
|
|
58
361
|
const candidate = quoteCandidate()
|
|
59
362
|
const fetch = vi.fn(function (this: unknown) {
|
|
@@ -82,6 +385,94 @@ describe('FundingQuoteProviders', () => {
|
|
|
82
385
|
vi.unstubAllGlobals()
|
|
83
386
|
}
|
|
84
387
|
})
|
|
388
|
+
|
|
389
|
+
const invalidAcrossQuotes = [
|
|
390
|
+
[
|
|
391
|
+
'mismatched input amounts',
|
|
392
|
+
(body: ReturnType<typeof acrossQuote>) => (body.inputAmount = '1'),
|
|
393
|
+
],
|
|
394
|
+
[
|
|
395
|
+
'mismatched token addresses',
|
|
396
|
+
(body: ReturnType<typeof acrossQuote>) =>
|
|
397
|
+
(body.inputToken.address = '0xfde4c96c8593536e31f229ea8f37b2ada2699bb2'),
|
|
398
|
+
],
|
|
399
|
+
[
|
|
400
|
+
'mismatched token chains',
|
|
401
|
+
(body: ReturnType<typeof acrossQuote>) => (body.inputToken.chainId = 1),
|
|
402
|
+
],
|
|
403
|
+
[
|
|
404
|
+
'mismatched token decimals',
|
|
405
|
+
(body: ReturnType<typeof acrossQuote>) => (body.inputToken.decimals = 18),
|
|
406
|
+
],
|
|
407
|
+
[
|
|
408
|
+
'expired quotes',
|
|
409
|
+
(body: ReturnType<typeof acrossQuote>) => (body.quoteExpiryTimestamp = 1767225600),
|
|
410
|
+
],
|
|
411
|
+
[
|
|
412
|
+
'out-of-range expiries',
|
|
413
|
+
(body: ReturnType<typeof acrossQuote>) =>
|
|
414
|
+
(body.quoteExpiryTimestamp = Number.MAX_SAFE_INTEGER),
|
|
415
|
+
],
|
|
416
|
+
[
|
|
417
|
+
'invalid output bounds',
|
|
418
|
+
(body: ReturnType<typeof acrossQuote>) => (body.minOutputAmount = '990001'),
|
|
419
|
+
],
|
|
420
|
+
] as const
|
|
421
|
+
|
|
422
|
+
for (const [name, mutate] of invalidAcrossQuotes)
|
|
423
|
+
test(`rejects Across quotes with ${name}`, async () => {
|
|
424
|
+
const candidate = quoteCandidate('across')
|
|
425
|
+
const body = acrossQuote(candidate)
|
|
426
|
+
mutate(body)
|
|
427
|
+
const adapter = FundingQuoteProviders.across({
|
|
428
|
+
apiKey: 'across-api-key',
|
|
429
|
+
fetch: recordingFetch([], async () => jsonResponse(body)),
|
|
430
|
+
integratorId: '0xdead',
|
|
431
|
+
now,
|
|
432
|
+
userAddress: '0x1111111111111111111111111111111111111111',
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
await expect(
|
|
436
|
+
adapter.probeQuote(probeInput(candidate), new AbortController().signal),
|
|
437
|
+
).rejects.toThrowErrorMatchingInlineSnapshot(`[FundingQuoteProviders.ProviderPayloadError]`)
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
test('returns bounded Across failure diagnostics', async () => {
|
|
441
|
+
const candidate = quoteCandidate('across')
|
|
442
|
+
const adapter = FundingQuoteProviders.across({
|
|
443
|
+
apiKey: 'across-api-key',
|
|
444
|
+
fetch: recordingFetch([], async () =>
|
|
445
|
+
jsonResponse({ apiKey: 'secret', code: 'UNAUTHORIZED' }, 401),
|
|
446
|
+
),
|
|
447
|
+
integratorId: '0xdead',
|
|
448
|
+
now,
|
|
449
|
+
userAddress: '0x1111111111111111111111111111111111111111',
|
|
450
|
+
})
|
|
451
|
+
const cause = await adapter
|
|
452
|
+
.probeQuote(probeInput(candidate), new AbortController().signal)
|
|
453
|
+
.catch((error: unknown) => error)
|
|
454
|
+
|
|
455
|
+
expect(FundingQuoteProviders.providerFailure({ cause, provider: 'across' })).toEqual({
|
|
456
|
+
code: 'UNAUTHORIZED',
|
|
457
|
+
failure: 'http',
|
|
458
|
+
id: 'across',
|
|
459
|
+
operation: 'swap/approval',
|
|
460
|
+
status: 401,
|
|
461
|
+
})
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
test('returns explicit timeout failure diagnostics', () => {
|
|
465
|
+
expect(
|
|
466
|
+
FundingQuoteProviders.providerFailure({
|
|
467
|
+
cause: new FundingQuotes.ProviderAdapterTimeoutError(),
|
|
468
|
+
provider: 'across',
|
|
469
|
+
}),
|
|
470
|
+
).toEqual({
|
|
471
|
+
failure: 'timeout',
|
|
472
|
+
id: 'across',
|
|
473
|
+
operation: 'swap/approval',
|
|
474
|
+
})
|
|
475
|
+
})
|
|
85
476
|
})
|
|
86
477
|
|
|
87
478
|
type RecordedRequest = {
|
|
@@ -89,18 +480,53 @@ type RecordedRequest = {
|
|
|
89
480
|
url: URL
|
|
90
481
|
}
|
|
91
482
|
|
|
92
|
-
function quoteCandidate(
|
|
483
|
+
function quoteCandidate(
|
|
484
|
+
provider: FundingQuotes.ProviderId = 'relay',
|
|
485
|
+
options: quoteCandidate.Options = {},
|
|
486
|
+
): FundingQuotes.QuoteProbeInput['candidate'] {
|
|
93
487
|
const [candidate] = FundingQuotes.listQuoteCandidates({
|
|
94
|
-
|
|
488
|
+
destinationToken: options.destinationToken,
|
|
489
|
+
provider,
|
|
95
490
|
sourceAmount: '1000000',
|
|
96
491
|
sourceAmountUnits: 'baseUnits',
|
|
97
|
-
|
|
98
|
-
|
|
492
|
+
sourceChain: options.sourceChain ?? 'base',
|
|
493
|
+
sourceToken: options.sourceToken ?? 'USDC',
|
|
99
494
|
})
|
|
100
|
-
if (!candidate) throw new Error(
|
|
495
|
+
if (!candidate) throw new Error(`Missing ${provider} quote candidate`)
|
|
101
496
|
return candidate
|
|
102
497
|
}
|
|
103
498
|
|
|
499
|
+
declare namespace quoteCandidate {
|
|
500
|
+
type Options = {
|
|
501
|
+
destinationToken?: string | undefined
|
|
502
|
+
sourceChain?: string | undefined
|
|
503
|
+
sourceToken?: string | undefined
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function acrossQuote(candidate: FundingQuotes.QuoteProbeInput['candidate']) {
|
|
508
|
+
return {
|
|
509
|
+
amountType: 'exactInput',
|
|
510
|
+
expectedFillTime: 2,
|
|
511
|
+
expectedOutputAmount: '990000',
|
|
512
|
+
inputAmount: candidate.sourceAmount.amount,
|
|
513
|
+
inputToken: {
|
|
514
|
+
address: candidate.sourceToken.address,
|
|
515
|
+
chainId: 8453,
|
|
516
|
+
decimals: candidate.sourceToken.decimals,
|
|
517
|
+
symbol: candidate.sourceToken.symbol,
|
|
518
|
+
},
|
|
519
|
+
minOutputAmount: '988000',
|
|
520
|
+
outputToken: {
|
|
521
|
+
address: candidate.destinationToken.address,
|
|
522
|
+
chainId: 4217,
|
|
523
|
+
decimals: candidate.destinationToken.decimals,
|
|
524
|
+
symbol: candidate.destinationToken.symbol,
|
|
525
|
+
},
|
|
526
|
+
quoteExpiryTimestamp: 1767225660,
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
104
530
|
function probeInput(
|
|
105
531
|
candidate: FundingQuotes.QuoteProbeInput['candidate'],
|
|
106
532
|
): FundingQuotes.QuoteProbeInput {
|