Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

integrate LNSwap #4938

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@
"jsontokens": "4.0.1",
"ledger-bitcoin": "0.2.3",
"limiter": "2.1.0",
"lnswap-sdk": "^0.0.1",
"lodash.get": "4.4.2",
"lodash.uniqby": "4.7.0",
"micro-packed": "0.3.2",
Expand Down
9 changes: 8 additions & 1 deletion src/app/pages/home/components/account-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,14 @@ export function AccountActions(props: FlexProps) {
onClick={() => navigate(RouteUrls.Swap)}
/>
),
[ChainID.Testnet]: null,
[ChainID.Testnet]: (
<ActionButton
data-testid={HomePageSelectors.SwapBtn}
icon={<SwapIcon />}
label="Swap"
onClick={() => navigate(RouteUrls.Swap)}
/>
),
})}
</Flex>
);
Expand Down
110 changes: 110 additions & 0 deletions src/app/pages/swap/hooks/use-lnswap-swap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { useCallback, useState } from 'react';
import { useAsync } from 'react-async-hook';

import { AlexSDK, Currency, TokenInfo } from 'lnswap-sdk';
import BigNumber from 'bignumber.js';

import { logger } from '@shared/logger';
import { createMoney } from '@shared/models/money.model';

import { useStxBalance } from '@app/common/hooks/balance/stx/use-stx-balance';
import { convertAmountToFractionalUnit } from '@app/common/money/calculate-money';
import { pullContractIdFromIdentity } from '@app/common/utils';
// TODO: change this
import { useSwappableCurrencyQuery } from '@app/query/common/lnswap-swaps/swappable-currency.query';
import { useTransferableStacksFungibleTokenAssetBalances } from '@app/query/stacks/balance/stacks-ft-balances.hooks';
import { useCurrentStacksAccount } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks';

import { SwapSubmissionData } from '../swap.context';
import { SwapAsset } from './use-swap-form';

export const oneHundredMillion = 100_000_000;

export function useLNSwapSwap() {
const alexSDK = useState(() => new AlexSDK())[0];
const [swapSubmissionData, setSwapSubmissionData] = useState<SwapSubmissionData>();
const [slippage, _setSlippage] = useState(0.04);
const [isFetchingExchangeRate, setIsFetchingExchangeRate] = useState(false);
const { data: supportedCurrencies = [] } = useSwappableCurrencyQuery(alexSDK);
console.log('30.hooks/use-lnswap-swap.tsx', supportedCurrencies);
const { result: prices } = useAsync(async () => await alexSDK.getLatestPrices(), [alexSDK]);
const { availableBalance: availableStxBalance } = useStxBalance();
const account = useCurrentStacksAccount();
const stacksFtAssetBalances = useTransferableStacksFungibleTokenAssetBalances(
account?.address ?? ''
);

const createSwapAssetFromAlexCurrency = useCallback(
(tokenInfo?: TokenInfo) => {
if (!prices) return;
if (!tokenInfo) {
logger.error('No token data found to swap');
return;
}

const currency = tokenInfo.id as Currency;
console.log('49.hooks/use-lnswap-swap.tsx', tokenInfo.id, currency);
const price = convertAmountToFractionalUnit(new BigNumber(prices[currency] ?? 0), 2);
const swapAsset = {
currency,
icon: tokenInfo.icon,
name: tokenInfo.name,
price: createMoney(price, 'USD'),
principal: 'pullContractIdFromIdentity(tokenInfo.contractAddress)',
};
console.log('58.hooks/use-lnswap-swap.tsx', swapAsset);
if (currency === Currency.STX) {
return {
...swapAsset,
balance: availableStxBalance,
displayName: 'Stacks',
};
}

const fungibleTokenBalance =
stacksFtAssetBalances.find(x => tokenInfo.contractAddress === x.asset.contractId)
?.balance ?? createMoney(0, tokenInfo.name, tokenInfo.decimals);
console.log('70.hooks/use-lnswap-swap.tsx', fungibleTokenBalance);
return {
...swapAsset,
balance: fungibleTokenBalance,
};
},
[availableStxBalance, prices, stacksFtAssetBalances]
);

async function fetchToAmount(
from: SwapAsset,
to: SwapAsset,
fromAmount: string
): Promise<string | undefined> {
console.log('84.fetchToAmount', from, to, fromAmount);
fromAmount = Math.floor(Number(fromAmount)).toString();
const amount = new BigNumber(fromAmount).multipliedBy(oneHundredMillion).dp(0).toString();
const amountAsBigInt = isNaN(Number(amount)) ? BigInt(0) : BigInt(amount);
console.log('86.fetchToAmount', from, to, amountAsBigInt);
try {
setIsFetchingExchangeRate(true);
const result = await alexSDK.getAmountTo(from.currency, amountAsBigInt, to.currency);
setIsFetchingExchangeRate(false);
console.log('91.setIsFetchingExchangeRate', result);
return new BigNumber(Number(result)).dividedBy(oneHundredMillion).toString();
} catch (e) {
logger.error('Error fetching exchange rate from LNSwap', e);
setIsFetchingExchangeRate(false);
return;
}
}

return {
alexSDK,
fetchToAmount,
createSwapAssetFromAlexCurrency,
isFetchingExchangeRate,
onSetIsFetchingExchangeRate: (value: boolean) => setIsFetchingExchangeRate(value),
onSetSwapSubmissionData: (value: SwapSubmissionData) => setSwapSubmissionData(value),
slippage,
supportedCurrencies,
swapSubmissionData,
};
}
214 changes: 214 additions & 0 deletions src/app/pages/swap/lnswap-swap-container.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { useMemo, useState } from 'react';
import { Outlet, useNavigate } from 'react-router-dom';

