Skip to content

Support tokens with multiplier#509

Merged
xavier-brochard merged 16 commits intomainfrom
NWNT-32
Sep 5, 2025
Merged

Support tokens with multiplier#509
xavier-brochard merged 16 commits intomainfrom
NWNT-32

Conversation

@xavier-brochard
Copy link
Copy Markdown
Contributor

@xavier-brochard xavier-brochard commented Sep 1, 2025

This PR introduces support to tokens with multipliers:

  • Convert raw amount to uiAmount when receiving programSubscribe notifications
  • Convert raw amount to uiAmount when mapping RPC txs
  • Convert uiAmount to raw amount in the send flow

Because converting from raw amount to uiAmount requires to fetch the mint account, we also need to perform a lot of refactors in the codebase:

  • we introduce a TokenHelper service that encapsulates the conversion logic
  • the whole logic for mapping transactions that used to live in pure non-async functions now needs to live in a TransactionMapper service that depends on TokenHelper
  • optimize against potential redundant mint fetching by caching for 1 minute

@xavier-brochard xavier-brochard changed the title fix: hasChanged now also detects changing uiAmount via multiplier change Support tokens with multiplier Sep 2, 2025
Comment on lines -26 to -27
export type GetTokenAccountsByOwnerResponse<TToken> =
readonly AccountInfoWithPubkey<AccountInfoBase & TToken>[];
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleaning up this unused type

import type { AssetsService, TokenHelper } from '../assets';
import { TransactionMapper } from './TransactionMapper';

