fix(staking): fix inconsistent Solana staking amounts between UI and device

This commit is contained in:
Jaroslav Hrách
2026-01-14 20:12:02 +01:00
committed by Jaroslav Hrách
parent 7eede71ad0
commit e6828b4a8d
9 changed files with 170 additions and 5 deletions

View File

@@ -7,6 +7,7 @@ import {
composeStakingTransaction,
} from '@suite-common/staking/src/actions/stakeFormActions';
import {
SolanaTxMeta,
prepareClaimSolTx,
prepareStakeSolTx,
prepareUnstakeSolTx,
@@ -26,6 +27,7 @@ import {
BlockchainNetworks,
EstimatedFee,
ExternalOutput,
PrecomposedLevels,
PrecomposedTransaction,
PrecomposedTransactionFinal,
PrepareStakeSolTxResponse,
@@ -157,6 +159,35 @@ async function estimateFee(
return { success: false };
}
const applySolanaTxMeta = (
composed: PrecomposedLevels,
solanaTxMeta: SolanaTxMeta,
): PrecomposedLevels =>
Object.fromEntries(
Object.entries(composed).map(([key, tx]) => {
if (tx.type === 'error') return [key, tx];
const nextTx = { ...tx, solanaTxMeta };
if (tx.type === 'final') {
const totalSpent = new BigNumber(solanaTxMeta.deviceAmountLamports)
.plus(solanaTxMeta.feeIncludingRentLamports)
.toString();
return [
key,
{
...nextTx,
fee: solanaTxMeta.feeIncludingRentLamports,
totalSpent,
},
];
}
return [key, nextTx];
}),
);
export const composeTransaction =
(formValues: StakeFormState, formState: ComposeActionContext) =>
async (_: Dispatch, getState: GetState) => {
@@ -179,7 +210,7 @@ export const composeTransaction =
const { levels } = feeInfo;
const predefinedLevels = levels.filter(l => l.label !== 'custom');
return composeStakingTransaction(
const composed = composeStakingTransaction(
formValues,
formState,
predefinedLevels,
@@ -187,6 +218,23 @@ export const composeTransaction =
estimatedFee,
undefined,
);
if (!composed) return;
if (estimatedFee.success && estimatedFee.payload) {
const txDataWithFee = await getTransactionData(
formValues,
selectedAccount,
blockchain,
estimatedFee.payload,
);
const solanaTxMeta = txDataWithFee?.success ? txDataWithFee.solanaTxMeta : undefined;
if (solanaTxMeta) {
return applySolanaTxMeta(composed, solanaTxMeta);
}
}
return composed;
};
export const signTransaction =

View File

@@ -20,6 +20,7 @@
"@suite-common/wallet-constants": "workspace:*",
"@trezor/blockchain-link": "workspace:*",
"@trezor/blockchain-link-types": "workspace:*",
"@trezor/blockchain-link-utils": "workspace:*",
"@trezor/connect": "workspace:*",
"@trezor/env-utils": "workspace:*",
"@trezor/type-utils": "workspace:*",

View File

@@ -28,11 +28,19 @@ export interface SolNetworkConfig {
network: Network;
}
export type SolanaTxMeta = {
deviceAmountLamports: string;
feeLamports: string;
rentLamports: string;
feeIncludingRentLamports: string;
};
export type StakeResponse = {
stakeTx:
| (CompilableTransactionMessage & TransactionMessageWithBlockhashLifetime)
| (Transaction & TransactionMessageWithBlockhashLifetime);
stakeAccount: Address;
txMeta: SolanaTxMeta;
};
export type Delegations = Array<Account<StakeStateAccount, Address>>;
@@ -40,6 +48,7 @@ export type Delegations = Array<Account<StakeStateAccount, Address>>;
export type UnstakeResponse = {
unstakeTx: CompilableTransactionMessage & TransactionMessageWithBlockhashLifetime;
unstakeAmount: bigint;
txMeta: SolanaTxMeta;
};
export type Connection = RpcFromTransport<
@@ -50,6 +59,7 @@ export type Connection = RpcFromTransport<
export type ClaimResponse = {
claimTx: CompilableTransactionMessage & TransactionMessageWithBlockhashLifetime;
totalClaimAmount: bigint;
txMeta: SolanaTxMeta;
};
export type TransactionShim = {

View File

@@ -61,7 +61,7 @@ const getStakingParams = (estimatedFee?: Fee[number]) => {
}
return {
сomputeUnitPrice: BigInt(estimatedFee.feePerUnit),
computeUnitPrice: BigInt(estimatedFee.feePerUnit),
computeUnitLimit: Number(estimatedFee.feeLimit), // solana package expects number
};
};
@@ -97,6 +97,7 @@ export const prepareStakeSolTx = async ({
return {
success: true,
tx: transformedTx,
solanaTxMeta: tx.txMeta,
};
} catch (e) {
console.error(e);
@@ -132,6 +133,7 @@ export const prepareUnstakeSolTx = async ({
return {
success: true,
tx: transformedTx,
solanaTxMeta: tx.txMeta,
};
} catch (e) {
console.error(e);
@@ -163,6 +165,7 @@ export const prepareClaimSolTx = async ({
return {
success: true,
tx: transformedTx,
solanaTxMeta: tx.txMeta,
};
} catch (e) {
console.error(e);

View File

@@ -9,6 +9,7 @@ import {
TransactionMessageWithBlockhashLifetime,
address,
appendTransactionMessageInstruction,
compileTransactionMessage,
createAddressWithSeed,
createNoopSigner,
createTransactionMessage,
@@ -21,8 +22,12 @@ import {
setTransactionMessageLifetimeUsingBlockhash,
} from '@solana/kit';
import {
SET_COMPUTE_UNIT_LIMIT_DISCRIMINATOR,
SET_COMPUTE_UNIT_PRICE_DISCRIMINATOR,
getSetComputeUnitLimitInstruction,
getSetComputeUnitLimitInstructionDataDecoder,
getSetComputeUnitPriceInstruction,
getSetComputeUnitPriceInstructionDataDecoder,
} from '@solana-program/compute-budget';
import {
STAKE_PROGRAM_ADDRESS,
@@ -39,6 +44,11 @@ import {
getTransferSolInstruction,
} from '@solana-program/system';
import {
SOL_BASE_FEE,
SOL_COMPUTE_UNIT_LIMIT,
SOL_MICROLAMPORTS_PER_LAMPORT,
} from '@suite-common/wallet-constants';
import {
STAKE_ACCOUNT_V2_SIZE,
getDelegations,
@@ -46,6 +56,7 @@ import {
stakeAccountState,
} from '@trezor/blockchain-link/src/workers/solana/utils/stakingAccounts';
import { StakeState } from '@trezor/blockchain-link-types/src/solana';
import { COMPUTE_BUDGET_PROGRAM_ID } from '@trezor/blockchain-link-utils/src/solana';
import { serializeError } from '@trezor/utils';
import { selectSolanaWalletSdkNetwork } from '../connection';
@@ -64,6 +75,7 @@ import {
Connection,
Delegations,
Params,
SolanaTxMeta,
StakeParams,
StakeResponse,
UnstakeResponse,
@@ -160,6 +172,59 @@ const baseTx = async (
return transactionMessage;
};
export const getFeeSummary = ({
transactionMessage,
}: {
transactionMessage: CompilableTransactionMessage & TransactionMessageWithBlockhashLifetime;
}) => {
const compiledMessage = compileTransactionMessage(transactionMessage);
const baseFeeLamports = SOL_BASE_FEE * BigInt(compiledMessage.header.numSignerAccounts);
let unitLimit = BigInt(SOL_COMPUTE_UNIT_LIMIT);
let unitPriceMicroLamports = 0n;
let isUnitLimitSet = false;
let isUnitPriceSet = false;
compiledMessage.instructions.forEach(instruction => {
if (
compiledMessage.staticAccounts[instruction.programAddressIndex] !==
COMPUTE_BUDGET_PROGRAM_ID
) {
return;
}
const { data } = instruction;
if (!data || data.length === 0) return;
if (data[0] === SET_COMPUTE_UNIT_LIMIT_DISCRIMINATOR && !isUnitLimitSet) {
const decoded = getSetComputeUnitLimitInstructionDataDecoder().decode(data);
unitLimit = BigInt(decoded.units);
isUnitLimitSet = true;
return;
}
if (data[0] === SET_COMPUTE_UNIT_PRICE_DISCRIMINATOR && !isUnitPriceSet) {
const decoded = getSetComputeUnitPriceInstructionDataDecoder().decode(data);
unitPriceMicroLamports = BigInt(decoded.microLamports);
isUnitPriceSet = true;
}
});
const priorityFeeLamports =
(unitPriceMicroLamports * BigInt(unitLimit) + SOL_MICROLAMPORTS_PER_LAMPORT - 1n) /
SOL_MICROLAMPORTS_PER_LAMPORT;
const feeLamports = baseFeeLamports + priorityFeeLamports;
return {
baseFeeLamports: baseFeeLamports.toString(10),
priorityFeeLamports: priorityFeeLamports.toString(10),
feeLamports: feeLamports.toString(10),
};
};
const timestampInSec = (): number => (Date.now() / 1000) | 0;
const isLockupInForce = (
@@ -248,7 +313,6 @@ export const stake = async ({
initializeStakeAccountInstruction,
stakeAccountPublicKey,
] = await createAccountWithSeedTx(address(sender), BigInt(lamports) + minimumRent, source);
const delegateInstruction = getDelegateStakeInstruction({
stake: stakeAccountPublicKey,
vote: validator,
@@ -276,9 +340,21 @@ export const stake = async ({
? await partiallySignTransactionMessageWithSigners(transactionMessage)
: transactionMessage;
const feeSummary = getFeeSummary({ transactionMessage });
const feeLamports = BigInt(feeSummary.feeLamports);
const feeIncludingRentLamports = (feeLamports + minimumRent).toString();
const deviceAmountLamports = (lamports + minimumRent).toString();
const txMeta: SolanaTxMeta = {
deviceAmountLamports,
feeLamports: feeSummary.feeLamports,
rentLamports: minimumRent.toString(),
feeIncludingRentLamports,
};
return {
stakeTx: signedTransactionMessage,
stakeAccount: stakeAccountPublicKey,
txMeta,
};
} catch (error) {
throw new Error(
@@ -430,7 +506,17 @@ export const unstake = async ({
throw new Error('Zero instructions');
}
return { unstakeTx: transactionMessage, unstakeAmount };
const feeSummary = getFeeSummary({ transactionMessage });
const feeLamports = BigInt(feeSummary.feeLamports);
const feeIncludingRentLamports = (feeLamports + minimumRent).toString();
const txMeta: SolanaTxMeta = {
deviceAmountLamports: unstakeAmount.toString(),
feeLamports: feeSummary.feeLamports,
rentLamports: minimumRent.toString(),
feeIncludingRentLamports,
};
return { unstakeTx: transactionMessage, unstakeAmount, txMeta };
} catch (error) {
throw new Error(
`Solana staking: unstaking failed - ${error instanceof Error ? error.message : serializeError(error)}`,
@@ -488,9 +574,18 @@ export const claim = async ({
}
}
const feeSummary = getFeeSummary({ transactionMessage });
const txMeta: SolanaTxMeta = {
deviceAmountLamports: totalClaimableStake.toString(),
feeLamports: feeSummary.feeLamports,
rentLamports: '0',
feeIncludingRentLamports: feeSummary.feeLamports,
};
return {
claimTx: transactionMessage,
totalClaimAmount: totalClaimableStake,
txMeta,
};
} catch (error) {
throw new Error(

View File

@@ -10,6 +10,9 @@
{
"path": "../../packages/blockchain-link-types"
},
{
"path": "../../packages/blockchain-link-utils"
},
{ "path": "../../packages/connect" },
{ "path": "../../packages/env-utils" },
{ "path": "../../packages/type-utils" },

View File

@@ -9,4 +9,7 @@ export const SOL_STAKING_OPERATION_FEE = new BigNumber(70_000); // 0.00007 SOL
export const SOL_COMPUTE_UNIT_PRICE = 100000;
export const SOL_COMPUTE_UNIT_LIMIT = 200000;
export const SOL_BASE_FEE = 5000n;
export const SOL_MICROLAMPORTS_PER_LAMPORT = 1_000_000n;
export const SOLANA_EPOCH_DAYS = 2;

View File

@@ -1,4 +1,4 @@
import { SolanaTx } from '@suite-common/staking-solana';
import { SolanaTx, SolanaTxMeta } from '@suite-common/staking-solana';
import { NetworkSymbol } from '@suite-common/wallet-config';
import { Fee } from '@trezor/blockchain-link-types/src/blockbook';
@@ -28,6 +28,7 @@ export type PrepareStakeSolTxResponse =
| {
success: true;
tx: SolanaTx;
solanaTxMeta: SolanaTxMeta;
}
| {
success: false;

View File

@@ -10986,6 +10986,7 @@ __metadata:
"@suite-common/wallet-constants": "workspace:*"
"@trezor/blockchain-link": "workspace:*"
"@trezor/blockchain-link-types": "workspace:*"
"@trezor/blockchain-link-utils": "workspace:*"
"@trezor/connect": "workspace:*"
"@trezor/env-utils": "workspace:*"
"@trezor/type-utils": "workspace:*"