import { bytesToHex } from '@stacks/common';
import { ContractCallPayload, TransactionTypes } from '@stacks/connect';
import {
AnchorMode,
PostConditionMode,
serializeCV,
serializePostCondition,
} from '@stacks/transactions';
import BigNumber from 'bignumber.js';

import { logger } from '@shared/logger';
import { RouteUrls } from '@shared/route-urls';
import { isDefined, isUndefined } from '@shared/utils';

import { LoadingKeys, useLoading } from '@app/common/hooks/use-loading';
import { useWalletType } from '@app/common/use-wallet-type';
import { NonceSetter } from '@app/components/nonce-setter';
import { defaultFeesMinValuesAsMoney } from '@app/query/stacks/fees/fees.utils';
import { useCurrentStacksAccount } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks';
import { useGenerateStacksContractCallUnsignedTx } from '@app/store/transactions/contract-call.hooks';
import { useSignStacksTransaction } from '@app/store/transactions/transaction.hooks';

import { SwapContainerLayout } from './components/swap-container.layout';
import { SwapForm } from './components/swap-form';
import { generateSwapRoutes } from './generate-swap-routes';
import { useAlexBroadcastSwap } from './hooks/use-alex-broadcast-swap';
import { oneHundredMillion, useLNSwapSwap } from './hooks/use-lnswap-swap';
import { useStacksBroadcastSwap } from './hooks/use-stacks-broadcast-swap';
import { SwapAsset, SwapFormValues } from './hooks/use-swap-form';
import { SwapContext, SwapProvider } from './swap.context';
import { migratePositiveBalancesToTop, sortSwappableAssetsBySymbol } from './swap.utils';

export const lnswapSwapRoutes = generateSwapRoutes(<LNSwapContainer />);

