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;