Skip to content

Commit

Permalink
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 20, 2025
1 parent f0c412f commit d6a690a
Show file tree
Hide file tree
Showing 8 changed files with 158 additions and 16 deletions.
12 changes: 9 additions & 3 deletions packages/blockchain-link/src/workers/solana/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,9 +197,15 @@ const pushTransaction = async (request: Request<MessageTypes.PushTransaction>) =
);
}
if (isSolanaError(error)) {
throw new Error(
`Solana error code: ${error.context.__code}. Please try again or contact support.`,
);
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 error;
}
Expand Down
1 change: 1 addition & 0 deletions packages/components/src/components/Badge/Badge.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const Badge: StoryObj<BadgeProps> = {
'primary',
'tertiary',
'destructive',
'warning',
undefined,
] satisfies BadgeProps['variant'][],
},
Expand Down
5 changes: 4 additions & 1 deletion packages/components/src/components/Badge/Badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export type BadgeSize = Extract<UISize, (typeof badgeSizes)[number]>;
export const allowedBadgeFrameProps = ['margin'] as const satisfies FramePropsKeys[];
type AllowedFrameProps = Pick<FrameProps, (typeof allowedBadgeFrameProps)[number]>;

export type BadgeVariant = Extract<UIVariant, 'primary' | 'tertiary' | 'destructive'>;
export type BadgeVariant = Extract<UIVariant, 'primary' | 'tertiary' | 'destructive' | 'warning'>;

export type BadgeProps = AllowedFrameProps & {
size?: BadgeSize;
Expand Down Expand Up @@ -54,6 +54,7 @@ const mapVariantToBackgroundColor = ({ $variant, $onElevation, theme }: MapArgs)
primary: 'backgroundPrimarySubtleOnElevation0',
tertiary: `backgroundNeutralSubtleOnElevation${$onElevation ? 1 : 0}`,
destructive: 'backgroundAlertRedSubtleOnElevation0',
warning: 'backgroundAlertYellowSubtleOnElevation0',
};

return theme[colorMap[$variant]];
Expand All @@ -64,6 +65,7 @@ const mapVariantToTextColor = ({ $variant, theme }: MapArgs): CSSColor => {
primary: 'textPrimaryDefault',
tertiary: 'textSubdued',
destructive: 'textAlertRed',
warning: 'textAlertYellow',
};

return theme[colorMap[$variant]];
Expand All @@ -74,6 +76,7 @@ const mapVariantToIconColor = ({ $variant, theme }: MapArgs): CSSColor => {
primary: 'iconPrimaryDefault',
tertiary: 'iconSubdued',
destructive: 'iconAlertRed',
warning: 'iconAlertYellow',
};

return theme[colorMap[$variant]];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ import {
isRbfCancelTransaction,
isRbfTransaction,
} from '@suite-common/wallet-utils';
import { NewModal } from '@trezor/components';
import { Column, NewModal } from '@trezor/components';
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';
Expand All @@ -33,10 +35,14 @@ import { getTransactionReviewModalActionText } from 'src/utils/suite/transaction

import { TransactionReviewDetails } from './TransactionReviewDetails';
import { TransactionReviewOutputList } from './TransactionReviewOutputList/TransactionReviewOutputList';
import { TransactionReviewOutputTimer } from './TransactionReviewOutputList/TransactionReviewOutputTimer';
import { TransactionReviewSummary } from './TransactionReviewSummary';
import { ConfirmActionModal } from '../DeviceContextModal/ConfirmActionModal';
import { ExpiredBlockhash } from '../UserContextModal/TxDetailModal/ExpiredBlockhash';
import { ReplaceByFeeFailedOriginalTxConfirmed } from '../UserContextModal/TxDetailModal/ReplaceByFeeFailedOriginalTxConfirmed';

const SOLANA_TIMEOUT = 10;

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

const isStakeForm = (form: FormState | StakeFormState): form is StakeFormState =>
Expand Down Expand Up @@ -70,6 +76,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 deviceModelInternal = device?.features?.internal_model;
const { precomposedTx, serializedTx } = txInfoState;

