From 61dc248f09179f9d7735916b3dd791712d0b8f20 Mon Sep 17 00:00:00 2001 From: Steven Luscher Date: Sat, 23 Nov 2024 08:19:40 +0000 Subject: [PATCH] Port `@suite-common/wallet-core` and `@suite-common/wallet-utils` to `@solana/web3.js` version 2 1. Corrected a decimal amount in one of the fixtures. That `amount` field is denominated in basis points. 2. Deleted most of the custom transaction instruction formatting code, in favour of the new auto-generated program clients for the System, Compute Budget, and Token programs. 3. Added a note that `lastValidBlockHeight` is sometimes `undefined` in tests --- .../src/send/sendFormSolanaThunks.ts | 18 +- suite-common/wallet-utils/package.json | 3 + .../src/__fixtures__/solanaUtils.ts | 78 ++-- .../src/__tests__/solanaUtils.test.ts | 12 +- suite-common/wallet-utils/src/solanaUtils.ts | 357 ++++++++++-------- yarn.lock | 21 ++ 6 files changed, 269 insertions(+), 220 deletions(-) diff --git a/suite-common/wallet-core/src/send/sendFormSolanaThunks.ts b/suite-common/wallet-core/src/send/sendFormSolanaThunks.ts index ed186754de86..52da1629cf49 100644 --- a/suite-common/wallet-core/src/send/sendFormSolanaThunks.ts +++ b/suite-common/wallet-core/src/send/sendFormSolanaThunks.ts @@ -15,7 +15,6 @@ import { calculateTotal, formatAmount, getExternalComposeOutput, - getPubKeyFromAddress, buildTransferTransaction, buildTokenTransferTransaction, getAssociatedTokenAccountAddress, @@ -202,7 +201,7 @@ export const composeSolanaTransactionFeeLevelsThunk = createThunk< // Since all the values don't have to be filled in the form at the time of this function call, we use dummy values // for the estimation, since these values don't affect the final fee. // The real transaction is constructed in `signTransaction`, this one is used solely for fee estimation and is never submitted. - const transactionMessage = ( + const transferTx = tokenTransferTxAndDestinationAddress != null ? tokenTransferTxAndDestinationAddress.transaction : await buildTransferTransaction( @@ -212,8 +211,7 @@ export const composeSolanaTransactionFeeLevelsThunk = createThunk< blockhash, lastValidBlockHeight, dummyPriorityFeesForFeeEstimation, - ) - ).compileMessage(); + ); const isCreatingAccount = tokenInfo && @@ -225,7 +223,7 @@ export const composeSolanaTransactionFeeLevelsThunk = createThunk< coin: account.symbol, request: { specific: { - data: transactionMessage.serialize().toString('hex'), + data: transferTx.serialize(), isCreatingAccount, }, }, @@ -376,8 +374,6 @@ export const signSolanaSendFormTransactionThunk = createThunk< }, ); - const serializedTx = tx.serializeMessage().toString('hex'); - const response = await TrezorConnect.solanaSignTransaction({ device: { path: device.path, @@ -386,7 +382,7 @@ export const signSolanaSendFormTransactionThunk = createThunk< }, useEmptyPassphrase: device.useEmptyPassphrase, path: selectedAccount.path, - serializedTx, + serializedTx: tx.serializeMessage(), additionalInfo: tokenTransferTxAndDestinationAddress && tokenTransferTxAndDestinationAddress.tokenAccountInfo @@ -407,10 +403,8 @@ export const signSolanaSendFormTransactionThunk = createThunk< }); } - const signerPubKey = await getPubKeyFromAddress(selectedAccount.descriptor); - tx.addSignature(signerPubKey, Buffer.from(response.payload.signature, 'hex')); - - const signedSerializedTx = tx.serialize().toString('hex'); + await tx.addSignature(response.payload.signature); + const signedSerializedTx = tx.serialize(); return { serializedTx: signedSerializedTx }; }, diff --git a/suite-common/wallet-utils/package.json b/suite-common/wallet-utils/package.json index 32937d365016..4dad7f33be65 100644 --- a/suite-common/wallet-utils/package.json +++ b/suite-common/wallet-utils/package.json @@ -13,6 +13,9 @@ }, "dependencies": { "@mobily/ts-belt": "^3.13.1", + "@solana-program/compute-budget": "^0.6.1", + "@solana-program/system": "^0.6.2", + "@solana-program/token": "^0.4.1", "@solana/buffer-layout": "^4.0.1", "@solana/web3.js": "^2.0.0", "@suite-common/fiat-services": "workspace:*", diff --git a/suite-common/wallet-utils/src/__fixtures__/solanaUtils.ts b/suite-common/wallet-utils/src/__fixtures__/solanaUtils.ts index 3268f4ed2e82..603d89b1aeea 100644 --- a/suite-common/wallet-utils/src/__fixtures__/solanaUtils.ts +++ b/suite-common/wallet-utils/src/__fixtures__/solanaUtils.ts @@ -1,3 +1,5 @@ +import { AccountRole } from '@solana/web3.js'; + import { BigNumber } from '@trezor/utils/src/bigNumber'; import { TOKEN_PROGRAM_PUBLIC_KEY, @@ -96,34 +98,33 @@ export const fixtures = { from: 'CR6QfobBidQTSYdR6jihKTfMnHkRUtw8cLDCxENDVYmd', to: 'GrwHUG2U6Nmr2CHjQ2kesKzbjMwvCNytcMAbhQxq1Jyd', owner: 'ETxHeBBcuw9Yu4dGuP3oXrD12V5RECvmi8ogQ9PkjyVF', - amount: new BigNumber('0.1'), + amount: new BigNumber('1'), mint: '6YuhWADZyAAxAaVKPm1G5N51RvDBXsnWo4SfsJ47wSoK', decimals: 9, }, expectedOutput: { - keys: [ - { - pubkey: 'CR6QfobBidQTSYdR6jihKTfMnHkRUtw8cLDCxENDVYmd', - isSigner: false, - isWritable: true, - }, + accounts: [ { - pubkey: '6YuhWADZyAAxAaVKPm1G5N51RvDBXsnWo4SfsJ47wSoK', - isSigner: false, - isWritable: false, + address: 'CR6QfobBidQTSYdR6jihKTfMnHkRUtw8cLDCxENDVYmd', + role: AccountRole.WRITABLE, }, { - pubkey: 'GrwHUG2U6Nmr2CHjQ2kesKzbjMwvCNytcMAbhQxq1Jyd', - isSigner: false, - isWritable: true, + address: '6YuhWADZyAAxAaVKPm1G5N51RvDBXsnWo4SfsJ47wSoK', + role: AccountRole.READONLY, }, { - pubkey: 'ETxHeBBcuw9Yu4dGuP3oXrD12V5RECvmi8ogQ9PkjyVF', - isSigner: true, - isWritable: false, + address: 'GrwHUG2U6Nmr2CHjQ2kesKzbjMwvCNytcMAbhQxq1Jyd', + role: AccountRole.WRITABLE, }, + expect.objectContaining({ + address: 'ETxHeBBcuw9Yu4dGuP3oXrD12V5RECvmi8ogQ9PkjyVF', + role: AccountRole.READONLY_SIGNER, + signer: expect.objectContaining({ + address: 'ETxHeBBcuw9Yu4dGuP3oXrD12V5RECvmi8ogQ9PkjyVF', + }), + }), ], - data: Buffer.from([12, 0, 0, 0, 0, 0, 0, 0, 0, 9]), + data: new Uint8Array([12, 1, 0, 0, 0, 0, 0, 0, 0, 9]), }, }, ], @@ -137,39 +138,36 @@ export const fixtures = { }, expectedOutput: { pubkey: 'GrwHUG2U6Nmr2CHjQ2kesKzbjMwvCNytcMAbhQxq1Jyd', - keys: [ - { - pubkey: 'ETxHeBBcuw9Yu4dGuP3oXrD12V5RECvmi8ogQ9PkjyVF', - isSigner: true, - isWritable: true, - }, + accounts: [ + expect.objectContaining({ + address: 'ETxHeBBcuw9Yu4dGuP3oXrD12V5RECvmi8ogQ9PkjyVF', + role: AccountRole.WRITABLE_SIGNER, + signer: expect.objectContaining({ + address: 'ETxHeBBcuw9Yu4dGuP3oXrD12V5RECvmi8ogQ9PkjyVF', + }), + }), { - pubkey: 'GrwHUG2U6Nmr2CHjQ2kesKzbjMwvCNytcMAbhQxq1Jyd', - isSigner: false, - isWritable: true, + address: 'GrwHUG2U6Nmr2CHjQ2kesKzbjMwvCNytcMAbhQxq1Jyd', + role: AccountRole.WRITABLE, }, { - pubkey: 'FAeNERRWGL8xtnwtM5dWBUs9Z1y5fenSJcawu55NQSWk', - isSigner: false, - isWritable: false, + address: 'FAeNERRWGL8xtnwtM5dWBUs9Z1y5fenSJcawu55NQSWk', + role: AccountRole.READONLY, }, { - pubkey: '6YuhWADZyAAxAaVKPm1G5N51RvDBXsnWo4SfsJ47wSoK', - isSigner: false, - isWritable: false, + address: '6YuhWADZyAAxAaVKPm1G5N51RvDBXsnWo4SfsJ47wSoK', + role: AccountRole.READONLY, }, { - pubkey: SYSTEM_PROGRAM_PUBLIC_KEY, - isSigner: false, - isWritable: false, + address: SYSTEM_PROGRAM_PUBLIC_KEY, + role: AccountRole.READONLY, }, { - pubkey: TOKEN_PROGRAM_PUBLIC_KEY, - isSigner: false, - isWritable: false, + address: TOKEN_PROGRAM_PUBLIC_KEY, + role: AccountRole.READONLY, }, ], - data: Buffer.from([]), + data: new Uint8Array([0]), }, }, ], @@ -198,7 +196,7 @@ export const fixtures = { }, }, expectedOutput: - '01000609c80f8b50107e9f3e3c16a661b8c806df454a6deb293d5e8730a9d28f2f4998c6a99c9c4d0c7def9dd60a3a40dc5266faf41996310aa62ad6cbd9b64e1e2cca78ebaa24826cef9644c1ecf0dfcf955775b8438528e97820efc2b20ed46be1dc580000000000000000000000000000000000000000000000000000000000000000527706a12f3f7c3c852582f0f79b515c03c6ffbe6e3100044ba7c982eb5cf9f28c97258f4e2489f1bb3d1029148e0d830b5a1399daff1084048e7bd8dbe9f8590306466fe5211732ffecadba72c39be7bc8ce5bbc5f7126b2c439b3a40000000d27c181cb023db6239e22e49e4b67f7dd9ed13f3d7ed319f9e91b3bc64cec0a906ddf6e1d765a193d9cbe146ceeb79ac1cb485ed5f5b37913a8cf5857eff00a96772b7d36a2e66e52c817f385d7e94d3d4b6d47d7171c9f2dd86c6f1be1a93eb040600050250c3000006000903a0860100000000000506000207040308000804010402000a0c00e1f5050000000009', + '01000609c80f8b50107e9f3e3c16a661b8c806df454a6deb293d5e8730a9d28f2f4998c6a99c9c4d0c7def9dd60a3a40dc5266faf41996310aa62ad6cbd9b64e1e2cca78ebaa24826cef9644c1ecf0dfcf955775b8438528e97820efc2b20ed46be1dc580000000000000000000000000000000000000000000000000000000000000000527706a12f3f7c3c852582f0f79b515c03c6ffbe6e3100044ba7c982eb5cf9f28c97258f4e2489f1bb3d1029148e0d830b5a1399daff1084048e7bd8dbe9f8590306466fe5211732ffecadba72c39be7bc8ce5bbc5f7126b2c439b3a40000000d27c181cb023db6239e22e49e4b67f7dd9ed13f3d7ed319f9e91b3bc64cec0a906ddf6e1d765a193d9cbe146ceeb79ac1cb485ed5f5b37913a8cf5857eff00a96772b7d36a2e66e52c817f385d7e94d3d4b6d47d7171c9f2dd86c6f1be1a93eb040600050250c3000006000903a086010000000000050600020704030801000804010402000a0c00e1f5050000000009', }, { description: diff --git a/suite-common/wallet-utils/src/__tests__/solanaUtils.test.ts b/suite-common/wallet-utils/src/__tests__/solanaUtils.test.ts index 89684de2d0cc..282153cbbed1 100644 --- a/suite-common/wallet-utils/src/__tests__/solanaUtils.test.ts +++ b/suite-common/wallet-utils/src/__tests__/solanaUtils.test.ts @@ -34,9 +34,7 @@ describe('solana utils', () => { input.decimals, ); - const keys = txix.keys.map(key => ({ ...key, pubkey: key.pubkey.toString() })); - - expect(keys).toEqual(expectedOutput.keys); + expect(txix.accounts).toEqual(expectedOutput.accounts); expect(txix.data).toEqual(expectedOutput.data); }); }); @@ -52,10 +50,8 @@ describe('solana utils', () => { input.tokenMintAddress, ); - const keys = txix.keys.map(key => ({ ...key, pubkey: key.pubkey.toString() })); - - expect(pubkey.toString()).toEqual(expectedOutput.pubkey); - expect(keys).toEqual(expectedOutput.keys); + expect(pubkey).toEqual(expectedOutput.pubkey); + expect(txix.accounts).toEqual(expectedOutput.accounts); expect(txix.data).toEqual(expectedOutput.data); }); }, @@ -78,7 +74,7 @@ describe('solana utils', () => { input.lastValidBlockHeight, input.priorityFees, ); - const message = tx.transaction.compileMessage().serialize().toString('hex'); + const message = tx.transaction.serializeMessage(); expect(message).toEqual(expectedOutput); }); diff --git a/suite-common/wallet-utils/src/solanaUtils.ts b/suite-common/wallet-utils/src/solanaUtils.ts index b6e86f58cd8f..b79b1772f9af 100644 --- a/suite-common/wallet-utils/src/solanaUtils.ts +++ b/suite-common/wallet-utils/src/solanaUtils.ts @@ -1,6 +1,5 @@ -import * as BufferLayout from '@solana/buffer-layout'; import { A, F, pipe } from '@mobily/ts-belt'; -import type { Transaction } from '@solana/web3.js'; +import type { Blockhash, CompilableTransactionMessage, TransactionMessage } from '@solana/web3.js'; import { BigNumber } from '@trezor/utils/src/bigNumber'; import type { TokenAccount } from '@trezor/blockchain-link-types'; @@ -8,39 +7,19 @@ import { solanaUtils as SolanaBlockchainLinkUtils } from '@trezor/blockchain-lin import { getLamportsFromSol } from './sendFormUtils'; -const { TOKEN_PROGRAM_PUBLIC_KEY, ASSOCIATED_TOKEN_PROGRAM_PUBLIC_KEY, SYSTEM_PROGRAM_PUBLIC_KEY } = - SolanaBlockchainLinkUtils; +const { TOKEN_PROGRAM_PUBLIC_KEY, SYSTEM_PROGRAM_PUBLIC_KEY } = SolanaBlockchainLinkUtils; const loadSolanaLib = async () => { - const lib = await import('@solana/web3.js'); - - return lib; + return await import('@solana/web3.js'); }; - -export const getPubKeyFromAddress = async (address: string) => { - const { PublicKey } = await loadSolanaLib(); - - return new PublicKey(address); +const loadSolanaComputeBudgetProgramLib = async () => { + return await import('@solana-program/compute-budget'); }; - -const encodeTokenTransferInstructionData = (instruction: { - transferChecked: { amount: BigNumber; decimals: number }; -}): Buffer => { - // To transfer Solana tokens we need to manually encode a buffer with the correct layout - // according to the instruction standard. - const LAYOUT = BufferLayout.union(BufferLayout.u8('instruction')); - LAYOUT.addVariant( - 12, - BufferLayout.struct([BufferLayout.nu64('amount'), BufferLayout.u8('decimals')]), - 'transferChecked', - ); - - const instructionMaxSpan = Math.max(...Object.values(LAYOUT.registry).map((r: any) => r.span)); - - const b = Buffer.alloc(instructionMaxSpan); - const span = LAYOUT.encode(instruction, b); - - return b.subarray(0, span); +const loadSolanaSystemProgramLib = async () => { + return await import('@solana-program/system'); +}; +const loadSolanaTokenProgramLib = async () => { + return await import('@solana-program/token'); }; type PriorityFees = { computeUnitPrice: string; computeUnitLimit: string }; @@ -50,15 +29,55 @@ export const dummyPriorityFeesForFeeEstimation: PriorityFees = { computeUnitLimit: '200000', }; -const addPriorityFees = async (transaction: Transaction, priorityFees: PriorityFees) => { - const { ComputeBudgetProgram } = await loadSolanaLib(); - transaction.add( - ComputeBudgetProgram.setComputeUnitLimit({ - units: parseInt(priorityFees.computeUnitLimit, 10), - }), - ComputeBudgetProgram.setComputeUnitPrice({ - microLamports: parseInt(priorityFees.computeUnitPrice, 10), - }), +async function createTransactionShim(message: CompilableTransactionMessage) { + const { + compileTransaction, + signTransaction, + createKeyPairFromPrivateKeyBytes, + getBase16Codec, + getTransactionEncoder, + } = await loadSolanaLib(); + + let transaction = compileTransaction(message); + + return { + async addSignature(privateKeyHex: string) { + const privateKeyBytes = getBase16Codec().encode(privateKeyHex); + const keypair = await createKeyPairFromPrivateKeyBytes(privateKeyBytes); + transaction = await signTransaction([keypair], transaction); + }, + serializeMessage() { + return getBase16Codec().decode(transaction.messageBytes); + }, + serialize() { + return pipe(transaction, getTransactionEncoder().encode, getBase16Codec().decode); + }, + }; +} + +const addPriorityFees = async ( + message: TMessage, + priorityFees: PriorityFees, +) => { + const [ + // @solana/web3.js + { prependTransactionMessageInstructions }, + // @solana-program/compute-budget + { getSetComputeUnitLimitInstruction, getSetComputeUnitPriceInstruction }, + ] = await Promise.all([loadSolanaLib(), loadSolanaComputeBudgetProgramLib()]); + + return pipe(message, m => + prependTransactionMessageInstructions( + [ + getSetComputeUnitLimitInstruction({ + units: parseInt(priorityFees.computeUnitLimit, 10), + }), + getSetComputeUnitPriceInstruction({ + microLamports: parseInt(priorityFees.computeUnitPrice, 10), + }), + ], + m, + ), ); }; @@ -70,47 +89,47 @@ export const buildTransferTransaction = async ( lastValidBlockHeight: number, priorityFees: PriorityFees, ) => { - const { Transaction, SystemProgram, PublicKey } = await loadSolanaLib(); - const transaction = new Transaction({ - blockhash, - lastValidBlockHeight, - feePayer: new PublicKey(fromAddress), - }); - - await addPriorityFees(transaction, priorityFees); - - transaction.add( - SystemProgram.transfer({ - fromPubkey: new PublicKey(fromAddress), - toPubkey: new PublicKey(toAddress), - lamports: getLamportsFromSol(amountInSol), - }), + const [ + // @solana/web3.js + { + address, + appendTransactionMessageInstruction, + createTransactionMessage, + lamports, + setTransactionMessageLifetimeUsingBlockhash, + setTransactionMessageFeePayer, + createNoopSigner, + }, + // @solana-program/system + { getTransferSolInstruction }, + ] = await Promise.all([loadSolanaLib(), loadSolanaSystemProgramLib()]); + const message = await pipe( + createTransactionMessage({ version: 'legacy' }), + m => setTransactionMessageFeePayer(address(fromAddress), m), + m => + setTransactionMessageLifetimeUsingBlockhash( + { + blockhash: blockhash as Blockhash, + lastValidBlockHeight: BigInt( + // FIXME: In tests, `lastValidBlockHeight` is sometimes `undefined`. + lastValidBlockHeight ?? '0xFFFFFFFFFFFFFFFF', + ), + }, + m, + ), + m => + appendTransactionMessageInstruction( + getTransferSolInstruction({ + amount: lamports(getLamportsFromSol(amountInSol)), + destination: address(toAddress), + source: createNoopSigner(address(fromAddress)), + }), + m, + ), + m => addPriorityFees(m, priorityFees), ); - return transaction; -}; - -// exported for testing -export const getMinimumRequiredTokenAccountsForTransfer = ( - tokenAccounts: TokenAccount[], - requiredAmount: string, -) => { - // sort the tokenAccounts from highest to lowest balance - let accumulatedBalance = new BigNumber('0'); - const requiredAccounts = F.toMutable( - pipe( - tokenAccounts, - A.sort((a, b) => new BigNumber(b.balance).comparedTo(new BigNumber(a.balance))), - A.takeWhile(tokenAccount => { - const needMoreAccounts = accumulatedBalance.lt(requiredAmount); - accumulatedBalance = accumulatedBalance.plus(tokenAccount.balance); - - return needMoreAccounts; - }), - ), - ); - - return requiredAccounts; + return await createTransactionShim(message); }; // Construct the transfer instruction for a token transfer @@ -123,21 +142,20 @@ export const buildTokenTransferInstruction = async ( mint: string, decimals: number, ) => { - const { TransactionInstruction, PublicKey } = await loadSolanaLib(); - // key layout: https://github.com/solana-labs/solana-program-library/blob/master/token/program/src/instruction.rs#L254 - const keys = [ - { pubkey: new PublicKey(from), isSigner: false, isWritable: true }, - { pubkey: new PublicKey(mint), isSigner: false, isWritable: false }, - { pubkey: new PublicKey(to), isSigner: false, isWritable: true }, - { pubkey: new PublicKey(owner), isSigner: true, isWritable: false }, - ]; - - return new TransactionInstruction({ - keys, - data: encodeTokenTransferInstructionData({ - transferChecked: { amount, decimals }, - }), - programId: new PublicKey(TOKEN_PROGRAM_PUBLIC_KEY), + const [ + // @solana/web3.js + { address, createNoopSigner }, + // @solana-program/token + { getTransferCheckedInstruction }, + ] = await Promise.all([loadSolanaLib(), loadSolanaTokenProgramLib()]); + + return getTransferCheckedInstruction({ + amount: BigInt(amount.toString()), + authority: createNoopSigner(address(owner)), + decimals, + destination: address(to), + mint: address(mint), + source: address(from), }); }; @@ -145,16 +163,20 @@ export const getAssociatedTokenAccountAddress = async ( baseAddress: string, tokenMintAddress: string, ) => { - const { PublicKey } = await loadSolanaLib(); - - return PublicKey.findProgramAddressSync( - [ - new PublicKey(baseAddress).toBuffer(), - new PublicKey(TOKEN_PROGRAM_PUBLIC_KEY).toBuffer(), - new PublicKey(tokenMintAddress).toBuffer(), - ], - new PublicKey(ASSOCIATED_TOKEN_PROGRAM_PUBLIC_KEY), - )[0]; + const [ + // @solana/web3.js + { address }, + // @solana-program/token + { findAssociatedTokenPda, TOKEN_PROGRAM_ADDRESS }, + ] = await Promise.all([loadSolanaLib(), loadSolanaTokenProgramLib()]); + + const [pdaAddress] = await findAssociatedTokenPda({ + mint: address(tokenMintAddress), + owner: address(baseAddress), + tokenProgram: TOKEN_PROGRAM_ADDRESS, + }); + + return pdaAddress; }; // Construct an instruction to create an associated token account. Used in token transfers @@ -163,58 +185,34 @@ export const buildCreateAssociatedTokenAccountInstruction = async ( newOwnerAddress: string, tokenMintAddress: string, ) => { - const { TransactionInstruction, PublicKey } = await loadSolanaLib(); + const [ + // @solana/web3.js + { address, createNoopSigner }, + // @solana-program/token + { getCreateAssociatedTokenInstruction }, + ] = await Promise.all([loadSolanaLib(), loadSolanaTokenProgramLib()]); const associatedTokenAccountAddress = await getAssociatedTokenAccountAddress( newOwnerAddress, tokenMintAddress, ); - // key layout: https://github.com/solana-labs/solana-program-library/blob/master/associated-token-account/program/src/lib.rs#L58 - const keys = [ - { - pubkey: new PublicKey(funderAddress), - isSigner: true, - isWritable: true, - }, - { - pubkey: new PublicKey(associatedTokenAccountAddress), - isSigner: false, - isWritable: true, - }, - { - pubkey: new PublicKey(newOwnerAddress), - isSigner: false, - isWritable: false, - }, - { - pubkey: new PublicKey(tokenMintAddress), - isSigner: false, - isWritable: false, - }, - { - pubkey: new PublicKey(SYSTEM_PROGRAM_PUBLIC_KEY), - isSigner: false, - isWritable: false, - }, - { - pubkey: new PublicKey(TOKEN_PROGRAM_PUBLIC_KEY), - isSigner: false, - isWritable: false, - }, - ]; - - const txInstruction = new TransactionInstruction({ - keys, - programId: new PublicKey(ASSOCIATED_TOKEN_PROGRAM_PUBLIC_KEY), - data: Buffer.from([]), + const txInstruction = getCreateAssociatedTokenInstruction({ + ata: associatedTokenAccountAddress, + mint: address(tokenMintAddress), + owner: address(newOwnerAddress), + payer: createNoopSigner(address(funderAddress)), }); return [txInstruction, associatedTokenAccountAddress] as const; }; type TokenTransferTxWithDestinationAddress = { - transaction: Transaction; + transaction: { + addSignature(privateKeyHex: string): Promise; + serializeMessage(): string; + serialize(): string; + }; destinationAddress: string; tokenAccountInfo?: { baseAddress: string; @@ -224,6 +222,29 @@ type TokenTransferTxWithDestinationAddress = { }; }; +// exported for testing +export const getMinimumRequiredTokenAccountsForTransfer = ( + tokenAccounts: TokenAccount[], + requiredAmount: string, +) => { + // sort the tokenAccounts from highest to lowest balance + let accumulatedBalance = new BigNumber('0'); + const requiredAccounts = F.toMutable( + pipe( + tokenAccounts, + A.sort((a, b) => new BigNumber(b.balance).comparedTo(new BigNumber(a.balance))), + A.takeWhile(tokenAccount => { + const needMoreAccounts = accumulatedBalance.lt(requiredAmount); + accumulatedBalance = accumulatedBalance.plus(tokenAccount.balance); + + return needMoreAccounts; + }), + ), + ); + + return requiredAccounts; +}; + export const buildTokenTransferTransaction = async ( fromAddress: string, toAddress: string, @@ -237,15 +258,31 @@ export const buildTokenTransferTransaction = async ( lastValidBlockHeight: number, priorityFees: PriorityFees, ): Promise => { - const { Transaction, PublicKey } = await loadSolanaLib(); - - const transaction = new Transaction({ - blockhash, - lastValidBlockHeight, - feePayer: new PublicKey(fromAddress), - }); - - await addPriorityFees(transaction, priorityFees); + const { + address, + appendTransactionMessageInstruction, + appendTransactionMessageInstructions, + createTransactionMessage, + setTransactionMessageFeePayer, + setTransactionMessageLifetimeUsingBlockhash, + } = await loadSolanaLib(); + + let message = await pipe( + createTransactionMessage({ version: 'legacy' }), + m => setTransactionMessageFeePayer(address(fromAddress), m), + m => + setTransactionMessageLifetimeUsingBlockhash( + { + blockhash: blockhash as Blockhash, + lastValidBlockHeight: BigInt( + // FIXME: In tests, `lastValidBlockHeight` is sometimes `undefined`. + lastValidBlockHeight ?? '0xFFFFFFFFFFFFFFFF', + ), + }, + m, + ), + m => addPriorityFees(m, priorityFees), + ); // Token transaction building logic @@ -276,8 +313,8 @@ export const buildTokenTransferTransaction = async ( ); // Add the account creation instruction to the transaction and use the newly created associated token account as the receiver - transaction.add(createAccountInstruction); - finalReceiverAddress = associatedTokenAccountAddress.toString(); + message = appendTransactionMessageInstruction(createAccountInstruction, message); + finalReceiverAddress = associatedTokenAccountAddress; } } @@ -295,17 +332,17 @@ export const buildTokenTransferTransaction = async ( tokenDecimals, ); - // Step 6: Add the token transfer instruction(s) to the transaction - transaction.add(transferInstruction); - remainingAmount = remainingAmount.minus(transferAmount); + + return transferInstruction; }); - await Promise.all(instructionPromises); + // Step 6: Add the token transfer instruction(s) to the transaction + message = appendTransactionMessageInstructions(await Promise.all(instructionPromises), message); // Step 7: Return the transaction return { - transaction, + transaction: await createTransactionShim(message), destinationAddress: finalReceiverAddress, tokenAccountInfo: isReceiverAddressSystemAccount ? { diff --git a/yarn.lock b/yarn.lock index 6a58a0644eca..63a5daddf5bd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7697,6 +7697,24 @@ __metadata: languageName: node linkType: hard +"@solana-program/compute-budget@npm:^0.6.1": + version: 0.6.1 + resolution: "@solana-program/compute-budget@npm:0.6.1" + peerDependencies: + "@solana/web3.js": ^2.0.0 + checksum: 10/bce81b0bb11ca6c6bb689689d27daaf6302a1d5af95fc862e002434ad0b5f122ffe73b15d8e3a688303a2617a6aa82c8cd6c60f2cbaf507cec88d16fc744a611 + languageName: node + linkType: hard + +"@solana-program/system@npm:^0.6.2": + version: 0.6.2 + resolution: "@solana-program/system@npm:0.6.2" + peerDependencies: + "@solana/web3.js": ^2.0.0 + checksum: 10/aef5fba1b10dd6bf5e69a497d35ac398ca6673af1d6dff7956717217a4475edf0c2ebb208132e4bebd8e039a1914523fecf9816b9a676357af10bfa7fed44b8b + languageName: node + linkType: hard + "@solana-program/token@npm:^0.4.1": version: 0.4.1 resolution: "@solana-program/token@npm:0.4.1" @@ -9411,6 +9429,9 @@ __metadata: resolution: "@suite-common/wallet-utils@workspace:suite-common/wallet-utils" dependencies: "@mobily/ts-belt": "npm:^3.13.1" + "@solana-program/compute-budget": "npm:^0.6.1" + "@solana-program/system": "npm:^0.6.2" + "@solana-program/token": "npm:^0.4.1" "@solana/buffer-layout": "npm:^4.0.1" "@solana/web3.js": "npm:^2.0.0" "@suite-common/fiat-services": "workspace:*"