Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(solana): Add support for Token-2022 tokens #35

Open
wants to merge 1 commit into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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