diff --git a/libs/coin-modules/coin-solana/src/api/cached.ts b/libs/coin-modules/coin-solana/src/api/cached.ts index 413db7bbcf80..160423a75998 100644 --- a/libs/coin-modules/coin-solana/src/api/cached.ts +++ b/libs/coin-modules/coin-solana/src/api/cached.ts @@ -82,6 +82,12 @@ export function cached(api: ChainAPI): ChainAPI { getEpochInfo: makeLRUCache(api.getEpochInfo, cacheKeyEmpty, minutes(1)), + getRecentPrioritizationFees: makeLRUCache( + api.getRecentPrioritizationFees, + cacheKeyByArgs, + seconds(30), + ), + config: api.config, }; } diff --git a/libs/coin-modules/coin-solana/src/api/chain/index.ts b/libs/coin-modules/coin-solana/src/api/chain/index.ts index 633174225438..ebd6fc0b3ce5 100644 --- a/libs/coin-modules/coin-solana/src/api/chain/index.ts +++ b/libs/coin-modules/coin-solana/src/api/chain/index.ts @@ -11,6 +11,7 @@ import { sendAndConfirmRawTransaction, SignaturesForAddressOptions, StakeProgram, + GetRecentPrioritizationFeesConfig, } from "@solana/web3.js"; import { getEnv } from "@ledgerhq/live-env"; import { Awaited } from "../../logic"; @@ -70,6 +71,10 @@ export type ChainAPI = Readonly<{ getEpochInfo: () => ReturnType; + getRecentPrioritizationFees: ( + config?: GetRecentPrioritizationFeesConfig, + ) => ReturnType; + config: Config; }>; @@ -86,7 +91,7 @@ export function getChainAPI( logger === undefined ? undefined : (url, options, fetch) => { - logger(url, options); + logger(url.toString(), options); fetch(url, options); }; @@ -198,6 +203,10 @@ export function getChainAPI( getEpochInfo: () => connection().getEpochInfo().catch(remapErrors), + getRecentPrioritizationFees: (config?: GetRecentPrioritizationFeesConfig) => { + return connection().getRecentPrioritizationFees(config).catch(remapErrors); + }, + config, }; } diff --git a/libs/coin-modules/coin-solana/src/api/chain/web3.ts b/libs/coin-modules/coin-solana/src/api/chain/web3.ts index 5396cb750adf..586b98ada343 100644 --- a/libs/coin-modules/coin-solana/src/api/chain/web3.ts +++ b/libs/coin-modules/coin-solana/src/api/chain/web3.ts @@ -11,8 +11,10 @@ import { StakeProgram, SystemProgram, TransactionInstruction, + ComputeBudgetProgram, } from "@solana/web3.js"; import chunk from "lodash/chunk"; +import uniqBy from "lodash/uniqBy"; import { ChainAPI } from "."; import { Awaited } from "../../logic"; import { @@ -25,7 +27,7 @@ import { TokenTransferCommand, TransferCommand, } from "../../types"; -import { drainSeqAsyncGen } from "../../utils"; +import { drainSeqAsyncGen, median } from "../../utils"; import { parseTokenAccountInfo, tryParseAsTokenAccount, tryParseAsVoteAccount } from "./account"; import { parseStakeAccountInfo } from "./account/parser"; import { StakeAccountInfo } from "./account/stake"; @@ -127,12 +129,10 @@ export function getTransactions( return drainSeqAsyncGen(getTransactionsGen(address, untilTxSignature, api)); } -export const buildTransferInstructions = ({ - sender, - recipient, - amount, - memo, -}: TransferCommand): TransactionInstruction[] => { +export const buildTransferInstructions = async ( + api: ChainAPI, + { sender, recipient, amount, memo }: TransferCommand, +): Promise => { const fromPublicKey = new PublicKey(sender); const toPublicKey = new PublicKey(recipient); @@ -153,12 +153,13 @@ export const buildTransferInstructions = ({ instructions.push(memoIx); } - return instructions; + return appendMaybePriorityFeeInstruction(api, [fromPublicKey, toPublicKey], instructions); }; -export const buildTokenTransferInstructions = ( +export const buildTokenTransferInstructions = async ( + api: ChainAPI, command: TokenTransferCommand, -): TransactionInstruction[] => { +): Promise => { const { ownerAddress, ownerAssociatedTokenAccountAddress, @@ -271,6 +272,36 @@ export async function getStakeAccountAddressWithSeed({ return pubkey.toBase58(); } +export async function getPriorityFee(api: ChainAPI, accounts: PublicKey[]): Promise { + const uniqAccs = uniqBy(accounts, acc => acc.toBase58()); + const recentFees = await api.getRecentPrioritizationFees({ + lockedWritableAccounts: uniqAccs, + }); + + return median(recentFees.map(item => item.prioritizationFee)); +} + +export async function buildMaybePriorityFeeInstruction( + api: ChainAPI, + accounts: PublicKey[], +): Promise { + const priorityFee = await getPriorityFee(api, accounts); + if (priorityFee === 0) return null; + + return ComputeBudgetProgram.setComputeUnitPrice({ + microLamports: priorityFee, + }); +} + +export async function appendMaybePriorityFeeInstruction( + api: ChainAPI, + accounts: PublicKey[], + ixs: TransactionInstruction[], +): Promise { + const priorityFeeIx = await buildMaybePriorityFeeInstruction(api, accounts); + return priorityFeeIx ? [priorityFeeIx, ...ixs] : ixs; +} + export function buildCreateAssociatedTokenAccountInstruction({ mint, owner, @@ -292,86 +323,82 @@ export function buildCreateAssociatedTokenAccountInstruction({ return instructions; } -export function buildStakeDelegateInstructions({ - authorizedAccAddr, - stakeAccAddr, - voteAccAddr, -}: StakeDelegateCommand): TransactionInstruction[] { +export async function buildStakeDelegateInstructions( + api: ChainAPI, + { authorizedAccAddr, stakeAccAddr, voteAccAddr }: StakeDelegateCommand, +): Promise { + const withdrawAuthority = new PublicKey(authorizedAccAddr); + const stakeAcc = new PublicKey(stakeAccAddr); + const voteAcc = new PublicKey(voteAccAddr); const tx = StakeProgram.delegate({ - authorizedPubkey: new PublicKey(authorizedAccAddr), - stakePubkey: new PublicKey(stakeAccAddr), - votePubkey: new PublicKey(voteAccAddr), + authorizedPubkey: withdrawAuthority, + stakePubkey: stakeAcc, + votePubkey: voteAcc, }); - return tx.instructions; + return appendMaybePriorityFeeInstruction(api, [withdrawAuthority, stakeAcc], tx.instructions); } -export function buildStakeUndelegateInstructions({ - authorizedAccAddr, - stakeAccAddr, -}: StakeUndelegateCommand): TransactionInstruction[] { +export async function buildStakeUndelegateInstructions( + api: ChainAPI, + { authorizedAccAddr, stakeAccAddr }: StakeUndelegateCommand, +): Promise { + const withdrawAuthority = new PublicKey(authorizedAccAddr); + const stakeAcc = new PublicKey(stakeAccAddr); const tx = StakeProgram.deactivate({ - authorizedPubkey: new PublicKey(authorizedAccAddr), - stakePubkey: new PublicKey(stakeAccAddr), + authorizedPubkey: withdrawAuthority, + stakePubkey: stakeAcc, }); - return tx.instructions; + return appendMaybePriorityFeeInstruction(api, [withdrawAuthority, stakeAcc], tx.instructions); } -export function buildStakeWithdrawInstructions({ - authorizedAccAddr, - stakeAccAddr, - amount, - toAccAddr, -}: StakeWithdrawCommand): TransactionInstruction[] { +export async function buildStakeWithdrawInstructions( + api: ChainAPI, + { authorizedAccAddr, stakeAccAddr, amount, toAccAddr }: StakeWithdrawCommand, +): Promise { + const withdrawAuthority = new PublicKey(authorizedAccAddr); + const stakeAcc = new PublicKey(stakeAccAddr); + const recipient = new PublicKey(toAccAddr); const tx = StakeProgram.withdraw({ - authorizedPubkey: new PublicKey(authorizedAccAddr), - stakePubkey: new PublicKey(stakeAccAddr), + authorizedPubkey: withdrawAuthority, + stakePubkey: stakeAcc, lamports: amount, - toPubkey: new PublicKey(toAccAddr), + toPubkey: recipient, }); - return tx.instructions; + return appendMaybePriorityFeeInstruction(api, [withdrawAuthority, stakeAcc], tx.instructions); } -export function buildStakeSplitInstructions({ - authorizedAccAddr, - stakeAccAddr, - seed, - amount, - splitStakeAccAddr, -}: StakeSplitCommand): TransactionInstruction[] { - // HACK: switch to split_with_seed when supported by @solana/web3.js - const splitIx = StakeProgram.split({ - authorizedPubkey: new PublicKey(authorizedAccAddr), +export async function buildStakeSplitInstructions( + api: ChainAPI, + { authorizedAccAddr, stakeAccAddr, seed, amount, splitStakeAccAddr }: StakeSplitCommand, +): Promise { + const basePk = new PublicKey(authorizedAccAddr); + const stakePk = new PublicKey(stakeAccAddr); + const splitStakePk = new PublicKey(splitStakeAccAddr); + const splitIx = StakeProgram.splitWithSeed({ + authorizedPubkey: basePk, lamports: amount, - stakePubkey: new PublicKey(stakeAccAddr), - splitStakePubkey: new PublicKey(splitStakeAccAddr), - }).instructions[1]; - - if (splitIx === undefined) { - throw new Error("expected split instruction"); - } - - const allocateIx = SystemProgram.allocate({ - accountPubkey: new PublicKey(splitStakeAccAddr), - basePubkey: new PublicKey(authorizedAccAddr), - programId: StakeProgram.programId, + stakePubkey: stakePk, + splitStakePubkey: splitStakePk, + basePubkey: basePk, seed, - space: StakeProgram.space, }); - - return [allocateIx, splitIx]; + return appendMaybePriorityFeeInstruction(api, [basePk, stakePk], splitIx.instructions); } -export function buildStakeCreateAccountInstructions({ - fromAccAddress, - stakeAccAddress, - seed, - amount, - stakeAccRentExemptAmount, - delegate, -}: StakeCreateAccountCommand): TransactionInstruction[] { +export async function buildStakeCreateAccountInstructions( + api: ChainAPI, + { + fromAccAddress, + stakeAccAddress, + seed, + amount, + stakeAccRentExemptAmount, + delegate, + }: StakeCreateAccountCommand, +): Promise { const fromPubkey = new PublicKey(fromAccAddress); const stakePubkey = new PublicKey(stakeAccAddress); @@ -394,6 +421,5 @@ export function buildStakeCreateAccountInstructions({ votePubkey: new PublicKey(delegate.voteAccAddress), }), ); - - return tx.instructions; + return appendMaybePriorityFeeInstruction(api, [fromPubkey, stakePubkey], tx.instructions); } diff --git a/libs/coin-modules/coin-solana/src/bridge.integration.test.ts b/libs/coin-modules/coin-solana/src/bridge.integration.test.ts index a970a79d4c09..cd4314d23000 100644 --- a/libs/coin-modules/coin-solana/src/bridge.integration.test.ts +++ b/libs/coin-modules/coin-solana/src/bridge.integration.test.ts @@ -952,6 +952,18 @@ const baseTx = { const baseAPI = { getLatestBlockhash: () => Promise.resolve(LATEST_BLOCKHASH_MOCK), getFeeForMessage: (_msg: unknown) => Promise.resolve(testOnChainData.fees.lamportsPerSignature), + getRecentPrioritizationFees: (_: string[]) => { + return Promise.resolve([ + { + slot: 122422797, + prioritizationFee: 0, + }, + { + slot: 122422797, + prioritizationFee: 0, + }, + ]); + }, } as ChainAPI; type StakeTestSpec = { diff --git a/libs/coin-modules/coin-solana/src/js-buildTransaction.ts b/libs/coin-modules/coin-solana/src/js-buildTransaction.ts index cb7f9b17bad8..146b594ca7fc 100644 --- a/libs/coin-modules/coin-solana/src/js-buildTransaction.ts +++ b/libs/coin-modules/coin-solana/src/js-buildTransaction.ts @@ -23,7 +23,7 @@ export const buildTransactionWithAPI = async ( transaction: Transaction, api: ChainAPI, ): Promise OnChainTransaction]> => { - const instructions = buildInstructions(transaction); + const instructions = await buildInstructions(api, transaction); const recentBlockhash = await api.getLatestBlockhash(); @@ -46,7 +46,10 @@ export const buildTransactionWithAPI = async ( ]; }; -function buildInstructions(tx: Transaction): TransactionInstruction[] { +async function buildInstructions( + api: ChainAPI, + tx: Transaction, +): Promise { const { commandDescriptor } = tx.model; if (commandDescriptor === undefined) { throw new Error("missing command descriptor"); @@ -54,27 +57,30 @@ function buildInstructions(tx: Transaction): TransactionInstruction[] { if (Object.keys(commandDescriptor.errors).length > 0) { throw new Error("can not build invalid command"); } - return buildInstructionsForCommand(commandDescriptor.command); + return buildInstructionsForCommand(api, commandDescriptor.command); } -function buildInstructionsForCommand(command: Command): TransactionInstruction[] { +async function buildInstructionsForCommand( + api: ChainAPI, + command: Command, +): Promise { switch (command.kind) { case "transfer": - return buildTransferInstructions(command); + return buildTransferInstructions(api, command); case "token.transfer": - return buildTokenTransferInstructions(command); + return buildTokenTransferInstructions(api, command); case "token.createATA": return buildCreateAssociatedTokenAccountInstruction(command); case "stake.createAccount": - return buildStakeCreateAccountInstructions(command); + return buildStakeCreateAccountInstructions(api, command); case "stake.delegate": - return buildStakeDelegateInstructions(command); + return buildStakeDelegateInstructions(api, command); case "stake.undelegate": - return buildStakeUndelegateInstructions(command); + return buildStakeUndelegateInstructions(api, command); case "stake.withdraw": - return buildStakeWithdrawInstructions(command); + return buildStakeWithdrawInstructions(api, command); case "stake.split": - return buildStakeSplitInstructions(command); + return buildStakeSplitInstructions(api, command); default: return assertUnreachable(command); } diff --git a/libs/coin-modules/coin-solana/src/js-synchronization.ts b/libs/coin-modules/coin-solana/src/js-synchronization.ts index 227b03467c3f..d5775c4c9a14 100644 --- a/libs/coin-modules/coin-solana/src/js-synchronization.ts +++ b/libs/coin-modules/coin-solana/src/js-synchronization.ts @@ -541,7 +541,7 @@ function getMainAccOperationTypeFromTx(tx: ParsedTransaction): OperationType | u const parsedIxs = instructions .map(ix => parseQuiet(ix)) - .filter(({ program }) => program !== "spl-memo"); + .filter(({ program }) => program !== "spl-memo" && program !== "unknown"); if (parsedIxs.length === 3) { const [first, second, third] = parsedIxs; @@ -635,7 +635,7 @@ function getTokenAccOperationType({ const { instructions } = tx.message; const [mainIx, ...otherIxs] = instructions .map(ix => parseQuiet(ix)) - .filter(({ program }) => program !== "spl-memo"); + .filter(({ program }) => program !== "spl-memo" && program !== "unknown"); if (mainIx !== undefined && otherIxs.length === 0) { switch (mainIx.program) { diff --git a/libs/coin-modules/coin-solana/src/utils.ts b/libs/coin-modules/coin-solana/src/utils.ts index 5e8fd41f437e..15b6f00492af 100644 --- a/libs/coin-modules/coin-solana/src/utils.ts +++ b/libs/coin-modules/coin-solana/src/utils.ts @@ -2,6 +2,7 @@ import { Cluster, clusterApiUrl } from "@solana/web3.js"; import { partition } from "lodash/fp"; import { getEnv } from "@ledgerhq/live-env"; import { ValidatorsAppValidator } from "./validator-app"; +import BigNumber from "bignumber.js"; // Hardcoding the Ledger validator info as backup, // because backend is flaky and sometimes doesn't return it anymore @@ -174,3 +175,17 @@ export const tupleOfUnion = export function sweetch(caze: T, cases: Record): R { return cases[caze]; } + +export function median(values: number[]): number { + const length = values.length; + if (!length) return 0; + + const sorted = values.sort((a, b) => a - b); + const middle = Math.floor(length / 2); + return length % 2 + ? BigNumber(sorted[middle]) + .plus(sorted[middle - 1]) + .div(2) + .toNumber() + : sorted[middle]; +} diff --git a/libs/ledger-live-common/src/families/solana/bridge/mock-data.ts b/libs/ledger-live-common/src/families/solana/bridge/mock-data.ts index b21218e84d8b..900af12183f0 100644 --- a/libs/ledger-live-common/src/families/solana/bridge/mock-data.ts +++ b/libs/ledger-live-common/src/families/solana/bridge/mock-data.ts @@ -889,4 +889,20 @@ export const getMockedMethods = (): { }, // manual { method: "getLatestBlockhash", params: [], answer: LATEST_BLOCKHASH_MOCK }, + { + method: "getRecentPrioritizationFees", + params: [ + ["AQbkEagmPgmsdAfS4X8V8UyJnXXjVPMvjeD15etqQ3Jh"] + ], + answer: [[ + { + slot: 122422797, + prioritizationFee: 0, + }, + { + slot: 122422797, + prioritizationFee: 0, + }, + ]], + }, ];