tapimo 0.5.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/dist/App.d.ts +34 -18
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/admin/apps/verified-tokens.d.ts +117 -117
  4. package/dist/apps/data/App.d.ts +3217 -3217
  5. package/dist/apps/data/FundingQuoteProviders.d.ts +32 -0
  6. package/dist/apps/data/FundingQuoteProviders.d.ts.map +1 -1
  7. package/dist/apps/data/FundingQuoteProviders.js +134 -1
  8. package/dist/apps/data/FundingQuoteProviders.js.map +1 -1
  9. package/dist/apps/data/routes/addresses.d.ts +206 -206
  10. package/dist/apps/data/routes/balances.d.ts +46 -46
  11. package/dist/apps/data/routes/coingecko.d.ts +169 -169
  12. package/dist/apps/data/routes/funding.d.ts +8 -2
  13. package/dist/apps/data/routes/funding.d.ts.map +1 -1
  14. package/dist/apps/data/routes/funding.js +4 -4
  15. package/dist/apps/data/routes/funding.js.map +1 -1
  16. package/dist/apps/data/routes/indexer.d.ts +34 -34
  17. package/dist/apps/data/routes/receipts.d.ts +228 -228
  18. package/dist/apps/data/routes/rpc.d.ts +31 -31
  19. package/dist/apps/data/routes/transfers.d.ts +8 -15
  20. package/dist/apps/data/routes/transfers.d.ts.map +1 -1
  21. package/dist/apps/data/routes/transfers.js +21 -27
  22. package/dist/apps/data/routes/transfers.js.map +1 -1
  23. package/dist/apps/data/routes/webhooks.d.ts +35 -35
  24. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  25. package/dist/apps/data/routes/webhooks.js +176 -42
  26. package/dist/apps/data/routes/webhooks.js.map +1 -1
  27. package/dist/apps/management/App.d.ts +982 -966
  28. package/dist/apps/management/App.d.ts.map +1 -1
  29. package/dist/apps/management/Billing.d.ts.map +1 -1
  30. package/dist/apps/management/Billing.js +5 -4
  31. package/dist/apps/management/Billing.js.map +1 -1
  32. package/dist/apps/management/routes/billing.d.ts.map +1 -1
  33. package/dist/apps/management/routes/billing.js +1 -1
  34. package/dist/apps/management/routes/billing.js.map +1 -1
  35. package/dist/apps/management/routes/invitations.d.ts +121 -121
  36. package/dist/apps/management/routes/invite-links.d.ts +174 -174
  37. package/dist/apps/management/routes/orgs.d.ts +102 -86
  38. package/dist/apps/management/routes/orgs.d.ts.map +1 -1
  39. package/dist/apps/management/routes/orgs.js +18 -1
  40. package/dist/apps/management/routes/orgs.js.map +1 -1
  41. package/dist/apps/management/routes/projects.d.ts +124 -124
  42. package/dist/apps/relay/Sponsorships.d.ts +3 -0
  43. package/dist/apps/relay/Sponsorships.d.ts.map +1 -1
  44. package/dist/apps/relay/Sponsorships.js +15 -1
  45. package/dist/apps/relay/Sponsorships.js.map +1 -1
  46. package/dist/db/Db.d.ts.map +1 -1
  47. package/dist/db/Db.js +4 -0
  48. package/dist/db/Db.js.map +1 -1
  49. package/dist/db/Schema.js +1 -1
  50. package/dist/db/Schema.js.map +1 -1
  51. package/dist/db/tables/organizations.d.ts +6 -0
  52. package/dist/db/tables/organizations.d.ts.map +1 -1
  53. package/dist/db/tables/organizations.js +26 -0
  54. package/dist/db/tables/organizations.js.map +1 -1
  55. package/dist/db/tables/requestUsage.d.ts +2 -11
  56. package/dist/db/tables/requestUsage.d.ts.map +1 -1
  57. package/dist/db/tables/requestUsage.js +1 -20
  58. package/dist/db/tables/requestUsage.js.map +1 -1
  59. package/dist/db/tables/sponsoredTransactions.d.ts +2 -1
  60. package/dist/db/tables/sponsoredTransactions.d.ts.map +1 -1
  61. package/dist/db/tables/sponsoredTransactions.js +46 -8
  62. package/dist/db/tables/sponsoredTransactions.js.map +1 -1
  63. package/dist/internal/Auth.d.ts +4 -0
  64. package/dist/internal/Auth.d.ts.map +1 -1
  65. package/dist/internal/Auth.js +7 -8
  66. package/dist/internal/Auth.js.map +1 -1
  67. package/dist/internal/Cache.d.ts.map +1 -1
  68. package/dist/internal/Cache.js +9 -1
  69. package/dist/internal/Cache.js.map +1 -1
  70. package/dist/internal/EdgeCache.d.ts.map +1 -1
  71. package/dist/internal/EdgeCache.js +12 -3
  72. package/dist/internal/EdgeCache.js.map +1 -1
  73. package/dist/internal/FundingQuotes.d.ts +16 -10
  74. package/dist/internal/FundingQuotes.d.ts.map +1 -1
  75. package/dist/internal/FundingQuotes.js +9 -0
  76. package/dist/internal/FundingQuotes.js.map +1 -1
  77. package/dist/internal/RateLimit.d.ts +2 -0
  78. package/dist/internal/RateLimit.d.ts.map +1 -1
  79. package/dist/internal/RateLimit.js +4 -4
  80. package/dist/internal/RateLimit.js.map +1 -1
  81. package/package.json +6 -1
  82. package/src/App.test.ts +2 -2
  83. package/src/Client.test-d.ts +28 -2
  84. package/src/apps/data/FundingQuoteProviders.test.ts +184 -0
  85. package/src/apps/data/FundingQuoteProviders.ts +185 -1
  86. package/src/apps/data/routes/funding.test.ts +7 -0
  87. package/src/apps/data/routes/funding.ts +4 -4
  88. package/src/apps/data/routes/tokens.test.ts +54 -0
  89. package/src/apps/data/routes/transfers.test.ts +73 -1
  90. package/src/apps/data/routes/transfers.ts +24 -30
  91. package/src/apps/data/routes/webhooks.test.ts +203 -0
  92. package/src/apps/data/routes/webhooks.ts +191 -54
  93. package/src/apps/management/Billing.test.ts +50 -1
  94. package/src/apps/management/Billing.ts +5 -4
  95. package/src/apps/management/routes/billing.ts +6 -1
  96. package/src/apps/management/routes/orgs.ts +19 -1
  97. package/src/apps/relay/Sponsorships.ts +24 -1
  98. package/src/db/Db.ts +4 -0
  99. package/src/db/Schema.ts +1 -1
  100. package/src/db/tables/organizations.test.ts +30 -0
  101. package/src/db/tables/organizations.ts +28 -0
  102. package/src/db/tables/requestUsage.ts +3 -23
  103. package/src/db/tables/sponsoredTransactions.test.ts +41 -0
  104. package/src/db/tables/sponsoredTransactions.ts +46 -7
  105. package/src/internal/Auth.test.ts +63 -10
  106. package/src/internal/Auth.ts +11 -9
  107. package/src/internal/Cache.test.ts +19 -0
  108. package/src/internal/Cache.ts +13 -1
  109. package/src/internal/EdgeCache.test.ts +40 -0
  110. package/src/internal/EdgeCache.ts +11 -3
  111. package/src/internal/FundingQuotes.test.ts +33 -0
  112. package/src/internal/FundingQuotes.ts +9 -0
  113. package/src/internal/RateLimit.test.ts +12 -0
  114. package/src/internal/RateLimit.ts +4 -4
