From 107194dae91df0f06014e285c8be0d5cd35e688a Mon Sep 17 00:00:00 2001 From: Alex Perry Date: Sun, 15 Dec 2024 03:28:22 +0100 Subject: [PATCH] feat: sponsored sbtc txns --- .../stacks-transaction-request/fee-form.tsx | 14 +- .../transaction-request.tsx | 46 +++++-- src/app/query/sbtc/sponsored-transactions.ts | 126 ++++++++++++++++++ 3 files changed, 174 insertions(+), 12 deletions(-) create mode 100644 src/app/query/sbtc/sponsored-transactions.ts diff --git a/src/app/features/stacks-transaction-request/fee-form.tsx b/src/app/features/stacks-transaction-request/fee-form.tsx index f03c1230caa..4dc95ec9c67 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'; 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/pages/transaction-request/transaction-request.tsx b/src/app/pages/transaction-request/transaction-request.tsx index e34a72c4de0..241956444ef 100644 --- a/src/app/pages/transaction-request/transaction-request.tsx +++ b/src/app/pages/transaction-request/transaction-request.tsx @@ -13,6 +13,7 @@ import { useStxCryptoAssetBalance, } from '@leather.io/query'; import { Link } from '@leather.io/ui'; +import { isString } from '@leather.io/utils'; import { logger } from '@shared/logger'; import { StacksTransactionFormValues } from '@shared/models/form.model'; @@ -37,10 +38,15 @@ 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 { + submitSponsoredSbtcTransaction, + useCheckSbtcSponsorshipEligible, +} from '@app/query/sbtc/sponsored-transactions'; import { useCurrentStacksAccountAddress } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; import { useTransactionRequestState } from '@app/store/transactions/requests.hooks'; import { useGenerateUnsignedStacksTransaction, + useSignStacksTransaction, useUnsignedStacksTransactionBaseState, } from '@app/store/transactions/transaction.hooks'; @@ -51,14 +57,23 @@ function TransactionRequestBase() { 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 +82,27 @@ 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(signedSponsoredTx); + if (!result.txId) + navigate(RouteUrls.TransactionBroadcastError, { state: { message: result.error } }); + } 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 +158,7 @@ function TransactionRequestBase() { {transactionRequest.txType === 'smart_contract' && } - + { + logger.debug('Submitting Sponsored sBTC Transaction!'); + try { + const { data } = await axios.post('http://localhost:5001/submit', { + tx: bytesToHex(sponsoredTx.serialize()), + }); + logger.debug('sBTC Sponsorship Success:', data.txId); + return { + txId: data.txId, + }; + } catch (error: any) { + const errMsg = `sBTC Sponsorship Failure (${error?.response?.data?.error || 'Unknkown'})`; + return { + error: errMsg, + }; + } +} + +async function verifySponsoredSbtcTransaction( + baseTx: TransactionBase, + nextNonce: NextNonce, + stxFees: Fees +): Promise { + logger.debug('Verifying Sponsored sBTC Transaction!'); + try { + // use the standard recommended fee + const standardFee = stxFees.estimates[FeeTypes.Middle].fee.amount.toNumber(); + // add sponsorship option + const { options } = baseTx as any; + options.txData.sponsored = true; + const sponsoredTx = await generateUnsignedTransaction({ + ...options, + fee: standardFee, + nonce: nextNonce?.nonce, + }); + const serializedTx = bytesToHex(sponsoredTx.serialize()); + logger.debug('Pre-serialization:', { + fee: sponsoredTx.auth.spendingCondition?.fee, + authType: sponsoredTx.auth.authType, + rawTx: serializedTx, + }); + + const { data } = await axios.post('http://localhost:5001/verify', { + tx: serializedTx, + }); + + logger.debug('Verification response:', data); + return { isEligible: true, unsignedSponsoredTx: sponsoredTx }; + } catch (error) { + logger.error('Transaction verification failed:', error); + return { isEligible: false }; + } +} + +export function useCheckSbtcSponsorshipEligible( + baseTx?: TransactionBase, + nextNonce?: any, + stxFees?: Fees +): SbtcSponsorshipVerificationResult { + const [isLoading, setIsLoading] = useState(true); + const [result, setResult] = useState(); + const stxAddress = useCurrentStacksAccountAddress(); + const [lastAddressChecked, setLastAddressChecked] = useState(); + + useEffect(() => { + if (!(baseTx && nextNonce && stxFees)) { + return; + } + if (result && stxAddress === lastAddressChecked) { + return; + } + verifySponsoredSbtcTransaction(baseTx, nextNonce, stxFees) + .then(result => { + setResult(result); + setLastAddressChecked(stxAddress); + }) + .catch(e => { + logger.error('Verification failure: ', e); + setResult({ isEligible: false }); + }) + .finally(() => { + setIsLoading(false); + }); + }, [baseTx, nextNonce, stxFees, result, stxAddress, lastAddressChecked]); + + return { + isVerifying: isLoading, + result, + }; +}