Skip to content

Commit

Permalink
fixup! feat(suite): add timer to solana tx modal
Browse files Browse the repository at this point in the history
  • Loading branch information
izmy committed Feb 21, 2025
1 parent e2bc122 commit e2ca37d
Show file tree
Hide file tree
Showing 11 changed files with 208 additions and 56 deletions.
25 changes: 16 additions & 9 deletions packages/blockchain-link/src/workers/solana/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import {
RpcMainnet,
RpcSubscriptionsMainnet,
SOLANA_ERROR__BLOCK_HEIGHT_EXCEEDED,
SOLANA_ERROR__JSON_RPC__SERVER_ERROR_SEND_TRANSACTION_PREFLIGHT_FAILURE,
SOLANA_ERROR__RPC_SUBSCRIPTIONS__CHANNEL_CONNECTION_CLOSED,
SOLANA_ERROR__RPC_SUBSCRIPTIONS__CHANNEL_FAILED_TO_CONNECT,
SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR,
SOLANA_ERROR__TRANSACTION_ERROR__BLOCKHASH_NOT_FOUND,
Signature,
Slot,
SolanaRpcApiMainnet,
Expand Down Expand Up @@ -196,16 +198,21 @@ const pushTransaction = async (request: Request<MessageTypes.PushTransaction>) =
'Solana backend connection failure. The backend might be inaccessible or the connection is unstable.',
);
}
if (
isSolanaError(
error,
SOLANA_ERROR__JSON_RPC__SERVER_ERROR_SEND_TRANSACTION_PREFLIGHT_FAILURE,
) &&
isSolanaError(error.cause, SOLANA_ERROR__TRANSACTION_ERROR__BLOCKHASH_NOT_FOUND)
) {
throw new Error(
'The transaction has expired because too much time passed between signing and sending. Please try again.',
);
}
if (isSolanaError(error)) {
if (error.context.__code === -32002) {
throw new Error(
'The transaction has expired because too much time passed between signing and sending. Please try again.',
);
} else {
throw new Error(
`Solana error code: ${error.context.__code}. Please try again or contact support.`,
);
}
throw new Error(
`Solana error code: ${error.context.__code}. Please try again or contact support.`,
);
}
throw error;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { UserContextPayload } from '@suite-common/suite-types';
import { cancelSignSendFormTransactionThunk, selectStake } from '@suite-common/wallet-core';

import { signAndPushSendFormTransactionThunk } from 'src/actions/wallet/send/sendFormThunks';
import { cancelSignTx as cancelSignStakingTx } from 'src/actions/wallet/stakeActions';
import { useDispatch, useSelector } from 'src/hooks/suite';