function LNSwapContainer() {
const [isSendingMax, setIsSendingMax] = useState(false);
const navigate = useNavigate();
const { setIsLoading } = useLoading(LoadingKeys.SUBMIT_SWAP_TRANSACTION);
const currentAccount = useCurrentStacksAccount();
const generateUnsignedTx = useGenerateStacksContractCallUnsignedTx();
const signTx = useSignStacksTransaction();
const { whenWallet } = useWalletType();

// Setting software to false until we revisit:
// https://github.com/leather-wallet/extension/issues/4750
const isSponsoredByAlex = whenWallet({
ledger: false,
software: false,
});

const {
alexSDK,
fetchToAmount,
createSwapAssetFromAlexCurrency, // TODO: change this
isFetchingExchangeRate,
onSetIsFetchingExchangeRate,
onSetSwapSubmissionData,
slippage,
supportedCurrencies,
swapSubmissionData,
} = useLNSwapSwap();

const broadcastAlexSwap = useAlexBroadcastSwap(alexSDK);
const broadcastStacksSwap = useStacksBroadcastSwap();

// this is where we set the swappable assets
const swappableAssets: SwapAsset[] = useMemo(
() =>
sortSwappableAssetsBySymbol(
supportedCurrencies.map(createSwapAssetFromAlexCurrency).filter(isDefined)
),
[createSwapAssetFromAlexCurrency, supportedCurrencies]
);

async function onSubmitSwapForReview(values: SwapFormValues) {
if (isUndefined(values.swapAssetFrom) || isUndefined(values.swapAssetTo)) {
logger.error('Error submitting swap for review');
return;
}

const [router, lpFee] = await Promise.all([
alexSDK.getRouter(values.swapAssetFrom.currency, values.swapAssetTo.currency),
alexSDK.getFeeRate(values.swapAssetFrom.currency, values.swapAssetTo.currency),
]);

onSetSwapSubmissionData({
fee: isSponsoredByAlex ? '0' : defaultFeesMinValuesAsMoney[1].amount.toString(),
feeCurrency: values.feeCurrency,
feeType: values.feeType,
liquidityFee: new BigNumber(Number(lpFee)).dividedBy(oneHundredMillion).toNumber(),
nonce: values.nonce,
protocol: 'LNSWAP',
router: router
.map(x => createSwapAssetFromAlexCurrency(supportedCurrencies.find(y => y.id === x)))
.filter(isDefined),
slippage,
sponsored: isSponsoredByAlex,
swapAmountFrom: values.swapAmountFrom,
swapAmountTo: values.swapAmountTo,
swapAssetFrom: values.swapAssetFrom,
swapAssetTo: values.swapAssetTo,
timestamp: new Date().toISOString(),
});

navigate(RouteUrls.SwapReview);
}

async function onSubmitSwap() {
if (isUndefined(currentAccount) || isUndefined(swapSubmissionData)) {
logger.error('Error submitting swap data to sign');
return;
}

if (
isUndefined(swapSubmissionData.swapAssetFrom) ||
isUndefined(swapSubmissionData.swapAssetTo)
) {
logger.error('No assets selected to perform swap');
return;
}

setIsLoading();

const fromAmount = BigInt(
new BigNumber(swapSubmissionData.swapAmountFrom)
.multipliedBy(oneHundredMillion)
.dp(0)
.toString()
);

const minToAmount = BigInt(
new BigNumber(swapSubmissionData.swapAmountTo)
.multipliedBy(oneHundredMillion)
.multipliedBy(new BigNumber(1).minus(slippage))
.dp(0)
.toString()
);

console.log('143 swapSubmissionData', swapSubmissionData);
const tx = alexSDK.runSwap(
currentAccount?.address,
swapSubmissionData.swapAssetFrom.currency,
swapSubmissionData.swapAssetTo.currency,
fromAmount,
minToAmount,
swapSubmissionData.router.map(x => x.currency)
);

// TODO: Add choose fee step
const tempFormValues = {
fee: swapSubmissionData.fee,
feeCurrency: swapSubmissionData.feeCurrency,
feeType: swapSubmissionData.feeType,
nonce: swapSubmissionData.nonce,
};

const payload: ContractCallPayload = {
anchorMode: AnchorMode.Any,
contractAddress: tx.contractAddress,
contractName: tx.contractName,
functionName: tx.functionName,
functionArgs: tx.functionArgs.map(x => bytesToHex(serializeCV(x))),
postConditionMode: PostConditionMode.Deny,
postConditions: tx.postConditions.map(pc => bytesToHex(serializePostCondition(pc))),
publicKey: currentAccount?.stxPublicKey,
sponsored: swapSubmissionData.sponsored,
txType: TransactionTypes.ContractCall,
};

const unsignedTx = await generateUnsignedTx(payload, tempFormValues);
if (!unsignedTx) return logger.error('Attempted to generate unsigned tx, but tx is undefined');

try {
const signedTx = await signTx(unsignedTx);
if (!signedTx)
return logger.error('Attempted to generate raw tx, but signed tx is undefined');
const txRaw = bytesToHex(signedTx.serialize());

return whenWallet({
ledger: await broadcastStacksSwap(signedTx),
software: isSponsoredByAlex
? await broadcastAlexSwap(txRaw)
: await broadcastStacksSwap(signedTx),
});
} catch (error) {}
}

const swapContextValue: SwapContext = {
fetchToAmount,
isFetchingExchangeRate,
isSendingMax,
onSetIsFetchingExchangeRate,
onSetIsSendingMax: value => setIsSendingMax(value),
onSubmitSwapForReview,
onSubmitSwap,
swappableAssetsFrom: migratePositiveBalancesToTop(swappableAssets),
swappableAssetsTo: swappableAssets,
swapSubmissionData,
};

return (
<SwapProvider value={swapContextValue}>
<SwapContainerLayout>
<SwapForm>
<NonceSetter />
<Outlet />
</SwapForm>
</SwapContainerLayout>
</SwapProvider>
);
}
16 changes: 16 additions & 0 deletions src/app/query/common/lnswap-swaps/swappable-currency.query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { useQuery } from '@tanstack/react-query';
import { AlexSDK } from 'lnswap-sdk';

export function useSwappableCurrencyQuery(alexSDK: AlexSDK) {
return useQuery(
['lnswap-supported-swap-currencies'],
async () => alexSDK.fetchSwappableCurrency(),
{
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
retryDelay: 1000 * 60,
staleTime: 1000 * 60 * 10,
}
);
}
4 changes: 3 additions & 1 deletion src/app/routes/app-routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import { rpcRequestRoutes } from '@app/routes/rpc-routes';
import { settingsRoutes } from '@app/routes/settings-routes';

import { OnboardingGate } from './onboarding-gate';
import { lnswapSwapRoutes } from '@app/pages/swap/lnswap-swap-container';

export function SuspenseLoadingSpinner() {
return <LoadingSpinner height="600px" />;
Expand Down Expand Up @@ -252,7 +253,8 @@ function useAppRoutes() {
{ledgerBitcoinTxSigningRoutes}
</Route>

{alexSwapRoutes}
{/* {alexSwapRoutes} */}
{lnswapSwapRoutes}

{/* Catch-all route redirects to onboarding */}
<Route path="*" element={<Navigate replace to={RouteUrls.Onboarding} />} />
Expand Down
Loading
Loading