diff --git a/apps/ledger-live-desktop/src/renderer/families/solana/AccountSubHeader.tsx b/apps/ledger-live-desktop/src/renderer/families/solana/AccountSubHeader.tsx
index 2b0fb8935f0a..b9812aee438b 100644
--- a/apps/ledger-live-desktop/src/renderer/families/solana/AccountSubHeader.tsx
+++ b/apps/ledger-live-desktop/src/renderer/families/solana/AccountSubHeader.tsx
@@ -1,5 +1,30 @@
import React from "react";
+import { Trans } from "react-i18next";
+import { SolanaAccount, SolanaTokenAccount } from "@ledgerhq/live-common/families/solana/types";
+import { isTokenAccountFrozen } from "@ledgerhq/live-common/families/solana/logic";
+import { SubAccount } from "@ledgerhq/types-live";
+
+import Box from "~/renderer/components/Box";
+import Alert from "~/renderer/components/Alert";
import AccountSubHeader from "../../components/AccountSubHeader/index";
-export default function SolanaAccountSubHeader() {
- return ;
+
+type Account = SolanaAccount | SolanaTokenAccount | SubAccount;
+
+type Props = {
+ account: Account;
+};
+
+export default function SolanaAccountSubHeader({ account }: Props) {
+ return (
+ <>
+ {isTokenAccountFrozen(account) && (
+
+
+
+
+
+ )}
+
+ >
+ );
}
diff --git a/apps/ledger-live-desktop/static/i18n/en/app.json b/apps/ledger-live-desktop/static/i18n/en/app.json
index bcd2313bc2ce..a92604a0e9f1 100644
--- a/apps/ledger-live-desktop/static/i18n/en/app.json
+++ b/apps/ledger-live-desktop/static/i18n/en/app.json
@@ -3598,6 +3598,9 @@
}
}
}
+ },
+ "token": {
+ "frozenStateWarning": "Account assets are frozen!"
}
},
"ethereum": {
@@ -5741,6 +5744,12 @@
"SolanaAssociatedTokenAccountWillBeFunded": {
"title": "Account will be funded"
},
+ "SolanaTokenAccountFrozen": {
+ "title": "Account assets are frozen"
+ },
+ "SolanaTokenAccounNotInitialized": {
+ "title": "Account not initialized"
+ },
"SolanaMemoIsTooLong": {
"title": "Memo is too long. Max length is {{maxLength}}"
},
diff --git a/apps/ledger-live-mobile/src/families/solana/AccountSubHeader.tsx b/apps/ledger-live-mobile/src/families/solana/AccountSubHeader.tsx
index 54fe755006cc..8cc760b845ff 100644
--- a/apps/ledger-live-mobile/src/families/solana/AccountSubHeader.tsx
+++ b/apps/ledger-live-mobile/src/families/solana/AccountSubHeader.tsx
@@ -1,8 +1,32 @@
import React from "react";
+import { Trans } from "react-i18next";
+import { SubAccount } from "@ledgerhq/types-live";
+import { Box, Alert, Text } from "@ledgerhq/native-ui";
+import { isTokenAccountFrozen } from "@ledgerhq/live-common/families/solana/logic";
+import { SolanaAccount, SolanaTokenAccount } from "@ledgerhq/live-common/families/solana/types";
import AccountSubHeader from "~/components/AccountSubHeader";
-function SolanaAccountSubHeader() {
- return ;
+type Account = SolanaAccount | SolanaTokenAccount | SubAccount;
+
+type Props = {
+ account: Account;
+};
+
+function SolanaAccountSubHeader({ account }: Props) {
+ return (
+ <>
+ {isTokenAccountFrozen(account) && (
+
+
+
+
+
+
+
+ )}
+
+ >
+ );
}
export default SolanaAccountSubHeader;
diff --git a/apps/ledger-live-mobile/src/locales/en/common.json b/apps/ledger-live-mobile/src/locales/en/common.json
index 4d9aa668380c..aca1274eb14b 100644
--- a/apps/ledger-live-mobile/src/locales/en/common.json
+++ b/apps/ledger-live-mobile/src/locales/en/common.json
@@ -5743,6 +5743,9 @@
"started": {
"description": "You may earn rewards by delegating your SOL assets to a validator."
}
+ },
+ "token": {
+ "frozenStateWarning": "Account assets are frozen!"
}
},
"near": {
diff --git a/apps/ledger-live-mobile/src/screens/Account/ListHeaderComponent.tsx b/apps/ledger-live-mobile/src/screens/Account/ListHeaderComponent.tsx
index d9eb76ced157..1047423e9909 100644
--- a/apps/ledger-live-mobile/src/screens/Account/ListHeaderComponent.tsx
+++ b/apps/ledger-live-mobile/src/screens/Account/ListHeaderComponent.tsx
@@ -139,7 +139,7 @@ export function getListHeaderComponents({
,
!!AccountSubHeader && (
-
+
),
oldestEditableOperation ? (
diff --git a/libs/ledger-live-common/src/families/solana/bridge.integration.test.ts b/libs/ledger-live-common/src/families/solana/bridge.integration.test.ts
index de126b1c64a0..1b9826f2178d 100644
--- a/libs/ledger-live-common/src/families/solana/bridge.integration.test.ts
+++ b/libs/ledger-live-common/src/families/solana/bridge.integration.test.ts
@@ -4,6 +4,9 @@ import BigNumber from "bignumber.js";
import {
SolanaAccount,
SolanaStake,
+ SolanaTokenAccount,
+ SolanaTokenAccountRaw,
+ TokenTransferTransaction,
Transaction,
TransactionModel,
TransactionStatus,
@@ -18,13 +21,7 @@ import {
} from "@ledgerhq/errors";
import { findTokenByAddressInCurrency } from "@ledgerhq/cryptoassets";
import { TokenCurrency } from "@ledgerhq/types-cryptoassets";
-import type {
- Account,
- AccountRaw,
- CurrenciesData,
- DatasetTest,
- TokenAccountRaw,
-} from "@ledgerhq/types-live";
+import type { Account, AccountRaw, CurrenciesData, DatasetTest } from "@ledgerhq/types-live";
import {
SolanaAccountNotFunded,
SolanaAddressOffEd25519,
@@ -33,6 +30,7 @@ import {
SolanaRecipientAssociatedTokenAccountWillBeFunded,
SolanaStakeAccountNotFound,
SolanaStakeAccountRequired,
+ SolanaTokenAccountFrozen,
SolanaTokenAccountHoldsAnotherToken,
SolanaValidatorRequired,
} from "./errors";
@@ -174,7 +172,7 @@ function makeAccount(freshAddress: string): AccountRaw {
};
}
-function makeSubTokenAccount(): TokenAccountRaw {
+function makeSubTokenAccount(): SolanaTokenAccountRaw {
return {
type: "TokenAccountRaw",
id: wSolSubAccId,
@@ -1118,3 +1116,148 @@ const mockedVoteAccount = {
program: "vote",
space: 3731,
};
+
+describe("solana tokens", () => {
+ const baseAtaMock = {
+ parsed: {
+ info: {
+ isNative: false,
+ mint: wSolToken.contractAddress,
+ owner: testOnChainData.fundedSenderAddress,
+ state: "initialized",
+ tokenAmount: {
+ amount: "10000000",
+ decimals: wSolToken.units[0].magnitude,
+ uiAmount: 10.0,
+ uiAmountString: "10",
+ },
+ },
+ type: "account",
+ },
+ program: "spl-token",
+ space: 165,
+ };
+ const frozenAtaMock = {
+ ...baseAtaMock,
+ parsed: {
+ ...baseAtaMock.parsed,
+ info: {
+ ...baseAtaMock.parsed.info,
+ state: "frozen",
+ },
+ },
+ };
+
+ const mockedTokenAcc: SolanaTokenAccount = {
+ type: "TokenAccount",
+ id: wSolSubAccId,
+ parentId: mainAccId,
+ token: wSolToken,
+ balance: new BigNumber(100),
+ operations: [],
+ pendingOperations: [],
+ spendableBalance: new BigNumber(100),
+ state: "initialized",
+ creationDate: new Date(),
+ operationsCount: 0,
+ starred: false,
+ balanceHistoryCache: {
+ HOUR: { balances: [], latestDate: null },
+ DAY: { balances: [], latestDate: null },
+ WEEK: { balances: [], latestDate: null },
+ },
+ swapHistory: [],
+ };
+ test("token.transfer :: status is error: sender ATA is frozen", async () => {
+ const txModel: TokenTransferTransaction = {
+ kind: "token.transfer",
+ uiState: {
+ subAccountId: wSolSubAccId,
+ },
+ };
+
+ const api = {
+ ...baseAPI,
+ getAccountInfo: () => Promise.resolve({ data: baseAtaMock } as any),
+ getBalance: () => Promise.resolve(10),
+ } as ChainAPI;
+
+ const tokenAcc: SolanaTokenAccount = {
+ ...mockedTokenAcc,
+ state: "frozen",
+ };
+ const account: SolanaAccount = {
+ ...baseAccount,
+ freshAddress: testOnChainData.fundedSenderAddress,
+ subAccounts: [tokenAcc],
+ solanaResources: { stakes: [] },
+ };
+
+ const tx: Transaction = {
+ model: txModel,
+ amount: new BigNumber(10),
+ recipient: testOnChainData.fundedAddress,
+ family: "solana",
+ };
+
+ const preparedTx = await prepareTransaction(account, tx, api);
+ const receivedTxStatus = await getTransactionStatus(account, preparedTx);
+ const expectedTxStatus: TransactionStatus = {
+ amount: new BigNumber(10),
+ estimatedFees: new BigNumber(testOnChainData.fees.lamportsPerSignature),
+ totalSpent: new BigNumber(10),
+ errors: {
+ amount: new SolanaTokenAccountFrozen(),
+ },
+ warnings: {},
+ };
+
+ expect(receivedTxStatus).toEqual(expectedTxStatus);
+ });
+
+ test("token.transfer :: status is error: recipient ATA is frozen", async () => {
+ const txModel: TokenTransferTransaction = {
+ kind: "token.transfer",
+ uiState: {
+ subAccountId: wSolSubAccId,
+ },
+ };
+
+ const api = {
+ ...baseAPI,
+ getAccountInfo: () => Promise.resolve({ data: frozenAtaMock } as any),
+ getBalance: () => Promise.resolve(10),
+ } as ChainAPI;
+
+ const tokenAcc: SolanaTokenAccount = {
+ ...mockedTokenAcc,
+ };
+ const account: SolanaAccount = {
+ ...baseAccount,
+ freshAddress: testOnChainData.fundedSenderAddress,
+ subAccounts: [tokenAcc],
+ solanaResources: { stakes: [] },
+ };
+
+ const tx: Transaction = {
+ model: txModel,
+ amount: new BigNumber(10),
+ recipient: testOnChainData.fundedAddress,
+ family: "solana",
+ };
+
+ const preparedTx = await prepareTransaction(account, tx, api);
+ const receivedTxStatus = await getTransactionStatus(account, preparedTx);
+ const expectedTxStatus: TransactionStatus = {
+ amount: new BigNumber(10),
+ estimatedFees: new BigNumber(testOnChainData.fees.lamportsPerSignature),
+ totalSpent: new BigNumber(10),
+ errors: {
+ recipient: new SolanaTokenAccountFrozen(),
+ },
+ warnings: {},
+ };
+
+ expect(receivedTxStatus).toEqual(expectedTxStatus);
+ });
+});
diff --git a/libs/ledger-live-common/src/families/solana/errors.ts b/libs/ledger-live-common/src/families/solana/errors.ts
index e90be7459bfb..94a9183b003a 100644
--- a/libs/ledger-live-common/src/families/solana/errors.ts
+++ b/libs/ledger-live-common/src/families/solana/errors.ts
@@ -16,6 +16,8 @@ export const SolanaTokenAccounNotInitialized = createCustomErrorClass(
"SolanaTokenAccounNotInitialized",
);
+export const SolanaTokenAccountFrozen = createCustomErrorClass("SolanaTokenAccountFrozen");
+
export const SolanaAddressOffEd25519 = createCustomErrorClass("SolanaAddressOfEd25519");
export const SolanaTokenRecipientIsSenderATA = createCustomErrorClass(
diff --git a/libs/ledger-live-common/src/families/solana/js-prepareTransaction.ts b/libs/ledger-live-common/src/families/solana/js-prepareTransaction.ts
index d119228eb701..2e29df1b93c3 100644
--- a/libs/ledger-live-common/src/families/solana/js-prepareTransaction.ts
+++ b/libs/ledger-live-common/src/families/solana/js-prepareTransaction.ts
@@ -19,6 +19,7 @@ import {
} from "./api/chain/web3";
import {
SolanaAccountNotFunded,
+ SolanaTokenAccountFrozen,
SolanaAddressOffEd25519,
SolanaInvalidValidator,
SolanaMemoIsTooLong,
@@ -47,6 +48,7 @@ import type {
CommandDescriptor,
SolanaAccount,
SolanaStake,
+ SolanaTokenAccount,
StakeCreateAccountTransaction,
StakeDelegateTransaction,
StakeSplitTransaction,
@@ -128,6 +130,10 @@ const deriveTokenTransferCommandDescriptor = async (
throw new Error("subaccount not found");
}
+ if ((subAccount as SolanaTokenAccount)?.state === "frozen") {
+ errors.amount = new SolanaTokenAccountFrozen();
+ }
+
await validateRecipientCommon(mainAccount, tx, errors, warnings, api);
const memo = model.uiState.memo;
@@ -239,6 +245,9 @@ async function getTokenRecipient(
if (recipientTokenAccount.mint.toBase58() !== mintAddress) {
return new SolanaTokenAccountHoldsAnotherToken();
}
+ if (recipientTokenAccount.state === "frozen") {
+ return new SolanaTokenAccountFrozen();
+ }
if (recipientTokenAccount.state !== "initialized") {
return new SolanaTokenAccounNotInitialized();
}
diff --git a/libs/ledger-live-common/src/families/solana/js-synchronization.ts b/libs/ledger-live-common/src/families/solana/js-synchronization.ts
index 505841b4ae68..bc1df8862479 100644
--- a/libs/ledger-live-common/src/families/solana/js-synchronization.ts
+++ b/libs/ledger-live-common/src/families/solana/js-synchronization.ts
@@ -37,7 +37,7 @@ import { InflationReward, ParsedTransaction, StakeActivationData } from "@solana
import { ChainAPI } from "./api";
import { ParsedOnChainTokenAccountWithInfo, toTokenAccountWithInfo } from "./api/chain/web3";
import { drainSeq } from "./utils";
-import { SolanaAccount, SolanaOperationExtra, SolanaStake } from "./types";
+import { SolanaAccount, SolanaOperationExtra, SolanaStake, SolanaTokenAccount } from "./types";
import { Account, Operation, OperationType, TokenAccount } from "@ledgerhq/types-live";
type OnChainTokenAccount = Awaited>["tokenAccounts"][number];
@@ -225,7 +225,7 @@ function newSubAcc({
mainAccountId: string;
assocTokenAcc: OnChainTokenAccount;
txs: TransactionDescriptor[];
-}): TokenAccount {
+}): SolanaTokenAccount {
const firstTx = txs[txs.length - 1];
const creationDate = new Date((firstTx.info.blockTime ?? Date.now() / 1000) * 1000);
@@ -257,6 +257,7 @@ function newSubAcc({
starred: false,
swapHistory: [],
token: tokenCurrency,
+ state: assocTokenAcc.info.state,
type: "TokenAccount",
};
}
@@ -269,7 +270,7 @@ function patchedSubAcc({
subAcc: TokenAccount;
assocTokenAcc: OnChainTokenAccount;
txs: TransactionDescriptor[];
-}): TokenAccount {
+}): SolanaTokenAccount {
const balance = new BigNumber(assocTokenAcc.info.tokenAmount.amount);
const newOps = compact(txs.map(tx => txToTokenAccOperation(tx, assocTokenAcc, subAcc.id)));
@@ -281,6 +282,7 @@ function patchedSubAcc({
balance,
spendableBalance: balance,
operations: totalOps,
+ state: assocTokenAcc.info.state,
};
}
@@ -521,6 +523,10 @@ function getMainAccOperationTypeFromTx(tx: ParsedTransaction): OperationType | u
switch (first.instruction.type) {
case "closeAccount":
return "OPT_OUT";
+ case "freezeAccount":
+ return "FREEZE";
+ case "thawAccount":
+ return "UNFREEZE";
}
break;
case "stake":
@@ -587,6 +593,14 @@ function getTokenAccOperationType({
case "associate":
return "NONE"; // ATA opt-in operation is added to the main account
}
+ break;
+ case "spl-token":
+ switch (mainIx.instruction.type) {
+ case "freezeAccount":
+ return "FREEZE";
+ case "thawAccount":
+ return "UNFREEZE";
+ }
}
}
diff --git a/libs/ledger-live-common/src/families/solana/logic.ts b/libs/ledger-live-common/src/families/solana/logic.ts
index 703f273173ac..fea208d4c098 100644
--- a/libs/ledger-live-common/src/families/solana/logic.ts
+++ b/libs/ledger-live-common/src/families/solana/logic.ts
@@ -1,8 +1,8 @@
import { findTokenById } from "@ledgerhq/cryptoassets";
import { PublicKey } from "@solana/web3.js";
-import { TokenAccount } from "@ledgerhq/types-live";
+import { AccountLike, TokenAccount } from "@ledgerhq/types-live";
import { StakeMeta } from "./api/chain/account/stake";
-import { SolanaStake, StakeAction } from "./types";
+import { SolanaStake, SolanaTokenAccount, StakeAction } from "./types";
import { assertUnreachable } from "./utils";
export type Awaited = T extends PromiseLike ? U : T;
@@ -121,3 +121,7 @@ export function stakeActivePercent(stake: SolanaStake) {
}
return (stake.activation.active / amount) * 100;
}
+
+export function isTokenAccountFrozen(account: AccountLike) {
+ return account.type === "TokenAccount" && (account as SolanaTokenAccount)?.state === "frozen";
+}
diff --git a/libs/ledger-live-common/src/families/solana/types.ts b/libs/ledger-live-common/src/families/solana/types.ts
index a0c5b957ae21..8b30f68ba28d 100644
--- a/libs/ledger-live-common/src/families/solana/types.ts
+++ b/libs/ledger-live-common/src/families/solana/types.ts
@@ -2,12 +2,15 @@ import {
Account,
AccountRaw,
Operation,
+ TokenAccount,
+ TokenAccountRaw,
TransactionCommon,
TransactionCommonRaw,
TransactionStatusCommon,
TransactionStatusCommonRaw,
} from "@ledgerhq/types-live";
import { ValidatorsAppValidator } from "./validator-app";
+import { TokenAccountState } from "./api/chain/account/token";
export type TransferCommand = {
kind: "transfer";
@@ -252,6 +255,8 @@ export type SolanaAccount = Account & { solanaResources: SolanaResources };
export type SolanaAccountRaw = AccountRaw & {
solanaResources: SolanaResourcesRaw;
};
+export type SolanaTokenAccount = TokenAccount & { state?: TokenAccountState };
+export type SolanaTokenAccountRaw = TokenAccountRaw & { state?: TokenAccountState };
export type TransactionStatus = TransactionStatusCommon;