@@ -22,6 +22,22 @@ describe('FundingQuoteProviders', () => {
22
22
  })
23
23
  })
24
24
 
25
+ test('requires every Squid deployment binding when any is configured', () => {
26
+ expect(FundingQuoteProviders.configureSquid()).toBeUndefined()
27
+ expect(() =>
28
+ FundingQuoteProviders.configureSquid({ integratorId: 'squid-integrator-id' }),
29
+ ).toThrowErrorMatchingInlineSnapshot(`[FundingQuoteProviders.ProviderConfigurationError]`)
30
+ expect(
31
+ FundingQuoteProviders.configureSquid({
32
+ integratorId: 'squid-integrator-id',
33
+ userAddress: '0x1111111111111111111111111111111111111111',
34
+ }),
35
+ ).toEqual({
36
+ integratorId: 'squid-integrator-id',
37
+ userAddress: '0x1111111111111111111111111111111111111111',
38
+ })
39
+ })
40
+
25
41
  test('maps successful Across quotes to available quote probes', async () => {
26
42
  const requests: RecordedRequest[] = []
27
43
  const candidate = quoteCandidate('across')
@@ -172,6 +188,80 @@ describe('FundingQuoteProviders', () => {
172
188
  })
173
189
  })
174
190
 
191
+ test('maps successful Squid quotes to available quote probes', async () => {
192
+ const requests: RecordedRequest[] = []
193
+ const candidate = quoteCandidate('squid')
194
+ const fetch = recordingFetch(requests, async () => jsonResponse(squidQuote(candidate)))
195
+ const adapter = FundingQuoteProviders.squid({
196
+ baseUrl: 'https://squid.test/v2/',
197
+ fetch,
198
+ integratorId: 'squid-integrator-id',
199
+ now,
200
+ userAddress: '0x1111111111111111111111111111111111111111',
201
+ })
202
+
203
+ const result = await adapter.probeQuote(probeInput(candidate), new AbortController().signal)
204
+
205
+ expect(requests).toHaveLength(1)
206
+ expect(requests[0]?.url.pathname).toBe('/v2/route')
207
+ expect(new Headers(requests[0]?.init?.headers).get('x-integrator-id')).toBe(
208
+ 'squid-integrator-id',
209
+ )
210
+ expect(jsonRequestBody(requests[0])).toEqual({
211
+ fromAddress: '0x1111111111111111111111111111111111111111',
212
+ fromAmount: '1000000',
213
+ fromChain: '8453',
214
+ fromToken: candidate.sourceToken.address,
215
+ quoteOnly: true,
216
+ slippage: 1,
217
+ toAddress: '0x1111111111111111111111111111111111111111',
218
+ toChain: '4217',
219
+ toToken: candidate.destinationToken.address,
220
+ })
221
+ expect(result).toEqual({
222
+ minimumOutputAmount: '989622',
223
+ outputAmount: '999620',
224
+ outputAmountFormatted: '0.99962',
225
+ quality: {
226
+ estimatedSeconds: 10,
227
+ liquiditySource: 'providerQuote',
228
+ sourceDetail: 'squid:route-v2',
229
+ tier: 'liquid',
230
+ },
231
+ sampledAt: '2026-01-01T00:00:00.000Z',
232
+ status: 'available',
233
+ })
234
+ })
235
+
236
+ test('maps unavailable Squid routes to unavailable quote probes', async () => {
237
+ const candidate = quoteCandidate('squid')
238
+ const adapter = FundingQuoteProviders.squid({
239
+ fetch: recordingFetch([], async () =>
240
+ jsonResponse(
241
+ {
242
+ message: 'Please increase the swap amount and try again.',
243
+ statusCode: 400,
244
+ type: 'BAD_REQUEST',
245
+ },
246
+ 400,
247
+ ),
248
+ ),
249
+ integratorId: 'squid-integrator-id',
250
+ now,
251
+ userAddress: '0x1111111111111111111111111111111111111111',
252
+ })
253
+
254
+ await expect(
255
+ adapter.probeQuote(probeInput(candidate), new AbortController().signal),
256
+ ).resolves.toMatchObject({
257
+ quality: {
258
+ sourceDetail: 'squid:route-v2:AMOUNT_TOO_LOW',
259
+ tier: 'unavailable',
260
+ },
261
+ status: 'unavailable',
262
+ })
263
+ })
264
+
175
265
  test('maps successful Rhino quotes to available quote probes', async () => {
176
266
  const requests: RecordedRequest[] = []
177
267
  const candidate = quoteCandidate('rhino')
@@ -437,6 +527,64 @@ describe('FundingQuoteProviders', () => {
437
527
  ).rejects.toThrowErrorMatchingInlineSnapshot(`[FundingQuoteProviders.ProviderPayloadError]`)
438
528
  })
439
529
 
530
+ const invalidSquidQuotes = [
531
+ [
532
+ 'mismatched input amounts',
533
+ (body: ReturnType<typeof squidQuote>) => (body.route.estimate.fromAmount = '1'),
534
+ ],
535
+ [
536
+ 'mismatched input token addresses',
537
+ (body: ReturnType<typeof squidQuote>) =>
538
+ (body.route.estimate.fromToken.address = '0xfde4c96c8593536e31f229ea8f37b2ada2699bb2'),
539
+ ],
540
+ [
541
+ 'mismatched input chains',
542
+ (body: ReturnType<typeof squidQuote>) => (body.route.estimate.fromToken.chainId = 1),
543
+ ],
544
+ [
545
+ 'mismatched input decimals',
546
+ (body: ReturnType<typeof squidQuote>) => (body.route.estimate.fromToken.decimals = 18),
547
+ ],
548
+ [
549
+ 'mismatched output token addresses',
550
+ (body: ReturnType<typeof squidQuote>) =>
551
+ (body.route.estimate.toToken.address = '0x20c00000000000000000000014f22ca97301eb73'),
552
+ ],
553
+ [
554
+ 'mismatched output chains',
555
+ (body: ReturnType<typeof squidQuote>) => (body.route.estimate.toToken.chainId = '1'),
556
+ ],
557
+ [
558
+ 'mismatched output decimals',
559
+ (body: ReturnType<typeof squidQuote>) => (body.route.estimate.toToken.decimals = 18 as never),
560
+ ],
561
+ [
562
+ 'zero output amounts',
563
+ (body: ReturnType<typeof squidQuote>) => (body.route.estimate.toAmount = '0'),
564
+ ],
565
+ [
566
+ 'invalid output bounds',
567
+ (body: ReturnType<typeof squidQuote>) => (body.route.estimate.toAmountMin = '1000000'),
568
+ ],
569
+ ] as const
570
+
571
+ for (const [name, mutate] of invalidSquidQuotes)
572
+ test(`rejects Squid quotes with ${name}`, async () => {
573
+ const candidate = quoteCandidate('squid')
574
+ const body = squidQuote(candidate)
575
+ mutate(body)
576
+ const adapter = FundingQuoteProviders.squid({
577
+ fetch: recordingFetch([], async () => jsonResponse(body)),
578
+ integratorId: 'squid-integrator-id',
579
+ now,
580
+ userAddress: '0x1111111111111111111111111111111111111111',
581
+ })
582
+
583
+ await expect(
584
+ adapter.probeQuote(probeInput(candidate), new AbortController().signal),
585
+ ).rejects.toThrowErrorMatchingInlineSnapshot(`[FundingQuoteProviders.ProviderPayloadError]`)
586
+ })
587
+
440
588
  test('returns bounded Across failure diagnostics', async () => {
441
589
  const candidate = quoteCandidate('across')
442
590
  const adapter = FundingQuoteProviders.across({
@@ -473,6 +621,19 @@ describe('FundingQuoteProviders', () => {
473
621
  operation: 'swap/approval',
474
622
  })
475
623
  })
624
+
625
+ test('returns bounded Squid failure diagnostics', () => {
626
+ expect(
627
+ FundingQuoteProviders.providerFailure({
628
+ cause: new TypeError('network failure'),
629
+ provider: 'squid',
630
+ }),
631
+ ).toEqual({
632
+ failure: 'network',
633
+ id: 'squid',
634
+ operation: 'v2/route',
635
+ })
636
+ })
476
637
  })
477
638
 
478
639
  type RecordedRequest = {
@@ -527,6 +688,29 @@ function acrossQuote(candidate: FundingQuotes.QuoteProbeInput['candidate']) {
527
688
  }
528
689
  }
529
690
 
691
+ function squidQuote(candidate: FundingQuotes.QuoteProbeInput['candidate']) {
692
+ return {
693
+ route: {
694
+ estimate: {
695
+ estimatedRouteDuration: 10,
696
+ fromAmount: candidate.sourceAmount.amount,
697
+ fromToken: {
698
+ address: candidate.sourceToken.address,
699
+ chainId: 8453,
700
+ decimals: candidate.sourceToken.decimals,
701
+ },
702
+ toAmount: '999620',
703
+ toAmountMin: '989622',
704
+ toToken: {
705
+ address: candidate.destinationToken.address,
706
+ chainId: '4217',
707
+ decimals: candidate.destinationToken.decimals,
708
+ },
709
+ },
710
+ },
711
+ }
712
+ }
713
+
530
714
  function probeInput(
531
715
  candidate: FundingQuotes.QuoteProbeInput['candidate'],
532
716
  ): FundingQuotes.QuoteProbeInput {
@@ -9,10 +9,12 @@ const defaultFetch: Fetch = (input, init) => globalThis.fetch(input, init)
9
9
  const defaultAcrossBaseUrl = 'https://app.across.to/api'
10
10
  const defaultRelayBaseUrl = 'https://api.relay.link'
11
11
  const defaultRhinoBaseUrl = 'https://api.rhino.fi/bridge'
12
+ const defaultSquidBaseUrl = 'https://v2.api.squidrouter.com/v2'
12
13
  const providerOperations = {
13
14
  across: 'swap/approval',
14
15
  relay: 'quote/v2',
15
16
  rhino: 'quote/bridge-swap/public',
17
+ squid: 'v2/route',
16
18
  } as const
17
19
 
18
20
  namespace schema {
@@ -44,6 +46,25 @@ namespace schema {
44
46
  receiveAmount: z.string().check(z.regex(/^\d+(\.\d+)?$/)),
45
47
  token: z.string(),
46
48
  })
49
+
50
+ const SquidToken = z.object({
51
+ address: z.string(),
52
+ chainId: z.union([z.string(), z.number().check(z.int(), z.nonnegative())]),
53
+ decimals: z.number().check(z.int(), z.nonnegative()),
54
+ })
55
+
56
+ export const SquidQuote = z.object({
57
+ route: z.object({
58
+ estimate: z.object({
59
+ estimatedRouteDuration: z.number().check(z.nonnegative()),
60
+ fromAmount: IntegerString,
61
+ fromToken: SquidToken,
62
+ toAmount: IntegerString,
63
+ toAmountMin: IntegerString,
64
+ toToken: SquidToken,
65
+ }),
66
+ }),
67
+ })
47
68
  }
48
69
 
49
70
  /** Creates the live funding quote adapters configured for this deployment. */
@@ -52,6 +73,7 @@ export function createAdapters(options: createAdapters.Options = {}) {
52
73
  ...(options.across ? { across: across(options.across) } : {}),
53
74
  ...(options.relay ? { relay: relay(options.relay) } : {}),
54
75
  ...(options.rhino ? { rhino: rhino(options.rhino) } : {}),
76
+ ...(options.squid ? { squid: squid(options.squid) } : {}),
55
77
  } satisfies FundingQuotes.ProviderAdapters
56
78
  }
