diff --git a/apps/ui/package.json b/apps/ui/package.json index be5a4b78e..1f2a4ea9e 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -51,15 +51,15 @@ "@solana/spl-token": "^0.3.5", "@solana/web3.js": "^1.62.0", "@swim-io/aptos": "workspace:^", - "@swim-io/core": "^0.39.0", - "@swim-io/evm": "^0.39.0", - "@swim-io/evm-contracts": "^0.39.0", - "@swim-io/pool-math": "^0.39.0", - "@swim-io/solana": "^0.39.0", - "@swim-io/solana-contracts": "^0.39.0", - "@swim-io/token-projects": "workspace:^", - "@swim-io/utils": "^0.39.0", - "@swim-io/wormhole": "^0.39.0", + "@swim-io/core": "^0.40.0", + "@swim-io/evm": "^0.40.0", + "@swim-io/evm-contracts": "^0.40.0", + "@swim-io/pool-math": "^0.40.0", + "@swim-io/solana": "^0.40.0", + "@swim-io/solana-contracts": "^0.40.0", + "@swim-io/token-projects": "^0.40.0", + "@swim-io/utils": "^0.40.0", + "@swim-io/wormhole": "^0.40.0", "bn.js": "^5.2.1", "classnames": "^2.3.1", "decimal.js": "^10.3.1", diff --git a/apps/ui/src/components/AddForm.tsx b/apps/ui/src/components/AddForm.tsx index dc530bf98..5d1287552 100644 --- a/apps/ui/src/components/AddForm.tsx +++ b/apps/ui/src/components/AddForm.tsx @@ -37,9 +37,9 @@ import { useMultipleUserBalances, usePool, usePoolMath, - useSplTokenAccountsQuery, useUserBalanceAmount, useUserNativeBalances, + useUserSolanaTokenAccountsQuery, useWallets, } from "../hooks"; import { @@ -206,7 +206,7 @@ export const AddForm = ({ poolTokens, poolSpec.isLegacyPool ? undefined : poolSpec.ecosystem, ); - const { data: splTokenAccounts = null } = useSplTokenAccountsQuery(); + const { data: splTokenAccounts = null } = useUserSolanaTokenAccountsQuery(); const startNewInteraction = useStartNewInteraction(() => { setFormInputAmounts(poolTokens.map(() => "0")); }); diff --git a/apps/ui/src/components/RecentInteractions.tsx b/apps/ui/src/components/RecentInteractions.tsx index 93557a68d..cb5d2c6e3 100644 --- a/apps/ui/src/components/RecentInteractions.tsx +++ b/apps/ui/src/components/RecentInteractions.tsx @@ -9,7 +9,7 @@ import { Fragment, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { useEnvironment, useInteractionState } from "../core/store"; -import { useSplTokenAccountsQuery, useWallets } from "../hooks"; +import { useUserSolanaTokenAccountsQuery, useWallets } from "../hooks"; import { isEveryAddressConnected } from "../models"; import type { InteractionType } from "../models"; @@ -34,7 +34,8 @@ export const RecentInteractions = ({ loadInteractionStatesFromIdb(env).catch(console.error); }, [env, loadInteractionStatesFromIdb]); - const { isSuccess: didLoadSplTokenAccounts } = useSplTokenAccountsQuery(); + const { isSuccess: didLoadSplTokenAccounts } = + useUserSolanaTokenAccountsQuery(); const wallets = useWallets(); // Don’t display current interaction const { recentInteractionId, interactionStates } = useInteractionState(); diff --git a/apps/ui/src/components/RecentInteractionsV2.tsx b/apps/ui/src/components/RecentInteractionsV2.tsx index 727ca36c2..5838f910e 100644 --- a/apps/ui/src/components/RecentInteractionsV2.tsx +++ b/apps/ui/src/components/RecentInteractionsV2.tsx @@ -12,7 +12,6 @@ import { useWallets } from "../hooks"; import { isEveryAddressConnected } from "../models"; import type { InteractionType } from "../models"; -import { MultiConnectButton } from "./ConnectButton"; import { ConnectedWallets } from "./ConnectedWallets"; import { InteractionStateComponentV2 } from "./molecules/InteractionStateComponentV2"; @@ -55,7 +54,6 @@ export const RecentInteractionsV2 = ({ } > diff --git a/apps/ui/src/components/RemoveForm.tsx b/apps/ui/src/components/RemoveForm.tsx index ae4962d20..0a4ac7775 100644 --- a/apps/ui/src/components/RemoveForm.tsx +++ b/apps/ui/src/components/RemoveForm.tsx @@ -40,9 +40,9 @@ import { usePoolMath, useRemoveFeesEstimationQuery, useRemoveFeesEstimationQueryV2, - useSplTokenAccountsQuery, useUserLpBalances, useUserNativeBalances, + useUserSolanaTokenAccountsQuery, useWallets, } from "../hooks"; import { @@ -93,7 +93,7 @@ export const RemoveForm = ({ state: poolState, } = usePool(poolSpec.id); const poolMath = usePoolMath(poolSpec.id); - const { data: splTokenAccounts = null } = useSplTokenAccountsQuery(); + const { data: splTokenAccounts = null } = useUserSolanaTokenAccountsQuery(); const startNewInteractionV1 = useStartNewInteraction(() => { if (method === RemoveMethod.ExactOutput) { setFormOutputAmounts(formOutputAmounts.map(() => "0")); diff --git a/apps/ui/src/components/SwapForm/SwapForm.test.tsx b/apps/ui/src/components/SwapForm/SwapForm.test.tsx index 586d1d72c..773e865bf 100644 --- a/apps/ui/src/components/SwapForm/SwapForm.test.tsx +++ b/apps/ui/src/components/SwapForm/SwapForm.test.tsx @@ -14,12 +14,12 @@ import { useErc20BalanceQuery, useGetSwapFormErrors, useSolanaClient, - useSolanaLiquidityQueries, - useSplTokenAccountsQuery, - useSplUserBalance, + useSolanaTokenAccountQueries, useStartNewInteraction, useSwapFeesEstimationQuery, useUserNativeBalances, + useUserSolanaTokenAccountsQuery, + useUserSolanaTokenBalance, } from "../../hooks"; import { mockOf, renderWithAppContext } from "../../testUtils"; @@ -37,9 +37,9 @@ jest.mock( jest.mock("../../hooks/solana", () => ({ ...jest.requireActual("../../hooks/solana"), - useSplTokenAccountsQuery: jest.fn(), - useSplUserBalance: jest.fn(), - useSolanaLiquidityQueries: jest.fn(), + useUserSolanaTokenAccountsQuery: jest.fn(), + useUserSolanaTokenBalance: jest.fn(), + useSolanaTokenAccountQueries: jest.fn(), })); jest.mock("../../hooks/solana/useSolanaClient", () => ({ @@ -66,13 +66,15 @@ jest.mock("../../hooks", () => ({ const useGetSwapFormErrorsMock = mockOf(useGetSwapFormErrors); const useSolanaClientMock = mockOf(useSolanaClient); -const useSplTokenAccountsQueryMock = mockOf(useSplTokenAccountsQuery); +const useUserSolanaTokenAccountsQueryMock = mockOf( + useUserSolanaTokenAccountsQuery, +); const useStartNewInteractionMock = mockOf(useStartNewInteraction); const useSwapFeesEstimationQueryMock = mockOf(useSwapFeesEstimationQuery); const useErc20BalanceQueryMock = mockOf(useErc20BalanceQuery); const useUserNativeBalancesMock = mockOf(useUserNativeBalances); -const useSplUserBalanceMock = mockOf(useSplUserBalance); -const useSolanaLiquidityQueriesMock = mockOf(useSolanaLiquidityQueries); +const useUserSolanaTokenBalanceMock = mockOf(useUserSolanaTokenBalance); +const useSolanaTokenAccountQueriesMock = mockOf(useSolanaTokenAccountQueries); const findFromTokenButton = () => screen.queryAllByRole("button")[0]; const findToTokenButton = () => screen.queryAllByRole("button")[3]; @@ -86,7 +88,7 @@ describe("SwapForm", () => { }, } as Partial as unknown as CustomConnection, }); - useSplTokenAccountsQueryMock.mockReturnValue({ + useUserSolanaTokenAccountsQueryMock.mockReturnValue({ data: [], }); @@ -107,8 +109,8 @@ describe("SwapForm", () => { useSwapFeesEstimationQueryMock.mockReturnValue(null); useGetSwapFormErrorsMock.mockReturnValue(() => []); useErc20BalanceQueryMock.mockReturnValue({ data: zero }); - useSplUserBalanceMock.mockReturnValue(zero); - useSolanaLiquidityQueriesMock.mockReturnValue([ + useUserSolanaTokenBalanceMock.mockReturnValue(zero); + useSolanaTokenAccountQueriesMock.mockReturnValue([ { data: [] }, ] as unknown as readonly UseQueryResult[]); }); diff --git a/apps/ui/src/components/SwapForm/SwapForm.tsx b/apps/ui/src/components/SwapForm/SwapForm.tsx index 1c79c92d8..e71d75a38 100644 --- a/apps/ui/src/components/SwapForm/SwapForm.tsx +++ b/apps/ui/src/components/SwapForm/SwapForm.tsx @@ -24,12 +24,12 @@ import { useIsLargeSwap, usePoolMaths, usePools, - useSplTokenAccountsQuery, useSwapFeesEstimationQuery, useSwapOutputAmountEstimate, useSwapTokensContext, useUserBalanceAmount, useUserNativeBalances, + useUserSolanaTokenAccountsQuery, } from "../../hooks"; import { useHasActiveInteraction, @@ -59,7 +59,7 @@ export const SwapForm = ({ maxSlippageFraction }: Props): ReactElement => { const { t } = useTranslation(); const config = useEnvironment(selectConfig, shallow); const { notify } = useNotification(); - const { data: splTokenAccounts = null } = useSplTokenAccountsQuery(); + const { data: splTokenAccounts = null } = useUserSolanaTokenAccountsQuery(); const startNewInteraction = useStartNewInteraction(() => { setFormInputAmount(""); }); diff --git a/apps/ui/src/components/TokenSearchModal.tsx b/apps/ui/src/components/TokenSearchModal.tsx index 0d80ee2cb..06577f061 100644 --- a/apps/ui/src/components/TokenSearchModal.tsx +++ b/apps/ui/src/components/TokenSearchModal.tsx @@ -16,7 +16,7 @@ import { useTranslation } from "react-i18next"; import shallow from "zustand/shallow.js"; import type { EcosystemId, TokenConfig } from "../config"; -import { ECOSYSTEM_LIST, isEcosystemEnabled } from "../config"; +import { ECOSYSTEM_LIST, isEcosystemEnabled, isSwimUsd } from "../config"; import { selectConfig } from "../core/selectors"; import { useEnvironment } from "../core/store"; import { useUserBalanceAmount } from "../hooks"; @@ -40,6 +40,7 @@ interface Props { readonly handleSelectEcosystem: (ecosystemId: EcosystemId) => void; readonly tokenOptionIds: readonly string[]; readonly selectedEcosystemId: EcosystemId; + readonly showSwimUsd?: boolean; } interface TokenProps { @@ -63,6 +64,7 @@ export const TokenSearchModal = ({ handleSelectEcosystem, selectedEcosystemId, tokenOptionIds, + showSwimUsd = false, }: Props): ReactElement => { const { t } = useTranslation(); const { tokens } = useEnvironment(selectConfig, shallow); @@ -81,7 +83,8 @@ export const TokenSearchModal = ({ const filteredTokens = tokens.filter( (token) => tokenOptionIds.includes(token.id) && - token.nativeEcosystemId === selectedEcosystemId, + (token.nativeEcosystemId === selectedEcosystemId || + (showSwimUsd && isSwimUsd(token))), ); const options = filteredTokens.map((token) => { diff --git a/apps/ui/src/components/TokenSearchModalV2.tsx b/apps/ui/src/components/TokenSearchModalV2.tsx deleted file mode 100644 index 2097adfac..000000000 --- a/apps/ui/src/components/TokenSearchModalV2.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { - EuiModalBody, - EuiModalHeader, - EuiModalHeaderTitle, - EuiSelectable, -} from "@elastic/eui"; -import type { EuiSelectableOption } from "@elastic/eui"; -import { TOKEN_PROJECTS_BY_ID } from "@swim-io/token-projects"; -import type { ReactElement } from "react"; -import { useCallback } from "react"; -import { useTranslation } from "react-i18next"; - -import { ECOSYSTEMS } from "../config"; -import type { TokenOption } from "../models"; - -import { CustomModal } from "./CustomModal"; -import { TokenConfigIcon } from "./TokenIcon"; - -type Option = EuiSelectableOption<{ readonly data: Readonly }>; - -const renderOption = (option: Option) => { - return ( - - ); -}; - -interface Props { - readonly handleClose: () => void; - readonly handleSelectTokenOption: (tokenOption: TokenOption) => void; - readonly tokenOptions: readonly TokenOption[]; -} - -export const TokenSearchModalV2 = ({ - handleClose, - handleSelectTokenOption, - tokenOptions, -}: Props): ReactElement => { - const { t } = useTranslation(); - const options = tokenOptions.map((option) => { - const { tokenConfig, ecosystemId } = option; - const ecosystem = ECOSYSTEMS[ecosystemId]; - const tokenProject = TOKEN_PROJECTS_BY_ID[tokenConfig.projectId]; - return { - label: `${tokenProject.symbol} on ${ecosystem.displayName}`, - searchableLabel: `${tokenProject.symbol} ${tokenProject.displayName} ${ecosystem.displayName}`, - showIcons: false, - data: option, - }; - }); - - const onSelectToken = useCallback( - (opts: readonly Option[]) => { - const selected = opts.find(({ checked }) => checked); - if (selected) { - handleSelectTokenOption(selected.data); - handleClose(); - } - }, - [handleSelectTokenOption, handleClose], - ); - - return ( - - - -

{t("token_search_modal.title")}

-
-
- - - - {(list, search) => ( - <> - {search} - {list} - - )} - - -
- ); -}; diff --git a/apps/ui/src/components/TokenSelectV2.tsx b/apps/ui/src/components/TokenSelectV2.tsx index 16c624aab..54321e499 100644 --- a/apps/ui/src/components/TokenSelectV2.tsx +++ b/apps/ui/src/components/TokenSelectV2.tsx @@ -1,11 +1,12 @@ -import { EuiButton } from "@elastic/eui"; +import { EuiButton, EuiFlexGroup, EuiFlexItem } from "@elastic/eui"; import type { ReactElement } from "react"; -import { useCallback, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; +import type { TokenConfig } from "../config"; import type { TokenOption } from "../models"; import { TokenConfigIcon } from "./TokenIcon"; -import { TokenSearchModalV2 } from "./TokenSearchModalV2"; +import { TokenSearchModal } from "./TokenSearchModal"; interface Props { readonly onSelectTokenOption: (tokenOption: TokenOption) => void; @@ -19,23 +20,52 @@ export const TokenSelectV2 = ({ selectedTokenOption, }: Props): ReactElement => { const [showModal, setShowModal] = useState(false); + const [selectedEcosystemId, setSelectedEcosystemId] = useState( + selectedTokenOption.ecosystemId, + ); + + const tokenOptionIds = useMemo( + () => tokenOptions.map(({ tokenConfig }) => tokenConfig.id), + [tokenOptions], + ); + + const handleSelectToken = useCallback( + (tokenConfig: TokenConfig) => + onSelectTokenOption({ + tokenConfig, + ecosystemId: selectedEcosystemId, + }), + [onSelectTokenOption, selectedEcosystemId], + ); const openModal = useCallback(() => setShowModal(true), [setShowModal]); const closeModal = useCallback(() => setShowModal(false), [setShowModal]); return ( <> - - + + + + + + {showModal && ( - )} diff --git a/apps/ui/src/components/molecules/InteractionTitle/InteractionTitle.tsx b/apps/ui/src/components/molecules/InteractionTitle/InteractionTitle.tsx index 555dd596b..dbced9a73 100644 --- a/apps/ui/src/components/molecules/InteractionTitle/InteractionTitle.tsx +++ b/apps/ui/src/components/molecules/InteractionTitle/InteractionTitle.tsx @@ -64,7 +64,7 @@ export const InteractionTitle: React.FC = ({ interaction }) => { ); } case InteractionType.RemoveUniform: { - const { minimumOutputAmounts } = interaction.params; + const { minimumOutputAmounts, exactBurnAmount } = interaction.params; const nonZeroOutputAmounts = [...minimumOutputAmounts.values()].filter( (amount) => !amount.isZero(), ); @@ -73,6 +73,7 @@ export const InteractionTitle: React.FC = ({ interaction }) => { , tokenAmounts: ( ), @@ -82,12 +83,13 @@ export const InteractionTitle: React.FC = ({ interaction }) => { ); } case InteractionType.RemoveExactBurn: { - const { minimumOutputAmount } = interaction.params; + const { minimumOutputAmount, exactBurnAmount } = interaction.params; return (
, tokenAmounts: ( = ({ interaction }) => { ); } case InteractionType.RemoveExactOutput: { - const { exactOutputAmounts } = interaction.params; + const { exactOutputAmounts, maximumBurnAmount } = interaction.params; const nonZeroOutputAmounts = [...exactOutputAmounts.values()].filter( (amount) => !amount.isZero(), ); @@ -109,6 +111,9 @@ export const InteractionTitle: React.FC = ({ interaction }) => { + ), tokenAmounts: ( ), diff --git a/apps/ui/src/components/molecules/TokenAmountInputV2.tsx b/apps/ui/src/components/molecules/TokenAmountInputV2.tsx index 79a71193d..bb3339861 100644 --- a/apps/ui/src/components/molecules/TokenAmountInputV2.tsx +++ b/apps/ui/src/components/molecules/TokenAmountInputV2.tsx @@ -14,7 +14,6 @@ import { getTokenDetailsForEcosystem } from "../../config"; import { i18next } from "../../i18n"; import type { TokenOption } from "../../models"; import { Amount } from "../../models"; -import { ConnectButton } from "../ConnectButton"; import { EuiFieldIntlNumber } from "../EuiFieldIntlNumber"; import { TokenSelectV2 } from "../TokenSelectV2"; @@ -80,7 +79,7 @@ export const TokenAmountInputV2: React.FC = ({ return ( - + = ({ )} - - - - - ); }; diff --git a/apps/ui/src/fixtures/tx/useReloadInteractionStateMutationFixture.ts b/apps/ui/src/fixtures/tx/useReloadInteractionStateMutationFixture.ts index 872f41715..b5ded97f3 100644 --- a/apps/ui/src/fixtures/tx/useReloadInteractionStateMutationFixture.ts +++ b/apps/ui/src/fixtures/tx/useReloadInteractionStateMutationFixture.ts @@ -133,7 +133,7 @@ export const SOLANA_TXS_FOR_RELOAD_INTERACTION: readonly SolanaTx[] = [ id: "53PBEMpqPraH1KFGSQfGn8JR62kndfU6iv6XqeJdDtpuEyD9FLkGjtnUZUB6TPv4H8A7kVxk2WiyEJPY7bLCNQGC", timestamp: 1656406854, interactionId: "a9747f341d116e592f6eac839b7f222d", - parsedTx: { + original: { blockTime: 1656406854, meta: { err: null, @@ -483,7 +483,7 @@ export const SOLANA_TXS_FOR_RELOAD_INTERACTION: readonly SolanaTx[] = [ id: "53mCCVJEvoERa1anMkJxm5JD3doRcMBoQVyw8ZgtJ5sMuDZsw1QaW8worMbsbWBqAhwAheURKNKA7xrafSHyDEjA", timestamp: 1656406848, interactionId: "a9747f341d116e592f6eac839b7f222d", - parsedTx: { + original: { blockTime: 1656406848, meta: { err: null, @@ -850,7 +850,7 @@ export const SOLANA_TXS_FOR_RELOAD_INTERACTION: readonly SolanaTx[] = [ id: "4LCZusMofy5oPLZe5cX5VCn4T1n6qgGsxCRhbwTVAcKSvZRvQLdeEWXJef2m5sD9u6XfRgRNRcBHJBwB48tun2eQ", timestamp: 1656406843, interactionId: "a9747f341d116e592f6eac839b7f222d", - parsedTx: { + original: { blockTime: 1656406843, meta: { err: null, @@ -1345,7 +1345,7 @@ export const SOLANA_TXS_FOR_RELOAD_INTERACTION: readonly SolanaTx[] = [ id: "5UfH9wni8vGP8Ch2KQp2JjoPKyWssFjePVpxAduFErWQVFEfF7Av3iCK9wA7CyQTWUkHZtr6ThoWxZXjr73dVQqF", timestamp: 1656406839, interactionId: "a9747f341d116e592f6eac839b7f222d", - parsedTx: { + original: { blockTime: 1656406839, meta: { err: null, @@ -1642,7 +1642,7 @@ export const SOLANA_TXS_FOR_RELOAD_INTERACTION: readonly SolanaTx[] = [ id: "reEurpv1vonjzLPpqoMWvcNV5bbJmhJwfPPM7d7PEEVcb8mN6DzZTqPtYMLcenJ6VLMa3naXe4gPzPkxurjQy4e", timestamp: 1656406836, interactionId: "a9747f341d116e592f6eac839b7f222d", - parsedTx: { + original: { blockTime: 1656406836, meta: { err: null, @@ -1812,7 +1812,7 @@ export const SOLANA_TXS_FOR_RELOAD_INTERACTION: readonly SolanaTx[] = [ id: "61FvZ4bp3Ua2ED6cv32rqZnLnW5hGDYMf6racBeoZXJaxzVUVZzEEqtut29aqeBoGwxk3Dhr7mbXY6ziVpCDiHTT", timestamp: 1656406832, interactionId: "a9747f341d116e592f6eac839b7f222d", - parsedTx: { + original: { blockTime: 1656406832, meta: { err: null, @@ -1982,36 +1982,36 @@ export const EVM_TXS_FOR_RELOAD_INTERACTION = [ id: "0xdacf9f474992e86e079b588573eff53542f1722386280c55aa71057e5771732f", timestamp: 1656406577, interactionId: "a9747f341d116e592f6eac839b7f222d", - response: { - hash: "0xdacf9f474992e86e079b588573eff53542f1722386280c55aa71057e5771732f", - type: 0, - accessList: null, - blockHash: - "0xa0cef0931f71340206080d50fd4f00fb1d924f3bd8a4ff436027f05147f2f2f2", - blockNumber: 7132783, - transactionIndex: 15, - confirmations: 60, - from: "0xb0A05611328d1068c91F58e2c83Ab4048De8CD7f", - gasPrice: { - type: "BigNumber", - hex: "0x59682f07", - }, - gasLimit: { - type: "BigNumber", - hex: "0x0191ca", - }, - to: "0xF890982f9310df57d00f659cf4fd87e65adEd8d7", - value: { - type: "BigNumber", - hex: "0x00", - }, - nonce: 41, - data: "0x0f5287b000000000000000000000000045b167cf5b14007ca0490dcfb7c4b870ec0c0aa6000000000000000000000000000000000000000000000000000000002b5c01900000000000000000000000000000000000000000000000000000000000000001a77f337a7b4a9d31232af9108048171b0b120b5cf09e06469b980e64c97f0dd400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000059080100a9747f341d116e592f6eac839b7f222d", - creates: null, - chainId: 0, - timestamp: 1656406577, - }, - receipt: { + // response: { + // hash: "0xdacf9f474992e86e079b588573eff53542f1722386280c55aa71057e5771732f", + // type: 0, + // accessList: null, + // blockHash: + // "0xa0cef0931f71340206080d50fd4f00fb1d924f3bd8a4ff436027f05147f2f2f2", + // blockNumber: 7132783, + // transactionIndex: 15, + // confirmations: 60, + // from: "0xb0A05611328d1068c91F58e2c83Ab4048De8CD7f", + // gasPrice: { + // type: "BigNumber", + // hex: "0x59682f07", + // }, + // gasLimit: { + // type: "BigNumber", + // hex: "0x0191ca", + // }, + // to: "0xF890982f9310df57d00f659cf4fd87e65adEd8d7", + // value: { + // type: "BigNumber", + // hex: "0x00", + // }, + // nonce: 41, + // data: "0x0f5287b000000000000000000000000045b167cf5b14007ca0490dcfb7c4b870ec0c0aa6000000000000000000000000000000000000000000000000000000002b5c01900000000000000000000000000000000000000000000000000000000000000001a77f337a7b4a9d31232af9108048171b0b120b5cf09e06469b980e64c97f0dd400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000059080100a9747f341d116e592f6eac839b7f222d", + // creates: null, + // chainId: 0, + // timestamp: 1656406577, + // }, + original: { to: "0xF890982f9310df57d00f659cf4fd87e65adEd8d7", from: "0xb0A05611328d1068c91F58e2c83Ab4048De8CD7f", contractAddress: null, @@ -2095,36 +2095,36 @@ export const EVM_TXS_FOR_RELOAD_INTERACTION = [ id: "0x5ddfb1925096babf7939393b62970700a4db183a5dd9ae36dfd2fc9c5d7da302", timestamp: 1656406883, interactionId: "a9747f341d116e592f6eac839b7f222d", - response: { - hash: "0x5ddfb1925096babf7939393b62970700a4db183a5dd9ae36dfd2fc9c5d7da302", - type: 0, - accessList: null, - blockHash: - "0x54d21312a280bff7c065a94a0f79c5d63067353ae3519a77127b661387d6c7f7", - blockNumber: 11039320, - transactionIndex: 1, - confirmations: 232, - from: "0xb0A05611328d1068c91F58e2c83Ab4048De8CD7f", - gasPrice: { - type: "BigNumber", - hex: "0x062b85e900", - }, - gasLimit: { - type: "BigNumber", - hex: "0x01ea1a", - }, - to: "0x61E44E506Ca5659E6c0bba9b678586fA2d729756", - value: { - type: "BigNumber", - hex: "0x00", - }, - nonce: 4, - data: "0xc687851900000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000100010000000001003c0275cf8e546d379e4af28406521092c8e7e4bc4854d9ee834f0c34cae36ae215eaedadf3f85e46ea9d0e865afc2af74998ab0c5112f8f492a5aeeef910dde20162bac3460000d2e900013b26409f8aaded3f5ddca184695aa6a0fa829b0c85caf84856324896d214ca9800000000000007752001000000000000000000000000000000000000000000000000000000002f32206c00000000000000000000000092934a8b10ddf85e81b65be1d6810544744700dc0006000000000000000000000000b0a05611328d1068c91f58e2c83ab4048de8cd7f00060000000000000000000000000000000000000000000000000000000000000000a9747f341d116e592f6eac839b7f222d", - creates: null, - chainId: 0, - timestamp: 1656406883, - }, - receipt: { + // response: { + // hash: "0x5ddfb1925096babf7939393b62970700a4db183a5dd9ae36dfd2fc9c5d7da302", + // type: 0, + // accessList: null, + // blockHash: + // "0x54d21312a280bff7c065a94a0f79c5d63067353ae3519a77127b661387d6c7f7", + // blockNumber: 11039320, + // transactionIndex: 1, + // confirmations: 232, + // from: "0xb0A05611328d1068c91F58e2c83Ab4048De8CD7f", + // gasPrice: { + // type: "BigNumber", + // hex: "0x062b85e900", + // }, + // gasLimit: { + // type: "BigNumber", + // hex: "0x01ea1a", + // }, + // to: "0x61E44E506Ca5659E6c0bba9b678586fA2d729756", + // value: { + // type: "BigNumber", + // hex: "0x00", + // }, + // nonce: 4, + // data: "0xc687851900000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000100010000000001003c0275cf8e546d379e4af28406521092c8e7e4bc4854d9ee834f0c34cae36ae215eaedadf3f85e46ea9d0e865afc2af74998ab0c5112f8f492a5aeeef910dde20162bac3460000d2e900013b26409f8aaded3f5ddca184695aa6a0fa829b0c85caf84856324896d214ca9800000000000007752001000000000000000000000000000000000000000000000000000000002f32206c00000000000000000000000092934a8b10ddf85e81b65be1d6810544744700dc0006000000000000000000000000b0a05611328d1068c91f58e2c83ab4048de8cd7f00060000000000000000000000000000000000000000000000000000000000000000a9747f341d116e592f6eac839b7f222d", + // creates: null, + // chainId: 0, + // timestamp: 1656406883, + // }, + original: { to: "0x61E44E506Ca5659E6c0bba9b678586fA2d729756", from: "0xb0A05611328d1068c91F58e2c83Ab4048De8CD7f", contractAddress: null, diff --git a/apps/ui/src/hooks/crossEcosystem/useMultipleUserBalances.ts b/apps/ui/src/hooks/crossEcosystem/useMultipleUserBalances.ts index 8a0a229a0..8748a494f 100644 --- a/apps/ui/src/hooks/crossEcosystem/useMultipleUserBalances.ts +++ b/apps/ui/src/hooks/crossEcosystem/useMultipleUserBalances.ts @@ -11,7 +11,7 @@ import { getTokenDetailsForEcosystem } from "../../config"; import { Amount } from "../../models"; import { useAptosTokenBalancesQuery } from "../aptos"; import { useErc20BalancesQuery } from "../evm"; -import { useSolanaWallet, useSplTokenAccountsQuery } from "../solana"; +import { useSolanaWallet, useUserSolanaTokenAccountsQuery } from "../solana"; const getTokenDetailsByEcosystem = ( tokenConfigs: readonly TokenConfig[], @@ -134,7 +134,7 @@ export const useMultipleUserBalances = ( acala, } = getTokenDetailsByEcosystem(tokenConfigs); const { address: solanaWalletAddress } = useSolanaWallet(); - const { data: splTokenAccounts = [] } = useSplTokenAccountsQuery(); + const { data: splTokenAccounts = [] } = useUserSolanaTokenAccountsQuery(); const solanaTokenAccounts = solana.map((tokenDetails) => solanaWalletAddress !== null ? findTokenAccountForMint( diff --git a/apps/ui/src/hooks/crossEcosystem/useUserBalances.ts b/apps/ui/src/hooks/crossEcosystem/useUserBalances.ts index 332ed6ce2..1e65bb0df 100644 --- a/apps/ui/src/hooks/crossEcosystem/useUserBalances.ts +++ b/apps/ui/src/hooks/crossEcosystem/useUserBalances.ts @@ -8,7 +8,7 @@ import { getTokenDetailsForEcosystem } from "../../config"; import { Amount } from "../../models"; import { useAptosTokenBalanceQuery } from "../aptos"; import { useErc20BalanceQuery } from "../evm"; -import { useSplUserBalance } from "../solana"; +import { useUserSolanaTokenBalance } from "../solana"; const useUserBalance = ( tokenConfig: TokenConfig | null, @@ -20,7 +20,7 @@ const useUserBalance = ( (getTokenDetailsForEcosystem(tokenConfig, APTOS_ECOSYSTEM_ID) ?? null), { enabled: ecosystemId === APTOS_ECOSYSTEM_ID }, ); - const splBalance = useSplUserBalance( + const splBalance = useUserSolanaTokenBalance( tokenConfig && (getTokenDetailsForEcosystem(tokenConfig, SOLANA_ECOSYSTEM_ID) ?? null), { enabled: ecosystemId === SOLANA_ECOSYSTEM_ID }, diff --git a/apps/ui/src/hooks/crossEcosystem/useUserNativeBalances.ts b/apps/ui/src/hooks/crossEcosystem/useUserNativeBalances.ts index c4e9dab8b..a066c0f89 100644 --- a/apps/ui/src/hooks/crossEcosystem/useUserNativeBalances.ts +++ b/apps/ui/src/hooks/crossEcosystem/useUserNativeBalances.ts @@ -8,7 +8,7 @@ import type { EcosystemId } from "../../config"; import { ECOSYSTEM_IDS } from "../../config"; import { useAptosGasBalanceQuery } from "../aptos"; import { useEvmUserNativeBalanceQuery } from "../evm"; -import { useSolBalanceQuery } from "../solana"; +import { useSolanaGasBalanceQuery } from "../solana"; export const useUserNativeBalances = ( /** only fetch the ecosystems specified to reduce network calls */ @@ -17,7 +17,7 @@ export const useUserNativeBalances = ( const { data: aptBalance = new Decimal(0) } = useAptosGasBalanceQuery({ enabled: ecosystemIds.includes(APTOS_ECOSYSTEM_ID), }); - const { data: solBalance = new Decimal(0) } = useSolBalanceQuery({ + const { data: solBalance = new Decimal(0) } = useSolanaGasBalanceQuery({ enabled: ecosystemIds.includes(SOLANA_ECOSYSTEM_ID), }); const { data: ethBalance = new Decimal(0) } = useEvmUserNativeBalanceQuery( diff --git a/apps/ui/src/hooks/interaction/useAddInteractionMutation.ts b/apps/ui/src/hooks/interaction/useAddInteractionMutation.ts index 1b22e38f4..75dbf29de 100644 --- a/apps/ui/src/hooks/interaction/useAddInteractionMutation.ts +++ b/apps/ui/src/hooks/interaction/useAddInteractionMutation.ts @@ -17,7 +17,7 @@ import { import type { AddInteractionState } from "../../models"; import { useWallets } from "../crossEcosystem"; import { useGetEvmClient } from "../evm"; -import { useSolanaClient, useSplTokenAccountsQuery } from "../solana"; +import { useSolanaClient, useUserSolanaTokenAccountsQuery } from "../solana"; export const useAddInteractionMutation = () => { const queryClient = useQueryClient(); @@ -26,7 +26,8 @@ export const useAddInteractionMutation = () => { const wallets = useWallets(); const solanaClient = useSolanaClient(); const getEvmClient = useGetEvmClient(); - const { data: existingSplTokenAccounts = [] } = useSplTokenAccountsQuery(); + const { data: existingSplTokenAccounts = [] } = + useUserSolanaTokenAccountsQuery(); const { updateInteractionState } = useInteractionStateV2(); const tokensByPoolId = getTokensByPool(config); @@ -123,24 +124,21 @@ export const useAddInteractionMutation = () => { if (tokenDetails === null) { throw new Error("Missing token detail"); } - const responses = await evmClient.approveTokenAmount({ + const approveTxGenerator = evmClient.generateErc20ApproveTxs({ atomicAmount: amount.toAtomicString(ecosystem), wallet, mintAddress: tokenDetails.address, spenderAddress: poolSpec.address, }); - await Promise.all( - responses.map(async (response) => { - const tx = await evmClient.getTx(response); - updateInteractionState(interaction.id, (draft) => { - if (draft.interactionType !== interaction.type) { - throw new Error("Interaction type mismatch"); - } - draft.approvalTxIds = [...draft.approvalTxIds, tx.id]; - }); - }), - ); + for await (const result of approveTxGenerator) { + updateInteractionState(interaction.id, (draft) => { + if (draft.interactionType !== interaction.type) { + throw new Error("Interaction type mismatch"); + } + draft.approvalTxIds.push(result.tx.id); + }); + } }), ); diff --git a/apps/ui/src/hooks/interaction/useCreateInteractionState.test.ts b/apps/ui/src/hooks/interaction/useCreateInteractionState.test.ts index b60504750..79d730544 100644 --- a/apps/ui/src/hooks/interaction/useCreateInteractionState.test.ts +++ b/apps/ui/src/hooks/interaction/useCreateInteractionState.test.ts @@ -17,7 +17,7 @@ import { import { Amount, InteractionType } from "../../models"; import { mockOf, renderHookWithAppContext } from "../../testUtils"; import { useWallets } from "../crossEcosystem"; -import { useSolanaWallet, useSplTokenAccountsQuery } from "../solana"; +import { useSolanaWallet, useUserSolanaTokenAccountsQuery } from "../solana"; import { usePoolMathByPoolIds } from "../swim"; import { useCreateInteractionState } from "./useCreateInteractionState"; @@ -37,7 +37,7 @@ jest.mock("../crossEcosystem", () => ({ jest.mock("../solana", () => ({ ...jest.requireActual("../solana"), useSolanaWallet: jest.fn(), - useSplTokenAccountsQuery: jest.fn(), + useUserSolanaTokenAccountsQuery: jest.fn(), })); jest.mock("../swim", () => ({ @@ -48,7 +48,9 @@ jest.mock("../swim", () => ({ // Make typescript happy with jest const useSolanaWalletMock = mockOf(useSolanaWallet); const usePoolMathByPoolIdsMock = mockOf(usePoolMathByPoolIds); -const useSplTokenAccountsQueryMock = mockOf(useSplTokenAccountsQuery); +const useUserSolanaTokenAccountsQueryMock = mockOf( + useUserSolanaTokenAccountsQuery, +); const useWalletsMock = mockOf(useWallets); describe("useCreateInteractionState", () => { @@ -64,7 +66,9 @@ describe("useCreateInteractionState", () => { address: "6sbzC1eH4FTujJXWj51eQe25cYvr4xfXbJ1vAj7j2k5J", }); usePoolMathByPoolIdsMock.mockReturnValue(MOCK_POOL_MATHS_BY_ID); - useSplTokenAccountsQueryMock.mockReturnValue({ data: MOCK_TOKEN_ACCOUNTS }); + useUserSolanaTokenAccountsQueryMock.mockReturnValue({ + data: MOCK_TOKEN_ACCOUNTS, + }); useWalletsMock.mockReturnValue(MOCK_WALLETS); }); @@ -105,7 +109,7 @@ describe("useCreateInteractionState", () => { }); it("create state from SOLANA USDC to ETHEREUM USDC", () => { - useSplTokenAccountsQueryMock.mockReturnValue({ data: [] }); + useUserSolanaTokenAccountsQueryMock.mockReturnValue({ data: [] }); const { result } = renderHookWithAppContext(() => useCreateInteractionState(), ); @@ -142,7 +146,7 @@ describe("useCreateInteractionState", () => { }); it("create state from BNB USDT to ETHEREUM USDC", () => { - useSplTokenAccountsQueryMock.mockReturnValue({ data: [] }); + useUserSolanaTokenAccountsQueryMock.mockReturnValue({ data: [] }); const { result } = renderHookWithAppContext(() => useCreateInteractionState(), ); diff --git a/apps/ui/src/hooks/interaction/useCreateInteractionState.ts b/apps/ui/src/hooks/interaction/useCreateInteractionState.ts index 925ebc277..d12520ed0 100644 --- a/apps/ui/src/hooks/interaction/useCreateInteractionState.ts +++ b/apps/ui/src/hooks/interaction/useCreateInteractionState.ts @@ -30,7 +30,7 @@ import { getTokensByPool, } from "../../models"; import { useWallets } from "../crossEcosystem"; -import { useSolanaWallet, useSplTokenAccountsQuery } from "../solana"; +import { useSolanaWallet, useUserSolanaTokenAccountsQuery } from "../solana"; import { usePoolMathByPoolIds } from "../swim"; export const createRequiredSplTokenAccounts = ( @@ -203,7 +203,7 @@ export const useCreateInteractionState = () => { const wallets = useWallets(); const { env } = useEnvironment(); const tokensByPoolId = getTokensByPool(config); - const { data: tokenAccounts = [] } = useSplTokenAccountsQuery(); + const { data: tokenAccounts = [] } = useUserSolanaTokenAccountsQuery(); const { address: walletAddress } = useSolanaWallet(); const poolMathsByPoolId = usePoolMathByPoolIds(); diff --git a/apps/ui/src/hooks/interaction/useCreateInteractionStateV2.test.ts b/apps/ui/src/hooks/interaction/useCreateInteractionStateV2.test.ts index dc0827312..c78400acf 100644 --- a/apps/ui/src/hooks/interaction/useCreateInteractionStateV2.test.ts +++ b/apps/ui/src/hooks/interaction/useCreateInteractionStateV2.test.ts @@ -10,7 +10,7 @@ import { MOCK_TOKEN_ACCOUNTS, MOCK_WALLETS } from "../../fixtures"; import { Amount, InteractionType, generateId } from "../../models"; import { mockOf, renderHookWithAppContext } from "../../testUtils"; import { useWallets } from "../crossEcosystem"; -import { useSplTokenAccountsQuery } from "../solana"; +import { useUserSolanaTokenAccountsQuery } from "../solana"; import { useCreateInteractionStateV2 } from "./useCreateInteractionStateV2"; @@ -35,12 +35,14 @@ jest.mock("../crossEcosystem", () => ({ jest.mock("../solana", () => ({ ...jest.requireActual("../solana"), - useSplTokenAccountsQuery: jest.fn(), + useUserSolanaTokenAccountsQuery: jest.fn(), })); // Make typescript happy with jest const generateIdMock = mockOf(generateId); -const useSplTokenAccountsQueryMock = mockOf(useSplTokenAccountsQuery); +const useUserSolanaTokenAccountsQueryMock = mockOf( + useUserSolanaTokenAccountsQuery, +); const useWalletsMock = mockOf(useWallets); describe("useCreateInteractionStateV2", () => { @@ -51,7 +53,9 @@ describe("useCreateInteractionStateV2", () => { envStore.current.setEnv(Env.Testnet); }); generateIdMock.mockReturnValue("11111111111111111111111111111111"); - useSplTokenAccountsQueryMock.mockReturnValue({ data: MOCK_TOKEN_ACCOUNTS }); + useUserSolanaTokenAccountsQueryMock.mockReturnValue({ + data: MOCK_TOKEN_ACCOUNTS, + }); useWalletsMock.mockReturnValue(MOCK_WALLETS); jest.spyOn(Date, "now").mockImplementation(() => 1657544558283); }); diff --git a/apps/ui/src/hooks/interaction/useCreateInteractionStateV2.ts b/apps/ui/src/hooks/interaction/useCreateInteractionStateV2.ts index 6243cf789..09ad899a4 100644 --- a/apps/ui/src/hooks/interaction/useCreateInteractionStateV2.ts +++ b/apps/ui/src/hooks/interaction/useCreateInteractionStateV2.ts @@ -29,7 +29,7 @@ import { getTokensByPool, } from "../../models"; import { useWallets } from "../crossEcosystem"; -import { useSplTokenAccountsQuery } from "../solana"; +import { useUserSolanaTokenAccountsQuery } from "../solana"; const calculateRequiredSplTokenAccounts = ( interaction: SwapInteractionV2, @@ -224,7 +224,7 @@ export const useCreateInteractionStateV2 = () => { const config = useEnvironment(selectConfig, shallow); const wallets = useWallets(); const { env } = useEnvironment(); - const { data: tokenAccounts = [] } = useSplTokenAccountsQuery(); + const { data: tokenAccounts = [] } = useUserSolanaTokenAccountsQuery(); const solanaWalletAddress = wallets[SOLANA_ECOSYSTEM_ID].address; const tokensByPoolId = getTokensByPool(config); diff --git a/apps/ui/src/hooks/interaction/useCrossChainEvmToEvmSwapInteractionMutation.ts b/apps/ui/src/hooks/interaction/useCrossChainEvmToEvmSwapInteractionMutation.ts index 85e4d9dd5..981822b5e 100644 --- a/apps/ui/src/hooks/interaction/useCrossChainEvmToEvmSwapInteractionMutation.ts +++ b/apps/ui/src/hooks/interaction/useCrossChainEvmToEvmSwapInteractionMutation.ts @@ -95,22 +95,23 @@ export const useCrossChainEvmToEvmSwapInteractionMutation = () => { fromTokenData.tokenConfig, fromTokenData.ecosystemId, ); - const approvalResponses = await fromEvmClient.approveTokenAmount({ + const approvalTxGenerator = fromEvmClient.generateErc20ApproveTxs({ atomicAmount, mintAddress: fromTokenDetails.address, wallet, spenderAddress: fromChainConfig.routingContractAddress, }); - const approvalTxs = await fromEvmClient.getTxs(approvalResponses); - updateInteractionState(interaction.id, (draft) => { - if ( - draft.interactionType !== InteractionType.SwapV2 || - draft.swapType !== SwapType.CrossChainEvmToEvm - ) { - throw new Error("Interaction type mismatch"); - } - draft.approvalTxIds = approvalTxs.map((tx) => tx.id); - }); + for await (const result of approvalTxGenerator) { + updateInteractionState(interaction.id, (draft) => { + if ( + draft.interactionType !== InteractionType.SwapV2 || + draft.swapType !== SwapType.CrossChainEvmToEvm + ) { + throw new Error("Interaction type mismatch"); + } + draft.approvalTxIds.push(result.tx.id); + }); + } const crossChainInitiateRequest = await fromRouting.populateTransaction[ "crossChainInitiate(address,uint256,uint256,uint16,bytes32,bytes16)" ]( @@ -144,7 +145,7 @@ export const useCrossChainEvmToEvmSwapInteractionMutation = () => { crossChainInitiateTxId, ); const wormholeSequence = parseSequenceFromLogEth( - crossChainInitiateTx.receipt, + crossChainInitiateTx.original, fromChainConfig.wormhole.bridge, ); const { wormholeChainId: emitterChainId } = ECOSYSTEMS[fromEcosystem]; diff --git a/apps/ui/src/hooks/interaction/useFromSolanaTransferMutation.ts b/apps/ui/src/hooks/interaction/useFromSolanaTransferMutation.ts index 60c24960c..89f8d5370 100644 --- a/apps/ui/src/hooks/interaction/useFromSolanaTransferMutation.ts +++ b/apps/ui/src/hooks/interaction/useFromSolanaTransferMutation.ts @@ -1,6 +1,6 @@ import { getEmitterAddressSolana } from "@certusone/wormhole-sdk"; import { Keypair } from "@solana/web3.js"; -import type { SolanaClient, TokenAccount } from "@swim-io/solana"; +import type { SolanaClient, SolanaTx, TokenAccount } from "@swim-io/solana"; import { SOLANA_ECOSYSTEM_ID, findTokenAccountForMint, @@ -32,7 +32,7 @@ import { } from "../../models"; import { useWallets } from "../crossEcosystem"; import { useGetEvmClient } from "../evm"; -import { useSolanaClient, useSplTokenAccountsQuery } from "../solana"; +import { useSolanaClient, useUserSolanaTokenAccountsQuery } from "../solana"; const getTransferredAmountsByTokenId = async ( interactionState: InteractionState, @@ -60,7 +60,7 @@ const getTransferredAmountsByTokenId = async ( }; export const useFromSolanaTransferMutation = () => { - const { data: splTokenAccounts = [] } = useSplTokenAccountsQuery(); + const { data: splTokenAccounts = [] } = useUserSolanaTokenAccountsQuery(); const config = useEnvironment(selectConfig); const { chains, wormhole } = config; const getEvmClient = useGetEvmClient(); @@ -104,7 +104,7 @@ export const useFromSolanaTransferMutation = () => { config, ); - let transferSplTokenTxIds: readonly string[] = []; + let transferSplTokenTxs: readonly SolanaTx[] = []; for (const [index, transfer] of fromSolanaTransfers.entries()) { const toEcosystem = getToEcosystemOfFromSolanaTransfer( transfer, @@ -113,10 +113,8 @@ export const useFromSolanaTransferMutation = () => { const { token, txIds } = transfer; // Transfer already completed, skip if (txIds.transferSplToken !== null) { - transferSplTokenTxIds = [ - ...transferSplTokenTxIds, - txIds.transferSplToken, - ]; + const tx = await solanaClient.getTx(txIds.transferSplToken); + transferSplTokenTxs = [...transferSplTokenTxs, tx]; continue; } @@ -152,32 +150,30 @@ export const useFromSolanaTransferMutation = () => { throw new Error("Missing SPL token account"); } - let transferSplTokenTxId = transfer.txIds.transferSplToken; - if (transferSplTokenTxId === null) { + if (transfer.txIds.transferSplToken === null) { // No existing tx const auxiliarySigner = Keypair.generate(); - transferSplTokenTxId = await solanaClient.initiateWormholeTransfer({ - atomicAmount: amount.toAtomicString(SOLANA_ECOSYSTEM_ID), - interactionId, - targetAddress: formatWormholeAddress( - evmEcosystem.protocol, - evmWalletAddress, - ), - targetChainId: evmEcosystem.wormholeChainId, - tokenProjectId: token.projectId, - wallet: solanaWallet, - auxiliarySigner, - wrappedTokenInfo: getWrappedTokenInfo(token, SOLANA_ECOSYSTEM_ID), - }); - // Update transfer state with txId - updateInteractionState(interactionId, (draft) => { - draft.fromSolanaTransfers[index].txIds.transferSplToken = - transferSplTokenTxId; - }); - transferSplTokenTxIds = [ - ...transferSplTokenTxIds, - transferSplTokenTxId, - ]; + const initiateTransferTxGenerator = + solanaClient.generateInitiatePortalTransferTxs({ + atomicAmount: amount.toAtomicString(SOLANA_ECOSYSTEM_ID), + interactionId, + targetAddress: formatWormholeAddress( + evmEcosystem.protocol, + evmWalletAddress, + ), + targetChainId: evmEcosystem.wormholeChainId, + tokenProjectId: token.projectId, + wallet: solanaWallet, + auxiliarySigner, + wrappedTokenInfo: getWrappedTokenInfo(token, SOLANA_ECOSYSTEM_ID), + }); + for await (const result of initiateTransferTxGenerator) { + transferSplTokenTxs = [...transferSplTokenTxs, result.tx]; + updateInteractionState(interactionId, (draft) => { + draft.fromSolanaTransfers[index].txIds.transferSplToken = + result.tx.id; + }); + } } } @@ -190,7 +186,7 @@ export const useFromSolanaTransferMutation = () => { transfer, interaction, ); - const transferSplTokenTxId = transferSplTokenTxIds[index]; + const transferTx = transferSplTokenTxs[index]; const evmWallet = wallets[toEcosystem].wallet; if (!evmWallet) { throw new Error("No EVM wallet"); @@ -199,8 +195,7 @@ export const useFromSolanaTransferMutation = () => { chains[Protocol.Evm], ({ ecosystem }) => ecosystem === toEcosystem, ); - const transferTx = await solanaClient.getTx(transferSplTokenTxId); - const sequence = parseSequenceFromLogSolana(transferTx.parsedTx); + const sequence = parseSequenceFromLogSolana(transferTx.original); const emitterAddress = await getEmitterAddressSolana( solanaWormhole.portal, ); @@ -217,21 +212,17 @@ export const useFromSolanaTransferMutation = () => { ); await evmWallet.switchNetwork(evmChain.chainId); const evmClient = getEvmClient(toEcosystem); - const redeemResponse = await evmClient.completeWormholeTransfer({ - interactionId, - vaa: vaaBytesResponse.vaaBytes, - wallet: evmWallet, - }); - if (redeemResponse === null) { - throw new Error( - `Transaction not found: (unlock/mint on ${evmChain.ecosystem})`, - ); + const completeTransferTxGenerator = + evmClient.generateCompletePortalTransferTxs({ + interactionId, + vaa: vaaBytesResponse.vaaBytes, + wallet: evmWallet, + }); + for await (const result of completeTransferTxGenerator) { + updateInteractionState(interactionId, (draft) => { + draft.fromSolanaTransfers[index].txIds.claimTokenOnEvm = result.tx.id; + }); } - // Update transfer state with txId - updateInteractionState(interactionId, (draft) => { - draft.fromSolanaTransfers[index].txIds.claimTokenOnEvm = - redeemResponse.hash; - }); } }); }; diff --git a/apps/ui/src/hooks/interaction/usePrepareSplTokenAccountMutation.ts b/apps/ui/src/hooks/interaction/usePrepareSplTokenAccountMutation.ts index cd4d6a43a..ac1975e28 100644 --- a/apps/ui/src/hooks/interaction/usePrepareSplTokenAccountMutation.ts +++ b/apps/ui/src/hooks/interaction/usePrepareSplTokenAccountMutation.ts @@ -4,10 +4,10 @@ import { useMutation, useQueryClient } from "react-query"; import { selectGetInteractionState } from "../../core/selectors"; import { useInteractionState } from "../../core/store"; import { - getSplTokenAccountsQueryKey, + getUserSolanaTokenAccountsQueryKey, useSolanaClient, useSolanaWallet, - useSplTokenAccountsQuery, + useUserSolanaTokenAccountsQuery, } from "../solana"; export const usePrepareSplTokenAccountMutation = () => { @@ -18,7 +18,7 @@ export const usePrepareSplTokenAccountMutation = () => { ); const getInteractionState = useInteractionState(selectGetInteractionState); const queryClient = useQueryClient(); - const { data: splTokenAccounts = [] } = useSplTokenAccountsQuery(); + const { data: splTokenAccounts = [] } = useUserSolanaTokenAccountsQuery(); return useMutation(async (interactionId: string) => { if (wallet === null) { @@ -56,7 +56,7 @@ export const usePrepareSplTokenAccountMutation = () => { ); if (missingAccountMints.length > 0) { - const splTokenAccountsQueryKey = getSplTokenAccountsQueryKey( + const splTokenAccountsQueryKey = getUserSolanaTokenAccountsQueryKey( interaction.env, solanaAddress, ); diff --git a/apps/ui/src/hooks/interaction/useReloadInteractionStateMutation.test.ts b/apps/ui/src/hooks/interaction/useReloadInteractionStateMutation.test.ts index 0e086ae14..e35c324af 100644 --- a/apps/ui/src/hooks/interaction/useReloadInteractionStateMutation.test.ts +++ b/apps/ui/src/hooks/interaction/useReloadInteractionStateMutation.test.ts @@ -12,12 +12,12 @@ import { SOLANA_TXS_FOR_RELOAD_INTERACTION, } from "../../fixtures/tx/useReloadInteractionStateMutationFixture"; import { - fetchEvmTxForInteractionId, + fetchEvmTxsForInteractionId, fetchSolanaTxsForInteractionId, } from "../../models"; import { mockOf, renderHookWithAppContext } from "../../testUtils"; import { useEvmWallet } from "../evm"; -import { useSolanaWallet, useSplTokenAccountsQuery } from "../solana"; +import { useSolanaWallet, useUserSolanaTokenAccountsQuery } from "../solana"; import { useReloadInteractionStateMutation } from "./useReloadInteractionStateMutation"; @@ -33,18 +33,20 @@ const useEvmWalletMock = mockOf(useEvmWallet); jest.mock("../solana", () => ({ ...jest.requireActual("../solana"), useSolanaWallet: jest.fn(), - useSplTokenAccountsQuery: jest.fn(), + useUserSolanaTokenAccountsQuery: jest.fn(), })); const useSolanaWalletMock = mockOf(useSolanaWallet); -const useSplTokenAccountsQueryMock = mockOf(useSplTokenAccountsQuery); +const useUserSolanaTokenAccountsQueryMock = mockOf( + useUserSolanaTokenAccountsQuery, +); jest.mock("../../models", () => ({ ...jest.requireActual("../../models"), - fetchEvmTxForInteractionId: jest.fn(), + fetchEvmTxsForInteractionId: jest.fn(), fetchSolanaTxsForInteractionId: jest.fn(), EvmConnection: jest.fn(), })); -const fetchEvmTxForInteractionIdMock = mockOf(fetchEvmTxForInteractionId); +const fetchEvmTxsForInteractionIdMock = mockOf(fetchEvmTxsForInteractionId); const fetchSolanaTxsForInteractionIdMock = mockOf( fetchSolanaTxsForInteractionId, ); @@ -68,7 +70,7 @@ describe("useReloadInteractionStateMutation", () => { }); it("should reload recent tx and recover interaction state", async () => { - useSplTokenAccountsQueryMock.mockReturnValue({ + useUserSolanaTokenAccountsQueryMock.mockReturnValue({ data: [ { mint: new PublicKey("7Lf95y8NuCU5RRC95oUtbBtckPAtbr9ubTgrCiyZ1kEf"), @@ -90,7 +92,7 @@ describe("useReloadInteractionStateMutation", () => { useEvmWalletMock.mockReturnValue({ address: "0xb0a05611328d1068c91f58e2c83ab4048de8cd7f", }); - fetchEvmTxForInteractionIdMock.mockReturnValue( + fetchEvmTxsForInteractionIdMock.mockReturnValue( Promise.resolve(EVM_TXS_FOR_RELOAD_INTERACTION), ); fetchSolanaTxsForInteractionIdMock.mockReturnValue( diff --git a/apps/ui/src/hooks/interaction/useReloadInteractionStateMutation.ts b/apps/ui/src/hooks/interaction/useReloadInteractionStateMutation.ts index 900ec91cc..235afe098 100644 --- a/apps/ui/src/hooks/interaction/useReloadInteractionStateMutation.ts +++ b/apps/ui/src/hooks/interaction/useReloadInteractionStateMutation.ts @@ -7,7 +7,7 @@ import { Protocol, getSolanaTokenDetails } from "../../config"; import { selectConfig, selectGetInteractionState } from "../../core/selectors"; import { useEnvironment, useInteractionState } from "../../core/store"; import { - fetchEvmTxForInteractionId, + fetchEvmTxsForInteractionId, fetchSolanaTxsForInteractionId, getFromEcosystemOfToSolanaTransfer, getRequiredEcosystems, @@ -25,13 +25,13 @@ import { useEvmWallet, useGetEvmClient } from "../evm"; import { useSolanaClient, useSolanaWallet, - useSplTokenAccountsQuery, + useUserSolanaTokenAccountsQuery, } from "../solana"; export const useReloadInteractionStateMutation = () => { const queryClient = useQueryClient(); const getEvmClient = useGetEvmClient(); - const { data: splTokenAccounts = [] } = useSplTokenAccountsQuery(); + const { data: splTokenAccounts = [] } = useUserSolanaTokenAccountsQuery(); const solanaClient = useSolanaClient(); const { address: solanaAddress } = useSolanaWallet(); const { address: evmAddress } = useEvmWallet(); @@ -80,7 +80,7 @@ export const useReloadInteractionStateMutation = () => { ); // Get other evm tx - const evmTxs = await fetchEvmTxForInteractionId( + const evmTxs = await fetchEvmTxsForInteractionId( interactionId, queryClient, interaction.env, @@ -170,7 +170,7 @@ export const useReloadInteractionStateMutation = () => { const match = solanaTxs.find( (solanaTx) => isPoolTx(poolSpec.contract, solanaTx) && - solanaTx.parsedTx.transaction.message.accountKeys.some( + solanaTx.original.transaction.message.accountKeys.some( (key) => key.pubkey.toBase58() === poolSpec.address, ), ); diff --git a/apps/ui/src/hooks/interaction/useRemoveInteractionMutation.ts b/apps/ui/src/hooks/interaction/useRemoveInteractionMutation.ts index 1cd073fe3..1f4ea6ea2 100644 --- a/apps/ui/src/hooks/interaction/useRemoveInteractionMutation.ts +++ b/apps/ui/src/hooks/interaction/useRemoveInteractionMutation.ts @@ -25,7 +25,7 @@ import { } from "../../models"; import { useWallets } from "../crossEcosystem"; import { useGetEvmClient } from "../evm"; -import { useSolanaClient, useSplTokenAccountsQuery } from "../solana"; +import { useSolanaClient, useUserSolanaTokenAccountsQuery } from "../solana"; const getPopulatedTxForEvmRemoveInteraction = ( interaction: @@ -90,7 +90,8 @@ export const useRemoveInteractionMutation = () => { const wallets = useWallets(); const solanaClient = useSolanaClient(); const getEvmClient = useGetEvmClient(); - const { data: existingSplTokenAccounts = [] } = useSplTokenAccountsQuery(); + const { data: existingSplTokenAccounts = [] } = + useUserSolanaTokenAccountsQuery(); const { updateInteractionState } = useInteractionStateV2(); const tokensByPoolId = getTokensByPool(config); @@ -183,23 +184,20 @@ export const useRemoveInteractionMutation = () => { if (tokenDetails === null) { throw new Error("Missing token detail"); } - const approvalResponses = await evmClient.approveTokenAmount({ + const approveTxGenerator = evmClient.generateErc20ApproveTxs({ atomicAmount: removeAmount.toAtomicString(ecosystem), wallet, mintAddress: tokenDetails.address, spenderAddress: poolSpec.address, }); - await Promise.all( - approvalResponses.map(async (response) => { - const tx = await evmClient.getTx(response); - updateInteractionState(interaction.id, (draft) => { - if (draft.interactionType !== interaction.type) { - throw new Error("Interaction type mismatch"); - } - draft.approvalTxIds.push(tx.id); - }); - }), - ); + for await (const result of approveTxGenerator) { + updateInteractionState(interaction.id, (draft) => { + if (draft.interactionType !== interaction.type) { + throw new Error("Interaction type mismatch"); + } + draft.approvalTxIds.push(result.tx.id); + }); + } const poolContract = Pool__factory.connect(poolSpec.address, signer); const txRequest = await getPopulatedTxForEvmRemoveInteraction( diff --git a/apps/ui/src/hooks/interaction/useSingleChainEvmSwapInteractionMutation.ts b/apps/ui/src/hooks/interaction/useSingleChainEvmSwapInteractionMutation.ts index fc91308f4..1e8582abd 100644 --- a/apps/ui/src/hooks/interaction/useSingleChainEvmSwapInteractionMutation.ts +++ b/apps/ui/src/hooks/interaction/useSingleChainEvmSwapInteractionMutation.ts @@ -66,27 +66,24 @@ export const useSingleChainEvmSwapInteractionMutation = () => { fromTokenSpec, fromTokenData.ecosystemId, ); - const approvalResponses = await client.approveTokenAmount({ + const approveTxGenerator = client.generateErc20ApproveTxs({ atomicAmount: inputAmountAtomicString, wallet, mintAddress: tokenDetails.address, spenderAddress: routingContractAddress, }); - await Promise.all( - approvalResponses.map(async (response) => { - const tx = await client.getTx(response); - updateInteractionState(interaction.id, (draft) => { - if ( - draft.interactionType !== InteractionType.SwapV2 || - draft.swapType !== SwapType.SingleChainEvm - ) { - throw new Error("Interaction type mismatch"); - } - draft.approvalTxIds.push(tx.id); - }); - }), - ); + for await (const result of approveTxGenerator) { + updateInteractionState(interaction.id, (draft) => { + if ( + draft.interactionType !== InteractionType.SwapV2 || + draft.swapType !== SwapType.SingleChainEvm + ) { + throw new Error("Interaction type mismatch"); + } + draft.approvalTxIds.push(result.tx.id); + }); + } const routingContract = Routing__factory.connect( routingContractAddress, diff --git a/apps/ui/src/hooks/interaction/useSingleChainSolanaSwapInteractionMutation.ts b/apps/ui/src/hooks/interaction/useSingleChainSolanaSwapInteractionMutation.ts index 3b0b6cf8d..db7d60f72 100644 --- a/apps/ui/src/hooks/interaction/useSingleChainSolanaSwapInteractionMutation.ts +++ b/apps/ui/src/hooks/interaction/useSingleChainSolanaSwapInteractionMutation.ts @@ -23,7 +23,7 @@ import { getTokensByPool, } from "../../models"; import { useWallets } from "../crossEcosystem"; -import { useSolanaClient, useSplTokenAccountsQuery } from "../solana"; +import { useSolanaClient, useUserSolanaTokenAccountsQuery } from "../solana"; import { useSwimUsd } from "../swim"; const createOperationSpec = ( @@ -101,7 +101,8 @@ export const useSingleChainSolanaSwapInteractionMutation = () => { const config = useEnvironment(selectConfig, shallow); const wallets = useWallets(); const solanaClient = useSolanaClient(); - const { data: existingSplTokenAccounts = [] } = useSplTokenAccountsQuery(); + const { data: existingSplTokenAccounts = [] } = + useUserSolanaTokenAccountsQuery(); const { updateInteractionState } = useInteractionStateV2(); const swimUsd = useSwimUsd(); const tokensByPoolId = getTokensByPool(config); diff --git a/apps/ui/src/hooks/interaction/useSolanaPoolOperationsMutation.ts b/apps/ui/src/hooks/interaction/useSolanaPoolOperationsMutation.ts index bb96b57be..c848f5d2f 100644 --- a/apps/ui/src/hooks/interaction/useSolanaPoolOperationsMutation.ts +++ b/apps/ui/src/hooks/interaction/useSolanaPoolOperationsMutation.ts @@ -13,14 +13,14 @@ import { import { useSolanaClient, useSolanaWallet, - useSplTokenAccountsQuery, + useUserSolanaTokenAccountsQuery, } from "../solana"; export const useSolanaPoolOperationsMutation = () => { const { env } = useEnvironment(); const config = useEnvironment(selectConfig, shallow); const { pools } = config; - const { data: splTokenAccounts = [] } = useSplTokenAccountsQuery(); + const { data: splTokenAccounts = [] } = useUserSolanaTokenAccountsQuery(); const solanaClient = useSolanaClient(); const { wallet, address: solanaWalletAddress } = useSolanaWallet(); const tokensByPoolId = getTokensByPool(config); diff --git a/apps/ui/src/hooks/interaction/useToSolanaTransferMutation.test.ts b/apps/ui/src/hooks/interaction/useToSolanaTransferMutation.test.ts index 37d090567..107f8890a 100644 --- a/apps/ui/src/hooks/interaction/useToSolanaTransferMutation.test.ts +++ b/apps/ui/src/hooks/interaction/useToSolanaTransferMutation.test.ts @@ -1,7 +1,7 @@ import { PublicKey } from "@solana/web3.js"; -import { EvmEcosystemId } from "@swim-io/evm"; -import type { TokenAccount } from "@swim-io/solana"; -import { SOLANA_ECOSYSTEM_ID } from "@swim-io/solana"; +import { EvmEcosystemId, EvmTxType } from "@swim-io/evm"; +import type { SolanaTx, TokenAccount } from "@swim-io/solana"; +import { SOLANA_ECOSYSTEM_ID, SolanaTxType } from "@swim-io/solana"; import { act, renderHook } from "@testing-library/react-hooks"; import { useQueryClient } from "react-query"; @@ -14,7 +14,7 @@ import type { Wallets } from "../../models"; import { mockOf, renderHookWithAppContext } from "../../testUtils"; import { useWallets } from "../crossEcosystem"; import { useGetEvmClient } from "../evm"; -import { useSolanaClient, useSplTokenAccountsQuery } from "../solana"; +import { useSolanaClient, useUserSolanaTokenAccountsQuery } from "../solana"; import { useToSolanaTransferMutation } from "./useToSolanaTransferMutation"; @@ -42,10 +42,12 @@ const getSignedVaaWithRetryMock = mockOf(getSignedVaaWithRetry); jest.mock("../solana", () => ({ ...jest.requireActual("../solana"), useSolanaClient: jest.fn(), - useSplTokenAccountsQuery: jest.fn(), + useUserSolanaTokenAccountsQuery: jest.fn(), })); const useSolanaClientMock = mockOf(useSolanaClient); -const useSplTokenAccountsQueryMock = mockOf(useSplTokenAccountsQuery); +const useUserSolanaTokenAccountsQueryMock = mockOf( + useUserSolanaTokenAccountsQuery, +); describe("useToSolanaTransferMutation", () => { beforeEach(() => { @@ -60,7 +62,7 @@ describe("useToSolanaTransferMutation", () => { }); it("should handle the transfer and patch interactionState with txIds", async () => { - useSplTokenAccountsQueryMock.mockReturnValue({ + useUserSolanaTokenAccountsQueryMock.mockReturnValue({ data: [ { mint: new PublicKey("9idXDPGb5jfwaf5fxjiMacgUcwpy3ZHfdgqSjAV5XLDr"), @@ -75,19 +77,26 @@ describe("useToSolanaTransferMutation", () => { mint, } as unknown as TokenAccount), ), - generateCompleteWormholeTransferTxIds: jest - .fn() - .mockReturnValue([ - Promise.resolve( - "3o1NH8sMDs5m9DMoVcqD5eZRny2JrrFBohn9TwEKHXhX4Xxg6uQV7JrupVuDJcwaHBuP8fCZhv1HWBYicMixsSPg", - ), - Promise.resolve( - "3ok2VJpHqZ2EqoDGVMyugENdKawTjNbmM4sm4tHpsoF6T8BHx78fk5vZBXH7KRpgX7P43vhnMnN5zb5NSogUfCsj", - ), - Promise.resolve( - "5rYoqeehFL7j5MbMqzE8NruiUeBaRVhwFpCKsdXUnuAr6NNcPiX3XUxq72SA2MtPhtEhEDU2ZPVP9m4rmkHgy2cC", - ), - ] as Partial>), + generateCompletePortalTransferTxs: jest.fn().mockReturnValue([ + Promise.resolve({ + tx: { + id: "3o1NH8sMDs5m9DMoVcqD5eZRny2JrrFBohn9TwEKHXhX4Xxg6uQV7JrupVuDJcwaHBuP8fCZhv1HWBYicMixsSPg", + }, + type: SolanaTxType.WormholeVerifySignatures, + }), + Promise.resolve({ + tx: { + id: "3ok2VJpHqZ2EqoDGVMyugENdKawTjNbmM4sm4tHpsoF6T8BHx78fk5vZBXH7KRpgX7P43vhnMnN5zb5NSogUfCsj", + }, + type: SolanaTxType.WormholePostVaa, + }), + Promise.resolve({ + tx: { + id: "5rYoqeehFL7j5MbMqzE8NruiUeBaRVhwFpCKsdXUnuAr6NNcPiX3XUxq72SA2MtPhtEhEDU2ZPVP9m4rmkHgy2cC", + }, + type: SolanaTxType.PortalRedeem, + }), + ] as Partial>), }); useWalletsMock.mockReturnValue({ [EvmEcosystemId.Bnb]: { @@ -109,14 +118,14 @@ describe("useToSolanaTransferMutation", () => { id: hash, }), ), - initiateWormholeTransfer: jest.fn(() => { - return Promise.resolve({ - approvalResponses: [], - transferResponse: { - hash: "0xd528c49eedda9d5a5a7f04a00355b7b124a30502b46532503cc83891844715b9", + generateInitiatePortalTransferTxs: jest.fn().mockReturnValue([ + Promise.resolve({ + tx: { + id: "0xd528c49eedda9d5a5a7f04a00355b7b124a30502b46532503cc83891844715b9", }, - }); - }), + type: EvmTxType.PortalTransferTokens, + }), + ]), provider: { getTransaction: jest.fn(() => Promise.resolve({ diff --git a/apps/ui/src/hooks/interaction/useToSolanaTransferMutation.ts b/apps/ui/src/hooks/interaction/useToSolanaTransferMutation.ts index a9231e96c..1662c04e3 100644 --- a/apps/ui/src/hooks/interaction/useToSolanaTransferMutation.ts +++ b/apps/ui/src/hooks/interaction/useToSolanaTransferMutation.ts @@ -3,7 +3,13 @@ import { parseSequenceFromLogEth, } from "@certusone/wormhole-sdk"; import { Keypair } from "@solana/web3.js"; -import { SOLANA_ECOSYSTEM_ID, findTokenAccountForMint } from "@swim-io/solana"; +import type { EvmTx } from "@swim-io/evm"; +import { EvmTxType } from "@swim-io/evm"; +import { + SOLANA_ECOSYSTEM_ID, + SolanaTxType, + findTokenAccountForMint, +} from "@swim-io/solana"; import { findOrThrow } from "@swim-io/utils"; import { WormholeChainId } from "@swim-io/wormhole"; import { useMutation } from "react-query"; @@ -26,10 +32,10 @@ import { } from "../../models"; import { useWallets } from "../crossEcosystem"; import { useGetEvmClient } from "../evm"; -import { useSolanaClient, useSplTokenAccountsQuery } from "../solana"; +import { useSolanaClient, useUserSolanaTokenAccountsQuery } from "../solana"; export const useToSolanaTransferMutation = () => { - const { data: splTokenAccounts = [] } = useSplTokenAccountsQuery(); + const { data: splTokenAccounts = [] } = useUserSolanaTokenAccountsQuery(); const { chains, wormhole } = useEnvironment(selectConfig, shallow); const getEvmClient = useGetEvmClient(); const solanaClient = useSolanaClient(); @@ -62,7 +68,7 @@ export const useToSolanaTransferMutation = () => { ); const evmClients = fromEcosystems.map(getEvmClient); - let transferTxIds: readonly string[] = []; + let transferTxs: readonly EvmTx[] = []; for (const [index, transfer] of toSolanaTransfers.entries()) { const { token, value, txIds } = transfer; const fromEcosystem = getFromEcosystemOfToSolanaTransfer( @@ -71,12 +77,12 @@ export const useToSolanaTransferMutation = () => { ); // Transfer completed, skip if (txIds.approveAndTransferEvmToken.length > 0) { - transferTxIds = [ - ...transferTxIds, + const transferTx = await evmClients[index].getTx( txIds.approveAndTransferEvmToken[ txIds.approveAndTransferEvmToken.length - 1 ], - ]; + ); + transferTxs = [...transferTxs, transferTx]; continue; } const evmWallet = wallets[fromEcosystem].wallet; @@ -94,9 +100,9 @@ export const useToSolanaTransferMutation = () => { } // Process transfer if transfer txId does not exist - const { approvalResponses, transferResponse } = await evmClients[ + const evmTxGenerator = evmClients[ index - ].initiateWormholeTransfer({ + ].generateInitiatePortalTransferTxs({ atomicAmount: humanDecimalToAtomicString(value, token, fromEcosystem), interactionId, targetAddress: formatWormholeAddress( @@ -109,31 +115,35 @@ export const useToSolanaTransferMutation = () => { wrappedTokenInfo: getWrappedTokenInfo(token, fromEcosystem), }); - // Update transfer state with txId - const approveAndTransferEvmTokenTxIds = [ - ...approvalResponses, - transferResponse, - ].map(({ hash }) => hash); - updateInteractionState(interactionId, (draft) => { - draft.toSolanaTransfers[index].txIds.approveAndTransferEvmToken = - approveAndTransferEvmTokenTxIds; - }); - transferTxIds = [...transferTxIds, transferResponse.hash]; + for await (const result of evmTxGenerator) { + switch (result.type) { + case EvmTxType.PortalTransferTokens: + case EvmTxType.Erc20Approve: + if (result.type === EvmTxType.PortalTransferTokens) { + transferTxs = [...transferTxs, result.tx]; + } + updateInteractionState(interactionId, (draft) => { + draft.toSolanaTransfers[ + index + ].txIds.approveAndTransferEvmToken.push(result.tx.id); + }); + break; + default: + throw new Error(`Unexpected transaction type: ${result.tx.id}`); + } + } } - const sequences = await Promise.all( - toSolanaTransfers.map(async (transfer, index) => { - // Claim token completed, skip - if (transfer.txIds.claimTokenOnSolana !== null) { - return null; - } - const transferTx = await evmClients[index].getTx(transferTxIds[index]); - return parseSequenceFromLogEth( - transferTx.receipt, - evmChains[index].wormhole.bridge, - ); - }), - ); + const sequences = toSolanaTransfers.map((transfer, index) => { + // Claim token completed, skip + if (transfer.txIds.claimTokenOnSolana !== null) { + return null; + } + return parseSequenceFromLogEth( + transferTxs[index].original, + evmChains[index].wormhole.bridge, + ); + }); for (const [index, transfer] of toSolanaTransfers.entries()) { const fromEcosystem = getFromEcosystemOfToSolanaTransfer( @@ -167,25 +177,32 @@ export const useToSolanaTransferMutation = () => { retries, ); const unlockSplTokenTxIdsGenerator = - solanaClient.generateCompleteWormholeTransferTxIds({ + solanaClient.generateCompletePortalTransferTxs({ interactionId, vaa, wallet: solanaWallet, auxiliarySigner, }); - let unlockSplTokenTxIds: readonly string[] = []; - for await (const txId of unlockSplTokenTxIdsGenerator) { - unlockSplTokenTxIds = [...unlockSplTokenTxIds, txId]; + for await (const result of unlockSplTokenTxIdsGenerator) { + switch (result.type) { + case SolanaTxType.WormholeVerifySignatures: + case SolanaTxType.WormholePostVaa: + updateInteractionState(interactionId, (draft) => { + draft.toSolanaTransfers[index].txIds.postVaaOnSolana.push( + result.tx.id, + ); + }); + break; + case SolanaTxType.PortalRedeem: + updateInteractionState(interactionId, (draft) => { + draft.toSolanaTransfers[index].txIds.claimTokenOnSolana = + result.tx.id; + }); + break; + default: + throw new Error(`Unexpected transaction type: ${result.tx.id}`); + } } - // Update transfer state with txId - const postVaaOnSolanaTxIds = unlockSplTokenTxIds.slice(0, -1); - const [claimTokenOnSolanaTxId] = unlockSplTokenTxIds.slice(-1); - updateInteractionState(interactionId, (draft) => { - draft.toSolanaTransfers[index].txIds.postVaaOnSolana = - postVaaOnSolanaTxIds; - draft.toSolanaTransfers[index].txIds.claimTokenOnSolana = - claimTokenOnSolanaTxId; - }); } }); }; diff --git a/apps/ui/src/hooks/solana/index.ts b/apps/ui/src/hooks/solana/index.ts index 53241b3b8..5023809c8 100644 --- a/apps/ui/src/hooks/solana/index.ts +++ b/apps/ui/src/hooks/solana/index.ts @@ -1,8 +1,8 @@ export * from "./useAnchorProvider"; export * from "./useCreateSplTokenAccountsMutation"; -export * from "./useSolanaLiquidityQuery"; export * from "./useSolanaClient"; +export * from "./useSolanaGasBalanceQuery"; +export * from "./useSolanaTokenAccountQueries"; export * from "./useSolanaWallet"; -export * from "./useSolBalanceQuery"; -export * from "./useSplTokenAccountsQuery"; -export * from "./useSplUserBalance"; +export * from "./useUserSolanaTokenAccountsQuery"; +export * from "./useUserSolanaTokenBalance"; diff --git a/apps/ui/src/hooks/solana/useCreateSplTokenAccountsMutation.ts b/apps/ui/src/hooks/solana/useCreateSplTokenAccountsMutation.ts index e949a1ab2..0f16a4dfb 100644 --- a/apps/ui/src/hooks/solana/useCreateSplTokenAccountsMutation.ts +++ b/apps/ui/src/hooks/solana/useCreateSplTokenAccountsMutation.ts @@ -7,7 +7,10 @@ import { findOrCreateSplTokenAccount } from "../../models"; import { useSolanaClient } from "./useSolanaClient"; import { useSolanaWallet } from "./useSolanaWallet"; -import { useSplTokenAccountsQuery } from "./useSplTokenAccountsQuery"; +import { + getUserSolanaTokenAccountsQueryKey, + useUserSolanaTokenAccountsQuery, +} from "./useUserSolanaTokenAccountsQuery"; export const useCreateSplTokenAccountsMutation = (): UseMutationResult< readonly TokenAccount[], @@ -18,7 +21,7 @@ export const useCreateSplTokenAccountsMutation = (): UseMutationResult< const queryClient = useQueryClient(); const solanaClient = useSolanaClient(); const { wallet, address } = useSolanaWallet(); - const { data: splTokenAccounts = null } = useSplTokenAccountsQuery(); + const { data: splTokenAccounts = null } = useUserSolanaTokenAccountsQuery(); return useMutation( async (mints: readonly string[]): Promise => { @@ -42,7 +45,9 @@ export const useCreateSplTokenAccountsMutation = (): UseMutationResult< }), ), ); - await queryClient.invalidateQueries([env, "tokenAccounts", address]); + await queryClient.invalidateQueries( + getUserSolanaTokenAccountsQueryKey(env, address), + ); return tokenAccountData.map((data) => data.tokenAccount); }, ); diff --git a/apps/ui/src/hooks/solana/useSolBalanceQuery.ts b/apps/ui/src/hooks/solana/useSolBalanceQuery.ts deleted file mode 100644 index 4a274c501..000000000 --- a/apps/ui/src/hooks/solana/useSolBalanceQuery.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { LAMPORTS_PER_SOL, PublicKey } from "@solana/web3.js"; -import Decimal from "decimal.js"; -import { useEffect, useMemo } from "react"; -import type { UseQueryOptions, UseQueryResult } from "react-query"; -import { useQuery, useQueryClient } from "react-query"; - -import { useEnvironment } from "../../core/store"; - -import { useSolanaClient } from "./useSolanaClient"; -import { useSolanaWallet } from "./useSolanaWallet"; - -const lamportsToSol = (balance: Decimal.Value): Decimal => { - return new Decimal(balance).dividedBy(LAMPORTS_PER_SOL); -}; - -// Returns user's Solana balance in SOL. -export const useSolBalanceQuery = ( - options?: Omit, "staleTime">, -): UseQueryResult => { - const { env } = useEnvironment(); - const solanaClient = useSolanaClient(); - const { address: walletAddress } = useSolanaWallet(); - - const queryClient = useQueryClient(); - const queryKey = useMemo( - () => [env, "solBalance", walletAddress], - [env, walletAddress], - ); - - useEffect(() => { - if (!walletAddress || !options?.enabled) { - return; - } - - // Make sure all network requests are ignored after exit, so the state is not mixed, e.g. state between different wallet addresses - let isExited = false; - - const clientSubscriptionId = solanaClient.connection.onAccountChange( - new PublicKey(walletAddress), - (accountInfo) => { - if (isExited) return; - - queryClient.setQueryData(queryKey, lamportsToSol(accountInfo.lamports)); - }, - ); - return () => { - isExited = true; - - solanaClient.connection - .removeAccountChangeListener(clientSubscriptionId) - .catch(console.error); - }; - }, [ - options?.enabled, - queryClient, - queryKey, - // make sure we are depending on `solanaClient.connection` not `solanaClient` because the reference of `solanaClient` won't change when we rotate the connection - solanaClient.connection, - walletAddress, - ]); - - return useQuery( - queryKey, - async () => { - if (!walletAddress) { - return new Decimal(0); - } - try { - const balance = await solanaClient.connection.getBalance( - new PublicKey(walletAddress), - ); - return lamportsToSol(balance); - } catch { - return new Decimal(0); - } - }, - { - ...options, - // rely on websocket to update outdated data - staleTime: Infinity, - }, - ); -}; diff --git a/apps/ui/src/hooks/solana/useSolBalanceQuery.test.ts b/apps/ui/src/hooks/solana/useSolanaGasBalanceQuery.test.ts similarity index 85% rename from apps/ui/src/hooks/solana/useSolBalanceQuery.test.ts rename to apps/ui/src/hooks/solana/useSolanaGasBalanceQuery.test.ts index bcda26b1f..6355cc97f 100644 --- a/apps/ui/src/hooks/solana/useSolBalanceQuery.test.ts +++ b/apps/ui/src/hooks/solana/useSolanaGasBalanceQuery.test.ts @@ -5,8 +5,8 @@ import { useQueryClient } from "react-query"; import { mockOf, renderHookWithAppContext } from "../../testUtils"; -import { useSolBalanceQuery } from "./useSolBalanceQuery"; import { useSolanaClient } from "./useSolanaClient"; +import { useSolanaGasBalanceQuery } from "./useSolanaGasBalanceQuery"; import { useSolanaWallet } from "./useSolanaWallet"; jest.mock("./useSolanaClient", () => ({ @@ -23,7 +23,7 @@ jest.mock("./useSolanaWallet", () => ({ const useSolanaWalletMock = mockOf(useSolanaWallet); const useSolanaClientMock = mockOf(useSolanaClient); -describe("useSolBalanceQuery", () => { +describe("useSolanaGasBalanceQuery", () => { beforeEach(() => { // Reset queryClient cache, otherwise test might return previous value renderHookWithAppContext(() => useQueryClient().clear()); @@ -35,12 +35,10 @@ describe("useSolBalanceQuery", () => { connection: { // eslint-disable-next-line @typescript-eslint/require-await getBalance: async () => 999, - onAccountChange: jest.fn(), - removeAccountChangeListener: jest.fn(async () => {}), } as Partial as unknown as CustomConnection, }); const { result, waitFor } = renderHookWithAppContext(() => - useSolBalanceQuery(), + useSolanaGasBalanceQuery(), ); await waitFor(() => result.current.isSuccess); expect(result.current.data).toEqual(new Decimal("0")); @@ -54,12 +52,10 @@ describe("useSolBalanceQuery", () => { connection: { // eslint-disable-next-line @typescript-eslint/require-await getBalance: async () => 123 * LAMPORTS_PER_SOL, - onAccountChange: jest.fn(), - removeAccountChangeListener: jest.fn(async () => {}), } as Partial as unknown as CustomConnection, }); const { result, waitFor } = renderHookWithAppContext(() => - useSolBalanceQuery(), + useSolanaGasBalanceQuery(), ); await waitFor(() => result.current.isSuccess); expect(result.current.data).toEqual(new Decimal("123")); @@ -75,12 +71,10 @@ describe("useSolBalanceQuery", () => { getBalance: async () => { throw new Error("Something went wrong"); }, - onAccountChange: jest.fn(), - removeAccountChangeListener: jest.fn(async () => {}), } as Partial as unknown as CustomConnection, }); const { result, waitFor } = renderHookWithAppContext(() => - useSolBalanceQuery(), + useSolanaGasBalanceQuery(), ); await waitFor(() => result.current.isSuccess); expect(result.current.data).toEqual(new Decimal("0")); diff --git a/apps/ui/src/hooks/solana/useSolanaGasBalanceQuery.ts b/apps/ui/src/hooks/solana/useSolanaGasBalanceQuery.ts new file mode 100644 index 000000000..270676c2b --- /dev/null +++ b/apps/ui/src/hooks/solana/useSolanaGasBalanceQuery.ts @@ -0,0 +1,37 @@ +import { LAMPORTS_PER_SOL, PublicKey } from "@solana/web3.js"; +import Decimal from "decimal.js"; +import type { UseQueryOptions, UseQueryResult } from "react-query"; +import { useQuery } from "react-query"; + +import { useEnvironment } from "../../core/store"; + +import { useSolanaClient } from "./useSolanaClient"; +import { useSolanaWallet } from "./useSolanaWallet"; + +// Returns user's Solana balance in SOL. +export const useSolanaGasBalanceQuery = ( + options?: UseQueryOptions, +): UseQueryResult => { + const { env } = useEnvironment(); + const solanaClient = useSolanaClient(); + const { address: walletAddress } = useSolanaWallet(); + + return useQuery( + [env, "solanaGasBalance", walletAddress], + async () => { + if (!walletAddress) { + return new Decimal(0); + } + try { + const balance = await solanaClient.connection.getBalance( + new PublicKey(walletAddress), + ); + // Convert lamports to SOL. + return new Decimal(balance).dividedBy(LAMPORTS_PER_SOL); + } catch { + return new Decimal(0); + } + }, + options, + ); +}; diff --git a/apps/ui/src/hooks/solana/useSolanaLiquidityQuery.ts b/apps/ui/src/hooks/solana/useSolanaTokenAccountQueries.ts similarity index 57% rename from apps/ui/src/hooks/solana/useSolanaLiquidityQuery.ts rename to apps/ui/src/hooks/solana/useSolanaTokenAccountQueries.ts index c4d5034f2..5881e62f0 100644 --- a/apps/ui/src/hooks/solana/useSolanaLiquidityQuery.ts +++ b/apps/ui/src/hooks/solana/useSolanaTokenAccountQueries.ts @@ -1,30 +1,12 @@ import type { TokenAccount } from "@swim-io/solana"; import type { UseQueryResult } from "react-query"; -import { useQueries, useQuery } from "react-query"; +import { useQueries } from "react-query"; import { useEnvironment } from "../../core/store"; import { useSolanaClient } from "./useSolanaClient"; -export const useSolanaLiquidityQuery = ( - tokenAccountAddresses: readonly string[], -): UseQueryResult => { - const { env } = useEnvironment(); - const solanaClient = useSolanaClient(); - - return useQuery( - [env, "liquidity", tokenAccountAddresses.join("")], - async () => { - if (tokenAccountAddresses.length === 0) { - return []; - } - - return await solanaClient.getMultipleTokenAccounts(tokenAccountAddresses); - }, - ); -}; - -export const useSolanaLiquidityQueries = ( +export const useSolanaTokenAccountQueries = ( tokenAccountAddresses: readonly (readonly string[])[], ): readonly UseQueryResult[] => { const { env } = useEnvironment(); @@ -32,7 +14,7 @@ export const useSolanaLiquidityQueries = ( return useQueries( tokenAccountAddresses.map((addresses) => ({ - queryKey: [env, "liquidity", addresses.join("")], + queryKey: [env, "solanaTokenAccounts", addresses.join()], queryFn: async (): Promise => { if (addresses.length === 0) { return []; diff --git a/apps/ui/src/hooks/solana/useSplTokenAccountsQuery.ts b/apps/ui/src/hooks/solana/useUserSolanaTokenAccountsQuery.ts similarity index 86% rename from apps/ui/src/hooks/solana/useSplTokenAccountsQuery.ts rename to apps/ui/src/hooks/solana/useUserSolanaTokenAccountsQuery.ts index f32f7f4ed..0cc0c8f77 100644 --- a/apps/ui/src/hooks/solana/useSplTokenAccountsQuery.ts +++ b/apps/ui/src/hooks/solana/useUserSolanaTokenAccountsQuery.ts @@ -11,12 +11,12 @@ import { useEnvironment } from "../../core/store"; import { useSolanaClient } from "./useSolanaClient"; import { useSolanaWallet } from "./useSolanaWallet"; -export const getSplTokenAccountsQueryKey = ( +export const getUserSolanaTokenAccountsQueryKey = ( env: Env, address: string | null, -) => [env, "tokenAccounts", address]; +) => [env, "userSolanaTokenAccounts", address]; -export const useSplTokenAccountsQuery = ( +export const useUserSolanaTokenAccountsQuery = ( owner?: string, options?: UseQueryOptions, ): UseQueryResult => { @@ -25,7 +25,7 @@ export const useSplTokenAccountsQuery = ( const { address: userAddress } = useSolanaWallet(); const address = owner ?? userAddress; - const queryKey = getSplTokenAccountsQueryKey(env, address); + const queryKey = getUserSolanaTokenAccountsQueryKey(env, address); return useQuery( queryKey, async () => { diff --git a/apps/ui/src/hooks/solana/useSplUserBalance.ts b/apps/ui/src/hooks/solana/useUserSolanaTokenBalance.ts similarity index 83% rename from apps/ui/src/hooks/solana/useSplUserBalance.ts rename to apps/ui/src/hooks/solana/useUserSolanaTokenBalance.ts index 90d61139f..2f721dda9 100644 --- a/apps/ui/src/hooks/solana/useSplUserBalance.ts +++ b/apps/ui/src/hooks/solana/useUserSolanaTokenBalance.ts @@ -4,9 +4,9 @@ import { atomicToHuman } from "@swim-io/utils"; import Decimal from "decimal.js"; import { useSolanaWallet } from "./useSolanaWallet"; -import { useSplTokenAccountsQuery } from "./useSplTokenAccountsQuery"; +import { useUserSolanaTokenAccountsQuery } from "./useUserSolanaTokenAccountsQuery"; -export const useSplUserBalance = ( +export const useUserSolanaTokenBalance = ( tokenDetails: TokenDetails | null, { enabled = true, @@ -18,7 +18,7 @@ export const useSplUserBalance = ( } = {}, ): Decimal | null => { const { address: walletAddress } = useSolanaWallet(); - const { data: splTokenAccounts = null } = useSplTokenAccountsQuery( + const { data: splTokenAccounts = null } = useUserSolanaTokenAccountsQuery( undefined, { enabled }, ); diff --git a/apps/ui/src/hooks/swim/usePoolBalances.ts b/apps/ui/src/hooks/swim/usePoolBalances.ts index bcb468d40..c005a50bc 100644 --- a/apps/ui/src/hooks/swim/usePoolBalances.ts +++ b/apps/ui/src/hooks/swim/usePoolBalances.ts @@ -8,14 +8,14 @@ import { getSolanaTokenDetails } from "../../config"; import { selectConfig } from "../../core/selectors"; import { useEnvironment } from "../../core/store"; import { isEvmPoolState, isSolanaPool } from "../../models"; -import { useSolanaLiquidityQueries } from "../solana"; +import { useSolanaTokenAccountQueries } from "../solana"; import { usePoolStateQueries } from "./usePoolStateQueries"; export const usePoolBalances = (poolSpecs: readonly PoolSpec[]) => { const { tokens } = useEnvironment(selectConfig, shallow); const poolStateQueries = usePoolStateQueries(poolSpecs); - const liquidityQueries = useSolanaLiquidityQueries( + const liquidityQueries = useSolanaTokenAccountQueries( poolSpecs.map((poolSpec) => poolSpec.ecosystem === SOLANA_ECOSYSTEM_ID ? [...poolSpec.tokenAccounts.values()] diff --git a/apps/ui/src/hooks/swim/usePools.ts b/apps/ui/src/hooks/swim/usePools.ts index 9d4e97ff9..066416e49 100644 --- a/apps/ui/src/hooks/swim/usePools.ts +++ b/apps/ui/src/hooks/swim/usePools.ts @@ -11,9 +11,9 @@ import { useEnvironment } from "../../core/store"; import type { PoolState } from "../../models"; import { getPoolUsdValue, isEvmPoolState } from "../../models"; import { - useSolanaLiquidityQueries, + useSolanaTokenAccountQueries, useSolanaWallet, - useSplTokenAccountsQuery, + useUserSolanaTokenAccountsQuery, } from "../solana"; import { usePoolLpMints } from "./usePoolLpMint"; @@ -119,13 +119,13 @@ const constructPool = ( export const usePools = (poolIds: readonly string[]): readonly PoolData[] => { const { pools, tokens: allTokens } = useEnvironment(selectConfig, shallow); const { address: walletAddress } = useSolanaWallet(); - const { data: splTokenAccounts = null } = useSplTokenAccountsQuery(); + const { data: splTokenAccounts = null } = useUserSolanaTokenAccountsQuery(); const poolSpecs = poolIds.map((poolId) => findOrThrow(pools, (pool) => pool.id === poolId), ); const poolStates = usePoolStateQueries(poolSpecs); const lpMints = usePoolLpMints(poolSpecs); - const liquidityQueries = useSolanaLiquidityQueries( + const liquidityQueries = useSolanaTokenAccountQueries( poolSpecs.map((poolSpec) => poolSpec.ecosystem === SOLANA_ECOSYSTEM_ID ? [...poolSpec.tokenAccounts.values()] diff --git a/apps/ui/src/models/crossEcosystem/tx.test.ts b/apps/ui/src/models/crossEcosystem/tx.test.ts index d3bc723ce..a0c04b97b 100644 --- a/apps/ui/src/models/crossEcosystem/tx.test.ts +++ b/apps/ui/src/models/crossEcosystem/tx.test.ts @@ -15,7 +15,7 @@ describe("Cross-ecosystem tx", () => { id: "34PhSGJi3XboZEhZEirTM6FEh1hNiYHSio1va1nNgH7S9LSNJQGSAiizEyVbgbVJzFjtsbyuJ2WijN53FSC83h7h", timestamp: defaultTimestamp, interactionId: defaultInteractionId, - parsedTx: parsedSwimSwapTx, + original: parsedSwimSwapTx, }; const ethereumTx: EvmTx = { @@ -23,7 +23,7 @@ describe("Cross-ecosystem tx", () => { id: "0x743087e871039d66b82fcb2cb719f6a541e650e05735c32c1be871ef9ae9a456", timestamp: defaultTimestamp, interactionId: defaultInteractionId, - receipt: mock(), + original: mock(), }; const bnbTx: EvmTx = { diff --git a/apps/ui/src/models/solana/findOrCreateSplTokenAccount.ts b/apps/ui/src/models/solana/findOrCreateSplTokenAccount.ts index 2c6e7b7af..5e38580f6 100644 --- a/apps/ui/src/models/solana/findOrCreateSplTokenAccount.ts +++ b/apps/ui/src/models/solana/findOrCreateSplTokenAccount.ts @@ -47,7 +47,11 @@ export const findOrCreateSplTokenAccount = async ({ ); await solanaClient.confirmTx(createSplTokenAccountTxId); await sleep(1000); // TODO: Find a better condition - await queryClient.invalidateQueries([env, "tokenAccounts", solanaAddress]); + await queryClient.invalidateQueries([ + env, + "userSolanaTokenAccounts", + solanaAddress, + ]); const tokenAccount = await solanaClient.getTokenAccountWithRetry( splTokenMintAddress, solanaAddress, diff --git a/apps/ui/src/models/solana/getSwimUsdBalanceChange.ts b/apps/ui/src/models/solana/getSwimUsdBalanceChange.ts index 482e02c7f..468965d7f 100644 --- a/apps/ui/src/models/solana/getSwimUsdBalanceChange.ts +++ b/apps/ui/src/models/solana/getSwimUsdBalanceChange.ts @@ -6,8 +6,8 @@ export const getSwimUsdBalanceChange = async ( solanaClient: SolanaClient, swimUsdSplTokenAccount: TokenAccount, ): Promise => { - const { parsedTx } = await solanaClient.getTx(swapToSwimUsdTxId); - const { preTokenBalances, postTokenBalances } = parsedTx.meta ?? {}; + const { original } = await solanaClient.getTx(swapToSwimUsdTxId); + const { preTokenBalances, postTokenBalances } = original.meta ?? {}; if (!preTokenBalances || !postTokenBalances) { throw new Error(`Invalid transaction: ${swapToSwimUsdTxId}`); } diff --git a/apps/ui/src/models/swim/doSingleSolanaPoolOperation.ts b/apps/ui/src/models/swim/doSingleSolanaPoolOperation.ts index 5cac9885f..d77634e37 100644 --- a/apps/ui/src/models/swim/doSingleSolanaPoolOperation.ts +++ b/apps/ui/src/models/swim/doSingleSolanaPoolOperation.ts @@ -91,18 +91,18 @@ const getTransferredAmount = ( mintAddress: string, walletAddress: string, splTokenAccounts: readonly TokenAccount[], - tx: SolanaTx, + { original }: SolanaTx, ): Decimal => inputOperationSpec.instruction === SwimDefiInstruction.Add ? getAmountMintedToAccountByMint( splTokenAccounts, - tx.parsedTx, + original, mintAddress, walletAddress, ) : getAmountTransferredToAccountByMint( splTokenAccounts, - tx.parsedTx, + original, mintAddress, walletAddress, ); diff --git a/apps/ui/src/models/swim/getTransferredAmounts.ts b/apps/ui/src/models/swim/getTransferredAmounts.ts index 031b85eef..d21ec3fde 100644 --- a/apps/ui/src/models/swim/getTransferredAmounts.ts +++ b/apps/ui/src/models/swim/getTransferredAmounts.ts @@ -33,7 +33,7 @@ export const getTransferredAmounts = ( // Solana-native token amount = getAmountTransferredToAccountByMint( splTokenAccounts, - tx.parsedTx, + tx.original, mint, walletAddress, ); @@ -43,7 +43,7 @@ export const getTransferredAmounts = ( // Wormhole-wrapped token amount = getAmountMintedToAccountByMint( splTokenAccounts, - tx.parsedTx, + tx.original, mint, walletAddress, ); diff --git a/apps/ui/src/models/swim/interactionId.ts b/apps/ui/src/models/swim/interactionId.ts index 9faa8d8ea..7e59617ee 100644 --- a/apps/ui/src/models/swim/interactionId.ts +++ b/apps/ui/src/models/swim/interactionId.ts @@ -27,7 +27,7 @@ const findEvmInteractionId = ( return dataHex.slice(-INTERACTION_ID_LENGTH_HEX); }; -export const fetchEvmTxForInteractionId = async ( +export const fetchEvmTxsForInteractionId = async ( interactionId: string, queryClient: QueryClient, env: Env, @@ -87,7 +87,7 @@ const addSolanaInteractionId = async ( const tx = await solanaClient.getTx(signatureInfo.signature); // NOTE: Getting the ID from the log is more brittle but simpler than getting it from the instructions const memoLog = - tx.parsedTx.meta?.logMessages?.find((log) => MEMO_LOG_REGEXP.test(log)) ?? + tx.original.meta?.logMessages?.find((log) => MEMO_LOG_REGEXP.test(log)) ?? null; const match = memoLog?.match(MEMO_LOG_REGEXP); const interactionId = match?.groups?.[INTERACTION_ID_MATCH_GROUP] ?? null; diff --git a/apps/ui/src/models/swim/pool.test.ts b/apps/ui/src/models/swim/pool.test.ts index 658786d02..221fdef2e 100644 --- a/apps/ui/src/models/swim/pool.test.ts +++ b/apps/ui/src/models/swim/pool.test.ts @@ -47,7 +47,7 @@ describe("Pool tests", () => { id: "string", timestamp: 123456789, ecosystemId: ecosystemId, - receipt: txReceipt, + original: txReceipt, interactionId: "1", }; expect(isPoolTx(contractAddress, tx)).toBe(false); @@ -55,34 +55,34 @@ describe("Pool tests", () => { it("returns false, if not pool Solana tx", () => { const contractAddress = "SWiMDJYFUGj6cPrQ6QYYYWZtvXQdRChSVAygDZDsCHC"; - const ptx = { + const parsedTx = { ...mockDeep(), transaction: parsedWormholeRedeemEvmUnlockWrappedTx.transaction, }; - const txs: SolanaTx = { + const tx: SolanaTx = { ecosystemId: SOLANA_ECOSYSTEM_ID, - parsedTx: ptx, + original: parsedTx, id: "string", timestamp: 123456789, interactionId: "1", }; - expect(isPoolTx(contractAddress, txs)).toBe(false); + expect(isPoolTx(contractAddress, tx)).toBe(false); }); it("returns true, if it's pool solana tx", () => { const contractAddress = "wormDTUJ6AWPNvk59vGQbDvGJmqbDTdgWgAqcLBCgUb"; - const ptx = { + const parsedTx = { ...mockDeep(), transaction: parsedWormholeRedeemEvmUnlockWrappedTx.transaction, }; - const txs: SolanaTx = { + const tx: SolanaTx = { ecosystemId: SOLANA_ECOSYSTEM_ID, - parsedTx: ptx, + original: parsedTx, id: "string", timestamp: 123456789, interactionId: "1", }; - expect(isPoolTx(contractAddress, txs)).toBe(true); + expect(isPoolTx(contractAddress, tx)).toBe(true); }); }); diff --git a/apps/ui/src/models/swim/pool.ts b/apps/ui/src/models/swim/pool.ts index 8e570657b..636fd6ca6 100644 --- a/apps/ui/src/models/swim/pool.ts +++ b/apps/ui/src/models/swim/pool.ts @@ -75,7 +75,7 @@ export const isPoolTx = ( if (!isSolanaTx(tx)) { return false; } - const { message } = tx.parsedTx.transaction; + const { message } = tx.original.transaction; return message.instructions.some( (ix) => ix.programId.toBase58() === poolContractAddress, ); diff --git a/apps/ui/src/models/wormhole/evm.ts b/apps/ui/src/models/wormhole/evm.ts index 59ad92cb9..0daf4fd58 100644 --- a/apps/ui/src/models/wormhole/evm.ts +++ b/apps/ui/src/models/wormhole/evm.ts @@ -14,11 +14,11 @@ export const isLockEvmTx = ( return false; } if ( - tx.receipt.to.toLowerCase() !== wormholeChainConfig.portal.toLowerCase() + tx.original.to.toLowerCase() !== wormholeChainConfig.portal.toLowerCase() ) { return false; } - return tx.receipt.logs.some( + return tx.original.logs.some( (log) => log.address.toLowerCase() === tokenDetails.address.toLowerCase(), ); }; @@ -33,11 +33,11 @@ export const isUnlockEvmTx = ( return false; } if ( - tx.receipt.to.toLowerCase() !== wormholeChainConfig.portal.toLowerCase() + tx.original.to.toLowerCase() !== wormholeChainConfig.portal.toLowerCase() ) { return false; } - return tx.receipt.logs.some( + return tx.original.logs.some( (log) => log.address.toLowerCase() === tokenDetails.address.toLowerCase(), ); }; diff --git a/apps/ui/src/models/wormhole/solana.test.ts b/apps/ui/src/models/wormhole/solana.test.ts index 38a4f5c24..19934d10e 100644 --- a/apps/ui/src/models/wormhole/solana.test.ts +++ b/apps/ui/src/models/wormhole/solana.test.ts @@ -41,7 +41,7 @@ describe("models - Wormhole utils", () => { ecosystemId: SOLANA_ECOSYSTEM_ID, timestamp: parsedSwimSwapTx.blockTime ?? null, id: parsedSwimSwapTx.transaction.signatures[0], - parsedTx: parsedSwimSwapTx, + original: parsedSwimSwapTx, }; expect( @@ -76,7 +76,7 @@ describe("models - Wormhole utils", () => { ecosystemId: SOLANA_ECOSYSTEM_ID, timestamp: parsedTx.blockTime!, id: parsedTx.transaction.signatures[0], - parsedTx, + original: parsedTx, }; const result = isPostVaaSolanaTx( @@ -101,7 +101,7 @@ describe("models - Wormhole utils", () => { ecosystemId: SOLANA_ECOSYSTEM_ID, timestamp: parsedWormholeRedeemEvmUnlockWrappedTx.blockTime!, id: parsedWormholeRedeemEvmUnlockWrappedTx.transaction.signatures[0], - parsedTx: parsedWormholeRedeemEvmUnlockWrappedTx, + original: parsedWormholeRedeemEvmUnlockWrappedTx, }; const result = isPostVaaSolanaTx( @@ -132,7 +132,7 @@ describe("models - Wormhole utils", () => { ecosystemId: SOLANA_ECOSYSTEM_ID, timestamp: parsedTx.blockTime!, id: parsedTx.transaction.signatures[0], - parsedTx, + original: parsedTx, }; const result = isRedeemOnSolanaTx( @@ -160,7 +160,7 @@ describe("models - Wormhole utils", () => { ecosystemId: SOLANA_ECOSYSTEM_ID, timestamp: parsedWormholeRedeemEvmUnlockWrappedTx.blockTime!, id: parsedWormholeRedeemEvmUnlockWrappedTx.transaction.signatures[0], - parsedTx: parsedWormholeRedeemEvmUnlockWrappedTx, + original: parsedWormholeRedeemEvmUnlockWrappedTx, }; const result = isRedeemOnSolanaTx( diff --git a/apps/ui/src/models/wormhole/solana.ts b/apps/ui/src/models/wormhole/solana.ts index bdb2a1457..e90049e1e 100644 --- a/apps/ui/src/models/wormhole/solana.ts +++ b/apps/ui/src/models/wormhole/solana.ts @@ -19,10 +19,10 @@ export const isLockSplTx = ( wormholeChainConfig: WormholeChainConfig, splTokenAccountAddress: string, token: TokenConfig, - { parsedTx }: SolanaTx, + { original }: SolanaTx, ): boolean => { if ( - !parsedTx.transaction.message.instructions.some( + !original.transaction.message.instructions.some( (ix) => ix.programId.toBase58() === wormholeChainConfig.portal, ) ) { @@ -31,11 +31,11 @@ export const isLockSplTx = ( return token.nativeEcosystemId === SOLANA_ECOSYSTEM_ID ? getAmountTransferredFromAccount( - parsedTx, + original, splTokenAccountAddress, ).greaterThan(0) : getAmountBurnedByMint( - parsedTx, + original, getSolanaTokenDetails(token).address, ).greaterThan(0); }; @@ -53,7 +53,7 @@ export const isPostVaaSolanaTx = ( if (signatureSetAddress === null) { return false; } - return tx.parsedTx.transaction.message.instructions.some( + return tx.original.transaction.message.instructions.some( (ix) => isPartiallyDecodedInstruction(ix) && ix.programId.toBase58() === wormholeChainConfig.bridge && @@ -65,16 +65,16 @@ export const isRedeemOnSolanaTx = ( wormholeChainConfig: WormholeChainConfig, token: TokenConfig, splTokenAccount: string, - { parsedTx }: SolanaTx, + { original }: SolanaTx, ): boolean => { if ( - !parsedTx.transaction.message.instructions.some( + !original.transaction.message.instructions.some( (ix) => ix.programId.toBase58() === wormholeChainConfig.portal, ) ) { return false; } return token.nativeEcosystemId === SOLANA_ECOSYSTEM_ID - ? getAmountTransferredToAccount(parsedTx, splTokenAccount).greaterThan(0) - : getAmountMintedToAccount(parsedTx, splTokenAccount).greaterThan(0); + ? getAmountTransferredToAccount(original, splTokenAccount).greaterThan(0) + : getAmountMintedToAccount(original, splTokenAccount).greaterThan(0); }; diff --git a/apps/ui/src/pages/SwapPageV2/SwapPageV2.scss b/apps/ui/src/pages/SwapPageV2/SwapPageV2.scss index 31f8dbf47..a57a0d267 100644 --- a/apps/ui/src/pages/SwapPageV2/SwapPageV2.scss +++ b/apps/ui/src/pages/SwapPageV2/SwapPageV2.scss @@ -6,3 +6,12 @@ max-width: 400px; } } +.buttons { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + .euiPopover { + margin-left: 5px; + } +} diff --git a/apps/ui/src/pages/SwapPageV2/SwapPageV2.tsx b/apps/ui/src/pages/SwapPageV2/SwapPageV2.tsx index dd6c296f4..92e770c6b 100644 --- a/apps/ui/src/pages/SwapPageV2/SwapPageV2.tsx +++ b/apps/ui/src/pages/SwapPageV2/SwapPageV2.tsx @@ -16,6 +16,7 @@ import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import shallow from "zustand/shallow.js"; +import { MultiConnectButton } from "../../components/ConnectButton"; import { RecentInteractionsV2 } from "../../components/RecentInteractionsV2"; import { SlippageButton } from "../../components/SlippageButton"; import { SwapFormV2 } from "../../components/SwapFormV2"; @@ -44,7 +45,7 @@ const SwapPageV2 = (): ReactElement => { ); return ( - + @@ -54,7 +55,8 @@ const SwapPageV2 = (): ReactElement => {

{t("nav.swap_v2")}

- + + { @@ -116,11 +123,23 @@ export class AptosClient extends Client< throw new Error("Not implemented"); } - public initiateWormholeTransfer(): Promise { + public generateInitiatePortalTransferTxs(): AsyncGenerator< + TxGeneratorResult + > { + throw new Error("Not implemented"); + } + + public generateCompletePortalTransferTxs(): AsyncGenerator< + TxGeneratorResult + > { throw new Error("Not implemented"); } - public completeWormholeTransfer(): Promise { + public generateInitiatePropellerTxs(): AsyncGenerator< + TxGeneratorResult, + any, + AptosTxType + > { throw new Error("Not implemented"); } } diff --git a/packages/aptos/src/protocol.ts b/packages/aptos/src/protocol.ts index cc552c1dc..1ae5aa779 100644 --- a/packages/aptos/src/protocol.ts +++ b/packages/aptos/src/protocol.ts @@ -29,9 +29,10 @@ export interface AptosEcosystemConfig extends EcosystemConfig { readonly chains: Partial>; } -export interface AptosTx extends Tx { - readonly ecosystemId: AptosEcosystemId; -} +// TODO: Make an enum +export type AptosTxType = string; + +export type AptosTx = Tx; -export const isAptosTx = (tx: Tx): tx is AptosTx => +export const isAptosTx = (tx: Tx): tx is AptosTx => tx.ecosystemId === APTOS_ECOSYSTEM_ID; diff --git a/packages/core/package.json b/packages/core/package.json index 99902856f..5dfe4d12f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@swim-io/core", - "version": "0.39.0", + "version": "0.40.0", "description": "Basic ecosystem-neutral types and helpers used by Swim.", "main": "build", "types": "types", diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index dd3bfabcb..312808b33 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -14,27 +14,50 @@ export interface WrappedTokenInfo { readonly wrappedAddress: string; } -export interface InitiateWormholeTransferParams { - readonly atomicAmount: string; +export interface InitiatePortalTransferParams { + readonly wallet: Wallet; readonly interactionId: string; + readonly tokenProjectId: TokenProjectId; /** Standardized Wormhole format, ie 32 bytes */ readonly targetAddress: Uint8Array; readonly targetChainId: ChainId; - readonly tokenProjectId: TokenProjectId; - readonly wallet: Wallet; + readonly atomicAmount: string; readonly wrappedTokenInfo?: WrappedTokenInfo; } -export interface CompleteWormholeTransferParams { +export interface CompletePortalTransferParams { + readonly wallet: Wallet; readonly interactionId: string; readonly vaa: Uint8Array; +} + +export interface InitiatePropellerParams { readonly wallet: Wallet; + readonly interactionId: string; + readonly sourceTokenId: TokenProjectId; + readonly targetWormholeChainId: ChainId; + readonly targetTokenNumber: number; + readonly targetWormholeAddress: Uint8Array; + readonly inputAmount: Decimal; + readonly maxPropellerFeeAtomic: string; + readonly gasKickStart: boolean; +} + +export interface TxGeneratorResult< + OriginalTx, + T extends Tx, + TxType extends string, +> { + readonly tx: T; + readonly type: TxType; } export abstract class Client< EcosystemId extends string, C extends ChainConfig, - T extends Tx, + OriginalTx, + TxType extends string, + T extends Tx, Wallet, > { public readonly ecosystemId: EcosystemId; @@ -59,11 +82,15 @@ export abstract class Client< owner: string, tokenDetails: readonly TokenDetails[], ): Promise; - public abstract initiateWormholeTransfer( - params: InitiateWormholeTransferParams, - ): Promise; - public abstract completeWormholeTransfer( - params: CompleteWormholeTransferParams, - ): Promise; + public abstract generateInitiatePortalTransferTxs( + params: InitiatePortalTransferParams, + ): AsyncGenerator>; + public abstract generateCompletePortalTransferTxs( + params: CompletePortalTransferParams, + ): AsyncGenerator>; + + public abstract generateInitiatePropellerTxs( + params: InitiatePropellerParams, + ): AsyncGenerator>; } diff --git a/packages/core/src/tx.ts b/packages/core/src/tx.ts index d94195ca7..350ffa233 100644 --- a/packages/core/src/tx.ts +++ b/packages/core/src/tx.ts @@ -1,8 +1,9 @@ /** Ecosystem-neutral transaction interface */ -export interface Tx { +export interface Tx { readonly id: string; - readonly ecosystemId: string; + readonly ecosystemId: EcosystemId; /** The time in seconds since Unix epoch */ readonly timestamp: number | null; readonly interactionId: string | null; + readonly original: OriginalTx; } diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index 136eb6358..6627a8a6c 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -1,6 +1,6 @@ { "name": "@swim-io/eslint-config", - "version": "0.39.0", + "version": "0.40.0", "description": "Shared default ESLint configuration for Swim TS projects.", "exports": { ".": "./index.cjs", diff --git a/packages/evm-contracts/package.json b/packages/evm-contracts/package.json index b29469338..a84333f4c 100644 --- a/packages/evm-contracts/package.json +++ b/packages/evm-contracts/package.json @@ -1,6 +1,6 @@ { "name": "@swim-io/evm-contracts", - "version": "0.39.0", + "version": "0.40.0", "description": "TS bindings for Swim EVM pool contracts.", "main": "build", "types": "types", diff --git a/packages/evm/package.json b/packages/evm/package.json index b96c282d6..f96ea8aaf 100644 --- a/packages/evm/package.json +++ b/packages/evm/package.json @@ -1,6 +1,6 @@ { "name": "@swim-io/evm", - "version": "0.39.0", + "version": "0.40.0", "description": "Swim code relating to EVM.", "main": "build", "types": "types", diff --git a/packages/evm/src/client.ts b/packages/evm/src/client.ts index 10566f473..5e80f9b32 100644 --- a/packages/evm/src/client.ts +++ b/packages/evm/src/client.ts @@ -6,17 +6,20 @@ import { } from "@certusone/wormhole-sdk"; import { Client, getTokenDetails } from "@swim-io/core"; import type { - CompleteWormholeTransferParams, - InitiateWormholeTransferParams, + CompletePortalTransferParams, + InitiatePortalTransferParams, + InitiatePropellerParams, TokenDetails, + TxGeneratorResult, } from "@swim-io/core"; -import { ERC20__factory } from "@swim-io/evm-contracts"; +import { ERC20__factory, Routing__factory } from "@swim-io/evm-contracts"; import { isNotNull } from "@swim-io/utils"; import Decimal from "decimal.js"; import type { ethers, providers } from "ethers"; import { utils as ethersUtils } from "ethers"; import type { EvmChainConfig, EvmEcosystemId, EvmTx } from "./protocol"; +import { EvmTxType } from "./protocol"; import { appendHexDataToEvmTx } from "./utils"; import type { EvmWalletAdapter } from "./walletAdapters"; @@ -62,6 +65,8 @@ export interface EvmClientOptions { export class EvmClient extends Client< EvmEcosystemId, EvmChainConfig, + TransactionReceipt, + EvmTxType, EvmTx, EvmWalletAdapter > { @@ -93,18 +98,11 @@ export class EvmClient extends Client< txIdOrResponse: TransactionResponse | string, ): Promise { const response = typeof txIdOrResponse === "string" ? null : txIdOrResponse; - const id = - typeof txIdOrResponse === "string" ? txIdOrResponse : txIdOrResponse.hash; - const receipt = await this.getTxReceipt(txIdOrResponse); - - if (receipt === null) { - throw new Error(`Transaction not found: ${id}`); - } - + const receipt = await this.getTxReceiptOrThrow(txIdOrResponse); return { id: receipt.transactionHash, ecosystemId: this.ecosystemId, - receipt, + original: receipt, timestamp: response?.timestamp ?? null, interactionId: null, }; @@ -154,7 +152,7 @@ export class EvmClient extends Client< ); } - public async initiateWormholeTransfer({ + public async *generateInitiatePortalTransferTxs({ atomicAmount, interactionId, targetAddress, @@ -162,24 +160,31 @@ export class EvmClient extends Client< tokenProjectId, wallet, wrappedTokenInfo, - }: InitiateWormholeTransferParams): Promise<{ - readonly approvalResponses: readonly ethers.providers.TransactionResponse[]; - readonly transferResponse: ethers.providers.TransactionResponse; - }> { + }: InitiatePortalTransferParams): AsyncGenerator< + TxGeneratorResult< + TransactionReceipt, + EvmTx, + EvmTxType.Erc20Approve | EvmTxType.PortalTransferTokens + > + > { const mintAddress = wrappedTokenInfo?.wrappedAddress ?? getTokenDetails(this.chainConfig, tokenProjectId).address; await wallet.switchNetwork(this.chainConfig.chainId); - const approvalResponses = await this.approveTokenAmount({ + const approvalGenerator = this.generateErc20ApproveTxs({ atomicAmount, mintAddress, spenderAddress: this.chainConfig.wormhole.portal, wallet, }); - const transferResponse = await this.transferToken({ + for await (const approvalTxResult of approvalGenerator) { + yield approvalTxResult; + } + + const tx = await this.transferToken({ interactionId, mintAddress, atomicAmount, @@ -187,27 +192,26 @@ export class EvmClient extends Client< targetAddress, wallet, }); - - if (transferResponse === null) { - throw new Error( - `Transaction not found (lock/burn from ${this.chainConfig.name})`, - ); - } - - return { - approvalResponses, - transferResponse, + yield { + tx, + type: EvmTxType.PortalTransferTokens, }; } /** * Adapted from https://github.com/certusone/wormhole/blob/2998031b164051a466bb98c71d89301ed482b4c5/sdk/js/src/token_bridge/redeem.ts#L24-L33 */ - public async completeWormholeTransfer({ + public async *generateCompletePortalTransferTxs({ interactionId, vaa, wallet, - }: CompleteWormholeTransferParams): Promise { + }: CompletePortalTransferParams): AsyncGenerator< + TxGeneratorResult< + TransactionReceipt, + EvmTx, + EvmTxType.PortalCompleteTransfer + > + > { const { signer } = wallet; if (!signer) { throw new Error("No EVM signer"); @@ -218,16 +222,89 @@ export class EvmClient extends Client< ); const populatedTx = await bridge.populateTransaction.completeTransfer(vaa); const txRequest = appendHexDataToEvmTx(interactionId, populatedTx); - return signer.sendTransaction(txRequest); + const completeResponse = await signer.sendTransaction(txRequest); + const tx = await this.getTx(completeResponse); + yield { + tx, + type: EvmTxType.PortalCompleteTransfer, + }; + } + + public async *generateInitiatePropellerTxs({ + wallet, + interactionId, + sourceTokenId, + targetWormholeChainId, + targetTokenNumber, + targetWormholeAddress, + inputAmount, + maxPropellerFeeAtomic, + gasKickStart, + }: InitiatePropellerParams): AsyncGenerator< + TxGeneratorResult< + ethers.providers.TransactionReceipt, + EvmTx, + EvmTxType.Erc20Approve | EvmTxType.SwimInitiatePropeller + >, + any, + unknown + > { + const { signer } = wallet; + if (signer === null) { + throw new Error("Missing EVM wallet"); + } + + await wallet.switchNetwork(this.chainConfig.chainId); + + const sourceTokenDetails = getTokenDetails(this.chainConfig, sourceTokenId); + const inputAmountAtomic = ethersUtils.parseUnits( + inputAmount.toString(), + sourceTokenDetails.decimals, + ); + + const approvalGenerator = this.generateErc20ApproveTxs({ + atomicAmount: inputAmountAtomic.toString(), + mintAddress: sourceTokenDetails.address, + spenderAddress: this.chainConfig.routingContractAddress, + wallet, + }); + + for await (const approvalTxResult of approvalGenerator) { + yield approvalTxResult; + } + + const memo = Buffer.from(interactionId, "hex"); + const routingContract = Routing__factory.connect( + this.chainConfig.routingContractAddress, + signer, + ); + const initiatePropellerResponse = await routingContract[ + "propellerInitiate(address,uint256,uint16,bytes32,bool,uint64,uint16,bytes16)" + ]( + sourceTokenDetails.address, + inputAmountAtomic, + targetWormholeChainId, + targetWormholeAddress, + gasKickStart, + maxPropellerFeeAtomic, + targetTokenNumber, + memo, + // overrides, // TODO: allow EVM overrides? + ); + const initiatePropellerTx = await this.getTx(initiatePropellerResponse); + yield { + tx: initiatePropellerTx, + type: EvmTxType.SwimInitiatePropeller, + }; } - public async approveTokenAmount({ + public async *generateErc20ApproveTxs({ atomicAmount, mintAddress, spenderAddress, wallet, - }: ApproveTokenAmountParams): Promise< - readonly ethers.providers.TransactionResponse[] + }: ApproveTokenAmountParams): AsyncGenerator< + TxGeneratorResult > { const { signer } = wallet; if (!signer) { @@ -235,47 +312,40 @@ export class EvmClient extends Client< } await wallet.switchNetwork(this.chainConfig.chainId); - const allowance = await getAllowanceEth( spenderAddress, mintAddress, signer, ); - let approvalResponses: readonly ethers.providers.TransactionResponse[] = []; if (allowance.lt(atomicAmount)) { if (!allowance.isZero()) { // Reset to 0 to avoid a race condition allowing Wormhole to steal funds // See https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729 // Note this is required by some ERC20 implementations such as USDT // See line 205 here: https://etherscan.io/address/0xdac17f958d2ee523a2206206994597c13d831ec7#code - const resetApprovalResponse = await this.approveErc20Token({ + const resetTx = await this.approveErc20Token({ atomicAmount: "0", mintAddress, signer, spenderAddress, }); - approvalResponses = resetApprovalResponse - ? [...approvalResponses, resetApprovalResponse] - : approvalResponses; + yield { + tx: resetTx, + type: EvmTxType.Erc20Approve, + }; } - const approvalResponse = await this.approveErc20Token({ + const tx = await this.approveErc20Token({ atomicAmount, mintAddress, signer, spenderAddress, }); - approvalResponses = approvalResponse - ? [...approvalResponses, approvalResponse] - : approvalResponses; - - // Wait for approvalResponse to be mined, otherwise the transfer might fail ("transfer amount exceeds allowance") - if (approvalResponse) { - await this.getTxReceiptOrThrow(approvalResponse); - } + yield { + tx, + type: EvmTxType.Erc20Approve, + }; } - - return approvalResponses; } private async getTxReceipt( @@ -319,11 +389,15 @@ export class EvmClient extends Client< } private async getTxReceiptOrThrow( - txResponse: TransactionResponse, + txIdOrResponse: string | TransactionResponse, ): Promise { - const txReceipt = await this.getTxReceipt(txResponse); + const txReceipt = await this.getTxReceipt(txIdOrResponse); if (txReceipt === null) { - throw new Error(`Transaction not found: ${txResponse.hash}`); + const id = + typeof txIdOrResponse === "string" + ? txIdOrResponse + : txIdOrResponse.hash; + throw new Error(`Transaction not found: ${id}`); } return txReceipt; } @@ -333,9 +407,10 @@ export class EvmClient extends Client< signer, spenderAddress, mintAddress, - }: ApproveErc20TokenParams): Promise { + }: ApproveErc20TokenParams): Promise { const token = ERC20__factory.connect(mintAddress, signer); - return token.approve(spenderAddress, atomicAmount); + const txResponse = await token.approve(spenderAddress, atomicAmount); + return await this.getTx(txResponse); } /** @@ -348,7 +423,7 @@ export class EvmClient extends Client< targetChainId, mintAddress, wallet, - }: TransferTokenParams): Promise { + }: TransferTokenParams): Promise { const { signer } = wallet; if (!signer) { throw new Error("No EVM signer"); @@ -367,6 +442,7 @@ export class EvmClient extends Client< createNonce(), ); const txRequest = appendHexDataToEvmTx(interactionId, populatedTx); - return signer.sendTransaction(txRequest); + const txResponse = await signer.sendTransaction(txRequest); + return await this.getTx(txResponse); } } diff --git a/packages/evm/src/protocol.ts b/packages/evm/src/protocol.ts index 4d6da2e86..c197cd5b2 100644 --- a/packages/evm/src/protocol.ts +++ b/packages/evm/src/protocol.ts @@ -46,10 +46,14 @@ export interface EvmEcosystemConfig readonly chains: Partial>>; } -export interface EvmTx extends Tx { - readonly ecosystemId: EvmEcosystemId; - readonly receipt: ethers.providers.TransactionReceipt; +export enum EvmTxType { + Erc20Approve = "erc20:approve", + PortalTransferTokens = "portal:transferTokens", + PortalCompleteTransfer = "portal:completeTransfer", + SwimInitiatePropeller = "swim:initiatePropeller", } -export const isEvmTx = (tx: Tx): tx is EvmTx => +export type EvmTx = Tx; + +export const isEvmTx = (tx: Tx): tx is EvmTx => isEvmEcosystemId(tx.ecosystemId); diff --git a/packages/pool-math/package.json b/packages/pool-math/package.json index e71f451f8..a3cb02426 100644 --- a/packages/pool-math/package.json +++ b/packages/pool-math/package.json @@ -1,6 +1,6 @@ { "name": "@swim-io/pool-math", - "version": "0.39.0", + "version": "0.40.0", "description": "A package for calculating input/output amounts for Swim liquidity pools.", "main": "build", "types": "types", diff --git a/packages/solana-contracts/package.json b/packages/solana-contracts/package.json index 03142d05d..ea176d2a5 100644 --- a/packages/solana-contracts/package.json +++ b/packages/solana-contracts/package.json @@ -1,6 +1,6 @@ { "name": "@swim-io/solana-contracts", - "version": "0.39.0", + "version": "0.40.0", "description": "TS bindings for Swim Solana pool contracts.", "main": "build", "types": "types", diff --git a/packages/solana-usdc-usdt-swap/package.json b/packages/solana-usdc-usdt-swap/package.json index 360de68c7..ba8163dc5 100644 --- a/packages/solana-usdc-usdt-swap/package.json +++ b/packages/solana-usdc-usdt-swap/package.json @@ -1,6 +1,6 @@ { "name": "@swim-io/solana-usdc-usdt-swap", - "version": "0.39.0", + "version": "0.40.0", "description": "Create Swim swap instructions for hexapool to swap Solana native USDC and USDT", "main": "build", "types": "types", diff --git a/packages/solana/package.json b/packages/solana/package.json index f50224923..feb2b2570 100644 --- a/packages/solana/package.json +++ b/packages/solana/package.json @@ -1,6 +1,6 @@ { "name": "@swim-io/solana", - "version": "0.39.0", + "version": "0.40.0", "description": "Swim code relating to Solana.", "main": "build", "types": "types", @@ -36,12 +36,15 @@ "@ledgerhq/hw-transport-webusb": "^6.0.2", "@project-serum/sol-wallet-adapter": "0.2.2", "@swim-io/core": "workspace:^", + "@swim-io/solana-contracts": "workspace:^", "@swim-io/token-projects": "workspace:^", "@swim-io/utils": "workspace:^" }, "devDependencies": { "@certusone/wormhole-sdk": "^0.6.2", + "@project-serum/anchor": "^0.25.0", "@project-serum/borsh": "^0.2.5", + "@solana/spl-memo": "^0.2.2", "@solana/spl-token": "^0.3.4", "@solana/web3.js": "^1.62.0", "@swim-io/eslint-config": "workspace:^", @@ -69,7 +72,9 @@ }, "peerDependencies": { "@certusone/wormhole-sdk": "^0.6.2", + "@project-serum/anchor": "^0.25.0", "@project-serum/borsh": "^0.2.5", + "@solana/spl-memo": "^0.2.2", "@solana/spl-token": "^0.3.4", "@solana/web3.js": "^1.62.0", "bn.js": "^5.2.1", diff --git a/packages/solana/src/client.ts b/packages/solana/src/client.ts index 8337acf84..2f9dfdbaa 100644 --- a/packages/solana/src/client.ts +++ b/packages/solana/src/client.ts @@ -1,8 +1,10 @@ +import type { ChainId } from "@certusone/wormhole-sdk"; +import { createVerifySignaturesInstructionsSolana } from "@certusone/wormhole-sdk"; +import type { Accounts } from "@project-serum/anchor"; +import { AnchorProvider, Program } from "@project-serum/anchor"; +import { createMemoInstruction } from "@solana/spl-memo"; import { - createPostVaaInstructionSolana, - createVerifySignaturesInstructionsSolana, -} from "@certusone/wormhole-sdk"; -import { + TOKEN_PROGRAM_ID, createAssociatedTokenAccountInstruction, getAssociatedTokenAddress, getAssociatedTokenAddressSync, @@ -17,18 +19,27 @@ import type { TransactionInstruction, } from "@solana/web3.js"; import { + ComputeBudgetProgram, Connection, Keypair, LAMPORTS_PER_SOL, PublicKey, + SYSVAR_CLOCK_PUBKEY, } from "@solana/web3.js"; import type { - CompleteWormholeTransferParams, - InitiateWormholeTransferParams, + CompletePortalTransferParams, + InitiatePortalTransferParams, + InitiatePropellerParams, TokenDetails, + TxGeneratorResult, } from "@swim-io/core"; import { Client, getTokenDetails } from "@swim-io/core"; -import { atomicToHuman, chunks, sleep } from "@swim-io/utils"; +import type { Propeller } from "@swim-io/solana-contracts"; +import { idl } from "@swim-io/solana-contracts"; +import { TokenProjectId } from "@swim-io/token-projects"; +import type { ReadonlyRecord } from "@swim-io/utils"; +import { atomicToHuman, chunks, humanToAtomic, sleep } from "@swim-io/utils"; +import BN from "bn.js"; import Decimal from "decimal.js"; import type { @@ -36,12 +47,21 @@ import type { SolanaEcosystemId, SolanaTx, } from "./protocol"; -import { SOLANA_ECOSYSTEM_ID } from "./protocol"; +import { SOLANA_ECOSYSTEM_ID, SolanaTxType } from "./protocol"; import type { TokenAccount } from "./serialization"; import { deserializeTokenAccount } from "./serialization"; -import { createMemoIx, createTx } from "./utils"; +import { + createApproveAndRevokeIxs, + createTx, + parsedTxToSolanaTx, +} from "./utils"; +import { extractOutputAmountFromAddTx } from "./utils/propeller"; import type { SolanaWalletAdapter } from "./walletAdapters"; -import { createRedeemOnSolanaTx, createTransferFromSolanaTx } from "./wormhole"; +import { + createPostVaaTx, + createRedeemOnSolanaTx, + createTransferFromSolanaTx, +} from "./wormhole"; export const DEFAULT_MAX_RETRIES = 10; export const DEFAULT_COMMITMENT_LEVEL: Finality = "confirmed"; @@ -51,15 +71,51 @@ type WithOptionalAuxiliarySigner = T & { readonly auxiliarySigner?: Keypair; }; -interface GeneratePostVaaTxIdsParams - extends Omit< - WithOptionalAuxiliarySigner< - CompleteWormholeTransferParams - >, - "wallet" - > { +type CompleteWormholeMessageParams = + CompletePortalTransferParams; + +interface GenerateVerifySignaturesTxsParams + extends Omit, "wallet"> { readonly signTx: (tx: Transaction) => Promise; readonly payerAddress: string; + readonly auxiliarySigner: Keypair; +} + +type SupportedTokenProjectId = + | TokenProjectId.SwimUsd + | TokenProjectId.Usdc + | TokenProjectId.Usdt; + +const SUPPORTED_TOKEN_PROJECT_IDS = [ + TokenProjectId.SwimUsd, + TokenProjectId.Usdc, + TokenProjectId.Usdt, +]; + +const isSupportedTokenProjectId = ( + id: TokenProjectId, +): id is SupportedTokenProjectId => SUPPORTED_TOKEN_PROJECT_IDS.includes(id); + +interface PropellerAddParams { + readonly wallet: SolanaWalletAdapter; + readonly routingContract: Program; + readonly interactionId: string; + readonly senderPublicKey: PublicKey; + readonly sourceTokenId: SupportedTokenProjectId; + readonly inputAmountAtomic: string; +} + +interface PropellerTransferParams { + readonly wallet: SolanaWalletAdapter; + readonly routingContract: Program; + readonly interactionId: string; + readonly senderPublicKey: PublicKey; + readonly targetWormholeChainId: ChainId; + readonly targetTokenNumber: number; + readonly targetWormholeAddress: Uint8Array; + readonly inputAmountAtomic: string; + readonly maxPropellerFeeAtomic: string; + readonly gasKickStart: boolean; } export interface GetSolanaTransactionOptions { @@ -83,17 +139,6 @@ export class CustomConnection extends Connection { } } -export const parsedTxToSolanaTx = ( - txId: string, - parsedTx: ParsedTransactionWithMeta, -): SolanaTx => ({ - id: txId, - ecosystemId: SOLANA_ECOSYSTEM_ID, - timestamp: parsedTx.blockTime ?? null, - interactionId: null, - parsedTx, -}); - export interface SolanaClientOptions { readonly endpoints?: readonly string[]; } @@ -105,6 +150,8 @@ export interface SolanaClientOptions { export class SolanaClient extends Client< SolanaEcosystemId, SolanaChainConfig, + ParsedTransactionWithMeta, + SolanaTxType, SolanaTx, SolanaWalletAdapter > { @@ -132,14 +179,12 @@ export class SolanaClient extends Client< public async getTx(txId: string): Promise { const parsedTx = await this.getParsedTx(txId); - return parsedTxToSolanaTx(txId, parsedTx); + return parsedTxToSolanaTx(parsedTx); } public async getTxs(txIds: readonly string[]): Promise { const parsedTxs = await this.getParsedTxs(txIds); - return parsedTxs.map((parsedTx, i) => - parsedTxToSolanaTx(txIds[i], parsedTx), - ); + return parsedTxs.map((parsedTx) => parsedTxToSolanaTx(parsedTx)); } public async getGasBalance(address: string): Promise { @@ -175,7 +220,7 @@ export class SolanaClient extends Client< ); } - public async initiateWormholeTransfer({ + public async *generateInitiatePortalTransferTxs({ atomicAmount, targetChainId, targetAddress, @@ -185,8 +230,14 @@ export class SolanaClient extends Client< wrappedTokenInfo, auxiliarySigner = Keypair.generate(), }: WithOptionalAuxiliarySigner< - InitiateWormholeTransferParams - >): Promise { + InitiatePortalTransferParams + >): AsyncGenerator< + TxGeneratorResult< + ParsedTransactionWithMeta, + SolanaTx, + SolanaTxType.PortalInitiateTransfer + > + > { const solanaWalletAddress = wallet.publicKey?.toBase58() ?? null; if (solanaWalletAddress === null) { throw new Error("No Solana wallet address"); @@ -198,7 +249,7 @@ export class SolanaClient extends Client< new PublicKey(mintAddress), new PublicKey(solanaWalletAddress), ).toBase58(); - const tx = await createTransferFromSolanaTx({ + const txRequest = await createTransferFromSolanaTx({ interactionId, connection: this.connection, bridgeAddress: this.chainConfig.wormhole.bridge, @@ -210,23 +261,31 @@ export class SolanaClient extends Client< amount: BigInt(atomicAmount), targetAddress, targetChainId, - originAddress: wrappedTokenInfo?.originAddress, - originChain: wrappedTokenInfo?.originChainId, + wrappedTokenInfo, }); - return await this.sendAndConfirmTx(async (txToSign: Transaction) => { + const txId = await this.sendAndConfirmTx(async (txToSign: Transaction) => { txToSign.partialSign(auxiliarySigner); return wallet.signTransaction(txToSign); - }, tx); + }, txRequest); + const tx = await this.getTx(txId); + yield { + tx, + type: SolanaTxType.PortalInitiateTransfer, + }; } - public async *generateCompleteWormholeTransferTxIds({ + public async *generateCompleteWormholeMessageTxs({ interactionId, vaa, wallet, - auxiliarySigner, - }: WithOptionalAuxiliarySigner< - CompleteWormholeTransferParams - >): AsyncGenerator { + auxiliarySigner = Keypair.generate(), + }: WithOptionalAuxiliarySigner): AsyncGenerator< + TxGeneratorResult< + ParsedTransactionWithMeta, + SolanaTx, + SolanaTxType.WormholeVerifySignatures | SolanaTxType.WormholePostVaa + > + > { const walletAddress = wallet.publicKey?.toBase58(); if (!walletAddress) { throw new Error("No Solana public key"); @@ -234,37 +293,170 @@ export class SolanaClient extends Client< const signTx = wallet.signTransaction.bind(wallet); - const postVaaSolanaTxIdsGenerator = this.generatePostVaaTxIds({ + const verifySignaturesTxsGenerator = this.generateVerifySignaturesTxs({ interactionId, signTx, payerAddress: walletAddress, vaa, auxiliarySigner, }); - for await (const txId of postVaaSolanaTxIdsGenerator) { - yield txId; + for await (const verifyTxResult of verifySignaturesTxsGenerator) { + yield verifyTxResult; + } + + const postVaaTxRequest = await createPostVaaTx({ + interactionId, + bridgeAddress: this.chainConfig.wormhole.bridge, + payerAddress: walletAddress, + vaa, + auxiliarySigner, + }); + const postVaaTxId = await this.sendAndConfirmTx(signTx, postVaaTxRequest); + const postVaaTx = await this.getTx(postVaaTxId); + yield { + tx: postVaaTx, + type: SolanaTxType.WormholePostVaa, + }; + } + + public async *generateCompletePortalTransferTxs({ + interactionId, + vaa, + wallet, + auxiliarySigner = Keypair.generate(), + }: WithOptionalAuxiliarySigner< + CompletePortalTransferParams + >): AsyncGenerator< + TxGeneratorResult< + ParsedTransactionWithMeta, + SolanaTx, + | SolanaTxType.WormholeVerifySignatures + | SolanaTxType.WormholePostVaa + | SolanaTxType.PortalRedeem + > + > { + const walletAddress = wallet.publicKey?.toBase58(); + if (!walletAddress) { + throw new Error("No Solana public key"); } - const redeemTx = await createRedeemOnSolanaTx({ + + const signTx = wallet.signTransaction.bind(wallet); + + const completeWormholeTransferTxsGenerator = + this.generateCompleteWormholeMessageTxs({ + interactionId, + vaa, + wallet, + auxiliarySigner, + }); + + for await (const result of completeWormholeTransferTxsGenerator) { + yield result; + } + + const redeemTxRequest = await createRedeemOnSolanaTx({ interactionId, bridgeAddress: this.chainConfig.wormhole.bridge, portalAddress: this.chainConfig.wormhole.portal, payerAddress: walletAddress, vaa, }); - yield await this.sendAndConfirmTx(signTx, redeemTx); + const redeemTxId = await this.sendAndConfirmTx(signTx, redeemTxRequest); + const redeemTx = await this.getTx(redeemTxId); + yield { + tx: redeemTx, + type: SolanaTxType.PortalRedeem, + }; } - public async completeWormholeTransfer( - params: WithOptionalAuxiliarySigner< - CompleteWormholeTransferParams - >, - ): Promise { - let txIds: readonly string[] = []; - const generator = this.generateCompleteWormholeTransferTxIds(params); - for await (const txId of generator) { - txIds = [...txIds, txId]; + public async *generateInitiatePropellerTxs({ + wallet, + interactionId, + sourceTokenId, + targetWormholeChainId, + targetTokenNumber, + targetWormholeAddress, + inputAmount, + maxPropellerFeeAtomic, + gasKickStart, + auxiliarySigner = Keypair.generate(), + }: WithOptionalAuxiliarySigner< + InitiatePropellerParams + >): AsyncGenerator< + TxGeneratorResult, + any, + unknown + > { + const senderPublicKey = wallet.publicKey; + if (senderPublicKey === null) { + throw new Error("Missing Solana wallet"); } - return txIds; + if (!isSupportedTokenProjectId(sourceTokenId)) { + throw new Error("Invalid source token id"); + } + + const sourceTokenDetails = getTokenDetails(this.chainConfig, sourceTokenId); + const inputAmountAtomic = humanToAtomic( + inputAmount, + sourceTokenDetails.decimals, + ).toString(); + + const anchorProvider = new AnchorProvider( + this.connection, + { + ...wallet, + publicKey: senderPublicKey, + }, + { commitment: "confirmed" }, + ); + const routingContract = new Program( + idl.propeller, + this.chainConfig.routingContractAddress, + anchorProvider, + ); + + let addOutputAmountAtomic: string | null = null; + if (sourceTokenId !== TokenProjectId.SwimUsd) { + const addTx = await this.propellerAdd({ + wallet, + routingContract, + interactionId, + senderPublicKey, + sourceTokenId, + inputAmountAtomic, + }); + + yield { + tx: addTx, + type: SolanaTxType.SwimPropellerAdd, + }; + + const outputAmount = extractOutputAmountFromAddTx(addTx.original); + if (!outputAmount) { + throw new Error("Could not parse propeller add output amount from log"); + } + addOutputAmountAtomic = outputAmount; + } + + const swimUsdInputAmountAtomic = addOutputAmountAtomic ?? inputAmountAtomic; + + const transferTx = await this.propellerTransfer({ + wallet, + routingContract, + interactionId, + senderPublicKey, + targetWormholeChainId, + targetTokenNumber, + targetWormholeAddress, + inputAmountAtomic: swimUsdInputAmountAtomic, + maxPropellerFeeAtomic, + gasKickStart, + auxiliarySigner, + }); + yield { + tx: transferTx, + type: SolanaTxType.SwimPropellerTransfer, + }; } public async confirmTx( @@ -415,13 +607,11 @@ export class SolanaClient extends Client< ), ), ); - return results.flatMap((accounts, i) => - accounts.map((account) => - account === null - ? null - : deserializeTokenAccount(new PublicKey(keys[i]), account.data), - ), - ); + return results.flat().map((account, i) => { + return account === null + ? null + : deserializeTokenAccount(new PublicKey(keys[i]), account.data); + }); } private incrementRpcProvider() { @@ -567,14 +757,20 @@ export class SolanaClient extends Client< ); } - private async *generatePostVaaTxIds({ + private async *generateVerifySignaturesTxs({ interactionId, payerAddress, vaa, signTx, - auxiliarySigner = Keypair.generate(), - }: GeneratePostVaaTxIdsParams): AsyncGenerator { - const memoIx = createMemoIx(interactionId, []); + auxiliarySigner, + }: GenerateVerifySignaturesTxsParams): AsyncGenerator< + TxGeneratorResult< + ParsedTransactionWithMeta, + SolanaTx, + SolanaTxType.WormholeVerifySignatures + > + > { + const memoIx = createMemoInstruction(interactionId); const verifyIxs: readonly TransactionInstruction[] = await createVerifySignaturesInstructionsSolana( this.connection, @@ -583,24 +779,14 @@ export class SolanaClient extends Client< Buffer.from(vaa), auxiliarySigner, ); - const finalIx: TransactionInstruction = - await createPostVaaInstructionSolana( - this.chainConfig.wormhole.bridge, - payerAddress, - Buffer.from(vaa), - auxiliarySigner, - ); // The verify signatures instructions can be batched into groups of 2 safely, // reducing the total number of transactions const batchableChunks = chunks(verifyIxs, 2); const feePayer = new PublicKey(payerAddress); - const unsignedTxs = batchableChunks.map((chunk) => + const verifyTxRequests = batchableChunks.map((chunk) => createTx({ feePayer }).add(...chunk, memoIx), ); - // The postVaa instruction can only execute after the verifySignature transactions have - // successfully completed - const finalTx = createTx({ feePayer }).add(finalIx, memoIx); // The signatureSet keypair also needs to sign the verifySignature transactions, thus a wrapper is needed const partialSignWrapper = async ( @@ -610,9 +796,215 @@ export class SolanaClient extends Client< return signTx(tx); }; - for (const tx of unsignedTxs) { - yield await this.sendAndConfirmTx(partialSignWrapper, tx); + for (const txRequest of verifyTxRequests) { + const txId = await this.sendAndConfirmTx(partialSignWrapper, txRequest); + const tx = await this.getTx(txId); + yield { + tx, + type: SolanaTxType.WormholeVerifySignatures, + }; } - yield await this.sendAndConfirmTx(signTx, finalTx); + } + + private getAddAccounts( + userSwimUsdAtaPublicKey: PublicKey, + userTokenAccounts: readonly PublicKey[], + auxiliarySigner: PublicKey, + lpMint: PublicKey, + poolTokenAccounts: readonly PublicKey[], + poolGovernanceFeeAccount: PublicKey, + ): Accounts { + return { + propeller: new PublicKey(this.chainConfig.routingContractStateAddress), + tokenProgram: TOKEN_PROGRAM_ID, + poolTokenAccount0: poolTokenAccounts[0], + poolTokenAccount1: poolTokenAccounts[1], + lpMint, + governanceFee: poolGovernanceFeeAccount, + userTransferAuthority: auxiliarySigner, + userTokenAccount0: userTokenAccounts[0], + userTokenAccount1: userTokenAccounts[1], + userLpTokenAccount: userSwimUsdAtaPublicKey, + twoPoolProgram: new PublicKey(this.chainConfig.twoPoolContractAddress), + }; + } + + private async getPropellerTransferAccounts( + walletPublicKey: PublicKey, + swimUsdAtaPublicKey: PublicKey, + auxiliarySigner: PublicKey, + ): Promise { + const bridgePublicKey = new PublicKey(this.chainConfig.wormhole.bridge); + const portalPublicKey = new PublicKey(this.chainConfig.wormhole.portal); + const swimUsdMintPublicKey = new PublicKey( + this.chainConfig.swimUsdDetails.address, + ); + const [wormholeConfig] = await PublicKey.findProgramAddress( + [Buffer.from("Bridge")], + bridgePublicKey, + ); + const [tokenBridgeConfig] = await PublicKey.findProgramAddress( + [Buffer.from("config")], + portalPublicKey, + ); + const [custody] = await PublicKey.findProgramAddress( + [swimUsdMintPublicKey.toBytes()], + portalPublicKey, + ); + const [custodySigner] = await PublicKey.findProgramAddress( + [Buffer.from("custody_signer")], + portalPublicKey, + ); + const [authoritySigner] = await PublicKey.findProgramAddress( + [Buffer.from("authority_signer")], + portalPublicKey, + ); + const [wormholeEmitter] = await PublicKey.findProgramAddress( + [Buffer.from("emitter")], + portalPublicKey, + ); + const [wormholeSequence] = await PublicKey.findProgramAddress( + [Buffer.from("Sequence"), wormholeEmitter.toBytes()], + bridgePublicKey, + ); + const [wormholeFeeCollector] = await PublicKey.findProgramAddress( + [Buffer.from("fee_collector")], + bridgePublicKey, + ); + return { + propeller: new PublicKey(this.chainConfig.routingContractStateAddress), + tokenProgram: TOKEN_PROGRAM_ID, + payer: walletPublicKey, + wormhole: bridgePublicKey, + tokenBridgeConfig, + userSwimUsdAta: swimUsdAtaPublicKey, + swimUsdMint: swimUsdMintPublicKey, + custody, + tokenBridge: portalPublicKey, + custodySigner, + authoritySigner, + wormholeConfig, + wormholeMessage: auxiliarySigner, + wormholeEmitter, + wormholeSequence, + wormholeFeeCollector, + clock: SYSVAR_CLOCK_PUBKEY, + }; + } + + private async propellerAdd({ + wallet, + routingContract, + interactionId, + senderPublicKey, + sourceTokenId, + inputAmountAtomic, + auxiliarySigner = Keypair.generate(), + }: WithOptionalAuxiliarySigner): Promise { + const [twoPoolConfig] = this.chainConfig.pools; + const addInputAmounts = + sourceTokenId === TokenProjectId.Usdc + ? [inputAmountAtomic, "0"] + : ["0", inputAmountAtomic]; + const addMaxFee = "0"; // TODO: Change to a real value + + const userTokenAccounts = SUPPORTED_TOKEN_PROJECT_IDS.reduce( + (accumulator, tokenProjectId) => { + const { address } = getTokenDetails(this.chainConfig, tokenProjectId); + return { + ...accumulator, + [tokenProjectId]: getAssociatedTokenAddressSync( + new PublicKey(address), + senderPublicKey, + ), + }; + }, + {} as ReadonlyRecord, + ); + const addAccounts = this.getAddAccounts( + userTokenAccounts[TokenProjectId.SwimUsd], + [ + userTokenAccounts[TokenProjectId.Usdc], + userTokenAccounts[TokenProjectId.Usdt], + ], + auxiliarySigner.publicKey, + new PublicKey(this.chainConfig.swimUsdDetails.address), + [...twoPoolConfig.tokenAccounts.values()].map( + (address) => new PublicKey(address), + ), + new PublicKey(twoPoolConfig.governanceFeeAccount), + ); + + const [approveIx, revokeIx] = await createApproveAndRevokeIxs( + userTokenAccounts[sourceTokenId], + inputAmountAtomic, + auxiliarySigner.publicKey, + senderPublicKey, + ); + const memoIx = createMemoInstruction(interactionId); + + const txRequest = await routingContract.methods + .propellerAdd( + addInputAmounts.map((amount) => new BN(amount)), + new BN(addMaxFee), + ) + .accounts(addAccounts) + .preInstructions([approveIx]) + .postInstructions([revokeIx, memoIx]) + .signers([auxiliarySigner]) + .transaction(); + const txId = await this.sendAndConfirmTx(async (tx) => { + tx.partialSign(auxiliarySigner); + return wallet.signTransaction(tx); + }, txRequest); + return await this.getTx(txId); + } + + private async propellerTransfer({ + wallet, + routingContract, + interactionId, + senderPublicKey, + targetWormholeChainId, + targetTokenNumber, + targetWormholeAddress, + inputAmountAtomic, + maxPropellerFeeAtomic, + gasKickStart, + auxiliarySigner = Keypair.generate(), + }: WithOptionalAuxiliarySigner): Promise { + const memo = Buffer.from(interactionId, "hex"); + const setComputeUnitLimitIx = ComputeBudgetProgram.setComputeUnitLimit({ + units: 350_000, + }); + const swimUsdTokenAccount = await getAssociatedTokenAddress( + new PublicKey(this.chainConfig.swimUsdDetails.address), + senderPublicKey, + ); + const transferAccounts = await this.getPropellerTransferAccounts( + senderPublicKey, + swimUsdTokenAccount, + auxiliarySigner.publicKey, + ); + const txRequest = await routingContract.methods + .propellerTransferNativeWithPayload( + new BN(inputAmountAtomic), + targetWormholeChainId, + targetWormholeAddress, + gasKickStart, + new BN(maxPropellerFeeAtomic), + targetTokenNumber, + memo, + ) + .accounts(transferAccounts) + .preInstructions([setComputeUnitLimitIx]) + .signers([auxiliarySigner]) + .transaction(); + + const txId = await this.sendAndConfirmTx(async (tx) => { + tx.partialSign(auxiliarySigner); + return wallet.signTransaction(tx); + }, txRequest); + return await this.getTx(txId); } } diff --git a/packages/solana/src/protocol.ts b/packages/solana/src/protocol.ts index d04232a0a..fd4b43525 100644 --- a/packages/solana/src/protocol.ts +++ b/packages/solana/src/protocol.ts @@ -37,10 +37,16 @@ export interface SolanaEcosystemConfig extends EcosystemConfig { readonly chains: Partial>; } -export interface SolanaTx extends Tx { - readonly ecosystemId: SolanaEcosystemId; - readonly parsedTx: ParsedTransactionWithMeta; +export enum SolanaTxType { + PortalInitiateTransfer = "portal:initiateTransfer", + PortalRedeem = "portal:redeem", + WormholeVerifySignatures = "wormhole:verifySignatures", + WormholePostVaa = "wormhole:postVaa", + SwimPropellerAdd = "swimPropeller:add", + SwimPropellerTransfer = "swimPropeller:transfer", } -export const isSolanaTx = (tx: Tx): tx is SolanaTx => +export type SolanaTx = Tx; + +export const isSolanaTx = (tx: Tx): tx is SolanaTx => tx.ecosystemId === SOLANA_ECOSYSTEM_ID; diff --git a/packages/solana/src/utils/propeller.ts b/packages/solana/src/utils/propeller.ts new file mode 100644 index 000000000..9431ce316 --- /dev/null +++ b/packages/solana/src/utils/propeller.ts @@ -0,0 +1,12 @@ +import type { ParsedTransactionWithMeta } from "@solana/web3.js"; + +const PROPELLER_OUTPUT_AMOUNT_REGEX = + /^Program log: propeller_add output_amount: (?\d+)/; +export const extractOutputAmountFromAddTx = ( + tx: ParsedTransactionWithMeta | null, +): string | null => { + const addLog = tx?.meta?.logMessages?.find((log) => + PROPELLER_OUTPUT_AMOUNT_REGEX.test(log), + ); + return addLog?.match(PROPELLER_OUTPUT_AMOUNT_REGEX)?.groups?.amount ?? null; +}; diff --git a/packages/solana/src/utils/splToken.ts b/packages/solana/src/utils/splToken.ts index 32d9b8630..ed8cdb30a 100644 --- a/packages/solana/src/utils/splToken.ts +++ b/packages/solana/src/utils/splToken.ts @@ -1,6 +1,11 @@ +import { Spl } from "@project-serum/anchor"; import { getAssociatedTokenAddressSync } from "@solana/spl-token"; -import type { ParsedTransactionWithMeta } from "@solana/web3.js"; +import type { + ParsedTransactionWithMeta, + TransactionInstruction, +} from "@solana/web3.js"; import { PublicKey } from "@solana/web3.js"; +import { BN } from "bn.js"; import Decimal from "decimal.js"; import type { TokenAccount } from "../serialization"; @@ -24,6 +29,31 @@ export const findTokenAccountForMint = ( ); }; +export const createApproveAndRevokeIxs = async ( + tokenAccount: PublicKey, + amount: string, + delegate: PublicKey, + authority: PublicKey, +): Promise => { + const splToken = Spl.token(); + const approveIx = splToken.methods + .approve(new BN(amount)) + .accounts({ + source: tokenAccount, + delegate, + authority, + }) + .instruction(); + const revokeIx = splToken.methods + .revoke() + .accounts({ + source: tokenAccount, + authority, + }) + .instruction(); + return Promise.all([approveIx, revokeIx]); +}; + interface ParsedSplTokenTransferInstruction { readonly program: string; // "spl-token" readonly programId: PublicKey; diff --git a/packages/solana/src/utils/tx.ts b/packages/solana/src/utils/tx.ts index 849f7f9f1..4b74321ac 100644 --- a/packages/solana/src/utils/tx.ts +++ b/packages/solana/src/utils/tx.ts @@ -1,6 +1,12 @@ -import type { TransactionBlockhashCtor } from "@solana/web3.js"; +import type { + ParsedTransactionWithMeta, + TransactionBlockhashCtor, +} from "@solana/web3.js"; import { Transaction } from "@solana/web3.js"; +import type { SolanaTx } from "../protocol"; +import { SOLANA_ECOSYSTEM_ID } from "../protocol"; + export type CreateTxOptions = Omit< TransactionBlockhashCtor, "blockhash" | "lastValidBlockHeight" @@ -9,3 +15,13 @@ export type CreateTxOptions = Omit< /** Create transaction with dummy blockhash and lastValidBlockHeight, expected to be overwritten by solanaClient.sendAndConfirmTx to prevent expired blockhash */ export const createTx = (opts: CreateTxOptions): Transaction => new Transaction({ ...opts, blockhash: "", lastValidBlockHeight: 0 }); + +export const parsedTxToSolanaTx = ( + parsedTx: ParsedTransactionWithMeta, +): SolanaTx => ({ + id: parsedTx.transaction.signatures[0], + ecosystemId: SOLANA_ECOSYSTEM_ID, + timestamp: parsedTx.blockTime ?? null, + interactionId: null, + original: parsedTx, +}); diff --git a/packages/solana/src/wormhole.ts b/packages/solana/src/wormhole.ts index a6d7e70cf..087db56df 100644 --- a/packages/solana/src/wormhole.ts +++ b/packages/solana/src/wormhole.ts @@ -2,6 +2,7 @@ import type { ChainId } from "@certusone/wormhole-sdk"; import { CHAIN_ID_SOLANA, createNonce, + createPostVaaInstructionSolana, getBridgeFeeIx, importCoreWasm, importTokenWasm, @@ -10,11 +11,13 @@ import { import { createApproveInstruction } from "@solana/spl-token"; import type { Connection, + Keypair, ParsedTransactionWithMeta, Transaction, VersionedTransactionResponse, } from "@solana/web3.js"; import { PublicKey } from "@solana/web3.js"; +import type { WrappedTokenInfo } from "@swim-io/core/types"; import { createMemoIx, createTx } from "./utils"; @@ -48,8 +51,7 @@ export interface CreateTransferFromSolanaTxParams { readonly amount: bigint; readonly targetAddress: Uint8Array; readonly targetChainId: ChainId; - readonly originAddress?: Uint8Array; - readonly originChain?: ChainId; + readonly wrappedTokenInfo?: WrappedTokenInfo; readonly fromOwnerAddress?: string; } export const createTransferFromSolanaTx = async ({ @@ -64,34 +66,32 @@ export const createTransferFromSolanaTx = async ({ amount, targetAddress, targetChainId, - originAddress, - originChain, + wrappedTokenInfo, fromOwnerAddress, }: CreateTransferFromSolanaTxParams): Promise => { const nonce = createNonce().readUInt32LE(0); - const fee = BigInt(0); // for now, this won't do anything, we may add later + const fee = BigInt(0); // not currently used const bridgeFeeIx = await getBridgeFeeIx( connection, bridgeAddress, payerAddress, ); + // eslint-disable-next-line @typescript-eslint/unbound-method const { + approval_authority_address, transfer_native_ix, transfer_wrapped_ix, - approval_authority_address, } = await importTokenWasm(); const approvalIx = createApproveInstruction( new PublicKey(fromAddress), new PublicKey(approval_authority_address(portalAddress)), - new PublicKey(fromOwnerAddress || payerAddress), + new PublicKey(fromOwnerAddress ?? payerAddress), amount, ); const isSolanaNative = - originChain === undefined || originChain === CHAIN_ID_SOLANA; - if (!isSolanaNative && !originAddress) { - throw new Error("originAddress is required when specifying originChain"); - } + wrappedTokenInfo === undefined || + wrappedTokenInfo.originChainId === CHAIN_ID_SOLANA; const transferIx = ixFromRust( isSolanaNative ? transfer_native_ix( @@ -114,8 +114,8 @@ export const createTransferFromSolanaTx = async ({ auxiliarySignerAddress, fromAddress, fromOwnerAddress || payerAddress, - originChain as number, // checked by isSolanaNative - originAddress as Uint8Array, // checked by throw + wrappedTokenInfo.originChainId, + wrappedTokenInfo.originAddress, nonce, amount, fee, @@ -123,10 +123,37 @@ export const createTransferFromSolanaTx = async ({ targetChainId, ), ); + const memoIx = createMemoIx(interactionId, []); - const tx = createTx({ + return createTx({ feePayer: new PublicKey(payerAddress), }).add(bridgeFeeIx, approvalIx, transferIx, memoIx); +}; + +export interface CreatePostVaaTxParams { + readonly interactionId: string; + readonly bridgeAddress: string; + readonly payerAddress: string; + readonly vaa: Uint8Array; + readonly auxiliarySigner: Keypair; +} +export const createPostVaaTx = async ({ + interactionId, + bridgeAddress, + payerAddress, + vaa, + auxiliarySigner, +}: CreatePostVaaTxParams): Promise => { + const postVaaIx = await createPostVaaInstructionSolana( + bridgeAddress, + payerAddress, + Buffer.from(vaa), + auxiliarySigner, + ); + const memoIx = createMemoIx(interactionId, []); + const tx = createTx({ + feePayer: new PublicKey(payerAddress), + }).add(postVaaIx, memoIx); return tx; }; diff --git a/packages/token-projects/package.json b/packages/token-projects/package.json index 1f5d738f2..76f55e3c2 100644 --- a/packages/token-projects/package.json +++ b/packages/token-projects/package.json @@ -1,6 +1,6 @@ { "name": "@swim-io/token-projects", - "version": "0.39.0", + "version": "0.40.0", "description": "A registry of token projects used by Swim.", "main": "build", "types": "types", diff --git a/packages/tsconfig/package.json b/packages/tsconfig/package.json index 78a54c8a8..858d7c773 100644 --- a/packages/tsconfig/package.json +++ b/packages/tsconfig/package.json @@ -1,6 +1,6 @@ { "name": "@swim-io/tsconfig", - "version": "0.39.0", + "version": "0.40.0", "description": "Shared default TypeScript configuration for Swim TS projects.", "files": [ "*.json", diff --git a/packages/utils/package.json b/packages/utils/package.json index 1ea75ca8a..2bce0da3f 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,6 +1,6 @@ { "name": "@swim-io/utils", - "version": "0.39.0", + "version": "0.40.0", "description": "Minimal-dependency general-purpose utils.", "main": "build", "types": "types", diff --git a/packages/wormhole/package.json b/packages/wormhole/package.json index 69af16861..d059923d1 100644 --- a/packages/wormhole/package.json +++ b/packages/wormhole/package.json @@ -1,6 +1,6 @@ { "name": "@swim-io/wormhole", - "version": "0.39.0", + "version": "0.40.0", "description": "Swim code relating to Wormhole.", "main": "build", "types": "types", diff --git a/yarn.lock b/yarn.lock index f0c822356..6980d7cd4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7248,6 +7248,19 @@ __metadata: languageName: node linkType: hard +"@swim-io/core@npm:^0.40.0": + version: 0.40.0 + resolution: "@swim-io/core@npm:0.40.0" + dependencies: + "@swim-io/token-projects": ^0.40.0 + "@swim-io/utils": ^0.40.0 + decimal.js: ^10.3.1 + peerDependencies: + "@certusone/wormhole-sdk": ^0.6.2 + checksum: 8f71a7c1708ae099d70fa6f39c3eac475702c948ed670924a66053ed8def4578358365a8e48d1fb31f253fd6df515b1827977f6072561159d7e604cfa8207afc + languageName: node + linkType: hard + "@swim-io/core@workspace:^, @swim-io/core@workspace:packages/core": version: 0.0.0-use.local resolution: "@swim-io/core@workspace:packages/core" @@ -7339,10 +7352,10 @@ __metadata: languageName: node linkType: hard -"@swim-io/evm-contracts@npm:^0.39.0": - version: 0.39.0 - resolution: "@swim-io/evm-contracts@npm:0.39.0" - checksum: 9c375aed437790788e6ac53ebf13829f34da2fe3d00859aed4d563b3e173d8ec2faac0b217c0ee11069978b755fa47c1db94b2a8cf3596157605716798a432ea +"@swim-io/evm-contracts@npm:^0.40.0": + version: 0.40.0 + resolution: "@swim-io/evm-contracts@npm:0.40.0" + checksum: 5061e0d2594e4912f271613f2cbbfb819238a809cb3c01a086339e325890607fd67efe8f5b47d6d434b016d8b92c6968331458a49ed4da9333753cc2fcf19145 languageName: node linkType: hard @@ -7400,14 +7413,14 @@ __metadata: languageName: node linkType: hard -"@swim-io/evm@npm:^0.39.0": - version: 0.39.0 - resolution: "@swim-io/evm@npm:0.39.0" +"@swim-io/evm@npm:^0.40.0": + version: 0.40.0 + resolution: "@swim-io/evm@npm:0.40.0" dependencies: - "@swim-io/core": ^0.39.0 - "@swim-io/evm-contracts": ^0.39.0 - "@swim-io/token-projects": ^0.39.0 - "@swim-io/utils": ^0.39.0 + "@swim-io/core": ^0.40.0 + "@swim-io/evm-contracts": ^0.40.0 + "@swim-io/token-projects": ^0.40.0 + "@swim-io/utils": ^0.40.0 graphql: ^16.6.0 graphql-request: ^4.3.0 moralis: ^1.8.0 @@ -7416,7 +7429,7 @@ __metadata: decimal.js: ^10.3.1 ethers: ^5.6.9 eventemitter3: ^4.0.7 - checksum: d45ae02e098b910f357ad4f584c74405d5882f223ba8f1469532e20739ad189e46729ee258421170fe5d5b4dab06486b74406beab0a4a3bc4896794dac6d6a2d + checksum: ba65ee3dcf51c9a4c1e329a7597b41c4226028f2fc76afd57c9e8185b20e04ef5563d47cdf017ec9aa122e148162408b16d544a167c6953b251a77fc83cebdfb languageName: node linkType: hard @@ -7479,12 +7492,12 @@ __metadata: languageName: unknown linkType: soft -"@swim-io/pool-math@npm:^0.39.0": - version: 0.39.0 - resolution: "@swim-io/pool-math@npm:0.39.0" +"@swim-io/pool-math@npm:^0.40.0": + version: 0.40.0 + resolution: "@swim-io/pool-math@npm:0.40.0" peerDependencies: decimal.js: ^10.3.1 - checksum: 68f86c9a614630192dd54a04a7f96765823e2fb2444a8807de172714570695f0ee0f8c82a676b682973dc64f54eca422b95828b7df9eacfa9b71d5cc5a819a63 + checksum: 6b3de0916194bcab630111aa1bc34d46d046c7d1960437a734b354a5f376e23e89ccfa228a077332ff33989373a9de6d883ff437849bd2cf61526d6db52d5843 languageName: node linkType: hard @@ -7614,19 +7627,19 @@ __metadata: languageName: unknown linkType: soft -"@swim-io/solana-contracts@npm:^0.39.0": - version: 0.39.0 - resolution: "@swim-io/solana-contracts@npm:0.39.0" +"@swim-io/solana-contracts@npm:^0.40.0": + version: 0.40.0 + resolution: "@swim-io/solana-contracts@npm:0.40.0" peerDependencies: "@project-serum/anchor": ^0.25.0 "@project-serum/borsh": ^0.2.5 "@solana/spl-token": ^0.2.0 "@solana/web3.js": ^1.50.1 - checksum: 891cea2e53838d8e6048054174c5685782cd5ef86c0d8e062e123fd26839bf03d0e15b9245f037de27fcca87352b695735e0fbde6a3abfa8dba156c5d8cb7575 + checksum: 5f78da938ff92fa660024ed2ad63345db68bee8b0f83f7e26cb86aa7e959a6e35d8afece1d11374274037b0821b4d55e1597f1ba40e1814a65e8faf75e75c041 languageName: node linkType: hard -"@swim-io/solana-contracts@workspace:packages/solana-contracts": +"@swim-io/solana-contracts@workspace:^, @swim-io/solana-contracts@workspace:packages/solana-contracts": version: 0.0.0-use.local resolution: "@swim-io/solana-contracts@workspace:packages/solana-contracts" dependencies: @@ -7753,6 +7766,28 @@ __metadata: languageName: node linkType: hard +"@swim-io/solana@npm:^0.40.0": + version: 0.40.0 + resolution: "@swim-io/solana@npm:0.40.0" + dependencies: + "@ledgerhq/hw-transport": ^6.27.1 + "@ledgerhq/hw-transport-webusb": ^6.0.2 + "@project-serum/sol-wallet-adapter": 0.2.2 + "@swim-io/core": ^0.40.0 + "@swim-io/token-projects": ^0.40.0 + "@swim-io/utils": ^0.40.0 + peerDependencies: + "@certusone/wormhole-sdk": ^0.6.2 + "@project-serum/borsh": ^0.2.5 + "@solana/spl-token": ^0.3.4 + "@solana/web3.js": ^1.62.0 + bn.js: ^5.2.1 + decimal.js: ^10.3.1 + ethers: ^5.7.0 + checksum: 207c35b40b87f6e2bdbec0a50dcbe0cb5bb437d5170e2691790b56c1c91fff508ea2cb5ec9cb8abbf52dcd23a1ca1e1663e843abee9e10c57eec1996584c186b + languageName: node + linkType: hard + "@swim-io/solana@workspace:^, @swim-io/solana@workspace:packages/solana": version: 0.0.0-use.local resolution: "@swim-io/solana@workspace:packages/solana" @@ -7760,12 +7795,15 @@ __metadata: "@certusone/wormhole-sdk": ^0.6.2 "@ledgerhq/hw-transport": ^6.27.1 "@ledgerhq/hw-transport-webusb": ^6.0.2 + "@project-serum/anchor": ^0.25.0 "@project-serum/borsh": ^0.2.5 "@project-serum/sol-wallet-adapter": 0.2.2 + "@solana/spl-memo": ^0.2.2 "@solana/spl-token": ^0.3.4 "@solana/web3.js": ^1.62.0 "@swim-io/core": "workspace:^" "@swim-io/eslint-config": "workspace:^" + "@swim-io/solana-contracts": "workspace:^" "@swim-io/token-projects": "workspace:^" "@swim-io/tsconfig": "workspace:^" "@swim-io/utils": "workspace:^" @@ -7791,7 +7829,9 @@ __metadata: typescript: ~4.8.4 peerDependencies: "@certusone/wormhole-sdk": ^0.6.2 + "@project-serum/anchor": ^0.25.0 "@project-serum/borsh": ^0.2.5 + "@solana/spl-memo": ^0.2.2 "@solana/spl-token": ^0.3.4 "@solana/web3.js": ^1.62.0 bn.js: ^5.2.1 @@ -7843,6 +7883,15 @@ __metadata: languageName: node linkType: hard +"@swim-io/token-projects@npm:^0.40.0": + version: 0.40.0 + resolution: "@swim-io/token-projects@npm:0.40.0" + dependencies: + "@swim-io/utils": ^0.40.0 + checksum: 48958357bcb6d30978833321f9e6db875581102416327f50b345515166505a5ce5e6e5f1b0d3d458ac440503f3cc9cb9436384e59da86da703f1e95e15518733 + languageName: node + linkType: hard + "@swim-io/token-projects@workspace:^, @swim-io/token-projects@workspace:packages/token-projects": version: 0.0.0-use.local resolution: "@swim-io/token-projects@workspace:packages/token-projects" @@ -7910,17 +7959,17 @@ __metadata: "@storybook/node-logger": ^6.5.10 "@storybook/react": ^6.5.10 "@swim-io/aptos": "workspace:^" - "@swim-io/core": ^0.39.0 + "@swim-io/core": ^0.40.0 "@swim-io/eslint-config": "workspace:^" - "@swim-io/evm": ^0.39.0 - "@swim-io/evm-contracts": ^0.39.0 - "@swim-io/pool-math": ^0.39.0 - "@swim-io/solana": ^0.39.0 - "@swim-io/solana-contracts": ^0.39.0 - "@swim-io/token-projects": "workspace:^" + "@swim-io/evm": ^0.40.0 + "@swim-io/evm-contracts": ^0.40.0 + "@swim-io/pool-math": ^0.40.0 + "@swim-io/solana": ^0.40.0 + "@swim-io/solana-contracts": ^0.40.0 + "@swim-io/token-projects": ^0.40.0 "@swim-io/tsconfig": "workspace:^" - "@swim-io/utils": ^0.39.0 - "@swim-io/wormhole": ^0.39.0 + "@swim-io/utils": ^0.40.0 + "@swim-io/wormhole": ^0.40.0 "@testing-library/jest-dom": ^5.16.4 "@testing-library/react": ^12.1.5 "@testing-library/react-hooks": ^7.0.2 @@ -7993,6 +8042,15 @@ __metadata: languageName: node linkType: hard +"@swim-io/utils@npm:^0.40.0": + version: 0.40.0 + resolution: "@swim-io/utils@npm:0.40.0" + peerDependencies: + decimal.js: ^10.3.1 + checksum: 0a9c84c556c57015aa118ddb4b72a00e01ac837c1c7870e475897b85a0cfdc1583f9e90b6615a804a90e8185fa80bc05e06e8f15275dda0dcd8475bbbb219bb3 + languageName: node + linkType: hard + "@swim-io/utils@workspace:^, @swim-io/utils@workspace:packages/utils": version: 0.0.0-use.local resolution: "@swim-io/utils@workspace:packages/utils" @@ -8020,20 +8078,20 @@ __metadata: languageName: unknown linkType: soft -"@swim-io/wormhole@npm:^0.39.0": - version: 0.39.0 - resolution: "@swim-io/wormhole@npm:0.39.0" +"@swim-io/wormhole@npm:^0.40.0": + version: 0.40.0 + resolution: "@swim-io/wormhole@npm:0.40.0" dependencies: - "@swim-io/core": ^0.39.0 - "@swim-io/solana": ^0.39.0 - "@swim-io/utils": ^0.39.0 + "@swim-io/core": ^0.40.0 + "@swim-io/solana": ^0.40.0 + "@swim-io/utils": ^0.40.0 grpc-web: ^1.3.1 peerDependencies: "@certusone/wormhole-sdk": ^0.6.2 "@solana/spl-token": ^0.3.4 "@solana/web3.js": ^1.62.0 ethers: ^5.6.9 - checksum: 953ed6377f6614fffa9b7a335543e804598380092bbdf71dee4ab52a90eef4738f3249133f729e4e89888592e76bf5205195931444f73b3aeaad25519cb85055 + checksum: 12af304e4184bb1db601df1bc8a3c40f38f76ef64d18de5bfcc783b55680adc3090fb7beb08bcee15c9a22313a724fb6418713eaa628f0fad1be449865455f9e languageName: node linkType: hard