Skip to content

Commit

Permalink
feat: sponsored sbtc txns
Browse files Browse the repository at this point in the history
  • Loading branch information
alexp3y authored and fbwoolf committed Dec 15, 2024
1 parent a46e95f commit 107194d
Show file tree
Hide file tree
Showing 3 changed files with 174 additions and 12 deletions.
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';
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
46 changes: 38 additions & 8 deletions src/app/pages/transaction-request/transaction-request.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand All @@ -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'));

Expand All @@ -67,12 +82,27 @@ function TransactionRequestBase() {
formikHelpers: FormikHelpers<StacksTransactionFormValues>
) {
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',
Expand Down Expand Up @@ -128,7 +158,7 @@ function TransactionRequestBase() {
{transactionRequest.txType === 'smart_contract' && <ContractDeployDetails />}

<NonceSetter />
<FeeForm fees={stxFees} />
<FeeForm fees={stxFees} sbtcSponsorshipEligibility={sbtcSponsorshipEligibility} />
<Link
alignSelf="flex-end"
my="space.04"
Expand Down
126 changes: 126 additions & 0 deletions src/app/query/sbtc/sponsored-transactions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { useEffect, useState } from 'react';

import { bytesToHex } from '@stacks/common';
import { StacksTransaction } from '@stacks/transactions';
import axios from 'axios';

import { FeeTypes, type Fees } from '@leather.io/models';
import { type NextNonce } from '@leather.io/query';

import { logger } from '@shared/logger';

import { generateUnsignedTransaction } from '@app/common/transactions/stacks/generate-unsigned-txs';
import { useCurrentStacksAccountAddress } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks';

interface TransactionBase {
options: any;
transaction: StacksTransaction | undefined;
}

interface SbtcSponsorshipVerificationResult {
isVerifying: boolean;
result: SbtcSponsorshipEligibility | undefined;
}

export interface SbtcSponsorshipEligibility {
isEligible: boolean;
unsignedSponsoredTx?: StacksTransaction;
}

interface SbtcSponsorshipSubmissionResult {
txId?: string;
error?: string;
}

export async function submitSponsoredSbtcTransaction(
sponsoredTx: StacksTransaction
): Promise<SbtcSponsorshipSubmissionResult> {
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<SbtcSponsorshipEligibility> {
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<SbtcSponsorshipEligibility | undefined>();
const stxAddress = useCurrentStacksAccountAddress();
const [lastAddressChecked, setLastAddressChecked] = useState<string | undefined>();

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,
};
}

0 comments on commit 107194d

Please sign in to comment.