Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(suite): solana staking transactions #16388

Open
wants to merge 4 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/blockchain-link-types/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ export interface Transaction {
};
solanaSpecific?: {
status: 'confirmed';
stakeType?: StakeType;
};
details: TransactionDetail;
vsize?: number;
Expand Down Expand Up @@ -286,3 +287,5 @@ export interface SubscriptionAccountInfo {
}

export type ChannelMessage<T> = T & { id: number };

export type StakeType = 'stake' | 'unstake' | 'claim';
100 changes: 93 additions & 7 deletions packages/blockchain-link-utils/src/solana.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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];
Expand Down Expand Up @@ -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';
Comment on lines +620 to +622
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can those types be used also for different purposes than stake? @vytick

Copy link
Contributor

@vytick vytick Jan 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but it depends on the referenced program to determine what are the types and what those mean.

}
}

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,
Expand All @@ -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,
Expand All @@ -641,6 +726,7 @@ export const transformTransaction = (
blockHash: tx.transaction.message.recentBlockhash,
solanaSpecific: {
status: 'confirmed',
stakeType,
},
};
};
26 changes: 20 additions & 6 deletions packages/suite/src/components/suite/UnstakingTxAmount.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<FormattedCryptoAmount value={formatNetworkAmount(amount, symbol)} symbol={symbol} />
);
}

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 <FormattedCryptoAmount value={formatNetworkAmount(amount, symbol)} symbol={symbol} />;
return (
<FormattedCryptoAmount
value={formatNetworkAmount(unstakeEthAmount, symbol)}
symbol={symbol}
/>
);
};
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -65,6 +70,17 @@ const getTransactionMessageId = ({ transaction, isPending }: GetTransactionMessa
}
};

const getSolTransactionStakeTypeName = (stakeType: StakeType) => {
switch (stakeType) {
case 'stake':
return 'Stake';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

possibly use localised strings?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would probably be good but I don't know if we want to translate it 😀

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't use translations for this on Ethereum, which is why I didn't localize them.

case 'unstake':
return 'Unstake';
case 'claim':
return 'Claim Withdraw Request';
}
};

export const TransactionHeader = ({ transaction, isPending }: TransactionHeaderProps) => {
const { translationString } = useTranslation();

Expand All @@ -78,6 +94,17 @@ export const TransactionHeader = ({ transaction, isPending }: TransactionHeaderP
</Row>
);
}
const solanaStakeType = transaction?.solanaSpecific?.stakeType;
if (solanaStakeType) {
return (
<Row gap={spacings.xxs} overflow="hidden">
<span>{getSolTransactionStakeTypeName(solanaStakeType)}</span>
{isSupportedSolStakingNetworkSymbol(transaction.symbol) && (
<UnstakingTxAmount transaction={transaction} />
)}
</Row>
);
}

const isMultiTokenTransaction = transaction.tokens.length > 1;
const transactionSymbol = getTxHeaderSymbol(transaction);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,10 @@ export const TransactionHeading = ({
/>
);
}
// hide amount for solana unstake transactions
if (transaction?.solanaSpecific?.stakeType === 'unstake') {
amount = null;
}
tomasklim marked this conversation as resolved.
Show resolved Hide resolved

return (
<>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,6 @@ export const SolStakingDashboard = ({ selectedAccount }: SolStakingDashboardProp
/>
</Column>
</DashboardSection>
{/* TODO: implement Transactions component */}
{/* <Transactions /> */}
</Column>
}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
),
);

Expand Down
10 changes: 8 additions & 2 deletions suite-common/wallet-utils/src/transactionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down