diff --git a/.changeset/fast-comics-talk.md b/.changeset/fast-comics-talk.md new file mode 100644 index 000000000000..c0eda9677d74 --- /dev/null +++ b/.changeset/fast-comics-talk.md @@ -0,0 +1,16 @@ +--- +"@ledgerhq/types-cryptoassets": minor +"@ledgerhq/cryptoassets": minor +"@ledgerhq/hw-app-aptos": minor +"@ledgerhq/types-live": minor +"@ledgerhq/crypto-icons-ui": minor +"@actions/turbo-affected": minor +"ledger-live-desktop": minor +"live-mobile": minor +"@ledgerhq/live-common": minor +"@ledgerhq/coin-framework": minor +"@ledgerhq/live-cli": minor +"@ledgerhq/live-env": minor +--- + +Support for Aptos blockchain diff --git a/apps/cli/src/live-common-setup-base.ts b/apps/cli/src/live-common-setup-base.ts index 631f9ec6fdf4..ef2bffa7d770 100644 --- a/apps/cli/src/live-common-setup-base.ts +++ b/apps/cli/src/live-common-setup-base.ts @@ -10,6 +10,8 @@ import { WALLET_API_VERSION } from "@ledgerhq/live-common/wallet-api/constants"; setWalletAPIVersion(WALLET_API_VERSION); setSupportedCurrencies([ + "aptos", + "aptos_testnet", "bitcoin", "ethereum", "bsc", diff --git a/apps/ledger-live-desktop/scripts/resolver.js b/apps/ledger-live-desktop/scripts/resolver.js index 80593b093ddd..69679add607a 100644 --- a/apps/ledger-live-desktop/scripts/resolver.js +++ b/apps/ledger-live-desktop/scripts/resolver.js @@ -27,6 +27,7 @@ module.exports = (path, options) => { "@solana/codecs-numbers", "@solana/codecs-strings", "@solana/options", + "@aptos-labs/aptos-client", ]); if (pkgNamesToTarget.has(pkg.name)) { 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..ee9a07e1394b 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 @@ -56,6 +56,8 @@ setSupportedCurrencies([ "songbird", "flare", "near", + "aptos", + "aptos_testnet", "icon", "icon_berlin_testnet", "optimism", 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..f2b78eaba678 100644 --- a/apps/ledger-live-desktop/src/renderer/components/OperationsList/ConfirmationCheck.tsx +++ b/apps/ledger-live-desktop/src/renderer/components/OperationsList/ConfirmationCheck.tsx @@ -15,6 +15,7 @@ import IconFees from "~/renderer/icons/Fees"; import IconTrash from "~/renderer/icons/Trash"; import IconLink from "~/renderer/icons/LinkIcon"; import IconCoins from "~/renderer/icons/Coins"; +import IconCheck from "~/renderer/icons/Check"; import Freeze from "~/renderer/icons/Freeze"; import Unfreeze from "~/renderer/icons/Unfreeze"; import Box from "~/renderer/components/Box"; @@ -120,6 +121,7 @@ const iconsComponent = { STAKE: IconDelegate, UNSTAKE: IconUndelegate, WITHDRAW_UNSTAKED: IconCoins, + UNKNOWN: IconCheck, }; class ConfirmationCheck extends PureComponent<{ marketColor: string; diff --git a/apps/ledger-live-desktop/src/renderer/families/aptos/index.ts b/apps/ledger-live-desktop/src/renderer/families/aptos/index.ts new file mode 100644 index 000000000000..481721d45aa3 --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/families/aptos/index.ts @@ -0,0 +1,5 @@ +import { AptosFamily } from "./types"; + +const family: AptosFamily = {}; + +export default family; diff --git a/apps/ledger-live-desktop/src/renderer/families/aptos/types.ts b/apps/ledger-live-desktop/src/renderer/families/aptos/types.ts new file mode 100644 index 000000000000..accb07ebf020 --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/families/aptos/types.ts @@ -0,0 +1,14 @@ +import { + AptosAccount, + Transaction, + TransactionStatus, +} from "@ledgerhq/live-common/families/aptos/types"; +import { Operation } from "@ledgerhq/types-live"; +import { FieldComponentProps, LLDCoinFamily } from "../types"; + +export type AptosFamily = LLDCoinFamily; +export type AptosFieldComponentProps = FieldComponentProps< + AptosAccount, + Transaction, + TransactionStatus +>; diff --git a/apps/ledger-live-desktop/src/renderer/modals/AddAccounts/steps/StepChooseCurrency.tsx b/apps/ledger-live-desktop/src/renderer/modals/AddAccounts/steps/StepChooseCurrency.tsx index 50d28cde6e2c..acf467625dad 100644 --- a/apps/ledger-live-desktop/src/renderer/modals/AddAccounts/steps/StepChooseCurrency.tsx +++ b/apps/ledger-live-desktop/src/renderer/modals/AddAccounts/steps/StepChooseCurrency.tsx @@ -35,6 +35,8 @@ const listSupportedTokens = () => const StepChooseCurrency = ({ currency, setCurrency }: StepProps) => { const mock = useEnv("MOCK"); + const aptos = useFeature("currencyAptos"); + const aptosTestnet = useFeature("currencyAptosTestnet"); const axelar = useFeature("currencyAxelar"); const stargaze = useFeature("currencyStargaze"); const secretNetwork = useFeature("currencySecretNetwork"); @@ -89,6 +91,8 @@ const StepChooseCurrency = ({ currency, setCurrency }: StepProps) => { const featureFlaggedCurrencies = useMemo( (): Partial | null>> => ({ + aptos, + aptos_testnet: aptosTestnet, axelar, stargaze, secret_network: secretNetwork, @@ -142,6 +146,8 @@ const StepChooseCurrency = ({ currency, setCurrency }: StepProps) => { xion, }), [ + aptos, + aptosTestnet, axelar, stargaze, secretNetwork, diff --git a/apps/ledger-live-desktop/static/i18n/en/app.json b/apps/ledger-live-desktop/static/i18n/en/app.json index 5b48eddb6c29..b04807f6c671 100644 --- a/apps/ledger-live-desktop/static/i18n/en/app.json +++ b/apps/ledger-live-desktop/static/i18n/en/app.json @@ -1842,7 +1842,8 @@ "withdrawUnbondedAmount": "Withdrawn Amount", "palletMethod": "Method", "transferAmount": "Transfer Amount", - "validatorsCount": "Validators ({{number}})" + "validatorsCount": "Validators ({{number}})", + "version": "Version" } }, "operationList": { @@ -5815,6 +5816,16 @@ "title": "Account not scanned by full node", "description": "Please configure your full node to scan for the accounts associated with this device. Your full node must first scan the blockchain for this account before you can add it to your portfolio." }, + "SequenceNumberTooNew": { + "title": "Sequence number too new" + }, + "SequenceNumberTooOld": { + "title": "Sequence number too old", + "description": "Sequence number too old" + }, + "TransactionExpired": { + "title": "Transaction expired" + }, "SwapRateExpiredError": { "title": "Rate Expired", "description": "Exchange rate expired. Please refresh and try again." diff --git a/apps/ledger-live-desktop/tests/specs/speculos/send.tx.spec.ts b/apps/ledger-live-desktop/tests/specs/speculos/send.tx.spec.ts index 5901aa174c4e..10027d5cb0a5 100644 --- a/apps/ledger-live-desktop/tests/specs/speculos/send.tx.spec.ts +++ b/apps/ledger-live-desktop/tests/specs/speculos/send.tx.spec.ts @@ -194,6 +194,13 @@ const transactionE2E = [ transaction: new Transaction(Account.XRP_1, Account.XRP_2, "0.0001", undefined, "noTag"), xrayTicket: "B2CQA-2816", }, + /* + TODO: https://ledgerhq.atlassian.net/browse/LIVE-15624 needs to be done to enable it + { + transaction: new Transaction(Account.APTOS_1, Account.APTOS_2, "0.0001"), + xrayTicket: "B2CQA-2920", + }, + */ ]; const tokenTransactionInvalid = [ diff --git a/apps/ledger-live-mobile/scripts/resolver.js b/apps/ledger-live-mobile/scripts/resolver.js index 086c1886b28d..b8860e752587 100644 --- a/apps/ledger-live-mobile/scripts/resolver.js +++ b/apps/ledger-live-mobile/scripts/resolver.js @@ -13,6 +13,7 @@ module.exports = (path, options) => { "@solana/codecs-numbers", "@solana/codecs-strings", "@solana/options", + "@aptos-labs/aptos-client", ]); if (pkgNamesToTarget.has(pkg.name)) { diff --git a/apps/ledger-live-mobile/src/live-common-setup.ts b/apps/ledger-live-mobile/src/live-common-setup.ts index 9010091d9022..839cab8a453a 100644 --- a/apps/ledger-live-mobile/src/live-common-setup.ts +++ b/apps/ledger-live-mobile/src/live-common-setup.ts @@ -118,6 +118,8 @@ setSupportedCurrencies([ "casper", "neon_evm", "lukso", + "aptos", + "aptos_testnet", "linea", "linea_sepolia", "blast", diff --git a/apps/ledger-live-mobile/src/locales/en/common.json b/apps/ledger-live-mobile/src/locales/en/common.json index 75e4b3a4ef62..84cf876bc324 100644 --- a/apps/ledger-live-mobile/src/locales/en/common.json +++ b/apps/ledger-live-mobile/src/locales/en/common.json @@ -971,6 +971,18 @@ "title": "Invalid Provider", "description": "You have to change \"My Ledger provider\" setting. To change it, open Ledger Live \"Settings\", select \"Experimental features\", and then select a different provider." }, + "GasLessThanEstimate": { + "title": "This may be too low. Please increase" + }, + "SequenceNumberTooNew": { + "title": "Sequence number too new" + }, + "SequenceNumberTooOld": { + "title": "Sequence number too old" + }, + "TransactionExpired": { + "title": "Transaction expired" + }, "SequenceNumberError": { "title": "Sequence number error", "description": "Please close the window and try again later" @@ -3762,6 +3774,7 @@ "to": "To", "infoTotalTitle": "Total debit", "infoTotalDesc": "Includes transaction amount and the selected network fees", + "gasFee": "Gas fee", "gasLimit": "Gas limit", "gasPrice": "Gas price", "maxFee": "Max fee", diff --git a/apps/ledger-live-mobile/src/screens/AddAccounts/01-SelectCrypto.tsx b/apps/ledger-live-mobile/src/screens/AddAccounts/01-SelectCrypto.tsx index 6e930fb44c88..151fa103a833 100644 --- a/apps/ledger-live-mobile/src/screens/AddAccounts/01-SelectCrypto.tsx +++ b/apps/ledger-live-mobile/src/screens/AddAccounts/01-SelectCrypto.tsx @@ -57,6 +57,8 @@ export default function AddAccountsSelectCrypto({ navigation, route }: Props) { const mock = useEnv("MOCK"); + const aptos = useFeature("currencyAptos"); + const aptosTestnet = useFeature("currencyAptosTestnet"); const axelar = useFeature("currencyAxelar"); const stargaze = useFeature("currencyStargaze"); const secretNetwork = useFeature("currencySecretNetwork"); @@ -111,6 +113,8 @@ export default function AddAccountsSelectCrypto({ navigation, route }: Props) { const featureFlaggedCurrencies = useMemo( (): Partial | null>> => ({ + aptos, + aptos_testnet: aptosTestnet, axelar, stargaze, umee, @@ -164,6 +168,8 @@ export default function AddAccountsSelectCrypto({ navigation, route }: Props) { xion, }), [ + aptos, + aptosTestnet, axelar, stargaze, umee, diff --git a/libs/coin-framework/src/currencies/__snapshots__/formatCurrencyUnit.test.ts.snap b/libs/coin-framework/src/currencies/__snapshots__/formatCurrencyUnit.test.ts.snap index 79f5a23ede14..ebc686395bfc 100644 --- a/libs/coin-framework/src/currencies/__snapshots__/formatCurrencyUnit.test.ts.snap +++ b/libs/coin-framework/src/currencies/__snapshots__/formatCurrencyUnit.test.ts.snap @@ -6,6 +6,10 @@ exports[`formatCurrencyUnit with custom options with locale de-DE should correct exports[`formatCurrencyUnit with custom options with locale de-DE should correctly format Algorand unit (ALGO) 1`] = `"12.345.678.900,000000- -ALGO"`; +exports[`formatCurrencyUnit with custom options with locale de-DE should correctly format Aptos (Testnet) unit (APT) 1`] = `"123.456.789,00000000- -APT"`; + +exports[`formatCurrencyUnit with custom options with locale de-DE should correctly format Aptos unit (APT) 1`] = `"123.456.789,00000000- -APT"`; + exports[`formatCurrencyUnit with custom options with locale de-DE should correctly format Arbitrum Sepolia unit (ether) 1`] = `"0,012345678900000000- -𝚝ETH"`; exports[`formatCurrencyUnit with custom options with locale de-DE should correctly format Arbitrum unit (ETH) 1`] = `"0,012345678900000000- -ETH"`; @@ -340,6 +344,10 @@ exports[`formatCurrencyUnit with custom options with locale en-US should correct exports[`formatCurrencyUnit with custom options with locale en-US should correctly format Algorand unit (ALGO) 1`] = `"12,345,678,900.000000- -ALGO"`; +exports[`formatCurrencyUnit with custom options with locale en-US should correctly format Aptos (Testnet) unit (APT) 1`] = `"123,456,789.00000000- -APT"`; + +exports[`formatCurrencyUnit with custom options with locale en-US should correctly format Aptos unit (APT) 1`] = `"123,456,789.00000000- -APT"`; + exports[`formatCurrencyUnit with custom options with locale en-US should correctly format Arbitrum Sepolia unit (ether) 1`] = `"0.012345678900000000- -𝚝ETH"`; exports[`formatCurrencyUnit with custom options with locale en-US should correctly format Arbitrum unit (ETH) 1`] = `"0.012345678900000000- -ETH"`; @@ -674,6 +682,10 @@ exports[`formatCurrencyUnit with custom options with locale es-ES should correct exports[`formatCurrencyUnit with custom options with locale es-ES should correctly format Algorand unit (ALGO) 1`] = `"12.345.678.900,000000- -ALGO"`; +exports[`formatCurrencyUnit with custom options with locale es-ES should correctly format Aptos (Testnet) unit (APT) 1`] = `"123.456.789,00000000- -APT"`; + +exports[`formatCurrencyUnit with custom options with locale es-ES should correctly format Aptos unit (APT) 1`] = `"123.456.789,00000000- -APT"`; + exports[`formatCurrencyUnit with custom options with locale es-ES should correctly format Arbitrum Sepolia unit (ether) 1`] = `"0,012345678900000000- -𝚝ETH"`; exports[`formatCurrencyUnit with custom options with locale es-ES should correctly format Arbitrum unit (ETH) 1`] = `"0,012345678900000000- -ETH"`; @@ -1008,6 +1020,10 @@ exports[`formatCurrencyUnit with custom options with locale fr-FR should correct exports[`formatCurrencyUnit with custom options with locale fr-FR should correctly format Algorand unit (ALGO) 1`] = `"12 345 678 900,000000- -ALGO"`; +exports[`formatCurrencyUnit with custom options with locale fr-FR should correctly format Aptos (Testnet) unit (APT) 1`] = `"123 456 789,00000000- -APT"`; + +exports[`formatCurrencyUnit with custom options with locale fr-FR should correctly format Aptos unit (APT) 1`] = `"123 456 789,00000000- -APT"`; + exports[`formatCurrencyUnit with custom options with locale fr-FR should correctly format Arbitrum Sepolia unit (ether) 1`] = `"0,012345678900000000- -𝚝ETH"`; exports[`formatCurrencyUnit with custom options with locale fr-FR should correctly format Arbitrum unit (ETH) 1`] = `"0,012345678900000000- -ETH"`; @@ -1342,6 +1358,10 @@ exports[`formatCurrencyUnit with custom options with locale ja-JP should correct exports[`formatCurrencyUnit with custom options with locale ja-JP should correctly format Algorand unit (ALGO) 1`] = `"12,345,678,900.000000- -ALGO"`; +exports[`formatCurrencyUnit with custom options with locale ja-JP should correctly format Aptos (Testnet) unit (APT) 1`] = `"123,456,789.00000000- -APT"`; + +exports[`formatCurrencyUnit with custom options with locale ja-JP should correctly format Aptos unit (APT) 1`] = `"123,456,789.00000000- -APT"`; + exports[`formatCurrencyUnit with custom options with locale ja-JP should correctly format Arbitrum Sepolia unit (ether) 1`] = `"0.012345678900000000- -𝚝ETH"`; exports[`formatCurrencyUnit with custom options with locale ja-JP should correctly format Arbitrum unit (ETH) 1`] = `"0.012345678900000000- -ETH"`; @@ -1676,6 +1696,10 @@ exports[`formatCurrencyUnit with custom options with locale ko-KR should correct exports[`formatCurrencyUnit with custom options with locale ko-KR should correctly format Algorand unit (ALGO) 1`] = `"12,345,678,900.000000- -ALGO"`; +exports[`formatCurrencyUnit with custom options with locale ko-KR should correctly format Aptos (Testnet) unit (APT) 1`] = `"123,456,789.00000000- -APT"`; + +exports[`formatCurrencyUnit with custom options with locale ko-KR should correctly format Aptos unit (APT) 1`] = `"123,456,789.00000000- -APT"`; + exports[`formatCurrencyUnit with custom options with locale ko-KR should correctly format Arbitrum Sepolia unit (ether) 1`] = `"0.012345678900000000- -𝚝ETH"`; exports[`formatCurrencyUnit with custom options with locale ko-KR should correctly format Arbitrum unit (ETH) 1`] = `"0.012345678900000000- -ETH"`; @@ -2010,6 +2034,10 @@ exports[`formatCurrencyUnit with custom options with locale pt-BR should correct exports[`formatCurrencyUnit with custom options with locale pt-BR should correctly format Algorand unit (ALGO) 1`] = `"12.345.678.900,000000- -ALGO"`; +exports[`formatCurrencyUnit with custom options with locale pt-BR should correctly format Aptos (Testnet) unit (APT) 1`] = `"123.456.789,00000000- -APT"`; + +exports[`formatCurrencyUnit with custom options with locale pt-BR should correctly format Aptos unit (APT) 1`] = `"123.456.789,00000000- -APT"`; + exports[`formatCurrencyUnit with custom options with locale pt-BR should correctly format Arbitrum Sepolia unit (ether) 1`] = `"0,012345678900000000- -𝚝ETH"`; exports[`formatCurrencyUnit with custom options with locale pt-BR should correctly format Arbitrum unit (ETH) 1`] = `"0,012345678900000000- -ETH"`; @@ -2344,6 +2372,10 @@ exports[`formatCurrencyUnit with custom options with locale ru-RU should correct exports[`formatCurrencyUnit with custom options with locale ru-RU should correctly format Algorand unit (ALGO) 1`] = `"12 345 678 900,000000- -ALGO"`; +exports[`formatCurrencyUnit with custom options with locale ru-RU should correctly format Aptos (Testnet) unit (APT) 1`] = `"123 456 789,00000000- -APT"`; + +exports[`formatCurrencyUnit with custom options with locale ru-RU should correctly format Aptos unit (APT) 1`] = `"123 456 789,00000000- -APT"`; + exports[`formatCurrencyUnit with custom options with locale ru-RU should correctly format Arbitrum Sepolia unit (ether) 1`] = `"0,012345678900000000- -𝚝ETH"`; exports[`formatCurrencyUnit with custom options with locale ru-RU should correctly format Arbitrum unit (ETH) 1`] = `"0,012345678900000000- -ETH"`; @@ -2678,6 +2710,10 @@ exports[`formatCurrencyUnit with custom options with locale tr-TR should correct exports[`formatCurrencyUnit with custom options with locale tr-TR should correctly format Algorand unit (ALGO) 1`] = `"12.345.678.900,000000- -ALGO"`; +exports[`formatCurrencyUnit with custom options with locale tr-TR should correctly format Aptos (Testnet) unit (APT) 1`] = `"123.456.789,00000000- -APT"`; + +exports[`formatCurrencyUnit with custom options with locale tr-TR should correctly format Aptos unit (APT) 1`] = `"123.456.789,00000000- -APT"`; + exports[`formatCurrencyUnit with custom options with locale tr-TR should correctly format Arbitrum Sepolia unit (ether) 1`] = `"0,012345678900000000- -𝚝ETH"`; exports[`formatCurrencyUnit with custom options with locale tr-TR should correctly format Arbitrum unit (ETH) 1`] = `"0,012345678900000000- -ETH"`; @@ -3012,6 +3048,10 @@ exports[`formatCurrencyUnit with custom options with locale zh-CN should correct exports[`formatCurrencyUnit with custom options with locale zh-CN should correctly format Algorand unit (ALGO) 1`] = `"12,345,678,900.000000- -ALGO"`; +exports[`formatCurrencyUnit with custom options with locale zh-CN should correctly format Aptos (Testnet) unit (APT) 1`] = `"123,456,789.00000000- -APT"`; + +exports[`formatCurrencyUnit with custom options with locale zh-CN should correctly format Aptos unit (APT) 1`] = `"123,456,789.00000000- -APT"`; + exports[`formatCurrencyUnit with custom options with locale zh-CN should correctly format Arbitrum Sepolia unit (ether) 1`] = `"0.012345678900000000- -𝚝ETH"`; exports[`formatCurrencyUnit with custom options with locale zh-CN should correctly format Arbitrum unit (ETH) 1`] = `"0.012345678900000000- -ETH"`; @@ -3346,6 +3386,10 @@ exports[`formatCurrencyUnit with default options should correctly format Akroma exports[`formatCurrencyUnit with default options should correctly format Algorand unit (ALGO) 1`] = `"12,345,678,900"`; +exports[`formatCurrencyUnit with default options should correctly format Aptos (Testnet) unit (APT) 1`] = `"123,456,789"`; + +exports[`formatCurrencyUnit with default options should correctly format Aptos unit (APT) 1`] = `"123,456,789"`; + exports[`formatCurrencyUnit with default options should correctly format Arbitrum Sepolia unit (ether) 1`] = `"0.0123456"`; exports[`formatCurrencyUnit with default options should correctly format Arbitrum unit (ETH) 1`] = `"0.0123456"`; diff --git a/libs/coin-framework/src/derivation.ts b/libs/coin-framework/src/derivation.ts index 72c57c9df874..20d7a4f27ba5 100644 --- a/libs/coin-framework/src/derivation.ts +++ b/libs/coin-framework/src/derivation.ts @@ -176,6 +176,9 @@ const modes: Readonly> = Object.freeze({ startsAt: 1, tag: "third-party", }, + aptos: { + overridesDerivation: "44'/637'/'/0'/0'", + }, ton: { overridesDerivation: "44'/607'/0'/0'/'/0'", }, diff --git a/libs/env/src/env.ts b/libs/env/src/env.ts index 2b3d265ba6aa..41bbcad3edb6 100644 --- a/libs/env/src/env.ts +++ b/libs/env/src/env.ts @@ -62,6 +62,26 @@ const envDefinitions = { parser: stringParser, desc: "Rosetta API for ICP", }, + APTOS_API_ENDPOINT: { + def: "https://apt.coin.ledger.com/node/v1", + parser: stringParser, + desc: "API enpoint for Aptos", + }, + APTOS_TESTNET_API_ENDPOINT: { + def: "https://apt.coin.ledger-stg.com/node/v1", + parser: stringParser, + desc: "API enpoint for Aptos", + }, + APTOS_INDEXER_ENDPOINT: { + def: "https://apt.coin.ledger.com/node/v1/graphql", + parser: stringParser, + desc: "Indexer endpoint for Aptos", + }, + APTOS_TESTNET_INDEXER_ENDPOINT: { + def: "https://apt.coin.ledger-stg.com/node/v1/graphql", + parser: stringParser, + desc: "Indexer endpoint for Aptos", + }, API_CASPER_INDEXER_ENDPOINT: { parser: stringParser, def: "https://casper.coin.ledger.com/indexer", diff --git a/libs/ledger-live-common/package.json b/libs/ledger-live-common/package.json index 4897434db0ec..024358a3515a 100644 --- a/libs/ledger-live-common/package.json +++ b/libs/ledger-live-common/package.json @@ -120,6 +120,8 @@ "https": false }, "dependencies": { + "@apollo/client": "^3.8.7", + "@aptos-labs/ts-sdk": "^1.33.1", "@blooo/hw-app-acre": "^1.1.1", "@cardano-foundation/ledgerjs-hw-app-cardano": "^7.1.2", "@celo/connect": "^6.0.2", @@ -157,6 +159,7 @@ "@ledgerhq/devices": "workspace:*", "@ledgerhq/errors": "workspace:^", "@ledgerhq/hw-app-algorand": "workspace:^", + "@ledgerhq/hw-app-aptos": "workspace:^", "@ledgerhq/hw-app-btc": "workspace:^", "@ledgerhq/hw-app-cosmos": "workspace:^", "@ledgerhq/hw-app-elrond": "workspace:^", @@ -192,6 +195,7 @@ "@ledgerhq/wallet-api-core": "^1.13.0", "@ledgerhq/wallet-api-exchange-module": "workspace:^", "@ledgerhq/wallet-api-server": "^1.6.0", + "@noble/hashes": "1.6.1", "@stricahq/typhonjs": "^2.0.0", "@taquito/ledger-signer": "^20.0.0", "@ton-community/ton-ledger": "^7.0.1", @@ -219,6 +223,7 @@ "date-fns": "^2.23.0", "expect": "^27.4.6", "fuse.js": "^6.6.2", + "graphql": "^16.8.1", "invariant": "^2.2.2", "isomorphic-ws": "^4.0.1", "jotai": "^2.10.1", diff --git a/libs/ledger-live-common/src/__tests__/test-helpers/environment.ts b/libs/ledger-live-common/src/__tests__/test-helpers/environment.ts index 42528c9f6990..89ae62b89195 100644 --- a/libs/ledger-live-common/src/__tests__/test-helpers/environment.ts +++ b/libs/ledger-live-common/src/__tests__/test-helpers/environment.ts @@ -1,11 +1,11 @@ -import winston from "winston"; +import { LiveConfig } from "@ledgerhq/live-config/LiveConfig"; +import { EnvName, setEnv, setEnvUnsafe } from "@ledgerhq/live-env"; import { listen } from "@ledgerhq/logs"; +import winston from "winston"; +import { liveConfig } from "../../config/sharedConfig"; import { setSupportedCurrencies } from "../../currencies"; -import { EnvName, setEnvUnsafe, setEnv } from "@ledgerhq/live-env"; -import { setWalletAPIVersion } from "../../wallet-api/version"; import { WALLET_API_VERSION } from "../../wallet-api/constants"; -import { LiveConfig } from "@ledgerhq/live-config/LiveConfig"; -import { liveConfig } from "../../config/sharedConfig"; +import { setWalletAPIVersion } from "../../wallet-api/version"; setWalletAPIVersion(WALLET_API_VERSION); setSupportedCurrencies([ @@ -104,6 +104,8 @@ setSupportedCurrencies([ "zksync", "zksync_sepolia", "mantra", + "aptos", + "aptos_testnet", "xion", ]); LiveConfig.setConfig(liveConfig); 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..ee464e3b5b74 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 @@ -1584,6 +1584,7 @@ exports[`sortCurrenciesByIds snapshot 1`] = ` "ethereum/erc20/insurace", "ethereum/erc20/holotoken", "ethereum/erc20/etoro_euro", + "aptos", "near", "avalanche_c_chain", "banano", diff --git a/libs/ledger-live-common/src/e2e/enum/Account.ts b/libs/ledger-live-common/src/e2e/enum/Account.ts index 99212cf94397..fbd7b54aa8d4 100644 --- a/libs/ledger-live-common/src/e2e/enum/Account.ts +++ b/libs/ledger-live-common/src/e2e/enum/Account.ts @@ -14,6 +14,22 @@ export class Account { public readonly nft?: Nft[], ) {} + static readonly APTOS_1 = new Account( + Currency.APT, + "Aptos 1", + "0x17457f3e93cbd37f5c714a89b92b86a478f1350918c37e0523fff83b66f21027", + undefined, + 0, + ); + + static readonly APTOS_2 = new Account( + Currency.APT, + "Aptos 2", + "0x6c0e2e27005620ea8e0f11762687b0e5483b721c8407dc855c6f7127f8881371", + undefined, + 1, + ); + static readonly BTC_NATIVE_SEGWIT_1 = new Account( Currency.BTC, "Bitcoin 1", diff --git a/libs/ledger-live-common/src/e2e/enum/AppInfos.ts b/libs/ledger-live-common/src/e2e/enum/AppInfos.ts index f4019c7e5eba..bb3b9e1ea2be 100644 --- a/libs/ledger-live-common/src/e2e/enum/AppInfos.ts +++ b/libs/ledger-live-common/src/e2e/enum/AppInfos.ts @@ -3,6 +3,8 @@ export class AppInfos { static readonly BITCOIN = new AppInfos("Bitcoin"); + static readonly APTOS = new AppInfos("Aptos"); + static readonly BITCOIN_TESTNET = new AppInfos("Bitcoin Test"); static readonly DOGECOIN = new AppInfos("Dogecoin"); diff --git a/libs/ledger-live-common/src/e2e/enum/Currency.ts b/libs/ledger-live-common/src/e2e/enum/Currency.ts index fa11b4f14c69..c4f91221d7aa 100644 --- a/libs/ledger-live-common/src/e2e/enum/Currency.ts +++ b/libs/ledger-live-common/src/e2e/enum/Currency.ts @@ -7,8 +7,11 @@ export class Currency { public readonly currencyId: string, public readonly speculosApp: AppInfos, ) {} + static readonly BTC = new Currency("Bitcoin", "BTC", "bitcoin", AppInfos.BITCOIN); + static readonly APT = new Currency("Aptos", "APT", "aptos", AppInfos.APTOS); + static readonly tBTC = new Currency( "Bitcoin Testnet", "𝚝BTC", diff --git a/libs/ledger-live-common/src/e2e/families/aptos.ts b/libs/ledger-live-common/src/e2e/families/aptos.ts new file mode 100644 index 000000000000..4b3a7868d620 --- /dev/null +++ b/libs/ledger-live-common/src/e2e/families/aptos.ts @@ -0,0 +1,7 @@ +import { pressBoth, pressUntilTextFound } from "../speculos"; +import { DeviceLabels } from "../enum/DeviceLabels"; + +export async function sendAptos() { + await pressUntilTextFound(DeviceLabels.APPROVE); + await pressBoth(); +} diff --git a/libs/ledger-live-common/src/e2e/speculos.ts b/libs/ledger-live-common/src/e2e/speculos.ts index 21d29da34210..71b68ffad5e7 100644 --- a/libs/ledger-live-common/src/e2e/speculos.ts +++ b/libs/ledger-live-common/src/e2e/speculos.ts @@ -26,6 +26,7 @@ import { sendTron } from "./families/tron"; import { sendStellar } from "./families/stellar"; import { sendCardano } from "./families/cardano"; import { sendXRP } from "./families/xrp"; +import { sendAptos } from "./families/aptos"; import { delegateNear } from "./families/near"; import { delegateCosmos, sendCosmos } from "./families/cosmos"; import { delegateSolana, sendSolana } from "./families/solana"; @@ -76,6 +77,14 @@ export const specs: Specs = { }, dependency: "", }, + Aptos: { + currency: getCryptoCurrencyById("aptos"), + appQuery: { + model: DeviceModelId.nanoSP, + appName: "Aptos", + }, + dependency: "", + }, Exchange: { appQuery: { model: DeviceModelId.nanoSP, @@ -519,6 +528,9 @@ export async function signSendTransaction(tx: Transaction) { case Currency.XRP: await sendXRP(tx); break; + case Currency.APT: + await sendAptos(); + break; default: throw new Error(`Unsupported currency: ${currencyName}`); } diff --git a/libs/ledger-live-common/src/families/aptos/LedgerAccount.test.ts b/libs/ledger-live-common/src/families/aptos/LedgerAccount.test.ts new file mode 100644 index 000000000000..e43582e7e7b3 --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/LedgerAccount.test.ts @@ -0,0 +1,37 @@ +import LedgerAccount from "./LedgerAccount"; + +jest.mock("./LedgerAccount"); +const mockedLedgerAccount = jest.mocked(LedgerAccount); + +describe("LedgerAccount Test", () => { + let account: LedgerAccount; + + beforeAll(() => { + account = new LedgerAccount("", ""); + }); + + it("builds the client properly", () => { + expect(LedgerAccount.fromLedgerConnection).toBeDefined(); + expect(typeof LedgerAccount.fromLedgerConnection).toBe("function"); + expect(account.init).toBeDefined(); + expect(typeof account.init).toBe("function"); + expect(account.toAptosAccount).toBeDefined(); + expect(typeof account.toAptosAccount).toBe("function"); + expect(account.hdWalletPath).toBeDefined(); + expect(typeof account.hdWalletPath).toBe("function"); + expect(account.address).toBeDefined(); + expect(typeof account.address).toBe("function"); + expect(account.authKey).toBeDefined(); + expect(typeof account.authKey).toBe("function"); + expect(account.pubKey).toBeDefined(); + expect(typeof account.pubKey).toBe("function"); + expect(account.asyncSignBuffer).toBeDefined(); + expect(typeof account.asyncSignBuffer).toBe("function"); + expect(account.asyncSignHexString).toBeDefined(); + expect(typeof account.asyncSignHexString).toBe("function"); + expect(account.signTransaction).toBeDefined(); + expect(typeof account.signTransaction).toBe("function"); + + expect(mockedLedgerAccount).toHaveBeenCalledTimes(1); + }); +}); diff --git a/libs/ledger-live-common/src/families/aptos/LedgerAccount.ts b/libs/ledger-live-common/src/families/aptos/LedgerAccount.ts new file mode 100644 index 000000000000..29d949fc6a2a --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/LedgerAccount.ts @@ -0,0 +1,103 @@ +import { + Account, + AccountAddress, + AccountAuthenticatorEd25519, + Ed25519PublicKey, + Ed25519Signature, + Hex, + RawTransaction, + SimpleTransaction, + generateSignedTransaction, + generateSigningMessageForTransaction, +} from "@aptos-labs/ts-sdk"; +import HwAptos from "@ledgerhq/hw-app-aptos"; +import Transport from "@ledgerhq/hw-transport"; +import { sha3_256 as sha3Hash } from "@noble/hashes/sha3"; + +export default class LedgerAccount { + private readonly hdPath: string; + + private client?: HwAptos; + private publicKey: Buffer = Buffer.from([]); + private accountAddress: AccountAddress = new AccountAddress( + new Uint8Array(AccountAddress.LENGTH), + ); + + static async fromLedgerConnection(transport: Transport, path: string): Promise { + const account = new LedgerAccount(path); + await account.init(transport); + return account; + } + + toAptosAccount(): Account { + return this as unknown as Account; + } + + constructor(path: string, pubKey?: string) { + this.hdPath = path; + if (pubKey) { + this.publicKey = Buffer.from(AccountAddress.from(pubKey).toUint8Array()); + this.accountAddress = this.authKey(); + } + } + + async init(transport: Transport, display = false): Promise { + this.client = new HwAptos(transport); + if (!this.publicKey.length && !display) { + const response = await this.client.getAddress(this.hdPath, display); + this.accountAddress = AccountAddress.from(response.address); + this.publicKey = response.publicKey; + } + } + + hdWalletPath(): string { + return this.hdPath; + } + + address(): AccountAddress { + return this.accountAddress; + } + + authKey(): AccountAddress { + const hash = sha3Hash.create(); + hash.update(this.publicKey.toString("hex")); + hash.update("\x00"); + return AccountAddress.from(hash.digest()); + } + + pubKey(): AccountAddress { + return AccountAddress.from(this.publicKey.toString("hex")); + } + + async asyncSignBuffer(buffer: Uint8Array): Promise { + if (!this.client) { + throw new Error("LedgerAccount not initialized"); + } + const response = await this.client.signTransaction(this.hdPath, Buffer.from(buffer)); + return new Hex(new Uint8Array(response.signature)); + } + + async asyncSignHexString(hexString: AccountAddress): Promise { + const isValidAddress = AccountAddress.isValid({ input: hexString }); + if (!isValidAddress) throw new Error("Invalid account address"); + const toSign = hexString.toUint8Array(); + return this.asyncSignBuffer(toSign); + } + + async signTransaction(rawTxn: RawTransaction): Promise { + const signingMessage = generateSigningMessageForTransaction({ + rawTransaction: rawTxn, + } as SimpleTransaction); + const sigHexStr = await this.asyncSignBuffer(signingMessage); + const signature = new Ed25519Signature(sigHexStr.toUint8Array()); + const authenticator = new AccountAuthenticatorEd25519( + new Ed25519PublicKey(this.publicKey.toString("hex")), + signature, + ); + + return generateSignedTransaction({ + transaction: { rawTransaction: rawTxn } as SimpleTransaction, + senderAuthenticator: authenticator, + }); + } +} diff --git a/libs/ledger-live-common/src/families/aptos/__snapshots__/bridge.integration.test.ts.snap b/libs/ledger-live-common/src/families/aptos/__snapshots__/bridge.integration.test.ts.snap new file mode 100644 index 000000000000..d7e264eba4a1 --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/__snapshots__/bridge.integration.test.ts.snap @@ -0,0 +1,193 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`aptos currency bridge scanAccounts aptos seed 1 1`] = ` +[ + { + "balance": "30100", + "currencyId": "aptos", + "derivationMode": "", + "freshAddress": "0x445fa0013887abd1a0c14acdec6e48090e0ad3fed3e08202aac15ca14f3be26b", + "freshAddressPath": "44'/637'/0'/0/0", + "id": "js:2:aptos:d1a8c6a1cdd52dd40c7ea61ee4571fb51fcae440a594c1eca18636928f1d3956:", + "index": 0, + "operationsCount": 5, + "pendingOperations": [], + "seedIdentifier": "d6816f4f22f867b56cf9304b776f452a16d107835d73ee8a33c4ced210300583", + "spendableBalance": "30100", + "swapHistory": [], + "syncHash": undefined, + "used": true, + "xpub": "d1a8c6a1cdd52dd40c7ea61ee4571fb51fcae440a594c1eca18636928f1d3956", + }, + { + "balance": "20000", + "currencyId": "aptos", + "derivationMode": "", + "freshAddress": "0xd20fa44192f94ba086ab16bfdf57e43ff118ada69b4c66fa9b9a9223cbc068c1", + "freshAddressPath": "44'/637'/1'/0/0", + "id": "js:2:aptos:6a7712fdac0cb4ed27076c707e7798be52cf6c93a2d43d5cf9b874d0a45a111e:", + "index": 1, + "operationsCount": 1, + "pendingOperations": [], + "seedIdentifier": "d6816f4f22f867b56cf9304b776f452a16d107835d73ee8a33c4ced210300583", + "spendableBalance": "20000", + "swapHistory": [], + "syncHash": undefined, + "used": true, + "xpub": "6a7712fdac0cb4ed27076c707e7798be52cf6c93a2d43d5cf9b874d0a45a111e", + }, + { + "balance": "0", + "currencyId": "aptos", + "derivationMode": "", + "freshAddress": "0xf4bf78be42e07959793c98c7e8345bb948bf10b8e6baac5e368eab66d09a9671", + "freshAddressPath": "44'/637'/2'/0/0", + "id": "js:2:aptos:8ffc0c2e141ead220f05b30fa01ce9a3783c5a157219f922b02ec194308b1b45:", + "index": 2, + "operationsCount": 0, + "pendingOperations": [], + "seedIdentifier": "d6816f4f22f867b56cf9304b776f452a16d107835d73ee8a33c4ced210300583", + "spendableBalance": "0", + "swapHistory": [], + "syncHash": undefined, + "used": false, + "xpub": "8ffc0c2e141ead220f05b30fa01ce9a3783c5a157219f922b02ec194308b1b45", + }, +] +`; + +exports[`aptos currency bridge scanAccounts aptos seed 1 2`] = ` +[ + [ + { + "accountId": "js:2:aptos:d1a8c6a1cdd52dd40c7ea61ee4571fb51fcae440a594c1eca18636928f1d3956:", + "blockHash": "0x4c000b55c435dc0d1040d26a7ee782fb7e151748d41ea9126ebe6bbbf2e95111", + "blockHeight": 266342014, + "extra": { + "version": "2066042051", + }, + "fee": "900", + "hasFailed": false, + "hash": "0x1e85342da3a81f9c3a30c585677d3e50101d5731dcaf31cc157a3c694e6aece8", + "id": "js:2:aptos:d1a8c6a1cdd52dd40c7ea61ee4571fb51fcae440a594c1eca18636928f1d3956:-0x1e85342da3a81f9c3a30c585677d3e50101d5731dcaf31cc157a3c694e6aece8-IN", + "recipients": [ + "0x445fa0013887abd1a0c14acdec6e48090e0ad3fed3e08202aac15ca14f3be26b", + ], + "senders": [ + "0xa0d8abc262e3321f87d745bd5d687e8f3fb14c87d48f840b6b56867df0026ec8", + ], + "transactionSequenceNumber": 10, + "type": "IN", + "value": "100000", + }, + { + "accountId": "js:2:aptos:d1a8c6a1cdd52dd40c7ea61ee4571fb51fcae440a594c1eca18636928f1d3956:", + "blockHash": "0x46de657932759f16ffcda6ef6ace41996f263287e538d3e0f5cbf85994695247", + "blockHeight": 266316843, + "extra": { + "version": "2065700142", + }, + "fee": "900", + "hasFailed": false, + "hash": "0x3c586d8c15c76848fbd3c84ddfbf11a1a07c92734da4dd8d530c9f23a0e7744c", + "id": "js:2:aptos:d1a8c6a1cdd52dd40c7ea61ee4571fb51fcae440a594c1eca18636928f1d3956:-0x3c586d8c15c76848fbd3c84ddfbf11a1a07c92734da4dd8d530c9f23a0e7744c-IN", + "recipients": [ + "0x445fa0013887abd1a0c14acdec6e48090e0ad3fed3e08202aac15ca14f3be26b", + ], + "senders": [ + "0xa0d8abc262e3321f87d745bd5d687e8f3fb14c87d48f840b6b56867df0026ec8", + ], + "transactionSequenceNumber": 9, + "type": "IN", + "value": "20000", + }, + { + "accountId": "js:2:aptos:d1a8c6a1cdd52dd40c7ea61ee4571fb51fcae440a594c1eca18636928f1d3956:", + "blockHash": "0xc546b75fd87fb75a2f328bebffade4ccf3345844eec4d2b6cf82e042fe9a7661", + "blockHeight": 266313878, + "extra": { + "version": "2065659252", + }, + "fee": "99900", + "hasFailed": false, + "hash": "0x5f2c2f597ab912dff9fe413b503036edbcc5488c03a4606f11eef868ed68258e", + "id": "js:2:aptos:d1a8c6a1cdd52dd40c7ea61ee4571fb51fcae440a594c1eca18636928f1d3956:-0x5f2c2f597ab912dff9fe413b503036edbcc5488c03a4606f11eef868ed68258e-IN", + "recipients": [ + "0x445fa0013887abd1a0c14acdec6e48090e0ad3fed3e08202aac15ca14f3be26b", + ], + "senders": [ + "0xa0d8abc262e3321f87d745bd5d687e8f3fb14c87d48f840b6b56867df0026ec8", + ], + "transactionSequenceNumber": 7, + "type": "IN", + "value": "20000", + }, + { + "accountId": "js:2:aptos:d1a8c6a1cdd52dd40c7ea61ee4571fb51fcae440a594c1eca18636928f1d3956:", + "blockHash": "0x1bbe17059abba7dc500aca2eb174c217e71f3dcd7486cd4b27dc6136cce8a60a", + "blockHeight": 266315762, + "extra": { + "version": "2065684418", + }, + "fee": "900", + "hasFailed": false, + "hash": "0xb74e3ab13f7a00faeb51c0e251602f53d387a64ac9fea7c76a8be3d0bf6f7a19", + "id": "js:2:aptos:d1a8c6a1cdd52dd40c7ea61ee4571fb51fcae440a594c1eca18636928f1d3956:-0xb74e3ab13f7a00faeb51c0e251602f53d387a64ac9fea7c76a8be3d0bf6f7a19-IN", + "recipients": [ + "0x445fa0013887abd1a0c14acdec6e48090e0ad3fed3e08202aac15ca14f3be26b", + ], + "senders": [ + "0xa0d8abc262e3321f87d745bd5d687e8f3fb14c87d48f840b6b56867df0026ec8", + ], + "transactionSequenceNumber": 8, + "type": "IN", + "value": "10000", + }, + { + "accountId": "js:2:aptos:d1a8c6a1cdd52dd40c7ea61ee4571fb51fcae440a594c1eca18636928f1d3956:", + "blockHash": "0xf37ce698cf2e6d9e4a0048cd6d09fb3f19f417ab8251a3cc1f19b8d0e503538f", + "blockHeight": 266342506, + "extra": { + "version": "2066048548", + }, + "fee": "99900", + "hasFailed": false, + "hash": "0xf980601fe40ad1dab0cc68fe08d2bc95c73e2a21c6d257475e0879394638058e", + "id": "js:2:aptos:d1a8c6a1cdd52dd40c7ea61ee4571fb51fcae440a594c1eca18636928f1d3956:-0xf980601fe40ad1dab0cc68fe08d2bc95c73e2a21c6d257475e0879394638058e-OUT", + "recipients": [ + "0xd20fa44192f94ba086ab16bfdf57e43ff118ada69b4c66fa9b9a9223cbc068c1", + ], + "senders": [ + "0x445fa0013887abd1a0c14acdec6e48090e0ad3fed3e08202aac15ca14f3be26b", + ], + "transactionSequenceNumber": 0, + "type": "OUT", + "value": "119900", + }, + ], + [ + { + "accountId": "js:2:aptos:6a7712fdac0cb4ed27076c707e7798be52cf6c93a2d43d5cf9b874d0a45a111e:", + "blockHash": "0xf37ce698cf2e6d9e4a0048cd6d09fb3f19f417ab8251a3cc1f19b8d0e503538f", + "blockHeight": 266342506, + "extra": { + "version": "2066048548", + }, + "fee": "99900", + "hasFailed": false, + "hash": "0xf980601fe40ad1dab0cc68fe08d2bc95c73e2a21c6d257475e0879394638058e", + "id": "js:2:aptos:6a7712fdac0cb4ed27076c707e7798be52cf6c93a2d43d5cf9b874d0a45a111e:-0xf980601fe40ad1dab0cc68fe08d2bc95c73e2a21c6d257475e0879394638058e-IN", + "recipients": [ + "0xd20fa44192f94ba086ab16bfdf57e43ff118ada69b4c66fa9b9a9223cbc068c1", + ], + "senders": [ + "0x445fa0013887abd1a0c14acdec6e48090e0ad3fed3e08202aac15ca14f3be26b", + ], + "transactionSequenceNumber": 0, + "type": "IN", + "value": "20000", + }, + ], + [], +] +`; diff --git a/libs/ledger-live-common/src/families/aptos/api/graphql/queries.ts b/libs/ledger-live-common/src/families/aptos/api/graphql/queries.ts new file mode 100644 index 000000000000..0c5df5daa4aa --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/api/graphql/queries.ts @@ -0,0 +1,53 @@ +import { gql } from "@apollo/client"; + +export const GetDelegatedStakingActivities = gql` + query getDelegatedStakingActivities($delegatorAddress: String) { + delegated_staking_activities( + where: { delegator_address: { _eq: $delegatorAddress } } + order_by: { transaction_version: asc } + ) { + amount + delegator_address + event_index + event_type + pool_address + transaction_version + } + } +`; +export const GetAccountTransactionsData = gql` + query GetAccountTransactionsData($address: String, $limit: Int) { + address_version_from_move_resources( + where: { address: { _eq: $address } } + order_by: { transaction_version: desc } + limit: $limit + ) { + transaction_version + __typename + } + } +`; +export const GetAccountTransactionsDataGt = gql` + query GetAccountTransactionsDataGt($address: String, $limit: Int, $gt: bigint) { + address_version_from_move_resources( + where: { address: { _eq: $address }, transaction_version: { _gt: $gt } } + order_by: { transaction_version: desc } + limit: $limit + ) { + transaction_version + __typename + } + } +`; +export const GetAccountTransactionsDataLt = gql` + query GetAccountTransactionsDataLt($address: String, $limit: Int, $lt: bigint) { + address_version_from_move_resources( + where: { address: { _eq: $address }, transaction_version: { _lt: $lt } } + order_by: { transaction_version: desc } + limit: $limit + ) { + transaction_version + __typename + } + } +`; diff --git a/libs/ledger-live-common/src/families/aptos/api/graphql/types.ts b/libs/ledger-live-common/src/families/aptos/api/graphql/types.ts new file mode 100644 index 000000000000..f8eadd57f97a --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/api/graphql/types.ts @@ -0,0 +1,85 @@ +export type Maybe = T | null; +export type InputMaybe = Maybe; +export type Exact = { [K in keyof T]: T[K] }; +/** All built-in and custom scalars, mapped to their actual values */ +export type Scalars = { + ID: string; + String: string; + Boolean: boolean; + Int: number; + Float: number; + bigint: any; + jsonb: any; + numeric: any; + timestamp: any; + timestamptz: any; +}; + +export type GetAccountTransactionsDataQueryVariables = Exact<{ + address?: InputMaybe; + limit?: InputMaybe; +}>; + +export type GetAccountTransactionsDataQuery = { + __typename?: "query_root"; + address_version_from_move_resources: Array<{ + __typename: "address_version_from_move_resources"; + transaction_version?: any | null; + }>; +}; + +export type GetAccountTransactionsDataGtQueryVariables = Exact<{ + address?: InputMaybe; + limit?: InputMaybe; + gt?: InputMaybe; +}>; + +export type GetAccountTransactionsDataGtQuery = { + __typename?: "query_root"; + address_version_from_move_resources: Array<{ + __typename: "address_version_from_move_resources"; + transaction_version?: any | null; + }>; +}; + +export type GetAccountTransactionsDataLtQueryVariables = Exact<{ + address?: InputMaybe; + limit?: InputMaybe; + lt?: InputMaybe; +}>; + +export type GetAccountTransactionsDataLtQuery = { + __typename?: "query_root"; + address_version_from_move_resources: Array<{ + __typename: "address_version_from_move_resources"; + transaction_version?: any | null; + }>; +}; + +export type DelegatedStakingActivity = { + amount: number; + delegator_address: string; + event_index: number; + event_type: string; + pool_address: string; + transaction_version: bigint; +}; + +export type GetDelegatedStakingActivitiesQuery = { + delegated_staking_activities: Array; +}; + +export type PartitionedDelegatedStakingActivities = Record; + +export type StakePrincipals = { + activePrincipals: number; + pendingInactivePrincipals: number; +}; + +export type StakeDetails = { + active: number; + inactive: number; + pendingInactive: number; + canWithdrawPendingInactive: boolean; + poolAddress: string; +}; diff --git a/libs/ledger-live-common/src/families/aptos/api/index.test.ts b/libs/ledger-live-common/src/families/aptos/api/index.test.ts new file mode 100644 index 000000000000..eff5afc25973 --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/api/index.test.ts @@ -0,0 +1,510 @@ +import { ApolloClient } from "@apollo/client"; +import { + AccountAddress, + Aptos, + ChainId, + Ed25519PublicKey, + InputEntryFunctionData, + RawTransaction, + Serializable, + post, +} from "@aptos-labs/ts-sdk"; +import network from "@ledgerhq/live-network/network"; +import BigNumber from "bignumber.js"; +import { AptosAPI } from "."; +import { Account } from "../../../e2e/enum/Account"; + +jest.mock("@aptos-labs/ts-sdk"); +jest.mock("@apollo/client"); +let mockedAptos; +let mockedApolloClient; +let mockedPost; + +jest.mock("@ledgerhq/live-network/network"); +const mockedNetwork = jest.mocked(network); + +describe("Aptos API", () => { + beforeEach(() => { + mockedAptos = jest.mocked(Aptos); + mockedApolloClient = jest.mocked(ApolloClient); + mockedPost = jest.mocked(post); + }); + + afterEach(() => jest.clearAllMocks()); + + it("builds the client properly for mainnet", () => { + const api = new AptosAPI("aptos"); + + expect(api.broadcast).toBeDefined(); + expect(typeof api.broadcast).toBe("function"); + expect(api.estimateGasPrice).toBeDefined(); + expect(typeof api.estimateGasPrice).toBe("function"); + expect(api.generateTransaction).toBeDefined(); + expect(typeof api.generateTransaction).toBe("function"); + expect(api.getAccount).toBeDefined(); + expect(typeof api.getAccount).toBe("function"); + expect(api.getAccountInfo).toBeDefined(); + expect(typeof api.getAccountInfo).toBe("function"); + expect(api.simulateTransaction).toBeDefined(); + expect(typeof api.simulateTransaction).toBe("function"); + }); + + it("builds the client properly for testnet", () => { + const api = new AptosAPI("aptos_testnet"); + + expect(api.broadcast).toBeDefined(); + expect(typeof api.broadcast).toBe("function"); + expect(api.estimateGasPrice).toBeDefined(); + expect(typeof api.estimateGasPrice).toBe("function"); + expect(api.generateTransaction).toBeDefined(); + expect(typeof api.generateTransaction).toBe("function"); + expect(api.getAccount).toBeDefined(); + expect(typeof api.getAccount).toBe("function"); + expect(api.getAccountInfo).toBeDefined(); + expect(typeof api.getAccountInfo).toBe("function"); + expect(api.simulateTransaction).toBeDefined(); + expect(typeof api.simulateTransaction).toBe("function"); + }); + + describe("getAccount", () => { + it("calls getAccountInfo", async () => { + const mockGetAccountInfo = jest.fn(); + mockedAptos.mockImplementation(() => { + return { + getAccountInfo: mockGetAccountInfo, + }; + }); + + const mockGetAccountSpy = jest.spyOn({ getAccount: mockGetAccountInfo }, "getAccount"); + + const api = new AptosAPI("aptos"); + await api.getAccount(Account.APTOS_1.address); + + expect(mockGetAccountSpy).toHaveBeenCalledWith({ + accountAddress: Account.APTOS_1.address, + }); + }); + }); + + describe("getAccountInfo", () => { + it("calls getBalance, fetchTransactions and getHeight", async () => { + mockedAptos.mockImplementation(() => ({ + view: jest.fn().mockReturnValue(["123"]), + getTransactionByVersion: jest.fn().mockReturnValue({ + type: "user_transaction", + version: "v1", + }), + getBlockByVersion: jest.fn().mockReturnValue({ + block_height: "1", + block_hash: "83ca6d", + }), + })); + + mockedNetwork.mockResolvedValue( + Promise.resolve({ + data: { + account: { + account_number: 1, + sequence: 0, + pub_key: { key: "k", "@type": "type" }, + base_account: { + account_number: 2, + sequence: 42, + pub_key: { key: "k2", "@type": "type2" }, + }, + }, + block_height: "999", + }, + status: 200, + headers: {} as any, + statusText: "", + config: { + headers: {} as any, + }, + }), + ); + + mockedApolloClient.mockImplementation(() => ({ + query: async () => ({ + data: { + address_version_from_move_resources: [{ transaction_version: "v1" }], + }, + loading: false, + networkStatus: 7, + }), + })); + + const api = new AptosAPI("aptos"); + const accountInfo = await api.getAccountInfo(Account.APTOS_1.address, "1"); + + expect(accountInfo.balance).toEqual(new BigNumber(123)); + expect(accountInfo.transactions).toEqual([ + { + type: "user_transaction", + version: "v1", + block: { + height: 1, + hash: "83ca6d", + }, + }, + ]); + expect(accountInfo.blockHeight).toEqual(999); + }); + + it("return balance = 0 if it fails to fetch balance", async () => { + mockedAptos.mockImplementation(() => ({ + view: jest.fn().mockImplementation(() => { + throw new Error("error"); + }), + getTransactionByVersion: jest.fn().mockReturnValue({ + type: "user_transaction", + version: "v1", + }), + getBlockByVersion: jest.fn().mockReturnValue({ + block_height: "1", + block_hash: "83ca6d", + }), + })); + + mockedNetwork.mockResolvedValue( + Promise.resolve({ + data: { + account: { + account_number: 1, + sequence: 0, + pub_key: { key: "k", "@type": "type" }, + base_account: { + account_number: 2, + sequence: 42, + pub_key: { key: "k2", "@type": "type2" }, + }, + }, + block_height: "999", + }, + status: 200, + headers: {} as any, + statusText: "", + config: { + headers: {} as any, + }, + }), + ); + + mockedApolloClient.mockImplementation(() => ({ + query: async () => ({ + data: { + address_version_from_move_resources: [{ transaction_version: "v1" }], + }, + loading: false, + networkStatus: 7, + }), + })); + + const api = new AptosAPI("aptos"); + const accountInfo = await api.getAccountInfo(Account.APTOS_1.address, "1"); + + expect(accountInfo.balance).toEqual(new BigNumber(0)); + expect(accountInfo.transactions).toEqual([ + { + type: "user_transaction", + version: "v1", + block: { + height: 1, + hash: "83ca6d", + }, + }, + ]); + expect(accountInfo.blockHeight).toEqual(999); + }); + + it("returns no transactions if it the address is empty", async () => { + mockedAptos.mockImplementation(() => ({ + view: jest.fn().mockReturnValue(["123"]), + getTransactionByVersion: jest.fn().mockReturnValue({ + type: "user_transaction", + version: "v1", + }), + getBlockByVersion: jest.fn().mockReturnValue({ + block_height: "1", + block_hash: "83ca6d", + }), + })); + + mockedNetwork.mockResolvedValue( + Promise.resolve({ + data: { + account: { + account_number: 1, + sequence: 0, + pub_key: { key: "k", "@type": "type" }, + base_account: { + account_number: 2, + sequence: 42, + pub_key: { key: "k2", "@type": "type2" }, + }, + }, + block_height: "999", + }, + status: 200, + headers: {} as any, + statusText: "", + config: { + headers: {} as any, + }, + }), + ); + + mockedApolloClient.mockImplementation(() => ({ + query: async () => ({ + data: { + address_version_from_move_resources: [{ transaction_version: "v1" }], + }, + loading: false, + networkStatus: 7, + }), + })); + + const api = new AptosAPI("aptos"); + const accountInfo = await api.getAccountInfo("", "1"); + + expect(accountInfo.balance).toEqual(new BigNumber(123)); + expect(accountInfo.transactions).toEqual([]); + expect(accountInfo.blockHeight).toEqual(999); + }); + + it("returns a null transaction if it fails to getTransactionByVersion", async () => { + mockedAptos.mockImplementation(() => ({ + view: jest.fn().mockReturnValue(["123"]), + getTransactionByVersion: jest.fn().mockImplementation(() => { + throw new Error("error"); + }), + getBlockByVersion: jest.fn().mockReturnValue({ + block_height: "1", + block_hash: "83ca6d", + }), + })); + + mockedNetwork.mockResolvedValue( + Promise.resolve({ + data: { + account: { + account_number: 1, + sequence: 0, + pub_key: { key: "k", "@type": "type" }, + base_account: { + account_number: 2, + sequence: 42, + pub_key: { key: "k2", "@type": "type2" }, + }, + }, + block_height: "999", + }, + status: 200, + headers: {} as any, + statusText: "", + config: { + headers: {} as any, + }, + }), + ); + + mockedApolloClient.mockImplementation(() => ({ + query: async () => ({ + data: { + address_version_from_move_resources: [{ transaction_version: "v1" }], + }, + loading: false, + networkStatus: 7, + }), + })); + + const api = new AptosAPI("aptos"); + const accountInfo = await api.getAccountInfo(Account.APTOS_1.address, "1"); + + expect(accountInfo.balance).toEqual(new BigNumber(123)); + expect(accountInfo.transactions).toEqual([null]); + expect(accountInfo.blockHeight).toEqual(999); + }); + }); + + describe("estimateGasPrice", () => { + it("estimates the gas price", async () => { + const gasEstimation = { gas_estimate: 100 }; + mockedAptos.mockImplementation(() => ({ + getGasPriceEstimation: jest.fn().mockReturnValue(gasEstimation), + })); + + const api = new AptosAPI("aptos"); + const gasPrice = await api.estimateGasPrice(); + + expect(gasPrice.gas_estimate).toEqual(100); + }); + }); + + describe("generateTransaction", () => { + const payload: InputEntryFunctionData = { + function: "0x1::coin::transfer", + functionArguments: ["0x13", 1], + }; + + it("generates a transaction with the correct options", async () => { + const options = { + maxGasAmount: "100", + gasUnitPrice: "50", + sequenceNumber: "1", + expirationTimestampSecs: "1735639799486", + }; + + const mockSimple = jest.fn().mockImplementation(async () => ({ + rawTransaction: null, + })); + mockedAptos.mockImplementation(() => ({ + transaction: { + build: { + simple: mockSimple, + }, + }, + })); + + const mockSimpleSpy = jest.spyOn({ simple: mockSimple }, "simple"); + + const api = new AptosAPI("aptos"); + await api.generateTransaction(Account.APTOS_1.address, payload, options); + + expect(mockSimpleSpy).toHaveBeenCalledWith({ + data: payload, + options: { + maxGasAmount: Number(options.maxGasAmount), + gasUnitPrice: Number(options.gasUnitPrice), + accountSequenceNumber: Number(options.sequenceNumber), + expireTimestamp: Number(options.expirationTimestampSecs), + }, + sender: Account.APTOS_1.address, + }); + }); + + it("generates a transaction with no expire timestamp option set", async () => { + const options = { + maxGasAmount: "100", + gasUnitPrice: "50", + sequenceNumber: "1", + }; + + const mockSimple = jest.fn().mockImplementation(async () => ({ + rawTransaction: null, + })); + const mockGetLedgerInfo = jest.fn().mockImplementation(async () => ({ + ledger_timestamp: "0", + })); + mockedAptos.mockImplementation(() => ({ + transaction: { + build: { + simple: mockSimple, + }, + }, + getLedgerInfo: mockGetLedgerInfo, + })); + + const mockSimpleSpy = jest.spyOn({ simple: mockSimple }, "simple"); + + const api = new AptosAPI("aptos"); + await api.generateTransaction(Account.APTOS_1.address, payload, options); + + expect(mockSimpleSpy).toHaveBeenCalledWith({ + data: payload, + options: { + maxGasAmount: Number(options.maxGasAmount), + gasUnitPrice: Number(options.gasUnitPrice), + accountSequenceNumber: Number(options.sequenceNumber), + expireTimestamp: 120, + }, + sender: Account.APTOS_1.address, + }); + }); + + it("throws an error when failing to build a transaction", async () => { + const options = { + maxGasAmount: "100", + gasUnitPrice: "50", + sequenceNumber: "1", + expirationTimestampSecs: "1735639799486", + }; + + const mockSimple = jest.fn().mockImplementation(async () => null); + mockedAptos.mockImplementation(() => ({ + transaction: { + build: { + simple: mockSimple, + }, + }, + })); + + const api = new AptosAPI("aptos"); + expect( + async () => await api.generateTransaction(Account.APTOS_1.address, payload, options), + ).rejects.toThrow(); + }); + }); + + describe("simulateTransaction", () => { + it("simulates a transaction with the correct options", async () => { + const mockSimple = jest.fn().mockImplementation(async () => ({ + rawTransaction: null, + })); + mockedAptos.mockImplementation(() => ({ + transaction: { + simulate: { + simple: mockSimple, + }, + }, + })); + + const mockSimpleSpy = jest.spyOn({ simple: mockSimple }, "simple"); + + const api = new AptosAPI("aptos"); + const address = new Ed25519PublicKey(Account.APTOS_1.address); + const tx = new RawTransaction( + new AccountAddress(Uint8Array.from(Buffer.from(Account.APTOS_2.address))), + BigInt(1), + "" as unknown as Serializable, + BigInt(100), + BigInt(50), + BigInt(1), + { chainId: 1 } as ChainId, + ); + await api.simulateTransaction(address, tx); + + expect(mockSimpleSpy).toHaveBeenCalledWith({ + signerPublicKey: address, + transaction: { rawTransaction: tx }, + options: { + estimateGasUnitPrice: true, + estimateMaxGasAmount: true, + estimatePrioritizedGasUnitPrice: false, + }, + }); + }); + }); + describe("broadcast", () => { + it("broadcasts the transaction", async () => { + mockedPost.mockImplementation(async () => ({ data: { hash: "ok" } })); + const mockedPostSpy = jest.spyOn({ post: mockedPost }, "post"); + + mockedAptos.mockImplementation(() => ({ + config: "config", + })); + + const api = new AptosAPI("aptos"); + await api.broadcast("signature"); + + expect(mockedPostSpy).toHaveBeenCalledWith({ + contentType: "application/x.aptos.signed_transaction+bcs", + aptosConfig: "config", + body: Uint8Array.from(Buffer.from("signature", "hex")), + path: "transactions", + type: "Fullnode", + originMethod: "", + }); + }); + }); +}); diff --git a/libs/ledger-live-common/src/families/aptos/api/index.ts b/libs/ledger-live-common/src/families/aptos/api/index.ts new file mode 100644 index 000000000000..6994006fb243 --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/api/index.ts @@ -0,0 +1,231 @@ +import { ApolloClient, InMemoryCache } from "@apollo/client"; +import { + AccountData, + Aptos, + AptosApiType, + AptosConfig, + Ed25519PublicKey, + GasEstimation, + InputEntryFunctionData, + InputGenerateTransactionOptions, + MimeType, + post, + RawTransaction, + SimpleTransaction, + TransactionResponse, + UserTransactionResponse, + PostRequestOptions, +} from "@aptos-labs/ts-sdk"; +import { getEnv } from "@ledgerhq/live-env"; +import network from "@ledgerhq/live-network/network"; +import BigNumber from "bignumber.js"; +import isUndefined from "lodash/isUndefined"; +import { APTOS_ASSET_ID } from "../constants"; +import { isTestnet } from "../logic"; +import type { AptosTransaction, TransactionOptions } from "../types"; +import { GetAccountTransactionsData, GetAccountTransactionsDataGt } from "./graphql/queries"; +import { + GetAccountTransactionsDataQuery, + GetAccountTransactionsDataGtQueryVariables, +} from "./graphql/types"; + +const getApiEndpoint = (currencyId: string) => + isTestnet(currencyId) ? getEnv("APTOS_TESTNET_API_ENDPOINT") : getEnv("APTOS_API_ENDPOINT"); +const getIndexerEndpoint = (currencyId: string) => + isTestnet(currencyId) + ? getEnv("APTOS_TESTNET_INDEXER_ENDPOINT") + : getEnv("APTOS_INDEXER_ENDPOINT"); + +export class AptosAPI { + private apiUrl: string; + private indexerUrl: string; + private aptosConfig: AptosConfig; + private aptosClient: Aptos; + private apolloClient: ApolloClient; + + constructor(currencyId: string) { + this.apiUrl = getApiEndpoint(currencyId); + this.indexerUrl = getIndexerEndpoint(currencyId); + this.aptosConfig = new AptosConfig({ + fullnode: this.apiUrl, + indexer: this.indexerUrl, + }); + this.aptosClient = new Aptos(this.aptosConfig); + this.apolloClient = new ApolloClient({ + uri: this.indexerUrl, + cache: new InMemoryCache(), + headers: { + "x-client": "ledger-live", + }, + }); + } + + async getAccount(address: string): Promise { + return this.aptosClient.getAccountInfo({ accountAddress: address }); + } + + async getAccountInfo(address: string, startAt: string) { + const [balance, transactions, blockHeight] = await Promise.all([ + this.getBalance(address), + this.fetchTransactions(address, startAt), + this.getHeight(), + ]); + + return { + balance, + transactions, + blockHeight, + }; + } + + async estimateGasPrice(): Promise { + return this.aptosClient.getGasPriceEstimation(); + } + + async generateTransaction( + address: string, + payload: InputEntryFunctionData, + options: TransactionOptions, + ): Promise { + const opts: Partial = {}; + if (!isUndefined(options.maxGasAmount)) { + opts.maxGasAmount = Number(options.maxGasAmount); + } + + if (!isUndefined(options.gasUnitPrice)) { + opts.gasUnitPrice = Number(options.gasUnitPrice); + } + + if (!isUndefined(options.sequenceNumber)) { + opts.accountSequenceNumber = Number(options.sequenceNumber); + } + + if (!isUndefined(options.expirationTimestampSecs)) { + opts.expireTimestamp = Number(options.expirationTimestampSecs); + } else { + try { + const ts = (await this.aptosClient.getLedgerInfo()).ledger_timestamp; + opts.expireTimestamp = Number(Math.ceil(+ts / 1_000_000 + 2 * 60)); // in milliseconds + } catch (_) { + // skip + } + } + + return this.aptosClient.transaction.build + .simple({ + sender: address, + data: payload, + options: opts, + }) + .then(t => t.rawTransaction) + .catch((error: any) => { + throw error; + }); + } + + async simulateTransaction( + address: Ed25519PublicKey, + tx: RawTransaction, + options = { + estimateGasUnitPrice: true, + estimateMaxGasAmount: true, + estimatePrioritizedGasUnitPrice: false, + }, + ): Promise { + return this.aptosClient.transaction.simulate.simple({ + signerPublicKey: address, + transaction: { rawTransaction: tx } as SimpleTransaction, + options, + }); + } + + async broadcast(signature: string): Promise { + const txBytes = Uint8Array.from(Buffer.from(signature, "hex")); + const pendingTx = await post({ + contentType: MimeType.BCS_SIGNED_TRANSACTION, + aptosConfig: this.aptosClient.config, + body: txBytes, + path: "transactions", + type: AptosApiType.FULLNODE, + originMethod: "", + }); + return pendingTx.data.hash; + } + + private async getBalance(address: string): Promise { + try { + const [balanceStr] = await this.aptosClient.view<[string]>({ + payload: { + function: "0x1::coin::balance", + typeArguments: [APTOS_ASSET_ID], + functionArguments: [address], + }, + }); + const balance = parseInt(balanceStr, 10); + return new BigNumber(balance); + } catch (e: any) { + return new BigNumber(0); + } + } + + private async fetchTransactions(address: string, gt?: string) { + if (!address) { + return []; + } + + let query = GetAccountTransactionsData; + if (gt) { + query = GetAccountTransactionsDataGt; + } + + const queryResponse = await this.apolloClient.query< + GetAccountTransactionsDataQuery, + GetAccountTransactionsDataGtQueryVariables + >({ + query, + variables: { + address, + limit: 1000, + gt, + }, + fetchPolicy: "network-only", + }); + + return Promise.all( + queryResponse.data.address_version_from_move_resources.map(({ transaction_version }) => { + return this.richItemByVersion(transaction_version); + }), + ); + } + + private async richItemByVersion(version: number): Promise { + try { + const tx: TransactionResponse = await this.aptosClient.getTransactionByVersion({ + ledgerVersion: version, + }); + const block = await this.getBlock(version); + return { + ...tx, + block, + } as AptosTransaction; + } catch (error) { + return null; + } + } + + private async getHeight(): Promise { + const { data } = await network({ + method: "GET", + url: this.apiUrl, + }); + return parseInt(data.block_height); + } + + private async getBlock(version: number) { + const block = await this.aptosClient.getBlockByVersion({ ledgerVersion: version }); + return { + height: parseInt(block.block_height), + hash: block.block_hash, + }; + } +} diff --git a/libs/ledger-live-common/src/families/aptos/bridge.integration.test.ts b/libs/ledger-live-common/src/families/aptos/bridge.integration.test.ts new file mode 100644 index 000000000000..8ff913a4403e --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/bridge.integration.test.ts @@ -0,0 +1,95 @@ +import { CurrenciesData, DatasetTest } from "@ledgerhq/types-live"; +import BigNumber from "bignumber.js"; +import { testBridge } from "../../__tests__/test-helpers/bridge"; +import "../../__tests__/test-helpers/setup"; +import { fromTransactionRaw } from "./transaction"; +import { Transaction } from "./types"; + +const aptos: CurrenciesData = { + scanAccounts: [ + { + name: "aptos seed 1", + apdus: ` + => 5b0500000d038000002c8000027d80000000 + <= 2104d6816f4f22f867b56cf9304b776f452a16d107835d73ee8a33c4ced210300583204bb135642f160c72c323d57ad509b904ff44d9f2b983e8b90468e19b6f431ea79000 + => 5b05000015058000002c8000027d800000008000000080000000 + <= 2104d1a8c6a1cdd52dd40c7ea61ee4571fb51fcae440a594c1eca18636928f1d3956200d8d6cf19a090a8080768d07a848acc333775e5327d2da8a4022301f7dbb88ff9000 + => 5b05000015058000002c8000027d800000008000000080000000 + <= 2104d1a8c6a1cdd52dd40c7ea61ee4571fb51fcae440a594c1eca18636928f1d3956200d8d6cf19a090a8080768d07a848acc333775e5327d2da8a4022301f7dbb88ff9000 + => 5b05000015058000002c8000027d800000018000000080000000 + <= 21046a7712fdac0cb4ed27076c707e7798be52cf6c93a2d43d5cf9b874d0a45a111e208e72477f799c2d3b2899b32b114988ab3d1af02dd0d3562196eccded2936f8449000 + => 5b05000015058000002c8000027d800000018000000080000000 + <= 21046a7712fdac0cb4ed27076c707e7798be52cf6c93a2d43d5cf9b874d0a45a111e208e72477f799c2d3b2899b32b114988ab3d1af02dd0d3562196eccded2936f8449000 + => 5b05000015058000002c8000027d800000028000000080000000 + <= 21048ffc0c2e141ead220f05b30fa01ce9a3783c5a157219f922b02ec194308b1b452084cf4bdff7814f8c3d08bfceb9d2615bf8c6850b208477528f8376c4250e4b5a9000 + => 5b05000015058000002c8000027d800000028000000080000000 + <= 21048ffc0c2e141ead220f05b30fa01ce9a3783c5a157219f922b02ec194308b1b452084cf4bdff7814f8c3d08bfceb9d2615bf8c6850b208477528f8376c4250e4b5a9000 + `, + }, + ], + accounts: [ + { + raw: { + id: "js:2:aptos:d1a8c6a1cdd52dd40c7ea61ee4571fb51fcae440a594c1eca18636928f1d3956:", + seedIdentifier: "d6816f4f22f867b56cf9304b776f452a16d107835d73ee8a33c4ced210300583", + used: true, + derivationMode: "", + index: 0, + freshAddress: "0x445fa0013887abd1a0c14acdec6e48090e0ad3fed3e08202aac15ca14f3be26b", + freshAddressPath: "44'/637'/0'/0/0", + blockHeight: 266360751, + creationDate: "2024-12-18T12:26:31.070Z", + operationsCount: 5, + operations: [], + pendingOperations: [], + currencyId: "aptos", + lastSyncDate: "2024-12-18T15:20:55.097Z", + balance: "30100", + spendableBalance: "30100", + balanceHistoryCache: { + HOUR: { balances: [0, 50000, 50000, 30100], latestDate: 1734534000000 }, + DAY: { balances: [0], latestDate: 1734480000000 }, + WEEK: { balances: [0], latestDate: 1734220800000 }, + }, + xpub: "d1a8c6a1cdd52dd40c7ea61ee4571fb51fcae440a594c1eca18636928f1d3956", + swapHistory: [], + }, + transactions: [ + { + name: "NO_NAME", + transaction: fromTransactionRaw({ + amount: "20000", + recipient: "0xd20fa44192f94ba086ab16bfdf57e43ff118ada69b4c66fa9b9a9223cbc068c1", + useAllAmount: false, + family: "aptos", + mode: "send", + fees: "1100", + options: '{"maxGasAmount":"11","gasUnitPrice":"100"}', + estimate: + '{"maxGasAmount":"11","gasUnitPrice":"100","sequenceNumber":"1","expirationTimestampSecs":"1734535375"}', + firstEmulation: "false", + errors: "{}", + }), + expectedStatus: () => + // you can use account and transaction for smart logic. drop the =>fn otherwise + ({ + errors: {}, + warnings: {}, + estimatedFees: BigNumber("900"), + amount: BigNumber("20000"), + totalSpent: BigNumber("20900"), + }), + }, + ], + }, + ], +}; + +const dataset: DatasetTest = { + implementations: ["js"], + currencies: { + aptos, + }, +}; + +testBridge(dataset); diff --git a/libs/ledger-live-common/src/families/aptos/bridge/bridgeHelpers/addresses.ts b/libs/ledger-live-common/src/families/aptos/bridge/bridgeHelpers/addresses.ts new file mode 100644 index 000000000000..9f047c2fd5cb --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/bridge/bridgeHelpers/addresses.ts @@ -0,0 +1,16 @@ +import { Account } from "@ledgerhq/types-live"; + +export const getAddress = ( + a: Account, +): { + address: string; + derivationPath: string; +} => ({ address: a.freshAddress, derivationPath: a.freshAddressPath }); + +export function isAddressValid(): boolean { + try { + return true; + } catch (err) { + return false; + } +} diff --git a/libs/ledger-live-common/src/families/aptos/bridge/bridgeHelpers/fee.ts b/libs/ledger-live-common/src/families/aptos/bridge/bridgeHelpers/fee.ts new file mode 100644 index 000000000000..7c0c27813aad --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/bridge/bridgeHelpers/fee.ts @@ -0,0 +1,5 @@ +import BigNumber from "bignumber.js"; + +export function getEstimatedFees(): BigNumber { + return new BigNumber(0); +} diff --git a/libs/ledger-live-common/src/families/aptos/bridge/js.test.ts b/libs/ledger-live-common/src/families/aptos/bridge/js.test.ts new file mode 100644 index 000000000000..ce24ff1fe361 --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/bridge/js.test.ts @@ -0,0 +1,106 @@ +import BigNumber from "bignumber.js"; +import bridge from "./js"; +import { getCryptoCurrencyById } from "@ledgerhq/cryptoassets/currencies"; + +describe("Aptos bridge interface ", () => { + describe("currencyBridge", () => { + it("should have a preload method that returns a promise", async () => { + const cryptoCurrency = getCryptoCurrencyById("aptos"); + const result = bridge.currencyBridge.preload(cryptoCurrency); + expect(result).toBeInstanceOf(Promise); + await expect(result).resolves.toEqual({}); + }); + + it("should have a hydrate method that is a function", () => { + expect(bridge.currencyBridge.hydrate).toBeDefined(); + expect(typeof bridge.currencyBridge.hydrate).toBe("function"); + const cryptoCurrency = getCryptoCurrencyById("aptos"); + const result = bridge.currencyBridge.hydrate({}, cryptoCurrency); + expect(result).toBeUndefined(); + }); + + it("should have a scanAccounts method that is a function", () => { + expect(bridge.currencyBridge.scanAccounts).toBeDefined(); + expect(typeof bridge.currencyBridge.scanAccounts).toBe("function"); + const cryptoCurrency = getCryptoCurrencyById("aptos"); + const deviceId = "test-device"; + const result = bridge.currencyBridge.scanAccounts({ + currency: cryptoCurrency, + deviceId, + syncConfig: { paginationConfig: {} }, + }); + expect(result).toBeDefined(); + }); + }); + + describe("accountBridge ", () => { + it("should contain all methods", () => { + expect(bridge.accountBridge.estimateMaxSpendable).toBeDefined(); + expect(typeof bridge.accountBridge.estimateMaxSpendable).toBe("function"); + expect(bridge.accountBridge.createTransaction).toBeDefined(); + expect(typeof bridge.accountBridge.createTransaction).toBe("function"); + expect(bridge.accountBridge.updateTransaction).toBeDefined(); + expect(typeof bridge.accountBridge.updateTransaction).toBe("function"); + expect(bridge.accountBridge.getTransactionStatus).toBeDefined(); + expect(typeof bridge.accountBridge.getTransactionStatus).toBe("function"); + expect(bridge.accountBridge.prepareTransaction).toBeDefined(); + expect(typeof bridge.accountBridge.prepareTransaction).toBe("function"); + expect(bridge.accountBridge.sync).toBeDefined(); + expect(typeof bridge.accountBridge.sync).toBe("function"); + expect(bridge.accountBridge.receive).toBeDefined(); + expect(typeof bridge.accountBridge.receive).toBe("function"); + expect(bridge.accountBridge.signOperation).toBeDefined(); + expect(typeof bridge.accountBridge.signOperation).toBe("function"); + expect(bridge.accountBridge.broadcast).toBeDefined(); + expect(typeof bridge.accountBridge.broadcast).toBe("function"); + }); + }); + describe("updateTransaction", () => { + it("should update the transaction with the given patch", () => { + const initialTransaction = { + amount: new BigNumber(100), + recipient: "address1", + mode: "send", + family: "aptos" as const, + options: { maxGasAmount: "", gasUnitPrice: "" }, + estimate: { maxGasAmount: "", gasUnitPrice: "" }, + firstEmulation: true, + }; + const patch = { amount: new BigNumber(200) }; + const updatedTransaction = bridge.accountBridge.updateTransaction(initialTransaction, patch); + expect(updatedTransaction).toEqual({ + amount: new BigNumber(200), + recipient: "address1", + mode: "send", + family: "aptos" as const, + options: { maxGasAmount: "", gasUnitPrice: "" }, + estimate: { maxGasAmount: "", gasUnitPrice: "" }, + firstEmulation: true, + }); + }); + + it("should not modify the original transaction object", () => { + const initialTransaction = { + amount: new BigNumber(100), + recipient: "address1", + mode: "send", + family: "aptos" as const, + options: { maxGasAmount: "", gasUnitPrice: "" }, + estimate: { maxGasAmount: "", gasUnitPrice: "" }, + firstEmulation: true, + }; + const patch = { amount: new BigNumber(200) }; + const updatedTransaction = bridge.accountBridge.updateTransaction(initialTransaction, patch); + expect(initialTransaction).toEqual({ + amount: new BigNumber(100), + recipient: "address1", + mode: "send", + family: "aptos" as const, + options: { maxGasAmount: "", gasUnitPrice: "" }, + estimate: { maxGasAmount: "", gasUnitPrice: "" }, + firstEmulation: true, + }); + expect(updatedTransaction).not.toBe(initialTransaction); + }); + }); +}); diff --git a/libs/ledger-live-common/src/families/aptos/bridge/js.ts b/libs/ledger-live-common/src/families/aptos/bridge/js.ts new file mode 100644 index 000000000000..4cd4d177cbb1 --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/bridge/js.ts @@ -0,0 +1,40 @@ +import type { AccountBridge, CurrencyBridge } from "@ledgerhq/types-live"; +import { getSerializedAddressParameters } from "@ledgerhq/coin-framework/bridge/jsHelpers"; +import type { Transaction } from "../types"; +import { makeAccountBridgeReceive } from "../../../bridge/jsHelpers"; + +import { sync, scanAccounts } from "../synchronisation"; +import getTransactionStatus from "../getTransactionStatus"; +import prepareTransaction from "../prepareTransaction"; +import createTransaction from "../createTransaction"; +import estimateMaxSpendable from "../estimateMaxSpendable"; +import signOperation from "../signOperation"; +import broadcast from "../broadcast"; + +const currencyBridge: CurrencyBridge = { + preload: () => Promise.resolve({}), + hydrate: () => {}, + scanAccounts, +}; + +const updateTransaction = (t: Transaction, patch: Partial): Transaction => ({ + ...t, + ...patch, +}); + +const receive = makeAccountBridgeReceive(); + +const accountBridge: AccountBridge = { + estimateMaxSpendable, + createTransaction, + updateTransaction, + getTransactionStatus, + getSerializedAddressParameters, + prepareTransaction, + sync, + receive, + signOperation, + broadcast, +}; + +export default { currencyBridge, accountBridge }; diff --git a/libs/ledger-live-common/src/families/aptos/bridge/mock.ts b/libs/ledger-live-common/src/families/aptos/bridge/mock.ts new file mode 100644 index 000000000000..d990c1a0c58a --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/bridge/mock.ts @@ -0,0 +1,161 @@ +import { getSerializedAddressParameters } from "@ledgerhq/coin-framework/bridge/jsHelpers"; +import { BigNumber } from "bignumber.js"; +import { + AmountRequired, + NotEnoughBalance, + RecipientRequired, + InvalidAddress, + InvalidAddressBecauseDestinationIsAlsoSource, +} from "@ledgerhq/errors"; +import type { Account, AccountBridge, AccountLike, CurrencyBridge } from "@ledgerhq/types-live"; +import type { Transaction, TransactionStatus } from "../types"; +import { DEFAULT_GAS, DEFAULT_GAS_PRICE } from "../logic"; +import { + scanAccounts, + signOperation, + broadcast, + sync, + makeAccountBridgeReceive, +} from "../../../bridge/mockHelpers"; +import { getEstimatedFees } from "./bridgeHelpers/fee"; +import { updateTransaction } from "@ledgerhq/coin-framework/bridge/jsHelpers"; +import { getAddress, isAddressValid } from "./bridgeHelpers/addresses"; +import { getMainAccount } from "../../../account"; + +const receive = makeAccountBridgeReceive(); + +const createTransaction = (): Transaction => ({ + family: "aptos", + mode: "send", + amount: BigNumber(0), + recipient: "", + useAllAmount: false, + fees: new BigNumber(0.0001), + firstEmulation: true, + options: { + maxGasAmount: DEFAULT_GAS.toString(), + gasUnitPrice: DEFAULT_GAS_PRICE.toString(), + }, + estimate: { + maxGasAmount: DEFAULT_GAS.toString(), + gasUnitPrice: DEFAULT_GAS_PRICE.toString(), + }, +}); + +const getTransactionStatus = async (a: Account, t: Transaction): Promise => { + const errors: TransactionStatus["errors"] = {}; + const warnings: TransactionStatus["warnings"] = {}; + + const { balance } = a; + const { address } = getAddress(a); + const { recipient, useAllAmount } = t; + let { amount } = t; + + if (!recipient) errors.recipient = new RecipientRequired(); + else if (!isAddressValid()) + errors.recipient = new InvalidAddress("", { + currencyName: a.currency.name, + }); + else if (recipient.toLowerCase() === address.toLowerCase()) + errors.recipient = new InvalidAddressBecauseDestinationIsAlsoSource(); + + if (!isAddressValid()) + errors.sender = new InvalidAddress("", { + currencyName: a.currency.name, + }); + + let estimatedFees = t.fees; + + if (!estimatedFees) estimatedFees = new BigNumber(0); + + let totalSpent = BigNumber(0); + + if (useAllAmount) { + totalSpent = a.spendableBalance; + amount = totalSpent.minus(estimatedFees); + if (amount.lte(0) || totalSpent.gt(balance)) { + errors.amount = new NotEnoughBalance(); + } + } + + if (!useAllAmount) { + totalSpent = amount.plus(estimatedFees); + if (amount.eq(0)) { + errors.amount = new AmountRequired(); + } + + if (totalSpent.gt(a.spendableBalance)) { + errors.amount = new NotEnoughBalance(); + } + } + + return { + errors, + warnings, + estimatedFees, + amount, + totalSpent, + }; +}; + +const prepareTransaction = async (a: Account, t: Transaction): Promise => { + const { address } = getAddress(a); + const { recipient } = t; + + if (recipient && address) { + if (t.useAllAmount) { + const amount = a.spendableBalance.minus(t.fees ? t.fees : new BigNumber(0)); + return { ...t, amount }; + } + } + + return t; +}; + +const estimateMaxSpendable = async ({ + account, + parentAccount, + transaction, +}: { + account: AccountLike; + parentAccount?: Account | null | undefined; + transaction?: Transaction | null | undefined; +}): Promise => { + const a = getMainAccount(account, parentAccount); + let balance = a.spendableBalance; + + if (balance.eq(0)) return balance; + + const estimatedFees = transaction?.fees ?? getEstimatedFees(); + + if (balance.lte(estimatedFees)) return new BigNumber(0); + + balance = balance.minus(estimatedFees); + + return balance; +}; +const preload = async () => ({}); + +const hydrate = () => {}; + +const currencyBridge: CurrencyBridge = { + preload, + hydrate, + scanAccounts, +}; +const accountBridge: AccountBridge = { + getSerializedAddressParameters, + createTransaction, + updateTransaction, + prepareTransaction, + getTransactionStatus, + sync, + receive, + signOperation, + broadcast, + estimateMaxSpendable, +}; +export default { + currencyBridge, + accountBridge, +}; diff --git a/libs/ledger-live-common/src/families/aptos/broadcast.test.ts b/libs/ledger-live-common/src/families/aptos/broadcast.test.ts new file mode 100644 index 000000000000..7adda4fecfb5 --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/broadcast.test.ts @@ -0,0 +1,99 @@ +import broadcast from "./broadcast"; +import { AptosAPI } from "./api"; +import { patchOperationWithHash } from "./../../operation"; +import type { Account, Operation, SignedOperation } from "@ledgerhq/types-live"; +import BigNumber from "bignumber.js"; + +jest.mock("./api"); +jest.mock("./../../operation"); +jest.mock("@ledgerhq/logs"); + +describe("broadcast", () => { + const mockAccount: Account = { + type: "Account", + seedIdentifier: "mockSeedIdentifier", + operationsCount: 0, + id: "mockAccountId", + currency: { + type: "CryptoCurrency", + id: "aptos", + name: "Aptos", + ticker: "APT", + units: [{ name: "APT", code: "APT", magnitude: 6 }], + managerAppName: "Aptos", + coinType: 637, + scheme: "aptos", + color: "#000000", + family: "aptos", + blockAvgTime: 5, + explorerViews: [], + }, + balance: BigNumber(1000), + spendableBalance: BigNumber(1000), + operations: [], + pendingOperations: [], + lastSyncDate: new Date(), + blockHeight: 0, + index: 0, + derivationMode: "", + freshAddress: "", + freshAddressPath: "", + used: false, + swapHistory: [], + creationDate: new Date(), + balanceHistoryCache: { + HOUR: { latestDate: 0, balances: [] }, + DAY: { latestDate: 0, balances: [] }, + WEEK: { latestDate: 0, balances: [] }, + }, + }; + + const mockOperation: Operation = { + id: "mockOperationId", + hash: "", + type: "OUT", + value: BigNumber(100), + fee: BigNumber(1), + senders: ["sender"], + recipients: ["recipient"], + blockHeight: null, + blockHash: null, + accountId: "mockAccountId", + date: new Date(), + extra: {}, + }; + + const mockSignedOperation: SignedOperation = { + operation: mockOperation, + signature: "mockSignature", + }; + + it("should broadcast the signed operation and return the patched operation", async () => { + const mockHash = "mockHash"; + (AptosAPI.prototype.broadcast as jest.Mock).mockResolvedValue(mockHash); + (patchOperationWithHash as jest.Mock).mockReturnValue({ + ...mockOperation, + hash: mockHash, + }); + + const result = await broadcast({ + signedOperation: mockSignedOperation, + account: mockAccount, + }); + + expect(AptosAPI.prototype.broadcast).toHaveBeenCalledWith("mockSignature"); + expect(patchOperationWithHash).toHaveBeenCalledWith(mockOperation, mockHash); + expect(result).toEqual({ ...mockOperation, hash: mockHash }); + }); + + it("should throw an error if broadcast fails", async () => { + (AptosAPI.prototype.broadcast as jest.Mock).mockRejectedValue(new Error("Broadcast failed")); + + await expect( + broadcast({ + signedOperation: mockSignedOperation, + account: mockAccount, + }), + ).rejects.toThrow("Broadcast failed"); + }); +}); diff --git a/libs/ledger-live-common/src/families/aptos/broadcast.ts b/libs/ledger-live-common/src/families/aptos/broadcast.ts new file mode 100644 index 000000000000..3a2e14b6c93f --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/broadcast.ts @@ -0,0 +1,17 @@ +import type { Account, Operation, SignedOperation } from "@ledgerhq/types-live"; +import { patchOperationWithHash } from "./../../operation"; +import { AptosAPI } from "./api"; + +const broadcast = async ({ + signedOperation, + account, +}: { + signedOperation: SignedOperation; + account: Account; +}): Promise => { + const { signature, operation } = signedOperation; + const hash = await new AptosAPI(account.currency.id).broadcast(signature); + return patchOperationWithHash(operation, hash); +}; + +export default broadcast; diff --git a/libs/ledger-live-common/src/families/aptos/buildTransaction.test.ts b/libs/ledger-live-common/src/families/aptos/buildTransaction.test.ts new file mode 100644 index 000000000000..c65ab1af1b41 --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/buildTransaction.test.ts @@ -0,0 +1,62 @@ +import { createFixtureAccount } from "../../mock/fixtures/cryptoCurrencies"; +import createTransaction from "./createTransaction"; +import buildTransaction from "./buildTransaction"; +import { AptosAPI } from "./api"; +import { normalizeTransactionOptions } from "./logic"; +import { InputEntryFunctionData } from "@aptos-labs/ts-sdk"; +import { TransactionOptions } from "./types"; + +const generateTransaction = jest.fn(() => "tx"); + +jest.mock("./logic", () => ({ + normalizeTransactionOptions: jest.fn(() => ({ + maxGasAmount: "100", + gasUnitPrice: "200", + })), + DEFAULT_GAS: 100, + DEFAULT_GAS_PRICE: 200, +})); + +jest.mock("./api", () => { + return { + AptosAPI: function () { + return { + generateTransaction, + }; + }, + }; +}); + +describe("buildTransaction Test", () => { + it("should return tx", async () => { + const account = createFixtureAccount(); + const transaction = createTransaction(); + const aptosClient = new AptosAPI(account.currency.id); + const result = await buildTransaction(account, transaction, aptosClient); + + const expected = "tx"; + + expect(result).toBe(expected); + + const mockedNormalizeTransactionOptions = jest.mocked(normalizeTransactionOptions); + + expect(mockedNormalizeTransactionOptions).toHaveBeenCalledTimes(1); + expect(generateTransaction).toHaveBeenCalledTimes(1); + + const generateTransactionArgs: [string, InputEntryFunctionData, TransactionOptions][] = + generateTransaction.mock.calls[0]; + + expect(mockedNormalizeTransactionOptions.mock.calls[0][0]).toEqual({ + maxGasAmount: "100", + gasUnitPrice: "200", + }); + + expect(generateTransactionArgs[0]).toBe("0x01"); + expect(generateTransactionArgs[1]).toEqual({ + function: "0x1::aptos_account::transfer_coins", + typeArguments: ["0x1::aptos_coin::AptosCoin"], + functionArguments: ["", "0"], + }); + expect(generateTransactionArgs[2]).toEqual({ maxGasAmount: "100", gasUnitPrice: "200" }); + }); +}); diff --git a/libs/ledger-live-common/src/families/aptos/buildTransaction.ts b/libs/ledger-live-common/src/families/aptos/buildTransaction.ts new file mode 100644 index 000000000000..b0cc76af3249 --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/buildTransaction.ts @@ -0,0 +1,29 @@ +import { InputEntryFunctionData, RawTransaction } from "@aptos-labs/ts-sdk"; +import type { Account } from "@ledgerhq/types-live"; +import BigNumber from "bignumber.js"; +import { AptosAPI } from "./api"; +import { APTOS_ASSET_ID } from "./constants"; +import { normalizeTransactionOptions } from "./logic"; +import type { Transaction } from "./types"; + +const buildTransaction = async ( + account: Account, + transaction: Transaction, + aptosClient: AptosAPI, +): Promise => { + const txPayload = getPayload(transaction.recipient, transaction.amount); + const txOptions = normalizeTransactionOptions(transaction.options); + const tx = await aptosClient.generateTransaction(account.freshAddress, txPayload, txOptions); + + return tx; +}; + +const getPayload = (sendTo: string, amount: BigNumber): InputEntryFunctionData => { + return { + function: "0x1::aptos_account::transfer_coins", + typeArguments: [APTOS_ASSET_ID], + functionArguments: [sendTo, amount.toString()], + }; +}; + +export default buildTransaction; diff --git a/libs/ledger-live-common/src/families/aptos/constants.ts b/libs/ledger-live-common/src/families/aptos/constants.ts new file mode 100644 index 000000000000..e97564afa963 --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/constants.ts @@ -0,0 +1,30 @@ +export const LOAD_LIMIT = 10; + +export enum TX_STATUS { + PENDING = "pending", + FAIL = "fail", + SUCCESS = "success", +} + +export const TRANSFER_TYPES = [ + "0x1::aptos_account::transfer", + "0x1::aptos_account::transfer_coins", + "0x1::coin::transfer", +]; +export const BATCH_TRANSFER_TYPES = [ + "0x1::aptos_account::batch_transfer", + "0x1::aptos_account::batch_transfer_coins", +]; +export const DELEGATION_POOL_TYPES = [ + "0x1::delegation_pool::add_stake", + "0x1::delegation_pool::withdraw", +]; + +export const APTOS_ASSET_ID = "0x1::aptos_coin::AptosCoin"; +export const APTOS_COIN_CHANGE = `0x1::coin::CoinStore<${APTOS_ASSET_ID}>`; + +export enum DIRECTION { + IN = "IN", + OUT = "OUT", + UNKNOWN = "UNKNOWN", +} diff --git a/libs/ledger-live-common/src/families/aptos/createTransaction.test.ts b/libs/ledger-live-common/src/families/aptos/createTransaction.test.ts new file mode 100644 index 000000000000..fbff2ea83902 --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/createTransaction.test.ts @@ -0,0 +1,32 @@ +import BigNumber from "bignumber.js"; +import createTransaction from "./createTransaction"; + +jest.mock("./logic", () => ({ + DEFAULT_GAS: 100, + DEFAULT_GAS_PRICE: 200, +})); + +describe("createTransaction Test", () => { + it("should return a transaction object", async () => { + const result = createTransaction(); + + const expected = { + family: "aptos", + mode: "send", + amount: BigNumber(0), + recipient: "", + useAllAmount: false, + firstEmulation: true, + options: { + maxGasAmount: "100", + gasUnitPrice: "200", + }, + estimate: { + maxGasAmount: "100", + gasUnitPrice: "200", + }, + }; + + expect(result).toEqual(expected); + }); +}); diff --git a/libs/ledger-live-common/src/families/aptos/createTransaction.ts b/libs/ledger-live-common/src/families/aptos/createTransaction.ts new file mode 100644 index 000000000000..80ba702fd1b7 --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/createTransaction.ts @@ -0,0 +1,22 @@ +import BigNumber from "bignumber.js"; +import type { Transaction } from "./types"; +import { DEFAULT_GAS, DEFAULT_GAS_PRICE } from "./logic"; + +const createTransaction = (): Transaction => ({ + family: "aptos", + mode: "send", + amount: BigNumber(0), + recipient: "", + useAllAmount: false, + firstEmulation: true, + options: { + maxGasAmount: DEFAULT_GAS.toString(), + gasUnitPrice: DEFAULT_GAS_PRICE.toString(), + }, + estimate: { + maxGasAmount: DEFAULT_GAS.toString(), + gasUnitPrice: DEFAULT_GAS_PRICE.toString(), + }, +}); + +export default createTransaction; diff --git a/libs/ledger-live-common/src/families/aptos/deviceTransactionConfig.ts b/libs/ledger-live-common/src/families/aptos/deviceTransactionConfig.ts new file mode 100644 index 000000000000..c5168a827f89 --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/deviceTransactionConfig.ts @@ -0,0 +1,30 @@ +import type { DeviceTransactionField } from "../../transaction"; +import BigNumber from "bignumber.js"; + +export const methodToString = (method: number): string => { + switch (method) { + case 0: + return "Coin transfer"; + default: + return "Unknown"; + } +}; + +export type ExtraDeviceTransactionField = { + type: "aptos.extendedAmount"; + label: string; + value: number | BigNumber; +}; + +function getDeviceTransactionConfig(): Array { + const fields: Array = []; + fields.push({ + type: "text", + label: "Type", + value: methodToString(0), + }); + + return fields; +} + +export default getDeviceTransactionConfig; diff --git a/libs/ledger-live-common/src/families/aptos/errors.ts b/libs/ledger-live-common/src/families/aptos/errors.ts new file mode 100644 index 000000000000..50f0279260ea --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/errors.ts @@ -0,0 +1,7 @@ +import { createCustomErrorClass } from "@ledgerhq/errors"; + +export const SequenceNumberTooOldError = createCustomErrorClass("SequenceNumberTooOld"); + +export const SequenceNumberTooNewError = createCustomErrorClass("SequenceNumberTooNew"); + +export const TransactionExpiredError = createCustomErrorClass("TransactionExpired"); diff --git a/libs/ledger-live-common/src/families/aptos/estimateMaxSpendable.test.ts b/libs/ledger-live-common/src/families/aptos/estimateMaxSpendable.test.ts new file mode 100644 index 000000000000..4c3840d018aa --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/estimateMaxSpendable.test.ts @@ -0,0 +1,97 @@ +import { createFixtureAccount } from "../../mock/fixtures/cryptoCurrencies"; +import createTransaction from "./createTransaction"; +import estimateMaxSpendable from "./estimateMaxSpendable"; +import BigNumber from "bignumber.js"; + +jest.mock("./getFeesForTransaction", () => ({ + getEstimatedGas: jest.fn(() => ({ + fees: new BigNumber(0), + estimate: { + maxGasAmount: 1, + gasUnitPrice: 2, + sequenceNumber: "", + expirationTimestampSecs: "", + }, + errors: {}, + })), +})); + +describe("estimateMaxSpendable Test", () => { + describe("spendable balance is lower than the total gas", () => { + it("should return 0", async () => { + const account = createFixtureAccount(); + + const spendableBalance = new BigNumber(0); + + account.spendableBalance = spendableBalance; + + const result = await estimateMaxSpendable({ + account, + }); + + const expected = spendableBalance; + + expect(result.isEqualTo(expected)).toBe(true); + }); + }); + + describe("spendable balance is higher than the total gas", () => { + it("should return spendable amount minus total gas", async () => { + const account = createFixtureAccount(); + + const spendableBalance = new BigNumber(100000); + + account.spendableBalance = spendableBalance; + + const result = await estimateMaxSpendable({ + account, + }); + + const expected = new BigNumber(80000); + + expect(result.isEqualTo(expected)).toBe(true); + }); + }); + + describe("transaction spendable balance is higher than the total gas", () => { + it("should return transaction spendable amount minus total gas", async () => { + const account = createFixtureAccount(); + const transaction = createTransaction(); + + const spendableBalance = new BigNumber(1); + + account.spendableBalance = spendableBalance; + + const result = await estimateMaxSpendable({ + account, + parentAccount: account, + transaction, + }); + + const expected = new BigNumber(0); + + expect(result.isEqualTo(expected)).toBe(true); + }); + }); + + describe("transaction spendable balance is higher than the total gas", () => { + it("should return transaction spendable amount minus total gas", async () => { + const account = createFixtureAccount(); + const transaction = createTransaction(); + + const spendableBalance = new BigNumber(100000); + + account.spendableBalance = spendableBalance; + + const result = await estimateMaxSpendable({ + account, + parentAccount: account, + transaction, + }); + + const expected = new BigNumber(99998); + + expect(result.isEqualTo(expected)).toBe(true); + }); + }); +}); diff --git a/libs/ledger-live-common/src/families/aptos/estimateMaxSpendable.ts b/libs/ledger-live-common/src/families/aptos/estimateMaxSpendable.ts new file mode 100644 index 000000000000..808634d0b729 --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/estimateMaxSpendable.ts @@ -0,0 +1,35 @@ +import type { Account, AccountLike } from "@ledgerhq/types-live"; +import { BigNumber } from "bignumber.js"; +import { getMainAccount } from "../../account"; +import { AptosAPI } from "./api"; +import { getEstimatedGas } from "./getFeesForTransaction"; +import { DEFAULT_GAS, DEFAULT_GAS_PRICE, getMaxSendBalance } from "./logic"; +import type { Transaction } from "./types"; + +const estimateMaxSpendable = async ({ + account, + parentAccount, + transaction, +}: { + account: AccountLike; + parentAccount?: Account; + transaction?: Transaction; +}): Promise => { + const mainAccount = getMainAccount(account, parentAccount); + + const aptosClient = new AptosAPI(mainAccount.currency.id); + + let maxGasAmount = new BigNumber(DEFAULT_GAS); + let gasUnitPrice = new BigNumber(DEFAULT_GAS_PRICE); + + if (transaction) { + const { estimate } = await getEstimatedGas(mainAccount, transaction, aptosClient); + + maxGasAmount = BigNumber(estimate.maxGasAmount); + gasUnitPrice = BigNumber(estimate.gasUnitPrice); + } + + return getMaxSendBalance(mainAccount.spendableBalance, maxGasAmount, gasUnitPrice); +}; + +export default estimateMaxSpendable; diff --git a/libs/ledger-live-common/src/families/aptos/getFeesForTransaction.test.ts b/libs/ledger-live-common/src/families/aptos/getFeesForTransaction.test.ts new file mode 100644 index 000000000000..2d8945043fc0 --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/getFeesForTransaction.test.ts @@ -0,0 +1,244 @@ +import BigNumber from "bignumber.js"; +import { createFixtureAccount } from "../../mock/fixtures/cryptoCurrencies"; +import createTransaction from "./createTransaction"; +import * as getFeesForTransaction from "./getFeesForTransaction"; +import { AptosAPI } from "./api"; + +let simulateTransaction = jest.fn(); + +jest.mock("./api", () => { + return { + AptosAPI: function () { + return { + estimateGasPrice: jest.fn(() => ({ gas_estimate: 101 })), + generateTransaction: jest.fn(() => "tx"), + simulateTransaction, + getAccount: jest.fn(() => ({ sequence_number: "123" })), + }; + }, + }; +}); + +jest.mock("@aptos-labs/ts-sdk", () => { + return { + Ed25519PublicKey: jest.fn(), + }; +}); + +jest.mock("./logic", () => { + return { + DEFAULT_GAS: 201, + DEFAULT_GAS_PRICE: 101, + ESTIMATE_GAS_MUL: 1, + normalizeTransactionOptions: jest.fn(), + }; +}); + +describe("getFeesForTransaction Test", () => { + describe("when using getFee", () => { + describe("with vm_status as SEQUENCE_NUMBER", () => { + it("should return a fee estimation object", async () => { + simulateTransaction = jest.fn(() => [ + { + success: false, + vm_status: ["SEQUENCE_NUMBER"], + expiration_timestamp_secs: 5, + }, + ]); + + const account = createFixtureAccount(); + const transaction = createTransaction(); + const aptosClient = new AptosAPI(account.currency.id); + + transaction.amount = new BigNumber(1); + account.xpub = "xpub"; + account.spendableBalance = new BigNumber(100000000); + + const result = await getFeesForTransaction.getFee(account, transaction, aptosClient); + + const expected = { + fees: new BigNumber(20301), + estimate: { + maxGasAmount: "201", + gasUnitPrice: "101", + sequenceNumber: "123", + expirationTimestampSecs: "", + }, + errors: { + sequenceNumber: ["SEQUENCE_NUMBER"], + }, + }; + + expect(result).toEqual(expected); + }); + }); + + describe("with vm_status as TRANSACTION_EXPIRED", () => { + it("should return a fee estimation object", async () => { + simulateTransaction = jest.fn(() => [ + { + success: false, + vm_status: ["TRANSACTION_EXPIRED"], + expiration_timestamp_secs: 5, + }, + ]); + + const account = createFixtureAccount(); + const transaction = createTransaction(); + const aptosClient = new AptosAPI(account.currency.id); + + transaction.amount = new BigNumber(1); + account.xpub = "xpub"; + account.spendableBalance = new BigNumber(100000000); + + const result = await getFeesForTransaction.getFee(account, transaction, aptosClient); + + const expected = { + fees: new BigNumber(20301), + estimate: { + maxGasAmount: "201", + gasUnitPrice: "101", + sequenceNumber: "123", + expirationTimestampSecs: "", + }, + errors: { + expirationTimestampSecs: ["TRANSACTION_EXPIRED"], + }, + }; + + expect(result).toEqual(expected); + }); + }); + + describe("with vm_status as INSUFFICIENT_BALANCE", () => { + it("should return a fee estimation object", async () => { + simulateTransaction = jest.fn(() => [ + { + success: false, + vm_status: ["INSUFFICIENT_BALANCE"], + expiration_timestamp_secs: 5, + }, + ]); + + const account = createFixtureAccount(); + const transaction = createTransaction(); + const aptosClient = new AptosAPI(account.currency.id); + + transaction.amount = new BigNumber(1); + account.xpub = "xpub"; + account.spendableBalance = new BigNumber(100000000); + + const result = await getFeesForTransaction.getFee(account, transaction, aptosClient); + + const expected = { + fees: new BigNumber(20301), + estimate: { + maxGasAmount: "201", + gasUnitPrice: "101", + sequenceNumber: "123", + expirationTimestampSecs: "", + }, + errors: {}, + }; + + expect(result).toEqual(expected); + }); + }); + + describe("with vm_status as DUMMY_STATE", () => { + it("should return a fee estimation object", () => { + simulateTransaction = jest.fn(() => [ + { + success: false, + vm_status: ["DUMMY_STATE"], + expiration_timestamp_secs: 5, + }, + ]); + + const account = createFixtureAccount(); + const transaction = createTransaction(); + const aptosClient = new AptosAPI(account.currency.id); + + transaction.amount = new BigNumber(1); + account.xpub = "xpub"; + account.spendableBalance = new BigNumber(100000000); + + expect(async () => { + await getFeesForTransaction.getFee(account, transaction, aptosClient); + }).rejects.toThrow("Simulation failed with following error: DUMMY_STATE"); + }); + }); + }); + + describe("when using getEstimatedGas", () => { + describe("when key not in cache", () => { + it("should return cached fee", async () => { + const account = createFixtureAccount(); + const transaction = createTransaction(); + const aptosClient = new AptosAPI(account.currency.id); + + const result = await getFeesForTransaction.getEstimatedGas( + account, + transaction, + aptosClient, + ); + + const expected = { + errors: {}, + estimate: { + expirationTimestampSecs: "", + gasUnitPrice: "101", + maxGasAmount: "201", + sequenceNumber: "123", + }, + fees: new BigNumber("20301"), + }; + + expect(result).toEqual(expected); + }); + }); + + describe("when key is in cache 22", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should return cached fee", async () => { + const mocked = jest.spyOn(getFeesForTransaction, "getFee"); + + const account = createFixtureAccount(); + const transaction = createTransaction(); + const aptosClient = new AptosAPI(account.currency.id); + + transaction.amount = new BigNumber(10); + + const result1 = await getFeesForTransaction.getEstimatedGas( + account, + transaction, + aptosClient, + ); + const result2 = await getFeesForTransaction.getEstimatedGas( + account, + transaction, + aptosClient, + ); + + expect(mocked).toHaveBeenCalledTimes(1); + + const expected = { + errors: {}, + estimate: { + expirationTimestampSecs: "", + gasUnitPrice: "101", + maxGasAmount: "201", + sequenceNumber: "123", + }, + fees: new BigNumber("20301"), + }; + + expect(result1).toEqual(expected); + expect(result2).toEqual(expected); + }); + }); + }); +}); diff --git a/libs/ledger-live-common/src/families/aptos/getFeesForTransaction.ts b/libs/ledger-live-common/src/families/aptos/getFeesForTransaction.ts new file mode 100644 index 000000000000..dc1cfc42717c --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/getFeesForTransaction.ts @@ -0,0 +1,130 @@ +import { Ed25519PublicKey } from "@aptos-labs/ts-sdk"; +import { log } from "@ledgerhq/logs"; +import type { Account } from "@ledgerhq/types-live"; +import BigNumber from "bignumber.js"; +import { AptosAPI } from "./api"; +import buildTransaction from "./buildTransaction"; +import { DEFAULT_GAS, DEFAULT_GAS_PRICE, ESTIMATE_GAS_MUL } from "./logic"; +import type { Transaction, TransactionErrors } from "./types"; + +type IGetEstimatedGasReturnType = { + fees: BigNumber; + estimate: { + maxGasAmount: string; + gasUnitPrice: string; + sequenceNumber: string; + expirationTimestampSecs: string; + }; + errors: TransactionErrors; +}; + +const CACHE = new Map(); + +export const getFee = async ( + account: Account, + transaction: Transaction, + aptosClient: AptosAPI, +): Promise => { + const res = { + fees: new BigNumber(0), + estimate: { + maxGasAmount: transaction.estimate.maxGasAmount, + gasUnitPrice: transaction.estimate.gasUnitPrice, + sequenceNumber: transaction.options.sequenceNumber || "", + expirationTimestampSecs: transaction.options.expirationTimestampSecs || "", + }, + errors: { ...transaction.errors }, + }; + + let gasPrice = DEFAULT_GAS_PRICE; + let gasLimit = DEFAULT_GAS; + let sequenceNumber = ""; + + try { + const { gas_estimate } = await aptosClient.estimateGasPrice(); + gasPrice = gas_estimate; + } catch (err) { + // skip + } + + if (account.xpub) { + try { + const publicKeyEd = new Ed25519PublicKey(account.xpub as string); + const tx = await buildTransaction(account, transaction, aptosClient); + const simulation = await aptosClient.simulateTransaction(publicKeyEd, tx); + const completedTx = simulation[0]; + + const expectedGas = BigNumber(gasLimit * gasPrice); + const isUnderMaxSpendable = !transaction.amount + .plus(expectedGas) + .isGreaterThan(account.spendableBalance); + + if (isUnderMaxSpendable && !completedTx.success) { + switch (true) { + case completedTx.vm_status.includes("SEQUENCE_NUMBER"): { + res.errors.sequenceNumber = completedTx.vm_status; + break; + } + case completedTx.vm_status.includes("TRANSACTION_EXPIRED"): { + res.errors.expirationTimestampSecs = completedTx.vm_status; + break; + } + case completedTx.vm_status.includes("INSUFFICIENT_BALANCE"): { + // skip, processed in getTransactionStatus + break; + } + default: { + throw Error(`Simulation failed with following error: ${completedTx.vm_status}`); + } + } + } + + gasLimit = + Number(completedTx.gas_used) || + Math.floor(Number(transaction.options.maxGasAmount) / ESTIMATE_GAS_MUL); + } catch (error: any) { + log(error.message); + throw error; + } + } + + try { + const { sequence_number } = await aptosClient.getAccount(account.freshAddress); + sequenceNumber = sequence_number; + } catch (_) { + // skip + } + + gasLimit = Math.ceil(gasLimit * ESTIMATE_GAS_MUL); + + res.estimate.gasUnitPrice = gasPrice.toString(); + res.estimate.sequenceNumber = sequenceNumber.toString(); + res.estimate.maxGasAmount = gasLimit.toString(); + + res.fees = res.fees.plus(BigNumber(gasPrice)).multipliedBy(BigNumber(gasLimit)); + CACHE.delete(getCacheKey(transaction)); + return res; +}; + +const getCacheKey = (transaction: Transaction): string => + JSON.stringify({ + amount: transaction.amount, + gasUnitPrice: transaction.options.gasUnitPrice, + maxGasAmount: transaction.options.maxGasAmount, + sequenceNumber: transaction.options.sequenceNumber, + expirationTimestampSecs: transaction.options.expirationTimestampSecs, + }); + +export const getEstimatedGas = async ( + account: Account, + transaction: Transaction, + aptosClient: AptosAPI, +): Promise => { + const key = getCacheKey(transaction); + + if (!CACHE.has(key)) { + CACHE.set(key, await getFee(account, transaction, aptosClient)); + } + + return CACHE.get(key); +}; diff --git a/libs/ledger-live-common/src/families/aptos/getTransactionStatus.test.ts b/libs/ledger-live-common/src/families/aptos/getTransactionStatus.test.ts new file mode 100644 index 000000000000..bc1a72f4c700 --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/getTransactionStatus.test.ts @@ -0,0 +1,288 @@ +import BigNumber from "bignumber.js"; +import { createFixtureAccount } from "../../mock/fixtures/cryptoCurrencies"; +import createTransaction from "./createTransaction"; +import getTransactionStatus from "./getTransactionStatus"; +import { + AmountRequired, + FeeNotLoaded, + GasLessThanEstimate, + InvalidAddress, + InvalidAddressBecauseDestinationIsAlsoSource, + NotEnoughBalance, + RecipientRequired, +} from "@ledgerhq/errors"; +import { + SequenceNumberTooNewError, + SequenceNumberTooOldError, + TransactionExpiredError, +} from "./errors"; + +describe("getTransactionStatus Test", () => { + it("should return errors for AmountRequired", async () => { + const account = createFixtureAccount(); + const transaction = createTransaction(); + + transaction.fees = new BigNumber(2); + transaction.recipient = "0x" + "0".repeat(64); + + const result = await getTransactionStatus(account, transaction); + + const expected = { + errors: { + amount: new AmountRequired(), + }, + warnings: {}, + estimatedFees: new BigNumber(2), + amount: new BigNumber(0), + totalSpent: new BigNumber(2), + }; + + expect(result).toEqual(expected); + }); + + it("should return errors for FeeNotLoaded", async () => { + const account = createFixtureAccount(); + const transaction = createTransaction(); + + transaction.fees = null; + transaction.amount = new BigNumber(2); + transaction.recipient = "0x" + "0".repeat(64); + + const result = await getTransactionStatus(account, transaction); + + const expected = { + errors: { + fees: new FeeNotLoaded(), + }, + warnings: {}, + estimatedFees: new BigNumber(0), + amount: new BigNumber(2), + totalSpent: new BigNumber(2), + }; + + expect(result).toEqual(expected); + }); + + it("should return errors for NotEnoughBalance", async () => { + const account = createFixtureAccount(); + const transaction = createTransaction(); + + account.balance = new BigNumber(1); + transaction.recipient = "0x" + "0".repeat(64); + transaction.amount = new BigNumber(2); + transaction.fees = new BigNumber(2); + + const result = await getTransactionStatus(account, transaction); + + const expected = { + errors: { + amount: new NotEnoughBalance(), + }, + warnings: {}, + estimatedFees: new BigNumber(2), + amount: new BigNumber(2), + totalSpent: new BigNumber(4), + }; + + expect(result).toEqual(expected); + }); + + it("should return errors for RecipientRequired", async () => { + const account = createFixtureAccount(); + const transaction = createTransaction(); + + transaction.amount = new BigNumber(2); + transaction.fees = new BigNumber(2); + + const result = await getTransactionStatus(account, transaction); + + const expected = { + errors: { + recipient: new RecipientRequired(), + }, + warnings: {}, + estimatedFees: new BigNumber(2), + amount: new BigNumber(2), + totalSpent: new BigNumber(4), + }; + + expect(result).toEqual(expected); + }); + + it("should return errors for InvalidAddress", async () => { + const account = createFixtureAccount(); + const transaction = createTransaction(); + + transaction.amount = new BigNumber(2); + transaction.fees = new BigNumber(2); + transaction.recipient = "0x"; + + const result = await getTransactionStatus(account, transaction); + + const expected = { + errors: { + recipient: new InvalidAddress(), + }, + warnings: {}, + estimatedFees: new BigNumber(2), + amount: new BigNumber(2), + totalSpent: new BigNumber(4), + }; + + expect(result).toEqual(expected); + }); + + it("should return errors for InvalidAddressBecauseDestinationIsAlsoSource", async () => { + const account = createFixtureAccount(); + const transaction = createTransaction(); + + transaction.amount = new BigNumber(2); + transaction.fees = new BigNumber(2); + transaction.recipient = "0x" + "0".repeat(64); + account.freshAddress = transaction.recipient; + + const result = await getTransactionStatus(account, transaction); + + const expected = { + errors: { + recipient: new InvalidAddressBecauseDestinationIsAlsoSource(), + }, + warnings: {}, + estimatedFees: new BigNumber(2), + amount: new BigNumber(2), + totalSpent: new BigNumber(4), + }; + + expect(result).toEqual(expected); + }); + + it("should return errors for GasLessThanEstimate", async () => { + const account = createFixtureAccount(); + const transaction = createTransaction(); + + transaction.amount = new BigNumber(2); + transaction.fees = new BigNumber(2); + transaction.recipient = "0x" + "0".repeat(64); + + transaction.options.maxGasAmount = "50"; + transaction.estimate.maxGasAmount = "100"; + + const result = await getTransactionStatus(account, transaction); + + const expected = { + errors: { + maxGasAmount: new GasLessThanEstimate(), + }, + warnings: {}, + estimatedFees: new BigNumber(2), + amount: new BigNumber(2), + totalSpent: new BigNumber(4), + }; + + expect(result).toEqual(expected); + }); + + it("should return errors for GasLessThanEstimate", async () => { + const account = createFixtureAccount(); + const transaction = createTransaction(); + + transaction.amount = new BigNumber(2); + transaction.fees = new BigNumber(2); + transaction.recipient = "0x" + "0".repeat(64); + + transaction.options.gasUnitPrice = "50"; + transaction.estimate.gasUnitPrice = "100"; + + const result = await getTransactionStatus(account, transaction); + + const expected = { + errors: { + gasUnitPrice: new GasLessThanEstimate(), + }, + warnings: {}, + estimatedFees: new BigNumber(2), + amount: new BigNumber(2), + totalSpent: new BigNumber(4), + }; + + expect(result).toEqual(expected); + }); + + it("should return errors for SequenceNumberTooOldError", async () => { + const account = createFixtureAccount(); + const transaction = createTransaction(); + + transaction.amount = new BigNumber(2); + transaction.fees = new BigNumber(2); + transaction.recipient = "0x" + "0".repeat(64); + transaction.errors = { + sequenceNumber: "TOO_OLD", + }; + + const result = await getTransactionStatus(account, transaction); + + const expected = { + errors: { + sequenceNumber: new SequenceNumberTooOldError(), + }, + warnings: {}, + estimatedFees: new BigNumber(2), + amount: new BigNumber(2), + totalSpent: new BigNumber(4), + }; + + expect(result).toEqual(expected); + }); + + it("should return errors for SequenceNumberTooNewError", async () => { + const account = createFixtureAccount(); + const transaction = createTransaction(); + + transaction.amount = new BigNumber(2); + transaction.fees = new BigNumber(2); + transaction.recipient = "0x" + "0".repeat(64); + transaction.errors = { + sequenceNumber: "TOO_NEW", + }; + + const result = await getTransactionStatus(account, transaction); + + const expected = { + errors: { + sequenceNumber: new SequenceNumberTooNewError(), + }, + warnings: {}, + estimatedFees: new BigNumber(2), + amount: new BigNumber(2), + totalSpent: new BigNumber(4), + }; + + expect(result).toEqual(expected); + }); + + it("should return errors for TransactionExpiredError", async () => { + const account = createFixtureAccount(); + const transaction = createTransaction(); + + transaction.amount = new BigNumber(2); + transaction.fees = new BigNumber(2); + transaction.recipient = "0x" + "0".repeat(64); + transaction.errors = { + expirationTimestampSecs: "expirationTimestampSecs", + }; + + const result = await getTransactionStatus(account, transaction); + + const expected = { + errors: { + expirationTimestampSecs: new TransactionExpiredError(), + }, + warnings: {}, + estimatedFees: new BigNumber(2), + amount: new BigNumber(2), + totalSpent: new BigNumber(4), + }; + + expect(result).toEqual(expected); + }); +}); diff --git a/libs/ledger-live-common/src/families/aptos/getTransactionStatus.ts b/libs/ledger-live-common/src/families/aptos/getTransactionStatus.ts new file mode 100644 index 000000000000..469954737047 --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/getTransactionStatus.ts @@ -0,0 +1,90 @@ +import { BigNumber } from "bignumber.js"; +import { + NotEnoughBalance, + RecipientRequired, + InvalidAddress, + FeeNotLoaded, + GasLessThanEstimate, + InvalidAddressBecauseDestinationIsAlsoSource, + AmountRequired, +} from "@ledgerhq/errors"; +import type { Account } from "@ledgerhq/types-live"; +import type { TransactionStatus } from "../..//generated/types"; +import type { Transaction } from "./types"; + +import { + SequenceNumberTooNewError, + SequenceNumberTooOldError, + TransactionExpiredError, +} from "./errors"; +import { AccountAddress } from "@aptos-labs/ts-sdk"; + +const getTransactionStatus = async (a: Account, t: Transaction): Promise => { + const errors: Record = {}; + const warnings: Record = {}; + const useAllAmount = !!t.useAllAmount; + + if (!t.fees) { + errors.fees = new FeeNotLoaded(); + } + + const estimatedFees = t.fees || BigNumber(0); + + if (t.amount.eq(0)) { + errors.amount = new AmountRequired(); + } + + const amount = t.amount; + + const totalSpent = useAllAmount ? a.balance : BigNumber(t.amount).plus(estimatedFees); + + if (totalSpent.gt(a.balance)) { + errors.amount = new NotEnoughBalance(); + } + + if (!t.recipient) { + errors.recipient = new RecipientRequired(); + } else if (AccountAddress.isValid({ input: t.recipient }).valid === false) { + errors.recipient = new InvalidAddress("", { currencyName: a.currency.name }); + } else if (t.recipient === a.freshAddress) { + errors.recipient = new InvalidAddressBecauseDestinationIsAlsoSource(); + } + + if ( + t.options.maxGasAmount && + t.estimate.maxGasAmount && + +t.options.maxGasAmount < +t.estimate.maxGasAmount + ) { + errors.maxGasAmount = new GasLessThanEstimate(); + } + + if ( + t.options.gasUnitPrice && + t.estimate.gasUnitPrice && + +t.options.gasUnitPrice < +t.estimate.gasUnitPrice + ) { + errors.gasUnitPrice = new GasLessThanEstimate(); + } + + if (t.errors?.sequenceNumber) { + if (t.errors.sequenceNumber.includes("TOO_OLD")) { + errors.sequenceNumber = new SequenceNumberTooOldError(); + } else if (t.errors.sequenceNumber.includes("TOO_NEW")) { + errors.sequenceNumber = new SequenceNumberTooNewError(); + } + } + + if (t.errors?.expirationTimestampSecs) { + errors.expirationTimestampSecs = new TransactionExpiredError(); + } + + return Promise.resolve({ + errors, + warnings, + estimatedFees, + amount, + totalSpent, + }); +}; + +export default getTransactionStatus; diff --git a/libs/ledger-live-common/src/families/aptos/hw-getAddress.ts b/libs/ledger-live-common/src/families/aptos/hw-getAddress.ts new file mode 100644 index 000000000000..46349e337de9 --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/hw-getAddress.ts @@ -0,0 +1,17 @@ +import Aptos from "@ledgerhq/hw-app-aptos"; +import type { Resolver } from "../../hw/getAddress/types"; +import type { AptosAddress } from "./types"; + +const resolver: Resolver = async (transport, { path, verify }): Promise => { + const aptos = new Aptos(transport); + + const r = await aptos.getAddress(path, verify || false); + + return { + address: r.address, + publicKey: r.publicKey.toString("hex"), + path, + }; +}; + +export default resolver; diff --git a/libs/ledger-live-common/src/families/aptos/logic.test.ts b/libs/ledger-live-common/src/families/aptos/logic.test.ts new file mode 100644 index 000000000000..9c9d29404c23 --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/logic.test.ts @@ -0,0 +1,807 @@ +import { + EntryFunctionPayloadResponse, + Event, + InputEntryFunctionData, + WriteSetChange, +} from "@aptos-labs/ts-sdk"; +import type { Operation, OperationType } from "@ledgerhq/types-live"; +import BigNumber from "bignumber.js"; +import { APTOS_ASSET_ID, APTOS_COIN_CHANGE, DIRECTION } from "./constants"; +import { + calculateAmount, + compareAddress, + getAptosAmounts, + getFunctionAddress, + isChangeOfAptos, + isTestnet, + processRecipients, + getMaxSendBalance, + normalizeTransactionOptions, + getBlankOperation, + txsToOps, +} from "./logic"; +import type { AptosTransaction, Transaction } from "./types"; + +jest.mock("@ledgerhq/cryptoassets", () => ({ + getCryptoCurrencyById: jest.fn(), +})); + +describe("Aptos logic ", () => { + describe("isTestnet", () => { + it("should return true for testnet currencies", () => { + expect(isTestnet("aptos_testnet")).toBe(true); + }); + + it("should return false for mainnet currencies", () => { + expect(isTestnet("aptos")).toBe(false); + }); + }); + + describe("getMaxSendBalance", () => { + it("should return the correct max send balance when amount is greater than total gas", () => { + const amount = new BigNumber(1000000); + const gas = new BigNumber(200); + const gasPrice = new BigNumber(100); + const result = getMaxSendBalance(amount, gas, gasPrice); + expect(result.isEqualTo(amount.minus(gas.multipliedBy(gasPrice)))).toBe(true); + }); + + it("should return zero when amount is less than total gas", () => { + const amount = new BigNumber(1000); + const gas = new BigNumber(200); + const gasPrice = new BigNumber(100); + const result = getMaxSendBalance(amount, gas, gasPrice); + expect(result.isEqualTo(new BigNumber(0))).toBe(true); + }); + + it("should return zero when amount is equal to total gas", () => { + const amount = new BigNumber(20000); + const gas = new BigNumber(200); + const gasPrice = new BigNumber(100); + const result = getMaxSendBalance(amount, gas, gasPrice); + expect(result.isEqualTo(new BigNumber(0))).toBe(true); + }); + + it("should handle zero amount", () => { + const amount = new BigNumber(0); + const gas = new BigNumber(200); + const gasPrice = new BigNumber(100); + const result = getMaxSendBalance(amount, gas, gasPrice); + expect(result.isEqualTo(new BigNumber(0))).toBe(true); + }); + + it("should handle zero gas and gas price", () => { + const amount = new BigNumber(1000000); + const gas = new BigNumber(0); + const gasPrice = new BigNumber(0); + const result = getMaxSendBalance(amount, gas, gasPrice); + expect(result.isEqualTo(amount)).toBe(true); + }); + }); + + describe("normalizeTransactionOptions", () => { + it("should normalize transaction options", () => { + const options: Transaction["options"] = { + maxGasAmount: "1000", + gasUnitPrice: "10", + sequenceNumber: "1", + expirationTimestampSecs: "1000000", + }; + + const result = normalizeTransactionOptions(options); + expect(result).toEqual(options); + }); + + it("should return undefined for empty values", () => { + const options: Transaction["options"] = { + maxGasAmount: "", + gasUnitPrice: "", + sequenceNumber: undefined, + expirationTimestampSecs: "1000000", + }; + + const result = normalizeTransactionOptions(options); + expect(result).toEqual({ + maxGasAmount: undefined, + gasUnitPrice: undefined, + sequenceNumber: undefined, + expirationTimestampSecs: "1000000", + }); + }); + }); + + describe("getBlankOperation", () => { + it("should return a blank operation", () => { + const tx: AptosTransaction = { + hash: "0x123", + block: { hash: "0xabc", height: 1 }, + timestamp: "1000000", + sequence_number: "1", + } as unknown as AptosTransaction; + + const id = "test-id"; + const result = getBlankOperation(tx, id); + + expect(result).toEqual({ + id: "", + hash: "0x123", + type: "", + value: new BigNumber(0), + fee: new BigNumber(0), + blockHash: "0xabc", + blockHeight: 1, + senders: [], + recipients: [], + accountId: id, + date: new Date(1000), + extra: { version: undefined }, + transactionSequenceNumber: 1, + hasFailed: false, + }); + }); + }); +}); + +describe("Aptos sync logic ", () => { + describe("compareAddress", () => { + it("should return true for identical addresses", () => { + const addressA = "0x1234567890abcdef"; + const addressB = "0x1234567890abcdef"; + expect(compareAddress(addressA, addressB)).toBe(true); + }); + + it("should return true for addresses with different cases", () => { + const addressA = "0x1234567890abcdef"; + const addressB = "0x1234567890ABCDEF"; + expect(compareAddress(addressA, addressB)).toBe(true); + }); + + it("should return true for addresses with different hex formats", () => { + const addressA = "0x1234567890abcdef"; + const addressB = "1234567890abcdef"; + expect(compareAddress(addressA, addressB)).toBe(true); + }); + + it("should return false for different addresses", () => { + const addressA = "0x1234567890abcdef"; + const addressB = "0xfedcba0987654321"; + expect(compareAddress(addressA, addressB)).toBe(false); + }); + }); + + describe("getFunctionAddress", () => { + it("should return the function address when payload contains a function", () => { + const payload: InputEntryFunctionData = { + function: "0x1::coin::transfer", + typeArguments: [], + functionArguments: [], + }; + + const result = getFunctionAddress(payload); + expect(result).toBe("0x1"); + }); + + it("should return undefined when payload does not contain a function", () => { + const payload = { + function: "::::", + typeArguments: [], + functionArguments: [], + } as InputEntryFunctionData; + + const result = getFunctionAddress(payload); + expect(result).toBeUndefined(); + }); + + it("should return undefined when payload is empty", () => { + const payload = {} as InputEntryFunctionData; + + const result = getFunctionAddress(payload); + expect(result).toBeUndefined(); + }); + }); + + describe("processRecipients", () => { + let op: Operation; + + beforeEach(() => { + op = { + id: "", + hash: "", + type: "" as OperationType, + value: new BigNumber(0), + fee: new BigNumber(0), + blockHash: "", + blockHeight: 0, + senders: [], + recipients: [], + accountId: "", + date: new Date(), + extra: {}, + transactionSequenceNumber: 0, + hasFailed: false, + }; + }); + + it("should add recipient for transfer-like functions from LL account", () => { + const payload: InputEntryFunctionData = { + function: "0x1::coin::transfer", + typeArguments: [], + functionArguments: ["0x13", 1], // from: &signer, to: address, amount: u64 + }; + + processRecipients(payload, "0x13", op, "0x1"); + expect(op.recipients).toContain("0x13"); + }); + + it("should add recipient for transfer-like functions from external account", () => { + const payload: InputEntryFunctionData = { + function: "0x1::coin::transfer", + typeArguments: [], + functionArguments: ["0x12", 1], // from: &signer, to: address, amount: u64 + }; + + processRecipients(payload, "0x13", op, "0x1"); + expect(op.recipients).toContain("0x12"); + }); + + it("should add recipients for batch transfer functions", () => { + const payload: InputEntryFunctionData = { + function: "0x1::aptos_account::batch_transfer_coins", + typeArguments: [APTOS_ASSET_ID], + functionArguments: [ + ["0x12", "0x13"], + [1, 2], + ], + }; + + op.senders.push("0x11"); + processRecipients(payload, "0x12", op, "0x1"); + expect(op.recipients).toContain("0x12"); + }); + + it("should add function address as recipient for other smart contracts", () => { + const payload: InputEntryFunctionData = { + function: "0x2::other::contract", + typeArguments: [], + functionArguments: [["0x12"], [1]], + }; + + processRecipients(payload, "0x11", op, "0x2"); + expect(op.recipients).toContain("0x2"); + }); + }); + + describe("isChangeOfAptos", () => { + it("should return true for a valid change of Aptos", () => { + const change = { + type: "write_resource", + data: { + type: APTOS_COIN_CHANGE, + data: { + withdraw_events: { + guid: { + id: { + addr: "0x11", + creation_num: "2", + }, + }, + }, + }, + }, + } as unknown as WriteSetChange; + + const event = { + guid: { + account_address: "0x11", + creation_number: "2", + }, + type: "0x1::coin::WithdrawEvent", + } as Event; + + const result = isChangeOfAptos(change, event, "withdraw_events"); + expect(result).toBe(true); + }); + + it("should return false for an invalid change of Aptos", () => { + const change = { + type: "write_resource", + data: { + type: APTOS_COIN_CHANGE, + data: { + withdraw_events: { + guid: { + id: { + addr: "0x12", + creation_num: "2", + }, + }, + }, + }, + }, + } as unknown as WriteSetChange; + + const event = { + guid: { + account_address: "0x11", + creation_number: "1", + }, + type: "0x1::coin::WithdrawEvent", + } as Event; + + const result = isChangeOfAptos(change, event, "withdraw_events"); + expect(result).toBe(false); + }); + + it("should return false for a change with a different WriteSet type", () => { + const change = { + type: "write_module", + data: {}, + } as unknown as WriteSetChange; + + const event = { + guid: { + account_address: "0x1", + creation_number: "1", + }, + type: "0x1::coin::WithdrawEvent", + } as Event; + + const result = isChangeOfAptos(change, event, "withdraw_events"); + expect(result).toBe(false); + }); + + it("should return false for a change with a different WriteSet Change type", () => { + const change = { + type: "write_resource", + data: { + type: "0x1::coin::CoinStore<0x1::aptos_coin::ANY_OTHER_COIN>", + data: { + withdraw_events: { + guid: { + id: { + addr: "0x11", + creation_num: "2", + }, + }, + }, + }, + }, + } as unknown as WriteSetChange; + + const event = { + guid: { + account_address: "0x11", + creation_number: "2", + }, + type: "0x1::coin::WithdrawEvent", + } as Event; + + const result = isChangeOfAptos(change, event, "withdraw_events"); + expect(result).toBe(false); + }); + }); + + describe("getAptosAmounts", () => { + it("should calculate the correct amounts for withdraw and deposit events", () => { + const tx = { + events: [ + { + type: "0x1::coin::WithdrawEvent", + guid: { + account_address: "0x11", + creation_number: "1", + }, + data: { + amount: "100", + }, + }, + { + type: "0x1::coin::DepositEvent", + guid: { + account_address: "0x11", + creation_number: "2", + }, + data: { + amount: "50", + }, + }, + ], + changes: [ + { + type: "write_resource", + data: { + type: APTOS_COIN_CHANGE, + data: { + withdraw_events: { + guid: { + id: { + addr: "0x11", + creation_num: "1", + }, + }, + }, + deposit_events: { + guid: { + id: { + addr: "0x11", + creation_num: "2", + }, + }, + }, + }, + }, + }, + ], + } as unknown as AptosTransaction; + + const address = "0x11"; + const result = getAptosAmounts(tx, address); + + expect(result.amount_in).toEqual(new BigNumber(50)); + expect(result.amount_out).toEqual(new BigNumber(100)); + }); + + it("should return zero amounts if no matching events are found", () => { + const tx = { + events: [ + { + type: "0x1::coin::WithdrawEvent", + guid: { + account_address: "0x11", + creation_number: "1", + }, + data: { + amount: "100", + }, + }, + { + type: "0x1::coin::DepositEvent", + guid: { + account_address: "0x11", + creation_number: "2", + }, + data: { + amount: "50", + }, + }, + ], + changes: [ + { + type: "write_resource", + data: { + type: APTOS_COIN_CHANGE, + data: { + withdraw_events: { + guid: { + id: { + addr: "0x12", // should fail by address check + creation_num: "1", + }, + }, + }, + deposit_events: { + guid: { + id: { + addr: "0x11", + creation_num: "3", // should fail by number check + }, + }, + }, + }, + }, + }, + ], + } as unknown as AptosTransaction; + + const address = "0x11"; + const result = getAptosAmounts(tx, address); + + expect(result.amount_in).toEqual(new BigNumber(0)); + expect(result.amount_out).toEqual(new BigNumber(0)); + }); + + it("should handle transactions with other events", () => { + const tx = { + events: [ + { + type: "0x1::coin::OtherEvent", + guid: { + account_address: "0x11", + creation_number: "1", + }, + data: { + amount: "100", + }, + }, + ], + } as unknown as AptosTransaction; + + const address = "0x1"; + const result = getAptosAmounts(tx, address); + + expect(result.amount_in).toEqual(new BigNumber(0)); + expect(result.amount_out).toEqual(new BigNumber(0)); + }); + }); + + describe("calculateAmount", () => { + it("should calculate the correct amount when the address is the sender", () => { + const address = "0x11"; + const sender = "0x11"; + const fee = new BigNumber(10); // account pays fees + const amount_in = new BigNumber(50); + const amount_out = new BigNumber(100); + + const result = calculateAmount(sender, address, fee, amount_in, amount_out); + + // LL negates the amount for SEND transactions during output + expect(result).toEqual(new BigNumber(60)); // -(50 - 100 - 10) + }); + + it("should calculate the correct amount when the address is not the sender", () => { + const address = "0x11"; + const sender = "0x12"; + const fee = new BigNumber(10); // sender pays fees + const amount_in = new BigNumber(100); + const amount_out = new BigNumber(50); + + const result = calculateAmount(sender, address, fee, amount_in, amount_out); + + expect(result).toEqual(new BigNumber(50)); // 100 - 50 + }); + + it("should handle transactions with zero amounts", () => { + const address = "0x11"; + const sender = "0x11"; + const fee = new BigNumber(10); + const amount_in = new BigNumber(0); + const amount_out = new BigNumber(0); + + const result = calculateAmount(sender, address, fee, amount_in, amount_out); + + // LL negates the amount for SEND transactions during output + expect(result).toEqual(new BigNumber(10)); // -(0 - 0 - 10) + }); + + it("should get negative numbers (for send tx with deposit to account)", () => { + const address = "0x11"; + const sender = "0x11"; + const fee = new BigNumber(10); + const amount_in = new BigNumber(100); + const amount_out = new BigNumber(0); + + const result = calculateAmount(sender, address, fee, amount_in, amount_out); + + // LL negates the amount for SEND transactions during output + expect(result).toEqual(new BigNumber(90).negated()); // 100 - 10 + }); + }); + + describe("txsToOps", () => { + it("should convert transactions to operations correctly", () => { + const address = "0x11"; + const id = "test-id"; + const txs: AptosTransaction[] = [ + { + hash: "0x123", + sender: "0x11", + gas_used: "200", + gas_unit_price: "100", + success: true, + payload: { + type: "entry_function_payload", + function: "0x1::coin::transfer", + type_arguments: [], + arguments: ["0x12", 100], + } as EntryFunctionPayloadResponse, + events: [ + { + type: "0x1::coin::WithdrawEvent", + guid: { + account_address: "0x11", + creation_number: "1", + }, + data: { + amount: "100", + }, + }, + { + type: "0x1::coin::DepositEvent", + guid: { + account_address: "0x12", + creation_number: "2", + }, + data: { + amount: "100", + }, + }, + ], + changes: [ + { + type: "write_resource", + data: { + type: APTOS_COIN_CHANGE, + data: { + withdraw_events: { + guid: { + id: { + addr: "0x11", + creation_num: "1", + }, + }, + }, + deposit_events: { + guid: { + id: { + addr: "0x12", + creation_num: "2", + }, + }, + }, + }, + }, + }, + ], + block: { hash: "0xabc", height: 1 }, + timestamp: "1000000", + sequence_number: "1", + } as unknown as AptosTransaction, + ]; + + const result = txsToOps({ address }, id, txs); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + id: expect.any(String), + hash: "0x123", + type: DIRECTION.OUT, + value: new BigNumber(20100), + fee: new BigNumber(20000), + blockHash: "0xabc", + blockHeight: 1, + senders: ["0x11"], + recipients: ["0x12"], + accountId: id, + date: new Date(1000), + extra: { version: undefined }, + transactionSequenceNumber: 1, + hasFailed: false, + }); + }); + + it("should skip transactions without functions in payload", () => { + const address = "0x11"; + const id = "test-id"; + const txs: AptosTransaction[] = [ + { + hash: "0x123", + sender: "0x11", + gas_used: "200", + gas_unit_price: "100", + success: true, + payload: {} as EntryFunctionPayloadResponse, + // payload: { + // type: "entry_function_payload", + // function: "0x1::coin::transfer", + // type_arguments: [], + // arguments: ["0x12", 100], + // } as EntryFunctionPayloadResponse, + events: [], + changes: [], + block: { hash: "0xabc", height: 1 }, + timestamp: "1000000", + sequence_number: "1", + } as unknown as AptosTransaction, + ]; + + const result = txsToOps({ address }, id, txs); + + expect(result).toHaveLength(0); + }); + + it("should skip transactions that result in no Aptos change", () => { + const address = "0x11"; + const id = "test-id"; + const txs: AptosTransaction[] = [ + { + hash: "0x123", + sender: "0x12", + gas_used: "200", + gas_unit_price: "100", + success: true, + payload: { + type: "entry_function_payload", + function: "0x1::coin::transfer", + type_arguments: [], + arguments: ["0x11", 100], + } as EntryFunctionPayloadResponse, + events: [], + changes: [], + block: { hash: "0xabc", height: 1 }, + timestamp: "1000000", + sequence_number: "1", + } as unknown as AptosTransaction, + ]; + + const result = txsToOps({ address }, id, txs); + + expect(result).toHaveLength(0); + }); + + it("should handle failed transactions", () => { + const address = "0x11"; + const id = "test-id"; + const txs: AptosTransaction[] = [ + { + hash: "0x123", + sender: "0x11", + gas_used: "200", + gas_unit_price: "100", + success: false, + payload: { + type: "entry_function_payload", + function: "0x1::coin::transfer", + type_arguments: [], + arguments: ["0x12", 100], + } as EntryFunctionPayloadResponse, + events: [ + { + type: "0x1::coin::WithdrawEvent", + guid: { + account_address: "0x11", + creation_number: "1", + }, + data: { + amount: "100", + }, + }, + { + type: "0x1::coin::DepositEvent", + guid: { + account_address: "0x12", + creation_number: "2", + }, + data: { + amount: "100", + }, + }, + ], + changes: [ + { + type: "write_resource", + data: { + type: APTOS_COIN_CHANGE, + data: { + withdraw_events: { + guid: { + id: { + addr: "0x11", + creation_num: "1", + }, + }, + }, + deposit_events: { + guid: { + id: { + addr: "0x12", + creation_num: "2", + }, + }, + }, + }, + }, + }, + ], + block: { hash: "0xabc", height: 1 }, + timestamp: "1000000", + sequence_number: "1", + } as unknown as AptosTransaction, + ]; + + const result = txsToOps({ address }, id, txs); + + expect(result).toHaveLength(1); + expect(result[0].hasFailed).toBe(true); + }); + }); +}); diff --git a/libs/ledger-live-common/src/families/aptos/logic.ts b/libs/ledger-live-common/src/families/aptos/logic.ts new file mode 100644 index 000000000000..33a7a4db00a5 --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/logic.ts @@ -0,0 +1,241 @@ +import { + EntryFunctionPayloadResponse, + Event, + InputEntryFunctionData, + WriteSetChange, + WriteSetChangeWriteResource, +} from "@aptos-labs/ts-sdk"; +import { getCryptoCurrencyById } from "@ledgerhq/cryptoassets/currencies"; +import type { Operation, OperationType } from "@ledgerhq/types-live"; +import BigNumber from "bignumber.js"; +import { encodeOperationId } from "../../operation"; +import { + APTOS_COIN_CHANGE, + BATCH_TRANSFER_TYPES, + DELEGATION_POOL_TYPES, + DIRECTION, + TRANSFER_TYPES, +} from "./constants"; +import type { AptosTransaction, TransactionOptions } from "./types"; + +export const DEFAULT_GAS = 200; +export const DEFAULT_GAS_PRICE = 100; +export const ESTIMATE_GAS_MUL = 1.0; // define buffer for gas estimation change here, if needed + +const CLEAN_HEX_REGEXP = /^0x0*|^0+/; + +export function isTestnet(currencyId: string): boolean { + return getCryptoCurrencyById(currencyId).isTestnetFor ? true : false; +} + +export const getMaxSendBalance = ( + amount: BigNumber, + gas: BigNumber, + gasPrice: BigNumber, +): BigNumber => { + const totalGas = gas.multipliedBy(gasPrice); + + return amount.gt(totalGas) ? amount.minus(totalGas) : new BigNumber(0); +}; + +export function normalizeTransactionOptions(options: TransactionOptions): TransactionOptions { + const check = (v: any) => ((v ?? "").toString().trim() ? v : undefined); + return { + maxGasAmount: check(options.maxGasAmount), + gasUnitPrice: check(options.gasUnitPrice), + sequenceNumber: check(options.sequenceNumber), + expirationTimestampSecs: check(options.expirationTimestampSecs), + }; +} + +export const getBlankOperation = ( + tx: AptosTransaction, + id: string, +): Operation> => ({ + id: "", + hash: tx.hash, + type: "" as OperationType, + value: new BigNumber(0), + fee: new BigNumber(0), + blockHash: tx.block?.hash, + blockHeight: tx.block?.height, + senders: [] as string[], + recipients: [] as string[], + accountId: id, + date: new Date(parseInt(tx.timestamp) / 1000), + extra: { version: tx?.version }, + transactionSequenceNumber: parseInt(tx.sequence_number), + hasFailed: false, +}); + +const convertFunctionPayloadResponseToInputEntryFunctionData = ( + payload: EntryFunctionPayloadResponse, +): InputEntryFunctionData => ({ + function: payload.function, + typeArguments: payload.type_arguments, + functionArguments: payload.arguments, +}); + +export const txsToOps = ( + info: { address: string }, + id: string, + txs: (AptosTransaction | null)[], +): Operation[] => { + const { address } = info; + const ops: Operation[] = []; + + txs.forEach(tx => { + if (tx !== null) { + const op: Operation = getBlankOperation(tx, id); + op.fee = new BigNumber(tx.gas_used).multipliedBy(new BigNumber(tx.gas_unit_price)); + + const payload = convertFunctionPayloadResponseToInputEntryFunctionData( + tx.payload as EntryFunctionPayloadResponse, + ); + + const function_address = getFunctionAddress(payload); + + if (!function_address) { + return; // skip transaction without functions in payload + } + + const { amount_in, amount_out } = getAptosAmounts(tx, address); + op.value = calculateAmount(tx.sender, address, op.fee, amount_in, amount_out); + op.type = compareAddress(tx.sender, address) ? DIRECTION.OUT : DIRECTION.IN; + op.senders.push(tx.sender); + + processRecipients(payload, address, op, function_address); + + if (op.value.isZero()) { + // skip transaction that result no Aptos change + op.type = DIRECTION.UNKNOWN; + } + + op.hasFailed = !tx.success; + op.id = encodeOperationId(id, tx.hash, op.type); + if (op.type !== DIRECTION.UNKNOWN) ops.push(op); + } + }); + + return ops; +}; + +export function compareAddress(addressA: string, addressB: string) { + return ( + addressA.replace(CLEAN_HEX_REGEXP, "").toLowerCase() === + addressB.replace(CLEAN_HEX_REGEXP, "").toLowerCase() + ); +} + +export function getFunctionAddress(payload: InputEntryFunctionData): string | undefined { + if (payload.function) { + const parts = payload.function.split("::"); + return parts.length === 3 && parts[0].length ? parts[0] : undefined; + } + return undefined; +} + +export function processRecipients( + payload: InputEntryFunctionData, + address: string, + op: Operation, + function_address: string, +): void { + // get recipients buy 3 groups + if ( + (TRANSFER_TYPES.includes(payload.function) || + DELEGATION_POOL_TYPES.includes(payload.function)) && + payload.functionArguments && + payload.functionArguments.length > 0 && + typeof payload.functionArguments[0] === "string" + ) { + // 1. Transfer like functions (includes some delegation pool functions) + op.recipients.push(payload.functionArguments[0].toString()); + } else if ( + BATCH_TRANSFER_TYPES.includes(payload.function) && + payload.functionArguments && + payload.functionArguments.length > 0 && + Array.isArray(payload.functionArguments[0]) + ) { + // 2. Batch function, to validate we are in the recipients list + if (!compareAddress(op.senders[0], address)) { + for (const recipient of payload.functionArguments[0]) { + if (recipient && compareAddress(recipient.toString(), address)) { + op.recipients.push(recipient.toString()); + } + } + } + } else { + // 3. other smart contracts, in this case smart contract will be treated as a recipient + op.recipients.push(function_address); + } +} + +function checkWriteSets(tx: AptosTransaction, event: Event, event_name: string): boolean { + return tx.changes.some(change => { + return isChangeOfAptos(change, event, event_name); + }); +} + +export function isChangeOfAptos(change: WriteSetChange, event: Event, event_name: string): boolean { + // to validate the event is related to Aptos Tokens we need to find change of type "write_resource" + // with the same guid as event + if (change.type == "write_resource") { + const change_data = (change as WriteSetChangeWriteResource).data; + if (change_data.type === APTOS_COIN_CHANGE) { + const change_event_data = change_data.data[event_name]; + if ( + change_event_data && + change_event_data.guid.id.addr === event.guid.account_address && + change_event_data.guid.id.creation_num === event.guid.creation_number + ) { + return true; + } + } + } + return false; +} + +export function getAptosAmounts( + tx: AptosTransaction, + address: string, +): { amount_in: BigNumber; amount_out: BigNumber } { + let amount_in = new BigNumber(0); + let amount_out = new BigNumber(0); + // collect all events related to the address and calculate the overall amounts + tx.events.forEach(event => { + if (compareAddress(event.guid.account_address, address)) { + switch (event.type) { + case "0x1::coin::WithdrawEvent": + if (checkWriteSets(tx, event, "withdraw_events")) { + amount_out = amount_out.plus(event.data.amount); + } + break; + case "0x1::coin::DepositEvent": + if (checkWriteSets(tx, event, "deposit_events")) { + amount_in = amount_in.plus(event.data.amount); + } + break; + } + } + }); + return { amount_in, amount_out }; +} + +export function calculateAmount( + sender: string, + address: string, + fee: BigNumber, + amount_in: BigNumber, + amount_out: BigNumber, +): BigNumber { + const is_sender: boolean = compareAddress(sender, address); + // Include fees if our address is the sender + if (is_sender) { + amount_out = amount_out.plus(fee); + } + // LL negates the amount for SEND transactions + // to show positive amount on the send transaction (ex: in "cancel" tx, when amount will be returned to our account) + // we need to make it negative + return is_sender ? amount_out.minus(amount_in) : amount_in.minus(amount_out); +} diff --git a/libs/ledger-live-common/src/families/aptos/prepareTransaction.test.ts b/libs/ledger-live-common/src/families/aptos/prepareTransaction.test.ts new file mode 100644 index 000000000000..3d17e1c115a5 --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/prepareTransaction.test.ts @@ -0,0 +1,135 @@ +import prepareTransaction from "./prepareTransaction"; +import { AptosAPI } from "./api"; +import { getEstimatedGas } from "./getFeesForTransaction"; +import { getMaxSendBalance } from "./logic"; +import BigNumber from "bignumber.js"; +import type { Account } from "@ledgerhq/types-live"; +import type { Transaction } from "./types"; + +jest.mock("./api"); +jest.mock("./getFeesForTransaction"); +jest.mock("./logic"); + +describe("Aptos prepareTransaction", () => { + describe("prepareTransaction", () => { + let account: Account; + let transaction: Transaction; + + beforeEach(() => { + account = { + id: "test-account-id", + name: "Test Account", + currency: { + id: "aptos", + name: "Aptos", + ticker: "APT", + units: [{ name: "Aptos", code: "APT", magnitude: 6 }], + }, + spendableBalance: new BigNumber(1000), + balance: new BigNumber(1000), + blockHeight: 0, + operations: [], + pendingOperations: [], + unit: { code: "APT", name: "Aptos", magnitude: 6 }, + lastSyncDate: new Date(), + subAccounts: [], + } as unknown as Account; + + transaction = { + amount: new BigNumber(0), + recipient: "", + useAllAmount: false, + fees: new BigNumber(0), + firstEmulation: true, + options: {}, + } as Transaction; + }); + + it("should return the transaction if recipient is not set", async () => { + const result = await prepareTransaction(account, transaction); + expect(result).toEqual(transaction); + }); + + it("should return the transaction with zero fees if amount is zero and useAllAmount is false", async () => { + transaction.recipient = "test-recipient"; + const result = await prepareTransaction(account, transaction); + expect(result.fees?.isZero()).toBe(true); + }); + + it("should set the amount to max sendable balance if useAllAmount is true", async () => { + transaction.recipient = "test-recipient"; + transaction.useAllAmount = true; + (getMaxSendBalance as jest.Mock).mockReturnValue(new BigNumber(900)); + (getEstimatedGas as jest.Mock).mockResolvedValue({ + fees: new BigNumber(2000), + estimate: { maxGasAmount: new BigNumber(200), gasUnitPrice: new BigNumber(10) }, + errors: {}, + }); + + const result = await prepareTransaction(account, transaction); + expect(result.amount.isEqualTo(new BigNumber(900))).toBe(true); + expect(result.fees?.isEqualTo(new BigNumber(2000))).toBe(true); + expect(new BigNumber(result.estimate.maxGasAmount).isEqualTo(new BigNumber(200))).toBe(true); + expect(result.errors).toEqual({}); + }); + + it("should call getEstimatedGas and set the transaction fees, estimate, and errors", async () => { + transaction.recipient = "test-recipient"; + transaction.amount = new BigNumber(100); + (getEstimatedGas as jest.Mock).mockResolvedValue({ + fees: new BigNumber(10), + estimate: { maxGasAmount: new BigNumber(200) }, + errors: {}, + }); + + const result = await prepareTransaction(account, transaction); + expect(getEstimatedGas).toHaveBeenCalledWith(account, transaction, expect.any(AptosAPI)); + expect(result.fees?.isEqualTo(new BigNumber(10))).toBe(true); + expect(new BigNumber(result.estimate.maxGasAmount).isEqualTo(new BigNumber(200))).toBe(true); + expect(result.errors).toEqual({}); + }); + + it("should set firstEmulation to false after the first call", async () => { + transaction.recipient = "test-recipient"; + transaction.amount = new BigNumber(100); + (getEstimatedGas as jest.Mock).mockResolvedValue({ + fees: new BigNumber(10), + estimate: { maxGasAmount: new BigNumber(200) }, + errors: {}, + }); + + const result = await prepareTransaction(account, transaction); + expect(result.firstEmulation).toBe(false); + }); + + it("should return the transaction with updated fees and estimate if recipient is set and amount is not zero", async () => { + transaction.recipient = "test-recipient"; + transaction.amount = new BigNumber(100); + (getEstimatedGas as jest.Mock).mockResolvedValue({ + fees: new BigNumber(2000), + estimate: { maxGasAmount: new BigNumber(200), gasUnitPrice: new BigNumber(10) }, + errors: {}, + }); + + const result = await prepareTransaction(account, transaction); + expect(result.fees?.isEqualTo(new BigNumber(2000))).toBe(true); + expect(new BigNumber(result.estimate.maxGasAmount).isEqualTo(new BigNumber(200))).toBe(true); + expect(result.errors).toEqual({}); + }); + + it("should set maxGasAmount in options", async () => { + transaction.recipient = "test-recipient"; + transaction.amount = new BigNumber(100); + transaction.firstEmulation = true; + (getEstimatedGas as jest.Mock).mockResolvedValue({ + fees: new BigNumber(2000), + estimate: { maxGasAmount: new BigNumber(200), gasUnitPrice: new BigNumber(10) }, + errors: {}, + }); + + const result = await prepareTransaction(account, transaction); + expect(new BigNumber(result.estimate.maxGasAmount).isEqualTo(new BigNumber(200))).toBe(true); + expect(result.firstEmulation).toBe(false); + }); + }); +}); diff --git a/libs/ledger-live-common/src/families/aptos/prepareTransaction.ts b/libs/ledger-live-common/src/families/aptos/prepareTransaction.ts new file mode 100644 index 000000000000..ec89d71db979 --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/prepareTransaction.ts @@ -0,0 +1,61 @@ +import type { Account } from "@ledgerhq/types-live"; +import BigNumber from "bignumber.js"; + +import { AptosAPI } from "./api"; +import { getEstimatedGas } from "./getFeesForTransaction"; +import type { Transaction } from "./types"; +import { DEFAULT_GAS, DEFAULT_GAS_PRICE, getMaxSendBalance } from "./logic"; + +const prepareTransaction = async ( + account: Account, + transaction: Transaction, +): Promise => { + if (!transaction.recipient) { + return transaction; + } + + // if transaction.useAllAmount is true, then we expect transaction.amount to be 0 + // so to check that actual amount is zero or not, we also need to check if useAllAmount is false + if (transaction.amount.isZero() && !transaction.useAllAmount) { + return { + ...transaction, + fees: BigNumber(0), + }; + } + + const aptosClient = new AptosAPI(account.currency.id); + + if (transaction.useAllAmount) { + // we will use this amount in simulation, to estimate gas + transaction.amount = getMaxSendBalance( + account.spendableBalance, + new BigNumber(DEFAULT_GAS), + new BigNumber(DEFAULT_GAS_PRICE), + ); + } + + const { fees, estimate, errors } = await getEstimatedGas(account, transaction, aptosClient); + + const amount = transaction.useAllAmount + ? getMaxSendBalance( + account.spendableBalance, + BigNumber(estimate.maxGasAmount), + BigNumber(estimate.gasUnitPrice), + ) + : transaction.amount; + transaction.amount = amount; + + transaction.options = { + ...transaction.options, + maxGasAmount: estimate.maxGasAmount, + }; + + transaction.fees = fees; + transaction.estimate = estimate; + transaction.errors = errors; + transaction.firstEmulation = false; + + return transaction; +}; + +export default prepareTransaction; diff --git a/libs/ledger-live-common/src/families/aptos/signOperation.test.ts b/libs/ledger-live-common/src/families/aptos/signOperation.test.ts new file mode 100644 index 000000000000..e7480585bedc --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/signOperation.test.ts @@ -0,0 +1,186 @@ +import { Observable } from "rxjs"; +import { createFixtureAccount } from "../../mock/fixtures/cryptoCurrencies"; +import createTransaction from "./createTransaction"; +import signOperation from "./signOperation"; +import BigNumber from "bignumber.js"; + +jest.mock("./api", () => { + return { + AptosAPI: function () { + return { + generateTransaction: jest.fn(() => "tx"), + }; + }, + }; +}); + +let signTransaction; + +jest.mock("./LedgerAccount", () => { + return function () { + return { + init: jest.fn(), + signTransaction, + }; + }; +}); + +jest.mock("../../hw/deviceAccess", () => { + return { + withDevice: jest.fn(() => observable => { + return observable(new Observable()); + }), + }; +}); + +jest.mock("../../operation", () => { + return { + encodeOperationId: jest.fn(() => "js:2:aptos:0x000"), + }; +}); + +jest.mock("./buildTransaction", () => { + return function () { + return { + sequence_number: "789", + }; + }; +}); + +describe("signOperation Test", () => { + beforeEach(() => { + signTransaction = jest.fn(() => "tx"); + }); + + it("should thrown an error", async () => { + signTransaction = () => { + throw new Error("observable-catch-error"); + }; + + const account = createFixtureAccount(); + const transaction = createTransaction(); + + account.id = "js:2:aptos:0x000:"; + transaction.mode = "send"; + + const observable = await signOperation({ + account, + deviceId: "1", + transaction, + }); + + observable.subscribe({ + error: err => { + expect(err.message).toBe("observable-catch-error"); + }, + }); + }); + + it("should return 3 operations", async () => { + const date = new Date("2020-01-01"); + jest.useFakeTimers().setSystemTime(date); + + const account = createFixtureAccount(); + const transaction = createTransaction(); + + account.id = "js:2:aptos:0x000:"; + transaction.mode = "send"; + + const observable = await signOperation({ + account, + deviceId: "1", + transaction, + }); + + expect(observable).toBeInstanceOf(Observable); + + const expectedValues = [ + { type: "device-signature-requested" }, + { type: "device-signature-granted" }, + { + type: "signed", + signedOperation: { + operation: { + id: "js:2:aptos:0x000", + hash: "", + type: "OUT", + value: new BigNumber(0), + fee: new BigNumber(0), + extra: {}, + blockHash: null, + blockHeight: null, + senders: [account.freshAddress], + recipients: [transaction.recipient], + accountId: "js:2:aptos:0x000:", + date, + transactionSequenceNumber: 789, + }, + signature: "7478", + }, + }, + ]; + + let i = 0; + + observable.forEach(signOperationEvent => { + expect(signOperationEvent).toEqual(expectedValues[i]); + i++; + }); + }); + + it("should return 3 operations with all amount", async () => { + const date = new Date("2020-01-01"); + jest.useFakeTimers().setSystemTime(date); + + const account = createFixtureAccount(); + const transaction = createTransaction(); + + account.balance = new BigNumber(40); + transaction.fees = new BigNumber(30); + transaction.useAllAmount = true; + + account.id = "js:2:aptos:0x000:"; + transaction.mode = "send"; + + const observable = await signOperation({ + account, + deviceId: "1", + transaction, + }); + + expect(observable).toBeInstanceOf(Observable); + + const expectedValues = [ + { type: "device-signature-requested" }, + { type: "device-signature-granted" }, + { + type: "signed", + signedOperation: { + operation: { + id: "js:2:aptos:0x000", + hash: "", + type: "OUT", + value: new BigNumber(10), + fee: transaction.fees, + extra: {}, + blockHash: null, + blockHeight: null, + senders: [account.freshAddress], + recipients: [transaction.recipient], + accountId: "js:2:aptos:0x000:", + date, + transactionSequenceNumber: 789, + }, + signature: "7478", + }, + }, + ]; + + let i = 0; + + observable.forEach(signOperationEvent => { + expect(signOperationEvent).toEqual(expectedValues[i]); + i++; + }); + }); +}); diff --git a/libs/ledger-live-common/src/families/aptos/signOperation.ts b/libs/ledger-live-common/src/families/aptos/signOperation.ts new file mode 100644 index 000000000000..c0d62db60ead --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/signOperation.ts @@ -0,0 +1,91 @@ +import type { Transaction } from "./types"; +import { Observable } from "rxjs"; +import { withDevice } from "../../hw/deviceAccess"; +import { encodeOperationId } from "../../operation"; +import buildTransaction from "./buildTransaction"; +import BigNumber from "bignumber.js"; + +import type { + Account, + Operation, + OperationType, + SignOperationFnSignature, +} from "@ledgerhq/types-live"; +import { AptosAPI } from "./api"; +import LedgerAccount from "./LedgerAccount"; + +const signOperation: SignOperationFnSignature = ({ + account, + deviceId, + transaction, +}: { + account: Account; + deviceId: any; + transaction: Transaction; +}) => + withDevice(deviceId)( + transport => + new Observable(o => { + async function main() { + const aptosClient = new AptosAPI(account.currency.id); + + o.next({ type: "device-signature-requested" }); + + const ledgerAccount = new LedgerAccount(account.freshAddressPath, account.xpub as string); + await ledgerAccount.init(transport); + + const rawTx = await buildTransaction(account, transaction, aptosClient); + const txBytes = await ledgerAccount.signTransaction(rawTx); + const signed = Buffer.from(txBytes).toString("hex"); + + o.next({ type: "device-signature-granted" }); + + const hash = ""; + const accountId = account.id; + const fee = transaction.fees || new BigNumber(0); + const extra = {}; + const type: OperationType = "OUT"; + const senders: string[] = []; + const recipients: string[] = []; + + if (transaction.mode === "send") { + senders.push(account.freshAddress); + recipients.push(transaction.recipient); + } + + // build optimistic operation + const operation: Operation = { + id: encodeOperationId(accountId, hash, type), + hash, + type, + value: transaction.useAllAmount + ? account.balance.minus(fee) + : transaction.amount.plus(fee), + fee, + extra, + blockHash: null, + blockHeight: null, + senders, + recipients, + accountId, + date: new Date(), + transactionSequenceNumber: Number(rawTx.sequence_number), + }; + + o.next({ + type: "signed", + signedOperation: { + operation, + signature: signed, + }, + }); + } + + main().then( + () => o.complete(), + e => o.error(e), + ); + }), + ); + +export default signOperation; diff --git a/libs/ledger-live-common/src/families/aptos/specs.ts b/libs/ledger-live-common/src/families/aptos/specs.ts new file mode 100644 index 000000000000..caa42eb8944b --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/specs.ts @@ -0,0 +1,65 @@ +import invariant from "invariant"; +import expect from "expect"; +import { DeviceModelId } from "@ledgerhq/devices"; +import { getCryptoCurrencyById } from "@ledgerhq/cryptoassets/currencies"; +import { parseCurrencyUnit } from "@ledgerhq/coin-framework/currencies/index"; +import { genericTestDestination, pickSiblings, botTest } from "../../bot/specs"; +import type { AppSpec } from "../../bot/types"; +import { acceptTransaction } from "./speculos-deviceActions"; +import type { Transaction } from "./types"; + +const currency = getCryptoCurrencyById("aptos"); +const minBalanceNewAccount = parseCurrencyUnit(currency.units[0], "0.0001"); +const maxAccountSiblings = 4; + +const aptos: AppSpec = { + name: "Aptos", + currency, + appQuery: { + model: DeviceModelId.nanoSP, + appName: "Aptos", + }, + genericDeviceAction: acceptTransaction, + testTimeout: 1000, + // testTimeout: 5 * 60 * 1000, + minViableAmount: minBalanceNewAccount, + transactionCheck: ({ maxSpendable }) => { + invariant(maxSpendable.gt(minBalanceNewAccount), "balance is too low"); + }, + mutations: [ + { + name: "Send ~50%", + maxRun: 1, + testDestination: genericTestDestination, + transaction: ({ account, siblings, bridge, maxSpendable }) => { + invariant(maxSpendable.gt(minBalanceNewAccount), "balance is too low"); + + const sibling = pickSiblings(siblings, maxAccountSiblings); + const recipient = sibling.freshAddress; + const amount = maxSpendable.div(2).integerValue(); + + const transaction = bridge.createTransaction(account); + const updates: Array> = [ + { + recipient, + }, + { amount }, + ]; + + return { + transaction, + updates, + }; + }, + test: ({ account, accountBeforeTransaction, operation }) => { + botTest("account balance moved with operation.value", () => + expect(account.balance.toString()).toBe( + accountBeforeTransaction.balance.minus(operation.value).toString(), + ), + ); + }, + }, + ], +}; + +export default { aptos }; diff --git a/libs/ledger-live-common/src/families/aptos/speculos-deviceActions.ts b/libs/ledger-live-common/src/families/aptos/speculos-deviceActions.ts new file mode 100644 index 000000000000..8fe4ff03b500 --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/speculos-deviceActions.ts @@ -0,0 +1,60 @@ +import type { DeviceAction } from "../../bot/types"; +import type { Transaction } from "./types"; +import { deviceActionFlow, formatDeviceAmount, SpeculosButton } from "../../bot/specs"; +import { State } from "@ledgerhq/coin-framework/bot/types"; + +const typeWording = { + send: "Send", + lock: "Lock", + unlock: "Unlock", + withdraw: "Withdraw", + vote: "Vote", + revoke: "Revoke", + activate: "Activate", + register: "Create Account", +}; + +export const acceptTransaction: DeviceAction> = deviceActionFlow({ + steps: [ + { + title: "Review", + button: SpeculosButton.RIGHT, + }, + { + title: "Amount", + button: SpeculosButton.RIGHT, + expectedValue: ({ account, status }) => + formatDeviceAmount(account.currency, status.amount, { + forceFloating: true, + }), + }, + { + title: "Address", + button: SpeculosButton.RIGHT, + expectedValue: ({ transaction }) => transaction.recipient, + }, + { + title: "Max Fees", + button: SpeculosButton.RIGHT, + }, + { + title: "No Gateway Fee", + button: SpeculosButton.RIGHT, + }, + { + title: "Validator", + button: SpeculosButton.RIGHT, + }, + { + title: "Type", + button: SpeculosButton.RIGHT, + expectedValue: ({ transaction }) => { + return typeWording[transaction.mode]; + }, + }, + { + title: "Accept", + button: SpeculosButton.BOTH, + }, + ], +}); diff --git a/libs/ledger-live-common/src/families/aptos/synchronisation.test.ts b/libs/ledger-live-common/src/families/aptos/synchronisation.test.ts new file mode 100644 index 000000000000..84e58dd2a99d --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/synchronisation.test.ts @@ -0,0 +1,397 @@ +import { AccountShapeInfo } from "@ledgerhq/coin-framework/bridge/jsHelpers"; +import { getCryptoCurrencyById } from "@ledgerhq/cryptoassets/index"; +import { Account, SyncConfig } from "@ledgerhq/types-live"; +import { firstValueFrom } from "rxjs"; +import { decodeAccountId } from "../../account"; +import { makeScanAccounts, makeSync, mergeOps } from "../../bridge/jsHelpers"; +import { AptosAPI } from "./api"; +import { txsToOps } from "./logic"; +import { getAccountShape } from "./synchronisation"; + +jest.mock("rxjs"); +let mockedFistValueFrom; + +jest.mock("../../account"); +let mockedDecodeAccountId; + +jest.mock("./api"); +let mockedAptosAPI; + +jest.mock("./logic"); +jest.mocked(txsToOps); + +jest.mock("../../bridge/jsHelpers"); +jest.mocked(makeScanAccounts); +jest.mocked(makeSync); + +describe("getAccountShape", () => { + beforeEach(() => { + mockedAptosAPI = jest.mocked(AptosAPI); + + mockedDecodeAccountId = jest.mocked(decodeAccountId).mockReturnValue({ + currencyId: "aptos", + derivationMode: "", + type: "js", + version: "1", + xpubOrAddress: "address", + }); + + mockedFistValueFrom = jest + .mocked(firstValueFrom) + .mockImplementation(async () => ({ publicKey: "publicKey" })); + + jest.mocked(mergeOps).mockReturnValue([]); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it("get xpub from device id", async () => { + const mockGetAccountInfo = jest.fn().mockImplementation(async () => ({ + balance: BigInt(0), + transactions: [], + blockHeight: 0, + })); + mockedAptosAPI.mockImplementation(() => ({ + getAccountInfo: mockGetAccountInfo, + })); + const mockGetAccountSpy = jest.spyOn({ getAccount: mockGetAccountInfo }, "getAccount"); + + const account = await getAccountShape( + { + id: "1", + address: "address", + currency: getCryptoCurrencyById("aptos"), + derivationMode: "", + index: 0, + xpub: "address", + derivationPath: "", + deviceId: "1", + initialAccount: { + id: "1:1:1:1:1", + // xpub: "address", + seedIdentifier: "1", + derivationMode: "", + index: 0, + freshAddress: "address", + freshAddressPath: "", + used: true, + balance: BigInt(10), + spendableBalance: BigInt(10), + creationDate: new Date(), + blockHeight: 0, + currency: getCryptoCurrencyById("aptos"), + operationsCount: 0, + operations: [], + pendingOperations: [], + lastSyncDate: new Date(), + balanceHistoryCache: {}, + swapHistory: [], + }, + } as unknown as AccountShapeInfo, + {} as SyncConfig, + ); + + expect(account.xpub).toEqual("7075626c69634b6579"); + expect(mockedFistValueFrom).toHaveBeenCalledTimes(1); + expect(mockedDecodeAccountId).toHaveBeenCalledTimes(0); + expect(mockedAptosAPI).toHaveBeenCalledTimes(1); + expect(mockGetAccountSpy).toHaveBeenCalledWith("address", undefined); + }); + + it("get xpub from device id when there is no initial account", async () => { + const mockGetAccountInfo = jest.fn().mockImplementation(async () => ({ + balance: BigInt(0), + transactions: [], + blockHeight: 0, + })); + mockedAptosAPI.mockImplementation(() => ({ + getAccountInfo: mockGetAccountInfo, + })); + const mockGetAccountSpy = jest.spyOn({ getAccount: mockGetAccountInfo }, "getAccount"); + + const account = await getAccountShape( + { + id: "1", + address: "address", + currency: getCryptoCurrencyById("aptos"), + derivationMode: "", + index: 0, + xpub: "address", + derivationPath: "", + deviceId: "1", + } as unknown as AccountShapeInfo, + {} as SyncConfig, + ); + + expect(account.xpub).toEqual("7075626c69634b6579"); + expect(mockedFistValueFrom).toHaveBeenCalledTimes(1); + expect(mockedDecodeAccountId).toHaveBeenCalledTimes(0); + expect(mockedAptosAPI).toHaveBeenCalledTimes(1); + expect(mockGetAccountSpy).toHaveBeenCalledWith("address", undefined); + }); + + it("get xpub from initial account id", async () => { + const mockGetAccountInfo = jest.fn().mockImplementation(async () => ({ + balance: BigInt(0), + transactions: [], + blockHeight: 0, + })); + mockedAptosAPI.mockImplementation(() => ({ + getAccountInfo: mockGetAccountInfo, + })); + const mockGetAccountSpy = jest.spyOn({ getAccount: mockGetAccountInfo }, "getAccount"); + + const account = await getAccountShape( + { + id: "1", + address: "address", + currency: getCryptoCurrencyById("aptos"), + derivationMode: "", + index: 0, + xpub: "address", + derivationPath: "", + // deviceId: "1", + initialAccount: { + id: "1:1:1:1:1", + // xpub: "address", + seedIdentifier: "1", + derivationMode: "", + index: 0, + freshAddress: "address", + freshAddressPath: "", + used: true, + balance: BigInt(10), + spendableBalance: BigInt(10), + creationDate: new Date(), + blockHeight: 0, + currency: getCryptoCurrencyById("aptos"), + operationsCount: 0, + operations: [], + pendingOperations: [], + lastSyncDate: new Date(), + balanceHistoryCache: {}, + swapHistory: [], + }, + } as unknown as AccountShapeInfo, + {} as SyncConfig, + ); + + expect(account.xpub).toEqual("address"); + expect(mockedFistValueFrom).toHaveBeenCalledTimes(0); + expect(mockedDecodeAccountId).toHaveBeenCalledTimes(1); + expect(mockedAptosAPI).toHaveBeenCalledTimes(1); + expect(mockGetAccountSpy).toHaveBeenCalledWith("address", undefined); + }); + + it("unable to get xpub error is thrown", async () => { + mockedDecodeAccountId = jest.mocked(decodeAccountId).mockReturnValue({ + currencyId: "aptos", + derivationMode: "", + type: "js", + version: "1", + xpubOrAddress: "", + }); + + expect( + async () => + await getAccountShape( + { + id: "1", + address: "address", + currency: getCryptoCurrencyById("aptos"), + derivationMode: "", + index: 0, + xpub: "address", + derivationPath: "", + // deviceId: "1", + initialAccount: { + id: "1:1:1:1:1", + // xpub: "address", + seedIdentifier: "1", + derivationMode: "", + index: 0, + freshAddress: "address", + freshAddressPath: "", + used: true, + balance: BigInt(10), + spendableBalance: BigInt(10), + creationDate: new Date(), + blockHeight: 0, + currency: getCryptoCurrencyById("aptos"), + operationsCount: 0, + operations: [], + pendingOperations: [], + lastSyncDate: new Date(), + balanceHistoryCache: {}, + swapHistory: [], + }, + } as unknown as AccountShapeInfo, + {} as SyncConfig, + ), + ).rejects.toThrow("Unable to retrieve public key"); + }); + + it("unable to get xpub error is thrown when there is no initial account", async () => { + mockedDecodeAccountId = jest.mocked(decodeAccountId).mockReturnValue({ + currencyId: "aptos", + derivationMode: "", + type: "js", + version: "1", + xpubOrAddress: "", + }); + + expect( + async () => + await getAccountShape( + { + id: "1", + address: "address", + currency: getCryptoCurrencyById("aptos"), + derivationMode: "", + index: 0, + xpub: "address", + derivationPath: "", + } as unknown as AccountShapeInfo, + {} as SyncConfig, + ), + ).rejects.toThrow("Unable to retrieve public key"); + }); + + it("get xpub from device id and account has operations history", async () => { + const mockGetAccountInfo = jest.fn().mockImplementation(async () => ({ + balance: BigInt(0), + transactions: [], + blockHeight: 0, + })); + mockedAptosAPI.mockImplementation(() => ({ + getAccountInfo: mockGetAccountInfo, + })); + const mockGetAccountSpy = jest.spyOn({ getAccount: mockGetAccountInfo }, "getAccount"); + + const account = await getAccountShape( + { + id: "1", + address: "address", + currency: getCryptoCurrencyById("aptos"), + derivationMode: "", + index: 0, + xpub: "address", + derivationPath: "", + deviceId: "1", + initialAccount: { + id: "1:1:1:1:1", + // xpub: "address", + seedIdentifier: "1", + derivationMode: "", + index: 0, + freshAddress: "address", + freshAddressPath: "", + used: true, + balance: BigInt(10), + spendableBalance: BigInt(10), + creationDate: new Date(), + blockHeight: 0, + currency: getCryptoCurrencyById("aptos"), + operationsCount: 1, + operations: [ + { + id: "1", + hash: "hash", + type: "OUT", + value: BigInt(10), + fee: BigInt(0), + blockHeight: 0, + blockHash: "blockHash", + accountId: "1", + senders: ["sender"], + recipients: ["recipient"], + date: new Date(), + // extra: {}, + }, + ], + pendingOperations: [], + lastSyncDate: new Date(), + balanceHistoryCache: {}, + swapHistory: [], + }, + } as unknown as AccountShapeInfo, + {} as SyncConfig, + ); + + expect(account.xpub).toEqual("7075626c69634b6579"); + expect(mockedFistValueFrom).toHaveBeenCalledTimes(1); + expect(mockedDecodeAccountId).toHaveBeenCalledTimes(0); + expect(mockedAptosAPI).toHaveBeenCalledTimes(1); + expect(mockGetAccountSpy).toHaveBeenCalledWith("address", undefined); + }); + + it("get xpub from device id and account has operations history with extra", async () => { + const mockGetAccountInfo = jest.fn().mockImplementation(async () => ({ + balance: BigInt(0), + transactions: [], + blockHeight: 0, + })); + mockedAptosAPI.mockImplementation(() => ({ + getAccountInfo: mockGetAccountInfo, + })); + const mockGetAccountSpy = jest.spyOn({ getAccount: mockGetAccountInfo }, "getAccount"); + + const account = await getAccountShape( + { + id: "1", + address: "address", + currency: getCryptoCurrencyById("aptos"), + derivationMode: "", + index: 0, + xpub: "address", + derivationPath: "", + deviceId: "1", + initialAccount: { + id: "1:1:1:1:1", + // xpub: "address", + seedIdentifier: "1", + derivationMode: "", + index: 0, + freshAddress: "address", + freshAddressPath: "", + used: true, + balance: BigInt(10), + spendableBalance: BigInt(10), + creationDate: new Date(), + blockHeight: 0, + currency: getCryptoCurrencyById("aptos"), + operationsCount: 1, + operations: [ + { + id: "1", + hash: "hash", + type: "OUT", + value: BigInt(10), + fee: BigInt(0), + blockHeight: 0, + blockHash: "blockHash", + accountId: "1", + senders: ["sender"], + recipients: ["recipient"], + date: new Date(), + extra: { version: 1 }, + }, + ], + pendingOperations: [], + lastSyncDate: new Date(), + balanceHistoryCache: {}, + swapHistory: [], + }, + } as unknown as AccountShapeInfo, + {} as SyncConfig, + ); + + expect(account.xpub).toEqual("7075626c69634b6579"); + expect(mockedFistValueFrom).toHaveBeenCalledTimes(1); + expect(mockedDecodeAccountId).toHaveBeenCalledTimes(0); + expect(mockedAptosAPI).toHaveBeenCalledTimes(1); + expect(mockGetAccountSpy).toHaveBeenCalledWith("address", 1); + }); +}); diff --git a/libs/ledger-live-common/src/families/aptos/synchronisation.ts b/libs/ledger-live-common/src/families/aptos/synchronisation.ts new file mode 100644 index 000000000000..2e692a3dce1a --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/synchronisation.ts @@ -0,0 +1,67 @@ +import Aptos from "@ledgerhq/hw-app-aptos"; +import { firstValueFrom, from } from "rxjs"; +import { decodeAccountId, encodeAccountId } from "../../account"; +import type { GetAccountShape } from "../../bridge/jsHelpers"; +import { makeScanAccounts, makeSync, mergeOps } from "../../bridge/jsHelpers"; +import { withDevice } from "../../hw/deviceAccess"; +import { AptosAPI } from "./api"; +import { txsToOps } from "./logic"; +import type { AptosAccount } from "./types"; + +export const getAccountShape: GetAccountShape = async info => { + const { address, initialAccount, derivationMode, currency, deviceId, derivationPath } = info; + + // "xpub" field is used to store publicKey to simulate transaction during sending tokens. + // We can't get access to the Nano X via bluetooth on the step of simulation + // but we need public key to simulate transaction. + // "xpub" field is used because this field exists in ledger operation type + let xpub = initialAccount?.xpub; + if (!initialAccount?.xpub && typeof deviceId === "string") { + const result = await firstValueFrom( + withDevice(deviceId)(transport => from(new Aptos(transport).getAddress(derivationPath))), + ); + xpub = Buffer.from(result.publicKey).toString("hex"); + } + if (!xpub && initialAccount?.id) { + const { xpubOrAddress } = decodeAccountId(initialAccount.id); + xpub = xpubOrAddress; + } + if (!xpub) { + // This is the corner case. We don't expect this happens + throw new Error("Unable to retrieve public key"); + } + + const oldOperations = initialAccount?.operations || []; + const startAt = (oldOperations[0]?.extra as any)?.version; + + const accountId = encodeAccountId({ + type: "js", + version: "2", + currencyId: currency.id, + xpubOrAddress: xpub as string, + derivationMode, + }); + + const aptosClient = new AptosAPI(currency.id); + const { balance, transactions, blockHeight } = await aptosClient.getAccountInfo(address, startAt); + + const newOperations = txsToOps(info, accountId, transactions); + const operations = mergeOps(oldOperations, newOperations); + + const shape: Partial = { + type: "Account", + id: accountId, + xpub, + balance: balance, + spendableBalance: balance, + operations, + operationsCount: operations.length, + blockHeight, + lastSyncDate: new Date(), + }; + + return shape; +}; + +export const scanAccounts = makeScanAccounts({ getAccountShape }); +export const sync = makeSync({ getAccountShape, shouldMergeOps: false }); diff --git a/libs/ledger-live-common/src/families/aptos/transaction.test.ts b/libs/ledger-live-common/src/families/aptos/transaction.test.ts new file mode 100644 index 000000000000..6408d66fc4d2 --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/transaction.test.ts @@ -0,0 +1,214 @@ +import BigNumber from "bignumber.js"; +import { createFixtureAccount } from "../../mock/fixtures/cryptoCurrencies"; +import createTransaction from "./createTransaction"; +import { formatTransaction, fromTransactionRaw, toTransactionRaw } from "./transaction"; +import { Transaction, TransactionRaw } from "./types"; + +jest.mock("./logic", () => ({ + DEFAULT_GAS: 100, + DEFAULT_GAS_PRICE: 200, +})); + +describe("transaction Test", () => { + describe("when formatTransaction", () => { + describe("when amount is 0 and fee is 0", () => { + it("should return a transaction SEND to 0xff00 with fees=?", async () => { + const account = createFixtureAccount(); + const transaction = createTransaction(); + + transaction.recipient = "0xff00"; + const result = formatTransaction(transaction, account); + + const expected = ` +SEND +TO 0xff00 +with fees=?`; + + expect(result).toBe(expected); + }); + }); + + describe("when amount is 0 and fee is 0.0001", () => { + it("should return a transaction SEND to 0xff00 with fees=0", async () => { + const account = createFixtureAccount(); + const transaction = createTransaction(); + + transaction.recipient = "0xff00"; + transaction.fees = new BigNumber("0.0001"); + const result = formatTransaction(transaction, account); + + const expected = ` +SEND +TO 0xff00 +with fees=0`; + + expect(result).toBe(expected); + }); + }); + + describe("when amount is 0 and fee is 0.1", () => { + it("should return a transaction SEND to 0xff00 with fees=0", async () => { + const account = createFixtureAccount(); + const transaction = createTransaction(); + + transaction.recipient = "0xff00"; + transaction.fees = new BigNumber("0.1"); + const result = formatTransaction(transaction, account); + + const expected = ` +SEND +TO 0xff00 +with fees=0`; + + expect(result).toBe(expected); + }); + }); + + describe("when amount is 1 and fee is 0.1", () => { + it("should return a transaction SEND to 0xff00 with fees=0", async () => { + const account = createFixtureAccount(); + const transaction = createTransaction(); + + transaction.amount = new BigNumber("1"); + transaction.recipient = "0xff00"; + transaction.fees = new BigNumber("0.1"); + const result = formatTransaction(transaction, account); + + const expected = ` +SEND 0 +TO 0xff00 +with fees=0`; + + expect(result).toBe(expected); + }); + }); + + describe("when amount is 10 and fee is 1", () => { + it("should return a transaction SEND to 0xff00 with fees=0", async () => { + const account = createFixtureAccount(); + const transaction = createTransaction(); + + transaction.amount = new BigNumber("10"); + transaction.recipient = "0xff00"; + transaction.fees = new BigNumber("1"); + const result = formatTransaction(transaction, account); + + const expected = ` +SEND 0 +TO 0xff00 +with fees=0`; + + expect(result).toBe(expected); + }); + }); + + describe("when amount is 1000 and fee is 1", () => { + it("should return a transaction SEND to 0xff00 with fees=0", async () => { + const account = createFixtureAccount(); + const transaction = createTransaction(); + + transaction.amount = new BigNumber("1000"); + transaction.recipient = "0xff00"; + transaction.fees = new BigNumber("1"); + const result = formatTransaction(transaction, account); + + const expected = ` +SEND 0 +TO 0xff00 +with fees=0`; + + expect(result).toBe(expected); + }); + }); + + describe("when using MAX with amount is 1000 and fee is 1", () => { + it("should return a transaction SEND to 0xff00 with fees=0", async () => { + const account = createFixtureAccount(); + const transaction = createTransaction(); + + transaction.amount = new BigNumber("1000"); + transaction.useAllAmount = true; + transaction.recipient = "0xff00"; + transaction.fees = new BigNumber("1"); + const result = formatTransaction(transaction, account); + + const expected = ` +SEND MAX +TO 0xff00 +with fees=0`; + + expect(result).toBe(expected); + }); + }); + }); + + describe("when fromTransactionRaw", () => { + it("should return the transaction object", () => { + const txRaw = { + family: "aptos", + mode: "send", + fees: null, + options: "{}", + estimate: "{}", + firstEmulation: "{}", + amount: "0.5", + recipient: "0xff00", + useAllAmount: false, + subAccountId: "0xff01", + recipientDomain: {}, + } as TransactionRaw; + + const result = fromTransactionRaw(txRaw); + + const expected = { + family: "aptos", + amount: new BigNumber("0.5"), + estimate: {}, + firstEmulation: {}, + mode: "send", + options: {}, + recipient: "0xff00", + recipientDomain: {}, + subAccountId: "0xff01", + useAllAmount: false, + }; + + expect(result).toEqual(expected); + }); + }); + + describe("when toTransactionRaw", () => { + it("should return the raw transaction object", () => { + const tx = { + family: "aptos", + amount: new BigNumber("0.5"), + estimate: {}, + firstEmulation: {}, + mode: "send", + options: {}, + recipient: "0xff00", + recipientDomain: {}, + subAccountId: "0xff01", + useAllAmount: false, + } as Transaction; + + const result = toTransactionRaw(tx); + + const expected = { + family: "aptos", + mode: "send", + fees: null, + options: "{}", + estimate: "{}", + firstEmulation: "{}", + amount: "0.5", + recipient: "0xff00", + useAllAmount: false, + subAccountId: "0xff01", + recipientDomain: {}, + } as TransactionRaw; + + expect(result).toEqual(expected); + }); + }); +}); diff --git a/libs/ledger-live-common/src/families/aptos/transaction.ts b/libs/ledger-live-common/src/families/aptos/transaction.ts new file mode 100644 index 000000000000..b9ddbadf948a --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/transaction.ts @@ -0,0 +1,65 @@ +import { BigNumber } from "bignumber.js"; +import type { Transaction, TransactionRaw } from "./types"; + +import { formatTransactionStatus } from "@ledgerhq/coin-bitcoin/transaction"; +import { + fromTransactionCommonRaw, + fromTransactionStatusRawCommon as fromTransactionStatusRaw, + toTransactionCommonRaw, + toTransactionStatusRawCommon as toTransactionStatusRaw, +} from "@ledgerhq/coin-framework/serialization"; +import { Account } from "@ledgerhq/types-live"; +import { formatCurrencyUnit } from "../../currencies"; + +export const formatTransaction = ( + { mode, amount, fees, recipient, useAllAmount }: Transaction, + account: Account, +): string => { + return ` +${mode.toUpperCase()} ${ + useAllAmount + ? "MAX" + : amount.isZero() + ? "" + : " " + formatCurrencyUnit(account.currency.units[0], amount) + } +TO ${recipient} +with fees=${fees ? formatCurrencyUnit(account.currency.units[0], fees) : "?"}`; +}; + +export const fromTransactionRaw = (t: TransactionRaw): Transaction => { + const common = fromTransactionCommonRaw(t); + return { + ...common, + family: t.family, + mode: t.mode, + options: JSON.parse(t.options), + estimate: JSON.parse(t.estimate), + firstEmulation: JSON.parse(t.firstEmulation), + ...(t.fees && { fees: new BigNumber(t.fees) }), + ...(t.errors && { errors: JSON.parse(t.errors) }), + }; +}; + +export const toTransactionRaw = (t: Transaction): TransactionRaw => { + const common = toTransactionCommonRaw(t); + return { + ...common, + family: t.family, + mode: t.mode, + fees: t.fees ? t.fees.toString() : null, + options: JSON.stringify(t.options), + estimate: JSON.stringify(t.estimate), + firstEmulation: JSON.stringify(t.firstEmulation), + errors: JSON.stringify(t.errors), + }; +}; + +export default { + formatTransaction, + fromTransactionRaw, + toTransactionRaw, + fromTransactionStatusRaw, + toTransactionStatusRaw, + formatTransactionStatus, +}; diff --git a/libs/ledger-live-common/src/families/aptos/types.ts b/libs/ledger-live-common/src/families/aptos/types.ts new file mode 100644 index 000000000000..8094315a0a2c --- /dev/null +++ b/libs/ledger-live-common/src/families/aptos/types.ts @@ -0,0 +1,81 @@ +import type { UserTransactionResponse } from "@aptos-labs/ts-sdk"; +import type { + Account, + TransactionCommon, + TransactionCommonRaw, + TransactionStatusCommon, + TransactionStatusCommonRaw, +} from "@ledgerhq/types-live"; +import type { BigNumber } from "bignumber.js"; + +export type AptosTransaction = UserTransactionResponse & { + block: { + height: number; + hash: string; + }; +}; + +export type AptosAccount = Account; + +export type TransactionStatus = TransactionStatusCommon; + +export type TransactionStatusRaw = TransactionStatusCommonRaw; + +export type AptosCoinStoreResource = { + coin: { + value: string; + }; +}; + +export type AptosResource = any> = { + data: T; + type: string; +}; + +export type AptosAddress = { + address: string; + publicKey: string; + path: string; +}; + +export interface TransactionEstimate { + maxGasAmount: string; + gasUnitPrice: string; + sequenceNumber?: string; + expirationTimestampSecs?: string; +} + +export type TransactionOptions = { + maxGasAmount: string; + gasUnitPrice: string; + sequenceNumber?: string; + expirationTimestampSecs?: string; +}; + +export type TransactionErrors = { + maxGasAmount?: string; + gasUnitPrice?: string; + sequenceNumber?: string; + expirationTimestampSecs?: string; +}; + +export type Transaction = TransactionCommon & { + mode: string; + family: "aptos"; + fees?: BigNumber | null; + options: TransactionOptions; + estimate: TransactionEstimate; + firstEmulation: boolean; + errors?: TransactionErrors; + tag?: string; +}; + +export type TransactionRaw = TransactionCommonRaw & { + family: "aptos"; + mode: string; + fees?: string | null; + options: string; + estimate: string; + firstEmulation: string; + errors?: string; +}; diff --git a/libs/ledger-live-common/src/featureFlags/defaultFeatures.ts b/libs/ledger-live-common/src/featureFlags/defaultFeatures.ts index 6656a08f7986..77fb31db6bad 100644 --- a/libs/ledger-live-common/src/featureFlags/defaultFeatures.ts +++ b/libs/ledger-live-common/src/featureFlags/defaultFeatures.ts @@ -37,6 +37,8 @@ export const CURRENCY_DEFAULT_FEATURES = { currencyArbitrumSepolia: DEFAULT_FEATURE, currencyAstar: DEFAULT_FEATURE, currencyAvalancheCChain: DEFAULT_FEATURE, + currencyAptos: DEFAULT_FEATURE, + currencyAptosTestnet: DEFAULT_FEATURE, currencyAxelar: DEFAULT_FEATURE, currencyBase: DEFAULT_FEATURE, currencyBaseSepolia: DEFAULT_FEATURE, diff --git a/libs/ledger-live-common/src/generated/bridge/js.ts b/libs/ledger-live-common/src/generated/bridge/js.ts index 4b902b98ac2f..7c6be6699d05 100644 --- a/libs/ledger-live-common/src/generated/bridge/js.ts +++ b/libs/ledger-live-common/src/generated/bridge/js.ts @@ -1,3 +1,4 @@ +import aptos from "../../families/aptos/bridge/js"; import casper from "../../families/casper/bridge/js"; import celo from "../../families/celo/bridge/js"; import { bridge as algorand } from "../../families/algorand/setup"; @@ -22,6 +23,7 @@ import { bridge as vechain } from "../../families/vechain/setup"; import { bridge as xrp } from "../../families/xrp/setup"; export default { + aptos, casper, celo, algorand, diff --git a/libs/ledger-live-common/src/generated/bridge/mock.ts b/libs/ledger-live-common/src/generated/bridge/mock.ts index a4b5ae66cce5..9e1541f46899 100644 --- a/libs/ledger-live-common/src/generated/bridge/mock.ts +++ b/libs/ledger-live-common/src/generated/bridge/mock.ts @@ -1,4 +1,5 @@ import algorand from "../../families/algorand/bridge/mock"; +import aptos from "../../families/aptos/bridge/mock"; import bitcoin from "../../families/bitcoin/bridge/mock"; import cardano from "../../families/cardano/bridge/mock"; import casper from "../../families/casper/bridge/mock"; @@ -15,6 +16,7 @@ import xrp from "../../families/xrp/bridge/mock"; export default { algorand, + aptos, bitcoin, cardano, casper, diff --git a/libs/ledger-live-common/src/generated/deviceTransactionConfig.ts b/libs/ledger-live-common/src/generated/deviceTransactionConfig.ts index 2e87853bee5c..87cd3a74dc6a 100644 --- a/libs/ledger-live-common/src/generated/deviceTransactionConfig.ts +++ b/libs/ledger-live-common/src/generated/deviceTransactionConfig.ts @@ -1,3 +1,4 @@ +import aptos from "../families/aptos/deviceTransactionConfig"; import casper from "../families/casper/deviceTransactionConfig"; import celo from "../families/celo/deviceTransactionConfig"; import algorand from "@ledgerhq/coin-algorand/deviceTransactionConfig"; @@ -21,6 +22,7 @@ import tron from "@ledgerhq/coin-tron/deviceTransactionConfig"; import xrp from "@ledgerhq/coin-xrp/deviceTransactionConfig"; export default { + aptos, casper, celo, algorand, @@ -43,6 +45,7 @@ export default { tron, xrp, }; +import { ExtraDeviceTransactionField as ExtraDeviceTransactionField_aptos } from "../families/aptos/deviceTransactionConfig"; import { ExtraDeviceTransactionField as ExtraDeviceTransactionField_casper } from "../families/casper/deviceTransactionConfig"; import { ExtraDeviceTransactionField as ExtraDeviceTransactionField_filecoin } from "@ledgerhq/coin-filecoin/bridge/deviceTransactionConfig"; import { ExtraDeviceTransactionField as ExtraDeviceTransactionField_stacks } from "@ledgerhq/coin-stacks/bridge/deviceTransactionConfig"; @@ -50,6 +53,7 @@ import { ExtraDeviceTransactionField as ExtraDeviceTransactionField_polkadot } f import { ExtraDeviceTransactionField as ExtraDeviceTransactionField_tron } from "@ledgerhq/coin-tron/bridge/deviceTransactionConfig"; export type ExtraDeviceTransactionField = + | ExtraDeviceTransactionField_aptos | ExtraDeviceTransactionField_casper | ExtraDeviceTransactionField_filecoin | ExtraDeviceTransactionField_stacks diff --git a/libs/ledger-live-common/src/generated/hw-getAddress.ts b/libs/ledger-live-common/src/generated/hw-getAddress.ts index 047bf2e26dff..ee8b677a5e42 100644 --- a/libs/ledger-live-common/src/generated/hw-getAddress.ts +++ b/libs/ledger-live-common/src/generated/hw-getAddress.ts @@ -1,3 +1,4 @@ +import aptos from "../families/aptos/hw-getAddress"; import casper from "../families/casper/hw-getAddress"; import celo from "../families/celo/hw-getAddress"; import { resolver as algorand } from "../families/algorand/setup"; @@ -22,6 +23,7 @@ import { resolver as vechain } from "../families/vechain/setup"; import { resolver as xrp } from "../families/xrp/setup"; export default { + aptos, casper, celo, algorand, diff --git a/libs/ledger-live-common/src/generated/specs.ts b/libs/ledger-live-common/src/generated/specs.ts index b17dc560ca9f..fb59f365268a 100644 --- a/libs/ledger-live-common/src/generated/specs.ts +++ b/libs/ledger-live-common/src/generated/specs.ts @@ -1,3 +1,4 @@ +import aptos from "../families/aptos/specs"; import casper from "../families/casper/specs"; import celo from "../families/celo/specs"; import algorand from "@ledgerhq/coin-algorand/specs"; @@ -22,6 +23,7 @@ import vechain from "@ledgerhq/coin-vechain/specs"; import xrp from "@ledgerhq/coin-xrp/specs"; export default { + aptos, casper, celo, algorand, diff --git a/libs/ledger-live-common/src/generated/transaction.ts b/libs/ledger-live-common/src/generated/transaction.ts index 08d1d04d2832..5142554bb78d 100644 --- a/libs/ledger-live-common/src/generated/transaction.ts +++ b/libs/ledger-live-common/src/generated/transaction.ts @@ -1,3 +1,4 @@ +import aptos from "../families/aptos/transaction"; import casper from "../families/casper/transaction"; import celo from "../families/celo/transaction"; import algorand from "@ledgerhq/coin-algorand/transaction"; @@ -22,6 +23,7 @@ import vechain from "@ledgerhq/coin-vechain/transaction"; import xrp from "@ledgerhq/coin-xrp/transaction"; export default { + aptos, casper, celo, algorand, diff --git a/libs/ledger-live-common/src/generated/types.ts b/libs/ledger-live-common/src/generated/types.ts index 5877a9385edb..904293ddf23e 100644 --- a/libs/ledger-live-common/src/generated/types.ts +++ b/libs/ledger-live-common/src/generated/types.ts @@ -4,6 +4,12 @@ import type { TransactionStatus as algorandTransactionStatus, TransactionStatusRaw as algorandTransactionStatusRaw, } from "@ledgerhq/coin-algorand/types"; +import type { + Transaction as aptosTransaction, + TransactionRaw as aptosTransactionRaw, + TransactionStatus as aptosTransactionStatus, + TransactionStatusRaw as aptosTransactionStatusRaw, +} from "../families/aptos/types"; import type { Transaction as bitcoinTransaction, TransactionRaw as bitcoinTransactionRaw, @@ -133,6 +139,7 @@ import type { export type Transaction = | algorandTransaction + | aptosTransaction | bitcoinTransaction | cardanoTransaction | casperTransaction @@ -157,6 +164,7 @@ export type Transaction = export type TransactionRaw = | algorandTransactionRaw + | aptosTransactionRaw | bitcoinTransactionRaw | cardanoTransactionRaw | casperTransactionRaw @@ -181,6 +189,7 @@ export type TransactionRaw = export type TransactionStatus = | algorandTransactionStatus + | aptosTransactionStatus | bitcoinTransactionStatus | cardanoTransactionStatus | casperTransactionStatus @@ -205,6 +214,7 @@ export type TransactionStatus = export type TransactionStatusRaw = | algorandTransactionStatusRaw + | aptosTransactionStatusRaw | bitcoinTransactionStatusRaw | cardanoTransactionStatusRaw | casperTransactionStatusRaw diff --git a/libs/ledgerjs/packages/cryptoassets/src/abandonseed.ts b/libs/ledgerjs/packages/cryptoassets/src/abandonseed.ts index 7be448eae23c..718f29e48a1d 100644 --- a/libs/ledgerjs/packages/cryptoassets/src/abandonseed.ts +++ b/libs/ledgerjs/packages/cryptoassets/src/abandonseed.ts @@ -1,5 +1,5 @@ -import invariant from "invariant"; import { CryptoCurrency } from "@ledgerhq/types-cryptoassets"; +import invariant from "invariant"; const EVM_DEAD_ADDRESS = "0x000000000000000000000000000000000000dEaD"; @@ -13,6 +13,8 @@ const abandonSeedAddresses: Partial> = { algorand: "PSHLIWQKDEETIIBQEOTLGCT5IF7BTTOKCUULONOGVGF2HYDT2IHW3H4CCI", // https://snowtrace.io/address/0x000000000000000000000000000000000000dead/tokens avalanche_c_chain: EVM_DEAD_ADDRESS, + aptos: EVM_DEAD_ADDRESS, + aptos_testnet: EVM_DEAD_ADDRESS, cosmos: "cosmos19rl4cm2hmr8afy4kldpxz3fka4jguq0auqdal4", ripple: "rHsMGQEkVNJmpGWs8XUBoTBiAAbwxZN5v3", stellar: "GDYPMQMYW2JTLPWAUAHIDY3E4VHP5SGTFC5SMA45L7ZPOTHWQ2PHEW3E", diff --git a/libs/ledgerjs/packages/cryptoassets/src/currencies.ts b/libs/ledgerjs/packages/cryptoassets/src/currencies.ts index 7a20ab850e8c..05182a32d4ff 100644 --- a/libs/ledgerjs/packages/cryptoassets/src/currencies.ts +++ b/libs/ledgerjs/packages/cryptoassets/src/currencies.ts @@ -80,6 +80,55 @@ const ethereumUnits = (name, code) => [ // to fix that we should always have the 'main' currency of the managerapp first in this list // e.g for Ethereum manager Ethereum is first in the list and other coin are in the bottom of the list export const cryptocurrenciesById: Record = { + aptos: { + type: "CryptoCurrency", + id: "aptos", + coinType: CoinType.APTOS, + name: "Aptos", + managerAppName: "Aptos", + ticker: "APT", + scheme: "aptos", + color: "#231F20", + family: "aptos", + units: [ + { + name: "APT", + code: "APT", + magnitude: 8, + }, + ], + explorerViews: [ + { + address: "https://explorer.aptoslabs.com/account/$address?network=mainnet", + tx: "https://explorer.aptoslabs.com/txn/$hash?network=mainnet", + }, + ], + }, + aptos_testnet: { + type: "CryptoCurrency", + id: "aptos_testnet", + coinType: CoinType.APTOS, + name: "Aptos (Testnet)", + managerAppName: "Aptos", + ticker: "APT", + scheme: "aptos_testnet", + color: "#FFCD29", + family: "aptos", + isTestnetFor: "aptos", + units: [ + { + name: "APT", + code: "APT", + magnitude: 8, + }, + ], + explorerViews: [ + { + address: "https://explorer.aptoslabs.com/account/$address?network=testnet", + tx: "https://explorer.aptoslabs.com/txn/$hash?network=testnet", + }, + ], + }, near: { type: "CryptoCurrency", id: "near", diff --git a/libs/ledgerjs/packages/hw-app-aptos/.unimportedrc.json b/libs/ledgerjs/packages/hw-app-aptos/.unimportedrc.json new file mode 100644 index 000000000000..1fb1c1c89940 --- /dev/null +++ b/libs/ledgerjs/packages/hw-app-aptos/.unimportedrc.json @@ -0,0 +1,4 @@ +{ + "entry": ["src/Aptos.ts"], + "ignoreUnimported": [] +} diff --git a/libs/ledgerjs/packages/hw-app-aptos/README.md b/libs/ledgerjs/packages/hw-app-aptos/README.md new file mode 100644 index 000000000000..bfca3c8d0bc4 --- /dev/null +++ b/libs/ledgerjs/packages/hw-app-aptos/README.md @@ -0,0 +1,115 @@ + + +[GitHub](https://github.com/LedgerHQ/ledger-live/), +[Ledger Devs Discord](https://developers.ledger.com/discord-pro), +[Developer Portal](https://developers.ledger.com/) + +## @ledgerhq/hw-app-aptos + +Ledger Hardware Wallet Aptos JavaScript bindings. + +*** + +## Are you adding Ledger support to your software wallet? + +You may be using this package to communicate with the Aptos Nano App. + +For a smooth and quick integration: + +* See the developers’ documentation on the [Developer Portal](https://developers.ledger.com/docs/transport/overview/) and +* Go on [Discord](https://developers.ledger.com/discord-pro/) to chat with developer support and the developer community. + +*** + +## API + + + +#### Table of Contents + +* [Aptos](#aptos) + * [Parameters](#parameters) + * [Examples](#examples) + * [getAddress](#getaddress) + * [Parameters](#parameters-1) + * [Examples](#examples-1) + * [signTransaction](#signtransaction) + * [Parameters](#parameters-2) + * [Examples](#examples-2) + +### Aptos + +Aptos API + +#### Parameters + +* `transport` **Transport** +* `scrambleKey` (optional, default `"aptos"`) + +#### Examples + +```javascript +import Transport from "@ledgerhq/hw-transport"; +import Aptos from "@ledgerhq/hw-app-aptos"; + +function establishConnection() { + return Transport.create() + .then(transport => new Aptos(transport)); +} + +function fetchAddress(aptosClient) { + return aptosClient.getAddress("44'/144'/0'/0/0"); +} + +function signTransaction(aptosClient, deviceData, seqNo, buffer) { * + const transactionBlob = encode(buffer); + + console.log('Sending transaction to device for approval...'); + return aptosClient.signTransaction("44'/144'/0'/0/0", transactionBlob); +} + +function prepareAndSign(aptosClient, seqNo) { + return fetchAddress(aptosClient) + .then(deviceData => signTransaction(aptosClient, deviceData, seqNo)); +} + +establishConnection() + .then(aptos => prepareAndSign(aptos, 123, payload)) + .then(signature => console.log(`Signature: ${signature}`)) + .catch(e => console.log(`An error occurred (${e.message})`)); +``` + +#### getAddress + +get Aptos address for a given BIP 32 path. + +##### Parameters + +* `path` **[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)** a path in BIP 32 format +* `display` optionally enable or not the display (optional, default `false`) + +##### Examples + +```javascript +const result = await aptos.getAddress("44'/144'/0'/0/0"); +const { publicKey, address } = result; +``` + +Returns **[Promise](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise)\** an object with a publicKey, address and (optionally) chainCode + +#### signTransaction + +sign a Aptos transaction with a given BIP 32 path + +##### Parameters + +* `path` **[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)** a path in BIP 32 format +* `txBuffer` **[Buffer](https://nodejs.org/api/buffer.html)** the buffer to be signed for transaction + +##### Examples + +```javascript +const signature = await aptos.signTransaction("44'/144'/0'/0/0", "12000022800000002400000002614000000001315D3468400000000000000C73210324E5F600B52BB3D9246D49C4AB1722BA7F32B7A3E4F9F2B8A1A28B9118CC36C48114F31B152151B6F42C1D61FE4139D34B424C8647D183142ECFC1831F6E979C6DA907E88B1CAD602DB59E2F"); +``` + +Returns **[Promise](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise)<{signature: [Buffer](https://nodejs.org/api/buffer.html)}>** a signature as hex string diff --git a/libs/ledgerjs/packages/hw-app-aptos/jest.config.ts b/libs/ledgerjs/packages/hw-app-aptos/jest.config.ts new file mode 100644 index 000000000000..c4f012862710 --- /dev/null +++ b/libs/ledgerjs/packages/hw-app-aptos/jest.config.ts @@ -0,0 +1,6 @@ +import baseConfig from "../../jest.config"; + +export default { + ...baseConfig, + rootDir: __dirname, +}; diff --git a/libs/ledgerjs/packages/hw-app-aptos/package.json b/libs/ledgerjs/packages/hw-app-aptos/package.json new file mode 100644 index 000000000000..caecf8b16612 --- /dev/null +++ b/libs/ledgerjs/packages/hw-app-aptos/package.json @@ -0,0 +1,59 @@ +{ + "name": "@ledgerhq/hw-app-aptos", + "version": "6.29.4", + "description": "Ledger Hardware Wallet Aptos Application API", + "keywords": [ + "Ledger", + "LedgerWallet", + "Aptos", + "apt", + "NanoS", + "Blue", + "Hardware Wallet" + ], + "repository": { + "type": "git", + "url": "https://github.com/LedgerHQ/ledger-live.git" + }, + "bugs": { + "url": "https://github.com/LedgerHQ/ledger-live/issues" + }, + "homepage": "https://github.com/LedgerHQ/ledger-live/tree/develop/libs/ledgerjs/packages/hw-app-aptos", + "publishConfig": { + "access": "public" + }, + "main": "lib/Aptos.js", + "module": "lib-es/Aptos.js", + "types": "lib/Aptos.d.ts", + "license": "Apache-2.0", + "dependencies": { + "@ledgerhq/errors": "workspace:^", + "@ledgerhq/hw-transport": "workspace:^", + "@noble/hashes": "1.6.1", + "bip32-path": "^0.4.2" + }, + "devDependencies": { + "@ledgerhq/hw-transport-mocker": "workspace:^", + "@types/jest": "^29.5.10", + "@types/node": "^20.8.10", + "documentation": "14.0.2", + "jest": "^29.7.0", + "rimraf": "^4.4.1", + "source-map-support": "^0.5.21", + "ts-jest": "^29.1.1", + "ts-node": "^10.4.0" + }, + "scripts": { + "clean": "rimraf lib lib-es", + "build": "tsc && tsc -m ES6 --outDir lib-es", + "prewatch": "pnpm build", + "watch": "tsc --watch", + "watch:es": "tsc --watch -m ES6 --outDir lib-es", + "doc": "documentation readme src/** --section=API --pe ts --re ts --re d.ts", + "lint": "eslint ./src --no-error-on-unmatched-pattern --ext .ts,.tsx --cache", + "lint:fix": "pnpm lint --fix", + "test": "jest", + "unimported": "unimported" + }, + "gitHead": "dd0dea64b58e5a9125c8a422dcffd29e5ef6abec" +} diff --git a/libs/ledgerjs/packages/hw-app-aptos/src/Aptos.ts b/libs/ledgerjs/packages/hw-app-aptos/src/Aptos.ts new file mode 100644 index 000000000000..c58eaf97f886 --- /dev/null +++ b/libs/ledgerjs/packages/hw-app-aptos/src/Aptos.ts @@ -0,0 +1,220 @@ +/******************************************************************************** + * Ledger Node JS API + * (c) 2017-2018 Ledger + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + ********************************************************************************/ + +import BIPPath from "bip32-path"; +import { sha3_256 as sha3Hash } from "@noble/hashes/sha3"; +import Transport from "@ledgerhq/hw-transport"; +import { StatusCodes } from "@ledgerhq/errors"; + +const MAX_APDU_LEN = 255; +const P1_NON_CONFIRM = 0x00; +const P1_CONFIRM = 0x01; +const P1_START = 0x00; +const P2_MORE = 0x80; +const P2_LAST = 0x00; + +const LEDGER_CLA = 0x5b; +const INS = { + GET_VERSION: 0x03, + GET_PUBLIC_KEY: 0x05, + SIGN_TX: 0x06, +}; + +interface AppConfig { + version: string; +} + +export interface AddressData { + publicKey: Buffer; + chainCode: Buffer; + address: string; +} + +/** + * Aptos API + * + * @example + * import Transport from "@ledgerhq/hw-transport"; + * import Aptos from "@ledgerhq/hw-app-aptos"; + * + * function establishConnection() { + * return Transport.create() + * .then(transport => new Aptos(transport)); + * } + * + * function fetchAddress(aptosClient) { + * return aptosClient.getAddress("44'/144'/0'/0/0"); + * } + * + * function signTransaction(aptosClient, deviceData, seqNo, buffer) { * + * const transactionBlob = encode(buffer); + * + * console.log('Sending transaction to device for approval...'); + * return aptosClient.signTransaction("44'/144'/0'/0/0", transactionBlob); + * } + * + * function prepareAndSign(aptosClient, seqNo) { + * return fetchAddress(aptosClient) + * .then(deviceData => signTransaction(aptosClient, deviceData, seqNo)); + * } + * + * establishConnection() + * .then(aptos => prepareAndSign(aptos, 123, payload)) + * .then(signature => console.log(`Signature: ${signature}`)) + * .catch(e => console.log(`An error occurred (${e.message})`)); + */ + +export default class Aptos { + transport: Transport; + + constructor(transport: Transport, scrambleKey = "aptos") { + this.transport = transport; + transport.decorateAppAPIMethods(this, ["getVersion", "getAddress"], scrambleKey); + } + + async getVersion(): Promise { + const [major, minor, patch] = await this.sendToDevice( + INS.GET_VERSION, + P1_NON_CONFIRM, + P2_LAST, + Buffer.alloc(0), + ); + return { + version: `${major}.${minor}.${patch}`, + }; + } + + /** + * get Aptos address for a given BIP 32 path. + * + * @param path a path in BIP 32 format + * @param display optionally enable or not the display + * @return an object with a publicKey, address and (optionally) chainCode + * @example + * const result = await aptos.getAddress("44'/144'/0'/0/0"); + * const { publicKey, address } = result; + */ + async getAddress(path: string, display = false): Promise { + const pathBuffer = this.pathToBuffer(path); + const responseBuffer = await this.sendToDevice( + INS.GET_PUBLIC_KEY, + display ? P1_CONFIRM : P1_NON_CONFIRM, + P2_LAST, + pathBuffer, + ); + + let offset = 1; + const pubKeyLen = responseBuffer.subarray(0, offset)[0] - 1; + const pubKeyBuffer = responseBuffer.subarray(++offset, (offset += pubKeyLen)); + const chainCodeLen = responseBuffer.subarray(offset, ++offset)[0]; + const chainCodeBuffer = responseBuffer.subarray(offset, offset + chainCodeLen); + + const address = "0x" + this.publicKeyToAddress(pubKeyBuffer).toString("hex"); + + return { + publicKey: pubKeyBuffer, + chainCode: chainCodeBuffer, + address, + }; + } + + /** + * sign a Aptos transaction with a given BIP 32 path + * + * + * @param path a path in BIP 32 format + * @param txBuffer the buffer to be signed for transaction + * @return a signature as hex string + * @example + * const signature = await aptos.signTransaction("44'/144'/0'/0/0", "12000022800000002400000002614000000001315D3468400000000000000C73210324E5F600B52BB3D9246D49C4AB1722BA7F32B7A3E4F9F2B8A1A28B9118CC36C48114F31B152151B6F42C1D61FE4139D34B424C8647D183142ECFC1831F6E979C6DA907E88B1CAD602DB59E2F"); + */ + async signTransaction(path: string, txBuffer: Buffer): Promise<{ signature: Buffer }> { + const pathBuffer = this.pathToBuffer(path); + await this.sendToDevice(INS.SIGN_TX, P1_START, P2_MORE, pathBuffer); + const responseBuffer = await this.sendToDevice(INS.SIGN_TX, 1, P2_LAST, txBuffer); + + const signatureLen = responseBuffer[0]; + const signatureBuffer = responseBuffer.subarray(1, 1 + signatureLen); + return { signature: signatureBuffer }; + } + + // send chunked if payload size exceeds maximum for a call + private async sendToDevice( + instruction: number, + p1: number, + p2: number, + payload: Buffer, + ): Promise { + const acceptStatusList = [StatusCodes.OK]; + let payloadOffset = 0; + + if (payload.length > MAX_APDU_LEN) { + while (payload.length - payloadOffset > MAX_APDU_LEN) { + const buf = payload.subarray(payloadOffset, (payloadOffset += MAX_APDU_LEN)); + const reply = await this.transport.send( + LEDGER_CLA, + instruction, + p1++, + P2_MORE, + buf, + acceptStatusList, + ); + this.throwOnFailure(reply); + } + } + + const buf = payload.subarray(payloadOffset); + const reply = await this.transport.send(LEDGER_CLA, instruction, p1, p2, buf, acceptStatusList); + this.throwOnFailure(reply); + + return reply.subarray(0, reply.length - 2); + } + + private pathToBuffer(originalPath: string): Buffer { + const path = originalPath + .split("/") + .filter(value => value !== "m") + .map(value => (value.endsWith("'") || value.endsWith("h") ? value : value + "'")) + .join("/"); + const pathNums: number[] = BIPPath.fromString(path).toPathArray(); + return this.serializePath(pathNums); + } + + private serializePath(path: number[]): Buffer { + const buf = Buffer.alloc(1 + path.length * 4); + buf.writeUInt8(path.length, 0); + for (const [i, num] of path.entries()) { + buf.writeUInt32BE(num, 1 + i * 4); + } + return buf; + } + + private publicKeyToAddress(pubKey: Buffer): Buffer { + const hash = sha3Hash.create(); + hash.update(pubKey); + hash.update("\x00"); + return Buffer.from(hash.digest()); + } + + private throwOnFailure(reply: Buffer): void { + // transport makes sure reply has a valid length + const status = reply.readUInt16BE(reply.length - 2); + if (status !== StatusCodes.OK) { + throw new Error(`Failure with status code: 0x${status.toString(16)}`); + } + } +} diff --git a/libs/ledgerjs/packages/hw-app-aptos/tests/aptos.test.ts b/libs/ledgerjs/packages/hw-app-aptos/tests/aptos.test.ts new file mode 100644 index 000000000000..0b9e6ce5a2ac --- /dev/null +++ b/libs/ledgerjs/packages/hw-app-aptos/tests/aptos.test.ts @@ -0,0 +1,66 @@ +import { openTransportReplayer, RecordStore } from "@ledgerhq/hw-transport-mocker"; +import Aptos from "../src/Aptos"; + +test("getVersion", async () => { + const transport = await openTransportReplayer( + RecordStore.fromString(` + => 5b03000000 + <= 0000019000 + `), + ); + const aptos = new Aptos(transport); + const result = await aptos.getVersion(); + expect(result).toEqual({ + version: "0.0.1", + }); +}); + +test("getAddress without display", async () => { + const transport = await openTransportReplayer( + RecordStore.fromString(` + => 5b05000015058000002c8000027d800000018000000080000000 + <= 2104d1d99b67e37b483161a0fa369c46f34a3be4863c20e20fc7cdc669c0826a41132070c750d272682024bdab22357e7091dff07583574df88850196c14e2da0209df9000 + `), + ); + const aptos = new Aptos(transport); + const { address } = await aptos.getAddress("m/44'/637'/1'/0'/0'", false); + expect(address).toEqual("0x783135e8b00430253a22ba041d860c373d7a1501ccf7ac2d1ad37a8ed2775aee"); +}); + +test("getAddress with display", async () => { + const transport = await openTransportReplayer( + RecordStore.fromString(` + => 5b05010015058000002c8000027d800000018000000080000000 + <= 2104d1d99b67e37b483161a0fa369c46f34a3be4863c20e20fc7cdc669c0826a41132070c750d272682024bdab22357e7091dff07583574df88850196c14e2da0209df9000 + `), + ); + const aptos = new Aptos(transport); + const { address } = await aptos.getAddress("m/44'/637'/1'/0'/0'", true); + expect(address).toEqual("0x783135e8b00430253a22ba041d860c373d7a1501ccf7ac2d1ad37a8ed2775aee"); +}); + +test("signTransaction", async () => { + const transport = await openTransportReplayer( + RecordStore.fromString(` + => 5b06008015058000002c8000027d800000018000000080000000 + <= 9000 + => 5b060100f3b5e97db07fa0bd0e5598aa3643a9bc6f6693bddc1a9fec9e674a461eaa00b193783135e8b00430253a22ba041d860c373d7a1501ccf7ac2d1ad37a8ed2775aee000000000000000002000000000000000000000000000000000000000000000000000000000000000104636f696e087472616e73666572010700000000000000000000000000000000000000000000000000000000000000010a6170746f735f636f696e094170746f73436f696e000220094c6fc0d3b382a599c37e1aaa7618eff2c96a3586876082c4594c50c50d7dde082a00000000000000204e0000000000006400000000000000565c51630000000022 + <= 409f6cc54741dceafa8ef5cd11bb33bf432b3296dc516ffa7d798778dac588e2efe2974a9401a366003fd66e14ae446f1d2a9eb99d863e9cf4bf7665cd19663b079000 + `), + ); + const aptos = new Aptos(transport); + const transaction = Buffer.from( + "b5e97db07fa0bd0e5598aa3643a9bc6f6693bddc1a9fec9e674a461eaa00b193783135e8b00430253a22ba041d860c373d7a1501ccf7ac2d1ad37a8ed2775aee000000000000000002000000000000000000000000000000000000000000000000000000000000000104636f696e087472616e73666572010700000000000000000000000000000000000000000000000000000000000000010a6170746f735f636f696e094170746f73436f696e000220094c6fc0d3b382a599c37e1aaa7618eff2c96a3586876082c4594c50c50d7dde082a00000000000000204e0000000000006400000000000000565c51630000000022", + "hex", + ); + const { signature } = await aptos.signTransaction("m/44'/637'/1'/0'/0'", transaction); + expect(signature.toString("hex")).toEqual( + "9f6cc54741dceafa8ef5cd11bb33bf432b3296dc516ffa7d798778dac588e2efe2974a9401a366003fd66e14ae446f1d2a9eb99d863e9cf4bf7665cd19663b07", + ); +}); + +test("should throw on invalid derivation path", async () => { + const transport = await openTransportReplayer(new RecordStore()); + const aptos = new Aptos(transport); + return expect(aptos.getAddress("some invalid derivation path", false)).rejects.toThrow("input"); +}); diff --git a/libs/ledgerjs/packages/hw-app-aptos/tsconfig.json b/libs/ledgerjs/packages/hw-app-aptos/tsconfig.json new file mode 100644 index 000000000000..0cf4676deafc --- /dev/null +++ b/libs/ledgerjs/packages/hw-app-aptos/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "lib" + }, + "include": ["src/**/*"] +} diff --git a/libs/ledgerjs/packages/types-cryptoassets/src/index.ts b/libs/ledgerjs/packages/types-cryptoassets/src/index.ts index 939757d0e208..fff4a8cefafd 100644 --- a/libs/ledgerjs/packages/types-cryptoassets/src/index.ts +++ b/libs/ledgerjs/packages/types-cryptoassets/src/index.ts @@ -135,6 +135,8 @@ export type CryptoCurrencyId = | "songbird" | "moonbeam" | "near" + | "aptos" + | "aptos_testnet" | "rsk" | "bittorrent" | "optimism" diff --git a/libs/ledgerjs/packages/types-cryptoassets/src/slip44.ts b/libs/ledgerjs/packages/types-cryptoassets/src/slip44.ts index 9afe2886fb5b..6f3bb9eeabd3 100644 --- a/libs/ledgerjs/packages/types-cryptoassets/src/slip44.ts +++ b/libs/ledgerjs/packages/types-cryptoassets/src/slip44.ts @@ -4,6 +4,7 @@ export enum CoinType { AION = 425, AKA = 200625, ALGO = 283, + APTOS = 637, ATOM = 118, ARK = 111, ATH = 1620, diff --git a/libs/ledgerjs/packages/types-live/src/feature.ts b/libs/ledgerjs/packages/types-live/src/feature.ts index 51f1676513d5..63e4a992350b 100644 --- a/libs/ledgerjs/packages/types-live/src/feature.ts +++ b/libs/ledgerjs/packages/types-live/src/feature.ts @@ -89,6 +89,8 @@ export type CurrencyFeatures = { currencyMoonriver: DefaultFeature; currencyVelasEvm: DefaultFeature; currencySyscoin: DefaultFeature; + currencyAptos: DefaultFeature; + currencyAptosTestnet: DefaultFeature; currencyAxelar: DefaultFeature; currencySecretNetwork: DefaultFeature; currencySeiNetwork: DefaultFeature; diff --git a/libs/ledgerjs/packages/types-live/src/operation.ts b/libs/ledgerjs/packages/types-live/src/operation.ts index 92e0187162c1..567a2d741717 100644 --- a/libs/ledgerjs/packages/types-live/src/operation.ts +++ b/libs/ledgerjs/packages/types-live/src/operation.ts @@ -11,6 +11,8 @@ export type OperationType = | "NONE" | "CREATE" | "REVEAL" + // APTOS + | "UNKNOWN" // COSMOS | "DELEGATE" | "UNDELEGATE" diff --git a/libs/ui/packages/crypto-icons/src/svg/APT.svg b/libs/ui/packages/crypto-icons/src/svg/APT.svg new file mode 100644 index 000000000000..3b95fd374af2 --- /dev/null +++ b/libs/ui/packages/crypto-icons/src/svg/APT.svg @@ -0,0 +1,3 @@ + + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5b10982a7fb..2f220d2ae45e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3851,6 +3851,12 @@ importers: libs/ledger-live-common: dependencies: + '@apollo/client': + specifier: ^3.8.7 + version: 3.12.4(@types/react@18.2.73)(graphql@16.8.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@aptos-labs/ts-sdk': + specifier: ^1.33.1 + version: 1.33.1 '@blooo/hw-app-acre': specifier: ^1.1.1 version: 1.1.1 @@ -3962,6 +3968,9 @@ importers: '@ledgerhq/hw-app-algorand': specifier: workspace:^ version: link:../ledgerjs/packages/hw-app-algorand + '@ledgerhq/hw-app-aptos': + specifier: workspace:^ + version: link:../ledgerjs/packages/hw-app-aptos '@ledgerhq/hw-app-btc': specifier: workspace:^ version: link:../ledgerjs/packages/hw-app-btc @@ -4067,6 +4076,9 @@ importers: '@ledgerhq/wallet-api-server': specifier: ^1.6.0 version: 1.6.0(react@18.3.1)(rxjs@7.8.1) + '@noble/hashes': + specifier: 1.6.1 + version: 1.6.1 '@stricahq/typhonjs': specifier: ^2.0.0 version: 2.0.0 @@ -4148,6 +4160,9 @@ importers: fuse.js: specifier: ^6.6.2 version: 6.6.2 + graphql: + specifier: ^16.8.1 + version: 16.8.1 invariant: specifier: ^2.2.2 version: 2.2.4 @@ -4698,6 +4713,49 @@ importers: specifier: ^10.4.0 version: 10.9.2(@types/node@20.12.12)(source-map-support@0.5.21)(typescript@5.4.3) + libs/ledgerjs/packages/hw-app-aptos: + dependencies: + '@ledgerhq/errors': + specifier: workspace:^ + version: link:../errors + '@ledgerhq/hw-transport': + specifier: workspace:^ + version: link:../hw-transport + '@noble/hashes': + specifier: 1.6.1 + version: 1.6.1 + bip32-path: + specifier: ^0.4.2 + version: 0.4.2 + devDependencies: + '@ledgerhq/hw-transport-mocker': + specifier: workspace:^ + version: link:../hw-transport-mocker + '@types/jest': + specifier: ^29.5.10 + version: 29.5.14 + '@types/node': + specifier: ^20.8.10 + version: 20.12.12 + documentation: + specifier: 14.0.2 + version: 14.0.2 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@20.12.12)(ts-node@10.9.2(@types/node@20.12.12)(source-map-support@0.5.21)(typescript@5.4.3)) + rimraf: + specifier: ^4.4.1 + version: 4.4.1 + source-map-support: + specifier: ^0.5.21 + version: 0.5.21 + ts-jest: + specifier: ^29.1.1 + version: 29.2.5(jest@29.7.0(@types/node@20.12.12)(ts-node@10.9.2(@types/node@20.12.12)(source-map-support@0.5.21)(typescript@5.4.3)))(typescript@5.4.3) + ts-node: + specifier: ^10.4.0 + version: 10.9.2(@types/node@20.12.12)(source-map-support@0.5.21)(typescript@5.4.3) + libs/ledgerjs/packages/hw-app-btc: dependencies: '@ledgerhq/hw-transport': @@ -8036,6 +8094,36 @@ packages: peerDependencies: ajv: '>=8' + '@apollo/client@3.12.4': + resolution: {integrity: sha512-S/eC9jxEW9Jg1BjD6AZonE1fHxYuvC3gFHop8FRQkUdeK63MmBD5r0DOrN2WlJbwha1MSD6A97OwXwjaujEQpA==} + peerDependencies: + graphql: ^15.0.0 || ^16.0.0 + graphql-ws: ^5.5.5 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc + subscriptions-transport-ws: ^0.9.0 || ^0.11.0 + peerDependenciesMeta: + graphql-ws: + optional: true + react: + optional: true + react-dom: + optional: true + subscriptions-transport-ws: + optional: true + + '@aptos-labs/aptos-cli@1.0.2': + resolution: {integrity: sha512-PYPsd0Kk3ynkxNfe3S4fanI3DiUICCoh4ibQderbvjPFL5A0oK6F4lPEO2t0MDsQySTk2t4vh99Xjy6Bd9y+aQ==} + hasBin: true + + '@aptos-labs/aptos-client@0.1.1': + resolution: {integrity: sha512-kJsoy4fAPTOhzVr7Vwq8s/AUg6BQiJDa7WOqRzev4zsuIS3+JCuIZ6vUd7UBsjnxtmguJJulMRs9qWCzVBt2XA==} + engines: {node: '>=15.10.0'} + + '@aptos-labs/ts-sdk@1.33.1': + resolution: {integrity: sha512-d6nWtUI//fyEN8DeLjm3+ro87Ad6+IKwR9pCqfrs/Azahso1xR1Llxd/O6fj/m1DDsuDj/HAsCsy5TC/aKD6Eg==} + engines: {node: '>=11.0.0'} + '@aw-web-design/x-default-browser@1.4.126': resolution: {integrity: sha512-Xk1sIhyNC/esHGGVjL/niHLowM0csl/kFO5uawBy4IrWwy0o1G8LGt3jP6nmWGz+USxeeqbihAmp/oVZju6wug==} hasBin: true @@ -11823,6 +11911,10 @@ packages: resolution: {integrity: sha512-1j6kQFb7QRru7eKN3ZDvRcP13rugwdxZqCjbiAVZfIJwgj2A65UmT4TgARXGlXgnRkORLTDTrO19ZErt7+QXgA==} engines: {node: ^14.21.3 || >=16} + '@noble/hashes@1.6.1': + resolution: {integrity: sha512-pq5D8h10hHBjyqX+cfBm0i8JUXJ0UhczFc4r74zbuT9XgewFo2E3J1cOaGtdZynILNmQ685YWGzGE1Zv6io50w==} + engines: {node: ^14.21.3 || >=16} + '@noble/secp256k1@1.7.1': resolution: {integrity: sha512-hOUk6AyBFmqVrv7k5WAw/LpszxVbj9gGN4JRkIX52fdFAj1UA61KXmZDvqVEm+pOyec3+fIeZB02LYa/pWOArw==} @@ -16589,6 +16681,22 @@ packages: webpack-dev-server: optional: true + '@wry/caches@1.0.1': + resolution: {integrity: sha512-bXuaUNLVVkD20wcGBWRyo7j9N3TxePEWFZj2Y+r9OoUzfqmavM84+mFykRicNsBqatba5JLay1t48wxaXaWnlA==} + engines: {node: '>=8'} + + '@wry/context@0.7.4': + resolution: {integrity: sha512-jmT7Sb4ZQWI5iyu3lobQxICu2nC/vbUhP0vIdd6tHC9PTfenmRmuIFqktc6GH9cgi+ZHnsLWPvfSvc4DrYmKiQ==} + engines: {node: '>=8'} + + '@wry/equality@0.5.7': + resolution: {integrity: sha512-BRFORjsTuQv5gxcXsuDXx6oGRhuVsEGwZy6LOzRRfgu+eSfxbhUQ9L9YtSEIuIjY/o7g3iWFjrc5eSY1GXP2Dw==} + engines: {node: '>=8'} + + '@wry/trie@0.5.0': + resolution: {integrity: sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA==} + engines: {node: '>=8'} + '@xmldom/xmldom@0.7.13': resolution: {integrity: sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==} engines: {node: '>=10.0.0'} @@ -17171,6 +17279,9 @@ packages: axios@0.24.0: resolution: {integrity: sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==} + axios@1.7.4: + resolution: {integrity: sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==} + axios@1.7.7: resolution: {integrity: sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==} @@ -23173,6 +23284,10 @@ packages: jws@3.2.2: resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + jwt-decode@4.0.0: + resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} + engines: {node: '>=18'} + keccak@3.0.2: resolution: {integrity: sha512-PyKKjkH53wDMLGrvmRGSNWgmSxZOUqbnXwKL9tmgbFYA1iAYqW21kfR7mZXV0MlESiefxQQE9X9fTa3X+2MPDQ==} engines: {node: '>=10.0.0'} @@ -24397,10 +24512,6 @@ packages: resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} engines: {node: '>=8'} - minipass@7.0.4: - resolution: {integrity: sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==} - engines: {node: '>=16 || 14 >=14.17'} - minipass@7.1.2: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} @@ -25008,6 +25119,9 @@ packages: resolution: {integrity: sha512-aiSt/4ubOTyb1N5C2ZbGrBvaJOXIZhZvpRPYuUVxQJe27wJZqf/o65iPrqgLcgfeOLaQ8cS2Q+762jrYvniTrA==} engines: {node: '>18.0.0'} + optimism@0.18.1: + resolution: {integrity: sha512-mLXNwWPa9dgFyDqkNi54sjDyNJ9/fTI6WGBLgnXku1vdKY/jovHfZT5r+aiVeFFLOz+foPNOm5YJ4mqgld2GBQ==} + optionator@0.8.3: resolution: {integrity: sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==} engines: {node: '>= 0.8.0'} @@ -25245,10 +25359,6 @@ packages: resolution: {integrity: sha512-QLcPegTHF11axjfojBIoDygmS2E3Lf+8+jI6wOVmNVenrKSo3mFdSGiIgdSHenczw3wPtlVMQaFVwGmM7BJdtg==} engines: {node: '>=0.10.0'} - path-scurry@1.10.2: - resolution: {integrity: sha512-7xTavNy5RQXnsjANvVvMkEjvloOinkAjv/Z6Ildz9v2RinZ4SBKTWFOVRbaF8p0vpHnyjV/UwNDdKuUv6M5qcA==} - engines: {node: '>=16 || 14 >=14.17'} - path-scurry@1.11.1: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} @@ -25447,6 +25557,9 @@ packages: resolution: {integrity: sha512-on2ZJVVDXRADWE6jnQaX0ioEylzgBpQk8r55NE4wjXW1ZxO+BgDlY6DXwj20i0V8eB4SenDQ00WEaxfiIQPcxg==} engines: {node: '>= 0.12.0'} + poseidon-lite@0.2.1: + resolution: {integrity: sha512-xIr+G6HeYfOhCuswdqcFpSX47SPhm0EpisWJ6h7fHlWwaVIvH3dLnejpatrtw6Xc6HaLrpq05y7VRfvDmDGIog==} + possible-typed-array-names@1.0.0: resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==} engines: {node: '>= 0.4'} @@ -27084,6 +27197,17 @@ packages: resolution: {integrity: sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==} hasBin: true + rehackt@0.1.0: + resolution: {integrity: sha512-7kRDOuLHB87D/JESKxQoRwv4DzbIdwkAGQ7p6QKGdVlY1IZheUnVhlk/4UZlNUVxdAXpyxikE3URsG067ybVzw==} + peerDependencies: + '@types/react': '*' + react: '*' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + relateurl@0.2.7: resolution: {integrity: sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==} engines: {node: '>= 0.10'} @@ -27250,6 +27374,10 @@ packages: resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} hasBin: true + response-iterator@0.2.11: + resolution: {integrity: sha512-5tdhcAeGMSyM0/FoxAYjoOxQZ2tRR2H/S/t6kGRXu6iiWcGY5UnZgkVANbTwBVUSGqWu0ADctmoi6lOCIF8uKQ==} + engines: {node: '>=0.8'} + responselike@2.0.1: resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} @@ -28429,6 +28557,10 @@ packages: resolution: {integrity: sha512-sQV7phh2WCYAn81oAkakC5qjq2Ml0g8ozqz03wOGnx9dDlG1de6yrF+0RAzSJD8fPUow3PTSMf2SAbOGxb93BA==} engines: {node: '>=0.10'} + symbol-observable@4.0.0: + resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} + engines: {node: '>=0.10'} + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -28818,6 +28950,10 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + ts-invariant@0.10.3: + resolution: {integrity: sha512-uivwYcQaxAucv1CzRp2n/QdYPo4ILf9VXgH19zEIjFx2EJufV16P0JtJVpYHy89DItG6Kwj2oIUjrcK5au+4tQ==} + engines: {node: '>=8'} + ts-jest@28.0.8: resolution: {integrity: sha512-5FaG0lXmRPzApix8oFG8RKjAz4ehtm8yMKOTy5HX3fY6W8kmvOrmcY0hKDElW52FJov+clhUbrKAqofnj4mXTg==} engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} @@ -30429,6 +30565,12 @@ packages: resolution: {integrity: sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==} engines: {node: '>=12.20'} + zen-observable-ts@1.2.5: + resolution: {integrity: sha512-QZWQekv6iB72Naeake9hS1KxHlotfRpe+WGNbNx5/ta+R3DNjVO2bswf63gXlWDcs+EMd7XY8HfVQyP1X6T4Zg==} + + zen-observable@0.8.15: + resolution: {integrity: sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==} + zip-stream@5.0.2: resolution: {integrity: sha512-LfOdrUvPB8ZoXtvOBz6DlNClfvi//b5d56mSWyJi7XbH/HfhOHfUhOqxhT/rUiR7yiktlunqRo+jY6y/cWC/5g==} engines: {node: '>= 12.0.0'} @@ -30538,6 +30680,52 @@ snapshots: jsonpointer: 5.0.1 leven: 3.1.0 + '@apollo/client@3.12.4(@types/react@18.2.73)(graphql@16.8.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@graphql-typed-document-node/core': 3.2.0(graphql@16.8.1) + '@wry/caches': 1.0.1 + '@wry/equality': 0.5.7 + '@wry/trie': 0.5.0 + graphql: 16.8.1 + graphql-tag: 2.12.6(graphql@16.8.1) + hoist-non-react-statics: 3.3.2 + optimism: 0.18.1 + prop-types: 15.8.1 + rehackt: 0.1.0(@types/react@18.2.73)(react@18.3.1) + response-iterator: 0.2.11 + symbol-observable: 4.0.0 + ts-invariant: 0.10.3 + tslib: 2.6.2 + zen-observable-ts: 1.2.5 + optionalDependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + + '@aptos-labs/aptos-cli@1.0.2': + dependencies: + commander: 12.1.0 + + '@aptos-labs/aptos-client@0.1.1': + dependencies: + axios: 1.7.4 + got: 11.8.6 + + '@aptos-labs/ts-sdk@1.33.1': + dependencies: + '@aptos-labs/aptos-cli': 1.0.2 + '@aptos-labs/aptos-client': 0.1.1 + '@noble/curves': 1.6.0 + '@noble/hashes': 1.6.1 + '@scure/bip32': 1.4.0 + '@scure/bip39': 1.3.0 + eventemitter3: 5.0.1 + form-data: 4.0.0 + js-base64: 3.7.7 + jwt-decode: 4.0.0 + poseidon-lite: 0.2.1 + '@aw-web-design/x-default-browser@1.4.126': dependencies: default-browser-id: 3.0.0 @@ -31777,7 +31965,7 @@ snapshots: '@babel/core': 7.24.3 '@babel/helper-compilation-targets': 7.23.6 '@babel/helper-plugin-utils': 7.25.9 - debug: 4.3.7 + debug: 4.3.4 lodash.debounce: 4.0.8 resolve: 1.22.8 transitivePeerDependencies: @@ -31788,7 +31976,7 @@ snapshots: '@babel/core': 7.24.3 '@babel/helper-compilation-targets': 7.25.9 '@babel/helper-plugin-utils': 7.25.9 - debug: 4.3.7 + debug: 4.3.4 lodash.debounce: 4.0.8 resolve: 1.22.8 transitivePeerDependencies: @@ -32852,7 +33040,7 @@ snapshots: dependencies: '@babel/core': 7.24.3 '@babel/helper-plugin-utils': 7.24.0 - '@babel/types': 7.24.0 + '@babel/types': 7.26.0 esutils: 2.0.3 '@babel/preset-react@7.24.1(@babel/core@7.24.3)': @@ -32947,7 +33135,7 @@ snapshots: '@babel/parser': 7.26.2 '@babel/template': 7.25.9 '@babel/types': 7.26.0 - debug: 4.3.7 + debug: 4.3.4 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -33103,7 +33291,7 @@ snapshots: '@ethereumjs/rlp': 5.0.2 '@ethereumjs/util': 8.0.5 '@noble/curves': 1.6.0 - '@noble/hashes': 1.5.0 + '@noble/hashes': 1.6.1 '@types/debug': 4.1.12 bignumber.js: 9.1.2 debug: 4.3.4 @@ -33546,7 +33734,7 @@ snapshots: '@confio/ics23@0.6.8': dependencies: - '@noble/hashes': 1.5.0 + '@noble/hashes': 1.6.1 protobufjs: 6.11.4 '@cosmjs/amino@0.26.8': @@ -33583,7 +33771,7 @@ snapshots: '@cosmjs/encoding': 0.26.8 '@cosmjs/math': 0.26.8 '@cosmjs/utils': 0.26.8 - '@noble/hashes': 1.5.0 + '@noble/hashes': 1.6.1 bn.js: 5.2.1 elliptic: 6.5.5 libsodium-wrappers: 0.7.13 @@ -35036,7 +35224,7 @@ snapshots: '@expo/env@0.3.0': dependencies: chalk: 4.1.2 - debug: 4.3.7 + debug: 4.3.4 dotenv: 16.4.5 dotenv-expand: 11.0.6 getenv: 1.0.0 @@ -35047,7 +35235,7 @@ snapshots: dependencies: '@expo/spawn-async': 1.7.2 chalk: 4.1.2 - debug: 4.3.7 + debug: 4.3.4 find-up: 5.0.0 minimatch: 3.1.2 p-limit: 3.1.0 @@ -35203,7 +35391,7 @@ snapshots: '@expo/image-utils': 0.5.1 '@expo/json-file': 8.3.0 '@react-native/normalize-colors': 0.74.85 - debug: 4.3.7 + debug: 4.3.4 fs-extra: 9.1.0 resolve-from: 5.0.0 semver: 7.6.3 @@ -35220,7 +35408,7 @@ snapshots: '@expo/image-utils': 0.5.1 '@expo/json-file': 8.3.0 '@react-native/normalize-colors': 0.74.85 - debug: 4.3.7 + debug: 4.3.4 expo-modules-autolinking: 1.11.2(expo-modules-core@1.12.26(react-native@0.75.4(@babel/core@7.24.3)(@types/react@18.2.73)(react@18.3.1)(typescript@5.4.3))(react@18.3.1))(react-native@0.75.4(@babel/core@7.24.3)(@types/react@18.2.73)(react@18.3.1)(typescript@5.4.3))(react@18.3.1) fs-extra: 9.1.0 resolve-from: 5.0.0 @@ -35704,6 +35892,10 @@ snapshots: dependencies: graphql: 15.8.0 + '@graphql-typed-document-node/core@3.2.0(graphql@16.8.1)': + dependencies: + graphql: 16.8.1 + '@grpc/grpc-js@1.6.7': dependencies: '@grpc/proto-loader': 0.6.13 @@ -36791,7 +36983,7 @@ snapshots: '@kwsites/file-exists@1.1.1': dependencies: - debug: 4.3.7 + debug: 4.3.4 transitivePeerDependencies: - supports-color @@ -37288,6 +37480,8 @@ snapshots: '@noble/hashes@1.5.0': {} + '@noble/hashes@1.6.1': {} + '@noble/secp256k1@1.7.1': {} '@node-ipc/js-queue@2.0.3': @@ -37308,7 +37502,7 @@ snapshots: '@npmcli/fs@3.1.1': dependencies: - semver: 7.6.3 + semver: 7.5.4 '@octokit/auth-app@6.1.1': dependencies: @@ -37853,7 +38047,7 @@ snapshots: '@polkadot-api/substrate-bindings@0.0.1': dependencies: - '@noble/hashes': 1.5.0 + '@noble/hashes': 1.6.1 '@polkadot-api/utils': 0.0.1 '@scure/base': 1.1.6 scale-ts: 1.6.0 @@ -39306,7 +39500,7 @@ snapshots: '@react-native/codegen@0.74.87': dependencies: - '@babel/parser': 7.24.1 + '@babel/parser': 7.26.2 glob: 7.2.3 hermes-parser: 0.19.1 invariant: 2.2.4 @@ -39318,7 +39512,7 @@ snapshots: '@react-native/codegen@0.74.87(@babel/preset-env@7.24.3(@babel/core@7.24.3))': dependencies: - '@babel/parser': 7.24.1 + '@babel/parser': 7.26.2 '@babel/preset-env': 7.24.3(@babel/core@7.24.3) glob: 7.2.3 hermes-parser: 0.19.1 @@ -41105,7 +41299,7 @@ snapshots: '@stacks/transactions@4.3.8': dependencies: - '@noble/hashes': 1.5.0 + '@noble/hashes': 1.6.1 '@noble/secp256k1': 1.7.1 '@stacks/common': 4.3.5 '@stacks/network': 4.3.5 @@ -42337,7 +42531,7 @@ snapshots: dependencies: '@babel/core': 7.24.3 '@babel/preset-env': 7.24.3(@babel/core@7.24.3) - '@babel/types': 7.24.0 + '@babel/types': 7.26.0 '@ndelangen/get-tarball': 3.0.9 '@storybook/codemod': 7.6.20 '@storybook/core-common': 7.6.20 @@ -42647,7 +42841,7 @@ snapshots: dependencies: '@babel/generator': 7.26.2 '@babel/parser': 7.26.2 - '@babel/traverse': 7.24.1 + '@babel/traverse': 7.25.9 '@babel/types': 7.26.0 '@storybook/csf': 0.1.3 '@storybook/types': 7.6.20 @@ -43392,7 +43586,7 @@ snapshots: '@svgr/hast-util-to-babel-ast@5.5.0': dependencies: - '@babel/types': 7.24.0 + '@babel/types': 7.26.0 '@svgr/plugin-jsx@5.5.0': dependencies: @@ -45169,7 +45363,7 @@ snapshots: '@vue/compiler-sfc@3.4.21': dependencies: - '@babel/parser': 7.24.1 + '@babel/parser': 7.26.2 '@vue/compiler-core': 3.4.21 '@vue/compiler-dom': 3.4.21 '@vue/compiler-ssr': 3.4.21 @@ -45350,13 +45544,29 @@ snapshots: optionalDependencies: webpack-dev-server: 4.15.2(webpack-cli@4.10.0)(webpack@5.94.0) + '@wry/caches@1.0.1': + dependencies: + tslib: 2.6.2 + + '@wry/context@0.7.4': + dependencies: + tslib: 2.6.2 + + '@wry/equality@0.5.7': + dependencies: + tslib: 2.6.2 + + '@wry/trie@0.5.0': + dependencies: + tslib: 2.6.2 + '@xmldom/xmldom@0.7.13': {} '@xmldom/xmldom@0.8.10': {} '@xrplf/isomorphic@1.0.0': dependencies: - '@noble/hashes': 1.5.0 + '@noble/hashes': 1.6.1 eventemitter3: 5.0.1 ws: 8.17.1 transitivePeerDependencies: @@ -46102,6 +46312,12 @@ snapshots: dependencies: follow-redirects: 1.15.6 + axios@1.7.4: + dependencies: + follow-redirects: 1.15.6 + form-data: 4.0.0 + proxy-from-env: 1.1.0 + axios@1.7.7: dependencies: follow-redirects: 1.15.6 @@ -47109,7 +47325,7 @@ snapshots: c32check@2.0.0: dependencies: - '@noble/hashes': 1.5.0 + '@noble/hashes': 1.6.1 base-x: 4.0.0 cac@6.7.14: {} @@ -47226,7 +47442,7 @@ snapshots: '@ethersproject/constants': 5.7.0 '@noble/curves': 1.6.0 '@noble/ed25519': 1.7.3 - '@noble/hashes': 1.5.0 + '@noble/hashes': 1.6.1 '@noble/secp256k1': 1.7.1 '@open-rpc/client-js': 1.8.1 '@scure/bip32': 1.4.0 @@ -48900,10 +49116,10 @@ snapshots: documentation@14.0.2: dependencies: '@babel/core': 7.24.3 - '@babel/generator': 7.24.1 - '@babel/parser': 7.24.1 - '@babel/traverse': 7.24.1 - '@babel/types': 7.24.0 + '@babel/generator': 7.26.2 + '@babel/parser': 7.26.2 + '@babel/traverse': 7.25.9 + '@babel/types': 7.26.0 chalk: 5.3.0 chokidar: 3.6.0 diff: 5.2.0 @@ -51528,7 +51744,7 @@ snapshots: fs.realpath: 1.0.0 minimatch: 8.0.4 minipass: 4.2.8 - path-scurry: 1.10.2 + path-scurry: 1.11.1 global-agent@3.0.0: dependencies: @@ -51655,6 +51871,11 @@ snapshots: graphql: 15.8.0 tslib: 2.6.2 + graphql-tag@2.12.6(graphql@16.8.1): + dependencies: + graphql: 16.8.1 + tslib: 2.6.2 + graphql@15.8.0: {} graphql@16.8.1: {} @@ -52030,7 +52251,7 @@ snapshots: dependencies: '@tootallnate/once': 1.1.2 agent-base: 6.0.2 - debug: 4.3.7 + debug: 4.3.4 transitivePeerDependencies: - supports-color @@ -54392,7 +54613,7 @@ snapshots: '@babel/core': 7.24.3 '@babel/generator': 7.26.2 '@babel/plugin-syntax-typescript': 7.24.1(@babel/core@7.24.3) - '@babel/traverse': 7.24.1 + '@babel/traverse': 7.25.9 '@babel/types': 7.26.0 '@jest/expect-utils': 28.1.3 '@jest/transform': 28.1.3 @@ -54925,7 +55146,7 @@ snapshots: jsdoc@4.0.3: dependencies: - '@babel/parser': 7.24.1 + '@babel/parser': 7.26.2 '@jsdoc/salty': 0.2.8 '@types/markdown-it': 14.1.1 bluebird: 3.7.2 @@ -55214,6 +55435,8 @@ snapshots: jwa: 1.4.1 safe-buffer: 5.2.1 + jwt-decode@4.0.0: {} + keccak@3.0.2: dependencies: node-addon-api: 2.0.2 @@ -55325,7 +55548,7 @@ snapshots: koa-route@3.2.0: dependencies: - debug: 4.3.7 + debug: 4.3.4 methods: 1.1.2 path-to-regexp: 1.8.0 transitivePeerDependencies: @@ -55333,7 +55556,7 @@ snapshots: koa-send@5.0.1: dependencies: - debug: 4.3.7 + debug: 4.3.4 http-errors: 1.8.1 resolve-path: 1.4.0 transitivePeerDependencies: @@ -55353,7 +55576,7 @@ snapshots: content-disposition: 0.5.4 content-type: 1.0.5 cookies: 0.9.1 - debug: 4.3.7 + debug: 4.3.4 delegates: 1.0.0 depd: 2.0.0 destroy: 1.2.0 @@ -55376,8 +55599,8 @@ snapshots: konan@2.1.1: dependencies: - '@babel/parser': 7.24.1 - '@babel/traverse': 7.24.1 + '@babel/parser': 7.26.2 + '@babel/traverse': 7.25.9 transitivePeerDependencies: - supports-color @@ -56482,9 +56705,9 @@ snapshots: metro-transform-plugins@0.80.12: dependencies: '@babel/core': 7.24.3 - '@babel/generator': 7.24.1 + '@babel/generator': 7.26.2 '@babel/template': 7.24.0 - '@babel/traverse': 7.24.1 + '@babel/traverse': 7.25.9 flow-enums-runtime: 0.0.6 nullthrows: 1.1.1 transitivePeerDependencies: @@ -56998,8 +57221,6 @@ snapshots: minipass@5.0.0: {} - minipass@7.0.4: {} - minipass@7.1.2: {} minizlib@1.3.3: @@ -57465,7 +57686,7 @@ snapshots: dependencies: hosted-git-info: 4.1.0 is-core-module: 2.13.1 - semver: 7.6.3 + semver: 7.5.4 validate-npm-package-license: 3.0.4 normalize-path@3.0.0: {} @@ -57713,6 +57934,13 @@ snapshots: - supports-color optional: true + optimism@0.18.1: + dependencies: + '@wry/caches': 1.0.1 + '@wry/context': 0.7.4 + '@wry/trie': 0.5.0 + tslib: 2.6.2 + optionator@0.8.3: dependencies: deep-is: 0.1.4 @@ -57960,11 +58188,6 @@ snapshots: dependencies: path-root-regex: 0.1.2 - path-scurry@1.10.2: - dependencies: - lru-cache: 10.2.0 - minipass: 7.0.4 - path-scurry@1.11.1: dependencies: lru-cache: 10.2.0 @@ -58169,6 +58392,8 @@ snapshots: transitivePeerDependencies: - supports-color + poseidon-lite@0.2.1: {} + possible-typed-array-names@1.0.0: {} postcss-attribute-case-insensitive@5.0.2(postcss@8.4.38): @@ -59171,7 +59396,7 @@ snapshots: react-docgen@7.0.3: dependencies: '@babel/core': 7.24.3 - '@babel/traverse': 7.24.1 + '@babel/traverse': 7.25.9 '@babel/types': 7.26.0 '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.20.5 @@ -60372,6 +60597,11 @@ snapshots: dependencies: jsesc: 0.5.0 + rehackt@0.1.0(@types/react@18.2.73)(react@18.3.1): + optionalDependencies: + '@types/react': 18.2.73 + react: 18.3.1 + relateurl@0.2.7: {} release-zalgo@1.0.0: @@ -60590,6 +60820,8 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + response-iterator@0.2.11: {} + responselike@2.0.1: dependencies: lowercase-keys: 2.0.0 @@ -62116,6 +62348,8 @@ snapshots: symbol-observable@2.0.3: {} + symbol-observable@4.0.0: {} + symbol-tree@3.2.4: {} symbol.inspect@1.0.1: {} @@ -62639,6 +62873,10 @@ snapshots: ts-interface-checker@0.1.13: {} + ts-invariant@0.10.3: + dependencies: + tslib: 2.6.2 + ts-jest@28.0.8(jest@28.1.3(@types/node@18.19.26))(typescript@5.6.3): dependencies: bs-logger: 0.2.6 @@ -62837,6 +63075,20 @@ snapshots: typescript: 5.6.3 yargs-parser: 21.1.1 + ts-jest@29.2.5(jest@29.7.0(@types/node@20.12.12)(ts-node@10.9.2(@types/node@20.12.12)(source-map-support@0.5.21)(typescript@5.4.3)))(typescript@5.4.3): + dependencies: + bs-logger: 0.2.6 + ejs: 3.1.10 + fast-json-stable-stringify: 2.1.0 + jest: 29.7.0(@types/node@20.12.12)(ts-node@10.9.2(@types/node@20.12.12)(source-map-support@0.5.21)(typescript@5.4.3)) + jest-util: 29.7.0 + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.6.3 + typescript: 5.4.3 + yargs-parser: 21.1.1 + ts-jest@29.2.5(jest@29.7.0(@types/node@20.12.12)(ts-node@10.9.2(@types/node@20.12.12)(typescript@5.4.3)))(typescript@5.4.3): dependencies: bs-logger: 0.2.6 @@ -65138,6 +65390,12 @@ snapshots: yocto-queue@1.0.0: {} + zen-observable-ts@1.2.5: + dependencies: + zen-observable: 0.8.15 + + zen-observable@0.8.15: {} + zip-stream@5.0.2: dependencies: archiver-utils: 4.0.1 diff --git a/tools/actions/turbo-affected/build/main.js b/tools/actions/turbo-affected/build/main.js index 153f1527a2e6..a25a9c52688c 100644 --- a/tools/actions/turbo-affected/build/main.js +++ b/tools/actions/turbo-affected/build/main.js @@ -18990,6 +18990,7 @@ var package_default = { "ljs:devices": "pnpm --filter devices", "ljs:errors": "pnpm --filter errors", "ljs:hw-app-algorand": "pnpm --filter hw-app-algorand", + "ljs:hw-app-aptos": "pnpm --filter hw-app-aptos", "ljs:hw-app-btc": "pnpm --filter hw-app-btc", "ljs:hw-app-cosmos": "pnpm --filter hw-app-cosmos", "ljs:hw-app-eth": "pnpm --filter hw-app-eth",