diff --git a/packages/blockchain-link-types/src/common.ts b/packages/blockchain-link-types/src/common.ts index 49cc8979b48..f16bd3c6240 100644 --- a/packages/blockchain-link-types/src/common.ts +++ b/packages/blockchain-link-types/src/common.ts @@ -152,6 +152,7 @@ export interface Transaction { }; solanaSpecific?: { status: 'confirmed'; + stakeType?: StakeType; }; details: TransactionDetail; vsize?: number; @@ -286,3 +287,5 @@ export interface SubscriptionAccountInfo { } export type ChannelMessage = T & { id: number }; + +export type StakeType = 'stake' | 'unstake' | 'claim'; diff --git a/packages/blockchain-link-utils/src/solana.ts b/packages/blockchain-link-utils/src/solana.ts index 6a047849c2e..74fa6daf471 100644 --- a/packages/blockchain-link-utils/src/solana.ts +++ b/packages/blockchain-link-utils/src/solana.ts @@ -14,7 +14,7 @@ import type { SolanaValidParsedTxWithMeta, TokenDetailByMint, } from '@trezor/blockchain-link-types/src/solana'; -import type { TokenInfo, TokenStandard } from '@trezor/blockchain-link-types/src'; +import type { TokenInfo, TokenStandard, StakeType } from '@trezor/blockchain-link-types/src'; import { isCodesignBuild } from '@trezor/env-utils'; import { formatTokenSymbol } from './utils'; @@ -36,6 +36,7 @@ export const SYSTEM_PROGRAM_PUBLIC_KEY = '11111111111111111111111111111111'; // WSOL transfers are denoted as transfers of SOL as well as WSOL, so we use this to filter out SOL values // when parsing tx effects. export const WSOL_MINT = 'So11111111111111111111111111111111111111112'; +export const STAKE_PROGRAM_PUBLIC_KEY = 'Stake11111111111111111111111111111111111111'; const tokenProgramNames = ['spl-token', 'spl-token-2022'] as const; export type TokenProgramName = (typeof tokenProgramNames)[number]; @@ -605,6 +606,83 @@ export const getTokens = ( return effects; }; +function getTransactionStakeType(tx: SolanaValidParsedTxWithMeta): StakeType | undefined { + const { instructions } = tx.transaction.message; + + if (!instructions) { + throw new Error('Invalid transaction data'); + } + + for (const instruction of instructions) { + if (instruction.programId === STAKE_PROGRAM_PUBLIC_KEY && 'parsed' in instruction) { + const { type } = instruction.parsed || {}; + + if (type === 'delegate') return 'stake'; + if (type === 'deactivate') return 'unstake'; + if (type === 'withdraw') return 'claim'; + } + } + + return undefined; +} + +const getUnstakeAmount = (tx: SolanaValidParsedTxWithMeta): string => { + const { transaction, meta } = tx; + const { instructions, accountKeys } = transaction.message; + + if (!instructions || !meta) { + throw new Error('Invalid transaction data'); + } + + const stakeAccountIndexes = instructions + .filter( + (instruction): instruction is ParsedInstruction => + instruction.programId === STAKE_PROGRAM_PUBLIC_KEY && + 'parsed' in instruction && + instruction.parsed?.type === 'deactivate', + ) + .map(instruction => { + if ( + typeof instruction.parsed?.info === 'object' && + 'stakeAccount' in instruction.parsed.info + ) { + const stakeAccount = instruction.parsed.info?.stakeAccount; + + return accountKeys.findIndex(key => key.pubkey === stakeAccount); + } + + return -1; + }) + .filter(index => index >= 0); + + const totalPostBalance = stakeAccountIndexes.reduce( + (sum, stakeAccountIndex) => + sum.plus(new BigNumber(meta.postBalances[stakeAccountIndex]?.toString(10) || 0)), + new BigNumber(0), + ); + + return totalPostBalance.toString(); +}; + +const determineTransactionType = ( + type: Transaction['type'], + stakeType?: StakeType, +): Transaction['type'] => { + if (type !== 'unknown' || !stakeType) { + return type; + } + + switch (stakeType) { + case 'claim': + return 'recv'; + case 'stake': + case 'unstake': + return 'sent'; + default: + return 'unknown'; + } +}; + export const transformTransaction = ( tx: SolanaValidParsedTxWithMeta, accountAddress: string, @@ -617,17 +695,24 @@ export const transformTransaction = ( const type = getTxType(tx, nativeEffects, accountAddress, tokens); - const targets = getTargets(nativeEffects, type, accountAddress); + const stakeType = getTransactionStakeType(tx); - const amount = getAmount( - nativeEffects.find(({ address }) => address === accountAddress), - type, - ); + const txType = determineTransactionType(type, stakeType); + + const targets = getTargets(nativeEffects, txType, accountAddress); + + const amount = + stakeType === 'unstake' + ? getUnstakeAmount(tx) + : getAmount( + nativeEffects.find(({ address }) => address === accountAddress), + type, + ); const details = getDetails(tx, nativeEffects, accountAddress, type); return { - type, + type: txType, txid: tx.transaction.signatures[0].toString(), blockTime: tx.blockTime == null ? undefined : Number(tx.blockTime), amount, @@ -641,6 +726,7 @@ export const transformTransaction = ( blockHash: tx.transaction.message.recentBlockhash, solanaSpecific: { status: 'confirmed', + stakeType, }, }; }; diff --git a/packages/suite/src/components/suite/UnstakingTxAmount.tsx b/packages/suite/src/components/suite/UnstakingTxAmount.tsx index c84fdf0ebe0..b42fd7f620f 100644 --- a/packages/suite/src/components/suite/UnstakingTxAmount.tsx +++ b/packages/suite/src/components/suite/UnstakingTxAmount.tsx @@ -14,14 +14,28 @@ interface UnstakingTxAmountProps { } export const UnstakingTxAmount = ({ transaction }: UnstakingTxAmountProps) => { - const { ethereumSpecific, symbol } = transaction; - const txSignature = ethereumSpecific?.parsedData?.methodId; + const { ethereumSpecific, solanaSpecific, symbol, amount } = transaction; - if (!isUnstakeTx(txSignature)) return null; + const solanaStakeType = solanaSpecific?.stakeType; + + // Handle Solana unstake transaction + if (solanaStakeType === 'unstake') { + return ( + + ); + } - const amount = getUnstakeAmountByEthereumDataHex(ethereumSpecific?.data); + // Handle Ethereum unstake transaction + const txSignature = ethereumSpecific?.parsedData?.methodId; + if (!isUnstakeTx(txSignature)) return null; - if (!amount) return null; + const unstakeEthAmount = getUnstakeAmountByEthereumDataHex(ethereumSpecific?.data); + if (!unstakeEthAmount) return null; - return ; + return ( + + ); }; diff --git a/packages/suite/src/components/wallet/TransactionItem/TransactionHeader.tsx b/packages/suite/src/components/wallet/TransactionItem/TransactionHeader.tsx index d43d564fa81..b5479386fab 100644 --- a/packages/suite/src/components/wallet/TransactionItem/TransactionHeader.tsx +++ b/packages/suite/src/components/wallet/TransactionItem/TransactionHeader.tsx @@ -1,8 +1,13 @@ -import { getTxHeaderSymbol, isSupportedEthStakingNetworkSymbol } from '@suite-common/wallet-utils'; +import { + getTxHeaderSymbol, + isSupportedEthStakingNetworkSymbol, + isSupportedSolStakingNetworkSymbol, +} from '@suite-common/wallet-utils'; import { AccountTransaction } from '@trezor/connect'; import { Row } from '@trezor/components'; import { spacings } from '@trezor/theme'; import { getNetworkDisplaySymbol, isNetworkSymbol } from '@suite-common/wallet-config'; +import { StakeType } from '@suite-common/wallet-types'; import { useTranslation } from 'src/hooks/suite'; import { WalletAccountTransaction } from 'src/types/wallet'; @@ -65,6 +70,17 @@ const getTransactionMessageId = ({ transaction, isPending }: GetTransactionMessa } }; +const getSolTransactionStakeTypeName = (stakeType: StakeType) => { + switch (stakeType) { + case 'stake': + return 'Stake'; + case 'unstake': + return 'Unstake'; + case 'claim': + return 'Claim Withdraw Request'; + } +}; + export const TransactionHeader = ({ transaction, isPending }: TransactionHeaderProps) => { const { translationString } = useTranslation(); @@ -78,6 +94,17 @@ export const TransactionHeader = ({ transaction, isPending }: TransactionHeaderP ); } + const solanaStakeType = transaction?.solanaSpecific?.stakeType; + if (solanaStakeType) { + return ( + + {getSolTransactionStakeTypeName(solanaStakeType)} + {isSupportedSolStakingNetworkSymbol(transaction.symbol) && ( + + )} + + ); + } const isMultiTokenTransaction = transaction.tokens.length > 1; const transactionSymbol = getTxHeaderSymbol(transaction); diff --git a/packages/suite/src/components/wallet/TransactionItem/TransactionHeading.tsx b/packages/suite/src/components/wallet/TransactionItem/TransactionHeading.tsx index 203b0ec890e..1758e7ca75f 100644 --- a/packages/suite/src/components/wallet/TransactionItem/TransactionHeading.tsx +++ b/packages/suite/src/components/wallet/TransactionItem/TransactionHeading.tsx @@ -132,6 +132,10 @@ export const TransactionHeading = ({ /> ); } + // hide amount for solana unstake transactions + if (transaction?.solanaSpecific?.stakeType === 'unstake') { + amount = null; + } return ( <> diff --git a/packages/suite/src/views/wallet/staking/components/EthStakingDashboard/components/EthStakingDashboard.tsx b/packages/suite/src/views/wallet/staking/components/EthStakingDashboard/components/EthStakingDashboard.tsx index 6076c420c33..512745c56dd 100644 --- a/packages/suite/src/views/wallet/staking/components/EthStakingDashboard/components/EthStakingDashboard.tsx +++ b/packages/suite/src/views/wallet/staking/components/EthStakingDashboard/components/EthStakingDashboard.tsx @@ -24,7 +24,7 @@ import { ClaimCard } from '../../StakingDashboard/components/ClaimCard'; import { StakingDashboard } from '../../StakingDashboard/StakingDashboard'; import { ApyCard } from '../../StakingDashboard/components/ApyCard'; import { PayoutCard } from '../../StakingDashboard/components/PayoutCard'; -import { Transactions } from './Transactions'; +import { Transactions } from '../../StakingDashboard/components/Transactions'; import { InstantStakeBanner } from './InstantStakeBanner'; interface EthStakingDashboardProps { diff --git a/packages/suite/src/views/wallet/staking/components/SolStakingDashboard/SolStakingDashboard.tsx b/packages/suite/src/views/wallet/staking/components/SolStakingDashboard/SolStakingDashboard.tsx index f2c41e5a615..b12f07f21b6 100644 --- a/packages/suite/src/views/wallet/staking/components/SolStakingDashboard/SolStakingDashboard.tsx +++ b/packages/suite/src/views/wallet/staking/components/SolStakingDashboard/SolStakingDashboard.tsx @@ -65,8 +65,6 @@ export const SolStakingDashboard = ({ selectedAccount }: SolStakingDashboardProp /> - {/* TODO: implement Transactions component */} - {/* */} } /> diff --git a/packages/suite/src/views/wallet/staking/components/EthStakingDashboard/components/Transactions.tsx b/packages/suite/src/views/wallet/staking/components/StakingDashboard/components/Transactions.tsx similarity index 100% rename from packages/suite/src/views/wallet/staking/components/EthStakingDashboard/components/Transactions.tsx rename to packages/suite/src/views/wallet/staking/components/StakingDashboard/components/Transactions.tsx diff --git a/suite-common/wallet-core/src/transactions/transactionsReducer.ts b/suite-common/wallet-core/src/transactions/transactionsReducer.ts index 110d55a2ac0..75497958ab9 100644 --- a/suite-common/wallet-core/src/transactions/transactionsReducer.ts +++ b/suite-common/wallet-core/src/transactions/transactionsReducer.ts @@ -350,7 +350,11 @@ export const selectAccountStakeTypeTransactions = createMemoizedSelector( [selectAccountTransactions], transactions => returnStableArrayIfEmpty( - transactions.filter(tx => isStakeTypeTx(tx?.ethereumSpecific?.parsedData?.methodId)), + transactions.filter( + tx => + isStakeTypeTx(tx?.ethereumSpecific?.parsedData?.methodId) || + !!tx?.solanaSpecific?.stakeType, + ), ), ); diff --git a/suite-common/wallet-utils/src/transactionUtils.ts b/suite-common/wallet-utils/src/transactionUtils.ts index 758c30060af..3aecb5ddf57 100644 --- a/suite-common/wallet-utils/src/transactionUtils.ts +++ b/suite-common/wallet-utils/src/transactionUtils.ts @@ -145,8 +145,14 @@ export const formatCardanoDeposit = (tx: WalletAccountTransaction) => ? formatNetworkAmount(tx.cardanoSpecific.deposit, tx.symbol) : undefined; -export const isTxFeePaid = (tx: WalletAccountTransaction) => - !!tx.details.vin.find(vin => vin.isOwn || vin.isAccountOwned) && tx.type !== 'joint'; +export const isTxFeePaid = (tx: WalletAccountTransaction) => { + const showFeeRowForSolClaim = tx?.solanaSpecific?.stakeType === 'claim'; + + return ( + (!!tx.details.vin.find(vin => vin.isOwn || vin.isAccountOwned) && tx.type !== 'joint') || + showFeeRowForSolClaim + ); +}; /** * Returns a sum of sent/recv txs amounts as a BigNumber.