From d642302f547cbf9701825409ea119732e9a181c9 Mon Sep 17 00:00:00 2001 From: Martin Zemanik Date: Mon, 2 Dec 2024 15:14:09 +0100 Subject: [PATCH] feat(solana): add support for Token-2022 tokens --- packages/blockchain-link-types/src/common.ts | 2 +- packages/blockchain-link-types/src/params.ts | 1 + packages/blockchain-link-utils/src/solana.ts | 50 ++++- packages/blockchain-link/package.json | 1 + .../src/workers/solana/index.ts | 55 ++++-- .../connect/src/api/blockchainEstimateFee.ts | 1 + .../src/send/sendFormSolanaThunks.ts | 21 +- suite-common/wallet-utils/package.json | 1 + .../src/__fixtures__/solanaUtils.ts | 184 +++++++++++++++++- .../src/__tests__/solanaUtils.test.ts | 3 + suite-common/wallet-utils/src/solanaUtils.ts | 40 ++-- yarn.lock | 11 ++ 12 files changed, 324 insertions(+), 46 deletions(-) diff --git a/packages/blockchain-link-types/src/common.ts b/packages/blockchain-link-types/src/common.ts index 75004777e2c..3d5827fa28c 100644 --- a/packages/blockchain-link-types/src/common.ts +++ b/packages/blockchain-link-types/src/common.ts @@ -53,7 +53,7 @@ export interface ServerInfo { consensusBranchId?: number; // zcash current branch id } -export type TokenStandard = 'ERC20' | 'ERC1155' | 'ERC721' | 'SPL' | 'BEP20'; +export type TokenStandard = 'ERC20' | 'ERC1155' | 'ERC721' | 'SPL' | 'SPL-2022' | 'BEP20'; export type TransferType = 'sent' | 'recv' | 'self' | 'unknown'; diff --git a/packages/blockchain-link-types/src/params.ts b/packages/blockchain-link-types/src/params.ts index 735aacb02b9..c97f7d2a25d 100644 --- a/packages/blockchain-link-types/src/params.ts +++ b/packages/blockchain-link-types/src/params.ts @@ -32,6 +32,7 @@ export interface EstimateFeeParams { data?: string; // eth tx data, sol tx message value?: string; // eth tx amount isCreatingAccount?: boolean; // sol account creation + newTokenAccountProgramName?: 'spl-token' | 'spl-token-2022'; // program name of the Solana Token account that is being created, ignored if isCreatingAccount is false, default: 'spl-token' }; } diff --git a/packages/blockchain-link-utils/src/solana.ts b/packages/blockchain-link-utils/src/solana.ts index 396bc3c29d1..6a047849c2e 100644 --- a/packages/blockchain-link-utils/src/solana.ts +++ b/packages/blockchain-link-utils/src/solana.ts @@ -14,7 +14,7 @@ import type { SolanaValidParsedTxWithMeta, TokenDetailByMint, } from '@trezor/blockchain-link-types/src/solana'; -import type { TokenInfo } from '@trezor/blockchain-link-types/src'; +import type { TokenInfo, TokenStandard } from '@trezor/blockchain-link-types/src'; import { isCodesignBuild } from '@trezor/env-utils'; import { formatTokenSymbol } from './utils'; @@ -27,6 +27,8 @@ export type ApiTokenAccount = { // Docs regarding solana programs: https://spl.solana.com/ // Token program docs: https://spl.solana.com/token export const TOKEN_PROGRAM_PUBLIC_KEY = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'; +// Token 2022 program docs: https://spl.solana.com/token-2022 +export const TOKEN_2022_PROGRAM_PUBLIC_KEY = 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'; // Associated token program docs: https://spl.solana.com/associated-token-account export const ASSOCIATED_TOKEN_PROGRAM_PUBLIC_KEY = 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'; // System program docs: https://docs.solana.com/developing/runtime-facilities/programs#system-program @@ -35,6 +37,20 @@ export const SYSTEM_PROGRAM_PUBLIC_KEY = '11111111111111111111111111111111'; // when parsing tx effects. export const WSOL_MINT = 'So11111111111111111111111111111111111111112'; +const tokenProgramNames = ['spl-token', 'spl-token-2022'] as const; +export type TokenProgramName = (typeof tokenProgramNames)[number]; + +export const tokenProgramsInfo = { + 'spl-token': { + publicKey: TOKEN_PROGRAM_PUBLIC_KEY, + tokenStandard: 'SPL', + }, + 'spl-token-2022': { + publicKey: TOKEN_2022_PROGRAM_PUBLIC_KEY, + tokenStandard: 'SPL-2022', + }, +} as const satisfies Record; + export const getTokenMetadata = async (): Promise => { const env = isCodesignBuild() ? 'stable' : 'develop'; @@ -65,9 +81,22 @@ export const getTokenNameAndSymbol = (mint: string, tokenDetailByMint: TokenDeta }; }; +const isTokenProgramName = (programName: string): programName is TokenProgramName => + tokenProgramNames.some(name => name === programName); + +export const tokenStandardToTokenProgramName = (standard: string): TokenProgramName => { + const tokenProgram = Object.entries(tokenProgramsInfo).find( + ([_, programInfo]) => programInfo.tokenStandard === standard, + ); + if (!tokenProgram) + throw new Error(`Cannot convert token standard ${standard} to Solana token program name`); + + return tokenProgram[0] as TokenProgramName; +}; + type SplTokenAccountData = { /** Name of the program that owns this account */ - program: 'spl-token'; + program: TokenProgramName; /** Parsed account data */ parsed: { info: { @@ -89,7 +118,7 @@ const isSplTokenAccount = (tokenAccount: ApiTokenAccount): tokenAccount is SplTo const { parsed } = tokenAccount.account.data; return ( - tokenAccount.account.data.program === 'spl-token' && + isTokenProgramName(tokenAccount.account.data.program) && 'info' in parsed && !!parsed.info && 'mint' in parsed.info && @@ -114,10 +143,13 @@ export const transformTokenInfo = ( // since ApiTokenAccount type is not precise enough, we type-guard the account to make sure they contain all the necessary data A.filter(isSplTokenAccount), A.map(tokenAccount => { - const { info } = tokenAccount.account.data.parsed; + const { + parsed: { info }, + program, + } = tokenAccount.account.data; return { - type: 'SPL', // Designation for Solana tokens + type: tokenProgramsInfo[program].tokenStandard, contract: info.mint, balance: info.tokenAmount.amount, decimals: info.tokenAmount.decimals, @@ -441,7 +473,7 @@ export const getAmount = ( }; type TokenTransferInstruction = { - program: 'spl-token'; + program: TokenProgramName; programId: Address; parsed: { type: 'transferChecked' | 'transfer'; @@ -472,7 +504,7 @@ const isTokenTransferInstruction = ( return ( 'program' in ix && typeof ix.program === 'string' && - ix.program === 'spl-token' && + isTokenProgramName(ix.program) && 'type' in parsed && typeof parsed.type === 'string' && (parsed.type === 'transferChecked' || parsed.type === 'transfer') && @@ -529,7 +561,7 @@ export const getTokens = ( const effects = tx.transaction.message.instructions .filter(isTokenTransferInstruction) .map((ix): TokenTransfer => { - const { parsed } = ix; + const { parsed, program } = ix; // some data, like `mint` and `decimals` may not be present in the instruction, but can be found in the token account info // so we try to find the token account info that matches the instruction and use it's data @@ -558,7 +590,7 @@ export const getTokens = ( return { type: getUiType(ix), - standard: 'SPL', + standard: tokenProgramsInfo[program].tokenStandard, from, to, contract: mint, diff --git a/packages/blockchain-link/package.json b/packages/blockchain-link/package.json index 1474f9973c8..c1d7ea7ebbb 100644 --- a/packages/blockchain-link/package.json +++ b/packages/blockchain-link/package.json @@ -77,6 +77,7 @@ }, "dependencies": { "@solana-program/token": "^0.4.1", + "@solana-program/token-2022": "^0.3.1", "@solana/web3.js": "^2.0.0", "@trezor/blockchain-link-types": "workspace:*", "@trezor/blockchain-link-utils": "workspace:*", diff --git a/packages/blockchain-link/src/workers/solana/index.ts b/packages/blockchain-link/src/workers/solana/index.ts index b44e9ca02f3..5be487b0c77 100644 --- a/packages/blockchain-link/src/workers/solana/index.ts +++ b/packages/blockchain-link/src/workers/solana/index.ts @@ -1,4 +1,5 @@ -import { getTokenSize } from '@solana-program/token'; +import { getTokenSize as _getTokenSize } from '@solana-program/token'; +import { getTokenSize as _getToken2022Size } from '@solana-program/token-2022'; import { address, assertTransactionIsFullySigned, @@ -52,7 +53,8 @@ import { solanaUtils } from '@trezor/blockchain-link-utils'; import { BigNumber, createLazy } from '@trezor/utils'; import { transformTokenInfo, - TOKEN_PROGRAM_PUBLIC_KEY, + tokenProgramsInfo, + type TokenProgramName, } from '@trezor/blockchain-link-utils/src/solana'; import { getSuiteVersion } from '@trezor/env-utils'; import { IntervalId } from '@trezor/type-utils'; @@ -281,17 +283,28 @@ const getAccountInfo = async (request: Request) => .filter((tx): tx is Transaction => !!tx); }; - const tokenAccounts = await api.rpc - .getTokenAccountsByOwner( - publicKey, - { programId: address(TOKEN_PROGRAM_PUBLIC_KEY) } /* filter */, - { - encoding: 'jsonParsed', - }, + const getTokenAccountsForProgram = (programPublicKey: string) => + api.rpc + .getTokenAccountsByOwner( + publicKey, + { programId: address(programPublicKey) } /* filter */, + { + encoding: 'jsonParsed', + }, + ) + .send(); + + const tokenAccounts = ( + await Promise.all( + Object.values(tokenProgramsInfo).map(programInfo => + getTokenAccountsForProgram(programInfo.publicKey), + ), ) - .send(); + ) + .map(res => res.value) + .flat(); - const allTxIds = await getAllTxIds(tokenAccounts.value.map(a => a.pubkey)); + const allTxIds = await getAllTxIds(tokenAccounts.map(a => a.pubkey)); const pageNumber = payload.page ? payload.page - 1 : 0; // for the first page of txs, payload.page is undefined, for the second page is 2 @@ -302,7 +315,7 @@ const getAccountInfo = async (request: Request) => const txIdPage = allTxIds.slice(pageStartIndex, pageEndIndex); - const tokenAccountsInfos = tokenAccounts.value.map(a => ({ + const tokenAccountsInfos = tokenAccounts.map(a => ({ address: a.pubkey, mint: a.account.data.parsed?.info?.mint as string | undefined, decimals: a.account.data.parsed?.info?.tokenAmount?.decimals as number | undefined, @@ -313,10 +326,10 @@ const getAccountInfo = async (request: Request) => // Fetch token info only if the account owns tokens let tokens: TokenInfo[] = []; - if (tokenAccounts.value.length > 0) { + if (tokenAccounts.length > 0) { const tokenMetadata = await request.getTokenMetadata(); - tokens = transformTokenInfo(tokenAccounts.value, tokenMetadata); + tokens = transformTokenInfo(tokenAccounts, tokenMetadata); } const { value: balance } = await api.rpc.getBalance(publicKey).send(); @@ -402,11 +415,17 @@ const getInfo = async (request: Request, isTestnet: boolea } as const; }; +const getTokenSize = (programName: TokenProgramName) => + ({ 'spl-token': _getTokenSize(), 'spl-token-2022': _getToken2022Size() })[programName]; + const estimateFee = async (request: Request) => { const api = await request.connect(); - const messageHex = request.payload.specific?.data; - const isCreatingAccount = request.payload.specific?.isCreatingAccount; + const { + data: messageHex, + isCreatingAccount, + newTokenAccountProgramName = 'spl-token', + } = request.payload.specific ?? {}; if (messageHex == null) { throw new Error('Could not estimate fee for transaction.'); @@ -417,7 +436,9 @@ const estimateFee = async (request: Request) => { const priorityFee = await getPriorityFee(api.rpc, message, transaction.signatures); const baseFee = await getBaseFee(api.rpc, message); const accountCreationFee = isCreatingAccount - ? await api.rpc.getMinimumBalanceForRentExemption(BigInt(getTokenSize())).send() + ? await api.rpc + .getMinimumBalanceForRentExemption(BigInt(getTokenSize(newTokenAccountProgramName))) + .send() : BigInt(0); const payload = [ diff --git a/packages/connect/src/api/blockchainEstimateFee.ts b/packages/connect/src/api/blockchainEstimateFee.ts index 2bd37bd9562..dc9f08fdf6c 100644 --- a/packages/connect/src/api/blockchainEstimateFee.ts +++ b/packages/connect/src/api/blockchainEstimateFee.ts @@ -44,6 +44,7 @@ export default class BlockchainEstimateFee extends AbstractMethod<'blockchainEst { name: 'to', type: 'string' }, { name: 'txsize', type: 'number' }, { name: 'isCreatingAccount', type: 'boolean' }, + { name: 'newTokenAccountProgramName', type: 'string' }, ]); } } diff --git a/suite-common/wallet-core/src/send/sendFormSolanaThunks.ts b/suite-common/wallet-core/src/send/sendFormSolanaThunks.ts index 379010d2f27..d7d752a2fe8 100644 --- a/suite-common/wallet-core/src/send/sendFormSolanaThunks.ts +++ b/suite-common/wallet-core/src/send/sendFormSolanaThunks.ts @@ -1,7 +1,11 @@ import { BigNumber } from '@trezor/utils/src/bigNumber'; import TrezorConnect, { FeeLevel } from '@trezor/connect'; import type { TokenInfo, TokenAccount } from '@trezor/blockchain-link-types'; -import { SYSTEM_PROGRAM_PUBLIC_KEY } from '@trezor/blockchain-link-utils/src/solana'; +import { + SYSTEM_PROGRAM_PUBLIC_KEY, + TokenProgramName, + tokenStandardToTokenProgramName, +} from '@trezor/blockchain-link-utils/src/solana'; import { ExternalOutput, PrecomposedTransaction, @@ -113,6 +117,7 @@ const fetchAccountOwnerAndTokenInfoForAddress = async ( address: string, symbol: string, mint: string, + tokenProgram: TokenProgramName, ) => { // Fetch data about recipient account owner if this is a token transfer // We need this in order to validate the address and ensure transfers go through @@ -126,7 +131,11 @@ const fetchAccountOwnerAndTokenInfoForAddress = async ( }); if (accountInfoResponse.success) { - const associatedTokenAccount = await getAssociatedTokenAccountAddress(address, mint); + const associatedTokenAccount = await getAssociatedTokenAccountAddress( + address, + mint, + tokenProgram, + ); accountOwner = accountInfoResponse.payload?.misc?.owner; tokenInfo = accountInfoResponse.payload?.tokens @@ -171,6 +180,7 @@ export const composeSolanaTransactionFeeLevelsThunk = createThunk< formState.outputs[0].address, account.symbol, tokenInfo.contract, + tokenStandardToTokenProgramName(tokenInfo.type), ) : [undefined, undefined]; @@ -204,6 +214,7 @@ export const composeSolanaTransactionFeeLevelsThunk = createThunk< blockhash, lastValidBlockHeight, dummyPriorityFeesForFeeEstimation, + tokenStandardToTokenProgramName(tokenInfo.type), ) : undefined; @@ -228,6 +239,9 @@ export const composeSolanaTransactionFeeLevelsThunk = createThunk< recipientTokenAccount === undefined && // if the recipient account has no owner, it means it's a new account and needs the token account to be created (recipientAccountOwner === SYSTEM_PROGRAM_PUBLIC_KEY || recipientAccountOwner == null); + const newTokenAccountProgramName = isCreatingAccount + ? tokenStandardToTokenProgramName(tokenInfo.type) + : undefined; const estimatedFee = await TrezorConnect.blockchainEstimateFee({ coin: account.symbol, @@ -235,6 +249,7 @@ export const composeSolanaTransactionFeeLevelsThunk = createThunk< specific: { data: transferTx.serialize(), isCreatingAccount, + newTokenAccountProgramName, }, }, }); @@ -340,6 +355,7 @@ export const signSolanaSendFormTransactionThunk = createThunk< formState.outputs[0].address, selectedAccount.symbol, token.contract, + tokenStandardToTokenProgramName(token.type), ) : [undefined, undefined]; @@ -366,6 +382,7 @@ export const signSolanaSendFormTransactionThunk = createThunk< computeUnitPrice: precomposedTransaction.feePerByte, computeUnitLimit: precomposedTransaction.feeLimit, }, + tokenStandardToTokenProgramName(token.type), ) : undefined; diff --git a/suite-common/wallet-utils/package.json b/suite-common/wallet-utils/package.json index 44cf2460aea..0eff49955a5 100644 --- a/suite-common/wallet-utils/package.json +++ b/suite-common/wallet-utils/package.json @@ -16,6 +16,7 @@ "@solana-program/compute-budget": "^0.6.1", "@solana-program/system": "^0.6.2", "@solana-program/token": "^0.4.1", + "@solana-program/token-2022": "^0.3.1", "@solana/web3.js": "^2.0.0", "@suite-common/fiat-services": "workspace:*", "@suite-common/metadata-types": "workspace:*", diff --git a/suite-common/wallet-utils/src/__fixtures__/solanaUtils.ts b/suite-common/wallet-utils/src/__fixtures__/solanaUtils.ts index ed6e3c37d7c..95d542514c7 100644 --- a/suite-common/wallet-utils/src/__fixtures__/solanaUtils.ts +++ b/suite-common/wallet-utils/src/__fixtures__/solanaUtils.ts @@ -4,6 +4,7 @@ import { BigNumber } from '@trezor/utils/src/bigNumber'; import { TOKEN_PROGRAM_PUBLIC_KEY, SYSTEM_PROGRAM_PUBLIC_KEY, + TOKEN_2022_PROGRAM_PUBLIC_KEY, } from '@trezor/blockchain-link-utils/src/solana'; export const fixtures = { @@ -93,7 +94,7 @@ export const fixtures = { ], buildTokenTransferInstruction: [ { - description: 'builds token transfer instruction', + description: 'builds token transfer instruction for the SPL Token program', input: { from: 'CR6QfobBidQTSYdR6jihKTfMnHkRUtw8cLDCxENDVYmd', to: 'GrwHUG2U6Nmr2CHjQ2kesKzbjMwvCNytcMAbhQxq1Jyd', @@ -101,6 +102,7 @@ export const fixtures = { amount: new BigNumber('1'), mint: '6YuhWADZyAAxAaVKPm1G5N51RvDBXsnWo4SfsJ47wSoK', decimals: 9, + tokenProgramName: 'spl-token' as const, }, expectedOutput: { accounts: [ @@ -127,14 +129,52 @@ export const fixtures = { data: new Uint8Array([12, 1, 0, 0, 0, 0, 0, 0, 0, 9]), }, }, + { + description: 'builds token transfer instruction for the Token 2022 program', + input: { + from: 'EdThAwDjfEj9joy2U7WvSsMwsHn5Wkby8R4j74qYJujz', + to: '4Qon5ZG7yYRkheuUwdqwN9nGRgu7oYn9tAp5tkhE77Mi', + owner: '6y2EP2MtSCuNE41h3gG9Fs7ZU2n24gcYiGqDpEYjDbRn', + amount: new BigNumber('1'), + mint: '8JH5uP374VW4YmVzE7LCRK9CbyRe1uXb85jK72RQzvWU', + decimals: 9, + tokenProgramName: 'spl-token-2022' as const, + }, + expectedOutput: { + accounts: [ + { + address: 'EdThAwDjfEj9joy2U7WvSsMwsHn5Wkby8R4j74qYJujz', + role: AccountRole.WRITABLE, + }, + { + address: '8JH5uP374VW4YmVzE7LCRK9CbyRe1uXb85jK72RQzvWU', + role: AccountRole.READONLY, + }, + { + address: '4Qon5ZG7yYRkheuUwdqwN9nGRgu7oYn9tAp5tkhE77Mi', + role: AccountRole.WRITABLE, + }, + expect.objectContaining({ + address: '6y2EP2MtSCuNE41h3gG9Fs7ZU2n24gcYiGqDpEYjDbRn', + role: AccountRole.READONLY_SIGNER, + signer: expect.objectContaining({ + address: '6y2EP2MtSCuNE41h3gG9Fs7ZU2n24gcYiGqDpEYjDbRn', + }), + }), + ], + data: new Uint8Array([12, 1, 0, 0, 0, 0, 0, 0, 0, 9]), + }, + }, ], buildCreateAssociatedTokenAccountInstruction: [ { - description: 'builds create associated token account instruction', + description: + 'builds create associated token account instruction for the SPL Token program', input: { funderAddress: 'ETxHeBBcuw9Yu4dGuP3oXrD12V5RECvmi8ogQ9PkjyVF', tokenMintAddress: '6YuhWADZyAAxAaVKPm1G5N51RvDBXsnWo4SfsJ47wSoK', newOwnerAddress: 'FAeNERRWGL8xtnwtM5dWBUs9Z1y5fenSJcawu55NQSWk', + tokenProgramName: 'spl-token' as const, }, expectedOutput: { pubkey: 'GrwHUG2U6Nmr2CHjQ2kesKzbjMwvCNytcMAbhQxq1Jyd', @@ -170,10 +210,54 @@ export const fixtures = { data: new Uint8Array([]), }, }, + { + description: + 'builds create associated token account instruction for the Token 2022 program', + input: { + funderAddress: '8CxSyuSwEjUXU2ABWU2pFmvxwZR5aMmSxFQ4mAS7Kg4p', + tokenMintAddress: '8JH5uP374VW4YmVzE7LCRK9CbyRe1uXb85jK72RQzvWU', + newOwnerAddress: '6y2EP2MtSCuNE41h3gG9Fs7ZU2n24gcYiGqDpEYjDbRn', + tokenProgramName: 'spl-token-2022' as const, + }, + expectedOutput: { + pubkey: 'EdThAwDjfEj9joy2U7WvSsMwsHn5Wkby8R4j74qYJujz', + accounts: [ + expect.objectContaining({ + address: '8CxSyuSwEjUXU2ABWU2pFmvxwZR5aMmSxFQ4mAS7Kg4p', + role: AccountRole.WRITABLE_SIGNER, + signer: expect.objectContaining({ + address: '8CxSyuSwEjUXU2ABWU2pFmvxwZR5aMmSxFQ4mAS7Kg4p', + }), + }), + { + address: 'EdThAwDjfEj9joy2U7WvSsMwsHn5Wkby8R4j74qYJujz', + role: AccountRole.WRITABLE, + }, + { + address: '6y2EP2MtSCuNE41h3gG9Fs7ZU2n24gcYiGqDpEYjDbRn', + role: AccountRole.READONLY, + }, + { + address: '8JH5uP374VW4YmVzE7LCRK9CbyRe1uXb85jK72RQzvWU', + role: AccountRole.READONLY, + }, + { + address: SYSTEM_PROGRAM_PUBLIC_KEY, + role: AccountRole.READONLY, + }, + { + address: TOKEN_2022_PROGRAM_PUBLIC_KEY, + role: AccountRole.READONLY, + }, + ], + data: new Uint8Array([]), + }, + }, ], buildTokenTransferTransaction: [ { - description: 'builds token transfer transaction in most common case', + description: + 'builds token transfer (SPL Token program) transaction in most common case', input: { fromAddress: 'ETxHeBBcuw9Yu4dGuP3oXrD12V5RECvmi8ogQ9PkjyVF', toAddress: 'FAeNERRWGL8xtnwtM5dWBUs9Z1y5fenSJcawu55NQSWk', @@ -194,13 +278,14 @@ export const fixtures = { computeUnitPrice: '100000', computeUnitLimit: '50000', }, + tokenProgramName: 'spl-token' as const, }, expectedOutput: '01000609c80f8b50107e9f3e3c16a661b8c806df454a6deb293d5e8730a9d28f2f4998c6a99c9c4d0c7def9dd60a3a40dc5266faf41996310aa62ad6cbd9b64e1e2cca78ebaa24826cef9644c1ecf0dfcf955775b8438528e97820efc2b20ed46be1dc580000000000000000000000000000000000000000000000000000000000000000527706a12f3f7c3c852582f0f79b515c03c6ffbe6e3100044ba7c982eb5cf9f28c97258f4e2489f1bb3d1029148e0d830b5a1399daff1084048e7bd8dbe9f8590306466fe5211732ffecadba72c39be7bc8ce5bbc5f7126b2c439b3a40000000d27c181cb023db6239e22e49e4b67f7dd9ed13f3d7ed319f9e91b3bc64cec0a906ddf6e1d765a193d9cbe146ceeb79ac1cb485ed5f5b37913a8cf5857eff00a96772b7d36a2e66e52c817f385d7e94d3d4b6d47d7171c9f2dd86c6f1be1a93eb040600050250c3000006000903a0860100000000000506000207040308000804010402000a0c00e1f5050000000009', }, { description: - 'builds token transfer transaction in the case an account already exists (simplest case, second most common)', + 'builds token transfer transaction (SPL Token program) in the case an account already exists (simplest case, second most common)', input: { fromAddress: 'ETxHeBBcuw9Yu4dGuP3oXrD12V5RECvmi8ogQ9PkjyVF', toAddress: 'FAeNERRWGL8xtnwtM5dWBUs9Z1y5fenSJcawu55NQSWk', @@ -224,13 +309,14 @@ export const fixtures = { computeUnitPrice: '100000', computeUnitLimit: '50000', }, + tokenProgramName: 'spl-token' as const, }, expectedOutput: '01000306c80f8b50107e9f3e3c16a661b8c806df454a6deb293d5e8730a9d28f2f4998c6a99c9c4d0c7def9dd60a3a40dc5266faf41996310aa62ad6cbd9b64e1e2cca78ebaa24826cef9644c1ecf0dfcf955775b8438528e97820efc2b20ed46be1dc58527706a12f3f7c3c852582f0f79b515c03c6ffbe6e3100044ba7c982eb5cf9f20306466fe5211732ffecadba72c39be7bc8ce5bbc5f7126b2c439b3a4000000006ddf6e1d765a193d9cbe146ceeb79ac1cb485ed5f5b37913a8cf5857eff00a96772b7d36a2e66e52c817f385d7e94d3d4b6d47d7171c9f2dd86c6f1be1a93eb030400050250c3000004000903a0860100000000000504010302000a0c00e1f5050000000009', }, { description: - 'builds token transfer transaction in the case the destination is a token account (rare case, power user case)', + 'builds token transfer transaction (SPL Token program) in the case the destination is a token account (rare case, power user case)', input: { fromAddress: 'ETxHeBBcuw9Yu4dGuP3oXrD12V5RECvmi8ogQ9PkjyVF', toAddress: 'GrwHUG2U6Nmr2CHjQ2kesKzbjMwvCNytcMAbhQxq1Jyd', @@ -251,9 +337,97 @@ export const fixtures = { computeUnitPrice: '100000', computeUnitLimit: '50000', }, + tokenProgramName: 'spl-token' as const, }, expectedOutput: '01000306c80f8b50107e9f3e3c16a661b8c806df454a6deb293d5e8730a9d28f2f4998c6a99c9c4d0c7def9dd60a3a40dc5266faf41996310aa62ad6cbd9b64e1e2cca78ebaa24826cef9644c1ecf0dfcf955775b8438528e97820efc2b20ed46be1dc58527706a12f3f7c3c852582f0f79b515c03c6ffbe6e3100044ba7c982eb5cf9f20306466fe5211732ffecadba72c39be7bc8ce5bbc5f7126b2c439b3a4000000006ddf6e1d765a193d9cbe146ceeb79ac1cb485ed5f5b37913a8cf5857eff00a96772b7d36a2e66e52c817f385d7e94d3d4b6d47d7171c9f2dd86c6f1be1a93eb030400050250c3000004000903a0860100000000000504010302000a0c00e1f5050000000009', }, + { + description: + 'builds token transfer (Token 2022 program) transaction in most common case', + input: { + fromAddress: 'ETxHeBBcuw9Yu4dGuP3oXrD12V5RECvmi8ogQ9PkjyVF', + toAddress: 'FAeNERRWGL8xtnwtM5dWBUs9Z1y5fenSJcawu55NQSWk', + toAddressOwner: SYSTEM_PROGRAM_PUBLIC_KEY, + tokenMint: '8JH5uP374VW4YmVzE7LCRK9CbyRe1uXb85jK72RQzvWU', + tokenUiAmount: '0.1', + tokenDecimals: 9, + fromTokenAccounts: [ + { + publicKey: 'EdThAwDjfEj9joy2U7WvSsMwsHn5Wkby8R4j74qYJujz', + balance: '12200000000', + }, + ], + toTokenAccount: undefined, + blockhash: '7xpT7BDE7q1ZWhe6Pg8PHRYbqgDwNK3L2v97rEfsjMkn', + lastValidBlockHeight: 50, + priorityFees: { + computeUnitPrice: '100000', + computeUnitLimit: '50000', + }, + tokenProgramName: 'spl-token-2022' as const, + }, + expectedOutput: + '01000609c80f8b50107e9f3e3c16a661b8c806df454a6deb293d5e8730a9d28f2f4998c6ca7f0545e6ff0eb69540020573c128136015f2a26163f90081ea42fb43be1665f18003838ed2728bc8bc4083dee3b9255492d2e65cf670bc2fdd51d1c32c499100000000000000000000000000000000000000000000000000000000000000006c6ede7c260ca13beca7a3017513ac103b5dad8335213f57d38b78967c6608b98c97258f4e2489f1bb3d1029148e0d830b5a1399daff1084048e7bd8dbe9f8590306466fe5211732ffecadba72c39be7bc8ce5bbc5f7126b2c439b3a40000000d27c181cb023db6239e22e49e4b67f7dd9ed13f3d7ed319f9e91b3bc64cec0a906ddf6e1ee758fde18425dbce46ccddab61afc4d83b90d27febdf928d8a18bfc6772b7d36a2e66e52c817f385d7e94d3d4b6d47d7171c9f2dd86c6f1be1a93eb040600050250c3000006000903a0860100000000000506000207040308000804010402000a0c00e1f5050000000009', + }, + { + description: + 'builds token transfer transaction (Token 2022 program) in the case an account already exists (simplest case, second most common)', + input: { + fromAddress: 'ETxHeBBcuw9Yu4dGuP3oXrD12V5RECvmi8ogQ9PkjyVF', + toAddress: 'FAeNERRWGL8xtnwtM5dWBUs9Z1y5fenSJcawu55NQSWk', + toAddressOwner: SYSTEM_PROGRAM_PUBLIC_KEY, + tokenMint: '8JH5uP374VW4YmVzE7LCRK9CbyRe1uXb85jK72RQzvWU', + tokenUiAmount: '0.1', + tokenDecimals: 9, + fromTokenAccounts: [ + { + publicKey: 'EdThAwDjfEj9joy2U7WvSsMwsHn5Wkby8R4j74qYJujz', + balance: '12200000000', + }, + ], + toTokenAccount: { + publicKey: '4Qon5ZG7yYRkheuUwdqwN9nGRgu7oYn9tAp5tkhE77Mi', + balance: '600000000', + }, + blockhash: '7xpT7BDE7q1ZWhe6Pg8PHRYbqgDwNK3L2v97rEfsjMkn', + lastValidBlockHeight: 50, + priorityFees: { + computeUnitPrice: '100000', + computeUnitLimit: '50000', + }, + tokenProgramName: 'spl-token-2022' as const, + }, + expectedOutput: + '01000306c80f8b50107e9f3e3c16a661b8c806df454a6deb293d5e8730a9d28f2f4998c632ac4f80897dd3abcbea173b042cdc1b710fab8b71bbe29ebc3328c1bc44c589ca7f0545e6ff0eb69540020573c128136015f2a26163f90081ea42fb43be16656c6ede7c260ca13beca7a3017513ac103b5dad8335213f57d38b78967c6608b90306466fe5211732ffecadba72c39be7bc8ce5bbc5f7126b2c439b3a4000000006ddf6e1ee758fde18425dbce46ccddab61afc4d83b90d27febdf928d8a18bfc6772b7d36a2e66e52c817f385d7e94d3d4b6d47d7171c9f2dd86c6f1be1a93eb030400050250c3000004000903a0860100000000000504020301000a0c00e1f5050000000009', + }, + { + description: + 'builds token transfer transaction (Token 2022 program) in the case the destination is a token account (rare case, power user case)', + input: { + fromAddress: 'ETxHeBBcuw9Yu4dGuP3oXrD12V5RECvmi8ogQ9PkjyVF', + toAddress: 'GrwHUG2U6Nmr2CHjQ2kesKzbjMwvCNytcMAbhQxq1Jyd', + toAddressOwner: TOKEN_2022_PROGRAM_PUBLIC_KEY, + tokenMint: '8JH5uP374VW4YmVzE7LCRK9CbyRe1uXb85jK72RQzvWU', + tokenUiAmount: '0.1', + tokenDecimals: 9, + fromTokenAccounts: [ + { + publicKey: 'EdThAwDjfEj9joy2U7WvSsMwsHn5Wkby8R4j74qYJujz', + balance: '12200000000', + }, + ], + toTokenAccount: undefined, + blockhash: '7xpT7BDE7q1ZWhe6Pg8PHRYbqgDwNK3L2v97rEfsjMkn', + lastValidBlockHeight: 50, + priorityFees: { + computeUnitPrice: '100000', + computeUnitLimit: '50000', + }, + tokenProgramName: 'spl-token-2022' as const, + }, + expectedOutput: + '01000306c80f8b50107e9f3e3c16a661b8c806df454a6deb293d5e8730a9d28f2f4998c6ca7f0545e6ff0eb69540020573c128136015f2a26163f90081ea42fb43be1665ebaa24826cef9644c1ecf0dfcf955775b8438528e97820efc2b20ed46be1dc586c6ede7c260ca13beca7a3017513ac103b5dad8335213f57d38b78967c6608b90306466fe5211732ffecadba72c39be7bc8ce5bbc5f7126b2c439b3a4000000006ddf6e1ee758fde18425dbce46ccddab61afc4d83b90d27febdf928d8a18bfc6772b7d36a2e66e52c817f385d7e94d3d4b6d47d7171c9f2dd86c6f1be1a93eb030400050250c3000004000903a0860100000000000504010302000a0c00e1f5050000000009', + }, ], }; diff --git a/suite-common/wallet-utils/src/__tests__/solanaUtils.test.ts b/suite-common/wallet-utils/src/__tests__/solanaUtils.test.ts index 282153cbbed..cb41ad7bb7e 100644 --- a/suite-common/wallet-utils/src/__tests__/solanaUtils.test.ts +++ b/suite-common/wallet-utils/src/__tests__/solanaUtils.test.ts @@ -32,6 +32,7 @@ describe('solana utils', () => { input.amount, input.mint, input.decimals, + input.tokenProgramName, ); expect(txix.accounts).toEqual(expectedOutput.accounts); @@ -48,6 +49,7 @@ describe('solana utils', () => { input.funderAddress, input.newOwnerAddress, input.tokenMintAddress, + input.tokenProgramName, ); expect(pubkey).toEqual(expectedOutput.pubkey); @@ -73,6 +75,7 @@ describe('solana utils', () => { input.blockhash, input.lastValidBlockHeight, input.priorityFees, + input.tokenProgramName, ); const message = tx.transaction.serializeMessage(); diff --git a/suite-common/wallet-utils/src/solanaUtils.ts b/suite-common/wallet-utils/src/solanaUtils.ts index 13bd5d9bbdf..3d6ff87296d 100644 --- a/suite-common/wallet-utils/src/solanaUtils.ts +++ b/suite-common/wallet-utils/src/solanaUtils.ts @@ -9,10 +9,11 @@ import { import { BigNumber } from '@trezor/utils/src/bigNumber'; import type { TokenAccount } from '@trezor/blockchain-link-types'; import { solanaUtils as SolanaBlockchainLinkUtils } from '@trezor/blockchain-link-utils'; +import type { TokenProgramName } from '@trezor/blockchain-link-utils/src/solana'; import { getLamportsFromSol } from './sendFormUtils'; -const { TOKEN_PROGRAM_PUBLIC_KEY, SYSTEM_PROGRAM_PUBLIC_KEY } = SolanaBlockchainLinkUtils; +const { SYSTEM_PROGRAM_PUBLIC_KEY, tokenProgramsInfo } = SolanaBlockchainLinkUtils; const loadSolanaLib = async () => { return await import('@solana/web3.js'); @@ -23,8 +24,16 @@ const loadSolanaComputeBudgetProgramLib = async () => { const loadSolanaSystemProgramLib = async () => { return await import('@solana-program/system'); }; -const loadSolanaTokenProgramLib = async () => { - return await import('@solana-program/token'); + +const loadSolanaTokenProgramLib = async (tokenProgramName: TokenProgramName) => { + switch (tokenProgramName) { + case 'spl-token': + return await import('@solana-program/token'); + case 'spl-token-2022': + return await import('@solana-program/token-2022'); + default: + throw new Error(`Unsupported token program: ${tokenProgramName}`); + } }; type PriorityFees = { computeUnitPrice: string; computeUnitLimit: string }; @@ -148,13 +157,14 @@ export const buildTokenTransferInstruction = async ( amount: BigNumber, mint: string, decimals: number, + tokenProgramName: TokenProgramName, ) => { const [ // @solana/web3.js { address, createNoopSigner }, - // @solana-program/token + // @solana-program/token or @solana-program/token-2022 { getTransferCheckedInstruction }, - ] = await Promise.all([loadSolanaLib(), loadSolanaTokenProgramLib()]); + ] = await Promise.all([loadSolanaLib(), loadSolanaTokenProgramLib(tokenProgramName)]); return getTransferCheckedInstruction({ amount: BigInt(amount.toString()), @@ -169,18 +179,19 @@ export const buildTokenTransferInstruction = async ( export const getAssociatedTokenAccountAddress = async ( baseAddress: string, tokenMintAddress: string, + tokenProgramName: TokenProgramName, ) => { const [ // @solana/web3.js { address }, - // @solana-program/token - { findAssociatedTokenPda, TOKEN_PROGRAM_ADDRESS }, - ] = await Promise.all([loadSolanaLib(), loadSolanaTokenProgramLib()]); + // @solana-program/token or @solana-program/token-2022 + { findAssociatedTokenPda }, + ] = await Promise.all([loadSolanaLib(), loadSolanaTokenProgramLib(tokenProgramName)]); const [pdaAddress] = await findAssociatedTokenPda({ mint: address(tokenMintAddress), owner: address(baseAddress), - tokenProgram: TOKEN_PROGRAM_ADDRESS, + tokenProgram: address(tokenProgramsInfo[tokenProgramName].publicKey), }); return pdaAddress; @@ -191,17 +202,19 @@ export const buildCreateAssociatedTokenAccountInstruction = async ( funderAddress: string, newOwnerAddress: string, tokenMintAddress: string, + tokenProgramName: TokenProgramName, ) => { const [ // @solana/web3.js { address, createNoopSigner }, - // @solana-program/token + // @solana-program/token or @solana-program/token-2022 { getCreateAssociatedTokenInstruction }, - ] = await Promise.all([loadSolanaLib(), loadSolanaTokenProgramLib()]); + ] = await Promise.all([loadSolanaLib(), loadSolanaTokenProgramLib(tokenProgramName)]); const associatedTokenAccountAddress = await getAssociatedTokenAccountAddress( newOwnerAddress, tokenMintAddress, + tokenProgramName, ); const txInstruction = getCreateAssociatedTokenInstruction({ @@ -266,6 +279,7 @@ export const buildTokenTransferTransaction = async ( blockhash: string, lastValidBlockHeight: number, priorityFees: PriorityFees, + tokenProgramName: TokenProgramName, ): Promise => { const { address, @@ -319,6 +333,7 @@ export const buildTokenTransferTransaction = async ( fromAddress, toAddress, tokenMint, + tokenProgramName, ); // Add the account creation instruction to the transaction and use the newly created associated token account as the receiver @@ -339,6 +354,7 @@ export const buildTokenTransferTransaction = async ( transferAmount, tokenMint, tokenDecimals, + tokenProgramName, ); remainingAmount = remainingAmount.minus(transferAmount); @@ -356,7 +372,7 @@ export const buildTokenTransferTransaction = async ( tokenAccountInfo: isReceiverAddressSystemAccount ? { baseAddress: toAddress, - tokenProgram: TOKEN_PROGRAM_PUBLIC_KEY, + tokenProgram: tokenProgramsInfo[tokenProgramName].publicKey, tokenMint, tokenAccount: finalReceiverAddress, } diff --git a/yarn.lock b/yarn.lock index bddf3412d72..f391ad6a9f3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8074,6 +8074,15 @@ __metadata: languageName: node linkType: hard +"@solana-program/token-2022@npm:^0.3.1": + version: 0.3.1 + resolution: "@solana-program/token-2022@npm:0.3.1" + peerDependencies: + "@solana/web3.js": ^2.0.0 + checksum: 10/1d18bb7d5f160134a08b59aa651de102f0c11798b1a7c99af89928b27a00cb4021afc98cfb27dc666d3a384d7f180160d0e428e9d7cede5f959bf7e651c13707 + languageName: node + linkType: hard + "@solana-program/token@npm:^0.4.1": version: 0.4.1 resolution: "@solana-program/token@npm:0.4.1" @@ -9807,6 +9816,7 @@ __metadata: "@solana-program/compute-budget": "npm:^0.6.1" "@solana-program/system": "npm:^0.6.2" "@solana-program/token": "npm:^0.4.1" + "@solana-program/token-2022": "npm:^0.3.1" "@solana/web3.js": "npm:^2.0.0" "@suite-common/fiat-services": "workspace:*" "@suite-common/metadata-types": "workspace:*" @@ -11590,6 +11600,7 @@ __metadata: resolution: "@trezor/blockchain-link@workspace:packages/blockchain-link" dependencies: "@solana-program/token": "npm:^0.4.1" + "@solana-program/token-2022": "npm:^0.3.1" "@solana/web3.js": "npm:^2.0.0" "@trezor/blockchain-link-types": "workspace:*" "@trezor/blockchain-link-utils": "workspace:*"