describe('TransactionMapper', () => {
Copy link
Copy Markdown
Contributor Author

@xavier-brochard xavier-brochard Sep 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test file simply contains all previously existing unit tests from transactions/utils:

  • utils/mapRpcTransaction.test.ts
  • utils/parseTransactionSpltTransfers.test.ts
    etc...

@socket-security
Copy link
Copy Markdown

socket-security bot commented Sep 4, 2025

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updated@​solana-program/​token-2022@​0.4.2 ⏵ 0.5.099 +110085 +191100

View full report

import { tokenAddressToCaip19 } from '../../utils/tokenAddressToCaip19';
import type { AssetMetadata } from '../assets/types';

export class TransactionMapper {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This class simply gathers all methods that previously existed in transactions/utils:

  • utils/mapRpcTransaction.ts
  • utils/parseTransactionSplTransfers.ts
  • etc...

This change was necessary because the low level method parseTransactionSplTransfers now needs to depend on TokenHelper to convert raw amount to uiAmount.

Comment on lines -179 to -282
/**
* Get the token account for a given mint and network.
* @param params - The parameters object containing mint and network.
* @param params.mint - The mint address.
* @param params.network - The network.
* @returns The token account. Handle with care, it might:
* - not exist (exists: false).
* - be encoded (data instanceof Uint8Array).
*/
async getTokenAccount<TData extends Uint8Array | object>({
mint,
network,
}: {
mint: Address;
network: Network;
}): Promise<MaybeAccount<TData> | MaybeEncodedAccount> {
const rpc = this.#connection.getRpc(network);
const tokenAccount = await fetchJsonParsedAccount<TData>(rpc, mint);

return tokenAccount;
}

/**
* Get the decimals of a given token account.
* @param tokenAccount - The token account.
* @returns The decimals.
*/
getDecimals<TData extends Uint8Array | MaybeHasDecimals>(
tokenAccount: MaybeAccount<TData> | MaybeEncodedAccount,
): number {
SendSplTokenBuilder.assertAccountExists(tokenAccount);
SendSplTokenBuilder.assertAccountDecoded(tokenAccount);

const { decimals } = tokenAccount.data;

if (!decimals) {
throw new Error(`Decimals not found for ${tokenAccount}`);
}

return decimals;
}

/**
* Check if a token account exists.
* @param tokenAccount - The token account.
* @returns Whether the token account exists.
*/
static isAccountExists<TData extends Uint8Array | object>(
tokenAccount: MaybeAccount<TData> | MaybeEncodedAccount,
): boolean {
return tokenAccount.exists;
}

/**
* Assert that a token account exists.
* @param tokenAccount - The token account.
*/
static assertAccountExists<TData extends Uint8Array | object>(
tokenAccount: MaybeAccount<TData> | MaybeEncodedAccount,
): asserts tokenAccount is (MaybeAccount<TData> | MaybeEncodedAccount) &
Exists {
if (!SendSplTokenBuilder.isAccountExists(tokenAccount)) {
throw new Error('Token account does not exist');
}
}

/**
* Assert that a token account does not exists.
* @param tokenAccount - The token account.
*/
static assertAccountNotExists<TData extends Uint8Array | object>(
tokenAccount: MaybeAccount<TData> | MaybeEncodedAccount,
): asserts tokenAccount is (MaybeAccount<TData> | MaybeEncodedAccount) &
Exists {
if (SendSplTokenBuilder.isAccountExists(tokenAccount)) {
throw new Error('Token account exists');
}
}

/**
* Check if a token account is decoded.
* @param tokenAccount - The token account.
* @returns Whether the token account is decoded.
*/
static isAccountDecoded<TData extends Uint8Array | object>(
tokenAccount: MaybeAccount<TData> | MaybeEncodedAccount,
): boolean {
SendSplTokenBuilder.assertAccountExists(tokenAccount);
return !(tokenAccount.data instanceof Uint8Array);
}

/**
* Assert that a token account is decoded.
* @param tokenAccount - The token account.
*/
static assertAccountDecoded<TData extends Uint8Array | object>(
tokenAccount: MaybeAccount<TData> | MaybeEncodedAccount,
): asserts tokenAccount is Account<Exclude<TData, Uint8Array>> & Exists {
SendSplTokenBuilder.assertAccountExists(tokenAccount);
if (!SendSplTokenBuilder.isAccountDecoded(tokenAccount)) {
throw new Error('Token account is encoded. Implement a decoder.');
}
}

Copy link
Copy Markdown
Contributor Author

@xavier-brochard xavier-brochard Sep 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All these were unused, and the Solana SDK export equivalent methods in case we need.

Comment on lines -231 to -356

describe('getTokenAccount', () => {
const mockMint = '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU' as Address;
const mockNetwork = Network.Localnet;

it('returns token account when it exists', async () => {
const mockTokenAccount = {
exists: true,
address: mockMint,
data: { decimals: 6 },
} as unknown as MaybeAccount<any>;

jest.spyOn(mockConnection, 'getRpc').mockReturnValue({
// Mock the minimum required RPC methods
getAccountInfo: jest.fn(),
} as unknown as Rpc<SolanaRpcApi>);

// Mock the web3.js fetchJsonParsedAccount function
const fetchJsonParsedAccountSpy = jest.spyOn(
require('@solana/kit'),
'fetchJsonParsedAccount',
);
fetchJsonParsedAccountSpy.mockResolvedValue(mockTokenAccount);

const result = await sendSplTokenBuilder.getTokenAccount({
mint: mockMint,
network: mockNetwork,
});

expect(result).toStrictEqual(mockTokenAccount);
expect(mockConnection.getRpc).toHaveBeenCalledWith(mockNetwork);
expect(fetchJsonParsedAccountSpy).toHaveBeenCalled();
});

it('returns non-existing account when token account does not exist', async () => {
const mockNonExistingAccount = {
exists: false,
} as unknown as MaybeAccount<any>;

jest.spyOn(mockConnection, 'getRpc').mockReturnValue({
getAccountInfo: jest.fn(),
} as unknown as Rpc<SolanaRpcApi>);

const fetchJsonParsedAccountSpy = jest.spyOn(
require('@solana/kit'),
'fetchJsonParsedAccount',
);
fetchJsonParsedAccountSpy.mockResolvedValue(mockNonExistingAccount);

const result = await sendSplTokenBuilder.getTokenAccount({
mint: mockMint,
network: mockNetwork,
});

expect(result).toStrictEqual(mockNonExistingAccount);
expect(mockConnection.getRpc).toHaveBeenCalledWith(mockNetwork);
expect(fetchJsonParsedAccountSpy).toHaveBeenCalled();
});
});

describe('getDecimals', () => {
it('returns decimals from token account', () => {
const mockTokenAccount = {
exists: true,
data: { decimals: 6 },
} as unknown as MaybeAccount<MaybeHasDecimals> & Exists;

const result = sendSplTokenBuilder.getDecimals(mockTokenAccount);
expect(result).toBe(6);
});

it('throws error if account does not exist', () => {
const mockTokenAccount = {
exists: false,
} as unknown as MaybeAccount<any>;

expect(() => sendSplTokenBuilder.getDecimals(mockTokenAccount)).toThrow(
'Token account does not exist',
);
});

it('throws error if decimals are not found', () => {
const mockTokenAccount = {
exists: true,
data: {},
} as unknown as MaybeAccount<any> & Exists;

expect(() => sendSplTokenBuilder.getDecimals(mockTokenAccount)).toThrow(
'Decimals not found',
);
});
});

describe('assertAccountDecoded', () => {
it('does not throw for decoded account', () => {
const mockDecodedAccount = {
exists: true,
data: { someField: 'value' },
} as unknown as MaybeAccount<any> & Exists;

expect(() => {
SendSplTokenBuilder.assertAccountDecoded(mockDecodedAccount);
}).not.toThrow();
});

it('throws for encoded account (Uint8Array)', () => {
const mockEncodedAccount = {
exists: true,
data: new Uint8Array([1, 2, 3]),
} as unknown as MaybeAccount<any> & Exists;

expect(() => {
SendSplTokenBuilder.assertAccountDecoded(mockEncodedAccount);
}).toThrow('Token account is encoded. Implement a decoder.');
});

it('throws for non-existent account', () => {
const mockNonExistentAccount = {
exists: false,
data: { someField: 'value' },
} as unknown as MaybeAccount<any>;

expect(() => {
SendSplTokenBuilder.assertAccountDecoded(mockNonExistentAccount);
}).toThrow('Token account does not exist');
});
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related methods were removed because unused

@xavier-brochard xavier-brochard marked this pull request as ready for review September 4, 2025 10:51
cursor[bot]

This comment was marked as outdated.

Comment on lines +382 to +394
/**
* WARNING: This is to compensate for the fact that the notification returned by Infura's programSubscribe
* includes a uiAmount/uiAmountString that does not take into account the mint's multiplier (if any).
* In theory, it should; because the regular Solana RPC (wss://api.mainnet-beta.solana.com) does.
*
* So this needs to be removed once Infura fixes their programSubscribe notification.
*/
const uiAmount = await this.#tokenHelper
.amountToUiAmountForMint(mint, network, lamports(BigInt(amount)))
.catch((error) => {
this.#logger.error('Error converting amount to uiAmount', error);
return uiAmountString;
});
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What should we do about this?

  • Support on our side and not request anything from Infura?
  • Support temporarily until Infura supports?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how long is it going to take for them to support it?

Copy link
Copy Markdown
Contributor Author

@xavier-brochard xavier-brochard Sep 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know yet. I've explained the situation to Gabriel, and he is analyzing the need now.
@aganglada

Comment on lines -285 to -330
async #mapRpcTransactionToKeyringTransaction(
transactionData: SolanaTransaction | null,
account: SolanaKeyringAccount,
scope: Network,
assetsMetadata?: Record<string, AssetMetadata | null>,
): Promise<Transaction | null> {
if (!transactionData) {
return null;
}

const mappedTransaction = mapRpcTransaction({
transactionData,
account,
scope,
});

if (!mappedTransaction) {
return null;
}

const caip19Ids = [
...new Set(
[...mappedTransaction.from, ...mappedTransaction.to]
.filter((item) => item.asset?.fungible)
.map((item) => (item.asset as { type: CaipAssetType }).type),
),
];

const assetsMetadataToUse =
assetsMetadata ??
(await this.#assetsService.getAssetsMetadata(caip19Ids));

mappedTransaction.from.forEach((from) => {
if (from.asset?.fungible && assetsMetadataToUse[from.asset.type]) {
from.asset.unit = assetsMetadataToUse[from.asset.type]?.symbol ?? '';
}
});

mappedTransaction.to.forEach((to) => {
if (to.asset?.fungible && assetsMetadataToUse[to.asset.type]) {
to.asset.unit = assetsMetadataToUse[to.asset.type]?.symbol ?? '';
}
});

return mappedTransaction;
}
Copy link
Copy Markdown
Contributor Author

@xavier-brochard xavier-brochard Sep 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic is getting embedded in the TransactionMapper now, so that it encapsulates the whole mapping logic.

cursor[bot]

This comment was marked as outdated.

cursor[bot]

This comment was marked as outdated.

@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud bot commented Sep 5, 2025

@xavier-brochard xavier-brochard merged commit ea98408 into main Sep 5, 2025
10 checks passed
@xavier-brochard xavier-brochard deleted the NWNT-32 branch September 5, 2025 09:27
github-merge-queue bot pushed a commit to MetaMask/metamask-extension that referenced this pull request Sep 5, 2025
<!--
Please submit this PR as a draft initially.
Do not mark it as "Ready for review" until the template has been
completely filled out, and PR status checks have passed at least once.
-->

## **Description**

This PR bumps the Solana Wallet Snap to its latest version `2.3.7`.

## **Changelog**

<!--
If this PR is not End-User-Facing and should not show up in the
CHANGELOG, you can choose to either:
1. Write `CHANGELOG entry: null`
2. Label with `no-changelog`

If this PR is End-User-Facing, please write a short User-Facing
description in the past tense like:
`CHANGELOG entry: Added a new tab for users to see their NFTs`
`CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker`

(This helps the Release Engineer do their job more quickly and
accurately)
-->

CHANGELOG entry: Added support to Solana tokens with multiplier
([#509](MetaMask/snap-solana-wallet#509))
CHANGELOG entry: Fix a bug that was causing to show spam Solana
transactions in the activity list
([#515](MetaMask/snap-solana-wallet#515))
CHANGELOG entry: Fixed an issue that was causing to show an empty symbol
instead of `UNKNOWN` in activity list for Solana tokens with no metadata
([#517](MetaMask/snap-solana-wallet#517))

## **Related issues**

Fixes:

## **Manual testing steps**

1. Go to this page...
2.
3.

## **Screenshots/Recordings**

<!-- If applicable, add screenshots and/or recordings to visualize the
before and after of your change. -->

### **Before**

<!-- [screenshots/recordings] -->

### **After**

<!-- [screenshots/recordings] -->

## **Pre-merge author checklist**

- [ ] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask
Extension Coding
Standards](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [ ] I've completed the PR template to the best of my ability
- [ ] I’ve included tests if applicable
- [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [ ] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.

## **Pre-merge reviewer checklist**

- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
github-merge-queue bot pushed a commit to MetaMask/metamask-mobile that referenced this pull request Sep 5, 2025
<!--
Please submit this PR as a draft initially.
Do not mark it as "Ready for review" until the template has been
completely filled out, and PR status checks have passed at least once.
-->

## **Description**

This PR bumps the Solana Wallet Snap to its latest version `2.3.7`.

## **Changelog**

<!--
If this PR is not End-User-Facing and should not show up in the
CHANGELOG, you can choose to either:
1. Write `CHANGELOG entry: null`
2. Label with `no-changelog`

If this PR is End-User-Facing, please write a short User-Facing
description in the past tense like:
`CHANGELOG entry: Added a new tab for users to see their NFTs`
`CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker`

(This helps the Release Engineer do their job more quickly and
accurately)
-->

CHANGELOG entry: Added support to Solana tokens with multiplier
([#509](MetaMask/snap-solana-wallet#509))
CHANGELOG entry: Fix a bug that was causing to show spam Solana
transactions in the activity list
([#515](MetaMask/snap-solana-wallet#515))
CHANGELOG entry: Fixed an issue that was causing to show an empty symbol
instead of `UNKNOWN` in activity list for Solana tokens with no metadata
([#517](MetaMask/snap-solana-wallet#517))

## **Related issues**

Fixes:

## **Manual testing steps**

```gherkin
Feature: my feature name

  Scenario: user [verb for user action]
    Given [describe expected initial app state]

    When user [verb for user action]
    Then [describe expected outcome]
```

## **Screenshots/Recordings**

<!-- If applicable, add screenshots and/or recordings to visualize the
before and after of your change. -->

### **Before**

<!-- [screenshots/recordings] -->

### **After**

<!-- [screenshots/recordings] -->

## **Pre-merge author checklist**

- [ ] I’ve followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [ ] I've completed the PR template to the best of my ability
- [ ] I’ve included tests if applicable
- [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [ ] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.

## **Pre-merge reviewer checklist**

- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants