diff --git a/packages/suite/src/actions/wallet/stake/stakeFormSolanaActions.ts b/packages/suite/src/actions/wallet/stake/stakeFormSolanaActions.ts index fd4afc5592..dbd32ff4e4 100644 --- a/packages/suite/src/actions/wallet/stake/stakeFormSolanaActions.ts +++ b/packages/suite/src/actions/wallet/stake/stakeFormSolanaActions.ts @@ -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 = diff --git a/suite-common/staking-solana/package.json b/suite-common/staking-solana/package.json index c0da0797ae..23b3fe9067 100644 --- a/suite-common/staking-solana/package.json +++ b/suite-common/staking-solana/package.json @@ -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:*", diff --git a/suite-common/staking-solana/src/types.ts b/suite-common/staking-solana/src/types.ts index 744fb5687b..14641a4607 100644 --- a/suite-common/staking-solana/src/types.ts +++ b/suite-common/staking-solana/src/types.ts @@ -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>; @@ -40,6 +48,7 @@ export type Delegations = Array>; 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 = { diff --git a/suite-common/staking-solana/src/utils/stakingUtils.ts b/suite-common/staking-solana/src/utils/stakingUtils.ts index 469a3f48d1..789f204899 100644 --- a/suite-common/staking-solana/src/utils/stakingUtils.ts +++ b/suite-common/staking-solana/src/utils/stakingUtils.ts @@ -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); diff --git a/suite-common/staking-solana/src/utils/transactionUtils.ts b/suite-common/staking-solana/src/utils/transactionUtils.ts index d146560d73..382a266c3e 100644 --- a/suite-common/staking-solana/src/utils/transactionUtils.ts +++ b/suite-common/staking-solana/src/utils/transactionUtils.ts @@ -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( diff --git a/suite-common/staking-solana/tsconfig.json b/suite-common/staking-solana/tsconfig.json index 9af932d4e8..9eba965b95 100644 --- a/suite-common/staking-solana/tsconfig.json +++ b/suite-common/staking-solana/tsconfig.json @@ -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" }, diff --git a/suite-common/wallet-constants/src/solanaStakingConstants.ts b/suite-common/wallet-constants/src/solanaStakingConstants.ts index fcd94a31c7..2799fb61ab 100644 --- a/suite-common/wallet-constants/src/solanaStakingConstants.ts +++ b/suite-common/wallet-constants/src/solanaStakingConstants.ts @@ -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; diff --git a/suite-common/wallet-types/src/solanaStaking.ts b/suite-common/wallet-types/src/solanaStaking.ts index 73d9d66d6e..90c5eebb35 100644 --- a/suite-common/wallet-types/src/solanaStaking.ts +++ b/suite-common/wallet-types/src/solanaStaking.ts @@ -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; diff --git a/yarn.lock b/yarn.lock index ce25347e36..1383d6693f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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:*"