Skip to content

Commit

Permalink
feat: sponsored sbtc txns
Browse files Browse the repository at this point in the history
  • Loading branch information
alexp3y committed Dec 18, 2024
1 parent e618ccf commit 6d1f772
Show file tree
Hide file tree
Showing 14 changed files with 455 additions and 85 deletions.
5 changes: 5 additions & 0 deletions config/wallet-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@
"enabled": true,
"emilyApiUrl": "https://sbtc-emily.com",
"showPromoLinkOnNetworks": ["mainnet", "testnet", "sbtcTestnet"],
"sponsorshipsEnabled": true,
"sponsorshipApiUrl": {
"mainnet": "https://sponsor.leather.io",
"testnet": "http://testnet-13-60-14-218.nip.io"
},
"contracts": {
"mainnet": {
"address": "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token::sbtc-token"
Expand Down
17 changes: 17 additions & 0 deletions config/wallet-config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -158,10 +158,27 @@
"type": "boolean",
"description": "Determines whether or not SBTC is enabled"
},
"sponsorshipsEnabled": {
"type": "boolean",
"description": "Determines whether or not sponsored sBTC transactions are enabled"
},
"emilyApiUrl": {
"type": "string",
"description": "URL for the Emily API"
},
"sponsorshipApiUrl": {
"type": "object",
"properties": {
"mainnet": {
"type": "string",
"description": "Mainnet URL for the Leather Sponsor API"
},
"testnet": {
"type": "string",
"description": "Testnet URL for the Leather Sponsor API"
}
}
},
"showPromoLinkOnNetworks": {
"type": "array",
"description": "Networks on which the promo link should be shown",
Expand Down
14 changes: 10 additions & 4 deletions src/app/features/stacks-transaction-request/fee-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,34 @@ import { StacksTransactionFormValues } from '@shared/models/form.model';
import { isTxSponsored } from '@app/common/transactions/stacks/transaction.utils';
import { FeesRow } from '@app/components/fees-row/fees-row';
import { LoadingRectangle } from '@app/components/loading-rectangle';
import type { SbtcSponsorshipEligibility } from '@app/query/sbtc/sponsored-transactions.query';
import { useUnsignedPrepareTransactionDetails } from '@app/store/transactions/transaction.hooks';

interface FeeFormProps {
fees?: Fees;
disableFeeSelection?: boolean;
defaultFeeValue?: number;
sbtcSponsorshipEligibility?: SbtcSponsorshipEligibility;
}

export function FeeForm({ fees, disableFeeSelection, defaultFeeValue }: FeeFormProps) {
export function FeeForm({
fees,
disableFeeSelection,
defaultFeeValue,
sbtcSponsorshipEligibility,
}: FeeFormProps) {
const { values } = useFormikContext<StacksTransactionFormValues>();
const transaction = useUnsignedPrepareTransactionDetails(values);

const isSponsored = transaction ? isTxSponsored(transaction) : false;

return (
<>
{fees?.estimates.length ? (
{!!sbtcSponsorshipEligibility && fees?.estimates.length ? (
<FeesRow
disableFeeSelection={disableFeeSelection}
defaultFeeValue={defaultFeeValue}
fees={fees}
isSponsored={isSponsored}
isSponsored={sbtcSponsorshipEligibility?.isEligible || isSponsored}
/>
) : (
<LoadingRectangle height="32px" width="100%" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import { TransactionTypes } from '@stacks/connect';
import BigNumber from 'bignumber.js';
import { useFormikContext } from 'formik';

import { useGetContractInterfaceQuery, useStxCryptoAssetBalance } from '@leather.io/query';
import {
useCalculateStacksTxFees,
useGetContractInterfaceQuery,
useNextNonce,
useStxCryptoAssetBalance,
} from '@leather.io/query';
import { stxToMicroStx } from '@leather.io/utils';

import { StacksTransactionFormValues } from '@shared/models/form.model';
Expand All @@ -13,8 +18,13 @@ import { useDefaultRequestParams } from '@app/common/hooks/use-default-request-s
import { initialSearchParams } from '@app/common/initial-search-params';
import { validateStacksAddress } from '@app/common/stacks-utils';
import { TransactionErrorReason } from '@app/features/stacks-transaction-request/transaction-error/transaction-error';
import { useCurrentStacksAccount } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks';
import { useCheckSbtcSponsorshipEligible } from '@app/query/sbtc/sponsored-transactions.hooks';
import {
useCurrentStacksAccount,
useCurrentStacksAccountAddress,
} from '@app/store/accounts/blockchain/stacks/stacks-account.hooks';
import { useTransactionRequestState } from '@app/store/transactions/requests.hooks';
import { useUnsignedStacksTransactionBaseState } from '@app/store/transactions/transaction.hooks';

function getIsMultisig() {
return initialSearchParams.get('isMultisig') === 'true';
Expand All @@ -30,10 +40,17 @@ export function useTransactionError() {
const { filteredBalanceQuery } = useStxCryptoAssetBalance(currentAccount?.address ?? '');
const availableUnlockedBalance = filteredBalanceQuery.data?.unlockedBalance;

const unsignedTx = useUnsignedStacksTransactionBaseState();
const stxAddress = useCurrentStacksAccountAddress();
const { data: stxFees } = useCalculateStacksTxFees(unsignedTx.transaction);
const { data: nextNonce } = useNextNonce(stxAddress);
const { isVerifying: isVerifyingSbtcEligibilty, result: sbtcSponsorshipEligibility } =
useCheckSbtcSponsorshipEligible(unsignedTx, nextNonce, stxFees);

return useMemo<TransactionErrorReason | void>(() => {
if (!origin) return TransactionErrorReason.ExpiredRequest;

if (filteredBalanceQuery.isLoading) return;
if (filteredBalanceQuery.isLoading || isVerifyingSbtcEligibilty) return;

if (!transactionRequest || !availableUnlockedBalance || !currentAccount) {
return TransactionErrorReason.Generic;
Expand All @@ -56,7 +73,7 @@ export function useTransactionError() {
return TransactionErrorReason.StxTransferInsufficientFunds;
}

if (!transactionRequest.sponsored) {
if (!transactionRequest.sponsored && !sbtcSponsorshipEligibility?.isEligible) {
if (zeroBalance) return TransactionErrorReason.FeeInsufficientFunds;

const feeValue = stxToMicroStx(values.fee);
Expand All @@ -73,5 +90,7 @@ export function useTransactionError() {
availableUnlockedBalance,
currentAccount,
values.fee,
isVerifyingSbtcEligibilty,
sbtcSponsorshipEligibility,
]);
}
149 changes: 91 additions & 58 deletions src/app/pages/swap/bitflow-swap-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
serializePostCondition,
} from '@stacks/transactions';

import { isError, isUndefined, satToBtc } from '@leather.io/utils';
import { isError, isUndefined } from '@leather.io/utils';

import { logger } from '@shared/logger';
import type { SwapFormValues } from '@shared/models/form.model';
Expand All @@ -20,6 +20,10 @@ import { bitflow } from '@shared/utils/bitflow-sdk';
import { LoadingKeys, useLoading } from '@app/common/hooks/use-loading';
import { Content, Page } from '@app/components/layout';
import { PageHeader } from '@app/features/container/headers/page.header';
import type {
SbtcSponsorshipEligibility,
TransactionBase,
} from '@app/query/sbtc/sponsored-transactions.query';
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';
Expand All @@ -29,13 +33,15 @@ import { SwapForm } from './components/swap-form';
import { generateSwapRoutes } from './generate-swap-routes';
import { useBitflowSwap } from './hooks/use-bitflow-swap';
import { useSbtcDepositTransaction } from './hooks/use-sbtc-deposit-transaction';
import { useSponsorTransactionFees } from './hooks/use-sponsor-tx-fees';
import { useStacksBroadcastSwap } from './hooks/use-stacks-broadcast-swap';
import { useSwapNavigate } from './hooks/use-swap-navigate';
import { SwapContext, SwapProvider } from './swap.context';

export const bitflowSwapRoutes = generateSwapRoutes(<BitflowSwapContainer />);

function BitflowSwapContainer() {
const [unsignedTx, setUnsignedTx] = useState<TransactionBase | undefined>();
const [isSendingMax, setIsSendingMax] = useState(false);
const [isPreparingSwapReview, setIsPreparingSwapReview] = useState(false);
const navigate = useNavigate();
Expand All @@ -47,6 +53,12 @@ function BitflowSwapContainer() {
const broadcastStacksSwap = useStacksBroadcastSwap();
const { onDepositSbtc, onReviewDepositSbtc } = useSbtcDepositTransaction();

const [sponsorshipEligibility, setSponsorshipEligibility] = useState<
SbtcSponsorshipEligibility | undefined
>();

const { checkEligibilityForSponsor, submitSponsoredTx } = useSponsorTransactionFees();

const {
fetchRouteQuote,
fetchQuoteAmount,
Expand All @@ -66,7 +78,11 @@ function BitflowSwapContainer() {
async (values: SwapFormValues) => {
try {
setIsPreparingSwapReview(true);
if (isUndefined(values.swapAssetBase) || isUndefined(values.swapAssetQuote)) {
if (
isUndefined(currentAccount) ||
isUndefined(values.swapAssetBase) ||
isUndefined(values.swapAssetQuote)
) {
logger.error('Error submitting swap for review');
return;
}
Expand All @@ -76,7 +92,7 @@ function BitflowSwapContainer() {
const sBtcDepositData = await onReviewDepositSbtc(swapData, isSendingMax);
onSetSwapSubmissionData({
...swapData,
fee: satToBtc(sBtcDepositData?.fee ?? 0).toNumber(),
fee: sBtcDepositData?.fee ?? 0,
maxSignerFee: sBtcDepositData?.maxSignerFee,
txData: { deposit: sBtcDepositData?.deposit },
});
Expand All @@ -91,23 +107,78 @@ function BitflowSwapContainer() {

if (!routeQuote) return;

onSetSwapSubmissionData(
getStacksSwapSubmissionData({ bitflowSwapAssets, routeQuote, slippage, values })
const stacksSwapData = getStacksSwapSubmissionData({
bitflowSwapAssets,
routeQuote,
slippage,
values,
});

const swapExecutionData = {
route: routeQuote.route,
amount: Number(stacksSwapData.swapAmountBase),
tokenXDecimals: routeQuote.tokenXDecimals,
tokenYDecimals: routeQuote.tokenYDecimals,
};

const swapParams = await bitflow.getSwapParams(
swapExecutionData,
currentAccount.address,
slippage
);

if (!routeQuote) return;

const formValues = {
fee: stacksSwapData.fee,
feeCurrency: stacksSwapData.feeCurrency,
feeType: stacksSwapData.feeType,
nonce: stacksSwapData.nonce,
};

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

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

const sponsorshipEligibility = await checkEligibilityForSponsor(values, unsignedTx);
stacksSwapData.sponsored = sponsorshipEligibility.isEligible;

setUnsignedTx(unsignedTx);
setSponsorshipEligibility(sponsorshipEligibility);
onSetSwapSubmissionData(stacksSwapData);

swapNavigate(RouteUrls.SwapReview);
} finally {
setIsPreparingSwapReview(false);
}
},
[
bitflowSwapAssets,
fetchRouteQuote,
currentAccount,
isCrossChainSwap,
isSendingMax,
onReviewDepositSbtc,
onSetSwapSubmissionData,
fetchRouteQuote,
bitflowSwapAssets,
slippage,
generateUnsignedTx,
checkEligibilityForSponsor,
onSetSwapSubmissionData,
swapNavigate,
onReviewDepositSbtc,
isSendingMax,
]
);

Expand All @@ -134,54 +205,15 @@ function BitflowSwapContainer() {
}

try {
const routeQuote = await fetchRouteQuote(
swapSubmissionData.swapAssetBase,
swapSubmissionData.swapAssetQuote,
swapSubmissionData.swapAmountBase
);

if (!routeQuote) return;

const swapExecutionData = {
route: routeQuote.route,
amount: Number(swapSubmissionData.swapAmountBase),
tokenXDecimals: routeQuote.tokenXDecimals,
tokenYDecimals: routeQuote.tokenYDecimals,
};

const swapParams = await bitflow.getSwapParams(
swapExecutionData,
currentAccount.address,
swapSubmissionData.slippage
);

const tempFormValues = {
fee: swapSubmissionData.fee,
feeCurrency: swapSubmissionData.feeCurrency,
feeType: swapSubmissionData.feeType,
nonce: swapSubmissionData.nonce,
};

const payload: ContractCallPayload = {
anchorMode: AnchorMode.Any,
contractAddress: swapParams.contractAddress,
contractName: swapParams.contractName,
functionName: swapParams.functionName,
functionArgs: swapParams.functionArgs.map(x => bytesToHex(serializeCV(x))),
postConditionMode: PostConditionMode.Deny,
postConditions: swapParams.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');

const signedTx = await signTx(unsignedTx);
if (sponsorshipEligibility?.isEligible)
return await submitSponsoredTx(sponsorshipEligibility.unsignedSponsoredTx!);

if (!unsignedTx?.transaction) return logger.error('No unsigned tx to sign');

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

return await broadcastStacksSwap(signedTx);
} catch (e) {
navigate(RouteUrls.SwapError, {
Expand All @@ -196,16 +228,17 @@ function BitflowSwapContainer() {
}, [
broadcastStacksSwap,
currentAccount,
fetchRouteQuote,
generateUnsignedTx,
isCrossChainSwap,
isLoading,
navigate,
onDepositSbtc,
setIsIdle,
setIsLoading,
signTx,
sponsorshipEligibility,
submitSponsoredTx,
swapSubmissionData,
unsignedTx,
]);

const swapContextValue: SwapContext = {
Expand Down
Loading

0 comments on commit 6d1f772

Please sign in to comment.