Skip to content

Commit e3fe03d

Browse files
committed
add: configure uniswap's getRouteStatus to get the response from across so we know the receiving txnId
1 parent 0155d73 commit e3fe03d

6 files changed

Lines changed: 274 additions & 5 deletions

File tree

src/interfaces/swapAndBridge.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,9 @@ export interface UniswapQuote {
120120
slippage?: number
121121
routeString?: string
122122
estimatedFillTimeMs?: number
123+
exclusiveRelayer?: string
124+
exclusivityDeadline?: number
125+
fillDeadline?: number
123126
aggregatedOutputs?: {
124127
amount: string
125128
token: string

src/services/across/api.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, expect, it, jest } from '@jest/globals'
2+
3+
import { AcrossAPI } from './api'
4+
5+
const makeResponse = (body: any, ok = true, status = ok ? 200 : 400) => ({
6+
ok,
7+
status,
8+
json: async () => body
9+
})
10+
11+
const txHash = '0x1111111111111111111111111111111111111111111111111111111111111111'
12+
const fillTxnRef = '0x2222222222222222222222222222222222222222222222222222222222222222'
13+
const depositRefundTxnRef = '0x3333333333333333333333333333333333333333333333333333333333333333'
14+
15+
describe('AcrossAPI', () => {
16+
it('returns the fill transaction id for completed bridges', async () => {
17+
const fetch = jest.fn(async () => makeResponse({ status: 'filled', fillTxnRef }))
18+
const acrossApi = new AcrossAPI({ fetch: fetch as any })
19+
20+
await expect(acrossApi.getRouteStatus({ txHash })).resolves.toEqual({
21+
status: 'completed',
22+
txnId: fillTxnRef
23+
})
24+
expect(fetch).toHaveBeenCalledWith(
25+
`https://app.across.to/api/deposit/status?depositTxnRef=${txHash}`
26+
)
27+
})
28+
29+
it('returns the refund transaction id for refunded bridges', async () => {
30+
const fetch = jest.fn(async () => makeResponse({ status: 'refunded', depositRefundTxnRef }))
31+
const acrossApi = new AcrossAPI({ fetch: fetch as any })
32+
33+
await expect(acrossApi.getRouteStatus({ txHash })).resolves.toEqual({
34+
status: 'refunded',
35+
txnId: depositRefundTxnRef
36+
})
37+
})
38+
39+
it('keeps polling expired bridges until the refund transaction is available', async () => {
40+
const fetch = jest.fn(async () => makeResponse({ status: 'expired' }))
41+
const acrossApi = new AcrossAPI({ fetch: fetch as any })
42+
43+
await expect(acrossApi.getRouteStatus({ txHash })).resolves.toEqual({ status: null })
44+
})
45+
46+
it('keeps polling while Across indexes the deposit', async () => {
47+
const fetch = jest.fn(async () =>
48+
makeResponse(
49+
{
50+
error: 'DepositNotFoundException',
51+
message: 'Deposit not found given the provided constraints'
52+
},
53+
false,
54+
404
55+
)
56+
)
57+
const acrossApi = new AcrossAPI({ fetch: fetch as any })
58+
59+
await expect(acrossApi.getRouteStatus({ txHash })).resolves.toEqual({ status: null })
60+
})
61+
})

src/services/across/api.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import SwapAndBridgeProviderApiError from '../../classes/SwapAndBridgeProviderApiError'
2+
import { CustomResponse, Fetch } from '../../interfaces/fetch'
3+
import { SwapAndBridgeRouteStatusResult } from '../../interfaces/swapAndBridge'
4+
import { ACROSS_API_BASE_URL } from './constants'
5+
6+
interface AcrossDepositStatusResponse {
7+
status: 'filled' | 'pending' | 'expired' | 'refunded'
8+
fillTxnRef?: string | null
9+
depositRefundTxnRef?: string | null
10+
}
11+
12+
interface AcrossDepositStatusErrorResponse {
13+
error: string
14+
message: string
15+
}
16+
17+
const isAcrossDepositNotFound = (
18+
response: AcrossDepositStatusResponse | AcrossDepositStatusErrorResponse
19+
): response is AcrossDepositStatusErrorResponse =>
20+
'error' in response && response.error === 'DepositNotFoundException'
21+
22+
export class AcrossAPI {
23+
#fetch: Fetch
24+
25+
#requestTimeoutMs = 15000
26+
27+
constructor({ fetch }: { fetch: Fetch }) {
28+
this.#fetch = fetch
29+
}
30+
31+
async getRouteStatus({ txHash }: { txHash: string }): Promise<SwapAndBridgeRouteStatusResult> {
32+
const params = new URLSearchParams({
33+
depositTxnRef: txHash
34+
})
35+
36+
let response: CustomResponse
37+
let timeoutPromise: NodeJS.Timeout | undefined
38+
39+
try {
40+
response = await Promise.race([
41+
this.#fetch(`${ACROSS_API_BASE_URL}/deposit/status?${params.toString()}`),
42+
new Promise<CustomResponse>((_, reject) => {
43+
timeoutPromise = setTimeout(() => {
44+
reject(
45+
new SwapAndBridgeProviderApiError(
46+
'Our service provider Across is temporarily unavailable or your internet connection is too slow.'
47+
)
48+
)
49+
}, this.#requestTimeoutMs)
50+
})
51+
])
52+
} catch (e: any) {
53+
if (e instanceof SwapAndBridgeProviderApiError) throw e
54+
55+
const message = e?.message || 'no message'
56+
const status = e?.status ? `, status: <${e.status}>` : ''
57+
throw new SwapAndBridgeProviderApiError(
58+
`Unable to get the route status. Please check back later to proceed. Our service provider Across could not be reached: <${message}>${status}`
59+
)
60+
} finally {
61+
if (timeoutPromise) clearTimeout(timeoutPromise)
62+
}
63+
64+
let responseBody: AcrossDepositStatusResponse | AcrossDepositStatusErrorResponse
65+
try {
66+
responseBody = await response.json()
67+
} catch (e: any) {
68+
const message = e?.message || 'no message'
69+
throw new SwapAndBridgeProviderApiError(
70+
`Unable to get the route status. Please check back later to proceed. Error details: <Unexpected non-JSON response from our service provider Across>, message: <${message}>`
71+
)
72+
}
73+
74+
if (!response.ok && !isAcrossDepositNotFound(responseBody)) {
75+
const upstreamMessage =
76+
'message' in responseBody
77+
? responseBody.message
78+
: JSON.stringify(responseBody).slice(0, 250)
79+
throw new SwapAndBridgeProviderApiError(
80+
`Unable to get the route status. Please check back later to proceed. Our service provider Across responded: <${upstreamMessage}>`
81+
)
82+
}
83+
84+
if (isAcrossDepositNotFound(responseBody)) return { status: null }
85+
if (responseBody.status === 'filled' && responseBody.fillTxnRef) {
86+
return { status: 'completed', txnId: responseBody.fillTxnRef }
87+
}
88+
if (responseBody.status === 'refunded' && responseBody.depositRefundTxnRef) {
89+
return { status: 'refunded', txnId: responseBody.depositRefundTxnRef }
90+
}
91+
92+
return { status: null }
93+
}
94+
}

src/services/across/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const ACROSS_API_BASE_URL = 'https://app.across.to/api'

src/services/uniswap/api.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,61 @@ describe('UniswapAPI', () => {
110110
expect(quote.routes[0]!.steps[0]!.minAmountOut).toBe('994000')
111111
})
112112

113+
it('tags Across bridge quotes so status polling uses the Across API', async () => {
114+
const fetch = jest.fn(async () =>
115+
makeResponse({
116+
requestId: 'request-id',
117+
routing: 'BRIDGE',
118+
quote: {
119+
chainId: 1,
120+
destinationChainId: 8453,
121+
input: { amount: '1000000', token: tokenIn },
122+
output: { amount: '999000', token: tokenIn, recipient: userAddress },
123+
swapper: userAddress,
124+
tradeType: 'EXACT_INPUT',
125+
quoteId: 'quote-id',
126+
exclusiveRelayer: '0x1111111111111111111111111111111111111111',
127+
exclusivityDeadline: 100,
128+
fillDeadline: 200
129+
}
130+
})
131+
)
132+
const uniswapApi = new UniswapAPI({ fetch: fetch as any, apiKey: 'test-key' })
133+
134+
const quote = await uniswapApi.quote({
135+
fromAsset: {
136+
address: tokenIn,
137+
amount: 1000000n,
138+
chainId: 1n,
139+
decimals: 6,
140+
flags: { canTopUpGasTank: false, isFeeToken: false, onGasTank: false, rewardsType: null },
141+
marketDataIn: [],
142+
name: 'USDC',
143+
priceIn: [{ baseCurrency: 'usd', price: 1 }],
144+
symbol: 'USDC'
145+
} as any,
146+
fromChainId: 1,
147+
fromTokenAddress: tokenIn,
148+
toAsset: {
149+
address: tokenIn,
150+
chainId: 8453,
151+
decimals: 6,
152+
name: 'USDC',
153+
symbol: 'USDC'
154+
},
155+
toChainId: 8453,
156+
toTokenAddress: tokenIn,
157+
fromAmount: 1000000n,
158+
userAddress,
159+
sort: 'output',
160+
isWrapOrUnwrap: false,
161+
accountNativeBalance: 1n,
162+
nativeSymbol: 'ETH'
163+
})
164+
165+
expect(quote.routes[0]!.usedBridgeNames).toEqual(['across'])
166+
})
167+
113168
it('builds swap calldata and parses the approval spender', async () => {
114169
const spender = '0x1111111111111111111111111111111111111111'
115170
const fetch = (jest.fn() as any)
@@ -224,4 +279,49 @@ describe('UniswapAPI', () => {
224279
expect(fetch).toHaveBeenCalledTimes(1)
225280
expect(tx.approvalData).toBe(null)
226281
})
282+
283+
describe('getRouteStatus', () => {
284+
const txHash = '0x1111111111111111111111111111111111111111111111111111111111111111'
285+
const fillTxnRef = '0x2222222222222222222222222222222222222222222222222222222222222222'
286+
287+
it('returns the source transaction id for same-chain swaps', async () => {
288+
const fetch = jest.fn()
289+
const uniswapApi = new UniswapAPI({ fetch: fetch as any, apiKey: 'test-key' })
290+
291+
await expect(
292+
uniswapApi.getRouteStatus({ txHash, fromChainId: 1, toChainId: 1 })
293+
).resolves.toEqual({ status: 'completed', txnId: txHash })
294+
expect(fetch).not.toHaveBeenCalled()
295+
})
296+
297+
it('delegates Across bridge status checks to the Across API', async () => {
298+
const fetch = jest.fn(async () => makeResponse({ status: 'filled', fillTxnRef }))
299+
const uniswapApi = new UniswapAPI({ fetch: fetch as any, apiKey: 'test-key' })
300+
301+
await expect(
302+
uniswapApi.getRouteStatus({ txHash, fromChainId: 1, toChainId: 8453, bridge: 'across' })
303+
).resolves.toEqual({ status: 'completed', txnId: fillTxnRef })
304+
expect(fetch).toHaveBeenCalledWith(
305+
`https://app.across.to/api/deposit/status?depositTxnRef=${txHash}`
306+
)
307+
})
308+
309+
it('uses the Uniswap status API for non-Across bridges', async () => {
310+
const fetch = jest.fn(async () =>
311+
makeResponse({
312+
requestId: 'request-id',
313+
swaps: [{ swapType: 'BRIDGE', status: 'SUCCESS', txHash: fillTxnRef }]
314+
})
315+
)
316+
const uniswapApi = new UniswapAPI({ fetch: fetch as any, apiKey: 'test-key' })
317+
318+
await expect(
319+
uniswapApi.getRouteStatus({ txHash, fromChainId: 1, toChainId: 8453 })
320+
).resolves.toEqual({ status: 'completed', txnId: fillTxnRef })
321+
expect(fetch).toHaveBeenCalledWith(
322+
`https://trade-api.gateway.uniswap.org/v1/swaps?txHashes=${txHash}&chainId=1`,
323+
{ headers: expect.any(Object) }
324+
)
325+
})
326+
})
227327
})

src/services/uniswap/api.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
isNoFeeToken,
3232
sortNativeTokenFirst
3333
} from '../../libs/swapAndBridge/swapAndBridge'
34+
import { AcrossAPI } from '../across/api'
3435
import {
3536
AMBIRE_FEE_TAKER_ADDRESS,
3637
FEE_PERCENT,
@@ -42,6 +43,11 @@ import {
4243

4344
const erc20Interface = new Interface(ERC20.abi)
4445

46+
const isAcrossBridgeQuote = (quote: UniswapQuote) =>
47+
quote.exclusiveRelayer !== undefined &&
48+
quote.exclusivityDeadline !== undefined &&
49+
quote.fillDeadline !== undefined
50+
4551
const normalizeAddress = (address: string) =>
4652
address.toLowerCase() === ZeroAddress.toLowerCase() ? ZeroAddress : getAddress(address)
4753

@@ -191,7 +197,9 @@ const normalizeUniswapRouteToSwapAndBridgeRoute = ({
191197
fromAmount,
192198
toAmount,
193199
currentUserTxIndex: 0,
194-
...(fromChainId === toChainId ? { usedDexName: 'Uniswap' } : { usedBridgeNames: ['uniswap'] }),
200+
...(fromChainId === toChainId
201+
? { usedDexName: 'Uniswap' }
202+
: { usedBridgeNames: [isAcrossBridgeQuote(quote) ? 'across' : 'uniswap'] }),
195203
userTxs: [userTx],
196204
steps: [step],
197205
inputValueInUsd,
@@ -233,6 +241,8 @@ export class UniswapAPI implements SwapProvider {
233241

234242
#fetch: Fetch
235243

244+
#acrossAPI: AcrossAPI
245+
236246
#headers: RequestInitWithCustomHeaders['headers']
237247

238248
#requestTimeoutMs = 15000
@@ -243,6 +253,7 @@ export class UniswapAPI implements SwapProvider {
243253

244254
constructor({ fetch, apiKey }: { fetch: Fetch; apiKey: string }) {
245255
this.#fetch = fetch
256+
this.#acrossAPI = new AcrossAPI({ fetch })
246257
this.#headers = {
247258
Accept: 'application/json',
248259
'Content-Type': 'application/json',
@@ -574,16 +585,15 @@ export class UniswapAPI implements SwapProvider {
574585
txHash,
575586
fromChainId,
576587
toChainId,
577-
requestId,
578-
routeId
588+
bridge
579589
}: {
580590
txHash: string
581591
fromChainId: number
582592
toChainId: number
583-
requestId?: string
584-
routeId?: string
593+
bridge?: string
585594
}): Promise<SwapAndBridgeRouteStatusResult> {
586595
if (fromChainId === toChainId) return { status: 'completed', txnId: txHash }
596+
if (bridge === 'across') return this.#acrossAPI.getRouteStatus({ txHash })
587597

588598
this.#ensureApiKey()
589599

0 commit comments

Comments
 (0)