@xswap-link/sdk 0.15.1 → 0.15.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.
- package/.eslintrc.json +1 -0
- package/CHANGELOG.md +10 -0
- package/README.md +22 -4
- package/dist/index.d.mts +47 -1
- package/dist/index.d.ts +47 -1
- package/dist/index.global.js +89 -89
- package/dist/index.js +1018 -766
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +911 -659
- package/dist/index.mjs.map +1 -1
- package/example/index.html +12 -0
- package/example/package.json +24 -0
- package/example/pnpm-lock.yaml +1164 -0
- package/example/src/App.tsx +421 -0
- package/example/src/main.tsx +8 -0
- package/example/vite.config.ts +9 -0
- package/package.json +1 -1
- package/src/components/Swap/SwapView/ConfirmationView/TxOverview/index.tsx +12 -7
- package/src/components/Swap/SwapView/ConfirmationView/TxProgress/index.tsx +136 -0
- package/src/components/Swap/SwapView/ConfirmationView/TxResult/index.tsx +89 -8
- package/src/components/Swap/SwapView/ConfirmationView/index.tsx +19 -4
- package/src/components/Swap/SwapView/FeesPanel/Fees/index.tsx +11 -5
- package/src/context/SwapProvider.tsx +22 -7
- package/src/context/TxUIWrapper.tsx +34 -3
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useCrossChainDelivery.ts +143 -0
- package/src/models/Route.ts +13 -0
- package/src/models/TransactionDelivery.ts +40 -0
- package/src/models/index.ts +1 -0
- package/src/services/api.ts +23 -0
|
@@ -4,25 +4,95 @@ import {
|
|
|
4
4
|
RedirectThinIcon,
|
|
5
5
|
} from "@src/assets/icons";
|
|
6
6
|
import { useSwapContext, useTxUIWrapper } from "@src/context";
|
|
7
|
+
import { CrossChainDelivery } from "@src/hooks";
|
|
8
|
+
import { Ecosystem } from "@src/models";
|
|
9
|
+
import { useMemo } from "react";
|
|
7
10
|
import { TokenTiles } from "../TokenTiles";
|
|
8
11
|
|
|
9
12
|
type Props = {
|
|
10
13
|
onCloseClick: () => void;
|
|
14
|
+
/**
|
|
15
|
+
* How the transfer actually settled. Absent for the flows whose delivery is not
|
|
16
|
+
* tracked — a single-chain swap, a non-EVM source, or a standalone transaction run
|
|
17
|
+
* through this modal — where the source receipt is the whole story.
|
|
18
|
+
*/
|
|
19
|
+
delivery?: CrossChainDelivery;
|
|
11
20
|
};
|
|
12
21
|
|
|
13
|
-
export const TxResult = ({ onCloseClick }: Props) => {
|
|
22
|
+
export const TxResult = ({ onCloseClick, delivery }: Props) => {
|
|
14
23
|
const { txError, txExplorerUrl, txMsg } = useTxUIWrapper();
|
|
15
|
-
const { isExpressDeliveryActive, srcChain, dstChain } =
|
|
24
|
+
const { isExpressDeliveryActive, srcChain, dstChain, supportedChains } =
|
|
25
|
+
useSwapContext();
|
|
26
|
+
|
|
27
|
+
const isCrossChain =
|
|
28
|
+
!!srcChain && !!dstChain && srcChain.chainId !== dstChain.chainId;
|
|
29
|
+
const failed = !!txError || !!delivery?.isFailed;
|
|
30
|
+
// Delivered, but as the bridged token: the destination swap never ran, and it
|
|
31
|
+
// never will — so this is an outcome to report, not a step still pending.
|
|
32
|
+
const partiallyFailed = !!delivery?.isPartiallyFailed;
|
|
33
|
+
|
|
34
|
+
const targetTx = delivery?.response?.targetTx;
|
|
35
|
+
const targetExplorerUrl = useMemo(() => {
|
|
36
|
+
if (!targetTx) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
const explorer = supportedChains.find(
|
|
40
|
+
(chain) =>
|
|
41
|
+
chain.chainId === targetTx.chainId && chain.ecosystem === Ecosystem.EVM,
|
|
42
|
+
)?.transactionExplorer;
|
|
43
|
+
return explorer ? `${explorer}/${targetTx.hash}` : undefined;
|
|
44
|
+
}, [supportedChains, targetTx]);
|
|
45
|
+
|
|
46
|
+
const note = useMemo(() => {
|
|
47
|
+
if (delivery?.isFailed) {
|
|
48
|
+
return `The transaction failed on ${
|
|
49
|
+
srcChain?.displayName || "the source chain"
|
|
50
|
+
}.`;
|
|
51
|
+
}
|
|
52
|
+
if (partiallyFailed) {
|
|
53
|
+
return `The swap on ${
|
|
54
|
+
dstChain?.displayName || "the destination chain"
|
|
55
|
+
} could not be completed, so the bridged tokens were sent to your wallet instead.`;
|
|
56
|
+
}
|
|
57
|
+
if (delivery?.isDelivered) {
|
|
58
|
+
return `Funds delivered on ${
|
|
59
|
+
dstChain?.displayName || "the destination chain"
|
|
60
|
+
}.`;
|
|
61
|
+
}
|
|
62
|
+
// Nothing was tracked (non-EVM source), so the estimate is all there is. Read
|
|
63
|
+
// the speed off the quote, never off the express-delivery fee — a fast CCTP
|
|
64
|
+
// transfer pays Circle out of the bridged USDC and charges no express fee.
|
|
65
|
+
if (isCrossChain) {
|
|
66
|
+
return `Approximated time of delivery: ${
|
|
67
|
+
isExpressDeliveryActive ? "30 sec" : "30 min"
|
|
68
|
+
}`;
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}, [
|
|
72
|
+
delivery?.isDelivered,
|
|
73
|
+
delivery?.isFailed,
|
|
74
|
+
partiallyFailed,
|
|
75
|
+
isCrossChain,
|
|
76
|
+
isExpressDeliveryActive,
|
|
77
|
+
srcChain?.displayName,
|
|
78
|
+
dstChain?.displayName,
|
|
79
|
+
]);
|
|
16
80
|
|
|
17
81
|
return (
|
|
18
82
|
<div className="flex flex-col gap-8 text-t_text_primary">
|
|
19
83
|
<div className="flex flex-col gap-2">
|
|
20
84
|
<div className="flex items-center gap-3">
|
|
21
85
|
<div className="text-2xl font-medium leading-8 tracking-[0.01em]">
|
|
22
|
-
{
|
|
86
|
+
{failed
|
|
87
|
+
? "Transaction Error"
|
|
88
|
+
: partiallyFailed
|
|
89
|
+
? "Partially Completed"
|
|
90
|
+
: "Transaction Success"}
|
|
23
91
|
</div>
|
|
24
|
-
{
|
|
92
|
+
{failed ? (
|
|
25
93
|
<ErrorCircleThinIcon className="w-6 h-6 shrink-0 text-[#F44336]" />
|
|
94
|
+
) : partiallyFailed ? (
|
|
95
|
+
<ErrorCircleThinIcon className="w-6 h-6 shrink-0 text-t_warning_light" />
|
|
26
96
|
) : (
|
|
27
97
|
<CheckCircleThinIcon className="w-6 h-6 shrink-0 text-[#6AE89B]" />
|
|
28
98
|
)}
|
|
@@ -33,11 +103,9 @@ export const TxResult = ({ onCloseClick }: Props) => {
|
|
|
33
103
|
</div>
|
|
34
104
|
) : (
|
|
35
105
|
!txMsg &&
|
|
36
|
-
|
|
106
|
+
!!note && (
|
|
37
107
|
<div className="text-sm leading-5 tracking-[0.01em] text-t_text_primary text-opacity-50">
|
|
38
|
-
{
|
|
39
|
-
isExpressDeliveryActive ? "30 sec" : "30 min"
|
|
40
|
-
}`}
|
|
108
|
+
{note}
|
|
41
109
|
</div>
|
|
42
110
|
)
|
|
43
111
|
)}
|
|
@@ -64,6 +132,19 @@ export const TxResult = ({ onCloseClick }: Props) => {
|
|
|
64
132
|
<RedirectThinIcon className="w-3 h-3" />
|
|
65
133
|
</div>
|
|
66
134
|
</a>
|
|
135
|
+
{targetExplorerUrl && (
|
|
136
|
+
<a
|
|
137
|
+
href={targetExplorerUrl}
|
|
138
|
+
target="_blank"
|
|
139
|
+
rel="noreferrer"
|
|
140
|
+
className="flex items-center gap-1.5 text-sm text-t_text_primary text-opacity-25 hover:text-opacity-50 transition-colors"
|
|
141
|
+
>
|
|
142
|
+
View Delivery{" "}
|
|
143
|
+
<div className="w-6 h-6 bg-t_bg_tertiary bg-opacity-5 rounded-[4px] flex items-center justify-center">
|
|
144
|
+
<RedirectThinIcon className="w-3 h-3" />
|
|
145
|
+
</div>
|
|
146
|
+
</a>
|
|
147
|
+
)}
|
|
67
148
|
<div className="flex-1 h-px bg-t_text_primary bg-opacity-10" />
|
|
68
149
|
</div>
|
|
69
150
|
)}
|
|
@@ -1,17 +1,32 @@
|
|
|
1
1
|
import { useTxUIWrapper } from "@src/context";
|
|
2
|
+
import { CrossChainDelivery } from "@src/hooks";
|
|
2
3
|
import { TxStatus } from "@src/models";
|
|
3
4
|
import { TxOverview } from "./TxOverview";
|
|
5
|
+
import { TxProgress } from "./TxProgress";
|
|
4
6
|
import { TxResult } from "./TxResult";
|
|
5
7
|
|
|
6
8
|
type Props = {
|
|
7
9
|
onCloseClick: () => void;
|
|
10
|
+
/** Delivery tracking for the transfer in the modal, owned by {@link TxUIWrapper}
|
|
11
|
+
* so the modal's own styling can follow it too. Idle unless one is in flight. */
|
|
12
|
+
delivery: CrossChainDelivery;
|
|
8
13
|
};
|
|
9
|
-
export const ConfirmationView = ({ onCloseClick }: Props) => {
|
|
14
|
+
export const ConfirmationView = ({ onCloseClick, delivery }: Props) => {
|
|
10
15
|
const { txStatus, txError } = useTxUIWrapper();
|
|
11
16
|
|
|
12
|
-
|
|
13
|
-
<TxResult onCloseClick={onCloseClick}
|
|
17
|
+
if (txError) {
|
|
18
|
+
return <TxResult onCloseClick={onCloseClick} delivery={delivery} />;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (txStatus !== TxStatus.COMPLETED) {
|
|
22
|
+
return <TxOverview onCloseClick={onCloseClick} />;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// The source tx is mined, which for a cross-chain swap is the halfway point, not
|
|
26
|
+
// the end: hold the success view until the funds actually land on the destination.
|
|
27
|
+
return delivery.isPolling ? (
|
|
28
|
+
<TxProgress onCloseClick={onCloseClick} delivery={delivery} />
|
|
14
29
|
) : (
|
|
15
|
-
<
|
|
30
|
+
<TxResult onCloseClick={onCloseClick} delivery={delivery} />
|
|
16
31
|
);
|
|
17
32
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useSwapContext } from "@src/context";
|
|
2
|
-
import { weiToHumanReadable } from "@src/utils";
|
|
2
|
+
import { safeBigNumberFrom, weiToHumanReadable } from "@src/utils";
|
|
3
3
|
import { Fee } from "../Fee";
|
|
4
4
|
|
|
5
5
|
export const Fees = ({
|
|
@@ -7,8 +7,14 @@ export const Fees = ({
|
|
|
7
7
|
}: {
|
|
8
8
|
solanaBridgeFee: string | null;
|
|
9
9
|
}) => {
|
|
10
|
-
const { route, slippage, feeToken, dstToken
|
|
11
|
-
|
|
10
|
+
const { route, slippage, feeToken, dstToken } = useSwapContext();
|
|
11
|
+
|
|
12
|
+
// Only routes that buy their speed with an express-delivery fee have a fee to show.
|
|
13
|
+
// A fast CCTP transfer has none — Circle's fast-transfer fee is taken out of the
|
|
14
|
+
// bridged USDC — so the row would read "0" on the very routes that are fastest.
|
|
15
|
+
const expressDeliveryFee = safeBigNumberFrom(
|
|
16
|
+
route?.xSwapFees.expressDeliveryFee || "0",
|
|
17
|
+
);
|
|
12
18
|
|
|
13
19
|
const fees = [
|
|
14
20
|
{
|
|
@@ -33,13 +39,13 @@ export const Fees = ({
|
|
|
33
39
|
}) || 0
|
|
34
40
|
} ${feeToken?.symbol || ""}`,
|
|
35
41
|
},
|
|
36
|
-
...(
|
|
42
|
+
...(expressDeliveryFee.gt(0)
|
|
37
43
|
? [
|
|
38
44
|
{
|
|
39
45
|
name: "Express delivery Fee: ",
|
|
40
46
|
value: `${
|
|
41
47
|
weiToHumanReadable({
|
|
42
|
-
amount:
|
|
48
|
+
amount: expressDeliveryFee.toString(),
|
|
43
49
|
decimals: feeToken?.decimals || 18,
|
|
44
50
|
precisionFractionalPlaces: 5,
|
|
45
51
|
}) || 0
|
|
@@ -498,14 +498,29 @@ export const SwapProvider = ({
|
|
|
498
498
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
499
499
|
[route],
|
|
500
500
|
);
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
501
|
+
/**
|
|
502
|
+
* Whether the quote in hand is delivered as express delivery.
|
|
503
|
+
*
|
|
504
|
+
* The API states it on the route wherever the fee cannot: a CCTP transfer charges
|
|
505
|
+
* no express-delivery fee (Circle's fast-transfer fee comes out of the bridged
|
|
506
|
+
* USDC), so it reports the flag outright. That answer wins whenever it is there.
|
|
507
|
+
*
|
|
508
|
+
* Everywhere else the fee IS the signal — on the CCIP paths express delivery is
|
|
509
|
+
* what the non-zero express-delivery fee buys — so the fee test stays as the
|
|
510
|
+
* fallback.
|
|
511
|
+
*/
|
|
512
|
+
const isExpressDeliveryActive = useMemo(() => {
|
|
513
|
+
if (!route) {
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
if (typeof route.expressDelivery === "boolean") {
|
|
517
|
+
return route.expressDelivery;
|
|
518
|
+
}
|
|
519
|
+
return (
|
|
504
520
|
expressDelivery &&
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
);
|
|
521
|
+
BigNumber.from(route.xSwapFees?.expressDeliveryFee || "0").gt("0")
|
|
522
|
+
);
|
|
523
|
+
}, [expressDelivery, route]);
|
|
509
524
|
// useMemo: indicates whether asynchronous data is loaded based on the presence of supported chains.
|
|
510
525
|
const isAsyncDataLoaded = useMemo(
|
|
511
526
|
() => supportedChains.length > 0 && bridgeTokens.length > 0,
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from "@src/components";
|
|
9
9
|
import { ConfirmationView } from "@src/components/Swap/SwapView/ConfirmationView";
|
|
10
10
|
import { CCIP_EXPLORER } from "@src/constants";
|
|
11
|
+
import { useCrossChainDelivery } from "@src/hooks";
|
|
11
12
|
import {
|
|
12
13
|
Ecosystem,
|
|
13
14
|
EnqueueTxProps,
|
|
@@ -63,7 +64,33 @@ export const TxUIWrapper = ({ children }: Props) => {
|
|
|
63
64
|
const [txNeedsApproval, setTxNeedsApproval] = useState(false);
|
|
64
65
|
const [txStatus, setTxStatus] = useState(TxStatus.SWAP_INIT);
|
|
65
66
|
|
|
66
|
-
const { supportedChains, setSrcValue } = useSwapContext();
|
|
67
|
+
const { supportedChains, setSrcValue, srcChain, dstChain } = useSwapContext();
|
|
68
|
+
|
|
69
|
+
// A cross-chain swap is only done when the funds land on the destination, so once
|
|
70
|
+
// the source tx is mined the transfer is followed to its end rather than declared
|
|
71
|
+
// finished (the poll is also what claims a CCTP transfer — see the hook). Tracked
|
|
72
|
+
// here, not in the view, so the modal's own styling can follow it too.
|
|
73
|
+
const isCrossChainSwap =
|
|
74
|
+
!!srcChain && !!dstChain && srcChain.chainId !== dstChain.chainId;
|
|
75
|
+
const delivery = useCrossChainDelivery({
|
|
76
|
+
enabled:
|
|
77
|
+
txStatus === TxStatus.COMPLETED &&
|
|
78
|
+
!txError &&
|
|
79
|
+
// Only while the modal is up: the chains below are the ones currently
|
|
80
|
+
// selected, and the user is free to pick different ones the moment they close
|
|
81
|
+
// it. An unclaimed CCTP transfer is the executor's and the sweep's job from
|
|
82
|
+
// there on, not this poll's.
|
|
83
|
+
isTxModalOpen &&
|
|
84
|
+
// A custom message means the modal is running a standalone transaction
|
|
85
|
+
// (an approval reset, say), not a swap with a destination leg.
|
|
86
|
+
!txMsg &&
|
|
87
|
+
isCrossChainSwap &&
|
|
88
|
+
// The status endpoint reads an EVM receipt; a Solana source has none.
|
|
89
|
+
srcChain?.ecosystem === Ecosystem.EVM,
|
|
90
|
+
chainId: srcChain?.chainId,
|
|
91
|
+
txHash,
|
|
92
|
+
walletAddress: evm.address,
|
|
93
|
+
});
|
|
67
94
|
|
|
68
95
|
const closeTransactionModal = (transactionKey: number) => {
|
|
69
96
|
if (txKey.current === transactionKey) {
|
|
@@ -435,14 +462,18 @@ export const TxUIWrapper = ({ children }: Props) => {
|
|
|
435
462
|
isTxModalOpenRef.current = flag;
|
|
436
463
|
}, []);
|
|
437
464
|
|
|
465
|
+
// Funds still in flight read as "waiting": the success glow belongs to the
|
|
466
|
+
// delivery, not to the source tx that started it.
|
|
438
467
|
const modalType: TxModalType = useMemo(
|
|
439
468
|
() =>
|
|
440
469
|
txError
|
|
441
470
|
? "error"
|
|
471
|
+
: delivery.isPolling
|
|
472
|
+
? "waiting"
|
|
442
473
|
: txStatus === TxStatus.COMPLETED
|
|
443
474
|
? "success"
|
|
444
475
|
: "neutral",
|
|
445
|
-
[txError, txStatus],
|
|
476
|
+
[txError, txStatus, delivery.isPolling],
|
|
446
477
|
);
|
|
447
478
|
|
|
448
479
|
const state = useMemo<TxUIWrapperState>(
|
|
@@ -490,7 +521,7 @@ export const TxUIWrapper = ({ children }: Props) => {
|
|
|
490
521
|
type={modalType}
|
|
491
522
|
onCloseClick={closeTxModal}
|
|
492
523
|
>
|
|
493
|
-
<ConfirmationView onCloseClick={closeTxModal} />
|
|
524
|
+
<ConfirmationView onCloseClick={closeTxModal} delivery={delivery} />
|
|
494
525
|
</TxModal>
|
|
495
526
|
)}
|
|
496
527
|
{children}
|
package/src/hooks/index.ts
CHANGED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CrossChainDeliveryStatus,
|
|
3
|
+
TransactionStatusResponse,
|
|
4
|
+
} from "@src/models";
|
|
5
|
+
import { getTransactionStatus } from "@src/services";
|
|
6
|
+
import { useEffect, useState } from "react";
|
|
7
|
+
|
|
8
|
+
/** Nothing changes after these: either the funds landed or they never will. */
|
|
9
|
+
const TERMINAL_STATUSES: CrossChainDeliveryStatus[] = [
|
|
10
|
+
"DONE",
|
|
11
|
+
"PARTIALLY_FAILED",
|
|
12
|
+
"FAILED",
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
/** A fast transfer lands in ~20s, so poll often enough that the UI is not the
|
|
16
|
+
* slowest part of it. */
|
|
17
|
+
const POLL_INTERVAL_MS = 4000;
|
|
18
|
+
/** Past the point where the user is plainly waiting on finality, not on us. */
|
|
19
|
+
const SLOW_POLL_INTERVAL_MS = 15000;
|
|
20
|
+
const SLOW_POLL_AFTER_MS = 3 * 60 * 1000;
|
|
21
|
+
|
|
22
|
+
type Params = {
|
|
23
|
+
/** Poll only while this is true — set it false for anything the endpoint cannot
|
|
24
|
+
* answer for (single-chain swaps, a non-EVM source, a tx that is not mined yet). */
|
|
25
|
+
enabled: boolean;
|
|
26
|
+
/** Source chain of the tx, i.e. the chain `txHash` was mined on. */
|
|
27
|
+
chainId?: string;
|
|
28
|
+
txHash?: string;
|
|
29
|
+
walletAddress?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type CrossChainDelivery = {
|
|
33
|
+
status: CrossChainDeliveryStatus | undefined;
|
|
34
|
+
response: TransactionStatusResponse | undefined;
|
|
35
|
+
/** Tracking this transfer and still waiting on it. */
|
|
36
|
+
isPolling: boolean;
|
|
37
|
+
isSettled: boolean;
|
|
38
|
+
isDelivered: boolean;
|
|
39
|
+
/** Delivered, but as the bridged token: the destination swap did not run. */
|
|
40
|
+
isPartiallyFailed: boolean;
|
|
41
|
+
isFailed: boolean;
|
|
42
|
+
/** When tracking began, for elapsed-time UI. Undefined while disabled. */
|
|
43
|
+
startedAt: number | undefined;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Follows a submitted cross-chain transfer to its destination.
|
|
48
|
+
*
|
|
49
|
+
* The poll is not just an observer: the API claims an attested-but-unclaimed CCTP
|
|
50
|
+
* transfer from inside the same call, so polling is also what keeps the delivery
|
|
51
|
+
* moving. Which is the point — most transfers land in well under a minute, and a
|
|
52
|
+
* client that waits out a fixed 30-minute estimate instead of asking is both wrong
|
|
53
|
+
* and slower than the transfer it is describing.
|
|
54
|
+
*
|
|
55
|
+
* Polling stops as soon as the answer is terminal, and backs off to a slow cadence
|
|
56
|
+
* once the wait is long enough to be finality rather than latency.
|
|
57
|
+
*/
|
|
58
|
+
export const useCrossChainDelivery = ({
|
|
59
|
+
enabled,
|
|
60
|
+
chainId,
|
|
61
|
+
txHash,
|
|
62
|
+
walletAddress,
|
|
63
|
+
}: Params): CrossChainDelivery => {
|
|
64
|
+
const [response, setResponse] = useState<TransactionStatusResponse>();
|
|
65
|
+
const [startedAt, setStartedAt] = useState<number>();
|
|
66
|
+
|
|
67
|
+
const active = enabled && !!chainId && !!txHash;
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (!active) {
|
|
71
|
+
setResponse(undefined);
|
|
72
|
+
setStartedAt(undefined);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let cancelled = false;
|
|
77
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
78
|
+
const controller = new AbortController();
|
|
79
|
+
const startTime = Date.now();
|
|
80
|
+
|
|
81
|
+
setResponse(undefined);
|
|
82
|
+
setStartedAt(startTime);
|
|
83
|
+
|
|
84
|
+
const poll = async () => {
|
|
85
|
+
try {
|
|
86
|
+
const next = await getTransactionStatus(
|
|
87
|
+
{
|
|
88
|
+
chainId: chainId as string,
|
|
89
|
+
txHash: txHash as string,
|
|
90
|
+
walletAddress,
|
|
91
|
+
},
|
|
92
|
+
controller.signal,
|
|
93
|
+
);
|
|
94
|
+
if (cancelled) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
setResponse(next);
|
|
98
|
+
if (TERMINAL_STATUSES.includes(next.status)) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
} catch (err) {
|
|
102
|
+
if (cancelled) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
// A single failed poll says nothing about the transfer — a cold RPC, a rate
|
|
106
|
+
// limit or a dropped request all look like this, and the next poll usually
|
|
107
|
+
// answers. Keep the loop alive and keep it out of the UI.
|
|
108
|
+
console.warn("Failed to read cross-chain delivery status:", err);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
timer = setTimeout(
|
|
112
|
+
poll,
|
|
113
|
+
Date.now() - startTime > SLOW_POLL_AFTER_MS
|
|
114
|
+
? SLOW_POLL_INTERVAL_MS
|
|
115
|
+
: POLL_INTERVAL_MS,
|
|
116
|
+
);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
poll();
|
|
120
|
+
|
|
121
|
+
return () => {
|
|
122
|
+
cancelled = true;
|
|
123
|
+
if (timer) {
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
}
|
|
126
|
+
controller.abort();
|
|
127
|
+
};
|
|
128
|
+
}, [active, chainId, txHash, walletAddress]);
|
|
129
|
+
|
|
130
|
+
const status = response?.status;
|
|
131
|
+
const isSettled = !!status && TERMINAL_STATUSES.includes(status);
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
status,
|
|
135
|
+
response,
|
|
136
|
+
isPolling: active && !isSettled,
|
|
137
|
+
isSettled,
|
|
138
|
+
isDelivered: status === "DONE",
|
|
139
|
+
isPartiallyFailed: status === "PARTIALLY_FAILED",
|
|
140
|
+
isFailed: status === "FAILED",
|
|
141
|
+
startedAt,
|
|
142
|
+
};
|
|
143
|
+
};
|
package/src/models/Route.ts
CHANGED
|
@@ -3,6 +3,19 @@ interface BaseRoute {
|
|
|
3
3
|
minAmountOut: string;
|
|
4
4
|
xSwapFees: XSwapFees;
|
|
5
5
|
message?: string;
|
|
6
|
+
/**
|
|
7
|
+
* Whether the route is delivered as express delivery, stated outright by the API.
|
|
8
|
+
*
|
|
9
|
+
* Served by the bridges whose speed the fee breakdown cannot express — today CCTP,
|
|
10
|
+
* which reports true on every route: it charges no express-delivery fee at all
|
|
11
|
+
* (Circle's fast-transfer fee comes out of the bridged USDC), so a client reading
|
|
12
|
+
* the fee saw every CCTP transfer as the slow tier.
|
|
13
|
+
*
|
|
14
|
+
* Absent everywhere else (the CCIP paths, single-chain), where the fee IS the
|
|
15
|
+
* signal — express delivery there is what a non-zero `expressDeliveryFee` buys —
|
|
16
|
+
* so fall back to the fee when this is undefined, never instead of it.
|
|
17
|
+
*/
|
|
18
|
+
expressDelivery?: boolean;
|
|
6
19
|
}
|
|
7
20
|
|
|
8
21
|
export interface EVMRoute extends BaseRoute {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* States the API reports for a submitted transaction (`GET /getTransactionStatus`).
|
|
3
|
+
*
|
|
4
|
+
* - `NOT_FOUND` — the source tx is not visible to the API's RPCs yet. Expected for
|
|
5
|
+
* the first poll or two after a swap; it is not a failure.
|
|
6
|
+
* - `IN_PROGRESS` — the source tx is mined and the destination has not settled yet.
|
|
7
|
+
* - `DONE` — the destination leg ran and the user holds the token they asked for.
|
|
8
|
+
* - `PARTIALLY_FAILED` — the transfer arrived but its destination swap did not run
|
|
9
|
+
* (or produced less than the committed minimum), so the bridged token was
|
|
10
|
+
* forwarded to the receiver as-is. Terminal: no swap will happen later.
|
|
11
|
+
* - `FAILED` — the source tx itself reverted.
|
|
12
|
+
* - `STUCK` — mined, but the bridge has not moved it for long enough that it needs
|
|
13
|
+
* looking at rather than waiting on.
|
|
14
|
+
*/
|
|
15
|
+
export type CrossChainDeliveryStatus =
|
|
16
|
+
| "NOT_FOUND"
|
|
17
|
+
| "IN_PROGRESS"
|
|
18
|
+
| "DONE"
|
|
19
|
+
| "STUCK"
|
|
20
|
+
| "FAILED"
|
|
21
|
+
| "PARTIALLY_FAILED";
|
|
22
|
+
|
|
23
|
+
export type DeliveryTransferInfo = {
|
|
24
|
+
hash: string;
|
|
25
|
+
chainId: string;
|
|
26
|
+
/** Absent on the plain tx references (the middle hop), which carry no transfer. */
|
|
27
|
+
token?: string;
|
|
28
|
+
amount?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type TransactionStatusResponse = {
|
|
32
|
+
status: CrossChainDeliveryStatus;
|
|
33
|
+
integratorHash?: string;
|
|
34
|
+
/** The speed the transfer actually settled at, as resolved by the bridge itself. */
|
|
35
|
+
expressDelivery?: boolean;
|
|
36
|
+
receiver?: string;
|
|
37
|
+
sourceTx?: DeliveryTransferInfo;
|
|
38
|
+
middleTx?: DeliveryTransferInfo;
|
|
39
|
+
targetTx?: DeliveryTransferInfo;
|
|
40
|
+
};
|
package/src/models/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ export * from "./Protocol";
|
|
|
13
13
|
export * from "./Route";
|
|
14
14
|
export * from "./SolanaWeb3Network";
|
|
15
15
|
export * from "./TokenData";
|
|
16
|
+
export * from "./TransactionDelivery";
|
|
16
17
|
export * from "./TransactionHistory";
|
|
17
18
|
export * from "./TxUIWrapper";
|
|
18
19
|
export * from "./Web3Environment";
|
package/src/services/api.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
TokenPrices,
|
|
15
15
|
TransactionHistory,
|
|
16
16
|
TransactionStatus,
|
|
17
|
+
TransactionStatusResponse,
|
|
17
18
|
} from "@src/models";
|
|
18
19
|
|
|
19
20
|
let XSWAP_API_URL = "https://xswap.link/api";
|
|
@@ -148,6 +149,28 @@ export async function getBalances(payload: GetTokenBalancesPayload) {
|
|
|
148
149
|
);
|
|
149
150
|
}
|
|
150
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Status of a submitted transaction, source tx and destination leg included.
|
|
154
|
+
*
|
|
155
|
+
* Polling this is also what moves a CCTP transfer along: when the API sees one
|
|
156
|
+
* attested by Circle but not yet claimed on the destination, it hands it to its own
|
|
157
|
+
* executor from inside this call (CCTP has no auto-relay). So a client that shows
|
|
158
|
+
* delivery progress should poll it rather than wait out a fixed estimate.
|
|
159
|
+
*
|
|
160
|
+
* EVM only — `chainId`/`txHash` identify an EVM receipt.
|
|
161
|
+
*/
|
|
162
|
+
export async function getTransactionStatus(
|
|
163
|
+
payload: { chainId: string; txHash: string; walletAddress?: string },
|
|
164
|
+
abortSignal?: AbortSignal,
|
|
165
|
+
): Promise<TransactionStatusResponse> {
|
|
166
|
+
return _sendRequest<TransactionStatusResponse>(
|
|
167
|
+
`/getTransactionStatus?${new URLSearchParams({
|
|
168
|
+
data: JSON.stringify(payload),
|
|
169
|
+
})}`,
|
|
170
|
+
{ signal: abortSignal },
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
151
174
|
export async function getTxStatus(payload: { transferId: string }) {
|
|
152
175
|
return await _sendRequest<{ status: TransactionStatus }>(
|
|
153
176
|
`/getTxStatus?${new URLSearchParams({
|