57
79
 
@@ -64,6 +86,8 @@ export declare namespace createAdapters {
64
86
  relay?: relay.Options | false | undefined
65
87
  /** Rhino quote adapter options. */
66
88
  rhino?: rhino.Options | false | undefined
89
+ /** Squid quote adapter options. */
90
+ squid?: squid.Options | false | undefined
67
91
  }
68
92
  }
69
93
 
@@ -92,6 +116,27 @@ export declare namespace configureAcross {
92
116
  }
93
117
  }
94
118
 
119
+ /** Resolves all-or-none Squid deployment configuration. */
120
+ export function configureSquid(options: configureSquid.Options = {}): squid.Options | undefined {
121
+ const configured = [options.integratorId, options.userAddress].filter(Boolean)
122
+ if (configured.length === 0) return undefined
123
+ if (!options.integratorId || !options.userAddress) throw new ProviderConfigurationError()
124
+ return {
125
+ integratorId: options.integratorId,
126
+ userAddress: options.userAddress,
127
+ }
128
+ }
129
+
130
+ export declare namespace configureSquid {
131
+ /** Optional deployment bindings required together to enable Squid. */
132
+ type Options = {
133
+ /** Squid integrator id binding. */
134
+ integratorId?: string | undefined
135
+ /** Squid quote-user binding. */
136
+ userAddress?: string | undefined
137
+ }
138
+ }
139
+
95
140
  /** Creates an Across quote adapter backed by `/swap/approval`. */
