diff --git a/.changeset/honest-dingos-unite.md b/.changeset/honest-dingos-unite.md new file mode 100644 index 000000000000..ee797fa22f83 --- /dev/null +++ b/.changeset/honest-dingos-unite.md @@ -0,0 +1,11 @@ +--- +"@ledgerhq/cryptoassets": minor +"@ledgerhq/types-live": minor +"@ledgerhq/coin-solana": minor +"ledger-live-desktop": minor +"live-mobile": minor +"@ledgerhq/live-common": minor +"@ledgerhq/coin-framework": minor +--- + +Solana spl tokens support diff --git a/apps/ledger-live-desktop/src/config/urls.ts b/apps/ledger-live-desktop/src/config/urls.ts index ee2bf428d1b3..92748153265a 100644 --- a/apps/ledger-live-desktop/src/config/urls.ts +++ b/apps/ledger-live-desktop/src/config/urls.ts @@ -4,6 +4,7 @@ export const supportLinkByTokenType = { trc20: "https://support.ledger.com/article/360013062159-zd", asa: "https://support.ledger.com/article/360015896040-zd", nfts: "https://support.ledger.com/article/4404389453841-zd", + // spl: "Solana spl tokens. TODO: to be defined", }; const errors: Record = { @@ -153,6 +154,7 @@ export const urls = { }, solana: { staking: "https://support.ledger.com/article/4731749170461-zd", + splTokenInfo: "Solana spl tokens link TODO: to be defined", recipient_info: "https://support.ledger.com", ledgerByFigmentTC: "https://cdn.figment.io/legal/Current%20Ledger_Online%20Staking%20Delgation%20Services%20Agreement.pdf", diff --git a/apps/ledger-live-desktop/src/renderer/components/OperationsList/ConfirmationCheck.tsx b/apps/ledger-live-desktop/src/renderer/components/OperationsList/ConfirmationCheck.tsx index 9637dd974ccd..df5c419a26a8 100644 --- a/apps/ledger-live-desktop/src/renderer/components/OperationsList/ConfirmationCheck.tsx +++ b/apps/ledger-live-desktop/src/renderer/components/OperationsList/ConfirmationCheck.tsx @@ -120,6 +120,7 @@ const iconsComponent = { STAKE: IconDelegate, UNSTAKE: IconUndelegate, WITHDRAW_UNSTAKED: IconCoins, + BURN: IconTrash, }; class ConfirmationCheck extends PureComponent<{ marketColor: string; diff --git a/apps/ledger-live-desktop/src/renderer/families/solana/AccountHeaderManageActions.ts b/apps/ledger-live-desktop/src/renderer/families/solana/AccountHeaderManageActions.ts index 3c1bfc333622..fdb64b93697e 100644 --- a/apps/ledger-live-desktop/src/renderer/families/solana/AccountHeaderManageActions.ts +++ b/apps/ledger-live-desktop/src/renderer/families/solana/AccountHeaderManageActions.ts @@ -6,10 +6,14 @@ import { openModal } from "~/renderer/actions/modals"; import IconCoins from "~/renderer/icons/Coins"; import { SolanaFamily } from "./types"; -const AccountHeaderActions: SolanaFamily["accountHeaderManageActions"] = ({ account, source }) => { +const AccountHeaderActions: SolanaFamily["accountHeaderManageActions"] = ({ + account, + parentAccount, + source, +}) => { const { t } = useTranslation(); const dispatch = useDispatch(); - const mainAccount = getMainAccount(account); + const mainAccount = getMainAccount(account, parentAccount); const { solanaResources } = mainAccount; const onClick = useCallback(() => { @@ -34,6 +38,10 @@ const AccountHeaderActions: SolanaFamily["accountHeaderManageActions"] = ({ acco } }, [account, dispatch, source, solanaResources, mainAccount]); + if (account.type === "TokenAccount") { + return null; + } + return [ { key: "Stake", 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..9fad5942d429 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/src/renderer/families/solana/TransactionConfirmFields.tsx b/apps/ledger-live-desktop/src/renderer/families/solana/TransactionConfirmFields.tsx new file mode 100644 index 000000000000..877c580773c9 --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/families/solana/TransactionConfirmFields.tsx @@ -0,0 +1,67 @@ +import React, { useMemo } from "react"; +import { getDeviceTransactionConfig } from "@ledgerhq/live-common/transaction/index"; +import { SolanaFamily } from "./types"; +import Alert from "~/renderer/components/Alert"; +import { Trans } from "react-i18next"; +import ConfirmTitle from "~/renderer/components/TransactionConfirm/ConfirmTitle"; +import LinkWithExternalIcon from "~/renderer/components/LinkWithExternalIcon"; +import Box from "~/renderer/components/Box"; +import { openURL } from "~/renderer/linking"; +import { useLocalizedUrl } from "~/renderer/hooks/useLocalizedUrls"; +import { urls } from "~/config/urls"; + +const Title: TitleComponent = props => { + const { transaction, account, parentAccount, status } = props; + const transferTokenHelpUrl = useLocalizedUrl(urls.solana.splTokenInfo); + + const fields = getDeviceTransactionConfig({ + account, + parentAccount, + transaction, + status, + }); + + const typeTransaction: string | undefined = useMemo(() => { + const typeField = fields.find(field => field.label && field.label === "Type"); + + if (typeField && typeField.type === "text" && typeField.value) { + return typeField.value; + } + }, [fields]); + + if (transaction.model.commandDescriptor?.command.kind === "token.transfer") { + return ( + + + + + openURL(transferTokenHelpUrl)} + /> + + + + ); + } + + return ; +}; + +type TransactionConfirmFields = SolanaFamily["transactionConfirmFields"]; +type TitleComponent = NonNullable["title"]>; + +const transactionConfirmFields: TransactionConfirmFields = { + // footer: Footer, // is not shown without manifestId + // fieldComponents, + title: Title, +}; + +export default transactionConfirmFields; diff --git a/apps/ledger-live-desktop/src/renderer/families/solana/index.ts b/apps/ledger-live-desktop/src/renderer/families/solana/index.ts index da1314ef035f..3396136c9ec2 100644 --- a/apps/ledger-live-desktop/src/renderer/families/solana/index.ts +++ b/apps/ledger-live-desktop/src/renderer/families/solana/index.ts @@ -6,6 +6,7 @@ import AccountBalanceSummaryFooter from "./AccountBalanceSummaryFooter"; import StakeBanner from "./StakeBanner"; import { SolanaFamily } from "./types"; import operationDetails from "./operationDetails"; +import transactionConfirmFields from "./TransactionConfirmFields"; const family: SolanaFamily = { accountHeaderManageActions, @@ -15,6 +16,7 @@ const family: SolanaFamily = { AccountBalanceSummaryFooter, StakeBanner, operationDetails, + transactionConfirmFields, }; export default family; diff --git a/apps/ledger-live-desktop/static/i18n/en/app.json b/apps/ledger-live-desktop/static/i18n/en/app.json index 8c2f61ca2781..221816407cbf 100644 --- a/apps/ledger-live-desktop/static/i18n/en/app.json +++ b/apps/ledger-live-desktop/static/i18n/en/app.json @@ -195,7 +195,8 @@ "STAKE": "Staked", "UNSTAKE": "Unstaked", "WITHDRAW_UNSTAKED": "Withdrawn", - "SENDING": "Sending" + "SENDING": "Sending", + "BURN": "Burned" }, "edit": { "title": "Speed up or Cancel", @@ -3695,6 +3696,10 @@ } } } + }, + "token": { + "frozenStateWarning": "Account assets are frozen!", + "transferWarning": "Solana SPL tokens transactions have unique characteristics. To learn more, visit: <0>ledger.com/spl" } }, "ethereum": { @@ -6067,6 +6072,15 @@ "SolanaAccountNotFunded": { "title": "Account not funded" }, + "SolanaAssociatedTokenAccountWillBeFunded": { + "title": "Account will be funded" + }, + "SolanaTokenAccountFrozen": { + "title": "Token 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/families/solana/SendRowsFee.tsx b/apps/ledger-live-mobile/src/families/solana/SendRowsFee.tsx index 9f4c1761c474..64ad6e73fa71 100644 --- a/apps/ledger-live-mobile/src/families/solana/SendRowsFee.tsx +++ b/apps/ledger-live-mobile/src/families/solana/SendRowsFee.tsx @@ -32,7 +32,7 @@ type Props = { StackNavigatorProps >; -export default function SolanaFeeRow({ account, status }: Props) { +export default function SolanaFeeRow({ account, parentAccount, status }: Props) { const { colors } = useTheme(); const extraInfoFees = useCallback(() => { Linking.openURL(urls.solana.supportPage); @@ -40,7 +40,7 @@ export default function SolanaFeeRow({ account, status }: Props) { const fees = (status as SolanaTransactionStatus).estimatedFees; - const unit = useAccountUnit(account); + const unit = useAccountUnit(account.type === "TokenAccount" ? parentAccount || account : account); const currency = getAccountCurrency(account); return ( diff --git a/apps/ledger-live-mobile/src/families/solana/TransactionConfirmFields.tsx b/apps/ledger-live-mobile/src/families/solana/TransactionConfirmFields.tsx new file mode 100644 index 000000000000..4bf31f76ebfd --- /dev/null +++ b/apps/ledger-live-mobile/src/families/solana/TransactionConfirmFields.tsx @@ -0,0 +1,46 @@ +import invariant from "invariant"; +import React from "react"; +import { Linking, View } from "react-native"; +import { Trans } from "react-i18next"; +import { Link } from "@ledgerhq/native-ui"; +import { DeviceTransactionField } from "@ledgerhq/live-common/transaction/index"; +import { + SolanaAccount, + SolanaTokenAccount, + Transaction, + TransactionStatus, +} from "@ledgerhq/live-common/families/solana/types"; +import Alert from "~/components/Alert"; +import { urls } from "~/utils/urls"; +import LText from "~/components/LText"; + +type SolanaFieldComponentProps = { + account: SolanaAccount | SolanaTokenAccount; + parentAccount: SolanaAccount | undefined | null; + transaction: Transaction; + status: TransactionStatus; + field: DeviceTransactionField; +}; + +const Warning = ({ transaction }: SolanaFieldComponentProps) => { + invariant(transaction.family === "solana", "solana transaction"); + if (transaction.model.commandDescriptor?.command.kind === "token.transfer") { + return ( + + + + + Linking.openURL(urls.solana.splTokenInfo)} type="color" /> + + + + + ); + } + return null; +}; + +export default { + warning: Warning, + fieldComponents: {}, +}; diff --git a/apps/ledger-live-mobile/src/locales/en/common.json b/apps/ledger-live-mobile/src/locales/en/common.json index 358ca7ef05b9..aae8de94ab2a 100644 --- a/apps/ledger-live-mobile/src/locales/en/common.json +++ b/apps/ledger-live-mobile/src/locales/en/common.json @@ -765,6 +765,12 @@ "SolanaAccountNotFunded": { "title": "Account not funded" }, + "SolanaAssociatedTokenAccountWillBeFunded": { + "title": "Account will be funded" + }, + "SolanaTokenAccountFrozen": { + "title": "Token account assets are frozen" + }, "SolanaAddressOfEd25519": { "title": "Address off ed25519 curve" }, @@ -5856,6 +5862,10 @@ "description": "You may earn rewards by delegating your SOL assets to a validator." }, "reserveWarning": "Please ensure you reserve at least {{amount}} SOL in your wallet to cover future network fees to deactivate and withdraw your stake" + }, + "token": { + "frozenStateWarning": "Account assets are frozen!", + "transferWarning": "Double-check the transaction details on your Ledger device before signing. Solana SPL tokens transactions have unique characteristics. To learn more, visit: <0>ledger.com/spl" } }, "near": { diff --git a/apps/ledger-live-mobile/src/screens/Account/ListHeaderComponent.tsx b/apps/ledger-live-mobile/src/screens/Account/ListHeaderComponent.tsx index f7af804b39ce..ea4333164cf6 100644 --- a/apps/ledger-live-mobile/src/screens/Account/ListHeaderComponent.tsx +++ b/apps/ledger-live-mobile/src/screens/Account/ListHeaderComponent.tsx @@ -161,7 +161,7 @@ export function getListHeaderComponents({
, !!AccountSubHeader && ( - + ), oldestEditableOperation ? ( diff --git a/apps/ledger-live-mobile/src/utils/urls.tsx b/apps/ledger-live-mobile/src/utils/urls.tsx index ca33dd4a61e7..aa21b22b2e5a 100644 --- a/apps/ledger-live-mobile/src/utils/urls.tsx +++ b/apps/ledger-live-mobile/src/utils/urls.tsx @@ -160,6 +160,7 @@ export const urls = { solana: { supportPage: "https://support.ledger.com", stakingPage: "https://support.ledger.com/article/4731749170461-zd", + splTokenInfo: "Solana spl tokens link TODO: to be defined", }, resources: { gettingStarted: diff --git a/libs/coin-framework/src/operation.ts b/libs/coin-framework/src/operation.ts index ca27a99a5b01..889cff983ff0 100644 --- a/libs/coin-framework/src/operation.ts +++ b/libs/coin-framework/src/operation.ts @@ -173,6 +173,7 @@ export function getOperationAmountNumber(op: Operation): BigNumber { case "OPT_OUT": case "SLASH": case "LOCK": + case "BURN": return op.value.negated(); case "FREEZE": diff --git a/libs/coin-framework/src/serialization/account.ts b/libs/coin-framework/src/serialization/account.ts index f16a0fec1d18..b5f982162154 100644 --- a/libs/coin-framework/src/serialization/account.ts +++ b/libs/coin-framework/src/serialization/account.ts @@ -27,6 +27,7 @@ import { export type FromFamiliyRaw = { assignFromAccountRaw?: AccountBridge["assignFromAccountRaw"]; + assignFromTokenAccountRaw?: AccountBridge["assignFromTokenAccountRaw"]; fromOperationExtraRaw?: AccountBridge["fromOperationExtraRaw"]; }; @@ -129,11 +130,21 @@ export function fromAccountRaw(rawAccount: AccountRaw, fromRaw?: FromFamiliyRaw) fromRaw.assignFromAccountRaw(rawAccount, res); } + if (fromRaw?.assignFromTokenAccountRaw && res.subAccounts) { + res.subAccounts.forEach((subAcc, index) => { + const subAccRaw = subAccountsRaw?.[index]; + if (subAcc.type === "TokenAccount" && subAccRaw?.type === "TokenAccountRaw") { + fromRaw.assignFromTokenAccountRaw?.(subAccRaw, subAcc); + } + }); + } + return res; } export type ToFamiliyRaw = { assignToAccountRaw?: AccountBridge["assignToAccountRaw"]; + assignToTokenAccountRaw?: AccountBridge["assignToTokenAccountRaw"]; toOperationExtraRaw?: AccountBridge["toOperationExtraRaw"]; }; @@ -207,6 +218,15 @@ export function toAccountRaw(account: Account, toFamilyRaw?: ToFamiliyRaw): Acco toFamilyRaw.assignToAccountRaw(account, res); } + if (toFamilyRaw?.assignToTokenAccountRaw && res.subAccounts) { + res.subAccounts.forEach((subAccRaw, index) => { + const subAcc = subAccounts?.[index]; + if (subAccRaw.type === "TokenAccountRaw" && subAcc?.type === "TokenAccount") { + toFamilyRaw.assignToTokenAccountRaw?.(subAcc, subAccRaw); + } + }); + } + if (swapHistory) { res.swapHistory = swapHistory.map(toSwapOperationRaw); } diff --git a/libs/coin-modules/coin-solana/src/api/chain/web3.ts b/libs/coin-modules/coin-solana/src/api/chain/web3.ts index fff6da9b0e03..22928e5ea2c7 100644 --- a/libs/coin-modules/coin-solana/src/api/chain/web3.ts +++ b/libs/coin-modules/coin-solana/src/api/chain/web3.ts @@ -173,6 +173,8 @@ export const buildTokenTransferInstructions = async ( const destinationPubkey = new PublicKey(recipientDescriptor.tokenAccAddress); + const destinationOwnerPubkey = new PublicKey(recipientDescriptor.walletAddress); + const instructions: TransactionInstruction[] = []; const mintPubkey = new PublicKey(mintAddress); @@ -182,7 +184,7 @@ export const buildTokenTransferInstructions = async ( createAssociatedTokenAccountInstruction( ownerPubkey, destinationPubkey, - ownerPubkey, + destinationOwnerPubkey, mintPubkey, ), ); diff --git a/libs/coin-modules/coin-solana/src/bridge.integration.test.ts b/libs/coin-modules/coin-solana/src/bridge.integration.test.ts index 124ae2f7bdfb..048332a678b6 100644 --- a/libs/coin-modules/coin-solana/src/bridge.integration.test.ts +++ b/libs/coin-modules/coin-solana/src/bridge.integration.test.ts @@ -2,6 +2,9 @@ import BigNumber from "bignumber.js"; import { SolanaAccount, SolanaStake, + SolanaTokenAccount, + SolanaTokenAccountRaw, + TokenTransferTransaction, Transaction, TransactionModel, TransactionStatus, @@ -14,34 +17,34 @@ import { NotEnoughBalance, RecipientRequired, } from "@ledgerhq/errors"; +import { findTokenByAddressInCurrency } from "@ledgerhq/cryptoassets"; +import { TokenCurrency } from "@ledgerhq/types-cryptoassets"; +import type { Account, AccountRaw, CurrenciesData, DatasetTest } from "@ledgerhq/types-live"; import { SolanaAccountNotFunded, SolanaAddressOffEd25519, SolanaInvalidValidator, SolanaMemoIsTooLong, - /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ SolanaRecipientAssociatedTokenAccountWillBeFunded, SolanaStakeAccountNotFound, SolanaStakeAccountRequired, - /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ + SolanaTokenAccountFrozen, SolanaTokenAccountHoldsAnotherToken, SolanaValidatorRequired, } from "./errors"; import { encodeAccountIdWithTokenAccountAddress, MAX_MEMO_LENGTH } from "./logic"; import createTransaction from "./createTransaction"; import { compact } from "lodash/fp"; -import { assertUnreachable } from "./utils"; +import { SYSTEM_ACCOUNT_RENT_EXEMPT, assertUnreachable } from "./utils"; import { getEnv } from "@ledgerhq/live-env"; -import { ChainAPI } from "./api"; +import { ChainAPI, LATEST_BLOCKHASH_MOCK } from "./api"; import { SolanaStakeAccountIsNotDelegatable, SolanaStakeAccountValidatorIsUnchangeable, } from "./errors"; import getTransactionStatus from "./getTransactionStatus"; import { prepareTransaction } from "./prepareTransaction"; -import type { Account, CurrenciesData, DatasetTest } from "@ledgerhq/types-live"; -import { encodeAccountId } from "@ledgerhq/coin-framework/account/accountId"; -import { LATEST_BLOCKHASH_MOCK } from "./api/chain"; +import { encodeAccountId } from "@ledgerhq/coin-framework/lib/account/accountId"; // do not change real properties or the test will break const testOnChainData = { @@ -61,7 +64,7 @@ const testOnChainData = { validatorAddress: "9QU2QSxhb24FUX3Tu2FpczXjpK3VYrvRudywSZaM29mF", fees: { stakeAccountRentExempt: 2282880, - systemAccountRentExempt: 890880, + systemAccountRentExempt: SYSTEM_ACCOUNT_RENT_EXEMPT, lamportsPerSignature: 5000, }, // --- maybe outdated or not real, fine for tests --- @@ -77,12 +80,16 @@ const mainAccId = encodeAccountId({ derivationMode: "solanaMain", }); -/* eslint-disable-next-line @typescript-eslint/no-unused-vars */ const wSolSubAccId = encodeAccountIdWithTokenAccountAddress( mainAccId, testOnChainData.wSolSenderAssocTokenAccAddress, ); +const wSolToken = findTokenByAddressInCurrency( + "So11111111111111111111111111111111111111112", + "solana", +) as TokenCurrency; + const fees = (signatureCount: number) => new BigNumber(signatureCount * testOnChainData.fees.lamportsPerSignature); @@ -128,7 +135,7 @@ const solana: CurrenciesData = { }, ...transferTests(), ...stakingTests(), - //...tokenTests() + ...tokenTests(), ], }, ], @@ -141,7 +148,7 @@ export const dataset: DatasetTest = { }, }; -function makeAccount(freshAddress: string) { +function makeAccount(freshAddress: string): AccountRaw { return { id: mainAccId, seedIdentifier: "", @@ -155,6 +162,19 @@ function makeAccount(freshAddress: string) { currencyId: "solana", lastSyncDate: "", balance: "0", + subAccounts: [makeSubTokenAccount()], + }; +} + +function makeSubTokenAccount(): SolanaTokenAccountRaw { + return { + type: "TokenAccountRaw", + id: wSolSubAccId, + parentId: mainAccId, + tokenId: wSolToken.id, + balance: "0", + operations: [], + pendingOperations: [], }; } @@ -168,15 +188,12 @@ type TransactionTestSpec = Exclude< function recipientRequired(): TransactionTestSpec[] { const models: TransactionModel[] = [ - // uncomment when tokens are supported - /* { kind: "token.transfer", uiState: { - subAccountId: "", + subAccountId: wSolSubAccId, }, }, - */ { kind: "transfer", uiState: {}, @@ -429,8 +446,6 @@ function transferTests(): TransactionTestSpec[] { ]; } -// uncomment when tokens are supported -/* eslint-disable-next-line @typescript-eslint/no-unused-vars */ function tokenTests(): TransactionTestSpec[] { return [ { @@ -495,7 +510,7 @@ function tokenTests(): TransactionTestSpec[] { warnings: {}, estimatedFees: fees(1), amount: testOnChainData.wSolSenderAssocTokenAccBalance.dividedBy(2), - totalSpent: zero, + totalSpent: testOnChainData.wSolSenderAssocTokenAccBalance.dividedBy(2), }, }, { @@ -518,7 +533,7 @@ function tokenTests(): TransactionTestSpec[] { warnings: {}, estimatedFees: fees(1), amount: testOnChainData.wSolSenderAssocTokenAccBalance.dividedBy(2), - totalSpent: zero, + totalSpent: testOnChainData.wSolSenderAssocTokenAccBalance.dividedBy(2), }, }, { @@ -537,8 +552,7 @@ function tokenTests(): TransactionTestSpec[] { expectedStatus: { errors: {}, warnings: { - recipient: new SolanaAccountNotFunded(), - recipientAssociatedTokenAccount: new SolanaRecipientAssociatedTokenAccountWillBeFunded(), + recipient: new SolanaRecipientAssociatedTokenAccountWillBeFunded(), }, // this fee is dynamic, skip //estimatedFees: new BigNumber(2044280), @@ -1110,6 +1124,150 @@ const mockedVoteAccount = { 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, + 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: [], unstakeReserve: BigNumber(0) }, + }; + + 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: [], unstakeReserve: BigNumber(0) }, + }; + + 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); + }); +}); + describe("Solana bridge", () => { test.todo( "This is an empty test to make jest command pass. Remove it once there is a real test.", diff --git a/libs/coin-modules/coin-solana/src/bridge/bridge.ts b/libs/coin-modules/coin-solana/src/bridge/bridge.ts index 7a64d25d6559..8387ab6a0d6a 100644 --- a/libs/coin-modules/coin-solana/src/bridge/bridge.ts +++ b/libs/coin-modules/coin-solana/src/bridge/bridge.ts @@ -27,6 +27,8 @@ import { assignToAccountRaw, fromOperationExtraRaw, toOperationExtraRaw, + assignFromTokenAccountRaw, + assignToTokenAccountRaw, } from "../serialization"; function makePrepare(getChainAPI: (config: Config) => Promise) { @@ -176,6 +178,8 @@ export function makeBridges({ assignToAccountRaw, toOperationExtraRaw, fromOperationExtraRaw, + assignFromTokenAccountRaw, + assignToTokenAccountRaw, }; const currencyBridge: CurrencyBridge = { diff --git a/libs/coin-modules/coin-solana/src/deviceTransactionConfig.ts b/libs/coin-modules/coin-solana/src/deviceTransactionConfig.ts index 89be8eb3759e..93500d8d4987 100644 --- a/libs/coin-modules/coin-solana/src/deviceTransactionConfig.ts +++ b/libs/coin-modules/coin-solana/src/deviceTransactionConfig.ts @@ -70,49 +70,23 @@ function fieldsForTransfer(_command: TransferCommand): DeviceTransactionField[] return fields; } -function fieldsForTokenTransfer(command: TokenTransferCommand): DeviceTransactionField[] { +function fieldsForTokenTransfer(_command: TokenTransferCommand): DeviceTransactionField[] { const fields: Array = []; - if (command.recipientDescriptor.shouldCreateAsAssociatedTokenAccount) { - fields.push({ - type: "address", - label: "Create token acct", - address: command.recipientDescriptor.tokenAccAddress, - }); - - fields.push({ - type: "address", - label: "From mint", - address: command.mintAddress, - }); - fields.push({ - type: "address", - label: "Funded by", - address: command.ownerAddress, - }); - } - fields.push({ type: "amount", label: "Transfer tokens", }); fields.push({ - type: "address", - address: command.ownerAssociatedTokenAccountAddress, - label: "From", - }); - - fields.push({ - type: "address", - address: command.ownerAddress, - label: "Owner", + type: "text", + value: "Solana", + label: "Network", }); fields.push({ - type: "address", - address: command.ownerAddress, - label: "Fee payer", + type: "fees", + label: "Max network fees", }); return fields; diff --git a/libs/coin-modules/coin-solana/src/errors.ts b/libs/coin-modules/coin-solana/src/errors.ts index e90be7459bfb..94a9183b003a 100644 --- a/libs/coin-modules/coin-solana/src/errors.ts +++ b/libs/coin-modules/coin-solana/src/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/coin-modules/coin-solana/src/logic.ts b/libs/coin-modules/coin-solana/src/logic.ts index 703f273173ac..fea208d4c098 100644 --- a/libs/coin-modules/coin-solana/src/logic.ts +++ b/libs/coin-modules/coin-solana/src/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/coin-modules/coin-solana/src/prepareTransaction.ts b/libs/coin-modules/coin-solana/src/prepareTransaction.ts index ae71cfe1754b..1edc967a2143 100644 --- a/libs/coin-modules/coin-solana/src/prepareTransaction.ts +++ b/libs/coin-modules/coin-solana/src/prepareTransaction.ts @@ -17,6 +17,7 @@ import { } from "./api/chain/web3"; import { SolanaAccountNotFunded, + SolanaTokenAccountFrozen, SolanaAddressOffEd25519, SolanaInvalidValidator, SolanaMemoIsTooLong, @@ -44,6 +45,7 @@ import type { CommandDescriptor, SolanaAccount, SolanaStake, + SolanaTokenAccount, StakeCreateAccountTransaction, StakeDelegateTransaction, StakeSplitTransaction, @@ -126,6 +128,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; @@ -232,6 +238,17 @@ async function getTokenRecipient( api, )); + if (!shouldCreateAsAssociatedTokenAccount) { + const associatedTokenAccount = await getMaybeTokenAccount( + recipientAssociatedTokenAccountAddress, + api, + ); + if (associatedTokenAccount instanceof Error) throw recipientTokenAccount; + if (associatedTokenAccount?.state === "frozen") { + return new SolanaTokenAccountFrozen(); + } + } + return { walletAddress: recipientAddress, shouldCreateAsAssociatedTokenAccount, @@ -241,6 +258,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/coin-modules/coin-solana/src/serialization.ts b/libs/coin-modules/coin-solana/src/serialization.ts index d280789fb28e..d111910644a9 100644 --- a/libs/coin-modules/coin-solana/src/serialization.ts +++ b/libs/coin-modules/coin-solana/src/serialization.ts @@ -5,8 +5,17 @@ import { SolanaOperationExtraRaw, SolanaResources, SolanaResourcesRaw, + SolanaTokenAccount, + SolanaTokenAccountRaw, } from "./types"; -import { Account, AccountRaw, OperationExtra, OperationExtraRaw } from "@ledgerhq/types-live"; +import { + Account, + AccountRaw, + OperationExtra, + OperationExtraRaw, + TokenAccount, + TokenAccountRaw, +} from "@ledgerhq/types-live"; import { BigNumber } from "bignumber.js"; export function toSolanaResourcesRaw(resources: SolanaResources): SolanaResourcesRaw { @@ -79,3 +88,23 @@ export function toOperationExtraRaw(extra: OperationExtra): OperationExtraRaw { function isExtraValid(extra: OperationExtra | OperationExtraRaw): boolean { return !!extra && typeof extra === "object"; } + +export function assignToTokenAccountRaw( + tokenAccount: TokenAccount, + tokenAccountRaw: TokenAccountRaw, +) { + const solanaTokenAccount = tokenAccount as SolanaTokenAccount; + if (solanaTokenAccount.state) { + (tokenAccountRaw as SolanaTokenAccountRaw).state = solanaTokenAccount.state; + } +} + +export function assignFromTokenAccountRaw( + tokenAccountRaw: TokenAccountRaw, + tokenAccount: TokenAccount, +) { + const stateRaw = (tokenAccountRaw as SolanaTokenAccountRaw).state; + if (stateRaw) { + (tokenAccount as SolanaTokenAccount).state = stateRaw; + } +} diff --git a/libs/coin-modules/coin-solana/src/specs.ts b/libs/coin-modules/coin-solana/src/specs.ts index d33a8fbdfb61..b21a3bbca124 100644 --- a/libs/coin-modules/coin-solana/src/specs.ts +++ b/libs/coin-modules/coin-solana/src/specs.ts @@ -15,12 +15,16 @@ import { acceptStakeDelegateTransaction, acceptStakeUndelegateTransaction, acceptStakeWithdrawTransaction, + acceptTransferTokensTransaction, + acceptTransferTokensWithATACreationTransaction, acceptTransferTransaction, } from "./speculos-deviceActions"; -import { assertUnreachable } from "./utils"; +import { SYSTEM_ACCOUNT_RENT_EXEMPT, assertUnreachable } from "./utils"; import { getCurrentSolanaPreloadData } from "./preload-data"; import { sample } from "lodash/fp"; import BigNumber from "bignumber.js"; +import { Account, TokenAccount } from "@ledgerhq/types-live"; +import { SolanaRecipientAssociatedTokenAccountWillBeFunded } from "./errors"; const maxAccount = 9; @@ -73,8 +77,16 @@ const solana: AppSpec = { }; }, test: input => { - const { account } = input; - botTest("account balance should be zero", () => + const { accountBeforeTransaction, account, operation } = input; + const extimatedMaxSpendable = BigNumber.max( + accountBeforeTransaction.spendableBalance.minus(SYSTEM_ACCOUNT_RENT_EXEMPT), + 0, + ).toNumber(); + + botTest("operation value should be estimated max spendable", () => + expect(operation.value.toNumber()).toBe(extimatedMaxSpendable), + ); + botTest("account spendableBalance should be zero", () => expect(account.spendableBalance.toNumber()).toBe(0), ); expectCorrectBalanceChange(input); @@ -461,6 +473,66 @@ const solana: AppSpec = { botTest("delegation exists", () => expect(delegationExists).toBe(false)); }, }, + { + name: "Transfer ~50% of spl token with ATA creation", + maxRun: 1, + deviceAction: acceptTransferTokensWithATACreationTransaction, + transaction: ({ account, bridge, siblings, maxSpendable }) => { + invariant(maxSpendable.gt(0), "balance is 0"); + + const senderTokenAcc = findTokenSubAccountWithBalance(account); + invariant(senderTokenAcc, "Sender token account with available balance not found"); + + const token = senderTokenAcc.token; + const siblingWithoutToken = siblings.find(acc => !findTokenSubAccount(acc, token.id)); + invariant(siblingWithoutToken, `Recipient without ${token.ticker} ATA not found`); + + const amount = senderTokenAcc.balance.div(1.9 + 0.2 * Math.random()).integerValue(); + const recipient = siblingWithoutToken.freshAddress; + const transaction = bridge.createTransaction(account); + const subAccountId = senderTokenAcc.id; + + return { + transaction, + updates: [{ subAccountId }, { recipient }, { amount }], + }; + }, + expectStatusWarnings: _ => { + return { + recipient: new SolanaRecipientAssociatedTokenAccountWillBeFunded(), + }; + }, + test: input => { + expectTokenAccountCorrectBalanceChange(input); + }, + }, + { + name: "Transfer ~50% of spl token to existing ATA", + maxRun: 1, + deviceAction: acceptTransferTokensTransaction, + transaction: ({ account, bridge, siblings, maxSpendable }) => { + invariant(maxSpendable.gt(0), "balance is 0"); + + const senderTokenAcc = findTokenSubAccountWithBalance(account); + invariant(senderTokenAcc, "Sender token account with available balance not found"); + + const token = senderTokenAcc.token; + const siblingTokenAccount = siblings.find(acc => findTokenSubAccount(acc, token.id)); + invariant(siblingTokenAccount, `Sibling with ${token.ticker} token ATA not found`); + + const amount = senderTokenAcc.balance.div(1.9 + 0.2 * Math.random()).integerValue(); + const recipient = siblingTokenAccount.freshAddress; + const transaction = bridge.createTransaction(account); + const subAccountId = senderTokenAcc.id; + return { + transaction, + updates: [{ subAccountId }, { recipient }, { amount }], + }; + }, + test: input => { + expectTokenAccountCorrectBalanceChange(input); + }, + }, ], }; @@ -509,6 +581,40 @@ function expectCorrectBalanceChange(input: TransactionTestInput) { ); } +function expectTokenAccountCorrectBalanceChange({ + account, + accountBeforeTransaction, + status, + transaction, +}: TransactionTestInput) { + const tokenAccId = transaction.subAccountId; + if (!tokenAccId) throw new Error("Wrong transaction!"); + const tokenAccAfterTx = account.subAccounts?.find(acc => acc.id === tokenAccId); + const tokenAccBeforeTx = accountBeforeTransaction.subAccounts?.find(acc => acc.id === tokenAccId); + + if (!tokenAccAfterTx || !tokenAccBeforeTx) { + throw new Error("token sub accounts not found!"); + } + + botTest("token balance decreased with operation", () => + expect(tokenAccAfterTx.balance.toString()).toBe( + tokenAccBeforeTx.balance.minus(status.amount).toString(), + ), + ); +} + +function findTokenSubAccount(account: Account, tokenId: string) { + return account.subAccounts?.find( + acc => acc.type === "TokenAccount" && acc.token.id === tokenId, + ) as TokenAccount | undefined; +} + +function findTokenSubAccountWithBalance(account: Account) { + return account.subAccounts?.find(acc => acc.type === "TokenAccount" && acc.balance.gt(0)) as + | TokenAccount + | undefined; +} + export default { solana, }; diff --git a/libs/coin-modules/coin-solana/src/speculos-deviceActions.ts b/libs/coin-modules/coin-solana/src/speculos-deviceActions.ts index 663e7e47ec1c..532536e33d50 100644 --- a/libs/coin-modules/coin-solana/src/speculos-deviceActions.ts +++ b/libs/coin-modules/coin-solana/src/speculos-deviceActions.ts @@ -1,3 +1,4 @@ +import { Account } from "@ledgerhq/types-live"; import type { DeviceAction } from "@ledgerhq/coin-framework/bot/types"; import type { Transaction } from "./types"; import { @@ -7,7 +8,8 @@ import { } from "@ledgerhq/coin-framework/bot/specs"; import { getCryptoCurrencyById } from "@ledgerhq/cryptoassets"; import BigNumber from "bignumber.js"; -import type { CryptoCurrency } from "@ledgerhq/types-cryptoassets"; +import type { CryptoCurrency, TokenCurrency } from "@ledgerhq/types-cryptoassets"; +import { findSubAccountById } from "@ledgerhq/coin-framework/lib/account/helpers"; function getMainCurrency(currency: CryptoCurrency) { if (currency.isTestnetFor !== undefined) { @@ -20,13 +22,25 @@ function ellipsis(str: string) { return `${str.slice(0, 7)}..${str.slice(-7)}`; } -function formatAmount(c: CryptoCurrency, amount: number) { - const currency = getMainCurrency(c); +function formatAmount(c: CryptoCurrency | TokenCurrency, amount: number) { + const currency = c.type === "CryptoCurrency" ? getMainCurrency(c) : c; return formatDeviceAmount(currency, new BigNumber(amount), { postfixCode: true, }); } +function formatTokenAmount(c: TokenCurrency, amount: number) { + return formatAmount(c, amount); +} + +function findTokenAccount(account: Account, id: string) { + const tokenAccount = findSubAccountById(account, id); + if (!tokenAccount || tokenAccount.type !== "TokenAccount") { + throw new Error("expected token account " + id); + } + return tokenAccount; +} + export const acceptTransferTransaction: DeviceAction = deviceActionFlow({ steps: [ { @@ -224,6 +238,161 @@ export const acceptStakeWithdrawTransaction: DeviceAction = de ], }); +export const acceptTransferTokensTransaction: DeviceAction = deviceActionFlow({ + steps: [ + { + title: "Transfer tokens", + button: SpeculosButton.RIGHT, + expectedValue: ({ account, transaction }) => { + const command = transaction.model.commandDescriptor?.command; + if (command?.kind === "token.transfer" && transaction.subAccountId) { + const tokenCurrency = findTokenAccount(account, transaction.subAccountId).token; + return formatTokenAmount(tokenCurrency, command.amount); + } + throwUnexpectedTransaction(); + }, + }, + { + title: "Mint", + button: SpeculosButton.RIGHT, + expectedValue: ({ transaction }) => { + const command = transaction.model.commandDescriptor?.command; + if (command?.kind === "token.transfer") { + return ellipsis(command.mintAddress); + } + throwUnexpectedTransaction(); + }, + }, + { + title: "From", + button: SpeculosButton.RIGHT, + expectedValue: ({ transaction }) => { + const command = transaction.model.commandDescriptor?.command; + if (command?.kind === "token.transfer") { + return ellipsis(command.ownerAssociatedTokenAccountAddress); + } + throwUnexpectedTransaction(); + }, + }, + { + title: "To", + button: SpeculosButton.RIGHT, + expectedValue: ({ transaction }) => { + const command = transaction.model.commandDescriptor?.command; + if (command?.kind === "token.transfer") { + return ellipsis(command.recipientDescriptor.tokenAccAddress); + } + throwUnexpectedTransaction(); + }, + }, + { + title: "Approve", + button: SpeculosButton.BOTH, + final: true, + }, + ], +}); + +export const acceptTransferTokensWithATACreationTransaction: DeviceAction = + deviceActionFlow({ + steps: [ + { + title: "Create token acct", + button: SpeculosButton.RIGHT, + expectedValue: ({ transaction }) => { + const command = transaction.model.commandDescriptor?.command; + if (command?.kind === "token.transfer") { + return ellipsis(command.recipientDescriptor.tokenAccAddress); + } + throwUnexpectedTransaction(); + }, + }, + { + title: "From mint", + button: SpeculosButton.RIGHT, + expectedValue: ({ transaction }) => { + const command = transaction.model.commandDescriptor?.command; + if (command?.kind === "token.transfer") { + return ellipsis(command.mintAddress); + } + throwUnexpectedTransaction(); + }, + }, + { + title: "Owned by", + button: SpeculosButton.RIGHT, + expectedValue: ({ transaction }) => { + const command = transaction.model.commandDescriptor?.command; + if (command?.kind === "token.transfer") { + return ellipsis(command.recipientDescriptor.walletAddress); + } + throwUnexpectedTransaction(); + }, + }, + { + title: "Funded by", + button: SpeculosButton.RIGHT, + expectedValue: ({ transaction }) => { + const command = transaction.model.commandDescriptor?.command; + if (command?.kind === "token.transfer") { + return ellipsis(command.ownerAddress); + } + throwUnexpectedTransaction(); + }, + }, + { + title: "Transfer tokens", + button: SpeculosButton.RIGHT, + expectedValue: ({ account, transaction }) => { + const command = transaction.model.commandDescriptor?.command; + if (command?.kind === "token.transfer" && transaction.subAccountId) { + const tokenCurrency = findTokenAccount(account, transaction.subAccountId).token; + return formatTokenAmount(tokenCurrency, command.amount); + } + throwUnexpectedTransaction(); + }, + }, + { + title: "Mint", + button: SpeculosButton.RIGHT, + expectedValue: ({ transaction }) => { + const command = transaction.model.commandDescriptor?.command; + if (command?.kind === "token.transfer") { + return ellipsis(command.mintAddress); + } + throwUnexpectedTransaction(); + }, + }, + { + title: "From", + button: SpeculosButton.RIGHT, + expectedValue: ({ transaction }) => { + const command = transaction.model.commandDescriptor?.command; + if (command?.kind === "token.transfer") { + return ellipsis(command.ownerAssociatedTokenAccountAddress); + } + throwUnexpectedTransaction(); + }, + }, + { + title: "To", + button: SpeculosButton.RIGHT, + expectedValue: ({ transaction }) => { + const command = transaction.model.commandDescriptor?.command; + if (command?.kind === "token.transfer") { + return ellipsis(command.recipientDescriptor.tokenAccAddress); + } + throwUnexpectedTransaction(); + }, + }, + { + title: "Approve", + button: SpeculosButton.BOTH, + final: true, + }, + ], + }); + function throwUnexpectedTransaction(): never { throw new Error("unexpected or unprepared transaction"); } diff --git a/libs/coin-modules/coin-solana/src/synchronization.ts b/libs/coin-modules/coin-solana/src/synchronization.ts index 348956645c07..8a906eac8a16 100644 --- a/libs/coin-modules/coin-solana/src/synchronization.ts +++ b/libs/coin-modules/coin-solana/src/synchronization.ts @@ -34,22 +34,12 @@ import { sum, } from "lodash/fp"; import { parseQuiet } from "./api/chain/program"; -import { - InflationReward, - ParsedTransactionMeta, - ParsedMessageAccount, - ParsedTransaction, - StakeActivationData, -} from "@solana/web3.js"; +import { InflationReward, ParsedTransaction, StakeActivationData } from "@solana/web3.js"; import { ChainAPI } from "./api"; -import { - ParsedOnChainTokenAccountWithInfo, - /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ - toTokenAccountWithInfo, -} from "./api/chain/web3"; +import { ParsedOnChainTokenAccountWithInfo, toTokenAccountWithInfo } from "./api/chain/web3"; import { drainSeq } from "./utils"; import { estimateTxFee } from "./tx-fees"; -import { SolanaAccount, SolanaOperationExtra, SolanaStake } from "./types"; +import { SolanaAccount, SolanaOperationExtra, SolanaStake, SolanaTokenAccount } from "./types"; import { Operation, OperationType, TokenAccount } from "@ledgerhq/types-live"; import { DelegateInfo, WithdrawInfo } from "./api/chain/instruction/stake/types"; @@ -113,7 +103,7 @@ export const getAccountShapeWithAPI = async ( const subAcc = subAccByMint.get(mint); - const lastSyncedTxSignature = subAcc?.operations?.[0].hash; + const lastSyncedTxSignature = subAcc?.operations?.[0]?.hash; const txs = await getTransactions( assocTokenAcc.onChainAcc.pubkey.toBase58(), @@ -232,9 +222,7 @@ export const getAccountShapeWithAPI = async ( } const shape: Partial = { - // uncomment when tokens are supported - // subAccounts as undefined makes TokenList disappear in desktop - //subAccounts: nextSubAccs, + subAccounts: nextSubAccs, id: mainAccountId, blockHeight, balance: mainAccBalance.plus(totalStakedBalance), @@ -264,7 +252,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); @@ -295,6 +283,7 @@ function newSubAcc({ spendableBalance: balance, swapHistory: [], token: tokenCurrency, + state: assocTokenAcc.info.state, type: "TokenAccount", }; } @@ -307,7 +296,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))); @@ -319,6 +308,7 @@ function patchedSubAcc({ balance, spendableBalance: balance, operations: totalOps, + state: assocTokenAcc.info.state, }; } @@ -358,26 +348,7 @@ function txToMainAccOperation( balanceDelta, }); - const accum = { - senders: [] as string[], - recipients: [] as string[], - }; - - const { senders, recipients } = - opType === "IN" || opType === "OUT" - ? message.accountKeys.reduce((acc, account, i) => { - const delta = new BigNumber(postBalances[i]).minus(new BigNumber(preBalances[i])); - if (delta.lt(0)) { - const shouldConsiderAsSender = i > 0 || !delta.negated().eq(txFee); - if (shouldConsiderAsSender) { - acc.senders.push(account.pubkey.toBase58()); - } - } else if (delta.gt(0)) { - acc.recipients.push(account.pubkey.toBase58()); - } - return acc; - }, accum) - : accum; + const { senders, recipients } = getMainSendersRecipients(tx, opType, txFee, accountAddress); const txHash = tx.info.signature; const txDate = new Date(tx.info.blockTime * 1000); @@ -467,10 +438,16 @@ function txToTokenAccOperation( return undefined; } + const { message } = tx.parsed.transaction; const assocTokenAccIndex = tx.parsed.transaction.message.accountKeys.findIndex(v => v.pubkey.equals(assocTokenAcc.onChainAcc.pubkey), ); + const accountOwner = assocTokenAcc.info.owner.toBase58(); + const accountOwnerIndex = message.accountKeys.findIndex( + pma => pma.pubkey.toBase58() === accountOwner, + ); + if (assocTokenAccIndex < 0) { return undefined; } @@ -485,14 +462,15 @@ function txToTokenAccOperation( new BigNumber(preTokenBalance?.uiTokenAmount.amount ?? 0), ); + const isFeePayer = accountOwnerIndex === 0; + const txFee = new BigNumber(tx.parsed.meta.fee); + const opType = getTokenAccOperationType({ tx: tx.parsed.transaction, delta }); const txHash = tx.info.signature; + const opFee = isFeePayer ? txFee : new BigNumber(0); - const { senders, recipients } = getTokenSendersRecipients({ - meta: tx.parsed.meta, - accounts: tx.parsed.transaction.message.accountKeys, - }); + const { senders, recipients } = getTokenSendersRecipients(tx); return { id: encodeOperationId(accountId, txHash, opType), @@ -501,7 +479,7 @@ function txToTokenAccOperation( hash: txHash, date: new Date(tx.info.blockTime * 1000), blockHeight: tx.info.slot, - fee: new BigNumber(0), + fee: opFee, recipients, senders, value: delta.abs(), @@ -537,12 +515,63 @@ function getMainAccOperationType({ : "NONE"; } -function getMainAccOperationTypeFromTx(tx: ParsedTransaction): OperationType | undefined { - const { instructions } = tx.message; +function getMainSendersRecipients( + tx: TransactionDescriptor, + opType: OperationType, + txFee: BigNumber, + accountAddress: string, +) { + const initialSendersRecipients = { + senders: [] as string[], + recipients: [] as string[], + }; + if (!tx.parsed.meta) { + return initialSendersRecipients; + } - const parsedIxs = instructions - .map(ix => parseQuiet(ix)) - .filter(({ program }) => program !== "spl-memo" && program !== "unknown"); + const { message } = tx.parsed.transaction; + const { postTokenBalances, preBalances, postBalances } = tx.parsed.meta; + + if (opType === "FEES") { + // SPL transfer to existing ATA. FEES operation is shown for the main account + return getTokenSendersRecipients(tx); + } + + if (opType === "OPT_IN") { + // Associated token account created + const incomingTokens = + postTokenBalances?.filter(tokenBalance => tokenBalance.owner === accountAddress) || []; + + initialSendersRecipients.senders = incomingTokens.map(token => token.mint); + initialSendersRecipients.recipients = incomingTokens.map(token => { + return message.accountKeys[token.accountIndex].pubkey.toBase58(); + }) || [accountAddress]; + + return initialSendersRecipients; + } + + if (opType === "IN" || opType === "OUT") { + const isAccFeePayer = (accIndex: number) => accIndex === 0; + + return message.accountKeys.reduce((acc, account, i) => { + const delta = new BigNumber(postBalances[i]).minus(new BigNumber(preBalances[i])); + if (delta.lt(0)) { + const shouldConsiderAsSender = !isAccFeePayer(i) || !delta.negated().eq(txFee); + if (shouldConsiderAsSender) { + acc.senders.push(account.pubkey.toBase58()); + } + } else if (delta.gt(0)) { + acc.recipients.push(account.pubkey.toBase58()); + } + return acc; + }, initialSendersRecipients); + } + + return initialSendersRecipients; +} + +function getMainAccOperationTypeFromTx(tx: ParsedTransaction): OperationType | undefined { + const parsedIxs = parseTxInstructions(tx); if (parsedIxs.length === 3) { const [first, second, third] = parsedIxs; @@ -574,6 +603,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": @@ -594,36 +627,34 @@ function getMainAccOperationTypeFromTx(tx: ParsedTransaction): OperationType | u return undefined; } -function getTokenSendersRecipients({ - meta, - accounts, -}: { - meta: ParsedTransactionMeta; - accounts: ParsedMessageAccount[]; -}) { - const { preTokenBalances, postTokenBalances } = meta; - return accounts.reduce( - (accum, account, i) => { - const preTokenBalance = preTokenBalances?.find(b => b.accountIndex === i); - const postTokenBalance = postTokenBalances?.find(b => b.accountIndex === i); - if (preTokenBalance && postTokenBalance) { - const tokenDelta = new BigNumber(postTokenBalance.uiTokenAmount.amount).minus( - new BigNumber(preTokenBalance.uiTokenAmount.amount), - ); - - if (tokenDelta.lt(0)) { - accum.senders.push(account.pubkey.toBase58()); - } else if (tokenDelta.gt(0)) { - accum.recipients.push(account.pubkey.toBase58()); - } +function getTokenSendersRecipients(tx: TransactionDescriptor) { + const initialSendersRecipients = { + senders: [] as string[], + recipients: [] as string[], + }; + + if (!tx.parsed.meta) { + return initialSendersRecipients; + } + + const accounts = tx.parsed.transaction.message.accountKeys; + const { preTokenBalances, postTokenBalances } = tx.parsed.meta; + + return accounts.reduce((accum, account, i) => { + const preTokenBalance = preTokenBalances?.find(b => b.accountIndex === i); + const postTokenBalance = postTokenBalances?.find(b => b.accountIndex === i); + if (preTokenBalance || postTokenBalance) { + const tokenDelta = new BigNumber(postTokenBalance?.uiTokenAmount.amount ?? 0).minus( + new BigNumber(preTokenBalance?.uiTokenAmount.amount ?? 0), + ); + if (tokenDelta.lt(0)) { + accum.senders.push(postTokenBalance?.owner || account.pubkey.toBase58()); + } else if (tokenDelta.gt(0)) { + accum.recipients.push(postTokenBalance?.owner || account.pubkey.toBase58()); } - return accum; - }, - { - senders: [] as string[], - recipients: [] as string[], - }, - ); + } + return accum; + }, initialSendersRecipients); } function getTokenAccOperationType({ @@ -633,17 +664,25 @@ function getTokenAccOperationType({ tx: ParsedTransaction; delta: BigNumber; }): OperationType { - const { instructions } = tx.message; - const [mainIx, ...otherIxs] = instructions - .map(ix => parseQuiet(ix)) - .filter(({ program }) => program !== "spl-memo" && program !== "unknown"); + const parsedIxs = parseTxInstructions(tx); + const [mainIx, ...otherIxs] = parsedIxs; if (mainIx !== undefined && otherIxs.length === 0) { switch (mainIx.program) { case "spl-associated-token-account": switch (mainIx.instruction.type) { case "associate": - return "OPT_IN"; + 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"; + case "burn": + return "BURN"; } } } @@ -652,6 +691,12 @@ function getTokenAccOperationType({ return fallbackType; } +function parseTxInstructions(tx: ParsedTransaction) { + return tx.message.instructions + .map(ix => parseQuiet(ix)) + .filter(({ program }) => program !== "spl-memo" && program !== "unknown"); +} + function dropMemoLengthPrefixIfAny(memo: string) { const lengthPrefixMatch = memo.match(/^\[(\d+)\]\s/); if (lengthPrefixMatch) { @@ -678,14 +723,10 @@ async function getAccount( }> { const balanceLamportsWithContext = await api.getBalanceAndContext(address); - const tokenAccounts: ParsedOnChainTokenAccountWithInfo[] = []; - - // no tokens for the first release - /*await api + const tokenAccounts = await api .getParsedTokenAccountsByOwner(address) - .then((res) => res.value) + .then(res => res.value) .then(map(toTokenAccountWithInfo)); - */ const stakeAccountsRaw = [ // ...(await api.getStakeAccountsByStakeAuth(address)), diff --git a/libs/coin-modules/coin-solana/src/tx-fees.ts b/libs/coin-modules/coin-solana/src/tx-fees.ts index b25bdbfb363f..ee177ad2e5d5 100644 --- a/libs/coin-modules/coin-solana/src/tx-fees.ts +++ b/libs/coin-modules/coin-solana/src/tx-fees.ts @@ -49,9 +49,10 @@ const createDummyTx = (address: string, kind: TransactionModel["kind"]) => { return createDummyStakeUndelegateTx(address); case "stake.withdraw": return createDummyStakeWithdrawTx(address); + case "token.transfer": + return createDummyTokenTransferTx(address); case "stake.split": case "token.createATA": - case "token.transfer": throw new Error(`not implemented for <${kind}>`); default: return assertUnreachable(kind); @@ -158,6 +159,32 @@ const createDummyStakeWithdrawTx = (address: string): Transaction => { }; }; +const createDummyTokenTransferTx = (address: string): Transaction => { + return { + ...createTransaction({} as any), + model: { + kind: "token.transfer", + uiState: {} as any, + commandDescriptor: { + command: { + kind: "token.transfer", + amount: 0, + mintAddress: randomAddresses[0], + mintDecimals: 0, + ownerAddress: address, + ownerAssociatedTokenAccountAddress: randomAddresses[1], + recipientDescriptor: { + walletAddress: randomAddresses[1], + tokenAccAddress: randomAddresses[2], + shouldCreateAsAssociatedTokenAccount: true, + }, + }, + ...commandDescriptorCommons, + }, + }, + }; +}; + const commandDescriptorCommons = { errors: {}, fee: 0, diff --git a/libs/coin-modules/coin-solana/src/types.ts b/libs/coin-modules/coin-solana/src/types.ts index 765a3a3a9a93..310d30d6f486 100644 --- a/libs/coin-modules/coin-solana/src/types.ts +++ b/libs/coin-modules/coin-solana/src/types.ts @@ -3,12 +3,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"; @@ -262,6 +265,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; diff --git a/libs/coin-modules/coin-solana/src/utils.ts b/libs/coin-modules/coin-solana/src/utils.ts index 15b6f00492af..3ecd31fcb7bf 100644 --- a/libs/coin-modules/coin-solana/src/utils.ts +++ b/libs/coin-modules/coin-solana/src/utils.ts @@ -18,6 +18,7 @@ export const LEDGER_VALIDATOR: ValidatorsAppValidator = { }; export const SOLANA_DELEGATION_RESERVE = 0.01; +export const SYSTEM_ACCOUNT_RENT_EXEMPT = 890880; export const assertUnreachable = (_: never): never => { throw new Error("unreachable assertion failed"); diff --git a/libs/ledger-live-common/src/account/serialization.test.ts b/libs/ledger-live-common/src/account/serialization.test.ts new file mode 100644 index 000000000000..0aa979de1482 --- /dev/null +++ b/libs/ledger-live-common/src/account/serialization.test.ts @@ -0,0 +1,26 @@ +import { getCryptoCurrencyById, getTokenById, setSupportedCurrencies } from "../currencies"; +import { genAccount, genTokenAccount } from "@ledgerhq/coin-framework/mocks/account"; +import { toAccountRaw, fromAccountRaw } from "./serialization"; +import { setWalletAPIVersion } from "../wallet-api/version"; +import { WALLET_API_VERSION } from "../wallet-api/constants"; + +setWalletAPIVersion(WALLET_API_VERSION); + +setSupportedCurrencies(["solana"]); +const Solana = getCryptoCurrencyById("solana"); +const USDC = getTokenById("solana/spl/EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); + +describe("serialization", () => { + test("TokenAccount extra fields should be serialized/deserialized", () => { + const acc: any = genAccount("mocked-account-1", { currency: Solana }); + const tokenAcc: any = genTokenAccount(1, acc, USDC); + tokenAcc.state = "initialized"; + acc.subAccounts = [tokenAcc]; + + const accRaw: any = toAccountRaw(acc); + expect(accRaw.subAccounts?.[0]?.state).toBe("initialized"); + + const deserializedAcc: any = fromAccountRaw(accRaw); + expect(deserializedAcc.subAccounts?.[0]?.state).toBe("initialized"); + }); +}); diff --git a/libs/ledger-live-common/src/account/serialization.ts b/libs/ledger-live-common/src/account/serialization.ts index 995b6088933f..197b8502abe0 100644 --- a/libs/ledger-live-common/src/account/serialization.ts +++ b/libs/ledger-live-common/src/account/serialization.ts @@ -68,6 +68,7 @@ export function fromAccountRaw(rawAccount: AccountRaw): Account { return commonFromAccountRaw(rawAccount, { assignFromAccountRaw: bridge.assignFromAccountRaw, + assignFromTokenAccountRaw: bridge.assignFromTokenAccountRaw, fromOperationExtraRaw: bridge.fromOperationExtraRaw, }); } @@ -77,6 +78,7 @@ export function toAccountRaw(account: Account, userData?: AccountUserData): Acco const commonAccountRaw = commonToAccountRaw(account, { assignToAccountRaw: bridge.assignToAccountRaw, + assignToTokenAccountRaw: bridge.assignToTokenAccountRaw, toOperationExtraRaw: bridge.toOperationExtraRaw, }); diff --git a/libs/ledger-live-common/src/currencies/__snapshots__/sortByMarketcap.test.ts.snap b/libs/ledger-live-common/src/currencies/__snapshots__/sortByMarketcap.test.ts.snap index 930ff82c6865..55c57b7a7e17 100644 --- a/libs/ledger-live-common/src/currencies/__snapshots__/sortByMarketcap.test.ts.snap +++ b/libs/ledger-live-common/src/currencies/__snapshots__/sortByMarketcap.test.ts.snap @@ -922,6 +922,7 @@ exports[`sortCurrenciesByIds snapshot 1`] = ` "ethereum/erc20/ksm_starter_token", "ethereum/erc20/aditus", "ethereum/erc20/ethopt_io", + "solana/spl/SHDWyBxihqiCj6YekG2GUr7wqKLeLAMK1gHZck9pL6y", "ethereum/erc20/bigboom", "ethereum/erc20/realchain", "ethereum/erc20/ink_protocol", @@ -15556,5 +15557,98 @@ exports[`sortCurrenciesByIds snapshot 1`] = ` "filecoin/erc20/pfil_token", "filecoin/erc20/wrapped_fil", "filecoin/erc20/wrapped_pfil_token", + "solana/spl/2VhjJ9WxaGC3EZFwJG9BDUs9KxKCAjQY4vgd1qxgYWVg", + "solana/spl/35r2jMGKytAJ7FyKfKRHPanT8kpjg3emPy7WG6GANCNB", + "solana/spl/3bRTivrVsitbmCTGtqwp7hxXPsybkjn4XLNtPsHqa3zR", + "solana/spl/3dgCCb15HMQSA4Pn3Tfii5vRk7aRqTH95LJjxzsG2Mug", + "solana/spl/4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R", + "solana/spl/4vMsoUT2BWatFweudnQM1xedRLfJgJ7hswhcpz4xgBTy", + "solana/spl/5MAYDfq5yxtudAhtfyuMBuHZjgAbaS9tbEyEQYAhDS5y", + "solana/spl/5oVNBeEEQvYi1cX3ir8Dx5n1P7pdxydbGF2X4TxVusJm", + "solana/spl/5tB5D6DGJMxxHYmNkfJNG237x6pZGEwTzGpUUh62yQJ7", + "solana/spl/6cVgJUqo4nmvQpbgrDZwyfd6RwWw5bfnCamS3M9N1fd", + "solana/spl/6DNSN2BJsaPFdFFc1zP37kkeNe4Usc1Sqkzr9C9vPWcU", + "solana/spl/6LX8BhMQ4Sy2otmAWj7Y5sKd9YTVVUgfMsBzT6B9W7ct", + "solana/spl/6VNKqgz9hk7zRShTFdg5AnkfKwZUcojzwAkzxSH3bnUm", + "solana/spl/7dHbWXmci3dT8UFYWYZweBLXgycu7Y3iL6trKn1Y7ARj", + "solana/spl/7i5KKsX2weiTkry7jA4ZwSuXGhs5eJBEjY8vVxR4pfRx", + "solana/spl/7kbnvuGBxxj8AG9qp8Scn56muWGaRaFqxg1FsRp3PaFT", + "solana/spl/7Q2afV64in6N6SeZsAAB81TJzwDoD6zpqmHkzi9Dcavn", + "solana/spl/7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU", + "solana/spl/9ET2QCQJdFkeKkuaampNbmicbA8eLYauFCWch9Ddh9p5", + "solana/spl/9mWRABuz2x6koTPCWiCPM49WUbcrNqGTHBV9T9k7y1o7", + "solana/spl/9nEqaUcb16sQ3Tn1psbkWqyhPdLmfHWjKGymREjsAgTE", + "solana/spl/a11bdAAuV8iB2fu7X6AxAvDTo1QZ8FXB3kk5eecdasp", + "solana/spl/A94X2fRy3wydNShU4dRaDyap2UuoeWJGWyATtyp61WZf", + "solana/spl/AFbX8oGjGpmVFywbVouvhQSRmiW2aR1mohfahi4Y2AdB", + "solana/spl/ATLASXmbPQxBUYbxPsV97usA3fPQYEqzQBUHgiFCUsXx", + "solana/spl/AURYydfxJib1ZkTir1Jn1J9ECYUtjb6rKQVmtYaixWPP", + "solana/spl/AZsHEMXd36Bj1EMNXhowJajpUXzrKcK57wW4ZGXVa7yR", + "solana/spl/BgwQjVNMWvt2d8CN51CsbniwRWyZ9H9HfHkEsvikeVuZ", + "solana/spl/BiDB55p4G3n1fGhwKFpxsokBMqgctL4qnZpDH1bVQxMD", + "solana/spl/BKipkearSqAUdNKa1WDstvcMjoPsSKBuNyvKDQDDu9WE", + "solana/spl/BLT1noyNr3GttckEVrtcfC6oyK6yV1DpPgSyXbncMwef", + "solana/spl/bSo13r4TkiE4KumL71LsHTPpL2euBYLFx6h9HP3piy1", + "solana/spl/C98A4nkJXhpVZNAZdHUA95RpTF3T4whtQubL3YobiUX9", + "solana/spl/ChVzxWRmrTeSgwd3Ui3UumcN8KX7VK3WaD4KGeSKpypj", + "solana/spl/CKaKtYvz6dKPyMvYq9Rh3UBrnNqYZAyd7iF4hJtjUvks", + "solana/spl/CRWNYkqdgvhGGae9CKfNka58j6QQkaD5bLhKXvUYqnc1", + "solana/spl/CWBzupvyXN1Cf5rsBEHbzfTFvreLfUaJ77BMNLVJ739y", + "solana/spl/DAtU322C23YpoZyWBm8szk12QyqHa9rUQe1EYXzbm1JE", + "solana/spl/DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263", + "solana/spl/DFL1zNkaGPWm1BqAVqRjCZvHmwTFrEaJtbzJWgseoNJh", + "solana/spl/DkNihsQs1hqEwf9TgKP8FmGv7dmMQ7hnKjS2ZSmMZZBE", + "solana/spl/DUSTawucrTsGU8hcqRdHDCbuYhCPADMLM2VcCb8VnFnQ", + "solana/spl/E5rk3nmgLUuKUiS94gg4bpWwWwyjCMtddsAXkTFLtHEy", + "solana/spl/Ea5SjE2Y6yvCeW5dYTn7PYMuW5ikXkvbGdcmSnXeaLjS", + "solana/spl/EchesyfXePKdLtoiZSL8pBe8Myagyy8ZRqsACNCFGnvp", + "solana/spl/EcQCUYv57C4V6RoPxkVUiDwtX1SP8y8FP5AEToYL8Az", + "solana/spl/EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "solana/spl/Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", + "solana/spl/ETAtLmCmsoiEEKfNrHKJ2kYy3MoABhU6NQvpSfij5tDs", + "solana/spl/F3nefJBcejYbtdREjui1T9DPh5dBgpkKq7u2GAAMXs5B", + "solana/spl/Fm9rHUTF5v3hwMLbStjZXqNBBoZyGriQaFM6sTFz3K8A", + "solana/spl/FR87nWEUxVgerFGhZM8Y4AggKGLnaXswr1Pd8wZ4kZcp", + "solana/spl/GDfnEsia2WLAW5t8yx2X5j2mkfA74i5kwGdDuZHt7XmG", + "solana/spl/GDsVXtyt2CBwieKSYMEsjjZXXvqz2G2VwudD7EvXzoEU", + "solana/spl/GENEtH5amGSi8kHAtQoezp1XEXwZJ8vcuePYnXdKrMYz", + "solana/spl/GFX1ZjR2P15tmrSwow6FjyDYcEkoFb4p4gJCpLBjaxHD", + "solana/spl/GsNzxJfFn6zQdJGeYsupJWzUAm57Ba7335mfhWvFiE9Z", + "solana/spl/HBB111SCo9jkCejsZfz8Ec8nH7T6THF8KEKSnvwT6XK6", + "solana/spl/HHjoYwUp5aU6pnrvN4s2pwEErwXNZKhxKGYjRJMoBjLw", + "solana/spl/HhJpBhRRn4g56VsyLuT8DL5Bv31HkXqsrahTTUCZeZg4", + "solana/spl/hntyVP6YFm1Hg25TN9WGLqM12b8TQmcknKrdu1oxWux", + "solana/spl/HxhWkVpk5NS4Ltg5nij2G671CKXFRKPK8vy271Ub4uEK", + "solana/spl/HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3", + "solana/spl/J1toso1uCk3RLmjorhTtrVwY9HJ7X8V9yYac6Y7kGCPn", + "solana/spl/jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL", + "solana/spl/kinXdEcpDQeHPEuQnqmUgtYykqKGVFq6CeVX5iAHJq6", + "solana/spl/MangoCzJ36AjZyKwVj3VnYU4GTonjfVEnJmvvWaxLac", + "solana/spl/MAPS41MDahZ9QdKXhVa4dWB9RuyfV4XqhyAZ8XcYepb", + "solana/spl/MEANeD3XDdUmNMsRGjASkSWdC8prLYsoRJ61pPeHctD", + "solana/spl/METAewgxyPbgwsseH8T16a39CQ5VyVxZi9zXiDPY18m", + "solana/spl/MNDEFzGvMt87ueuHvVU9VcTqsAP5b3fTGPsHuuPA5ey", + "solana/spl/mSoLzYCxHdYgdzU16g5QSh3i5K3z3KZK7ytfqcJm7So", + "solana/spl/NFTUkR4u7wKxy9QLaX2TGvd9oZSWoMo4jqSJqdMb7Nk", + "solana/spl/orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE", + "solana/spl/poLisWXnNRwC6oBu1vHiuKQzFjGL4XDSu4g9qjz9qVk", + "solana/spl/PoRTjZMPXb9T7dyU7tpLEZRQj7e6ssfAE62j2oQuc6y", + "solana/spl/PRSMNsEPqhGVCH1TtWiJqPjJyh2cKrLostPZTNy1o5x", + "solana/spl/PsyFiqqjiv41G7o5SMRzDJCu4psptThNR2GtfeGHfSq", + "solana/spl/RLBxxFkseAZ4RgJH3Sqn8jXxhmGoz9jWxDNJMh8pL7a", + "solana/spl/rndrizKT3MK1iimdxRdWabcF7Zg7AR5T4nud4EkHBof", + "solana/spl/Saber2gLauYim4Mvftnrasomsv6NvAuncvMEZwcLpD1", + "solana/spl/SCSuPPNUSypLBsV4darsrYNg4ANPgaGhKhsA3GmMyjz", + "solana/spl/SLCLww7nc1PD2gQPQdGayHviVVcpMthnqUz2iWKhNQV", + "solana/spl/SLNDpmoWTVADgEdndyvWzroNL7zSi1dF9PC3xHGtPwp", + "solana/spl/So11111111111111111111111111111111111111112", + "solana/spl/SRMuApVNdxXokk5GT7XD5cUUgXMBCoAz2LHeuAoKWRt", + "solana/spl/StepAscQoEioFxxWGnh2sLBDFp9d8rvKz2Yp39iDpyT", + "solana/spl/Taki7fi3Zicv7Du1xNAWLaf6mRK7ikdn77HeGzgwvo4", + "solana/spl/TuLipcqtGVXP9XR62wM8WWCm6a9vhLs7T1uoWBk6FDs", + "solana/spl/UXPhBoR3qG4UCiGNJfV7MqhHyFqKN68g45GoYvAeL2M", + "solana/spl/xxxxa1sKNGwFtw2kFn8XauW9xq8hBZ5kVtcSesTT9fW", + "solana/spl/z3dn17yLaGMKffVogeFHQ9zWVcXgqgf3PQnDsNs2g6M", + "solana/spl/zebeczgi5fSEtbpfQKVZKCJ3WgYXxjkMUkNNx7fLKAF", ] `; diff --git a/libs/ledger-live-common/src/families/solana/__snapshots__/bridge.integration.test.ts.snap b/libs/ledger-live-common/src/families/solana/__snapshots__/bridge.integration.test.ts.snap index d87b5fd826f3..c0a8430aad9b 100644 --- a/libs/ledger-live-common/src/families/solana/__snapshots__/bridge.integration.test.ts.snap +++ b/libs/ledger-live-common/src/families/solana/__snapshots__/bridge.integration.test.ts.snap @@ -18,10 +18,24 @@ exports[`solana currency bridge scanAccounts solana seed 1 1`] = ` "unstakeReserve": "0", }, "spendableBalance": "82498960", + "subAccounts": [], "swapHistory": [], "syncHash": undefined, "used": true, }, + { + "approvals": undefined, + "balance": "7960720", + "id": "js:2:solana:AQbkEagmPgmsdAfS4X8V8UyJnXXjVPMvjeD15etqQ3Jh:solanaMain+8RtwWeqdFz4EFuZU3MAadfYMWSdRMamjFrfq6BXkHuNN", + "operationsCount": 1, + "parentId": "js:2:solana:AQbkEagmPgmsdAfS4X8V8UyJnXXjVPMvjeD15etqQ3Jh:solanaMain", + "pendingOperations": [], + "spendableBalance": "7960720", + "state": "initialized", + "swapHistory": [], + "tokenId": "solana/spl/So11111111111111111111111111111111111111112", + "type": "TokenAccountRaw", + }, { "balance": "0", "currencyId": "solana", @@ -38,6 +52,7 @@ exports[`solana currency bridge scanAccounts solana seed 1 1`] = ` "unstakeReserve": "0", }, "spendableBalance": "0", + "subAccounts": [], "swapHistory": [], "syncHash": undefined, "used": false, @@ -102,6 +117,28 @@ exports[`solana currency bridge scanAccounts solana seed 1 2`] = ` "value": "0", }, ], + [ + { + "accountId": "js:2:solana:AQbkEagmPgmsdAfS4X8V8UyJnXXjVPMvjeD15etqQ3Jh:solanaMain+8RtwWeqdFz4EFuZU3MAadfYMWSdRMamjFrfq6BXkHuNN", + "blockHash": "9tPbgLaETEenufCt5SzXMuWijgFJj549W9j5cJLbaogn", + "blockHeight": 108521109, + "contract": undefined, + "extra": {}, + "fee": "5000", + "hasFailed": false, + "hash": "A29zPnK1jPr2tGziTnaAvSnadYR2kLCv9sPywj9FJsaEFjtpwmUonspN3WJgz4u6XWmjtVpoFsDrygEnvW51cgk", + "id": "js:2:solana:AQbkEagmPgmsdAfS4X8V8UyJnXXjVPMvjeD15etqQ3Jh:solanaMain+8RtwWeqdFz4EFuZU3MAadfYMWSdRMamjFrfq6BXkHuNN-A29zPnK1jPr2tGziTnaAvSnadYR2kLCv9sPywj9FJsaEFjtpwmUonspN3WJgz4u6XWmjtVpoFsDrygEnvW51cgk-IN", + "operator": undefined, + "recipients": [ + "8RtwWeqdFz4EFuZU3MAadfYMWSdRMamjFrfq6BXkHuNN", + ], + "senders": [], + "standard": undefined, + "tokenId": undefined, + "type": "IN", + "value": "7960720", + }, + ], [], ] `; diff --git a/libs/ledgerjs/packages/cryptoassets/src/tokens.ts b/libs/ledgerjs/packages/cryptoassets/src/tokens.ts index 527e23420e05..cac0d4f98e63 100644 --- a/libs/ledgerjs/packages/cryptoassets/src/tokens.ts +++ b/libs/ledgerjs/packages/cryptoassets/src/tokens.ts @@ -14,6 +14,7 @@ import trc20tokens, { TRC20Token } from "./data/trc20"; import { tokens as mainnetTokens } from "./data/evm/1"; import { tokens as bnbTokens } from "./data/evm/56"; import filecoinTokens from "./data/filecoin-erc20"; +import spltokens, { SPLToken } from "./data/spl"; import { ERC20Token } from "./types"; const emptyArray = []; @@ -54,6 +55,8 @@ addTokens(vechainTokens.map(convertVechainToken)); addTokens(jettonTokens.map(convertJettonToken)); // Filecoin tokens addTokens(filecoinTokens.map(convertERC20)); +// Solana tokens +addTokens(spltokens.map(convertSplTokens)); type TokensListOptions = { withDelisted: boolean; @@ -403,6 +406,39 @@ function convertElrondESDTTokens([ }; } +function convertSplTokens([ + chainId, + name, + symbol, + address, + decimals, + enableCountervalues, +]: SPLToken): TokenCurrency { + const chainIdToCurrencyId = { + 101: "solana", + 102: "solana_testnet", + 103: "solana_devnet", + }; + const currencyId = chainIdToCurrencyId[chainId]; + return { + type: "TokenCurrency", + id: `solana/spl/${address}`, + contractAddress: address, + parentCurrency: getCryptoCurrencyById(currencyId), + name, + tokenType: "spl", + ticker: symbol, + disableCountervalue: !enableCountervalues, + units: [ + { + name, + code: symbol, + magnitude: decimals, + }, + ], + }; +} + function convertCardanoNativeTokens([ parentCurrencyId, policyId, diff --git a/libs/ledgerjs/packages/types-live/src/bridge.ts b/libs/ledgerjs/packages/types-live/src/bridge.ts index 53b3bc4cd577..8183741755be 100644 --- a/libs/ledgerjs/packages/types-live/src/bridge.ts +++ b/libs/ledgerjs/packages/types-live/src/bridge.ts @@ -6,7 +6,7 @@ import { BigNumber } from "bignumber.js"; import type { Observable } from "rxjs"; import type { CryptoCurrency } from "@ledgerhq/types-cryptoassets"; -import type { AccountLike, Account, AccountRaw } from "./account"; +import type { AccountLike, Account, AccountRaw, TokenAccount, TokenAccountRaw } from "./account"; import type { SignOperationEvent, SignedOperation, @@ -194,6 +194,25 @@ export interface AccountBridge< * @param {Account} account - The original account object. */ assignFromAccountRaw?: (accountRaw: R, account: A) => void; + /** + * This function mutates the 'tokenAccountRaw' object in-place to add any extra fields that the coin may need to set. + * It is called during the serialization mechanism + * + * @param {TokenAccount} tokenAccount - The original token account object. + * @param {TokenAccountRaw} tokenAccountRaw - The token account in its serialized form. + */ + assignToTokenAccountRaw?: (tokenAccount: TokenAccount, tokenAccountRaw: TokenAccountRaw) => void; + /** + * This function mutates the 'tokenAccount' object in-place to add any extra fields that the coin may need to set. + * It is called during the deserialization mechanism + * + * @param {TokenAccountRaw} tokenAccountRaw - The token account in its serialized form. + * @param {TokenAccount} tokenAccount - The original token account object. + */ + assignFromTokenAccountRaw?: ( + tokenAccountRaw: TokenAccountRaw, + tokenAccount: TokenAccount, + ) => void; /** * This function mutates the 'account' object to extend it with any extra fields of the coin. * For instance bitcoinResources needs to be created. diff --git a/libs/ledgerjs/packages/types-live/src/operation.ts b/libs/ledgerjs/packages/types-live/src/operation.ts index 92e0187162c1..16d014c2683d 100644 --- a/libs/ledgerjs/packages/types-live/src/operation.ts +++ b/libs/ledgerjs/packages/types-live/src/operation.ts @@ -51,7 +51,9 @@ export type OperationType = // NEAR | "STAKE" | "UNSTAKE" - | "WITHDRAW_UNSTAKED"; + | "WITHDRAW_UNSTAKED" + // SOLANA + | "BURN"; export type OperationExtra = unknown; /**