Expand Down Expand Up @@ -127,6 +136,8 @@ export const TransactionReviewModalContent = ({

const isCancelRbfAction = isRbfCancelTransaction(precomposedTx);

const isSolanaExpired = networkType === 'solana' && remainingTime <= 0;

const actionLabel = getTransactionReviewModalActionText({
stakeType,
isBumpFeeRbfAction,
Expand Down Expand Up @@ -200,6 +211,19 @@ export const TransactionReviewModalContent = ({
);
}

if (isSolanaExpired) {
return (
<>
<NewModal.Button variant="primary" onClick={onCancel}>
<Translation id="TR_TRY_AGAIN" />
</NewModal.Button>
<NewModal.Button variant="tertiary" onClick={onCancel}>
<Translation id="TR_CLOSE" />
</NewModal.Button>
</>
);
}

if (areDetailsVisible) {
return null;
}
Expand Down Expand Up @@ -251,18 +275,28 @@ export const TransactionReviewModalContent = ({
);
}

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

return (
<TransactionReviewOutputList
account={account}
precomposedTx={precomposedTx}
signedTx={serializedTx}
outputs={outputs}
buttonRequestsCount={buttonRequestsCount}
isRbfAction={isBumpFeeRbfAction}
isTradingAction={isTradingAction}
isSending={isSending}
stakeType={stakeType || undefined}
/>
<Column gap={spacings.md}>
{networkType === 'solana' && (
<TransactionReviewOutputTimer remainingTime={remainingTime} />
)}

<TransactionReviewOutputList
account={account}
precomposedTx={precomposedTx}
signedTx={serializedTx}
outputs={outputs}
buttonRequestsCount={buttonRequestsCount}
isRbfAction={isBumpFeeRbfAction}
isTradingAction={isTradingAction}
isSending={isSending}
stakeType={stakeType || undefined}
/>
</Column>
);
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import React from 'react';

import styled from 'styled-components';

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

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

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

type TransactionReviewOutputTimerProps = {
remainingTime: number;
isMinimal?: boolean;
};

export const TransactionReviewOutputTimer = ({
isMinimal,
remainingTime,
}: TransactionReviewOutputTimerProps) => {
if (isMinimal) {
return (
<Badge variant="warning">
<TimerBox>
<Translation
id="TR_SOLANA_TX_CONFIRMATION_TIMER_SHORT"
values={{
remainingTime,
strong: chunks => <strong>{chunks}</strong>,
}}
/>
</TimerBox>
</Badge>
);
}

return (
<Banner variant="warning" icon="hourglass">
<TimerBox>
<Text typographyStyle="callout" as="div">
<Translation id="TR_SOLANA_TX_CONFIRMATION_TIMER" values={{ remainingTime }} />
</Text>
<Translation id="TR_SOLANA_TX_CONFIRMATION_TIMER_DESCRIPTION" />
</TimerBox>
</Banner>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Box, Card, Column, IconCircle, Text } from '@trezor/components';
import { spacings } from '@trezor/theme';
import { HELP_CENTER_SOL_SEND } from '@trezor/urls';

import { Translation } from '../../../../Translation';
import { TrezorLink } from '../../../../TrezorLink';

export const ExpiredBlockhash = () => (
<Card fillType="flat">
<Column gap={spacings.xs}>
<Box margin={{ bottom: spacings.md }}>
<IconCircle name="warning" size={110} variant="destructive" />
</Box>

<Text typographyStyle="titleSmall">
<Translation id="TR_SOLANA_TX_SEND_FAILED_TITLE" />
</Text>
<Translation id="TR_SOLANA_TX_SEND_FAILED_DESCRIPTION" />

<TrezorLink typographyStyle="hint" href={HELP_CENTER_SOL_SEND} icon="arrowUpRight">
<Translation id="TR_LEARN_MORE" />
</TrezorLink>
</Column>
</Card>
);
22 changes: 22 additions & 0 deletions packages/suite/src/support/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9128,6 +9128,28 @@ export default defineMessages({
id: 'TR_SOLANA_TX_CONFIRMATION_MAY_TAKE_UP_TO_1_MIN',
defaultMessage: 'Transaction confirmation may take up to <nowrap>one (1) minute</nowrap>',
},
TR_SOLANA_TX_SEND_FAILED_TITLE: {
id: 'TR_SOLANA_TX_SEND_FAILED_TITLE',
defaultMessage: 'Send transaction failed',
},
TR_SOLANA_TX_SEND_FAILED_DESCRIPTION: {
id: 'TR_SOLANA_TX_SEND_FAILED_DESCRIPTION',
defaultMessage:
'The time to sign a Solana transaction is limited. It could no longer be submitted because it timed out and is no longer valid.',
},
TR_SOLANA_TX_CONFIRMATION_TIMER: {
id: 'TR_SOLANA_TX_CONFIRMATION_TIMER',
defaultMessage: '{remainingTime} seconds left',
},
TR_SOLANA_TX_CONFIRMATION_TIMER_SHORT: {
id: 'TR_SOLANA_TX_CONFIRMATION_TIMER_SHORT',
defaultMessage: '<strong>{remainingTime}s</strong> left to confirm',
},
TR_SOLANA_TX_CONFIRMATION_TIMER_DESCRIPTION: {
id: 'TR_SOLANA_TX_CONFIRMATION_TIMER_DESCRIPTION',
defaultMessage:
'Due to Solana constraints, you have ~1 minute to check everything before your transaction times out.',
},
TR_VIEW_ONLY_PROMO_YES: {
id: 'TR_VIEW_ONLY_PROMO_YES',
defaultMessage: 'Enable',
Expand Down
3 changes: 3 additions & 0 deletions packages/urls/src/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@ export const HELP_CENTER_REPLACE_BY_FEE_BITCOIN =
'https://trezor.io/learn/a/replace-by-fee-rbf-bitcoin';
export const HELP_CENTER_CANCEL_TRANSACTION: Url =
'https://trezor.io/support/a/can-i-cancel-or-reverse-a-transaction';
// TODO: update this link when the article is ready
export const HELP_CENTER_SOL_SEND: Url =
'https://trezor.io/learn/a/solana-sol-on-trezor-safe-5-trezor-safe-3-and-trezor-model-t';

export const INVITY_URL: Url = 'https://invity.io/';
export const INVITY_SCHEDULE_OF_FEES: Url = 'https://blog.invity.io/schedule-of-fees';
Expand Down

0 comments on commit d6a690a

Please sign in to comment.