Expand All @@ -19,6 +20,7 @@ type TransactionReviewModalProps =
export const TransactionReviewModal = ({ type, decision }: TransactionReviewModalProps) => {
const send = useSelector(state => state.wallet.send);
const stake = useSelector(selectStake);
const selectedAccount = useSelector(state => state.wallet.selectedAccount);
const dispatch = useDispatch();

const isSend = Boolean(send?.precomposedTx);
Expand All @@ -30,10 +32,24 @@ export const TransactionReviewModal = ({ type, decision }: TransactionReviewModa
else dispatch(cancelSignStakingTx());
};

const handleTryAgainSignTx = () => {
if (send.precomposedForm != null && send.precomposedTx != null) {
dispatch(
signAndPushSendFormTransactionThunk({
formState: send.precomposedForm,
precomposedTransaction: send.precomposedTx,
selectedAccount: selectedAccount.account,
}),
);
}
};

return (
<TransactionReviewModalContent
timestamp={send.timestamp}
decision={decision}
txInfoState={txInfoState}
tryAgainSignTx={handleTryAgainSignTx}
cancelSignTx={handleCancelSignTx}
isRbfConfirmedError={type === 'review-transaction-rbf-previous-transaction-mined-error'}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,18 @@ import {
isRbfCancelTransaction,
isRbfTransaction,
} from '@suite-common/wallet-utils';
import { Column, NewModal } from '@trezor/components';
import { Button, Column, NewModal, Row } from '@trezor/components';
import TrezorConnect from '@trezor/connect';
import { copyToClipboard, download } from '@trezor/dom-utils';
import { ConfirmOnDevice } from '@trezor/product-components';
import { useTimer } from '@trezor/react-utils';
import { EventType, TransactionCreatedEvent, analytics } from '@trezor/suite-analytics';
import { spacings } from '@trezor/theme';
import { Deferred } from '@trezor/utils';

import * as modalActions from 'src/actions/suite/modalActions';
import { Translation } from 'src/components/suite';
import { useDispatch, useSelector } from 'src/hooks/suite';
import { useTransactionTimeout } from 'src/hooks/useTransactionTimeout';
import { selectIsActionAbortable } from 'src/reducers/suite/suiteReducer';
import { selectAccountIncludingChosenInTrading } from 'src/reducers/wallet/selectedAccountReducer';
import { getTransactionReviewModalActionText } from 'src/utils/suite/transactionReview';
Expand All @@ -41,7 +42,7 @@ import { ConfirmActionModal } from '../DeviceContextModal/ConfirmActionModal';
import { ExpiredBlockhash } from '../UserContextModal/TxDetailModal/ExpiredBlockhash';
import { ReplaceByFeeFailedOriginalTxConfirmed } from '../UserContextModal/TxDetailModal/ReplaceByFeeFailedOriginalTxConfirmed';

const SOLANA_TIMEOUT = 10;
const SOLANA_TX_TIMEOUT_SECONDS = 58;

const isStakeState = (state: SendState | StakeState): state is StakeState => 'data' in state;

Expand All @@ -57,15 +58,19 @@ const mapRbfTypeToReporting: Record<
};

type TransactionReviewModalContentProps = {
timestamp?: number;
decision: Deferred<boolean, string | number | undefined> | undefined;
txInfoState: SendState | StakeState;
tryAgainSignTx: () => void;
cancelSignTx: () => void;
isRbfConfirmedError?: boolean;
};

export const TransactionReviewModalContent = ({
timestamp = 0,
decision,
txInfoState,
tryAgainSignTx,
cancelSignTx,
isRbfConfirmedError,
}: TransactionReviewModalContentProps) => {
Expand All @@ -76,8 +81,9 @@ export const TransactionReviewModalContent = ({
const [isSending, setIsSending] = useState(false);
const [areDetailsVisible, setAreDetailsVisible] = useState(false);

const timer = useTimer();
const remainingTime = Math.max(0, SOLANA_TIMEOUT - timer.timeSpend.seconds);
const remainingTime = useTransactionTimeout(timestamp, SOLANA_TX_TIMEOUT_SECONDS, () => {
TrezorConnect.cancel('tx-timeout');
});

const deviceModelInternal = device?.features?.internal_model;
const { precomposedTx, serializedTx } = txInfoState;
Expand Down Expand Up @@ -124,7 +130,7 @@ export const TransactionReviewModalContent = ({
: getTxStakeNameByDataHex(outputs[0]?.value);

const onCancel = () => {
if (isRbfConfirmedError) {
if (isRbfConfirmedError || networkType === 'solana') {
dispatch(modalActions.onCancel());
}

Expand Down Expand Up @@ -202,6 +208,14 @@ export const TransactionReviewModalContent = ({
reportTransactionCreatedEvent('downloaded');
};

const handleTryAgain = (cancel: boolean) => {
if (cancel) {
TrezorConnect.cancel('tx-timeout');
}

tryAgainSignTx();
};

const BottomContent = () => {
if (isRbfConfirmedError) {
return (
Expand All @@ -214,7 +228,7 @@ export const TransactionReviewModalContent = ({
if (isSolanaExpired) {
return (
<>
<NewModal.Button variant="primary" onClick={onCancel}>
<NewModal.Button variant="primary" onClick={() => handleTryAgain(false)}>
<Translation id="TR_TRY_AGAIN" />
</NewModal.Button>
<NewModal.Button variant="tertiary" onClick={onCancel}>
Expand Down Expand Up @@ -276,15 +290,11 @@ export const TransactionReviewModalContent = ({
}

if (isSolanaExpired) {
return <ExpiredBlockhash />;
return <ExpiredBlockhash symbol={symbol} />;
}

return (
<Column gap={spacings.md}>
{networkType === 'solana' && (
<TransactionReviewOutputTimer remainingTime={remainingTime} />
)}

<TransactionReviewOutputList
account={account}
precomposedTx={precomposedTx}
Expand All @@ -295,6 +305,8 @@ export const TransactionReviewModalContent = ({
isTradingAction={isTradingAction}
isSending={isSending}
stakeType={stakeType || undefined}
remainingTime={remainingTime}
onTryAgain={handleTryAgain}
/>
</Column>
);
Expand All @@ -318,15 +330,36 @@ export const TransactionReviewModalContent = ({
onBackClick={areDetailsVisible ? () => setAreDetailsVisible(false) : undefined}
description={
!areDetailsVisible && (
<TransactionReviewSummary
tx={precomposedTx}
account={account}
broadcast={isBroadcastEnabled}
onDetailsClick={() => {
setAreDetailsVisible(!areDetailsVisible);
}}
stakeType={stakeType}
/>
<Row justifyContent="space-between">
<TransactionReviewSummary
tx={precomposedTx}
account={account}
broadcast={isBroadcastEnabled}
onDetailsClick={() => {
setAreDetailsVisible(!areDetailsVisible);
}}
stakeType={stakeType}
/>
{networkType === 'solana' &&
!isSolanaExpired &&
buttonRequestsCount != 1 && (
<Row gap={spacings.xs}>
<Button
icon="arrowClockwise"
variant="tertiary"
type="button"
size="tiny"
onClick={() => handleTryAgain(true)}
>
<Translation id="TR_AGAIN" />
</Button>
<TransactionReviewOutputTimer
remainingTime={remainingTime}
isMinimal
/>
</Row>
)}
</Row>
)
}
bottomContent={<BottomContent />}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { Account } from 'src/types/wallet';

import { TransactionReviewOutput } from './TransactionReviewOutput';
import type { TransactionReviewOutputElementProps } from './TransactionReviewOutputElement';
import { TransactionReviewOutputTimer } from './TransactionReviewOutputTimer';
import { TransactionReviewTotalOutput } from './TransactionReviewTotalOutput';

export type TransactionReviewOutputListProps = {
Expand All @@ -26,6 +27,8 @@ export type TransactionReviewOutputListProps = {
isTradingAction: boolean;
isSending?: boolean;
stakeType?: StakeType;
remainingTime?: number;
onTryAgain?: (close: boolean) => void;
};

const getState = (
Expand Down Expand Up @@ -73,6 +76,8 @@ export const TransactionReviewOutputList = ({
isTradingAction,
isSending,
stakeType,
remainingTime,
onTryAgain,
}: TransactionReviewOutputListProps) => {
const outputRefs = useRef<(HTMLDivElement | null)[]>([]);
const totalOutputRef = useRef<HTMLDivElement | null>(null);
Expand Down Expand Up @@ -108,10 +113,18 @@ export const TransactionReviewOutputList = ({
) {
return (
<Card>
<Column gap={spacings.xxxl}>
<H3>
<Translation id="TR_SEND_ADDRESS_CONFIRMATION_HEADING" />
</H3>
<Column gap={spacings.xxl}>
<Column gap={spacings.md}>
<H3>
<Translation id="TR_SEND_ADDRESS_CONFIRMATION_HEADING" />
</H3>
{networkType === 'solana' && remainingTime != null && (
<TransactionReviewOutputTimer
remainingTime={remainingTime}
onTryAgain={onTryAgain}
/>
)}
</Column>
<BulletList
isOrdered
bulletGap={spacings.md}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,34 @@ import React from 'react';

import styled from 'styled-components';

import { Badge, Banner, Text } from '@trezor/components';
import { Badge, Banner, Row, Text } from '@trezor/components';
import { spacings } from '@trezor/theme';

import { Translation } from 'src/components/suite/Translation';

const TimerBox = styled.div`
font-variant-numeric: tabular-nums;
`;

const RetryLink = styled.a`
text-decoration: underline;
text-decoration-style: dotted;
&:hover {
text-decoration: none;
}
`;

type TransactionReviewOutputTimerProps = {
remainingTime: number;
isMinimal?: boolean;
onTryAgain?: (close: boolean) => void;
};

export const TransactionReviewOutputTimer = ({
isMinimal,
remainingTime,
onTryAgain,
}: TransactionReviewOutputTimerProps) => {
if (isMinimal) {
return (
Expand All @@ -42,6 +54,13 @@ export const TransactionReviewOutputTimer = ({
<Translation id="TR_SOLANA_TX_CONFIRMATION_TIMER" values={{ remainingTime }} />
</Text>
<Translation id="TR_SOLANA_TX_CONFIRMATION_TIMER_DESCRIPTION" />
{onTryAgain && (
<Row margin={{ top: spacings.xs }}>
<RetryLink onClick={() => onTryAgain(true)}>
<Translation id="TR_SOLANA_TX_CONFIRMATION_TIMER_AGAIN" />
</RetryLink>
</Row>
)}
</TimerBox>
</Banner>
);
Expand Down
Loading

0 comments on commit e2ca37d

Please sign in to comment.