96
141
  export function across(options: across.Options): FundingQuotes.ProviderAdapter {
97
142
  assertAcrossConfiguration(options)
@@ -284,6 +329,74 @@ export declare namespace rhino {
284
329
  }
285
330
  }
286
331
 
332
+ /** Creates a Squid quote adapter backed by `/v2/route`. */
333
+ export function squid(options: squid.Options): FundingQuotes.ProviderAdapter {
334
+ assertSquidConfiguration(options)
335
+ const baseUrl = normalizeBaseUrl(options.baseUrl ?? defaultSquidBaseUrl)
336
+ const fetcher = options.fetch ?? defaultFetch
337
+ const now = options.now ?? (() => new Date())
338
+
339
+ return {
340
+ async probeQuote({ candidate }, signal) {
341
+ const fromChain = eip155ChainId(candidate.sourceChain)
342
+ const toChain = eip155ChainId(candidate.destinationChain)
343
+
344
+ try {
345
+ const quote = await requestJson(new URL(`${baseUrl}/route`), {
346
+ body: {
347
+ fromAddress: options.userAddress,
348
+ fromAmount: candidate.sourceAmount.amount,
349
+ fromChain,
350
+ fromToken: candidate.sourceToken.address,
351
+ quoteOnly: true,
352
+ // V1 quotes use a fixed 1% tolerance; callers cannot customize execution policy.
353
+ slippage: 1,
354
+ toAddress: options.userAddress,
355
+ toChain,
356
+ toToken: candidate.destinationToken.address,
357
+ },
358
+ fetcher,
359
+ headers: { 'x-integrator-id': options.integratorId },
360
+ method: 'POST',
361
+ signal,
362
+ })
363
+ return formatSquidResult(candidate, quote, now())
364
+ } catch (cause) {
365
+ const unavailable =
366
+ providerUnavailable(cause, [
367
+ 'NO_ROUTE',
368
+ 'NO_ROUTE_FOUND',
369
+ 'ROUTE_NOT_FOUND',
370
+ 'UNSUPPORTED_ROUTE',
371
+ ]) ?? squidUnavailable(cause)
372
+ if (!unavailable) throw cause
373
+ return unavailableResult({
374
+ detail: 'squid:route-v2',
375
+ message: unavailable,
376
+ now: now(),
377
+ source: 'providerQuote',
378
+ })
379
+ }
380
+ },
381
+ }
382
+ }
383
+
384
+ export declare namespace squid {
385
+ /** Squid quote adapter options. */
386
+ type Options = {
387
+ /** Override for tests or Squid test environments. */
388
+ baseUrl?: string | undefined
389
+ /** Fetch implementation override for tests. */
390
+ fetch?: Fetch | undefined
391
+ /** Squid integrator id sent in the required request header. */
392
+ integratorId: string
393
+ /** Clock override for deterministic tests. */
394
+ now?: (() => Date) | undefined
395
+ /** Probe wallet address used as the Squid sender and recipient. */
396
+ userAddress: string
397
+ }
398
+ }
399
+
287
400
  /** Returns bounded provider failure details suitable for structured request logs. */
