Skip to content

Commit

Permalink
feat(solana): add support for Token-2022 tokens
Browse files Browse the repository at this point in the history
  • Loading branch information
MartinZemanik authored and tomasklim committed Dec 11, 2024
1 parent a9a2d76 commit d642302
Show file tree
Hide file tree
Showing 12 changed files with 324 additions and 46 deletions.
2 changes: 1 addition & 1 deletion packages/blockchain-link-types/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
1 change: 1 addition & 0 deletions packages/blockchain-link-types/src/params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
};
}

Expand Down
50 changes: 41 additions & 9 deletions packages/blockchain-link-utils/src/solana.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand All @@ -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<TokenProgramName, { publicKey: string; tokenStandard: TokenStandard }>;

export const getTokenMetadata = async (): Promise<TokenDetailByMint> => {
const env = isCodesignBuild() ? 'stable' : 'develop';

Expand Down Expand Up @@ -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: {
Expand All @@ -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 &&
Expand All @@ -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,
Expand Down Expand Up @@ -441,7 +473,7 @@ export const getAmount = (
};

type TokenTransferInstruction = {
program: 'spl-token';
program: TokenProgramName;
programId: Address;
parsed: {
type: 'transferChecked' | 'transfer';
Expand Down Expand Up @@ -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') &&
Expand Down Expand Up @@ -529,7 +561,7 @@ export const getTokens = (
const effects = tx.transaction.message.instructions
.filter(isTokenTransferInstruction)
.map<TokenTransfer>((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
Expand Down Expand Up @@ -558,7 +590,7 @@ export const getTokens = (

return {
type: getUiType(ix),
standard: 'SPL',
standard: tokenProgramsInfo[program].tokenStandard,
from,
to,
contract: mint,
Expand Down
1 change: 1 addition & 0 deletions packages/blockchain-link/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
55 changes: 38 additions & 17 deletions packages/blockchain-link/src/workers/solana/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -281,17 +283,28 @@ const getAccountInfo = async (request: Request<MessageTypes.GetAccountInfo>) =>
.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
Expand All @@ -302,7 +315,7 @@ const getAccountInfo = async (request: Request<MessageTypes.GetAccountInfo>) =>

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,
Expand All @@ -313,10 +326,10 @@ const getAccountInfo = async (request: Request<MessageTypes.GetAccountInfo>) =>

// 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();
Expand Down Expand Up @@ -402,11 +415,17 @@ const getInfo = async (request: Request<MessageTypes.GetInfo>, isTestnet: boolea
} as const;
};

const getTokenSize = (programName: TokenProgramName) =>
({ 'spl-token': _getTokenSize(), 'spl-token-2022': _getToken2022Size() })[programName];

const estimateFee = async (request: Request<MessageTypes.EstimateFee>) => {
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.');
Expand All @@ -417,7 +436,9 @@ const estimateFee = async (request: Request<MessageTypes.EstimateFee>) => {
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 = [
Expand Down
1 change: 1 addition & 0 deletions packages/connect/src/api/blockchainEstimateFee.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
]);
}
}
Expand Down
21 changes: 19 additions & 2 deletions suite-common/wallet-core/src/send/sendFormSolanaThunks.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -171,6 +180,7 @@ export const composeSolanaTransactionFeeLevelsThunk = createThunk<
formState.outputs[0].address,
account.symbol,
tokenInfo.contract,
tokenStandardToTokenProgramName(tokenInfo.type),
)
: [undefined, undefined];

Expand Down Expand Up @@ -204,6 +214,7 @@ export const composeSolanaTransactionFeeLevelsThunk = createThunk<
blockhash,
lastValidBlockHeight,
dummyPriorityFeesForFeeEstimation,
tokenStandardToTokenProgramName(tokenInfo.type),
)
: undefined;

Expand All @@ -228,13 +239,17 @@ 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,
request: {
specific: {
data: transferTx.serialize(),
isCreatingAccount,
newTokenAccountProgramName,
},
},
});
Expand Down Expand Up @@ -340,6 +355,7 @@ export const signSolanaSendFormTransactionThunk = createThunk<
formState.outputs[0].address,
selectedAccount.symbol,
token.contract,
tokenStandardToTokenProgramName(token.type),
)
: [undefined, undefined];

Expand All @@ -366,6 +382,7 @@ export const signSolanaSendFormTransactionThunk = createThunk<
computeUnitPrice: precomposedTransaction.feePerByte,
computeUnitLimit: precomposedTransaction.feeLimit,
},
tokenStandardToTokenProgramName(token.type),
)
: undefined;

Expand Down
1 change: 1 addition & 0 deletions suite-common/wallet-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
Loading

0 comments on commit d642302

Please sign in to comment.