Skip to content

Commit

Permalink
fix(blockchain-link): fix solana txs when sending to associated token…
Browse files Browse the repository at this point in the history
… account

(cherry picked from commit 5cb6820)
  • Loading branch information
PeterBenc authored and komret committed Jan 4, 2024
1 parent 3e0cd26 commit a1de5cc
Show file tree
Hide file tree
Showing 5 changed files with 207 additions and 23 deletions.
6 changes: 6 additions & 0 deletions packages/blockchain-link-types/src/solana.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ export type SolanaValidParsedTxWithMeta = ParsedTransactionWithMeta & {
blockTime: Required<NonNullable<ParsedTransactionWithMeta['blockTime']>>;
};

export type SolanaTokenAccountInfo = {
address: string;
mint: string | undefined;
decimals: number | undefined;
};

export type {
ParsedInstruction,
ParsedTransactionWithMeta,
Expand Down
131 changes: 112 additions & 19 deletions packages/blockchain-link-utils/src/solana.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
ParsedInstruction,
ParsedTransactionWithMeta,
SolanaValidParsedTxWithMeta,
SolanaTokenAccountInfo,
PartiallyDecodedInstruction,
TokenDetailByMint,
PublicKey,
Expand Down Expand Up @@ -403,35 +404,126 @@ export const getAmount = (
return accountEffect.amount.toString();
};

type TokenTransferInstruction = {
program: 'spl-token';
programId: PublicKey;
parsed: {
type: 'transferChecked' | 'transfer';
info: {
destination: string;
authority: string;
source: string;
mint?: string;
tokenAmount?: {
amount: string;
decimals: number;
};
amount?: string;
};
};
};

const isTokenTransferInstruction = (
ix: ParsedInstruction | PartiallyDecodedInstruction,
): ix is TokenTransferInstruction => {
if (!('parsed' in ix)) {
return false;
}

const { parsed } = ix;

return (
'program' in ix &&
typeof ix.program === 'string' &&
ix.program === 'spl-token' &&
'type' in parsed &&
typeof parsed.type === 'string' &&
(parsed.type === 'transferChecked' || parsed.type === 'transfer') &&
'info' in parsed &&
typeof parsed.info === 'object' &&
'authority' in parsed.info &&
typeof parsed.info.authority === 'string' &&
'source' in parsed.info &&
typeof parsed.info.source === 'string' &&
'destination' in parsed.info &&
typeof parsed.info.destination === 'string' &&
(('tokenAmount' in parsed.info &&
typeof parsed.info.tokenAmount === 'object' &&
'amount' in parsed.info.tokenAmount &&
typeof parsed.info.tokenAmount.amount === 'string') ||
('amount' in parsed.info && typeof parsed.info.amount === 'string'))
);
};

export const getTokens = (
tx: ParsedTransactionWithMeta,
accountAddress: string,
tokenDetailByMint: TokenDetailByMint,
tokenAccountsInfos: SolanaTokenAccountInfo[],
): TokenTransfer[] => {
const getUiType = ({ parsed }: TokenTransferInstruction) => {
const accountAddresses = [
...tokenAccountsInfos.map(({ address }) => address),
accountAddress,
];
const isAccountDestination = accountAddresses.includes(parsed.info.destination);

const isAccountSource = accountAddresses.includes(
parsed.info.authority || parsed.info.source,
);

if (isAccountDestination && isAccountSource) {
return 'self';
}
if (isAccountDestination) {
return 'recv';
}
return 'sent';
};

const matchTokenAccountInfo = ({ parsed }: TokenTransferInstruction, address: string) =>
address === parsed.info?.source ||
address === parsed.info.destination ||
address === parsed.info?.authority;

const effects = tx.transaction.message.instructions
.filter((ix): ix is ParsedInstruction => 'parsed' in ix)
.filter(
ix =>
ix.program === 'spl-token' &&
(ix.parsed.type === 'transfer' || ix.parsed.type === 'transferChecked'),
)
.map((ix): TokenTransfer => {
.filter(isTokenTransferInstruction)
.map<TokenTransfer>((ix): TokenTransfer => {
const { parsed } = ix;

// Accounting for 'self' transfers would involve fetching owned token account data from RPC
// and comparing it with the destination address. This is overkill for most users and thus it is
// left unimplemented.
const uiType = parsed.info.authority === accountAddress ? 'sent' : 'recv';
// 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
const instructionTokenInfo = tokenAccountsInfos.find(tokenAccountInfo =>
matchTokenAccountInfo(ix, tokenAccountInfo.address),
);

// when sending tokens to associated token account, the instruction does not contain mint
const mint = parsed.info.mint || instructionTokenInfo?.mint || 'Unknown token contract';

const decimals =
parsed.info.tokenAmount?.decimals || instructionTokenInfo?.decimals || 0;
const amount = parsed.info.tokenAmount?.amount || parsed.info.amount || '-1';

const source = parsed.info.authority || parsed.info.source;

// if sending/receiving to associated token account, we replace the tokenAccount address with the associated token account address
// to simplify the information for the user since teh UI does not recognize the concept of associated token accounts
const from = source === instructionTokenInfo?.address ? accountAddress : source;

const to =
parsed.info.destination === instructionTokenInfo?.address
? accountAddress
: parsed.info.destination;

return {
type: uiType,
type: getUiType(ix),
standard: 'SPL',
from: parsed.info.authority,
to: parsed.info.destination,
contract: parsed.info.mint,
decimals: parsed.info.tokenAmount?.decimals || 0,
...getTokenNameAndSymbol(parsed.info.mint, tokenDetailByMint),
amount: parsed.info.tokenAmount?.amount || '-1',
from,
to,
contract: mint,
decimals,
...getTokenNameAndSymbol(mint, tokenDetailByMint),
amount,
};
});

Expand All @@ -441,11 +533,12 @@ export const getTokens = (
export const transformTransaction = async (
tx: SolanaValidParsedTxWithMeta,
accountAddress: string,
tokenAccountsInfos: SolanaTokenAccountInfo[],
): Promise<Transaction> => {
const tokenDetailByMint = await getTokenMetadata();
const nativeEffects = getNativeEffects(tx);

const tokens = getTokens(tx, accountAddress, tokenDetailByMint);
const tokens = getTokens(tx, accountAddress, tokenDetailByMint, tokenAccountsInfos);

const type = getTxType(tx, nativeEffects, accountAddress, tokens);

Expand Down
63 changes: 63 additions & 0 deletions packages/blockchain-link-utils/src/tests/fixtures/solana.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,19 @@ const instructions = {
},
program: 'spl-token',
},
// without mint and tokenAmount
tokenTransferToAssociated: {
parsed: {
info: {
amount: '1534951700',
authority: '5oPm4YqwfGceRoyAYJUZ3FqLb5KvhkLFTzRv92gXzCtz',
destination: 'CoWky2oRf1LEENrV7WsFFzFgrbs14y5PibvtetdfTvXg',
source: 'DaL2xPLQQ2rxHyqQxaE9E44Boa5bRpnfAZt9HaRMr9RY',
},
type: 'transfer',
},
program: 'spl-token',
},
};

const parsedTransactions = {
Expand Down Expand Up @@ -176,6 +189,24 @@ const parsedTransactions = {
slot: 5,
},
},
tokenTransferToAssociated: {
transaction: {
meta: {},
transaction: {
signatures: ['txid1'],
message: {
accountKeys: [
{ pubkey: { toString: () => 'address1' } },
{ pubkey: { toString: () => 'address2' } },
],
instructions: [instructions.tokenTransferToAssociated],
},
},
version: 'legacy',
blockTime: 1631753600,
slot: 5,
},
},
};

const effects = {
Expand Down Expand Up @@ -626,15 +657,45 @@ export const fixtures = {
transaction: parsedTransactions.basic.transaction,
accountAddress: 'someAddress',
map: sampleMintToDetailMap,
tokenAccountsInfos: [],
},
expectedOutput: [],
},
{
description: 'parses a single token transfer',
input: {
transaction: parsedTransactions.tokenTransferToAssociated.transaction,
accountAddress: 'ETxHeBBcuw9Yu4dGuP3oXrD12V5RECvmi8ogQ9PkjyVF',
map: sampleMintToDetailMap,
tokenAccountsInfos: [
{
address: 'CoWky2oRf1LEENrV7WsFFzFgrbs14y5PibvtetdfTvXg',
mint: 'So11111111111111111111111111111111111115555',
decimals: 1,
},
],
},
expectedOutput: [
{
type: 'recv',
standard: 'SPL',
from: '5oPm4YqwfGceRoyAYJUZ3FqLb5KvhkLFTzRv92gXzCtz',
to: 'ETxHeBBcuw9Yu4dGuP3oXrD12V5RECvmi8ogQ9PkjyVF',
contract: 'So11111111111111111111111111111111111115555',
decimals: 1,
name: 'So11111111111111111111111111111111111115555',
symbol: 'So1...',
amount: '1534951700',
},
],
},
{
description: 'parses a token transfer to associated token account',
input: {
transaction: parsedTransactions.singleTokenTransfer.transaction,
accountAddress: 'ETxHeBBcuw9Yu4dGuP3oXrD12V5RECvmi8ogQ9PkjyVF',
map: sampleMintToDetailMap,
tokenAccountsInfos: [],
},
expectedOutput: [
{
Expand All @@ -656,6 +717,7 @@ export const fixtures = {
transaction: parsedTransactions.multiTokenTransfer.transaction,
accountAddress: 'ETxHeBBcuw9Yu4dGuP3oXrD12V5RECvmi8ogQ9PkjyVF',
map: sampleMintToDetailMap,
tokenAccountsInfos: [],
},
expectedOutput: [
{
Expand Down Expand Up @@ -689,6 +751,7 @@ export const fixtures = {
input: {
transaction: parsedTransactions.basic.transaction,
accountAddress: effects.negative.address,
tokenAccountsInfos: [],
},
expectedOutput: {
type: 'sent',
Expand Down
2 changes: 2 additions & 0 deletions packages/blockchain-link-utils/src/tests/solana.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ describe('solana/utils', () => {
input.transaction as ParsedTransactionWithMeta,
input.accountAddress,
input.map,
input.tokenAccountsInfos,
);
expect(result).toEqual(expectedOutput);
});
Expand All @@ -119,6 +120,7 @@ describe('solana/utils', () => {
const result = await transformTransaction(
input.transaction as SolanaValidParsedTxWithMeta,
input.accountAddress,
input.tokenAccountsInfos,
);
expect(result).toEqual(expectedOutput);
});
Expand Down
28 changes: 24 additions & 4 deletions packages/blockchain-link/src/workers/solana/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
import type {
SolanaValidParsedTxWithMeta,
ParsedTransactionWithMeta,
SolanaTokenAccountInfo,
} from '@trezor/blockchain-link-types/lib/solana';
import type * as MessageTypes from '@trezor/blockchain-link-types/lib/messages';
import { CustomError } from '@trezor/blockchain-link-types/lib/constants/errors';
Expand Down Expand Up @@ -108,14 +109,23 @@ const getAccountInfo = async (request: Request<MessageTypes.GetAccountInfo>) =>

const accountInfo = await api.getAccountInfo(publicKey);

const getTransactionPage = async (txIds: string[]) => {
const getTransactionPage = async (
txIds: string[],
tokenAccountsInfos: SolanaTokenAccountInfo[],
) => {
const transactionsPage = await fetchTransactionPage(api, txIds);

return (
await Promise.all(
transactionsPage
.filter(isValidTransaction)
.map(tx => solanaUtils.transformTransaction(tx, payload.descriptor)),
.map(tx =>
solanaUtils.transformTransaction(
tx,
payload.descriptor,
tokenAccountsInfos,
),
),
)
).filter((tx): tx is Transaction => !!tx);
};
Expand Down Expand Up @@ -144,7 +154,14 @@ const getAccountInfo = async (request: Request<MessageTypes.GetAccountInfo>) =>

const txIdPage = allTxIds.slice(pageStartIndex, pageEndIndex);

const transactionPage = details === 'txs' ? await getTransactionPage(txIdPage) : undefined;
const tokenAccountsInfos = tokenAccounts.value.map(a => ({
address: a.pubkey.toString(),
mint: a.account.data.parsed?.info?.mint as string | undefined,
decimals: a.account.data.parsed?.info?.tokenAmount?.decimals as number | undefined,
}));

const transactionPage =
details === 'txs' ? await getTransactionPage(txIdPage, tokenAccountsInfos) : undefined;

// Fetch token info only if the account owns tokens
let tokens: TokenInfo[] = [];
Expand Down Expand Up @@ -318,7 +335,7 @@ const subscribeAccounts = async (
return;
}

const tx = await solanaUtils.transformTransaction(lastTx, a.descriptor);
const tx = await solanaUtils.transformTransaction(lastTx, a.descriptor, []);
post({
id: -1,
type: RESPONSES.NOTIFICATION,
Expand Down Expand Up @@ -357,6 +374,7 @@ const subscribe = (request: Request<MessageTypes.Subscribe>) => {
break;
case 'accounts':
// accounts subscription is currently disabled due to it possibly causing crashes
// TODO: it also need to be updated to take tokenAccounts into account
// subscribeAccounts(request, request.payload.accounts);
break;
default:
Expand All @@ -375,6 +393,7 @@ const unsubscribe = (request: Request<MessageTypes.Unsubscribe>) => {
break;
case 'accounts': {
// accounts subscription is currently disabled due to it possibly causing crashes
// TODO: it also need to be updated to take tokenAccounts into account
// unsubscribeAccounts(request, request.payload.accounts);
break;
}
Expand Down Expand Up @@ -443,6 +462,7 @@ class SolanaWorker extends BaseWorker<SolanaAPI> {
}

// accounts subscription is currently disabled due to it possibly causing crashes
// TODO: it also need to be updated to take tokenAccounts into account
// this.state.accounts.forEach(
// a => a.subscriptionId && this.api?.removeAccountChangeListener(a.subscriptionId),
// );
Expand Down

0 comments on commit a1de5cc

Please sign in to comment.