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/cli/src/live-common-setup-base.ts b/apps/cli/src/live-common-setup-base.ts index 631f9ec6fdf4..3779fcd304ff 100644 --- a/apps/cli/src/live-common-setup-base.ts +++ b/apps/cli/src/live-common-setup-base.ts @@ -44,6 +44,8 @@ setSupportedCurrencies([ "hedera", "cardano", "solana", + "solana_testnet", + "solana_devnet", "osmosis", "fantom", "moonbeam", diff --git a/apps/ledger-live-desktop/src/config/urls.ts b/apps/ledger-live-desktop/src/config/urls.ts index 62e6aa6607ce..cff48e77eccb 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: "https://support.ledger.com/article/7723954701469-zd", }; const errors: Record = { @@ -153,6 +154,8 @@ export const urls = { }, solana: { staking: "https://support.ledger.com/article/4731749170461-zd", + splTokenInfo: + "https://support.ledger.com/article/Verify-Solana-Address-from-Token-Account-Address", recipient_info: "https://support.ledger.com", ledgerByChorusOneTC: "https://chorus.one/tos", ledgerByFigmentTC: diff --git a/apps/ledger-live-desktop/src/live-common-set-supported-currencies.ts b/apps/ledger-live-desktop/src/live-common-set-supported-currencies.ts index a9ad1858c4bc..062d3849533d 100644 --- a/apps/ledger-live-desktop/src/live-common-set-supported-currencies.ts +++ b/apps/ledger-live-desktop/src/live-common-set-supported-currencies.ts @@ -20,6 +20,8 @@ setSupportedCurrencies([ "bsc", "polkadot", "solana", + "solana_testnet", + "solana_devnet", "ripple", "litecoin", "polygon", 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/components/TransactionConfirm/index.tsx b/apps/ledger-live-desktop/src/renderer/components/TransactionConfirm/index.tsx index 0a23acfe4f4c..87a1502f1550 100644 --- a/apps/ledger-live-desktop/src/renderer/components/TransactionConfirm/index.tsx +++ b/apps/ledger-live-desktop/src/renderer/components/TransactionConfirm/index.tsx @@ -169,6 +169,7 @@ const TransactionConfirm = ({ parentAccount={parentAccount} transaction={transaction} status={status} + device={device} /> ) : null } 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 248fa4eb2483..4c67c9f92fe5 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 IconCoins from "~/renderer/icons/Coins"; import { SolanaFamily } from "./types"; import { useGetStakeLabelLocaleBased } from "~/renderer/hooks/useGetStakeLabelLocaleBased"; -const AccountHeaderActions: SolanaFamily["accountHeaderManageActions"] = ({ account, source }) => { +const AccountHeaderActions: SolanaFamily["accountHeaderManageActions"] = ({ + account, + parentAccount, + source, +}) => { const dispatch = useDispatch(); const label = useGetStakeLabelLocaleBased(); - 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..421f94b9e007 --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/families/solana/TransactionConfirmFields.tsx @@ -0,0 +1,66 @@ +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 Box from "~/renderer/components/Box"; +import { openURL } from "~/renderer/linking"; +import { useLocalizedUrl } from "~/renderer/hooks/useLocalizedUrls"; +import { urls } from "~/config/urls"; +import { DeviceModelId } from "@ledgerhq/devices"; +import { Link } from "@ledgerhq/react-ui"; + +const Title: TitleComponent = props => { + const { transaction, account, parentAccount, status, device } = 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" && + device.modelId === DeviceModelId.nanoS + ) { + return ( + + + + + openURL(transferTokenHelpUrl)} /> + + + + ); + } + + return ; +}; + +type TransactionConfirmFields = SolanaFamily["transactionConfirmFields"]; +type TitleComponent = NonNullable["title"]>; + +const transactionConfirmFields: TransactionConfirmFields = { + 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/src/renderer/families/types.ts b/apps/ledger-live-desktop/src/renderer/families/types.ts index 52c13aedefe1..b6adf482b4eb 100644 --- a/apps/ledger-live-desktop/src/renderer/families/types.ts +++ b/apps/ledger-live-desktop/src/renderer/families/types.ts @@ -169,6 +169,7 @@ export type LLDCoinFamily< parentAccount: A | null | undefined; transaction: T; status: TS; + device: Device; }>; footer?: React.ComponentType<{ diff --git a/apps/ledger-live-desktop/static/i18n/en/app.json b/apps/ledger-live-desktop/static/i18n/en/app.json index 5b48eddb6c29..bf53f0427500 100644 --- a/apps/ledger-live-desktop/static/i18n/en/app.json +++ b/apps/ledger-live-desktop/static/i18n/en/app.json @@ -200,7 +200,8 @@ "STAKE": "Staked", "UNSTAKE": "Unstaked", "WITHDRAW_UNSTAKED": "Withdrawn", - "SENDING": "Sending" + "SENDING": "Sending", + "BURN": "Burned" }, "edit": { "title": "Speed up or Cancel", @@ -3741,6 +3742,10 @@ } } } + }, + "token": { + "frozenStateWarning": "Account assets are frozen!", + "transferWarning": "To verify the recipient address for Solana tokens using a Ledger Nano S™, <0>follow these instructions." } }, "ethereum": { @@ -6202,6 +6207,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-desktop/tests/specs/services/wallet-api.spec.ts-snapshots/wallet-api-currencies-darwin.json b/apps/ledger-live-desktop/tests/specs/services/wallet-api.spec.ts-snapshots/wallet-api-currencies-darwin.json index efcbd915c445..b35222e7ad83 100644 --- a/apps/ledger-live-desktop/tests/specs/services/wallet-api.spec.ts-snapshots/wallet-api-currencies-darwin.json +++ b/apps/ledger-live-desktop/tests/specs/services/wallet-api.spec.ts-snapshots/wallet-api-currencies-darwin.json @@ -143,6 +143,24 @@ "color": "#000", "decimals": 9 }, + { + "type": "CryptoCurrency", + "id": "solana_testnet", + "ticker": "SOL", + "name": "Solana testnet", + "family": "solana", + "color": "#000", + "decimals": 9 + }, + { + "type": "CryptoCurrency", + "id": "solana_devnet", + "ticker": "SOL", + "name": "Solana devnet", + "family": "solana", + "color": "#000", + "decimals": 9 + }, { "type": "CryptoCurrency", "id": "ripple", diff --git a/apps/ledger-live-desktop/tests/specs/services/wallet-api.spec.ts-snapshots/wallet-api-currencies-linux.json b/apps/ledger-live-desktop/tests/specs/services/wallet-api.spec.ts-snapshots/wallet-api-currencies-linux.json index efcbd915c445..b35222e7ad83 100644 --- a/apps/ledger-live-desktop/tests/specs/services/wallet-api.spec.ts-snapshots/wallet-api-currencies-linux.json +++ b/apps/ledger-live-desktop/tests/specs/services/wallet-api.spec.ts-snapshots/wallet-api-currencies-linux.json @@ -143,6 +143,24 @@ "color": "#000", "decimals": 9 }, + { + "type": "CryptoCurrency", + "id": "solana_testnet", + "ticker": "SOL", + "name": "Solana testnet", + "family": "solana", + "color": "#000", + "decimals": 9 + }, + { + "type": "CryptoCurrency", + "id": "solana_devnet", + "ticker": "SOL", + "name": "Solana devnet", + "family": "solana", + "color": "#000", + "decimals": 9 + }, { "type": "CryptoCurrency", "id": "ripple", diff --git a/apps/ledger-live-mobile/src/components/ValidateOnDevice.tsx b/apps/ledger-live-mobile/src/components/ValidateOnDevice.tsx index 9736c734cb93..4c059d56b851 100644 --- a/apps/ledger-live-mobile/src/components/ValidateOnDevice.tsx +++ b/apps/ledger-live-mobile/src/components/ValidateOnDevice.tsx @@ -87,6 +87,7 @@ type SubComponentCommonProps = { parentAccount?: Account | null | undefined; transaction: Transaction; status: TransactionStatus; + device: Device; }; export default function ValidateOnDevice({ @@ -179,6 +180,7 @@ export default function ValidateOnDevice({ parentAccount={parentAccount} transaction={transaction} status={status} + device={device} /> ) : ( {titleWording} @@ -214,6 +216,7 @@ export default function ValidateOnDevice({ transaction={transaction} recipientWording={recipientWording} status={status} + device={device} /> ) : null} 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..2041e5089098 --- /dev/null +++ b/apps/ledger-live-mobile/src/families/solana/TransactionConfirmFields.tsx @@ -0,0 +1,52 @@ +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 { Device } from "@ledgerhq/live-common/hw/actions/types"; +import { DeviceModelId } from "@ledgerhq/devices"; +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; + device: Device; +}; + +const Warning = ({ transaction, device }: SolanaFieldComponentProps) => { + invariant(transaction.family === "solana", "solana transaction"); + if ( + transaction.model.commandDescriptor?.command.kind === "token.transfer" && + device.modelId === DeviceModelId.nanoS + ) { + return ( + + + + + Linking.openURL(urls.solana.splTokenInfo)} /> + + + + + ); + } + return null; +}; + +export default { + warning: Warning, + fieldComponents: {}, +}; diff --git a/apps/ledger-live-mobile/src/live-common-setup.ts b/apps/ledger-live-mobile/src/live-common-setup.ts index 9010091d9022..e4f9565fa2d9 100644 --- a/apps/ledger-live-mobile/src/live-common-setup.ts +++ b/apps/ledger-live-mobile/src/live-common-setup.ts @@ -18,7 +18,7 @@ import { setDeviceMode } from "@ledgerhq/live-common/hw/actions/app"; import { getDeviceModel } from "@ledgerhq/devices"; import { DescriptorEvent } from "@ledgerhq/hw-transport"; import VersionNumber from "react-native-version-number"; -import type { DeviceModelId } from "@ledgerhq/types-devices"; +import { DeviceModelId } from "@ledgerhq/types-devices"; import { Platform } from "react-native"; import { setSecp256k1Instance } from "@ledgerhq/live-common/families/bitcoin/logic"; import { setGlobalOnBridgeError } from "@ledgerhq/live-common/bridge/useBridgeTransaction"; @@ -53,6 +53,8 @@ setSupportedCurrencies([ "bsc", "polkadot", "solana", + "solana_testnet", + "solana_devnet", "ripple", "litecoin", "polygon", @@ -183,7 +185,9 @@ if (__DEV__ && Config.DEVICE_PROXY_URL) { map(({ type, descriptor }) => ({ type, id: `httpdebug|${descriptor}`, - deviceModel: getDeviceModel((Config?.FALLBACK_DEVICE_MODEL_ID as DeviceModelId) || "nanoX"), + deviceModel: getDeviceModel( + (Config?.FALLBACK_DEVICE_MODEL_ID as DeviceModelId) || DeviceModelId.nanoX, + ), wired: Config?.FALLBACK_DEVICE_WIRED === "YES", name: descriptor, })), diff --git a/apps/ledger-live-mobile/src/locales/en/common.json b/apps/ledger-live-mobile/src/locales/en/common.json index 5d9ef44e8329..07c78d0fcfd2 100644 --- a/apps/ledger-live-mobile/src/locales/en/common.json +++ b/apps/ledger-live-mobile/src/locales/en/common.json @@ -790,6 +790,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" }, @@ -5929,6 +5935,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": "To verify the recipient address for Solana tokens using a Ledger Nano S™, <0>follow these instructions." } }, "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 0fd754e885e0..63f93e3950ac 100644 --- a/apps/ledger-live-mobile/src/utils/urls.tsx +++ b/apps/ledger-live-mobile/src/utils/urls.tsx @@ -166,6 +166,8 @@ export const urls = { solana: { supportPage: "https://support.ledger.com", stakingPage: "https://support.ledger.com/article/4731749170461-zd", + splTokenInfo: + "https://support.ledger.com/article/Verify-Solana-Address-from-Token-Account-Address", }, 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-evm/src/createTransaction.ts b/libs/coin-modules/coin-evm/src/createTransaction.ts index f225a42b6cac..7a0f606dfb57 100644 --- a/libs/coin-modules/coin-evm/src/createTransaction.ts +++ b/libs/coin-modules/coin-evm/src/createTransaction.ts @@ -1,5 +1,5 @@ import BigNumber from "bignumber.js"; -import { Account, AccountBridge } from "@ledgerhq/types-live"; +import { AccountBridge, AccountLike } from "@ledgerhq/types-live"; import { Transaction as EvmTransaction } from "./types"; import { DEFAULT_GAS_LIMIT } from "./transaction"; @@ -9,6 +9,16 @@ import { DEFAULT_GAS_LIMIT } from "./transaction"; */ export const DEFAULT_NONCE = -1; +const getChainId = (account: AccountLike): number => { + if (account.type === "Account") { + return account.currency.ethereumLikeInfo?.chainId || 0; + } + if (account.type === "TokenAccount") { + return account.token.parentCurrency.ethereumLikeInfo?.chainId || 0; + } + return 0; +}; + /** * EVM Transaction factory. * By default the transaction is an EIP-1559 transaction. @@ -24,7 +34,7 @@ export const createTransaction: AccountBridge["createTransaction maxPriorityFeePerGas: new BigNumber(0), gasLimit: DEFAULT_GAS_LIMIT, nonce: DEFAULT_NONCE, - chainId: (account as Account).currency?.ethereumLikeInfo?.chainId || 0, + chainId: getChainId(account), feesStrategy: "medium", type: 2, }); diff --git a/libs/coin-modules/coin-solana/src/api/chain/index.ts b/libs/coin-modules/coin-solana/src/api/chain/index.ts index d07c939b0c98..a685a02d1e5f 100644 --- a/libs/coin-modules/coin-solana/src/api/chain/index.ts +++ b/libs/coin-modules/coin-solana/src/api/chain/index.ts @@ -8,13 +8,16 @@ import { FetchMiddleware, VersionedMessage, PublicKey, - sendAndConfirmRawTransaction, SignaturesForAddressOptions, StakeProgram, TransactionInstruction, ComputeBudgetProgram, VersionedTransaction, TransactionMessage, + SendTransactionError, + BlockhashWithExpiryBlockHeight, + Commitment, + GetLatestBlockhashConfig, } from "@solana/web3.js"; import { makeLRUCache, minutes } from "@ledgerhq/live-network/cache"; import { getEnv } from "@ledgerhq/live-env"; @@ -23,6 +26,7 @@ import { Awaited } from "../../logic"; import { getStakeActivation } from "./stake-activation"; export const LATEST_BLOCKHASH_MOCK = "EEbZs6DmDyDjucyYbo3LwVJU7pQYuVopYcYTSEZXskW3"; +export const LAST_VALID_BLOCK_HEIGHT_MOCK = 280064048; export type Config = { readonly endpoint: string; @@ -31,7 +35,9 @@ export type Config = { export type ChainAPI = Readonly<{ getBalance: (address: string) => Promise; - getLatestBlockhash: () => Promise; + getLatestBlockhash: ( + commitmentOrConfig?: Commitment | GetLatestBlockhashConfig, + ) => Promise; getFeeForMessage: (message: VersionedMessage) => Promise; @@ -66,7 +72,10 @@ export type ChainAPI = Readonly<{ address: string, ) => Promise>["value"]>; - sendRawTransaction: (buffer: Buffer) => ReturnType; + sendRawTransaction: ( + buffer: Buffer, + recentBlockhash?: BlockhashWithExpiryBlockHeight, + ) => ReturnType; findAssocTokenAccAddress: (owner: string, mint: string) => Promise; @@ -105,23 +114,24 @@ export function getChainAPI( fetch(url, options); }; + let _connection: Connection; const connection = () => { - return new Connection(config.endpoint, { - ...(fetchMiddleware ? { fetchMiddleware } : {}), - commitment: "finalized", - confirmTransactionInitialTimeout: getEnv("SOLANA_TX_CONFIRMATION_TIMEOUT") || 0, - }); + if (!_connection) { + _connection = new Connection(config.endpoint, { + ...(fetchMiddleware ? { fetchMiddleware } : {}), + commitment: "finalized", + confirmTransactionInitialTimeout: getEnv("SOLANA_TX_CONFIRMATION_TIMEOUT") || 0, + }); + } + return _connection; }; return { getBalance: (address: string) => connection().getBalance(new PublicKey(address)).catch(remapErrors), - getLatestBlockhash: () => - connection() - .getLatestBlockhash() - .then(r => r.blockhash) - .catch(remapErrors), + getLatestBlockhash: (commitmentOrConfig?: Commitment | GetLatestBlockhashConfig) => + connection().getLatestBlockhash(commitmentOrConfig).catch(remapErrors), getFeeForMessage: (msg: VersionedMessage) => connection() @@ -201,10 +211,39 @@ export function getChainAPI( .then(r => r.value) .catch(remapErrors), - sendRawTransaction: (buffer: Buffer) => { - return sendAndConfirmRawTransaction(connection(), buffer, { - commitment: "confirmed", - }).catch(remapErrors); + sendRawTransaction: (buffer: Buffer, recentBlockhash?: BlockhashWithExpiryBlockHeight) => { + return (async () => { + const conn = connection(); + + const commitment = "confirmed"; + + const signature = await conn.sendRawTransaction(buffer, { + preflightCommitment: commitment, + }); + + if (!recentBlockhash) { + recentBlockhash = await conn.getLatestBlockhash(commitment); + } + const { value: status } = await conn.confirmTransaction( + { + blockhash: recentBlockhash.blockhash, + lastValidBlockHeight: recentBlockhash.lastValidBlockHeight, + signature, + }, + commitment, + ); + if (status.err) { + if (signature != null) { + throw new SendTransactionError({ + action: "send", + signature: signature, + transactionMessage: `Status: (${JSON.stringify(status)})`, + }); + } + throw new Error(`Raw transaction ${signature} failed (${JSON.stringify(status)})`); + } + return signature; + })().catch(remapErrors); }, findAssocTokenAccAddress: (owner: string, mint: string) => { @@ -245,7 +284,7 @@ export function getChainAPI( // RecentBlockhash can by any public key during simulation // since 'replaceRecentBlockhash' is set to 'true' below recentBlockhash: PublicKey.default.toString(), - }).compileToV0Message(), + }).compileToLegacyMessage(), ); const rpcResponse = await connection().simulateTransaction(testTransaction, { replaceRecentBlockhash: true, 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..24204516db1f 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, ), ); @@ -209,7 +211,7 @@ export const buildTokenTransferInstructions = async ( ); } - return instructions; + return appendMaybePriorityFeeInstructions(api, instructions, ownerPubkey); }; export async function findAssociatedTokenAccountPubkey( @@ -286,10 +288,12 @@ export async function appendMaybePriorityFeeInstructions( const writableAccs = instructions .map(ix => ix.keys.filter(acc => acc.isWritable).map(acc => acc.pubkey.toBase58())) .flat(); - const priorityFeeIx = await buildMaybePriorityFeeInstruction(api, writableAccs); - if (priorityFeeIx) instructions.unshift(priorityFeeIx); - const computeUnitsIx = await buildComputeUnitInstruction(api, instructions, payer); + const [priorityFeeIx, computeUnitsIx] = await Promise.all([ + buildMaybePriorityFeeInstruction(api, writableAccs), + buildComputeUnitInstruction(api, instructions, payer), + ]); + if (priorityFeeIx) instructions.unshift(priorityFeeIx); if (computeUnitsIx) instructions.unshift(computeUnitsIx); return instructions; } @@ -317,11 +321,10 @@ export async function buildComputeUnitInstruction( : null; } -export function buildCreateAssociatedTokenAccountInstruction({ - mint, - owner, - associatedTokenAccountAddress, -}: TokenCreateATACommand): TransactionInstruction[] { +export function buildCreateAssociatedTokenAccountInstruction( + api: ChainAPI, + { mint, owner, associatedTokenAccountAddress }: TokenCreateATACommand, +): Promise { const ownerPubKey = new PublicKey(owner); const mintPubkey = new PublicKey(mint); const associatedTokenAccPubkey = new PublicKey(associatedTokenAccountAddress); @@ -335,7 +338,7 @@ export function buildCreateAssociatedTokenAccountInstruction({ ), ]; - return instructions; + return appendMaybePriorityFeeInstructions(api, instructions, ownerPubKey); } export async function buildStakeDelegateInstructions( 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..2eab9609ba2b 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, LAST_VALID_BLOCK_HEIGHT_MOCK, 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), @@ -950,7 +964,11 @@ const baseTx = { } as Transaction; const baseAPI = { - getLatestBlockhash: () => Promise.resolve(LATEST_BLOCKHASH_MOCK), + getLatestBlockhash: () => + Promise.resolve({ + blockhash: LATEST_BLOCKHASH_MOCK, + lastValidBlockHeight: LAST_VALID_BLOCK_HEIGHT_MOCK, + }), getFeeForMessage: (_msg: unknown) => Promise.resolve(testOnChainData.fees.lamportsPerSignature), getRecentPrioritizationFees: (_: string[]) => { return Promise.resolve([ @@ -1110,6 +1128,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 305011771c86..0b1bea1ae480 100644 --- a/libs/coin-modules/coin-solana/src/bridge/bridge.ts +++ b/libs/coin-modules/coin-solana/src/bridge/bridge.ts @@ -28,6 +28,8 @@ import { assignToAccountRaw, fromOperationExtraRaw, toOperationExtraRaw, + assignFromTokenAccountRaw, + assignToTokenAccountRaw, } from "../serialization"; function makePrepare(getChainAPI: (config: Config) => Promise) { @@ -178,6 +180,8 @@ export function makeBridges({ toOperationExtraRaw, fromOperationExtraRaw, getSerializedAddressParameters, + assignFromTokenAccountRaw, + assignToTokenAccountRaw, }; const currencyBridge: CurrencyBridge = { diff --git a/libs/coin-modules/coin-solana/src/broadcast.ts b/libs/coin-modules/coin-solana/src/broadcast.ts index 46dcd0633def..2910191748ce 100644 --- a/libs/coin-modules/coin-solana/src/broadcast.ts +++ b/libs/coin-modules/coin-solana/src/broadcast.ts @@ -3,6 +3,7 @@ import { patchOperationWithHash } from "@ledgerhq/coin-framework/operation"; import type { Account, Operation, SignedOperation } from "@ledgerhq/types-live"; import { ChainAPI } from "./api"; import { SolanaTxConfirmationTimeout, SolanaTxSimulationFailedWhilePendingOp } from "./errors"; +import { BlockhashWithExpiryBlockHeight } from "@solana/web3.js"; export const broadcastWithAPI = async ( { @@ -14,10 +15,13 @@ export const broadcastWithAPI = async ( }, api: ChainAPI, ): Promise => { - const { signature, operation } = signedOperation; + const { signature, operation, rawData } = signedOperation; try { - const txSignature = await api.sendRawTransaction(Buffer.from(signature, "hex")); + const txSignature = await api.sendRawTransaction( + Buffer.from(signature, "hex"), + rawData?.recentBlockhash as BlockhashWithExpiryBlockHeight, + ); return patchOperationWithHash(operation, txSignature); } catch (e: any) { // heuristics to make some errors more user friendly diff --git a/libs/coin-modules/coin-solana/src/buildTransaction.ts b/libs/coin-modules/coin-solana/src/buildTransaction.ts index 146b594ca7fc..33fb090081f7 100644 --- a/libs/coin-modules/coin-solana/src/buildTransaction.ts +++ b/libs/coin-modules/coin-solana/src/buildTransaction.ts @@ -15,6 +15,7 @@ import { VersionedTransaction as OnChainTransaction, TransactionInstruction, TransactionMessage, + BlockhashWithExpiryBlockHeight, } from "@solana/web3.js"; import { ChainAPI } from "./api"; @@ -22,16 +23,23 @@ export const buildTransactionWithAPI = async ( address: string, transaction: Transaction, api: ChainAPI, -): Promise OnChainTransaction]> => { - const instructions = await buildInstructions(api, transaction); - - const recentBlockhash = await api.getLatestBlockhash(); +): Promise< + readonly [ + OnChainTransaction, + BlockhashWithExpiryBlockHeight, + (signature: Buffer) => OnChainTransaction, + ] +> => { + const [instructions, recentBlockhash] = await Promise.all([ + buildInstructions(api, transaction), + api.getLatestBlockhash(), + ]); const feePayer = new PublicKey(address); const tm = new TransactionMessage({ payerKey: feePayer, - recentBlockhash, + recentBlockhash: recentBlockhash.blockhash, instructions, }); @@ -39,6 +47,7 @@ export const buildTransactionWithAPI = async ( return [ tx, + recentBlockhash, (signature: Buffer) => { tx.addSignature(new PublicKey(address), signature); return tx; @@ -70,7 +79,7 @@ async function buildInstructionsForCommand( case "token.transfer": return buildTokenTransferInstructions(api, command); case "token.createATA": - return buildCreateAssociatedTokenAccountInstruction(command); + return buildCreateAssociatedTokenAccountInstruction(api, command); case "stake.createAccount": return buildStakeCreateAccountInstructions(api, command); case "stake.delegate": 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..faba5b53feaa 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; @@ -36,8 +36,8 @@ export function decodeAccountIdWithTokenAccountAddress(accountIdWithTokenAccount }; } -export function toTokenId(mint: string): string { - return `solana/spl/${mint}`; +export function toTokenId(currencyId: string, mint: string): string { + return `${currencyId}/spl/${mint}`; } export function toTokenMint(tokenId: string): string { @@ -48,8 +48,8 @@ export function toSubAccMint(subAcc: TokenAccount): string { return toTokenMint(subAcc.token.id); } -export function tokenIsListedOnLedger(mint: string): boolean { - return findTokenById(toTokenId(mint))?.type === "TokenCurrency"; +export function tokenIsListedOnLedger(currencyId: string, mint: string): boolean { + return findTokenById(toTokenId(currencyId, mint))?.type === "TokenCurrency"; } export function stakeActions(stake: SolanaStake): StakeAction[] { @@ -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 994eaa2cc522..669488642712 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/signOperation.ts b/libs/coin-modules/coin-solana/src/signOperation.ts index 5d436993281f..9b0c7a467fc6 100644 --- a/libs/coin-modules/coin-solana/src/signOperation.ts +++ b/libs/coin-modules/coin-solana/src/signOperation.ts @@ -15,12 +15,13 @@ import type { TransferCommand, } from "./types"; import { buildTransactionWithAPI } from "./buildTransaction"; -import type { SolanaSigner } from "./signer"; +import type { Resolution, SolanaSigner } from "./signer"; import BigNumber from "bignumber.js"; import { encodeOperationId } from "@ledgerhq/coin-framework/operation"; import { assertUnreachable } from "./utils"; import { ChainAPI } from "./api"; import { SignerContext } from "@ledgerhq/coin-framework/signer"; +import { DeviceModelId } from "@ledgerhq/devices"; const buildOptimisticOperation = (account: Account, transaction: Transaction): SolanaOperation => { if (transaction.model.commandDescriptor === undefined) { @@ -45,15 +46,51 @@ const buildOptimisticOperation = (account: Account, transaction: Transaction): S return optimisticOp; }; +function getResolution( + transaction: Transaction, + deviceModelId?: DeviceModelId, +): Resolution | undefined { + if (!transaction.subAccountId || !transaction.model.commandDescriptor) return; + + const { command } = transaction.model.commandDescriptor; + switch (command.kind) { + case "token.transfer": { + if (command.recipientDescriptor.shouldCreateAsAssociatedTokenAccount) { + return { + deviceModelId, + createATA: { + address: command.recipientDescriptor.walletAddress, + mintAddress: command.mintAddress, + }, + }; + } + return { + deviceModelId, + tokenAddress: command.recipientDescriptor.tokenAccAddress, + }; + } + // Not sure we need to handle this case as we don't use the TLV descriptor on the steps of createATA + case "token.createATA": { + return { + deviceModelId, + createATA: { + address: command.owner, + mintAddress: command.mint, + }, + }; + } + } +} + export const buildSignOperation = ( signerContext: SignerContext, api: () => Promise, ): AccountBridge["signOperation"] => - ({ account, deviceId, transaction }) => + ({ account, deviceId, deviceModelId, transaction }) => new Observable(subscriber => { const main = async () => { - const [tx, signOnChainTransaction] = await buildTransactionWithAPI( + const [tx, recentBlockhash, signOnChainTransaction] = await buildTransactionWithAPI( account.freshAddress, transaction, await api(), @@ -64,7 +101,11 @@ export const buildSignOperation = }); const { signature } = await signerContext(deviceId, signer => - signer.signTransaction(account.freshAddressPath, Buffer.from(tx.message.serialize())), + signer.signTransaction( + account.freshAddressPath, + Buffer.from(tx.message.serialize()), + getResolution(transaction, deviceModelId), + ), ); subscriber.next({ @@ -78,6 +119,9 @@ export const buildSignOperation = signedOperation: { operation: buildOptimisticOperation(account, transaction), signature: Buffer.from(signedTx.serialize()).toString("hex"), + rawData: { + recentBlockhash, + }, }, }); }; diff --git a/libs/coin-modules/coin-solana/src/signer.ts b/libs/coin-modules/coin-solana/src/signer.ts index b4dfbd316927..b3707495df9d 100644 --- a/libs/coin-modules/coin-solana/src/signer.ts +++ b/libs/coin-modules/coin-solana/src/signer.ts @@ -1,10 +1,26 @@ +import { DeviceModelId } from "@ledgerhq/devices"; + export type SolanaAddress = { address: Buffer; }; export type SolanaSignature = { signature: Buffer; }; + +export type Resolution = { + deviceModelId?: DeviceModelId | undefined; + tokenAddress?: string; + createATA?: { + address: string; + mintAddress: string; + }; +}; + export interface SolanaSigner { getAddress(path: string, display?: boolean): Promise; - signTransaction(path: string, txBuffer: Buffer): Promise; + signTransaction( + path: string, + txBuffer: Buffer, + resolution?: Resolution, + ): Promise; } 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..569595b523b1 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"; @@ -97,7 +87,7 @@ export const getAccountShapeWithAPI = async ( const nextSubAccs: TokenAccount[] = []; for (const [mint, accs] of onChainTokenAccsByMint.entries()) { - if (!tokenIsListedOnLedger(mint)) { + if (!tokenIsListedOnLedger(currency.id, mint)) { continue; } @@ -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(), @@ -124,6 +114,7 @@ export const getAccountShapeWithAPI = async ( const nextSubAcc = subAcc === undefined ? newSubAcc({ + currencyId: currency.id, mainAccountId, assocTokenAcc, txs, @@ -232,9 +223,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), @@ -257,19 +246,21 @@ export const getAccountShapeWithAPI = async ( }; function newSubAcc({ + currencyId, mainAccountId, assocTokenAcc, txs, }: { + currencyId: string; 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); - const tokenId = toTokenId(assocTokenAcc.info.mint.toBase58()); + const tokenId = toTokenId(currencyId, assocTokenAcc.info.mint.toBase58()); const tokenCurrency = getTokenById(tokenId); const accosTokenAccPubkey = assocTokenAcc.onChainAcc.pubkey; @@ -295,6 +286,7 @@ function newSubAcc({ spendableBalance: balance, swapHistory: [], token: tokenCurrency, + state: assocTokenAcc.info.state, type: "TokenAccount", }; } @@ -307,7 +299,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 +311,7 @@ function patchedSubAcc({ balance, spendableBalance: balance, operations: totalOps, + state: assocTokenAcc.info.state, }; } @@ -358,26 +351,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 +441,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 +465,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 +482,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 +518,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; @@ -564,16 +596,18 @@ function getMainAccOperationTypeFromTx(tx: ParsedTransaction): OperationType | u switch (first.program) { case "spl-associated-token-account": - switch (first.instruction.type) { - case "associate": - return "OPT_IN"; + if (first.instruction.type === "associate") { + return "OPT_IN"; } - // needed for lint break; case "spl-token": switch (first.instruction.type) { case "closeAccount": return "OPT_OUT"; + case "freezeAccount": + return "FREEZE"; + case "thawAccount": + return "UNFREEZE"; } break; case "stake": @@ -594,36 +628,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 +665,24 @@ 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": + if (mainIx.instruction.type === "associate") { + return "NONE"; // ATA opt-in operation is added to the main account + } + break; + case "spl-token": switch (mainIx.instruction.type) { - case "associate": - return "OPT_IN"; + 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/transaction.ts b/libs/coin-modules/coin-solana/src/transaction.ts index 4475702d81a6..0ed1ef8c3e98 100644 --- a/libs/coin-modules/coin-solana/src/transaction.ts +++ b/libs/coin-modules/coin-solana/src/transaction.ts @@ -72,7 +72,7 @@ function formatCommand(mainAccount: Account, tx: Transaction, command: Command) case "token.transfer": return formatTokenTransfer(mainAccount, tx, command); case "token.createATA": - return formatCreateATA(command); + return formatCreateATA(mainAccount, command); case "stake.createAccount": return formatStakeCreateAccount(mainAccount, tx, command); case "stake.delegate": @@ -147,8 +147,8 @@ function formatTokenTransfer(mainAccount: Account, tx: Transaction, command: Tok return "\n" + str; } -function formatCreateATA(command: TokenCreateATACommand) { - const token = getTokenById(toTokenId(command.mint)); +function formatCreateATA(mainAccount: Account, command: TokenCreateATACommand) { + const token = getTokenById(toTokenId(mainAccount.currency.id, command.mint)); const str = [` OPT IN TOKEN: ${token.ticker}`].filter(Boolean).join("\n"); return "\n" + str; } diff --git a/libs/coin-modules/coin-solana/src/tx-fees.ts b/libs/coin-modules/coin-solana/src/tx-fees.ts index 825a613e4385..f727a77504f4 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, @@ -196,9 +223,9 @@ async function waitNextBlockhash(api: ChainAPI, currentBlockhash: string) { log("info", `sleeping for ${sleepTimeMS} ms, waiting for a new blockhash`); await sleep(sleepTimeMS); const blockhash = await api.getLatestBlockhash(); - if (blockhash !== currentBlockhash) { + if (blockhash.blockhash !== currentBlockhash) { log("info", "got a new blockhash"); - return blockhash; + return blockhash.blockhash; } log("info", "got same blockhash"); } 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 8506162e58b7..111c2d0a0746 100644 --- a/libs/coin-modules/coin-solana/src/utils.ts +++ b/libs/coin-modules/coin-solana/src/utils.ts @@ -39,6 +39,7 @@ export const LEDGER_VALIDATOR_LIST: ValidatorsAppValidator[] = [ export const LEDGER_VALIDATORS_VOTE_ACCOUNTS = LEDGER_VALIDATOR_LIST.map(v => v.voteAccount); 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/package.json b/libs/ledger-live-common/package.json index 4897434db0ec..e6cca9432091 100644 --- a/libs/ledger-live-common/package.json +++ b/libs/ledger-live-common/package.json @@ -172,9 +172,11 @@ "@ledgerhq/hw-app-trx": "workspace:^", "@ledgerhq/hw-app-vet": "workspace:^", "@ledgerhq/hw-app-xrp": "workspace:^", + "@ledgerhq/hw-bolos": "workspace:*", "@ledgerhq/hw-transport": "workspace:^", "@ledgerhq/hw-transport-mocker": "workspace:^", "@ledgerhq/ledger-cal-service": "workspace:^", + "@ledgerhq/ledger-trust-service": "workspace:*", "@ledgerhq/live-app-sdk": "^0.8.1", "@ledgerhq/live-config": "workspace:^", "@ledgerhq/live-countervalues": "workspace:^", @@ -263,6 +265,7 @@ "@types/react": "^18.2.21", "@types/uuid": "^8.3.4", "benchmark": "^2.1.4", + "buffer": "6.0.3", "camelcase": "^6.2.1", "cross-env": "^7.0.3", "env-cmd": "*", @@ -289,9 +292,8 @@ "ts-jest": "^29.1.1", "ts-node": "^10.4.0", "typescript": "5.1.3", + "undici": "6.19.2", "uuid": "^8.3.2", - "ws": "7", - "buffer": "6.0.3", - "undici": "6.19.2" + "ws": "7" } } 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 05b1ea249f81..ab01c28d62a0 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 @@ -924,6 +924,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", @@ -15988,5 +15989,115 @@ exports[`sortCurrenciesByIds snapshot 1`] = ` "filecoin/erc20/pfil_token", "filecoin/erc20/wrapped_fil", "filecoin/erc20/wrapped_pfil_token", + "solana/spl/27G8MtK7VtTcCHkpASjSDdkWWYfoqT6ggEuKidVJidD4", + "solana/spl/2FPyTwcZLUg1MDrwsyoP4D6s1tM7hAkHYRjkNb5w6Pxk", + "solana/spl/31k88G5Mq7ptbRDf3AM13HAq6wRQHXHikR8hik7wPygk", + "solana/spl/3bRTivrVsitbmCTGtqwp7hxXPsybkjn4XLNtPsHqa3zR", + "solana/spl/3dgCCb15HMQSA4Pn3Tfii5vRk7aRqTH95LJjxzsG2Mug", + "solana/spl/3NZ9JMVBmGAqocybic2c7LQCJScmgsAZ6vQqTDzcqmJh", + "solana/spl/3psH1Mj1f7yUfaD5gh6Zj7epE8hhrMkMETgv5TshQA4o", + "solana/spl/4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R", + "solana/spl/4LLbsb5ReP3yEtYzmXewyGjcir5uXtKFURtaEUVC2AHs", + "solana/spl/4vMsoUT2BWatFweudnQM1xedRLfJgJ7hswhcpz4xgBTy", + "solana/spl/5MAYDfq5yxtudAhtfyuMBuHZjgAbaS9tbEyEQYAhDS5y", + "solana/spl/5oVNBeEEQvYi1cX3ir8Dx5n1P7pdxydbGF2X4TxVusJm", + "solana/spl/6dKCoWjpj5MFU5gWDEFdpUUeBasBLK3wLEwhUzQPAa1e", + "solana/spl/6gnCPhXtLnUD76HjQuSYPENLSZdG8RvDB1pTLM5aLSJA", + "solana/spl/7atgF8KQo4wJrD5ATGX7t1V2zVvykPJbFfNeVf1icFv1", + "solana/spl/7dHbWXmci3dT8UFYWYZweBLXgycu7Y3iL6trKn1Y7ARj", + "solana/spl/7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr", + "solana/spl/7i5KKsX2weiTkry7jA4ZwSuXGhs5eJBEjY8vVxR4pfRx", + "solana/spl/7Q2afV64in6N6SeZsAAB81TJzwDoD6zpqmHkzi9Dcavn", + "solana/spl/7vfCXTUXx5WJV5JADk17DUJ4ksgau7utNKj4b963voxs", + "solana/spl/7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU", + "solana/spl/85VBFQZC9TZkfaptBWjvUw7YbZjy52A6mjtPGjstQAmQ", + "solana/spl/947tEoG318GUmyjVYhraNRvWpMX7fpBTDQFBoJvSkSG3", + "solana/spl/a11bdAAuV8iB2fu7X6AxAvDTo1QZ8FXB3kk5eecdasp", + "solana/spl/A1KLoBrKBde8Ty9qtNQUtq3C2ortoC3u7twggz7sEto6", + "solana/spl/AFbX8oGjGpmVFywbVouvhQSRmiW2aR1mohfahi4Y2AdB", + "solana/spl/AMUwxPsqWSd1fbCGzWsrRKDcNoduuWMkdR38qPdit8G8", + "solana/spl/7iT1GRYYhEop2nV1dyCwK2MGyLmPHq47WhPGSwiqcUg5", + "solana/spl/AT79ReYU9XtHUTF5vM6Q4oa9K8w7918Fp5SU7G1MDMQY", + "solana/spl/ATLASXmbPQxBUYbxPsV97usA3fPQYEqzQBUHgiFCUsXx", + "solana/spl/ATRLuHph8dxnPny4WSNW7fxkhbeivBrtWbY6BfB4xpLj", + "solana/spl/AURYydfxJib1ZkTir1Jn1J9ECYUtjb6rKQVmtYaixWPP", + "solana/spl/AujTJJ7aMS8LDo3bFzoyXDwT3jBALUbu4VZhzZdTZLmG", + "solana/spl/AHW5N8iqZobTcBepkSJzZ61XtAuSzBDcpxtrLG6KUKPk", + "solana/spl/BiDB55p4G3n1fGhwKFpxsokBMqgctL4qnZpDH1bVQxMD", + "solana/spl/BLZEEuZUBVqFhj8adcCFPJvPVCiCyVmh3hkJMrU8KuJA", + "solana/spl/bSo13r4TkiE4KumL71LsHTPpL2euBYLFx6h9HP3piy1", + "solana/spl/MEW1gQWJ3nEXg2qgERiKu7FAFj79PHvQVREQUzScPP5", + "solana/spl/CKaKtYvz6dKPyMvYq9Rh3UBrnNqYZAyd7iF4hJtjUvks", + "solana/spl/CvB1ztJvpYQPvdPBePtRzjL4aQidjydtUz61NWgcgQtP", + "solana/spl/4Cnk9EPnW5ixfLZatCPJjDB1PUtcRpVVgTQukm9epump", + "solana/spl/DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263", + "solana/spl/DFL1zNkaGPWm1BqAVqRjCZvHmwTFrEaJtbzJWgseoNJh", + "solana/spl/EchesyfXePKdLtoiZSL8pBe8Myagyy8ZRqsACNCFGnvp", + "solana/spl/EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm", + "solana/spl/EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "solana/spl/Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", + "solana/spl/ETAtLmCmsoiEEKfNrHKJ2kYy3MoABhU6NQvpSfij5tDs", + "solana/spl/FANoyuAQZx7AHCnxqsLeWq6te63F6zs6ENkbncCyYUZu", + "solana/spl/FLUXBmPhT3Fd1EDVFdg46YREqHBeNypn1h4EbnTzWERX", + "solana/spl/FoXyMu5xwXre7zEoSvzViRk3nGawHUp9kUh97y2NDhcq", + "solana/spl/FtgGSFADXBtroxq8VCausXRr2of47QBf5AS1NtZCu4GD", + "solana/spl/8wXtPeU6557ETkp9WHFY1n1EcU6NxDvbAggHGsMYiHsB", + "solana/spl/GDfnEsia2WLAW5t8yx2X5j2mkfA74i5kwGdDuZHt7XmG", + "solana/spl/GENEtH5amGSi8kHAtQoezp1XEXwZJ8vcuePYnXdKrMYz", + "solana/spl/GFX1ZjR2P15tmrSwow6FjyDYcEkoFb4p4gJCpLBjaxHD", + "solana/spl/GTH3wG3NErjwcf7VGCoXEXkgXSHvYhx5gtATeeM5JAS1", + "solana/spl/H53UGEyBrB9easo9ego8yYk7o4Zq1G5cCtkxD3E3hZav", + "solana/spl/HHjoYwUp5aU6pnrvN4s2pwEErwXNZKhxKGYjRJMoBjLw", + "solana/spl/HhJpBhRRn4g56VsyLuT8DL5Bv31HkXqsrahTTUCZeZg4", + "solana/spl/hntyVP6YFm1Hg25TN9WGLqM12b8TQmcknKrdu1oxWux", + "solana/spl/HxhWkVpk5NS4Ltg5nij2G671CKXFRKPK8vy271Ub4uEK", + "solana/spl/HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3", + "solana/spl/HzwqbKZw8HxMN6bF2yFZNrht3c2iXXzpKcFu7uBEDKtr", + "solana/spl/BZLbGTNCSFfoth2GYDtwr7e4imWzpR5jqcUuGEwr646K", + "solana/spl/iotEVVZLEywoTn1QdwNPddxPWszn3zFhEot3MfL9fns", + "solana/spl/J1toso1uCk3RLmjorhTtrVwY9HJ7X8V9yYac6Y7kGCPn", + "solana/spl/J2LWsSXx4r3pYbJ1fwuX5Nqo7PPxjcGPpUb2zHNadWKa", + "solana/spl/jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL", + "solana/spl/JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN", + "solana/spl/KMNo3nJsBXfcpJTVhZcXLW7RmTwTt4GVFE7suUBo9sS", + "solana/spl/kinXdEcpDQeHPEuQnqmUgtYykqKGVFq6CeVX5iAHJq6", + "solana/spl/LAinEtNLgpmCP9Rvsf5Hn8W6EhNiKLZQti1xfWMLy6X", + "solana/spl/LFNTYraetVioAPnGJht4yNg2aUZFXR776cMeN9VMjXp", + "solana/spl/LSTxxxnJzKDFSLr4dUkPcmCf5VyryEqzPLz5j4bpxFp", + "solana/spl/MangoCzJ36AjZyKwVj3VnYU4GTonjfVEnJmvvWaxLac", + "solana/spl/mb1eu7TzEc71KxDpsmsKoucSSuuoGLv1drys1oP2jh6", + "solana/spl/METAewgxyPbgwsseH8T16a39CQ5VyVxZi9zXiDPY18m", + "solana/spl/MNDEFzGvMt87ueuHvVU9VcTqsAP5b3fTGPsHuuPA5ey", + "solana/spl/ED5nyyWEzpPPiWimP8vYm7sD7TD3LAt3Q3gRTWHzPJBY", + "solana/spl/mSoLzYCxHdYgdzU16g5QSh3i5K3z3KZK7ytfqcJm7So", + "solana/spl/NeonTjSjsuo3rexg9o6vHuMXw62f9V7zvmu8M8Zut44", + "solana/spl/NFTUkR4u7wKxy9QLaX2TGvd9oZSWoMo4jqSJqdMb7Nk", + "solana/spl/nosXBVoaCTtYdLvKY6Csb4AC8JCdQKKAaWYtx2ZMoo7", + "solana/spl/NYANpAp9Cr7YarBNrby7Xx4xU6No6JKTBuohNA3yscP", + "solana/spl/octo82drBEdm8CSDaEKBymVn86TBtgmPnDdmE64PTqJ", + "solana/spl/orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE", + "solana/spl/poLisWXnNRwC6oBu1vHiuKQzFjGL4XDSu4g9qjz9qVk", + "solana/spl/WskzsKqEW3ZsmrhPAevfVZb6PuuLzWov9mJWZsfDePC", + "solana/spl/RLBxxFkseAZ4RgJH3Sqn8jXxhmGoz9jWxDNJMh8pL7a", + "solana/spl/rndrizKT3MK1iimdxRdWabcF7Zg7AR5T4nud4EkHBof", + "solana/spl/Saber2gLauYim4Mvftnrasomsv6NvAuncvMEZwcLpD1", + "solana/spl/SCSuPPNUSypLBsV4darsrYNg4ANPgaGhKhsA3GmMyjz", + "solana/spl/SRMuApVNdxXokk5GT7XD5cUUgXMBCoAz2LHeuAoKWRt", + "solana/spl/SHARKSYJjqaNyxVfrpnBN9pjgkhwDhatnMyicWPnr1s", + "solana/spl/7BgBvyjrZX1YKz4oh9mjb8ZScatkkwb8DzFx7LoiVkM3", + "solana/spl/SLNDpmoWTVADgEdndyvWzroNL7zSi1dF9PC3xHGtPwp", + "solana/spl/SNSNkV9zfG5ZKWQs6x4hxvBRV6s8SqMfSGCtECDvdMd", + "solana/spl/So11111111111111111111111111111111111111112", + "solana/spl/2wme8EVkw8qsfSk2B3QeX4S64ac6wxHPXb3GrdckEkio", + "solana/spl/StepAscQoEioFxxWGnh2sLBDFp9d8rvKz2Yp39iDpyT", + "solana/spl/Taki7fi3Zicv7Du1xNAWLaf6mRK7ikdn77HeGzgwvo4", + "solana/spl/TNSRxcUxoT9xBG3de7PiJyTDYu7kskLqcpddxnEJAS6", + "solana/spl/ukHH6c7mMyiWCf1b9pnWe25TSpkDDt3H5pQZgZ74J82", + "solana/spl/UXPhBoR3qG4UCiGNJfV7MqhHyFqKN68g45GoYvAeL2M", + "solana/spl/WENWENvqqNya429ubCdR81ZmD69brwQaaBYY6p3LCpk", + "solana/spl/xxxxa1sKNGwFtw2kFn8XauW9xq8hBZ5kVtcSesTT9fW", + "solana/spl/yomFPUqz1wJwYSfD5tZJUtS3bNb8xs8mx9XzBv8RL39", + "solana/spl/zebeczgi5fSEtbpfQKVZKCJ3WgYXxjkMUkNNx7fLKAF", + "solana/spl/ZEUS1aR7aX8DFFJf5QjWj2ftDDdNTroMNGo8YoQm3Gq", ] `; diff --git a/libs/ledger-live-common/src/families/evm/walletApiAdapter.test.ts b/libs/ledger-live-common/src/families/evm/walletApiAdapter.test.ts index a908cb8e7b12..1c584868cbfc 100644 --- a/libs/ledger-live-common/src/families/evm/walletApiAdapter.test.ts +++ b/libs/ledger-live-common/src/families/evm/walletApiAdapter.test.ts @@ -256,7 +256,7 @@ describe("getWalletAPITransactionSignFlowInfos", () => { const { canEditFees, hasFeesProvided, liveTx } = evm.getWalletAPITransactionSignFlowInfos({ walletApiTransaction: ethPlatformTx, - account: { currency: { ethereumLikeInfo: { chainId: 1 } } } as Account, + account: { type: "Account", currency: { ethereumLikeInfo: { chainId: 1 } } } as Account, }); expect(canEditFees).toBe(true); 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..f12d8b4810bc 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,23 @@ exports[`solana currency bridge scanAccounts solana seed 1 1`] = ` "unstakeReserve": "0", }, "spendableBalance": "82498960", + "subAccounts": [], "swapHistory": [], "syncHash": undefined, "used": true, }, + { + "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 +51,7 @@ exports[`solana currency bridge scanAccounts solana seed 1 1`] = ` "unstakeReserve": "0", }, "spendableBalance": "0", + "subAccounts": [], "swapHistory": [], "syncHash": undefined, "used": false, @@ -102,6 +116,24 @@ exports[`solana currency bridge scanAccounts solana seed 1 2`] = ` "value": "0", }, ], + [ + { + "accountId": "js:2:solana:AQbkEagmPgmsdAfS4X8V8UyJnXXjVPMvjeD15etqQ3Jh:solanaMain+8RtwWeqdFz4EFuZU3MAadfYMWSdRMamjFrfq6BXkHuNN", + "blockHash": "9tPbgLaETEenufCt5SzXMuWijgFJj549W9j5cJLbaogn", + "blockHeight": 108521109, + "extra": {}, + "fee": "5000", + "hasFailed": false, + "hash": "A29zPnK1jPr2tGziTnaAvSnadYR2kLCv9sPywj9FJsaEFjtpwmUonspN3WJgz4u6XWmjtVpoFsDrygEnvW51cgk", + "id": "js:2:solana:AQbkEagmPgmsdAfS4X8V8UyJnXXjVPMvjeD15etqQ3Jh:solanaMain+8RtwWeqdFz4EFuZU3MAadfYMWSdRMamjFrfq6BXkHuNN-A29zPnK1jPr2tGziTnaAvSnadYR2kLCv9sPywj9FJsaEFjtpwmUonspN3WJgz4u6XWmjtVpoFsDrygEnvW51cgk-IN", + "recipients": [ + "8RtwWeqdFz4EFuZU3MAadfYMWSdRMamjFrfq6BXkHuNN", + ], + "senders": [], + "type": "IN", + "value": "7960720", + }, + ], [], ] `; diff --git a/libs/ledger-live-common/src/families/solana/setup.ts b/libs/ledger-live-common/src/families/solana/setup.ts index ea9dd49cdd34..4a5acb597ff5 100644 --- a/libs/ledger-live-common/src/families/solana/setup.ts +++ b/libs/ledger-live-common/src/families/solana/setup.ts @@ -1,5 +1,6 @@ // Goal of this file is to inject all necessary device/signer dependency to coin-modules +import semver from "semver"; import Solana from "@ledgerhq/hw-app-solana"; import Transport from "@ledgerhq/hw-transport"; import type { Bridge } from "@ledgerhq/types-live"; @@ -8,11 +9,83 @@ import { createBridges } from "@ledgerhq/coin-solana/bridge/js"; import makeCliTools from "@ledgerhq/coin-solana/cli-transaction"; import solanaResolver from "@ledgerhq/coin-solana/hw-getAddress"; import { SolanaAccount, Transaction, TransactionStatus } from "@ledgerhq/coin-solana/types"; +import { DeviceModelId } from "@ledgerhq/devices"; +import { loadPKI } from "@ledgerhq/hw-bolos"; +import calService from "@ledgerhq/ledger-cal-service"; +import trustService from "@ledgerhq/ledger-trust-service"; +import { FirmwareOrAppUpdateRequired, TransportStatusError } from "@ledgerhq/errors"; import { CreateSigner, createResolver, executeWithSigner } from "../../bridge/setup"; import type { Resolver } from "../../hw/getAddress/types"; +const TRUSTED_NAME_MIN_VERSION = "1.6.1"; + +async function checkVersion(app: Solana) { + const { version } = await app.getAppConfiguration(); + if (semver.lt(version, TRUSTED_NAME_MIN_VERSION)) { + throw new FirmwareOrAppUpdateRequired(); + } +} + +function isPKIUnsupportedError(err: unknown): err is TransportStatusError { + return err instanceof TransportStatusError && err.message.includes("0x6a81"); +} + const createSigner: CreateSigner = (transport: Transport) => { - return new Solana(transport); + const app = new Solana(transport); + return { + getAddress: app.getAddress, + signTransaction: async (path, tx, resolution) => { + if (resolution) { + if (!resolution.deviceModelId) { + throw new Error("Resolution provided without a deviceModelId"); + } + + if (resolution.deviceModelId !== DeviceModelId.nanoS) { + const { descriptor, signature } = await calService.getCertificate( + resolution.deviceModelId, + ); + + try { + await loadPKI(transport, "TRUSTED_NAME", descriptor, signature); + } catch (err) { + if (isPKIUnsupportedError(err)) { + throw new FirmwareOrAppUpdateRequired(); + } + } + + if (resolution.tokenAddress) { + await checkVersion(app); + + const challenge = await app.getChallenge(); + const { signedDescriptor } = await trustService.getOwnerAddress( + resolution.tokenAddress, + challenge, + ); + + if (signedDescriptor) { + await app.provideTrustedName(signedDescriptor); + } + } + if (resolution.createATA) { + await checkVersion(app); + + const challenge = await app.getChallenge(); + const { signedDescriptor } = await trustService.computedTokenAddress( + resolution.createATA.address, + resolution.createATA.mintAddress, + challenge, + ); + + if (signedDescriptor) { + await app.provideTrustedName(signedDescriptor); + } + } + } + } + + return app.signTransaction(path, tx); + }, + }; }; const bridge: Bridge = createBridges( diff --git a/libs/ledger-live-common/src/families/solana/walletApiAdapter.test.ts b/libs/ledger-live-common/src/families/solana/walletApiAdapter.test.ts new file mode 100644 index 000000000000..f28e6a3c2570 --- /dev/null +++ b/libs/ledger-live-common/src/families/solana/walletApiAdapter.test.ts @@ -0,0 +1,95 @@ +import { Account, TokenAccount } from "@ledgerhq/types-live"; +import { SolanaTransaction as WalletAPITransaction } from "@ledgerhq/wallet-api-core"; +import BigNumber from "bignumber.js"; +import { Transaction } from "@ledgerhq/coin-solana/types"; +import sol from "./walletApiAdapter"; + +describe("getWalletAPITransactionSignFlowInfos", () => { + describe("should properly get infos for Solana TX", () => { + it("simple transfer", () => { + const solanaTx: WalletAPITransaction = { + family: "solana", + amount: new BigNumber(100000), + recipient: "0xABCDEFG", + model: { + kind: "transfer", + uiState: {}, + commandDescriptor: { + command: { + kind: "transfer", + amount: 100000, + sender: "0xABCDEF", + recipient: "0xABCDEFG", + }, + fee: 0, + errors: {}, + warnings: {}, + }, + }, + }; + + const expectedLiveTx: Partial = { + ...solanaTx, + }; + + const { canEditFees, hasFeesProvided, liveTx } = sol.getWalletAPITransactionSignFlowInfos({ + walletApiTransaction: solanaTx, + account: {} as Account, + }); + + expect(canEditFees).toBe(false); + + expect(hasFeesProvided).toBe(false); + + expect(liveTx).toEqual(expectedLiveTx); + }); + + it("should add subAccountId for token transfer", () => { + const solanaTx: WalletAPITransaction = { + family: "solana", + amount: new BigNumber(100000), + recipient: "0xABCDEFG", + model: { + kind: "token.transfer", + uiState: { + subAccountId: "", // Automatically replaced by LL + }, + commandDescriptor: { + command: { + kind: "token.transfer", + amount: 100000, + mintAddress: "0xABCDE", + mintDecimals: 6, + ownerAddress: "0xABCDEF", + ownerAssociatedTokenAccountAddress: "0xABCDEF", + recipientDescriptor: { + shouldCreateAsAssociatedTokenAccount: false, + tokenAccAddress: "0xABCDEFG", + walletAddress: "0xABCDEFG", + }, + }, + fee: 0, + errors: {}, + warnings: {}, + }, + }, + }; + + const expectedLiveTx: Partial = { + ...solanaTx, + subAccountId: "subAccountId", + }; + + const { canEditFees, hasFeesProvided, liveTx } = sol.getWalletAPITransactionSignFlowInfos({ + walletApiTransaction: solanaTx, + account: { id: "subAccountId", type: "TokenAccount" } as TokenAccount, + }); + + expect(canEditFees).toBe(false); + + expect(hasFeesProvided).toBe(false); + + expect(liveTx).toEqual(expectedLiveTx); + }); + }); +}); diff --git a/libs/ledger-live-common/src/families/solana/walletApiAdapter.ts b/libs/ledger-live-common/src/families/solana/walletApiAdapter.ts new file mode 100644 index 000000000000..3b5b36ea28cc --- /dev/null +++ b/libs/ledger-live-common/src/families/solana/walletApiAdapter.ts @@ -0,0 +1,26 @@ +import { SolanaTransaction as WalletAPISolanaTransaction } from "@ledgerhq/wallet-api-core"; +import { GetWalletAPITransactionSignFlowInfos } from "../../wallet-api/types"; +import { Transaction } from "@ledgerhq/coin-solana/types"; + +const CAN_EDIT_FEES = false; + +const HAS_FEES_PROVIDED = false; + +const getWalletAPITransactionSignFlowInfos: GetWalletAPITransactionSignFlowInfos< + WalletAPISolanaTransaction, + Transaction +> = ({ walletApiTransaction, account }) => { + const liveTx: Transaction = { ...walletApiTransaction }; + + if (!liveTx.subAccountId && account.type === "TokenAccount") { + liveTx.subAccountId = account.id; + } + + return { + canEditFees: CAN_EDIT_FEES, + liveTx, + hasFeesProvided: HAS_FEES_PROVIDED, + }; +}; + +export default { getWalletAPITransactionSignFlowInfos }; diff --git a/libs/ledger-live-common/src/generated/walletApiAdapter.ts b/libs/ledger-live-common/src/generated/walletApiAdapter.ts index 7ac7d0821085..b126b100ecbb 100644 --- a/libs/ledger-live-common/src/generated/walletApiAdapter.ts +++ b/libs/ledger-live-common/src/generated/walletApiAdapter.ts @@ -1,11 +1,13 @@ import bitcoin from "../families/bitcoin/walletApiAdapter"; import evm from "../families/evm/walletApiAdapter"; import polkadot from "../families/polkadot/walletApiAdapter"; +import solana from "../families/solana/walletApiAdapter"; import xrp from "../families/xrp/walletApiAdapter"; export default { bitcoin, evm, polkadot, + solana, xrp, }; diff --git a/libs/ledger-live-common/src/hw/actions/transaction.ts b/libs/ledger-live-common/src/hw/actions/transaction.ts index 3f5baf551b87..d70568dcc1f6 100644 --- a/libs/ledger-live-common/src/hw/actions/transaction.ts +++ b/libs/ledger-live-common/src/hw/actions/transaction.ts @@ -151,6 +151,7 @@ export const createAction = ( account: mainAccount, transaction, deviceId: device.deviceId, + deviceModelId: device.modelId, }) .pipe( catchError(error => diff --git a/libs/ledger-live-common/src/mock/fixtures/cryptoCurrencies.ts b/libs/ledger-live-common/src/mock/fixtures/cryptoCurrencies.ts index 605bd3288fc2..09ae0d7cde57 100644 --- a/libs/ledger-live-common/src/mock/fixtures/cryptoCurrencies.ts +++ b/libs/ledger-live-common/src/mock/fixtures/cryptoCurrencies.ts @@ -38,7 +38,7 @@ export function createFixtureCryptoCurrency(family: string): CryptoCurrency { } const defaultEthCryptoFamily = cryptocurrenciesById["ethereum"]; -const defaultERC20USDTToken = findTokenById["usd_tether__erc20_"]; +const defaultERC20USDTToken = findTokenById("ethereum/erc20/usd_tether__erc20_")!; export function createFixtureTokenAccount( id = "00", diff --git a/libs/ledger-live-common/src/wallet-api/ACRE/server.ts b/libs/ledger-live-common/src/wallet-api/ACRE/server.ts index 95ed2f2b388a..6afa197339f8 100644 --- a/libs/ledger-live-common/src/wallet-api/ACRE/server.ts +++ b/libs/ledger-live-common/src/wallet-api/ACRE/server.ts @@ -114,7 +114,7 @@ export const handlers = ({ const parentAccount = getParentAccount(account, accounts); const accountFamily = isTokenAccount(account) - ? parentAccount?.currency.family + ? account.token.parentCurrency.family : account.currency.family; const mainAccount = getMainAccount(account, parentAccount); @@ -123,7 +123,7 @@ export const handlers = ({ const { canEditFees, liveTx, hasFeesProvided } = getWalletAPITransactionSignFlowInfos({ walletApiTransaction: transaction, - account: mainAccount, + account, }); if (accountFamily !== liveTx.family) { diff --git a/libs/ledger-live-common/src/wallet-api/Exchange/server.ts b/libs/ledger-live-common/src/wallet-api/Exchange/server.ts index 16865d705120..cebf61396935 100644 --- a/libs/ledger-live-common/src/wallet-api/Exchange/server.ts +++ b/libs/ledger-live-common/src/wallet-api/Exchange/server.ts @@ -233,7 +233,7 @@ export const handlers = ({ const { liveTx } = getWalletAPITransactionSignFlowInfos({ walletApiTransaction: transaction, - account: mainFromAccount, + account: fromAccount, }); if (liveTx.family !== mainFromAccountFamily) { @@ -253,7 +253,7 @@ export const handlers = ({ const subAccountId = fromParentAccount && fromParentAccount.id !== fromAccount.id ? fromAccount.id : undefined; - const bridgeTx = accountBridge.createTransaction(mainFromAccount); + const bridgeTx = accountBridge.createTransaction(fromAccount); /** * We append the `recipient` to the tx created from `createTransaction` * to avoid having userGasLimit reset to null for ETH txs diff --git a/libs/ledger-live-common/src/wallet-api/logic.ts b/libs/ledger-live-common/src/wallet-api/logic.ts index dce1851fc44b..55c4b1bf9269 100644 --- a/libs/ledger-live-common/src/wallet-api/logic.ts +++ b/libs/ledger-live-common/src/wallet-api/logic.ts @@ -435,7 +435,7 @@ export function completeExchangeLogic( const { liveTx } = getWalletAPITransactionSignFlowInfos({ walletApiTransaction: transaction, - account: mainFromAccount, + account: fromAccount, }); if (liveTx.family !== mainFromAccountFamily) { @@ -452,7 +452,7 @@ export function completeExchangeLogic( */ const subAccountId = exchange.fromParentAccount ? fromAccount.id : undefined; - const bridgeTx = accountBridge.createTransaction(mainFromAccount); + const bridgeTx = accountBridge.createTransaction(fromAccount); /** * We append the `recipient` to the tx created from `createTransaction` * to avoid having userGasLimit reset to null for ETH txs diff --git a/libs/ledger-services/cal/src/certificate.integ.test.ts b/libs/ledger-services/cal/src/certificate.integ.test.ts index 086dc3119316..e8e15e1902f0 100644 --- a/libs/ledger-services/cal/src/certificate.integ.test.ts +++ b/libs/ledger-services/cal/src/certificate.integ.test.ts @@ -40,7 +40,7 @@ describe("getCertificate", () => { "returns all data in expected format for $device", async ({ device, descriptor, signature }) => { // When - const result = await getCertificate(device, "1.3.0", { env: "test", signatureKind: "test" }); + const result = await getCertificate(device, { env: "test", signatureKind: "test" }); // Then expect(result).toEqual({ diff --git a/libs/ledger-services/cal/src/certificate.ts b/libs/ledger-services/cal/src/certificate.ts index 56b74ba0a680..42a0937a9647 100644 --- a/libs/ledger-services/cal/src/certificate.ts +++ b/libs/ledger-services/cal/src/certificate.ts @@ -42,11 +42,9 @@ export type CertificateInfo = { /** * Retrieve PKI certificate * @param device - * @param version semver */ export async function getCertificate( device: Device, - version: string, { env = "prod", signatureKind = "prod", ref = undefined }: ServiceOption = DEFAULT_OPTION, ): Promise { const { data } = await network({ @@ -56,7 +54,7 @@ export async function getCertificate( output: "id,target_device,not_valid_after,public_key_usage,certificate_version,descriptor", target_device: DeviceModel[device], public_key_usage: "trusted_name", - note_valid_after: version, + latest: true, ref, }, }); diff --git a/libs/ledger-services/trust/src/index.ts b/libs/ledger-services/trust/src/index.ts index f2f45e4a7d65..d5844a51cbe1 100644 --- a/libs/ledger-services/trust/src/index.ts +++ b/libs/ledger-services/trust/src/index.ts @@ -4,8 +4,9 @@ * Use only exposed methods below outside of this module. */ -import { getOwnerAddress } from "./solana"; +import { getOwnerAddress, computedTokenAddress } from "./solana"; export default { getOwnerAddress, + computedTokenAddress, }; diff --git a/libs/ledgerjs/packages/cryptoassets/src/__snapshots__/currencies.test.ts.snap b/libs/ledgerjs/packages/cryptoassets/src/__snapshots__/currencies.test.ts.snap index f386a43d4b64..cf6adca489ed 100644 --- a/libs/ledgerjs/packages/cryptoassets/src/__snapshots__/currencies.test.ts.snap +++ b/libs/ledgerjs/packages/cryptoassets/src/__snapshots__/currencies.test.ts.snap @@ -8,6 +8,7 @@ exports[`all USDT are countervalue enabled 1`] = ` "elrond/esdt/555344542d663863303863", "ethereum/erc20/usd_tether__erc20_", "polygon/erc20/(pos)_tether_usd", + "solana/spl/Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", "ton/jetton/eqcxe6mutqjkfngfarotkot1lzbdiix1kcixrv7nw2id_sds", "tron/trc20/tr7nhqjekqxgtci8q8zy4pl8otszgjlj6t", ] diff --git a/libs/ledgerjs/packages/cryptoassets/src/abandonseed.ts b/libs/ledgerjs/packages/cryptoassets/src/abandonseed.ts index 7be448eae23c..24fadaab068e 100644 --- a/libs/ledgerjs/packages/cryptoassets/src/abandonseed.ts +++ b/libs/ledgerjs/packages/cryptoassets/src/abandonseed.ts @@ -42,6 +42,8 @@ const abandonSeedAddresses: Partial> = { zencash: "zngWJRgpBa45KUeRuCmdMsqti4ohhe9sVwC", bsc: EVM_DEAD_ADDRESS, solana: "GjJyeC1r2RgkuoCWMyPYkCWSGSGLcz266EaAkLA27AhL", + solana_testnet: "GjJyeC1r2RgkuoCWMyPYkCWSGSGLcz266EaAkLA27AhL", + solana_devnet: "GjJyeC1r2RgkuoCWMyPYkCWSGSGLcz266EaAkLA27AhL", polygon: EVM_DEAD_ADDRESS, crypto_org: "cro1r3ywhs4ng96dnm9zkc5y3etl7tps5cvvz26lr4", crypto_org_croeseid: "cro1r3ywhs4ng96dnm9zkc5y3etl7tps5cvvz26lr4", diff --git a/libs/ledgerjs/packages/cryptoassets/src/tokens.ts b/libs/ledgerjs/packages/cryptoassets/src/tokens.ts index 527e23420e05..a1da74195b3e 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,36 @@ function convertElrondESDTTokens([ }; } +function convertSplTokens([chainId, name, symbol, address, decimals]: SPLToken): TokenCurrency { + const chainIdToCurrencyId = { + // Fallback in case CAL is using chainIds for vault + 1: "solana", + 2: "solana_testnet", + 3: "solana_devnet", + 101: "solana", + 102: "solana_testnet", + 103: "solana_devnet", + }; + const currencyId = chainIdToCurrencyId[chainId]; + return { + type: "TokenCurrency", + id: `${currencyId}/spl/${address}`, + contractAddress: address, + parentCurrency: getCryptoCurrencyById(currencyId), + name, + tokenType: "spl", + ticker: symbol, + disableCountervalue: false, + units: [ + { + name, + code: symbol, + magnitude: decimals, + }, + ], + }; +} + function convertCardanoNativeTokens([ parentCurrencyId, policyId, diff --git a/libs/ledgerjs/packages/hw-app-solana/README.md b/libs/ledgerjs/packages/hw-app-solana/README.md index 58ad2c349e5b..7a9aa41b1cc9 100644 --- a/libs/ledgerjs/packages/hw-app-solana/README.md +++ b/libs/ledgerjs/packages/hw-app-solana/README.md @@ -53,6 +53,9 @@ If ledger returns error `6808` - enable blind signature in settings (not needed * [Examples](#examples-3) * [getAppConfiguration](#getappconfiguration) * [Examples](#examples-4) + * [getChallenge](#getchallenge) + * [provideTrustedName](#providetrustedname) + * [Parameters](#parameters-4) ### Solana @@ -135,3 +138,19 @@ solana.getAppConfiguration().then(r => r.version) ``` Returns **[Promise](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise)\** application config object + +#### getChallenge + +Method returning a 4 bytes TLV challenge as an hex string + +Returns **[Promise](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise)<[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)>** + +#### provideTrustedName + +Provides a trusted name to be displayed during transactions in place of the token address it is associated to. It shall be run just before a transaction involving the associated address that would be displayed on the device. + +##### Parameters + +* `data` **[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)** a stringified buffer of some TLV encoded data to represent the trusted name + +Returns **[Promise](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise)<[boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)>** a boolean diff --git a/libs/ledgerjs/packages/hw-app-solana/package.json b/libs/ledgerjs/packages/hw-app-solana/package.json index ca20b93ed8e9..da32869a4731 100644 --- a/libs/ledgerjs/packages/hw-app-solana/package.json +++ b/libs/ledgerjs/packages/hw-app-solana/package.json @@ -36,7 +36,6 @@ "@ledgerhq/hw-transport-node-speculos": "workspace:^", "@types/jest": "^29.5.10", "@types/node": "^20.8.10", - "axios": "1.7.7", "documentation": "14.0.2", "jest": "^29.7.0", "rimraf": "^4.4.1", diff --git a/libs/ledgerjs/packages/hw-app-solana/src/Solana.ts b/libs/ledgerjs/packages/hw-app-solana/src/Solana.ts index 3d6f6ef7c4e2..3e7c7b926081 100644 --- a/libs/ledgerjs/packages/hw-app-solana/src/Solana.ts +++ b/libs/ledgerjs/packages/hw-app-solana/src/Solana.ts @@ -7,6 +7,7 @@ import BIPPath from "bip32-path"; const P1_NON_CONFIRM = 0x00; const P1_CONFIRM = 0x01; +const P2_INIT = 0x00; const P2_EXTEND = 0x01; const P2_MORE = 0x02; @@ -19,6 +20,8 @@ const INS = { GET_ADDR: 0x05, SIGN: 0x06, SIGN_OFFCHAIN: 0x07, + GET_CHALLENGE: 0x20, + PROVIDE_TRUSTED_NAME: 0x21, }; enum EXTRA_STATUS_CODES { @@ -47,7 +50,13 @@ export default class Solana { this.transport = transport; this.transport.decorateAppAPIMethods( this, - ["getAddress", "signTransaction", "getAppConfiguration"], + [ + "getAddress", + "signTransaction", + "getAppConfiguration", + "getChallenge", + "provideTrustedName", + ], scrambleKey, ); } @@ -169,6 +178,44 @@ export default class Solana { }; } + /** + * Method returning a 4 bytes TLV challenge as an hex string + * + * @returns {Promise} + */ + async getChallenge(): Promise { + return this.transport.send(LEDGER_CLA, INS.GET_CHALLENGE, P1_NON_CONFIRM, P2_INIT).then(res => { + const data = res.toString("hex"); + const fourBytesChallenge = data.slice(0, -4); + const statusCode = data.slice(-4); + + if (statusCode !== "9000") { + throw new Error( + `An error happened while generating the challenge. Status code: ${statusCode}`, + ); + } + return `0x${fourBytesChallenge}`; + }); + } + + /** + * Provides a trusted name to be displayed during transactions in place of the token address it is associated to. It shall be run just before a transaction involving the associated address that would be displayed on the device. + * + * @param data a stringified buffer of some TLV encoded data to represent the trusted name + * @returns a boolean + */ + async provideTrustedName(data: string): Promise { + await this.transport.send( + LEDGER_CLA, + INS.PROVIDE_TRUSTED_NAME, + P1_NON_CONFIRM, + P2_INIT, + Buffer.from(data, "hex"), + ); + + return true; + } + private pathToBuffer(originalPath: string) { const path = originalPath .split("/") @@ -191,13 +238,13 @@ export default class Solana { private async sendToDevice(instruction: number, p1: number, payload: Buffer) { /* * By default transport will throw if status code is not OK. - * For some pyaloads we need to enable blind sign in the app settings + * For some payloads we need to enable blind sign in the app settings * and this is reported with StatusCodes.MISSING_CRITICAL_PARAMETER first byte prefix * so we handle it and show a user friendly error message. */ const acceptStatusList = [StatusCodes.OK, EXTRA_STATUS_CODES.BLIND_SIGNATURE_REQUIRED]; - let p2 = 0; + let p2 = P2_INIT; let payload_offset = 0; if (payload.length > MAX_PAYLOAD) { diff --git a/libs/ledgerjs/packages/types-live/src/bridge.ts b/libs/ledgerjs/packages/types-live/src/bridge.ts index 65089031046c..7e296ef14af5 100644 --- a/libs/ledgerjs/packages/types-live/src/bridge.ts +++ b/libs/ledgerjs/packages/types-live/src/bridge.ts @@ -6,7 +6,8 @@ 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 { DeviceModelId } from "@ledgerhq/types-devices"; +import type { AccountLike, Account, AccountRaw, TokenAccount, TokenAccountRaw } from "./account"; import type { SignOperationEvent, SignedOperation, @@ -68,6 +69,7 @@ export type SignOperationArg0 = account: A; transaction: T; deviceId: DeviceId; + deviceModelId?: DeviceModelId; }; /** @@ -202,6 +204,25 @@ interface SendReceiveAccountBridge< * @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; /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aebc98de4749..4cbc6b377dcb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4004,6 +4004,9 @@ importers: '@ledgerhq/hw-app-xrp': specifier: workspace:^ version: link:../ledgerjs/packages/hw-app-xrp + '@ledgerhq/hw-bolos': + specifier: workspace:* + version: link:../ledgerjs/packages/hw-bolos '@ledgerhq/hw-transport': specifier: workspace:^ version: link:../ledgerjs/packages/hw-transport @@ -4013,6 +4016,9 @@ importers: '@ledgerhq/ledger-cal-service': specifier: workspace:^ version: link:../ledger-services/cal + '@ledgerhq/ledger-trust-service': + specifier: workspace:* + version: link:../ledger-services/trust '@ledgerhq/live-app-sdk': specifier: ^0.8.1 version: 0.8.2 @@ -5236,9 +5242,6 @@ importers: '@types/node': specifier: ^20.8.10 version: 20.12.12 - axios: - specifier: 1.7.7 - version: 1.7.7 documentation: specifier: 14.0.2 version: 14.0.2 @@ -12891,8 +12894,6 @@ packages: '@react-native/babel-preset@0.74.87': resolution: {integrity: sha512-hyKpfqzN2nxZmYYJ0tQIHG99FQO0OWXp/gVggAfEUgiT+yNKas1C60LuofUsK7cd+2o9jrpqgqW4WzEDZoBlTg==} engines: {node: '>=18'} - peerDependencies: - '@babel/core': '*' '@react-native/babel-preset@0.75.4': resolution: {integrity: sha512-UtyYCDJ3rZIeggyFEfh/q5t/FZ5a1h9F8EI37Nbrwyk/OKPH+1XS4PbHROHJzBARlJwOAfmT75+ovYUO0eakJA==} @@ -26159,6 +26160,7 @@ packages: engines: {node: '>=0.6.0', teleport: '>=0.2.0'} deprecated: |- You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other. + (For a CapTP with native promises, see @endo/eventual-send and @endo/captp) qrcode-terminal@0.11.0: @@ -33185,7 +33187,7 @@ snapshots: outdent: 0.5.0 prettier: 2.8.8 resolve-from: 5.0.0 - semver: 7.5.4 + semver: 7.6.3 '@changesets/assemble-release-plan@6.0.3': dependencies: @@ -33195,7 +33197,7 @@ snapshots: '@changesets/should-skip-package': 0.1.0 '@changesets/types': 6.0.0 '@manypkg/get-packages': 1.1.3 - semver: 7.5.4 + semver: 7.6.3 '@changesets/changelog-git@0.2.0': dependencies: @@ -33240,7 +33242,7 @@ snapshots: p-limit: 2.3.0 preferred-pm: 3.1.4 resolve-from: 5.0.0 - semver: 7.5.4 + semver: 7.6.3 spawndamnit: 2.0.0 term-size: 2.2.1 @@ -33264,7 +33266,7 @@ snapshots: '@manypkg/get-packages': 1.1.3 chalk: 2.4.2 fs-extra: 7.0.1 - semver: 7.5.4 + semver: 7.6.3 '@changesets/get-github-info@0.6.0(patch_hash=7jzpsqogb5i6art53pk3h33ix4)': dependencies: @@ -39101,7 +39103,7 @@ snapshots: - '@babel/preset-env' - supports-color - '@react-native/babel-preset@0.74.87(@babel/core@7.24.3)': + '@react-native/babel-preset@0.74.87': dependencies: '@babel/core': 7.24.3 '@babel/plugin-proposal-async-generator-functions': 7.20.7(@babel/core@7.24.3) @@ -39150,7 +39152,7 @@ snapshots: - '@babel/preset-env' - supports-color - '@react-native/babel-preset@0.74.87(@babel/core@7.24.3)(@babel/preset-env@7.24.3(@babel/core@7.24.3))': + '@react-native/babel-preset@0.74.87(@babel/preset-env@7.24.3(@babel/core@7.24.3))': dependencies: '@babel/core': 7.24.3 '@babel/plugin-proposal-async-generator-functions': 7.20.7(@babel/core@7.24.3) @@ -39498,7 +39500,7 @@ snapshots: '@react-native/metro-babel-transformer@0.74.87': dependencies: '@babel/core': 7.24.3 - '@react-native/babel-preset': 0.74.87(@babel/core@7.24.3) + '@react-native/babel-preset': 0.74.87 hermes-parser: 0.19.1 nullthrows: 1.1.1 transitivePeerDependencies: @@ -44729,7 +44731,7 @@ snapshots: debug: 4.3.7 globby: 11.1.0 is-glob: 4.0.3 - semver: 7.5.4 + semver: 7.6.3 tsutils: 3.21.0(typescript@4.9.5) optionalDependencies: typescript: 4.9.5 @@ -44743,7 +44745,7 @@ snapshots: debug: 4.3.7 globby: 11.1.0 is-glob: 4.0.3 - semver: 7.5.4 + semver: 7.6.3 tsutils: 3.21.0(typescript@5.4.3) optionalDependencies: typescript: 5.4.3 @@ -44757,7 +44759,7 @@ snapshots: debug: 4.3.7 globby: 11.1.0 is-glob: 4.0.3 - semver: 7.5.4 + semver: 7.6.3 tsutils: 3.21.0(typescript@5.6.3) optionalDependencies: typescript: 5.6.3 @@ -46423,7 +46425,7 @@ snapshots: '@babel/plugin-transform-parameters': 7.25.9(@babel/core@7.24.3) '@babel/preset-react': 7.24.1(@babel/core@7.24.3) '@babel/preset-typescript': 7.24.1(@babel/core@7.24.3) - '@react-native/babel-preset': 0.74.87(@babel/core@7.24.3) + '@react-native/babel-preset': 0.74.87 babel-plugin-react-compiler: 0.0.0-experimental-592953e-20240517 babel-plugin-react-native-web: 0.19.13 react-refresh: 0.14.2 @@ -46440,7 +46442,7 @@ snapshots: '@babel/plugin-transform-parameters': 7.25.9(@babel/core@7.24.3) '@babel/preset-react': 7.24.1(@babel/core@7.24.3) '@babel/preset-typescript': 7.24.1(@babel/core@7.24.3) - '@react-native/babel-preset': 0.74.87(@babel/core@7.24.3)(@babel/preset-env@7.24.3(@babel/core@7.24.3)) + '@react-native/babel-preset': 0.74.87(@babel/preset-env@7.24.3(@babel/core@7.24.3)) babel-plugin-react-compiler: 0.0.0-experimental-592953e-20240517 babel-plugin-react-native-web: 0.19.13 react-refresh: 0.14.2 @@ -52722,7 +52724,7 @@ snapshots: '@babel/parser': 7.24.1 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 - semver: 7.5.4 + semver: 7.6.3 transitivePeerDependencies: - supports-color @@ -54434,7 +54436,7 @@ snapshots: jest-util: 29.7.0 natural-compare: 1.4.0 pretty-format: 29.7.0 - semver: 7.5.4 + semver: 7.6.3 transitivePeerDependencies: - metro - supports-color @@ -55985,7 +55987,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.5.4 + semver: 7.6.3 make-error@1.3.6: {}