From 31439ed4bbd64a81d00624cff6caaa398202c83b Mon Sep 17 00:00:00 2001 From: Fara Woolf Date: Sat, 14 Dec 2024 15:33:10 -0500 Subject: [PATCH] feat: sponsored sbtc txns --- config/wallet-config.json | 5 + config/wallet-config.schema.json | 17 ++ .../stacks-transaction-request/fee-form.tsx | 14 +- .../hooks/use-transaction-error.ts | 27 +++- src/app/pages/swap/bitflow-swap-container.tsx | 149 +++++++++++------- src/app/pages/swap/bitflow-swap.utils.ts | 4 +- .../components/swap-details/swap-details.tsx | 6 +- .../pages/swap/hooks/use-sponsor-tx-fees.tsx | 65 ++++++++ .../transaction-request.tsx | 72 +++++++-- .../remote-config/remote-config.query.ts | 20 ++- .../sbtc/sponsored-transactions.hooks.ts | 65 ++++++++ .../sbtc/sponsored-transactions.query.ts | 91 +++++++++++ .../store/transactions/contract-call.hooks.ts | 3 +- 13 files changed, 454 insertions(+), 84 deletions(-) create mode 100644 src/app/pages/swap/hooks/use-sponsor-tx-fees.tsx create mode 100644 src/app/query/sbtc/sponsored-transactions.hooks.ts create mode 100644 src/app/query/sbtc/sponsored-transactions.query.ts diff --git a/config/wallet-config.json b/config/wallet-config.json index 77669e11c61..9e72752b879 100644 --- a/config/wallet-config.json +++ b/config/wallet-config.json @@ -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" diff --git a/config/wallet-config.schema.json b/config/wallet-config.schema.json index 3bcf5f1df35..1756875564e 100644 --- a/config/wallet-config.schema.json +++ b/config/wallet-config.schema.json @@ -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", diff --git a/src/app/features/stacks-transaction-request/fee-form.tsx b/src/app/features/stacks-transaction-request/fee-form.tsx index f03c1230caa..be6f0e3e74d 100644 --- a/src/app/features/stacks-transaction-request/fee-form.tsx +++ b/src/app/features/stacks-transaction-request/fee-form.tsx @@ -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(); const transaction = useUnsignedPrepareTransactionDetails(values); - const isSponsored = transaction ? isTxSponsored(transaction) : false; return ( <> - {fees?.estimates.length ? ( + {!!sbtcSponsorshipEligibility && fees?.estimates.length ? ( ) : ( diff --git a/src/app/features/stacks-transaction-request/hooks/use-transaction-error.ts b/src/app/features/stacks-transaction-request/hooks/use-transaction-error.ts index fce97c9e6ae..0b4f4b96a5e 100644 --- a/src/app/features/stacks-transaction-request/hooks/use-transaction-error.ts +++ b/src/app/features/stacks-transaction-request/hooks/use-transaction-error.ts @@ -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'; @@ -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'; @@ -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(() => { if (!origin) return TransactionErrorReason.ExpiredRequest; - if (filteredBalanceQuery.isLoading) return; + if (filteredBalanceQuery.isLoading || isVerifyingSbtcEligibilty) return; if (!transactionRequest || !availableUnlockedBalance || !currentAccount) { return TransactionErrorReason.Generic; @@ -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); @@ -73,5 +90,7 @@ export function useTransactionError() { availableUnlockedBalance, currentAccount, values.fee, + isVerifyingSbtcEligibilty, + sbtcSponsorshipEligibility, ]); } diff --git a/src/app/pages/swap/bitflow-swap-container.tsx b/src/app/pages/swap/bitflow-swap-container.tsx index aede9359f2f..a017a988867 100644 --- a/src/app/pages/swap/bitflow-swap-container.tsx +++ b/src/app/pages/swap/bitflow-swap-container.tsx @@ -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'; @@ -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'; @@ -29,6 +33,7 @@ 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'; @@ -36,6 +41,7 @@ import { SwapContext, SwapProvider } from './swap.context'; export const bitflowSwapRoutes = generateSwapRoutes(); function BitflowSwapContainer() { + const [unsignedTx, setUnsignedTx] = useState(); const [isSendingMax, setIsSendingMax] = useState(false); const [isPreparingSwapReview, setIsPreparingSwapReview] = useState(false); const navigate = useNavigate(); @@ -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, @@ -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; } @@ -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 }, }); @@ -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, ] ); @@ -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, { @@ -196,8 +228,6 @@ function BitflowSwapContainer() { }, [ broadcastStacksSwap, currentAccount, - fetchRouteQuote, - generateUnsignedTx, isCrossChainSwap, isLoading, navigate, @@ -205,7 +235,10 @@ function BitflowSwapContainer() { setIsIdle, setIsLoading, signTx, + sponsorshipEligibility, + submitSponsoredTx, swapSubmissionData, + unsignedTx, ]); const swapContextValue: SwapContext = { diff --git a/src/app/pages/swap/bitflow-swap.utils.ts b/src/app/pages/swap/bitflow-swap.utils.ts index 715e2a16ad8..22b7cbf2e3d 100644 --- a/src/app/pages/swap/bitflow-swap.utils.ts +++ b/src/app/pages/swap/bitflow-swap.utils.ts @@ -3,7 +3,7 @@ import type { RouteQuote } from 'bitflow-sdk'; import { BtcFeeType, FeeTypes } from '@leather.io/models'; import { type SwapAsset, defaultSwapFee } from '@leather.io/query'; -import { capitalize, isDefined, microStxToStx } from '@leather.io/utils'; +import { capitalize, isDefined } from '@leather.io/utils'; import type { SwapFormValues } from '@shared/models/form.model'; @@ -31,7 +31,7 @@ export function getStacksSwapSubmissionData({ values, }: getStacksSwapSubmissionDataArgs): SwapSubmissionData { return { - fee: microStxToStx(defaultSwapFee.amount).toNumber(), + fee: defaultSwapFee.amount.toString(), feeCurrency: 'STX', feeType: FeeTypes[FeeTypes.Middle], liquidityFee: estimateLiquidityFee(routeQuote.route.dex_path), diff --git a/src/app/pages/swap/components/swap-details/swap-details.tsx b/src/app/pages/swap/components/swap-details/swap-details.tsx index bc9399a17ad..c7ae3980cb5 100644 --- a/src/app/pages/swap/components/swap-details/swap-details.tsx +++ b/src/app/pages/swap/components/swap-details/swap-details.tsx @@ -4,6 +4,8 @@ import { HStack, styled } from 'leather-styles/jsx'; import { ChevronRightIcon } from '@leather.io/ui'; import { + convertAmountToBaseUnit, + createMoney, createMoneyFromDecimal, formatMoneyPadded, isDefined, @@ -97,7 +99,9 @@ export function SwapDetails() { value={ swapSubmissionData.sponsored ? 'Sponsored' - : `${swapSubmissionData.fee.toString()} ${swapSubmissionData.feeCurrency}` + : `${convertAmountToBaseUnit( + createMoney(new BigNumber(swapSubmissionData.fee), swapSubmissionData.feeCurrency) + ).toString()} ${swapSubmissionData.feeCurrency}` } /> {Number(swapSubmissionData?.nonce) >= 0 ? ( diff --git a/src/app/pages/swap/hooks/use-sponsor-tx-fees.tsx b/src/app/pages/swap/hooks/use-sponsor-tx-fees.tsx new file mode 100644 index 00000000000..29b9ef4553c --- /dev/null +++ b/src/app/pages/swap/hooks/use-sponsor-tx-fees.tsx @@ -0,0 +1,65 @@ +import { useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import type { StacksTransaction } from '@stacks/transactions'; + +import { FeeTypes } from '@leather.io/models'; +import { defaultFeesMaxValuesAsMoney } from '@leather.io/query'; + +import { logger } from '@shared/logger'; +import type { SwapFormValues } from '@shared/models/form.model'; +import { RouteUrls } from '@shared/route-urls'; + +import { LoadingKeys, useLoading } from '@app/common/hooks/use-loading'; +import { useToast } from '@app/features/toasts/use-toast'; +import { useConfigSbtc } from '@app/query/common/remote-config/remote-config.query'; +import { + type TransactionBase, + submitSponsoredSbtcTransaction, + verifySponsoredSbtcTransaction, +} from '@app/query/sbtc/sponsored-transactions.query'; +import { useSignStacksTransaction } from '@app/store/transactions/transaction.hooks'; + +export function useSponsorTransactionFees() { + const { sponsorshipApiUrl } = useConfigSbtc(); + const { setIsIdle } = useLoading(LoadingKeys.SUBMIT_SWAP_TRANSACTION); + const signTx = useSignStacksTransaction(); + const navigate = useNavigate(); + const toast = useToast(); + + const checkEligibilityForSponsor = async (values: SwapFormValues, baseTx: TransactionBase) => { + return await verifySponsoredSbtcTransaction({ + apiUrl: sponsorshipApiUrl, + baseTx, + nonce: Number(values.nonce), + fee: defaultFeesMaxValuesAsMoney[FeeTypes.Middle].amount.toNumber(), + }); + }; + + const submitSponsoredTx = useCallback( + async (unsignedSponsoredTx: StacksTransaction) => { + try { + const signedSponsoredTx = await signTx(unsignedSponsoredTx); + if (!signedSponsoredTx) return logger.error('Unable to sign sponsored transaction!'); + + const result = await submitSponsoredSbtcTransaction(sponsorshipApiUrl, signedSponsoredTx); + if (!result.txid) { + navigate(RouteUrls.SwapError, { state: { message: result.error } }); + return; + } + + toast.success('Transaction submitted!'); + setIsIdle(); + navigate(RouteUrls.Activity); + } catch (error) { + return logger.error('Failed to submit sponsor transaction', error); + } + }, + [navigate, setIsIdle, signTx, toast, sponsorshipApiUrl] + ); + + return { + checkEligibilityForSponsor, + submitSponsoredTx, + }; +} diff --git a/src/app/pages/transaction-request/transaction-request.tsx b/src/app/pages/transaction-request/transaction-request.tsx index e34a72c4de0..f910f912e32 100644 --- a/src/app/pages/transaction-request/transaction-request.tsx +++ b/src/app/pages/transaction-request/transaction-request.tsx @@ -13,13 +13,17 @@ import { useStxCryptoAssetBalance, } from '@leather.io/query'; import { Link } from '@leather.io/ui'; +import { isString } from '@leather.io/utils'; +import { finalizeTxSignature } from '@shared/actions/finalize-tx-signature'; import { logger } from '@shared/logger'; import { StacksTransactionFormValues } from '@shared/models/form.model'; import { RouteUrls } from '@shared/route-urls'; import { analytics } from '@shared/utils/analytics'; +import { useDefaultRequestParams } from '@app/common/hooks/use-default-request-search-params'; import { useOnMount } from '@app/common/hooks/use-on-mount'; +import { stacksTransactionToHex } from '@app/common/transactions/stacks/transaction.utils'; import { stxFeeValidator } from '@app/common/validation/forms/fee-validators'; import { nonceValidator } from '@app/common/validation/nonce-validators'; import { NonceSetter } from '@app/components/nonce-setter'; @@ -37,28 +41,48 @@ import { PostConditions } from '@app/features/stacks-transaction-request/post-co import { StxTransferDetails } from '@app/features/stacks-transaction-request/stx-transfer-details/stx-transfer-details'; import { StacksTxSubmitAction } from '@app/features/stacks-transaction-request/submit-action'; import { TransactionError } from '@app/features/stacks-transaction-request/transaction-error/transaction-error'; +import { useConfigSbtc } from '@app/query/common/remote-config/remote-config.query'; +import { useCheckSbtcSponsorshipEligible } from '@app/query/sbtc/sponsored-transactions.hooks'; +import { submitSponsoredSbtcTransaction } from '@app/query/sbtc/sponsored-transactions.query'; import { useCurrentStacksAccountAddress } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; -import { useTransactionRequestState } from '@app/store/transactions/requests.hooks'; +import { + useTransactionRequest, + useTransactionRequestState, +} from '@app/store/transactions/requests.hooks'; import { useGenerateUnsignedStacksTransaction, + useSignStacksTransaction, useUnsignedStacksTransactionBaseState, } from '@app/store/transactions/transaction.hooks'; function TransactionRequestBase() { + const sbtcConfig = useConfigSbtc(); + const { tabId } = useDefaultRequestParams(); + const requestToken = useTransactionRequest(); + const transactionRequest = useTransactionRequestState(); const unsignedTx = useUnsignedStacksTransactionBaseState(); const { data: stxFees } = useCalculateStacksTxFees(unsignedTx.transaction); const generateUnsignedTx = useGenerateUnsignedStacksTransaction(); const stxAddress = useCurrentStacksAccountAddress(); + const { filteredBalanceQuery } = useStxCryptoAssetBalance(stxAddress); const availableUnlockedBalance = filteredBalanceQuery.data?.availableUnlockedBalance; const { data: nextNonce, status: nonceQueryStatus } = useNextNonce(stxAddress); - const canSubmit = filteredBalanceQuery.status === 'success' && nonceQueryStatus === 'success'; + + const { isVerifying: isVerifyingSbtcSponsorship, result: sbtcSponsorshipEligibility } = + useCheckSbtcSponsorshipEligible(unsignedTx, nextNonce, stxFees); + + const canSubmit = + filteredBalanceQuery.status === 'success' && + nonceQueryStatus === 'success' && + !isVerifyingSbtcSponsorship; const navigate = useNavigate(); const { stacksBroadcastTransaction } = useStacksBroadcastTransaction({ token: 'STX' }); + const signStacksTransaction = useSignStacksTransaction(); useOnMount(() => void analytics.track('view_transaction_signing')); @@ -67,12 +91,42 @@ function TransactionRequestBase() { formikHelpers: FormikHelpers ) { formikHelpers.setSubmitting(true); - const unsignedTx = await generateUnsignedTx(values); - - if (!unsignedTx) - return logger.error('Failed to generate unsigned transaction in transaction-request'); - - await stacksBroadcastTransaction(unsignedTx); + if (sbtcSponsorshipEligibility?.isEligible) { + try { + const signedSponsoredTx = await signStacksTransaction( + sbtcSponsorshipEligibility.unsignedSponsoredTx! + ); + if (!signedSponsoredTx) throw new Error('Unable to sign sponsored transaction!'); + const result = await submitSponsoredSbtcTransaction( + sbtcConfig.sponsorshipApiUrl, + signedSponsoredTx + ); + if (!result.txid) { + navigate(RouteUrls.TransactionBroadcastError, { state: { message: result.error } }); + return; + } + if (requestToken && tabId) { + finalizeTxSignature({ + requestPayload: requestToken, + tabId: tabId, + data: { + txRaw: stacksTransactionToHex(signedSponsoredTx), + txId: result.txid, + }, + }); + } + } catch (e: any) { + const message = isString(e) ? e : e.message; + navigate(RouteUrls.TransactionBroadcastError, { state: { message } }); + } + } else { + const unsignedTx = await generateUnsignedTx(values); + + if (!unsignedTx) + return logger.error('Failed to generate unsigned transaction in transaction-request'); + + await stacksBroadcastTransaction(unsignedTx); + } void analytics.track('submit_fee_for_transaction', { calculation: stxFees?.calculation || 'unknown', @@ -128,7 +182,7 @@ function TransactionRequestBase() { {transactionRequest.txType === 'smart_contract' && } - + ; emilyApiUrl: string; + sponsorshipApiUrl: { + mainnet: string; + testnet: string; + }; swapsEnabled: boolean; + sponsorshipsEnabled: boolean; } export function useConfigSbtc() { @@ -57,11 +62,16 @@ export function useConfigSbtc() { const displayPromoCardOnNetworks = (sbtc as any)?.showPromoLinkOnNetworks ?? []; const contractIdMainnet = sbtc?.contracts.mainnet.address ?? ''; const contractIdTestnet = sbtc?.contracts.testnet.address ?? ''; + const apiUrlMainnet = sbtc?.sponsorshipApiUrl.mainnet ?? ''; + const apiUrlTestnet = sbtc?.sponsorshipApiUrl.testnet ?? ''; return { + configLoading: !sbtc, isSbtcEnabled: sbtc?.enabled ?? false, + isSbtcSponsorshipsEnabled: (sbtc?.enabled && sbtc?.sponsorshipsEnabled) ?? false, emilyApiUrl: sbtc?.emilyApiUrl ?? '', contractId: network.chain.bitcoin.mode === 'mainnet' ? contractIdMainnet : contractIdTestnet, + sponsorshipApiUrl: network.chain.bitcoin.mode === 'mainnet' ? apiUrlMainnet : apiUrlTestnet, isSbtcContract(contract: string) { return ( contract === getPrincipalFromContractId(contractIdMainnet) || diff --git a/src/app/query/sbtc/sponsored-transactions.hooks.ts b/src/app/query/sbtc/sponsored-transactions.hooks.ts new file mode 100644 index 00000000000..3a6335806ad --- /dev/null +++ b/src/app/query/sbtc/sponsored-transactions.hooks.ts @@ -0,0 +1,65 @@ +import { useEffect, useState } from 'react'; + +import { FeeTypes, type Fees } from '@leather.io/models'; +import type { NextNonce } from '@leather.io/query'; + +import { logger } from '@shared/logger'; + +import { useCurrentStacksAccountAddress } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; + +import { useConfigSbtc } from '../common/remote-config/remote-config.query'; +import { + type SbtcSponsorshipEligibility, + type SbtcSponsorshipVerificationResult, + type TransactionBase, + verifySponsoredSbtcTransaction, +} from './sponsored-transactions.query'; + +export function useCheckSbtcSponsorshipEligible( + baseTx?: TransactionBase, + nextNonce?: NextNonce, + stxFees?: Fees +): SbtcSponsorshipVerificationResult { + const sbtcConfig = useConfigSbtc(); + const stxAddress = useCurrentStacksAccountAddress(); + const [isLoading, setIsLoading] = useState(true); + const [result, setResult] = useState(); + const [lastAddressChecked, setLastAddressChecked] = useState(); + + useEffect(() => { + if (!sbtcConfig.configLoading && !sbtcConfig.isSbtcSponsorshipsEnabled) { + if (isLoading) setIsLoading(false); + return; + } + if (!(sbtcConfig && baseTx && nextNonce && stxFees)) { + return; + } + if (result && stxAddress === lastAddressChecked) { + return; + } + // use the standard recommended fee from estimates + const standardFeeEstimate = stxFees.estimates[FeeTypes.Middle].fee.amount.toNumber(); + verifySponsoredSbtcTransaction({ + apiUrl: sbtcConfig.sponsorshipApiUrl, + baseTx, + nonce: nextNonce.nonce, + fee: standardFeeEstimate, + }) + .then(result => { + setResult(result); + setLastAddressChecked(stxAddress); + }) + .catch(e => { + logger.error('Verification failure: ', e); + setResult({ isEligible: false }); + }) + .finally(() => { + setIsLoading(false); + }); + }, [baseTx, stxFees, result, stxAddress, lastAddressChecked, nextNonce, isLoading, sbtcConfig]); + + return { + isVerifying: isLoading, + result, + }; +} diff --git a/src/app/query/sbtc/sponsored-transactions.query.ts b/src/app/query/sbtc/sponsored-transactions.query.ts new file mode 100644 index 00000000000..1010ff607dc --- /dev/null +++ b/src/app/query/sbtc/sponsored-transactions.query.ts @@ -0,0 +1,91 @@ +import { bytesToHex } from '@stacks/common'; +import { StacksTransaction } from '@stacks/transactions'; +import axios from 'axios'; + +import { logger } from '@shared/logger'; + +import { queryClient } from '@app/common/persistence'; +import { generateUnsignedTransaction } from '@app/common/transactions/stacks/generate-unsigned-txs'; + +export interface TransactionBase { + options?: any; + transaction: StacksTransaction | undefined; +} + +export interface SbtcSponsorshipVerificationResult { + isVerifying: boolean; + result: SbtcSponsorshipEligibility | undefined; +} + +export interface SbtcSponsorshipEligibility { + isEligible: boolean; + unsignedSponsoredTx?: StacksTransaction; +} + +interface SbtcSponsorshipSubmissionResult { + txid?: string; + error?: string; +} + +export async function submitSponsoredSbtcTransaction( + apiUrl: string, + sponsoredTx: StacksTransaction +): Promise { + try { + const { data } = await axios.post(`${apiUrl}/submit`, { + tx: bytesToHex(sponsoredTx.serialize()), + }); + return { + txid: data.txid, + }; + } catch (error: any) { + const errMsg = `sBTC Sponsorship Failure (${error?.response?.data?.error || 'Unknown'})`; + return { + error: errMsg, + }; + } +} + +interface VerifySponsoredSbtcTransactionArgs { + apiUrl: string; + baseTx: TransactionBase; + nonce?: number; + fee?: number; +} +export async function verifySponsoredSbtcTransaction({ + apiUrl, + baseTx, + nonce, + fee, +}: VerifySponsoredSbtcTransactionArgs): Promise { + try { + // add sponsorship option + const { options } = baseTx as any; + options.txData.sponsored = true; + const sponsoredTx = await generateUnsignedTransaction({ + ...options, + fee, + nonce, + }); + const serializedTx = bytesToHex(sponsoredTx.serialize()); + + const result = await queryClient.fetchQuery({ + queryKey: ['verify-sponsored-sbtc-transaction', serializedTx], + queryFn: async () => { + const { data } = await axios.post( + `${apiUrl}/verify`, + { + tx: serializedTx, + }, + { timeout: 5000 } + ); + return data; + }, + }); + + return { isEligible: result, unsignedSponsoredTx: sponsoredTx }; + } catch (error) { + logger.error('Transaction verification failed:', error); + return { isEligible: false }; + } +} diff --git a/src/app/store/transactions/contract-call.hooks.ts b/src/app/store/transactions/contract-call.hooks.ts index 6f758edd864..7579c0ff23f 100644 --- a/src/app/store/transactions/contract-call.hooks.ts +++ b/src/app/store/transactions/contract-call.hooks.ts @@ -30,7 +30,8 @@ export function useGenerateStacksContractCallUnsignedTx() { fee: values.fee ?? 0, txData: { ...payload, network }, }; - return generateUnsignedTransaction(options); + const transaction = await generateUnsignedTransaction(options); + return { transaction, options }; }, [account, network, nextNonce?.nonce] );