288
401
  export function providerFailure(options: providerFailure.Options): providerFailure.Result {
289
402
  const operation = providerOperations[options.provider]
@@ -436,6 +549,42 @@ function formatRhinoResult(
436
549
  }
437
550
  }
438
551
 
552
+ function formatSquidResult(
553
+ candidate: FundingQuotes.QuoteCandidate,
554
+ quote: unknown,
555
+ now: Date,
556
+ ): FundingQuotes.QuoteProbeResult {
557
+ const parsed = schema.SquidQuote.safeParse(quote)
558
+ if (!parsed.success) throw new ProviderPayloadError()
559
+ const response = parsed.data.route.estimate
560
+ if (
561
+ response.fromAmount !== candidate.sourceAmount.amount ||
562
+ !matchesSquidToken(response.fromToken, candidate.sourceChain, candidate.sourceToken) ||
563
+ !matchesSquidToken(response.toToken, candidate.destinationChain, candidate.destinationToken) ||
564
+ BigInt(response.toAmount) <= 0n ||
565
+ BigInt(response.toAmountMin) <= 0n ||
566
+ BigInt(response.toAmountMin) > BigInt(response.toAmount)
567
+ )
568
+ throw new ProviderPayloadError()
569
+
570
+ return {
571
+ minimumOutputAmount: response.toAmountMin,
572
+ outputAmount: response.toAmount,
573
+ outputAmountFormatted: decimalFromBaseUnits(
574
+ response.toAmount,
575
+ candidate.destinationToken.decimals,
576
+ ),
577
+ quality: {
578
+ estimatedSeconds: Math.ceil(response.estimatedRouteDuration),
579
+ liquiditySource: 'providerQuote',
580
+ sourceDetail: 'squid:route-v2',
581
+ tier: 'liquid',
582
+ },
583
+ sampledAt: now.toISOString(),
584
+ status: 'available',
585
+ }
586
+ }
587
+
439
588
  function unavailableResult(options: {
440
589
  detail: string
441
590
  message: string
@@ -492,11 +641,29 @@ function providerUnavailable(cause: unknown, codes: readonly string[]) {
492
641
 
493
642
  function providerErrorCode(input: unknown) {
494
643
  const body = record(input)
644
+ const error = record(body['error'])
495
645
  const code =
496
- stringValue(body['_tag']) ?? stringValue(body['code']) ?? stringValue(body['errorCode'])
646
+ stringValue(body['_tag']) ??
647
+ stringValue(body['code']) ??
648
+ stringValue(body['errorCode']) ??
649
+ stringValue(body['errorType']) ??
650
+ stringValue(body['type']) ??
651
+ stringValue(error['code']) ??
652
+ stringValue(error['type'])
497
653
  return code && /^[A-Za-z0-9_.:-]{1,64}$/.test(code) ? code : undefined
498
654
  }
499
655
 
656
+ function squidUnavailable(cause: unknown) {
657
+ if (!(cause instanceof ProviderResponseError) || cause.status !== 400) return undefined
658
+ const body = record(cause.body)
659
+ if (
660
+ body['type'] === 'BAD_REQUEST' &&
661
+ body['message'] === 'Please increase the swap amount and try again.'
662
+ )
663
+ return 'AMOUNT_TOO_LOW'
664
+ return undefined
665
+ }
666
+
500
667
  function providerChainId(
501
668
  quoteChain: FundingQuotes.QuoteCandidate['sourceChain'],
502
669
  provider: string,
@@ -539,6 +706,18 @@ function matchesToken(
539
706
  )
540
707
  }
541
708
 
709
+ function matchesSquidToken(
710
+ token: z.output<typeof schema.SquidQuote>['route']['estimate']['fromToken'],
711
+ chain: FundingQuotes.ChainRef,
712
+ expected: FundingQuotes.TokenRef,
713
+ ) {
714
+ return (
715
+ token.address.toLowerCase() === expected.address.toLowerCase() &&
716
+ String(token.chainId) === eip155ChainId(chain) &&
717
+ token.decimals === expected.decimals
718
+ )
719
+ }
720
+
542
721
  function eip155ChainId(chain: { id: string; kind: string }) {
543
722
  if (chain.kind !== 'evm') throw new UnsupportedProviderChainError()
544
723
  const match = /^eip155:(\d+)$/.exec(chain.id)
@@ -605,6 +784,11 @@ function assertAcrossConfiguration(options: across.Options) {
605
784
  throw new ProviderConfigurationError()
606
785
  }
607
786
 
787
+ function assertSquidConfiguration(options: squid.Options) {
788
+ if (options.integratorId.length === 0 || !/^0x[\da-fA-F]{40}$/.test(options.userAddress))
789
+ throw new ProviderConfigurationError()
790
+ }
791
+
608
792
  class ProviderConfigurationError extends Error {
609
793
  override name = 'FundingQuoteProviders.ProviderConfigurationError'
610
794
  }
@@ -299,6 +299,13 @@ describe('GET /funding/providers', () => {
299
299
  requiresApiKey: false,
300
300
  status: 'unconfigured',
301
301
  },
302
+ {
303
+ id: 'squid',
304
+ kind: 'solverNetwork',
305
+ name: 'Squid',
306
+ requiresApiKey: true,
307
+ status: 'unconfigured',
308
+ },
302
309
  ])
303
310
  })
304
311
 
@@ -118,7 +118,7 @@ export namespace schema {
118
118
  export const ProviderRef = Schema.describe(
119
119
  z.object({
120
120
  id: z
121
- .enum(['across', 'relay', 'rhino'])
121
+ .enum(['across', 'relay', 'rhino', 'squid'])
122
122
  .check(z.describe('Stable provider id.'), z.meta({ examples: ['relay'] })),
123
123
  kind: z
124
124
  .string()
@@ -237,7 +237,7 @@ export namespace schema {
237
237
  export const Provider = Schema.describe(
238
238
  z.object({
239
239
  id: z
240
- .enum(['across', 'relay', 'rhino'])
240
+ .enum(['across', 'relay', 'rhino', 'squid'])
241
241
  .check(z.describe('Stable provider id.'), z.meta({ examples: ['relay'] })),
242
242
  kind: z
243
243
  .string()
@@ -285,10 +285,10 @@ export namespace schema {
285
285
  z.meta({ examples: ['USDC.e'] }),
286
286
  ),
287
287
  provider: z
288
- .optional(z.enum(['across', 'relay', 'rhino']))
288
+ .optional(z.enum(['across', 'relay', 'rhino', 'squid']))
289
289
  .check(
290
290
  z.describe(
291
- 'Only request a quote from this provider. Supported ids are `across`, `relay`, and `rhino`; omit it to query every configured provider.',
291
+ 'Only request a quote from this provider. Supported ids are `across`, `relay`, `rhino`, and `squid`; omit it to query every configured provider.',
292
292
  ),
293
293
  z.meta({ examples: ['relay'] }),
294
294
  ),
@@ -272,6 +272,60 @@ describe('GET /tokens?verified=true', () => {
272
272
  })
273
273
 
274
274
  describe('GET /tokens?addresses=', () => {
275
+ test('separates repeated addresses in the response cache', async () => {
276
+ const routed = TestApp.create({ db })
277
+ const headers = { authorization: `Bearer ${TestApp.key.token}` }
278
+ const repeated = new URLSearchParams()
279
+ repeated.append('addresses', TestApp.token)
280
+ repeated.append('addresses', TestApp.tokenWithHolders)
281
+ const single = new URLSearchParams({
282
+ addresses: TestApp.tokenWithHolders,
283
+ })
284
+
285
+ const primed = await routed.request(`/v1/tokens?${repeated}`, { headers })
286
+ const miss = await routed.request(`/v1/tokens?${single}`, { headers })
287
+ const hit = await routed.request(`/v1/tokens?${single}`, { headers })
288
+ const primedBody = await TestApp.json(primed, Tokens.schema.getTokens.Response)
289
+ const body = await TestApp.json(hit, Tokens.schema.getTokens.Response)
290
+
291
+ expect(primed.status).toBe(200)
292
+ expect(miss.status).toBe(200)
293
+ expect(hit.status).toBe(200)
294
+ expect(primedBody.data.map((token) => token.address)).toEqual([
295
+ TestApp.token,
296
+ TestApp.tokenWithHolders,
297
+ ])
298
+ expect(miss.headers.has('RateLimit-Limit')).toBe(true)
299
+ expect(hit.headers.has('RateLimit-Limit')).toBe(false)
300
+ expect(body.data.map((token) => token.address)).toEqual([TestApp.tokenWithHolders])
301
+ })
302
+
303
+ test('separates repeated includes in the response cache', async () => {
304
+ const routed = TestApp.create({ db })
305
+ const headers = { authorization: `Bearer ${TestApp.key.token}` }
306
+ const repeated = new URLSearchParams({ limit: '5' })
307
+ repeated.append('include', 'createdAt')
308
+ repeated.append('include', 'holderCount')
309
+ const single = new URLSearchParams({
310
+ include: 'holderCount',
311
+ limit: '5',
312
+ })
313
+
314
+ const primed = await routed.request(`/v1/tokens?${repeated}`, { headers })
315
+ const miss = await routed.request(`/v1/tokens?${single}`, { headers })
316
+ const hit = await routed.request(`/v1/tokens?${single}`, { headers })
317
+ const primedBody = await TestApp.json(primed, Tokens.schema.getTokens.Response)
318
+ const body = await TestApp.json(hit, Tokens.schema.getTokens.Response)
319
+
320
+ expect(primed.status).toBe(200)
321
+ expect(miss.status).toBe(200)
322
+ expect(hit.status).toBe(200)
323
+ expect(primedBody.data.every((token) => typeof token.createdAt === 'string')).toBe(true)
324
+ expect(miss.headers.has('RateLimit-Limit')).toBe(true)
325
+ expect(hit.headers.has('RateLimit-Limit')).toBe(false)
326
+ expect(body.data.every((token) => token.createdAt === undefined)).toBe(true)
327
+ })
328
+
275
329
  test('returns the requested tokens as a single page in input order', async () => {
276
330
  const client = app()
277
331
  const response = await client.v1.tokens.$get(
@@ -271,7 +271,24 @@ describe('GET /transfers', () => {
271
271
  },
272
272
  )
273
273
 
274
- test('embeds a capped total count on demand via include=totalCount', async () => {
274
+ test('embeds a capped unscoped total count on demand', async () => {
275
+ const client = TestApp.client()
276
+ const response = await client.v1.transfers.$get(
277
+ { query: { include: 'totalCount', limit: '5' } },
278
+ TestApp.auth,
279
+ )
280
+ const body = await TestApp.json(response, Transfers.schema.getTransfers.Response)
281
+
282
+ expect(response.status).toBe(200)
283
+ expect(response.headers.get('server-timing')?.includes('transfers_count;dur=')).toBe(true)
284
+ expect(body.meta).toBeDefined()
285
+ expect(Number.isInteger(body.meta?.totalCount)).toBe(true)
286
+ expect(body.meta!.totalCount).toBeGreaterThanOrEqual(body.data.length)
287
+ expect(body.meta!.totalCount).toBeLessThanOrEqual(Schema.countCap)
288
+ expect(body.meta!.totalCountCapped).toBe(body.meta!.totalCount >= Schema.countCap)
289
+ })
290
+
291
+ test('embeds a capped token-scoped total count on demand', async () => {
275
292
  const client = TestApp.client()
276
293
 
277
294
  if (Runtime.get().mode === 'localnet') expect(TestApp.token).toBeDefined()
@@ -350,6 +367,61 @@ describe('GET /transfers', () => {
350
367
  },
351
368
  )
352
369
 
370
+ test.skipIf(Runtime.get().mode !== 'localnet')(
371
+ 'counts unscoped cross-token transfers',
372
+ async () => {
373
+ const crossToken = Runtime.get().fixtures?.crossTokenTransfer
374
+ expect(crossToken).toBeDefined()
375
+ if (!crossToken) return
376
+
377
+ const client = TestApp.client()
378
+ const response = await client.v1.transfers.$get(
379
+ {
380
+ query: {
381
+ address: crossToken.sender,
382
+ include: 'crossToken,totalCount',
383
+ limit: '50',
384
+ },
385
+ },
386
+ TestApp.auth,
387
+ )
388
+ const body = await TestApp.json(response, Transfers.schema.getTransfers.Response)
389
+
390
+ expect(response.status).toBe(200)
391
+ expect(response.headers.get('server-timing')?.includes('transfers_count;dur=')).toBe(true)
392
+ expect(body.data.some((entry) => entry.transactionHash === crossToken.hash)).toBe(true)
393
+ expect(body.meta).toBeDefined()
394
+ expect(body.meta!.totalCount).toBeGreaterThanOrEqual(body.data.length)
395
+ },
396
+ )
397
+
398
+ test.skipIf(Runtime.get().mode !== 'localnet')(
399
+ 'counts token-scoped cross-token transfers',
400
+ async () => {
401
+ const crossToken = Runtime.get().fixtures?.crossTokenTransfer
402
+ expect(crossToken).toBeDefined()
403
+ if (!crossToken) return
404
+
405
+ const client = TestApp.client()
406
+ const response = await client.v1.transfers.$get(
407
+ {
408
+ query: {
409
+ include: 'crossToken,totalCount',
410
+ limit: '50',
411
+ token: crossToken.destinationToken,
412
+ },
413
+ },
414
+ TestApp.auth,
415
+ )
416
+ const body = await TestApp.json(response, Transfers.schema.getTransfers.Response)
417
+
418
+ expect(response.status).toBe(200)
419
+ expect(response.headers.get('server-timing')?.includes('transfers_count;dur=')).toBe(true)
420
+ expect(body.meta).toBeDefined()
421
+ expect(body.meta!.totalCount).toBeGreaterThanOrEqual(body.data.length)
422
+ },
423
+ )
424
+
353
425
  test('surfaces a 502 when transfer data is unavailable', async () => {
354
426
  // Point at an unreachable indexer so the upstream query fails deterministically.
355
427
  const client = TestApp.client({ tidx: { baseUrl: 'http://127.0.0.1:1' } })